Verify New Zealand's COVID Pass
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
Identify the customer information by Decoding the payload as discussed in the previous article.
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:
Decode Sign1Message from payload
Extract JSON Web Key (JWK) from Sign1Message
Generate public key from JWK
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.
Subscribe to my newsletter
Read articles from Sree P directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by