Reverse engineering digital COVID-19 certificates

Posted on Jun 28, 2021

Update: FHI released the code for the mobile application and backend API a few hours after this post went public. Awesome stuff!

Many countries are in the process of rolling out digital COVID-19 certificates. In Europe, most of these appear to be in the form of a QR code. I recently received my own certificate, and was curious about the data it contained, as well as the supporting infrastructure around certificate verification.

Electronic and printed COVID-19 certificates

In Norway, much of the “COVID infrastructure” was released as open source via the national health authority’s GitHub account. There was a pretty big public push for openness after the government decided to roll out a closed source contact tracing application that collected detailed GPS data, instead of relying on the more privacy friendly Bluetooth-based technology developed by Google and Apple.

To my mild disappointment, I wasn’t able to find much information about the Norwegian COVID-19 certificate. Though the EU has been working on a public certificate standard, which it appears to have been based on.

Decoding the QR code

If you’ve tried to scan a certificate with your phone, you’ve probably seen something like this:

NO1:NCF8B0*50T9WUWGSLKH47GO0-LVTQ7U2BKMF8CKZ9C*70M+9FN0JECBARWY07AC82VD97TK0G90XJC/$ENF6OF64W5346SF6JPCT3E:TCMA7LB7..DX%DZJC0/D5UAFUD JC/.D.:8LPCG/D3PCAY8MPCG/DU6VO/EZKEZ96446F56ZKEHEC-3E:10O80+:4NF5U%Q/D4BY6AV3P5RHB967MKW063W-NA:-S+I52/3H1GP5KO/0JC6HJTIS5:CQT%2.H7NYI E7XD3:C25SVM+P$BA:PO.*SCV8V2P LQ40Q9V0B O 06HBM.QMF+VJ+24%NBO37C7U*QEC9AR76$RES4 IFY1B1TK*9DM447CVOQP9TU9EOJU9-1T0%7TT8MRME+9Q6Q9OR7Q56KN+OO52JJ.3H*G/+V+27PXAUE1V5RCU7AUAPVO74KA+9 R59MQYP9HKDI:IZ.KQNTN21PP9KG5$N6NCGJ3DM3VC:DU1LW2TQAF$N5K08GGJ+*6D97DZ6NE8SSFMTDQ NRN0X/F$00TZQ01PG%R089T26-8EM+VK-H4%4S:5+7U88MY9I7MQVR2VDO.*1SYO8/L6ADZZAZ8VV.MSY5DPJ4MC5NR2OTRE0W/0SIT:*7-XEBI4 L7B BMAON2EOUE4RDLMU+LC.WH$Y9VCQX/HOS5UC0R*E4-5SOGFJS0/MP4P+3N-/P.SGH3KZ2P2Z00V6UBAMNO9C7L$G6HVOXSI+SA6KOWFSPF*56 4KIYS$2OPCFV89RKT

For this example, I’m going to use a certificate from the FHI’s press release. There’s also sample certificates following the EU format here.

Source: https://github.com/ehn-dcc-development/hcert-spec

Basically, this is just a data blob that’s been packed a bunch of different ways and signed with a key. To decode the data we can roughly follow the reverse of the diagram above:

  1. Separate the “prefix” from the base45 encoded data. I haven’t found an official specification for this, but it appears that the first colon splits the prefix and the base45 data. The EU certificates use HC1 and the Norwegian ones use NO1.
  2. Base45 decode the data after the prefix
  3. Decompress the data with ZLib
  4. Extract the CBOR payload from the COSE document. CBOR and COSE are binary formats comparable to JSON and JWTs. COSE is a binary object that contains headers, a payload, and a signature. CBOR is basically just a binary JSON object.
  5. Decode the CBOR payload

Let’s implement this in Python. To start, you’ll need to install some dependencies:

pip install pyzbar Pillow base45 cbor2 cose requests

The pyzbar module requires some extra installation steps, which vary depending on your platform. More here.

from pyzbar.pyzbar import decode, ZBarSymbol
from PIL import Image
import base45
import zlib
import cbor2
from cose.messages import CoseMessage

qr_data = decode(Image.open('qr_code.png'), symbols=[ZBarSymbol.QRCODE])[0].data.decode('utf-8')
b45_data = qr_data[4:]
zlib_data = base45.b45decode(b45_data)
cose = zlib.decompress(zlib_data)
decoded_cose = CoseMessage.decode(cose)
payload = cbor2.loads(decoded_cose.payload)

print(payload)

This gives us the output of the CBOR payload:

{1: 'NO', 4: 1635112800, 6: 1622537625, -260: {1: {'du': '2021-10-25', 'dob': '1979', 'nam': {'fn': 'Tel*', 'gn': 'G.', 'fnt': 'TEL', 'gnt': None}, 'ver': 
'1.0.0', 'reason': 0}}}

The EU formatted certificates appear to contain a bit more information:

{1: 'NO', 4: 1635199200, 6: 1624347447, -260: {1: {'r': [{'ci': 'URN:UVCI:01:NO:PRV/EV6EVFPHCEGX108VC', 'co': 'NO', 'df': '2021-04-26', 'du': '2021-10-26', 'fr': '2021-04-26', 'is': 'Norwegian Institute of Public Health', 'tg': '840539006'}], 'dob': '1979-05-12', 'nam': {'fn': 'Telokk', 'gn': 'Gry', 'fnt': 'TELOKK', 'gnt': 'GRY'}, 'ver': '1.3.0'}}}

