Verify New Zealand's COVID Pass

Sree PSree P
6 min read

Intro

NZ COVID passes (NZCP) are QR Codes issued by the health authority of New Zealand during the COVID outbreak. These contain the user's vaccine information, which is mainly used at borders to enforce certain restrictions.

Barcode has two major parts

  1. Identify the customer information by Decoding the payload as discussed in the previous article.

  2. Verify the integrity of the barcode information. This article focussed on this part.

Example of QR Code

For reference, this is the example QR Code which is more explained here

NZCP:/1/2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVAYFE6VGU4MCDGK7DHLLYWHVPUS2YIDJOA6Y524TD3AZRM263WTY2BE4DPKIF27WKF3UDNNVSVWRDYIYVJ65IRJJJ6Z25M2DO4YZLBHWFQGVQR5ZLIWEQJOZTS3IQ7JTNCFDX

For languages and libraries, again refer to my other article

Cryptography Basics

NZCP uses the P-256 elliptic curve (secp256r1) to create a signature for the customer's data. EC is an asymmetric cryptography method where encryption can be done with a private or public key, and decryption is done with the opposite key. In our case, the payload is encrypted with a private key at the issuer level, sent as part of the QR code data itself, and we use the public key to decrypt it to validate the integrity of the barcode. I recommend watching this video for insights into Elliptic Curve cryptography.

COSE - CBOR Object Signing and Encryption

CBOR is the format used for transmitting payloads, similar to JSON, but the data is transmitted in binary format. This results in a smaller size, which is especially useful when creating QR codes.

COSE is a specification for CBOR that transfers data along with signatures to verify data integrity. COSE has two mechanisms: SignMessage and Sign1Message. SignMessage can include multiple signatures, allowing consumers to choose which signature to validate. If at least one signature is verified, it is considered successful.

Sign1Message, on the other hand, has only one signature, which is included in the protected headers. The corresponding public keys are used to validate the signature of the content. NZCP uses Sign1Message as the standard for transmitting signatures.

 +-----------+
| Sign1      | (Protected Headers)
| Message   | (Unprotected Headers) (Optional)
+-----------+
     |
     v
+---------+
| Payload  | (Data to be signed)
+---------+
     |
     v
+-----------+
| Signature | (Generated using signing algorithm)

Generation credits: Gemini

// Sample representation of Sign1Message COSE data
{
  "protected": {
    "alg": 7,
    "kid": h'11'
  },
  "unprotected": {},
  "payload": h'48656c6c6f20576f726c64',  // "Hello World" in hex
  "signature": h'9a9e3b7a23e8a7a03e79e92c1f17f8a5...'
}

Generation credits: ChatGPT

Here is the RFC 8152 specification for further reading.

Steps involved

Verification of the payload is more complex than simply decoding the message. Here are the steps:

  1. Decode Sign1Message from payload

  2. Extract JSON Web Key (JWK) from Sign1Message

  3. Generate public key from JWK

  4. Verify Sign1Message with the public key

Decode Sign1Message from payload

This has already been covered in Decoding New Zealand's COVID Pass article.

Extract JSON Web Key (JWK) from Sign1Message

The actual code begins here :) Define a data model for serializing protected attributes.

@Serializable
data class ProtectedAttrs(
    @SerialName("1")
    val alg: Int,
    @SerialName("4")
    val base64EncodedKeyIdentifier: String
)

Define the configuration required for verification. Usually, this should be fetched via an API, but for simplicity, let's define the configuration here.

// Actual configuration provided by health authority

data class Config(
    val keyIdentifier: String = "key-1", // kid
    val publicKeyJWK: JWK = JWK(),
    val trustedIssuers: List<String> = listOf(
        "did:web:nzcp.covid19.health.nz"
    ),
)

// secp256r1 public key material
data class JWK(
    val crv: String = "P-256",
    val kty: String = "EC",
    val x: String = "zRR-XGsCp12Vvbgui4DD6O6cqmhfPuXMhi1OxPl8760",
    val y: String = "Iv5SU6FuW-TRYh5_GOrJlcV_gpF_GpFQhCOD8LSk3T0",
)

