5 min read

What's inside My Vaccine Pass? (Part 1)

What's inside My Vaccine Pass? (Part 1)
Photo by Adriel Kloppenburg / Unsplash

When they were talking about how vaccine passports might work, I had an idea of how I’d make such a system. A QR code with some information about each person and their vaccination status, a signature signed with the private key of the issuer, and a way for people to verify that signature with the issuer’s public key.

It turns out that it’s exactly what they’ve done. And it’s very similar to what happens every day on the web with OAuth2 and JWT.

I wanted to decode and verify my COVID pass so I can see how it works. So that’s what I did.

The spec for the system is public, at https://nzcp.covid19.health.nz/. (It’s actually a GitHub Pages site.)

First up, I have to decode my QR code:

NZCP:/1/2KCE3I...KLCMJQ

(Most of it omitted for what should be obvious reasons.)

The first thing the spec says is:

Check if the message received from the QR Code begins with the prefix NZCP:/, if it does not then fail.

Yes, it is. It starts with NZCP:/

Next:

Parse the character(s) (representing the version-identifier) as an unsigned integer following the NZCP:/ suffix and before the next slash character (/) encountered. If this errors then fail. If the value returned is un-recognized as a major protocol version supported by the verifying software then fail. NOTE - for instance in this version of the specification this value MUST be 1.

Tick. It is NZCP:/1/.

With the remainder of the message following the / after the version-identifier, attempt to decode it using base32 as defined by RFC4648 NOTE add back in padding if required, if an error is encountered during decoding then fail.

Now things get a bit trickier, as I can’t decode base32 in my head. So I’ll need to write some code. Base32 isn’t part of the .NET standard library either. So I found something on NuGet that had lots of downloads. Maybe it will work.

var decodedBytes = SimpleBase.Base32.Rfc4648.Decode(messageBase32);

I appear to have an array of bytes. (Actually it’s a Span<byte>.)

Next step:

With the decoded message attempt to decode it as COSE_Sign1 CBOR structure, if an error is encountered during decoding then fail.

CBOR stands for Concise Binary Object Representation and it is essentially a binary version of JSON. The System.Formats.Cbor package can decode them.

var message = new CborReader(decodedBytes);

I’m not sure if this is a COSE_Sign1 structure though. RFC8152 tells me that COSE means CBOR Object Signing and Encryption. CBOR Tag 18 is the one for COSE_Sign1, and it appears that my message has that tag.

Now:

With the headers returned from the COSE_Sign1 decoding step, check for the presence of the required headers as defined in the data model section, if these conditions are not meet then fail.

The message itself is an array. The first element is the header:

var arrayLength = message.ReadStartArray();
var headerBytes = message.ReadByteString();
var headerMessage = new CborReader(headerBytes);

This code decodes the header.

var headerLength = headerMessage.ReadStartMap();

int algValue = 0;
string kidValue = null;

for (var i = 0; i < headerLength; i++)
{
    var headerKey = headerMessage.ReadInt32();

    if (headerKey == 1)
    {
        // it's alg
        algValue = headerMessage.ReadInt32();
    }
    else if (headerKey == 4)
    {
        // it's kid
        var headerValueBytes = headerMessage.ReadByteString();
        kidValue = Encoding.ASCII.GetString(headerValueBytes);
    }
}

headerMessage.ReadEndMap();

It is a map with two key-value pairs. COSE has some well-known keys with IDs. We have keys here identified by 1 and 4 which represent alg and kid respectively.

The value of the alg is a single byte: -7. This represents the algorithm ES256. The value of kid is a byte string, which I’ve decoded as ASCII:

WriteLine($"Header decoded. Alg = {algValue}, Kid = {kidValue}");

Output:

Header decoded. Alg = -7, Kid = z12Kf7UQ

I’m not sure if that’s a valid key ID yet. We will check that later.

So far, we’ve got a valid alg and kid. There are other headers in the rest of the message that we still need to validate.

The second element of message is an empty map. Skip it!

var mapLength = message.ReadStartMap();
message.ReadEndMap();

The third element contains the actual payload, as a byte string:

var payloadBytes = message.ReadByteString();
var payloadMessage = new CborReader(payloadBytes);
var payloadLength = payloadMessage.ReadStartMap();

There are some well-known keys, identified by an integer, and keys can also be strings. We have both in our payload. The integer keys are the standard headers. The final element is @vc which is our actual COVID Pass.