Certificate verification

It was nice to decode the data, but I wanted to verify certificates as well. Certificate verification has been implemented via the COSE message, which follows a similar standard to JWTs, where there are headers, a payload, and a cryptographic signature.

Source: https://pycose.readthedocs.io/en/latest/cose/messages/sign1message.html

Here’s where I ran into a slight problem. As far as I could tell, Norway had not published its public keys - so I started poking around. I found an application in the App/Play Store published by the public health authority called, “Kontroll av koronasertifikat”, or “Control of Corona Certificate”. This appears to be the application used at the border to verify your COVID-19 status. I figured that if the application does what it says, it should have access to those public keys, so I downloaded it.

Normally, when I want to decompile an Android application I start with a Java decompiler like JADX. Though this didn’t provide much help because the application was developed in Xamarin, a platform for developing apps in .NET.

Luckily, not all hope was lost. If you decompress a Xamarin app with something like Apktool or 7-Zip, you’ll end up with a folder labeled “assemblies” which contains the application’s core code, packed away within a bunch of .dll files. In my case those dll’s were compressed, so I used this tool to decompress them.

Now I could start looking at the application’s code in my favorite .NET decompiler, dotPeek.

I eventually found references to an API endpoint seemingly designed to serve up public keys, but some of the values were missing. Some more looking around revealed the values are loaded from a .json file that’s stored in the “assets” directory of the APK.

For whatever reason FHI has decided to “protect” the API endpoint with an Authorization header, and a static key that’s distributed with the app. They also check to ensure the User-Agent header matches what they would expect to see coming from the app. Now, why they try and restrict access to an API endpoint that’s accessed by everybody who downloads the app I’m not sure. Especially since all it does is serve up public keys. But, to avoid making people angry I will leave the key out of this post. What I can say is that anybody with 7-Zip can get it themselves.

Note to developers: Defense in depth is good. Adding features that provide no security benefit is just adding to your attack surface. 😉

import requests

headers = {
    'Authorization': '[super mega secret static key publicly distributed via the Play Store]',
    'User-Agent': 'FHICORC/38357 CFNetwork/1240.0.4 Darwin/20.5.0'
}

r = requests.get('https://koronakontroll.nhn.no/v2/publickey', headers=headers)
if r.status_code == 200:
    with open('certs.json', 'wb') as file:
        certs = r.content
        file.write(certs)

The response looks like this, but with a bunch more keys:

[
  {
    "kid": "hA1+pwEOxCI=",
    "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIQ5LJGhrs3m//HC60//4N8WDL1DiHJRUTirld4U9ebBYYsTtxWknGG0Uton12x8yDHm7wm7aRoFhd5MxW4G5cw=="
  },
  {
    "kid": "/IcqIBnnZzc=",
    "publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbxlQ/UTz0AxZWF+xQs+w+xveq5Eche9zf16LnDFYY/K8pZ6l6sb2KlXw8Z/kwopzzbv5LCX1VxLsGO9Rs9OzLg=="
  }
]

Cool, so now we have the public keys saved to a .json file. Next step is to verify my certificate against the public keys.

I ran into some compatibility issues with key formats, which I didn’t spend a lot of time debugging. My hack-ish solution was to extract the X and Y numbers from the public key using the cryptography library, and create a new instance of the public key in the cose library.

import json
import base64
from cryptography.hazmat.primitives.serialization import load_der_public_key

def get_publickey(kid):
        # Load certificates from file
        with open('certs.json', 'rb') as file:
            certs = json.load(file)
        
        # Iterate through all the certs, and process if the correct kid is found
        for c in certs:
            if c['kid'] == base64.b64encode(kid).decode('utf-8'):
                public_key = c['publicKey']
                return public_key

# Using the decoded_cose object from the code above
algorithm = decoded_cose.get_attr(Algorithm).fullname
kid = decoded_cose.get_attr(KID)

public_key_b64 = get_publickey(kid)
der_public_key = base64.b64decode(public_key_b64)

public_key = load_der_public_key(der_public_key)
x = public_key.public_numbers().x.to_bytes(32, "big")
y = public_key.public_numbers().y.to_bytes(32, "big")

cose_key = EC2Key(crv='P_256', x=x, y=y, optional_params={'ALG': 'ES256'})
decoded_cose.key = cose_key
print(decoded_cose.verify_signature())

Result:

True

This code will only work with the ES256 algorithm, so if you were to implement this in the real world you’ll probably want to detect the algorithm type from the header, and adjust the parameters accordingly.

Final thoughts

All in all, this solution seems to be built fairly well. Norway has designed their domestic COVID-19 certificate based on the EU standard, which is good. Though I do wish FHI was more transparent about how the solution worked. I’m not sure why they didn’t release this as open source. Surely other health authorities could benefit?

If I had to critique the EU standard, it would be that the technologies they chose to use aren’t as mature as other options available. This means that the libraries are maintained to a lesser degree, and haven’t been subjected to the same level of public scrutiny. The Python libraries for COSE and Base45 only have 19 and 10 stars on GitHub at the time of writing. Looking back at history, JWT libraries had some pretty serious vulnerabilities early on.

FHI, you get a 8/10. Points deducted for a lack of openness and trying to implement weird “security” features.