Now, let's serialize the protected attributes of Sign1Message into the data model defined above.

suspend fun isValid(payload: String): Boolean {
    // sign1 message from payload - covered in decoding article
    val sign1Message = getSign1Message(payload)

    // Extract protected headers
    val json = Json { ignoreUnknownKeys = true }
    val protectedJson = sign1Message.protectedAttributes.ToJSONString()
    val protectedAttrs = json.decodeFromString(ProtectedAttrs.serializer(), protectedJson)

    .......
    .......
}

Verify that the algorithm matches the NZCP standards.

// Verify protected headers
if (protectedAttrs.alg != -7) { // As per spec, this needs to be -7
   throw Exception("Invalid Algorithm")
}

Verify the issuer of the signature.

val config = Config()
// Content
val contentJson = CBORObject.DecodeFromBytes(sign1Message.GetContent()).ToJSONString()
val certData = json.decodeFromString(CertData.serializer(), contentJson)
// Verify trusted issuers (did:web:nzcp.covid19.health.nz)
if (!config.trustedIssuers.contains(certData.issuer)) {
    throw Exception("issuer not known")
}

Validate kid and initialize JWK

// Extract KID
val encodedKid = protectedAttrs.base64EncodedKeyIdentifier
val kid = String(org.apache.commons.codec.binary.Base64.decodeBase64(encodedKid.toByteArray()))
if (kid != config.keyIdentifier) {
    throw Exception("unknown kid")
}
val jwk = config.publicKeyJWK

Generate public key from JWK

Use the above JWK to create an Elliptic curve public key


suspend fun isValid(payload: String): Boolean {
    ........
    ........
    val jwk = config.publicKeyJWK

    // Generate public key from jwk
    val name = "secp256r1" // NIST P-256, prime256v1
    val ecKeyFactory = KeyFactory.getInstance("EC")
    val xBytes = decodePoint(base64EncodedPoint = jwk.x)
    val yBytes = decodePoint(base64EncodedPoint = jwk.y)
    val point = ECPoint(BigInteger(1, xBytes), BigInteger(1, yBytes))
    val parameterSpec: ECNamedCurveParameterSpec = ECNamedCurveTable.getParameterSpec(name)
    val spec: ECParameterSpec = ECNamedCurveSpec(
        name,
        parameterSpec.curve,
        parameterSpec.g,
        parameterSpec.n,
        parameterSpec.h,
        parameterSpec.seed
    )
    val publicKey = ecKeyFactory.generatePublic(ECPublicKeySpec(point, spec)) as ECPublicKey

    ..........
    ..........
}

private fun decodePoint(base64EncodedPoint: String): ByteArray {
    // _ should be replaced with /, and - with +. convert base64url to base64
    var updated = base64EncodedPoint
        .replace(oldChar = '-', newChar = '+')
        .replace(oldChar = '_', newChar = '/')
    // update padding
    updated = when (updated.length % 4) {
        2 -> "$updated=="
        3 -> "$updated="
        else -> updated
    }
    // decode
    return Base64.getDecoder().decode(updated.toByteArray())
}

Verify Sign1Message with the public key

Finally, validate the Sign1Message using the generated public key.

sign1Message.validate(OneKey(publicKey, null))

Let's have a look at the whole picture