for (var i = 0; i < payloadLength; i++)
{
    var nextOne = payloadMessage.PeekState();

    if (nextOne == CborReaderState.TextString)
    {
        var keyString = payloadMessage.ReadTextString();
        if (keyString.ToString() == "vc")
        {
            var vcLength = payloadMessage.ReadStartMap();

            for (var j = 0; j < vcLength; j++)
            {
                var key = payloadMessage.ReadTextString();

                switch (key)
                {
                    case "@context":

                        pass.Context = ReadStringArray(payloadMessage);

                        break;

                    case "version":
                        pass.Version = payloadMessage.ReadTextString();
                        break;

                    case "type":
                        pass.Type= ReadStringArray(payloadMessage);
                        break;

                    case "credentialSubject":
                        pass.CredentialSubject = ReadCredentialSubject(payloadMessage);
                        break;

                    default:
                        break;
                }
            }

            payloadMessage.ReadEndMap();
        }
    }
    else
    {
        var payloadKey = payloadMessage.ReadInt32();

        switch (payloadKey)
        {
            case 1:
                issValue = payloadMessage.ReadTextString();
                break;

            case 4:
                nbfValue = payloadMessage.ReadInt32();
                break;

            case 5:
                expValue = payloadMessage.ReadInt32();
                break;

            case 7:
                jtiBytes = payloadMessage.ReadByteString();
                break;
        }
    }
}

At the end, we get this:

Key (int) Key (description) Value 1 iss (Issuer) did:web:nzcp.identity.health.nz 4 exp (Expiry date) 1652702400 (Monday, 16 May 2022 12:00:00 UTC) 5 nbf (Valid from) 1637060400 (Tuesday, 16 November 2021 11:00:00 UTC) 7 jti (Token identifier) ByteString, which is a UUID according to the spec (not shown) vc Verifiable credential object

The vc is an object containing all the claims:

Key (string) Description Value @context Array of strings with URLs version Version number 1.0.0 type Array of types [ "VerifiableCredential", "PublicCovidPass" ] credentialSubject Map of name { "givenName": "ARUN", "familyName": "STEPHENS", "dob": "yyyy-MM-dd" }

Of note is that your NHI number is not part of the payload, nor is there any information about your specific vaccine status. The only thing that matters to operate the traffic light system is that you have a pass - it could be because you are vaccinated or it may be because of an exemption. The pass doesn’t care.

Also of note is that the jti claim is a UUID, but you can’t just pass the bytes in that order into the constructor for a .NET Guid structure, because Guid expects a different byte order for some segments. To turn it into a UUID URN, I have this very ugly code:

var jtiUuid = new byte[][]
{
    jtiBytes.Skip(0).Take(4).ToArray(),
    jtiBytes.Skip(4).Take(2).ToArray(),
    jtiBytes.Skip(6).Take(2).ToArray(),
    jtiBytes.Skip(8).Take(2).ToArray(),
    jtiBytes.Skip(10).ToArray()
};

var jtiString = $"urn:uuid:{Convert.ToHexString(jtiUuid[0])}-{Convert.ToHexString(jtiUuid[1])}-{Convert.ToHexString(jtiUuid[2])}-{Convert.ToHexString(jtiUuid[3])}-{Convert.ToHexString(jtiUuid[4])}".ToLower();

We now know everything the holder of this pass is claiming. But are their claims valid?

Validate that the iss claim in the decoded CWT payload is an issuer you trust refer to the trusted issuers section for a trusted list, if not then fail.

The value was did:web:nzcp.identity.health.nz and we trust this issuer. Although I would argue that if it had a .govt.nz domain it would seem more trustworthy.

Following the rules outlined in [issuer identifier(https://nzcp.covid19.health.nz/#issuer-identifier)] retrieve the issuers public key that was used to sign the CWT, if an error occurs then fail.

We can retrieve the public key from the DID configuration at https://nzcp.identity.health.nz/.well-known/did.json.

{
    "id": "did:web:nzcp.identity.health.nz",
    "@context": [
        "https://w3.org/ns/did/v1",
        "https://w3id.org/security/suites/jws-2020/v1"
    ],
    "verificationMethod": [
        {
            "id": "did:web:nzcp.identity.health.nz#z12Kf7UQ",
            "controller": "did:web:nzcp.identity.health.nz",
            "type": "JsonWebKey2020",
            "publicKeyJwk": {
                "kty": "EC",
                "crv": "P-256",
                "x": "DQCKJusqMsT0u7CjpmhjVGkHln3A3fS-ayeH4Nu52tc",
                "y": "lxgWzsLtVI8fqZmTPPo9nZ-kzGs7w7XO8-rUU68OxmI"
            }
        }
    ],
    "assertionMethod": [
        "did:web:nzcp.identity.health.nz#z12Kf7UQ"
    ]
}

You will note that the key ID from above (z12Kf7UQ) is present in the DID document. So it must be a valid key.

We need to extract the JWK from there, and then validate the entire original message with that key.

With the retrieved public key validate the digital signature over the COSE_Sign1 structure, if an error occurs then fail.

Up until now we have just been parsing a CBOR object. Now we have to validate the signature. Microsoft doesn’t provide anything that is specific for COSE. So we will leave that for the next post, along with an explanation of how this is very similar to a lot of authentication that happens on the web.