This is article two of a series of three. The first is here and the third is here.
Last time I explained why I think SMART Health Cards are awesome. This time I’m just going to dig into how they work. To recap: a SMART Health Card (I’ll just call it a SHC from now on) is a chunk of data that:
- Contains some health information
(e.g., information about vaccines administered to an individual); - Is provably vouched for by some known issuer
(e.g., WA DOH confirms that the individual actually received the vaccines); - Can be shared in many ways
(e.g., through a printed QR Code or in a mobile wallet); and - May be invalidated/retracted if the issuer no longer believes the claim.
The best way to think about security and privacy for a SHC is to imagine it as a physical piece of paper containing the same information (which, sometimes, it is!):
- An issuer gives a SHC to humans they believe should have it. (E.g., WAVerify will give you a COVID vaccine SHC if you prove you have access to the email address or mobile phone that was recorded when you physically received the vaccine.)
- It’s up to the holder to “protect” a SHC by not losing it or allowing it to be stolen. (E.g., by keeping a printed version safely in your wallet or behind a PIN on your mobile phone). SHCs are not encrypted!
- The holder can share the SHC with whomever they like. (E.g., by allowing the bouncer at your favorite dive to scan the printout).
A really important implication of this approach is that a SHC is not a proof of identity. If I show you a SHC with Sean’s vaccine information, it doesn’t mean that I am Sean — it just means that I have access to this information about Sean (maybe legitimately, maybe not). Additional work is required to prove that the person with the SHC is the same person that is in the SHC. Typically this is done by looking at an actual ID (e.g., a drivers license) and verifying that the name/birthdate/etc. match what’s in the SHC.
OK, that’s basically what we’re working with here. Now let’s see how they’re put actually together and used.
Health Information
A SHC can contain any health information that can be represented in JSON using FHIR resources, which in practice means anything. For example, here’s one of my vaccine records as a FHIR “Immunization” resource:
{
"resourceType": "Immunization",
"status": "completed",
"vaccineCode": {
"coding": [ {
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
} ]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "2021-03-25",
"lotNumber": "REDACTED"
}
Most of this is pretty self-explanatory (which is one of the advantages of FHIR over other formats). Code “208” in the CVX system represents the original Pfizer-BioNTech vaccine, and I’ve redacted out the actual lot number because, well, I don’t really know why I care about you seeing that but whatever.
The patient “reference” element is interesting — what does “resource:0
” mean? Because information in a SHC is usually composed of not just one but a few related resources, my actual COVID SHC also includes a “Patient
” resource that contains my demographic information. Referencing that resource links the two together to form a complete record. Related resources are packaged together into a collection called a “Bundle
” (another FHIR type). Here’s a complete COVID bundle (cut to include just my first vaccination):
{
"resourceType": "Bundle",
"type": "collection",
"entry": [
{
"fullUrl": "resource:0",
"resource": {
"resourceType": "Patient",
"name": [
{
"family": "NOLAN",
"given": [ "SEAN", "P." ]
}
],
"birthDate": "REDACTED"
}
},{
"fullUrl": "resource:1",
"resource": {
"resourceType": "Immunization",
"status": "completed",
"vaccineCode": {
"coding": [ {
"system": "http://hl7.org/fhir/sid/cvx",
"code": "208"
} ]
},
"patient": {
"reference": "resource:0"
},
"occurrenceDateTime": "2021-03-25",
"lotNumber": "REDACTED"
}
}
]
}
The “entry
” array in the bundle is just an array of resources, each one keyed with a “fullUrl
” value that can be used as a reference. Typically fullUrl
values are actual unique identifiers (often REST-style URLs), but the SHC specification prescribes the use of “resource:#
” values instead. Primarily this is to help minimize resource size, but it’s also a nice reminder that the resources in a SHC should stand alone without dependencies outside of its bundle.
Actually, to be really precise, the specification only requires “resource:#
” identifiers when the SHC is destined to be shared using a QR code. We’ll talk a lot more about this later, but QR codes are quite limited in the amount of data they can reasonably contain. One way the specification tries to keep the size down is with a set of minimization steps which result in still-valid but terse resources.
Obviously there’s a lot more to FHIR and we could keep going for a long time — but I think that’s enough for our purposes. The important takeaway is just that the actual goodies in the magic inner core of a SHC are just FHIR resources that can represent any useful health-related information.
Signatures and Verifiable Credentials
One problem with a paper vaccine card is that it’s trivial to forge — and the same goes for a random digital bundle of FHIR resources. Now this may not always be the end of the world (smart folks like Bruce Schneier have argued that it can actually be a “feature”), often it’s a real problem. One of the coolest things about SHCs is that they require a very small incremental investment vs. paper to be made strongly “verifiable” in a way that doesn’t require sensitive centralized databases or other privacy-threatening technology.
Like so many other things in our digital world, SHCs accomplish this using public key cryptography.
First, an issuer (e.g., WANotify) creates a pair of related “keys.” One is a “private” key that the organization keeps to itself as an internal secret. The other is a “public” key that is published openly for anyone to see at a well-known URL (e.g., for Washington it’s at https://waverify.doh.wa.gov/creds/.well-known/jwks.json). Keys must include some specific properties and be formatted as a JSON Web Key Set; here’s one way to get the job done (full source on github):
const newKeys = async () => {
const keystore = jose.JWK.createKeyStore();
const props = { "use": "sig", "alg": "ES256" };
const key = await keystore.generate("EC", "P-256", props);
const publicJSON = { "keys": [ key.toJSON() ] };
console.log(">>> PUBLIC\n" + JSON.stringify(publicJSON, null, 2));
const privateJSON = { "keys": [ key.toJSON(true) ] };
console.log(">>> PRIVATE\n" + JSON.stringify(privateJSON, null, 2));
}
Next, the FHIR bundle needs to be wrapped up in a structure that’s almost but not quite a W3C Verifiable Credential (a fully “legit” VC can be directly derived from the SHC format; where they differ it’s basically another attempt to reduce data size for the QR representation). In practice that means using JSON like this:
{
"iss": ISS_URL,
"nbf": NOT_BEFORE_EPOCH_SECONDS,
"vc": {
"type": [ "https://smarthealth.cards#health-card" ],
"credentialSubject": {
"fhirVersion": "4.0.1",
"fhirBundle": BUNDLE_RESOURCE
}
}
}
ISS_URL
tells readers where to find the public key that will ultimately verify that the issuer signed the bundle. Note this is a prefix of the JWKS URL we saw above (e.g., for Washington the ISS value is https://waverify.doh.wa.gov/creds). “.well-known/jwks.json” is part of the specification to be appended by the reader. Also, the ISS value cannot have a trailing slash — it is regularly used in exact string comparisons, so the protocol can’t afford to be as sloppy as we usually are about that.
NOT_BEFORE_EPOCH_SECONDS
is a timestamp that defines when the VC should be considered valid, in epoch seconds. Most typically this is just the time when it was created (e.g., in Javascript, new Date() / 1000
). The rest is pretty much boilerplate — of course the fhirVersion
value must match the version to which your fhirBundle
conforms.
Finally, we can sign this bad boy using a JSON Web Signature. JWS (or if you’re picky, JWS using compact serialization) is one of the two flavors of JSON Web Token — a format that makes it easy to move signed and/or encrypted data around the web. The issuer first compresses the data with DEFLATE (that size issue again!), then uses its private key to sign it, using code something like this:
const signCard = async (cardPath, keyStorePath) => {
const cardJSON = JSON.parse(fs.readFileSync(cardPath));
const compressedCard = zlib.deflateRawSync(JSON.stringify(cardJSON));
const keystoreJSON = JSON.parse(fs.readFileSync(keyStorePath));
const keystore = await jose.JWK.asKeyStore(keystoreJSON);
const signingKey = keystore.all()[0];
const options = { format: 'compact', fields: { zip: 'DEF' } };
const signer = jose.JWS.createSign(options, signingKey);
const jws = await signer.update(Buffer.from(compressedCard)).final();
console.log(jws);
}
*** Don’t miss the deflateRawSync
call above! “raw” means that the result won’t include a zip header. This can trip up implementations that expect a header by default. One such example is the Java Inflater
class; passing true
to it’s constructor will do the trick.
And here’s our resulting JWS (in SHC conversations often referred to as the VC):
eyJ6aXAiOiJERUYiLCJhbGciOiJFUzI1NiIsImtpZCI6IjhkdzRfM04tR2hIX1dzSlZtSzBtT19KT1Z0cHRwZFdwblFETmRpekhsTjQifQ.fZFNT8MwDIb_i7l27TrYQL0B2wEJDcQGF7RDmrprUD6qfFQbqP8dpysIJERuju3H72t_gHAOCmi8b12RZXhgqpWYcqMybrFykIAuayjyxdX8cj47zxcJdByKD_DHFqF4_W51ilnfIJO-STmzlTs7BZMYwC6ByEPtBZObUL4h95FSN8K-oHXCaJJxkU7TnEbG35ugK4mxxqIzwXLcDhNhTCSjAuBGSqJFQgI0wB5JFpGDlM9WUsFXfzGlgq_gD_Aj84L6o2Wm8ARhSkjiwfrh_npNmb3oUEfbm9UQP6bRmgt1LQ7xG3Y9xaWgVSyZj9Sn1fL6drtaQt8nf6rK_1d1p1TQ4p2NBp1nPrjBdryUx4o-O8a50HhrqoHATSX0fjDgjs6jGi9MV2rkZWrsPosbzpyoMt4dCMCHTphNr6Df9Qm04yoGOTVa1FHbz01SkeE82CEVrW6FOiFm-WR6PpnNCSuNXwdVov29h11P7xM.eJ9No_vOioiU1lXEYn2hJefhRCRAQJCZY1DpYkmgDfVhR0hj7kwOdeg7NguApiu8377h09bDfEbvjcN38-1P8g
That big garbled mess is actually hiding three different base64url-encoded sections, each separated by dots (feel free to skip the Where’s Waldo game on this): a header, the payload and the signature itself. Decoding the header is a useful exercise:
{
"zip":"DEF",
"alg":"ES256",
"kid":"8dw4_3N-GhH_WsJVmK0mO_JOVtptpdWpnQDNdizHlN4"
}
First is the “zip” field that we added ourselves — this is actually a non-standard header for JWS, but totally allowed and a useful hint/reminder that our payload is compressed. More important are the algorithm and key identifier used to sign the payload. So to verify a SHC, the receiver:
- Decodes the three sections with base64url.
- Uses the ISS value in the payload to get the list of valid keys for the issuer.
- Uses the “kid” field in the header to pick the right one from that list.
- Computes the signature and compares it to the one in the JWS.
And then magic happens. We’re really close now — just need to cover a couple of side issues and then look at sharing (including using QR codes). Hang in there!
Trusted Issuers and Directories
Protocols are all well and good, but at some point the rubber has to meet the road. Sure you can trust any issuer that puts a JWKS file up on the web, but should you? Probably not — but how to decide? Typically folks delegate the work to some centralized organization (a trust network or trust registry) that serves as the gatekeeper for inclusion. The trust network publishes a directory of vetted participants at a secure, well-known location that everyone can start from.
Unfortunately, this practical necessity is often the last thing that gets implemented in trust-dependent projects, because it’s so easy to fake up in demos and tests (including mine). And while I wasn’t on the ground in 2021 when folks were putting it together, it sure seems like the COVID-initiated SHC network was no exception. This is the situation as it appears to be today, at least in the USA and other jurisdictions without a strong central network of their own:
- The collection of vetted issuers is known as the CommonTrust Network.
- The Verifiable Clinical Information Coalition (née the Vaccine Credential Initiative, both conveniently abbreviated as VCI) sets the rules for inclusion in the network.
- The Commons Project is responsible for actually vetting issuers against these rules and maintaining technical infrastructure for the network.
- Machine-readable lists of issuers, their ISS values and cached public keys are available in the VCI Directory on GitHub.
One of the nicest things that VCI does is to take a daily “snapshot” of all the JWKS files in the network and aggregate them into a single file here. This means that any application that wants to verify SHCs can just download this one file and use it to find keys, rather than having to download each JWKS on-demand. Since network calls are always slow and unreliable, the fewer the better. Good stuff!
Despite its near-impenetrable web of trademarks, VCI/CommonTrust has so far used a pretty common-sense approach to inclusion. Issuers are required to be one of the following (from their site):
- Clinical health system or hospital providing patient care
- National or regional pharmacy chain
- National or regional laboratory diagnostics provider
- National or regional health insurance payor
- Government or governmental agency
It’s pretty easy to verify whether an applicant does or does not fit into one of these categories; there doesn’t seem to be much controversy about that. What is up for grabs, however, is whether this same trust network should be used for all SHCs. For example, would it be better to have a separate trust network for insurance cards? There are good arguments pro and con; the nerds among us who just want to know what URL to use will just have to wait and see how it plays out!
Revocation
If you were really paying attention back when we talked about the SHC privacy and security model, you might have realized that since people hold SHCs themselves, without any “live” link back to the issuer, the issuer might have trouble retracting the credential if it needs to, for example if a bug caused them to put the wrong plan identifier on an insurance card. “Revocation” is not a common part of the SHC lifecycle (e.g., your insurance card from last year doesn’t need to be revoked — it’s still a valid insurance card, the data just shows that the plan is expired) — but it can happen.
In the case of a major sh*tshow — say somebody hacks into an issuer’s internal systems — public keys can simply be removed from the trust network and all SHCs signed by that issuer will immediately become invalid. But that’s a pretty big hammer, sure to cause a lot of chaos and annoyance throughout the system. More typically, a SHC issuer creates and publishes a card revocation list (CRL) that contains opaque identifiers for individual cards that should not be trusted.
The details are here, but in brief:
- The issuer adds an “rid” field to the signed SHC VC. Because it may be posted publicly, the rid must be a random, opaque value.
- The issuer maintains a CRL containing revoked rids for each of its public keys at “ISS_URL/.well-known/KID.json”, where KID is a key identifier from its JWKS file (that .well-known URL construct is really quite handy).
- Verifiers download the CRL regularly and use it to reject individual cards that show up on the list. “Regularly” isn’t very well-defined, but daily seems to be a generally-accepted frequency.
There are some more details that enable time-based revocation, but I’m calling it good there. TLDR, there’s a pretty good way for issuers to retract a card that was issued in error.
Sharing by File
OK, we’ve got a SHC in JWS/VC format, and we want to move it around — how to do that? We’ll cover the less-commonly-used method first, which is just sticking a SHC into a file. The specification defines an extension “.smart-health-card” and MIME type “application/smart-health-card” for these files.
It’s pretty simple, except for one thing. The file format actually can contain multiple cards — it’s a JSON object with a “verifiableCredential
” array, each entry of which is a JWS/VC string. I can see where they were going with this concept … it might be nice to have a file that wraps up multiple cards from different issuers. But with the appearance of SMART Health Links I’m thinking this capability may be very, very lightly used. Ah well, there’s always a little leftover detritus when you’re figuring out something new.
Presumably as SHCs become more and more popular, sharing them around this way may increase in popularity. For example, it’d be really nice to just tap a link with the right MIME type and have the SHC drop directly into your Google or Apple wallet. Likely will happen, but it we’re just early, so the process using files is a bit clunky.
One nice thing about storing a SHC in a file — unlike with QR codes, there’s no practical size restriction. It turns out that this is a pretty big deal as the use cases expand, and is a key reason that SMART Health Links are playing an increasingly important role. We’ll talk about those next time.
Sharing by QR Code
Finally! The first experience everyone has with SMART Health Cards is the QR Code — but it’s taken more than 2,500 words to get there. With that background, at least it’s pretty easy to understand what’s going on inside. A SHC QR Code simply contains the JWS/VC string we created and signed earlier. It’s not a link to that data, it’s the actual data itself.
Space really is at a premium with QR Codes. It depends on things like the image size and error correction level, but in practice SHC data in JWS/VC form has to be 1,191 bytes or smaller. The original specification had a vehicle for “chunking” larger data into multiple codes (imagine a page with four individual QRs on it, scanned one after another) but for obvious reasons that was quickly deprecated by the community.
So everything about encoding QRs is about squeezing things down. There are a few different “modes” that can be used for QR data; the SMART folks figured out that numeric
was the best option for SHC data. The spec goes into gory detail here, so I’m just going to cheat and just pop in a little code using the qrcode npm package:
const makeQR = async (jws, path) => {
const numericJWS = jws.split('')
.map((c) => c.charCodeAt(0) - 45)
.flatMap((c) => [Math.floor(c / 10), c % 10]) // Need to maintain leading zeros
.join('');
const segments = [
{ data: 'shc:/', mode: 'byte' },
{ data: numericJWS, mode: 'numeric' }
];
await qrcode.toFile(path, segments, {
width: 600,
errorCorrectionLevel: 'L'
});
}
The code generates this PNG (go ahead and scan it; just remember that nobody will trust our made-up issuer). Below the image is a peek at the data inside the code.
shc:/56762909524320603460292437404460312229595326546034602925407728043360287028647167452228092861596255773757320307713705592843045577386345713877217139041230390445035427377445255574536325243964376756625970396136605736015745253339113274232853506010630510396944363103210574242923543626251037236469676940230968572536536835115567767028292437726172062710057150582720342264601153040537454359586867424454683276536925766228207252766176755543115461071077755429552176302350232725682507504208046063655728293400383964776339701021453244742209217624357121453453403107591208253833113000662743225229297562401039652453260608725830076475756828777443315423593359392238612072212638682925365828037421082925672623633212424070254357772663586850582350206111073109660542641120375938626160745769595065673364530666402457536412403635095337645871043136107526064412755242584138764561123865045731095569715236711165570969305004550467043936076705332167046535696121556976407507660034115208032759696866272039384303576123586170096126601232410569624542697035667053776776663271075522322227396759336909235712366403077666263439415204252753770304386256241105222441694209253460256400423709356765332238723343745541667305125904043510753201726129036005526239237508030700631209693732240077406561685860087528420557243912666937063075614576236352312043295710456926742958234137695439414532330729282238543066706340005
… and we made it. Woo hoo! There are actually a few other ways to move SHC data around using FHIR APIs, but I’m going to call it here.
I hope all of this noise proves useful, both to help understand what’s going on under the covers and give you a bit of a head start on actually working with SHCs in code. Next time we’ll dive into SMART Health Links, which address some of the challenges with SHCs and add some neat stuff of their own. I’d love to hear your thoughts or corrections, just hit me up on Twitter or LinkedIn or whatever.
As always … just keep swimming!