JWTs done right: Quebec's proof of vaccination

May 20, 2021 • Reading time: 8 minutes

Note: Data in this blog post has been anonymized, for obvious reasons.

When Quebec announced that it would be sending proof-of-vaccination emails to everyone who had been vaccinated with an attached QRcode, I got a bit weak at the knees. I couldn't wait to pick it apart and shake my head at the amount of private medical information that would doubtless be exposed in the process.

Well, my proof of vaccination finally arrived, and the result is… actually pretty okay. Still, there's always some fun to be had in zero-knowledge hacks, so I thought I'd blog about my experiences anyway.

Original proof of vaccination PDF, showing a QRcode and textual data

My first impression was, "my goodness that's an unnecessarily large QRcode". There's not that much information listed below the QRcode, so surely they're pulling a fast one and encoding all kinds of personal information without my knowledge. You know, like that barcode on the back of your driver's license.

Naturally, the first thing I did was scan the code with a QRcode app.

shc:/567629000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000413774

Interesting. I'd expected a big ol' blob of JSON, but there's something else going on here. The giant wall of numbers seems inefficient over base64 encoding, but they were able to shoehorn everything into a single QRcode.

This unfortunately is a bit where the zero-knowledge part of the process ends, because I have a pretty clear indicator of where to go from here: the URI scheme. Clearly, this is intended to be associated with some app or another on the device of the person verifying the code, which will register itself to handle this shc: scheme. But what is the scheme?

A bit of searching led me to IANA's Big Book O' URI Schemes, where shc is listed as provisionally registered under the name SMART Health Cards Framework. So this isn't just something that the Quebec government came up with off the cuff, it's actually part of a real thing! That's encouraging and unexpected.

It turns out that the format has extensive documentation and very sensible design goals, which I find both relieving as a holder of such a code, and a bit disappointing as someone about to pick the whole thing apart. But no matter! I have a code in hand and a doc to follow, so let's peel off the lid and take a peek inside.

According to the doc, the use of numeric mode to encode the QRcode data enables a slightly higher data density than using binary mode, which explains the giant URI of numbers rather than a more sensible base64-encoded string. First mystery solved.

The long string of numbers is apparently encoded from an ASCII string, where each pair of digits represents a single character code in base 10. To make things more confusing, the output is computed using Ord(c)-45. Time for a bit of scripting to reverse that process.

php -r '$o = ""; foreach (str_split(preg_replace("/[^0-9]/", "", file_get_contents("php://stdin")), 2) as $c) $o .= chr($c + 45); echo $o;' <input.txt | xxd

00000000: 6579 4a72 6157 5169 4f69 4a73 4d33 6c79  eyJraWQiOiJsM3ly
00000010: 5254 4632 526a 646d 6157 5270 6257 5649  RTF2RjdmaWRpbWVI
...
000003b0: 3561 6876 5265 336d 6368 7335 7836 4e49  5ahvRe3mchs5x6NI
000003c0: 4669 3556 5277                           Fi5VRw

There are a few things to be learned from this. First, apparently PHP is still my go-to quick-and-dirty programming language. How depressing, but we'll file that personal revelation away for later introspection.

From a more technical standpoint, now things are looking like base64-encoded strings. And sure enough, the doc tells me that I should be looking at a JWS, that is to say, a signed JSON Web Token.

I'll pause here and say that this is in fact a great use case for JWTs. Basically, rather than some meaningless token or a giant blob of sensitive data, the JWT concept implies that I should expect a list of permissions that I am entitled to, wrapped up in a blob that is cryptographically signed by the issuer (in this case, Quebec Santé et Services sociaux).

This model is nice because it's verifiable by anyone with the appropriate public key, even without an internet connection. Furthermore, the answer to "is this person entitled to board a flight/attend a concert/visit a seniors' residence?" should be directly answerable inline, rather than indirectly implied through a proprietary API or a bunch of arcane fields related to vaccine lot numbers and so forth.

Now, I don't have a copy of the appropriate public key, but the body should be signed rather than encrypted, so it should still be possible for me to read it.

Maybe in the spirit of reverse engineering I should be manually dismantling the JWS, but it's a pretty well-documented (and, importantly, well-implemented) spec. I'm going to take the lazy way out and use the web-token/jwt-framework Composer package for this.

$ composer require web-token/jwt-framework
<?php
require_once(__DIR__.'/vendor/autoload.php');

use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Jose\Component\Signature\Serializer\CompactSerializer;

$serializerManager = new JWSSerializerManager([
    new CompactSerializer(),
]);

$input_raw = file_get_contents('php://stdin');
$input_token = implode(
    array_map(
        function ($ord) { return chr($ord + 45); },
        str_split(preg_replace('/[^0-9]+/', '', $input_raw), 2)
    )
);

