Exploring Singapore's Vaccination Cert

Upon completing my COVID vaccination, I was pleasantly surprised to receive a digitally signed vaccination certificate. Based on my previous experience, I knew that getting a traditionally certified genuine chop stamp true copy of a certificate was not going to be cheap. A quick google search shows that it costs $10 to certify a one page document as a true copy, a further $75 for issuance of a notorial certificate and a further further $85.60 for the Singapore Academy of Law to authenticate the certified true copy, making the total cost a whopping $170.60. That is sure going to deter people from travelling, but fortunately we can now get it for free thanks to technology.

So, how does the digitally signed certificate work? The certificate, stored in a file extension ending in oa, is basically a JSON file. I do not recommend sharing that file as personal details such as birth date, IC number, passport number are stored unencrypted in the file. An encrypted copy of the certificate is also stored at https://api-vaccine.storage.aws.notarise.gov.sg. Based on code in the the /dist/index.js file of the oa-encryption library, the encryption algorithm appears to be AES-GCM with 256 bit key length, 96 bit IV and 128 bit tag length. Upon decryption, it appears that individual fields in certificate are stored separately. The field contains a uuid like string which is used as a salt, the data type and ends with the actual data.

"name":[{
    "text":"212aae69-512f-45f8-83df-52638cc7db7e:string:LIM BINJIE, BENJAMIN"}],
    "gender":"62c839e9-87cf-4878-859c-4e2342cea026:string:male",
    "country":"93d3e245-a2ac-4544-adff-f15e03615ce1:string:SG"}
    "system":"15dfead2-5ea7-46cc-855e-1bf5b8ca791f:string:http://standards.ihis.com.sg"
    ...

The actual hashing is done using the keccak256 algorithm, which is very similar to SHA3 except for some changes to the padding. This operation is performed in the /dist/cjs/2.0/digest.js file of the open-attestation library. The data is flattened, hashed, sorted in alphabetical order and then it goes through hashing once again ending up as the targetHash. Continuing from my sample data above, we see the following fields and their corresponding hashes.

# Hashing of individual fields
keccak256({"fhirBundle.entry.0.name.0.text":"212aae69-512f-45f8-83df-52638cc7db7e:string:LIM BINJIE, BENJAMIN"})
139ab12be28c4c90482a657bd394517a724e6aaae34265fd002d62440dc0b170

keccak256({"fhirBundle.entry.0.gender":"62c839e9-87cf-4878-859c-4e2342cea026:string:male"})
136e180daf05b76b90ace1cf28a4c05eb485fac23ac011424d70a55a5c82df98

keccak256({"fhirBundle.entry.1.address.country":"93d3e245-a2ac-4544-adff-f15e03615ce1:string:SG"})
7c007a0b49968e107b8a1dee58c0096aebe1960a904659ae08a9a82d83d1493f

keccak256({"fhirBundle.entry.4.vaccineCode.coding.0.system":"15dfead2-5ea7-46cc-855e-1bf5b8ca791f:string:http://standards.ihis.com.sg"})
6219063b745934f36aba1aa42ab5a38f32de6a23871e609cb2b139efa79eef0e

# After sorting in alphabetical order
[136e180daf05b76b90ace1cf28a4c05eb485fac23ac011424d70a55a5c82df98,
139ab12be28c4c90482a657bd394517a724e6aaae34265fd002d62440dc0b170,
6219063b745934f36aba1aa42ab5a38f32de6a23871e609cb2b139efa79eef0e,
7c007a0b49968e107b8a1dee58c0096aebe1960a904659ae08a9a82d83d1493f
...]

# Merkle root
keccak256([136e180daf05b76b90ace1cf28a4c05eb485fac23ac011424d70a55a5c82df98,
139ab12be28c4c90482a657bd394517a724e6aaae34265fd002d62440dc0b170,
6219063b745934f36aba1aa42ab5a38f32de6a23871e609cb2b139efa79eef0e,
7c007a0b49968e107b8a1dee58c0096aebe1960a904659ae08a9a82d83d1493f
...])
32fe558b4beff554c35f1f2903fdc0c90784f541dbb0af0a32e390acd0caea8f

Since the vaccination certificate is individually issued and not as a batch, the targetHash is same as the merkleRoot. The certificate has a field stating the verification method, which in this case is did:ethr. The verification appears to be done in /dist/cfs/common/did/verifier.js file of the oa-verify library. The provider appears to be infura and the rpc url is https://mainnet.infura.io/v3/bb46da3f80e040e8ab73c0a9ff365d18. It is interesting to note that GovTech hardcoded their Project Id into the library. The ethers library is used to arrayify the merkelRoot into a byte array which is used together with the signature to compute the ethereumAddress and check if it matches. Continuing from my sample data above, we see the following being performed.

# merkleRoot
32fe558b4beff554c35f1f2903fdc0c90784f541dbb0af0a32e390acd0caea8f

# messageByte
Ethers.arrayify(merkleRoot)
[50,254,85,139,75,239,245,84,195,95,31,41,3,253,192,201,7,132,245,65,219,176,175,10,50,227,144,172,208,202,234,143]

# signature (from oa cert)
0x33d34f9dcbe668a63d9ff355912731a235a6fbba7bad1b84eb0393f86b8d75da1b6ac1ad31393862a00ea3bcb321654b0cc1ab7300518c4d38207cf4dd2c3c831b

# Check if computation of etherum address matches
Ethers.VerifyMessage(messageByte, signature) == ethereumAddress
0xa05e47618bf84101b032b300ab18fc11b90bd549 == 0xa05e47618bf84101b032b300ab18fc11b90bd549

And there we have it. After going on a journey starting from the open-attestation-cli library all through the oa-encryption, open-attestation, oa-verify library, we have managed to trace the entire process from reading in the certificate to the final verification. At times, the sheer amount of boilerplate code gave me nightmares from the days of writing getters and setters in Java. Maybe one day, someone will write a simple verify.py that does it all in a single script.