Vaccine certificates: building an app to understand how they work
— react-native, vaccination, qr-codes, javascript, fhir, smart-health-it, software — 12 min read
Digital vaccination certificates (sometimes called "vaccine passports") have been in use since the summer of 2021 and are meant to communicate an individual's COVID-19 vaccination status in a standardized way. Vaccine certificate policy is a contentious topic, one that we won't get into here. My opinion, in short, is that vaccine certificates raise serious ethical concerns, and I hope to see an end to their use very soon.
There were a few motivations for this post. First, with the ubiquity of vaccine certificates in daily life, it seems important to understand practical considerations around their use, for example:
- What information is shared in vaccine certificates?
- How are vaccine certificates verified? Is any information sent to the cloud during verification?
- What stops somebody from modifying their certificate or creating one from scratch?
Second, having a background in healthcare data, I was curious to understand how vaccine certificate data is encoded and the technical design behind the QR code.
"What I cannot create, I do not understand" - Richard Feynman
What better way to learn than to build! I built a mobile QR code scanner app with React Native — join me below to learn how it works.
How vaccine certificates work
Vaccine certificates implement the SMART Health Card standard, which was created in August 2020 in response to COVID-19. Though the "SMART Health Card" name sounds generic, only 2 types of clinical data are currently supported: immunization records and laboratory test results.
A SMART Health Card is a FHIR Bundle payload inside a JSON Web Signature:
Overview of decoding process
Here's the step-by-step process from QR code to presentable data, showing example data at each step (examples are drawn from the SMART Health Card spec):
QR Code | |
---|---|
⬇️ | Convert QR image pixels to integer data |
Numerically-encoded JWS | |
⬇️ | Convert integer pairs to characters |
Compact JWS | |
⬇️ | Decode JWS payload using DEFLATE |
JSON JWS | |
⬇️ | Pick vc.credentialSubject.fhirBundle from JSON object |
FHIR bundle |
How the app works
Check out the app source code on GitHub: danielpgross/rn-vax-qr-scanner
The app consists of 3 main parts:
- CameraScreen, the starting point in the app, displays the camera's field of view while continuously scanning for QR codes. When it detects one, it navigates to
ScanResultScreen
, passing in the raw QR data. - ScanResultScreen displays the scan results. On load, it uses
useQRScanResult
to decode the raw QR data. Once decoding is finished, the results are presented in a table layout. - useQRScanResult is a hook containing the decoding logic. It performs the various decoding steps to get from raw QR data to presentable information, handling any errors along the way.
Check out the recording below of the app in action. You can also download the source code (linked above) and run it on your own device!
The QR code
SMART Health Cards are standard V22 QR codes. The app uses react-native-vision-camera, a popular React Native library for interfacing with the device's camera. It uses the vision-camera-code-scanner plugin for that library to automatically decode any QR codes detected in the camera's view. Thanks to these, the QR scanner implementation is very simple, taking only ~60 lines of code in CameraScreen.
The SMART Health Card spec defines an unusual way of encoding its content: as a string of integer pairs, where each pair plus 45 is a Javascript character code. The spec explains the rationale for this in more detail, but in short, it's to fit 20% more data into the QR code.
Based on that, the raw QR data (in the format shc:/1234...
, see above), can be decoded into its JWS content as follows (relevant source):
// rawQRInput is defined already
// Match the set of integer pairs after `shc:/`const numericallyEncodedContent = rawQRInput.substring(5).match(/(..?)/g)
// Convert set of integer pairs into charactersconst jwsContent = numericallyEncodedContent .map(num => String.fromCharCode(parseInt(num, 10) + 45)) .join('')
The JSON Web Signature (JWS)
The decoded QR code content is a JSON Web Signature (JWS), which can be thought of as an arbitrary chunk of data that's been "cryptographically sealed": it's signed on creation, and any further modifications render the signature invalid. JWS is in the same family as the JSON Web Token (JWT), which is used widely across the web in modern authentication schemes.
There are different parameters (cryptography, compression, and data format) used to generate a JWS and the SMART Health Card standard defines what those parameters are. Notably, the payload itself can be read without any crypography; it can be simply decompressed with the DEFLATE algorithm. This means that reading the SMART Health Card's data and verifying its authenticity are two decoupled operations, though we do need the iss
field from the decompressed content.
Decompressing the payload
For decoding the payload, all we need is a way to decompress DEFLATE-encoded data. zlib is the classic open-source implementation of DEFLATE and is widely available. It's built natively into node.js, but not into React Native. I opted to use react-zlib-js, a port of zlib to pure JS that can be used in React Native. This is a simple solution that works across platforms, but trades off performance. A more performant solution would be to use a native mobile zlib implementation like react-native-zlib, but the payload is so small here that I doubt there would be any noticeable difference. react-zlib-js requires node.js' Buffer, which can be easily brought into React Native land with an npm package.
Bringing it all together (relevant source):
import * as zlib from 'react-zlib-js'import { Buffer } from 'buffer'
// jwsContent is defined in the previous step
const [header, payload, signature] = jwsContent.split('.');const decodedPayload = Buffer.from(payload, 'base64');const decompressedCard = zlib.inflateRawSync(decodedPayload);const cardJson = JSON.parse(decompressedCard.toString());
Verification
JWS uses asymmetric crypography, just like web browsers do for HTTPS. The signer uses a private key to generate the JWS, which can then be verified by anyone that has the public key. While the signer's public key can be used by anyone to verify JWSes from that signer, only the private key can be used to sign JWSes. Verification, therefore, is a matter of fetching the signer's public key and then using it in a defined cryptographic algorithm (in this case, the ES256
algorithm) to check the JWS' signature portion against its header and payload portions.
Fortunately, there are quite a few JS libraries for working with JWS and related standards. The challenge here, again, is finding something that works with React Native. Many of the libraries depend on features from the node.js runtime that aren't present in React Native. I started with jose, but after running into React Native incompatibilities ended up using jsrsasign instead. jsrasign is written in pure JS, bringing the same tradeoff as above with zlib: simplicity and cross-platform compatibility at the expense of performance. Again, the performance difference here should be negligible because we're only decoding a single JWS at a time.
As mentioned above, we need the signer's public key to perform the verification. According to the SMART Health Card spec, the iss
(short for "issuer") key in the JWS JSON object specifies a web domain that hosts the signer's public key. As per JWS convention, the key is contained at the static path /.well-known/jwks.json
on that domain. That key can be fetched in a regular HTTP request, for which I used axios. Notably, this is the only network request that we need to make during the entire process.
If the issuer is defined in the JWS itself, what's stopping anyone with a web domain from issuing SMART Health Cards? This is where the VCI Directory comes in, defining a set of trusted issuers. If a SMART Health Card JWS specifies an issuer outside of this set, it's considered "untrusted", even if the JWS is cryptographically valid. To make things simpler and faster, I downloaded this list and included it in the app. (If I wanted to keep the app up-to-date, I'd need to manually update this copy from time to time.)
With all that, we have (relevant source):
import { participating_issuers as TRUSTED_ISSUERS } from './vci-issuers.json'import axios from 'axios'import { jws, KEYUTIL } from 'jsrsasign'
enum VerificationStatus { Invalid, Unverified, Verified}
// jwsContent, cardJson are defined in the previous steps
const trustedIssuer = TRUSTED_ISSUERS.find(entity => entity.iss === cardJson.iss)try { const {data: issuerPubkey} = await axios.get(`${cardJson.iss}/.well-known/jwks.json`)
const importedPubkey = KEYUTIL.getKey(issuerPubkey.keys[0]) const isValidJwks = jws.JWS.verify(jwsContent, importedPubkey, ["ES256"])
if (isValidJwks && trustedIssuer) { result.verification.status = VerificationStatus.Verified } else if (isValidJwks) { result.verification.status = VerificationStatus.Unverified } else { result.verification.status = VerificationStatus.Invalid }} catch (e) { // Silently continue, this means it could not be verified console.error('JWK error: ', e)
result.verification.status = VerificationStatus.Invalid}
The FHIR bundle
By now, we've got the decompressed JWS payload, which is a JSON object. From this point, things are pretty straightforward — it's mostly a matter of pulling data out of that object and displaying it nicely. The format of that JSON object is defined in the HL7 FHIR SMART Health Card Implementation Guide.
One notable thing here is that the bundle doesn't contain human-readable information on the administered vaccine, but rather encodes it using one or more standard code sets. The CDC's CVX code set seems to be used most commonly by issuers, but the spec names 5 others, including SNOMED and ICD-11. Similarly, the bundle contains vaccine manufacturer information coded with the CDC's MVX code set.
As a result, we need to look up the human-readable code values to display the vaccine and manufacturer information in a nice human-readable format. I looked up official vaccine trade names in the Canadian Vaccine Catalogue and combined those with the CDC's CVX code set to create a simple map between CVX codes and vaccine trade names. For manufacturer information, I drew the data directly from the CDC's MVX code set. This mapping is in vaccineLabelMappings.js
. Like with the VCI list, if I wanted to keep the app up-to-date, I'd need to manually update this mapping from time to time.
Thoughts on the technical design
Having dug deep into how vaccine certificates work, let's zoom out and talk a bit about the technical design behind SMART Health Cards. First, let's answer some of the questions we set out with:
What information is shared in vaccine certificates? As we've seen, the information contained in the QR code boils down to basic personal information (full name, date of birth) and information about each administered dose. There's nothing here that came as a surprise, except maybe the detail of metadata provided on each dose (e.g. identity assurance level).
How are vaccine certificates verified? Is any information sent to the cloud during verification? Vaccine certificates are verified cryptographically using an asymmetric encryption scheme. This is clever because the certificate can be verified entirely on-device (with the help of the public key); it's not necessary to send the certificate over the network. The only network request triggered during QR scanning is an HTTP GET request for the issuer's public key. That request is anonymous and does not include any information about the specific QR code being scanned.
What stops somebody from modifying their certificate or creating one from scratch? As discussed, vaccine certificates are based on JSON Web Signatures. The signature portion of a JWS is generated crypographically using the issuer's private key combined with the header and payload portions. If the header or payload is modified, the signature becomes cryptographically invalid, and can't be regenerated without the private key. Issuers keep their private keys under tight security, off limits to ordinary people.
Similarly, it's impossible to create a crypographically valid certificate from scratch without a private key. Somebody could generate their own public/private key pair and use that private key to generate a certificate, but the resulting certificate would show up as "untrusted" during verification.
Flaws
Having touched on the strengths of the technical design, let's see if we can identify any flaws:
Mitigation in case of private key leak
What would happen if an issuer's private key were to be leaked (i.e. shared with an unauthorized party)? The unauthorized party holding the private key could create inauthentic certificates that would pass verification. The only solution would be for the issuer to create a new public/private key pair, invalidating any certificates created (legitimately or illegitemately) until that point. This would require the issuer to distribute new certificates to everybody who had been granted one in the past, a task that would likely prove tedious and confusing to the public. (Though this is a generic flaw relevant to JWS and its relatives (like JWT), SMART Health Cards are particularly vulnerable because they don't expire.)
Dependency on DNS
To verify a certicate, the issuer's public key needs to be fetched over the web, using the web domain defined in the JWS. What would happen if that domain's resolution were to be spoofed by an attacker? The requester could be tricked into using a different public key for verification, for example one from the attacker's own private/public key pair. The attacker could then use their corresponding private key to generate inauthentic certificates that would pass verification.
Dependency on trusted issuer list
SMART Health Cards rely on the VCI Directory to know which issuer domains are trusted. What would happen if an attacker could modify the VCI Directory or the set of hardcoded trusted issuers in a given certificate validation implementation? They could add their own domain, publish their own public key there, and then create crypographically valid certificates using their corresponding private key. On the bright side, the attack could be immediately reversed at any point afterward by removing the attacker's domain from the list of trusted issuers.
Conclusion
Overall, the SMART Health Card technical design is elegant and well-thought out:
- It scales well, with few compute resources required to generate, distribute, and verify certificates
- It's compact and highly versatile, working across myriad digital devices, not to mention good ol' fashioned paper
- It allows verification to happen entirely offline (with the public key downloaded in advance)
- It makes it very difficult for non-issuers to tamper with or generate valid certificates
- It's built on open standards (JWS, FHIR) that are widely adopted
- It can be implemented without licensing costs using readily obtainable open-source software (as demonstrated here)