$jws = $serializerManager->unserialize($input_token);
var_dump($jws);
$ cat input.txt | php parse.php
object(Jose\Component\Signature\JWS)#5 (4) {
  ["isPayloadDetached":"Jose\Component\Signature\JWS":private]=>
  bool(false)
  ["encodedPayload":"Jose\Component\Signature\JWS":private]=>
  string(772) "hVNhb9..."
  ["signatures":"Jose\Component\Signature\JWS":private]=>
  array(1) {
    [0]=>
    object(Jose\Component\Signature\Signature)#6 (4) {
      ["encodedProtectedHeader":"Jose\Component\Signature\Signature":private]=>
      string(106) "eyJraW..."
      ["protectedHeader":"Jose\Component\Signature\Signature":private]=>
      array(3) {
        ["kid"]=>
        string(43) "l3yrE1..."
        ["zip"]=>
        string(3) "DEF"
        ["alg"]=>
        string(5) "ES256"
      }
      ["header":"Jose\Component\Signature\Signature":private]=>
      array(0) {
      }
      ["signature":"Jose\Component\Signature\Signature":private]=>
      string(64) "�Q�..."
    }
  }
  ["payload":"Jose\Component\Signature\JWS":private]=>
  string(579) "�Sao..."
}

Okay, so we're successfully decoding the header, but the body isn't coming. The hint here is the "zip":"DEF" in the header, as also indicated in the spec.

payload is compressed with the DEFLATE (see RFC1951) algorithm before being signed (note, this should be "raw" DEFLATE compression, omitting any zlib or gz headers

Cool beans. Let's give it a try:

echo json_encode(json_decode(gzinflate($jws->getPayload())), JSON_PRETTY_PRINT);

NB: We're decoding and then re-encoding the JSON object to add whitespace for readability by specifying the JSON_PRETTY_PRINT constant.

{
    "iss": "https:\/\/covid19.quebec.ca\/PreuveVaccinaleApi\/issuer",
    "iat": 1621476457,
    "vc": {
        "@context": [
            "https:\/\/www.w3.org\/2018\/credentials\/v1"
        ],
        "type": [
            "VerifiableCredential",
            "https:\/\/smarthealth.cards#health-card",
            "https:\/\/smarthealth.cards#immunization",
            "https:\/\/smarthealth.cards#covid19"
        ],
        "credentialSubject": {
            "fhirVersion": "1.0.2",
            "fhirBundle": {
                "resourceType": "Bundle",
                "type": "Collection",
                "entry": [
                    {
                        "resource": {
                            "resourceType": "Patient",
                            "name": [
                                {
                                    "family": [
                                        "Paulson"
                                    ],
                                    "given": [
                                        "Mikkel"
                                    ]
                                }
                            ],
                            "birthDate": "1987-xx-xx",
                            "gender": "Male"
                        }
                    },
                    {
                        "resource": {
                            "resourceType": "Immunization",
                            "vaccineCode": {
                                "coding": [
                                    {
                                        "system": "http:\/\/hl7.org\/fhir\/sid\/cvx",
                                        "code": "208"
                                    }
                                ]
                            },
                            "patient": {
                                "reference": "resource:0"
                            },
                            "lotNumber": "xxxxxx",
                            "status": "Completed",
                            "occurrenceDateTime": "2021-xx-xxT04:00:00+00:00",
                            "location": {
                                "reference": "resource:0",
                                "display": "xxxxxxxxxxxxxxxxxx"
                            },
                            "protocolApplied": {
                                "doseNumber": 1,
                                "targetDisease": {
                                    "coding": [
                                        {
                                            "system": "http:\/\/browser.ihtsdotools.org\/?perspective=full&conceptId1=840536004",
                                            "code": "840536004"
                                        }
                                    ]
                                }
                            },
                            "note": [
                                {
                                    "text": "PB COVID-19"
                                }
                            ]
                        }
                    }
                ]
            }
        }
    }
}

There's a bit more personal information than strictly necessary in there, though I suppose pairing the name and birthdate with photo ID is a reasonable process. They also provide specific vaccine details rather than particular permissions, as I'd hoped. Then again, that does make the whole thing friendlier for cross-jurisdictional use and avoids the need to re-issue JWSes every time a policy changes, which in the case of Quebec is about twice a week.

Throughout this dissection, I've been wondering what would prevent someone from simply displaying another person's perfectly valid proof of vaccination. Since the entire body is cryptographically signed, you can't modify someone else's proof of vaccination to add your own name, which means that pairing a proof of vaccination with a photo ID is a perfectly reasonable plan. That will certainly be the case at airports, but I highly doubt that sporting venues and so forth will bother asking for a second ID. They will simply scan a QRcode, see a checkmark on their device, and move on to the next.

One parting thought: while my process has been geared towards figuring out what of my personal data is being encoded in the QRcode, the JWT model is notorious for being easy to screw up by either forgetting to validate before parsing the data, or permitting tokens with no signature. If implementations don't respect a central whitelist of authorized signers, it would be trivially easy to create a perfectly valid token that you sign with your own key. As always, the security of the model really depends on how strictly the validating party enforces the standard.

However, it turns out that the only personal information is exactly the info contained on the full PDF proof of vaccination: name, birthdate, gender (for some reason), and information on the date and specific doses the holder has received to date. If you're comfortable with the privacy implications of showing your driver's license at a bar, you should have no further qualms about being asked to show your proof of vaccination.

The code is a total pile of garbarge, but if you want to see what's in your own QRcode, you can check out the GitHub repository for this post.