fun isValid(payload: String): Boolean {
    // sign1 message from payload
    val sign1Message = getSign1Message(payload)

    // Extract protected headers
    val json = Json { ignoreUnknownKeys = true }
    val protectedJson = sign1Message.protectedAttributes.ToJSONString()
    val protectedAttrs = json.decodeFromString(ProtectedAttrs.serializer(), protectedJson)
    // Verify protected headers
    if (protectedAttrs.alg != -7) { // As per spec, this needs to be -7
        throw Exception("Invalid Algorithm")
    }

    val config = Config()
    // Content
    val contentJson = CBORObject.DecodeFromBytes(sign1Message.GetContent()).ToJSONString()
    val certData = json.decodeFromString(CertData.serializer(), contentJson)
    // Verify trusted issuers (did:web:nzcp.covid19.health.nz)
    if (!config.trustedIssuers.contains(certData.issuer)) {
        throw Exception("issuer not known")
    }

    // Extract KID
    val encodedKid = protectedAttrs.base64EncodedKeyIdentifier
    val kid = String(org.apache.commons.codec.binary.Base64.decodeBase64(encodedKid.toByteArray()))
    if (kid != config.keyIdentifier) {
        throw Exception("unknown kid")
    }
    val jwk = config.publicKeyJWK

    // Generate public key from jwk
    val name = "secp256r1" // NIST P-256, prime256v1
    val ecKeyFactory = KeyFactory.getInstance("EC")
    val xBytes = decodePoint(base64EncodedPoint = jwk.x)
    val yBytes = decodePoint(base64EncodedPoint = jwk.y)
    val point = ECPoint(BigInteger(1, xBytes), BigInteger(1, yBytes))
    val parameterSpec: ECNamedCurveParameterSpec = ECNamedCurveTable.getParameterSpec(name)
    val spec: ECParameterSpec = ECNamedCurveSpec(
        name,
        parameterSpec.curve,
        parameterSpec.g,
        parameterSpec.n,
        parameterSpec.h,
        parameterSpec.seed
    )
    val publicKey = ecKeyFactory.generatePublic(ECPublicKeySpec(point, spec)) as ECPublicKey

    // Final step: Validate Sign1Message
    return sign1Message.validate(OneKey(publicKey, null))
}

fun main() {
    runBlocking {
        val isValid = Decoder().isValid("NZCP:/1/2KCEVIQEIVVWK6JNGEASNICZAEP2KALYDZSGSZB2O5SWEOTOPJRXALTDN53GSZBRHEXGQZLBNR2GQLTOPICRUYMBTIFAIGTUKBAAUYTWMOSGQQDDN5XHIZLYOSBHQJTIOR2HA4Z2F4XXO53XFZ3TGLTPOJTS6MRQGE4C6Y3SMVSGK3TUNFQWY4ZPOYYXQKTIOR2HA4Z2F4XW46TDOAXGG33WNFSDCOJONBSWC3DUNAXG46RPMNXW45DFPB2HGL3WGFTXMZLSONUW63TFGEXDALRQMR2HS4DFQJ2FMZLSNFTGSYLCNRSUG4TFMRSW45DJMFWG6UDVMJWGSY2DN53GSZCQMFZXG4LDOJSWIZLOORUWC3CTOVRGUZLDOSRWSZ3JOZSW4TTBNVSWISTBMNVWUZTBNVUWY6KOMFWWKZ2TOBQXE4TPO5RWI33CNIYTSNRQFUYDILJRGYDVAYFE6VGU4MCDGK7DHLLYWHVPUS2YIDJOA6Y524TD3AZRM263WTY2BE4DPKIF27WKF3UDNNVSVWRDYIYVJ65IRJJJ6Z25M2DO4YZLBHWFQGVQR5ZLIWEQJOZTS3IQ7JTNCFDX")
        print("QR code data is valid? $isValid") // should print true
    }
}

Things missed here

One thing I haven't covered here is how to fetch JWK information from an API. I left this out to keep the article concise and because the APIs are no longer available. In short, once the APIs return the JWK, we need to ensure the assertion method matches and create the JWK for the corresponding kid.

Final thoughts

I still remember when we were racing against time to deliver this. Even though we had a specification, it took some time to figure out the exact steps needed to decode and verify the payload. Looking at this now, I realize this knowledge is slowly fading away. I hope this article helps to document the technology to decode and verify QR codes issued by the New Zealand Health Authority.

0
Subscribe to my newsletter

Read articles from Sree P directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sree P
Sree P