Introduction

In 2020, the Dutch government created a CoronaCheck App.

With this app, you are able to prove you’ve been vaccinated, you’ve had corona or that you’ve recently tested negative for corona. To get a digital certificate, you can generate a QR code in this app for the Netherlands.

In addition to this app, there is also a CoronaCheck Scanner which scans this QR code and checks the validity of the visitor’s vaccination record, recovery or negative test result. And also if the data on your screen matches the data on the proof of identity.

Both applications are developed by the Dutch government and the source code is public available on GitHub.

This fact and my personal interest in these applications and QR codes in general was the impuls for me to write this blog.

QR Code

Let’s first start at the begining : how are QR codes encoded and decoded.

How do QR codes work?

qr-explained

In addition to these definitions, there are two more important aspects in a QR code:

1️⃣ Masks

When encoding a QR code, there are eight mask patterns which are used to change the outputted matrix. Each mask pattern changes the bits according to their coordinates in the QR matrix. The purpose of a mask pattern is to make the QR code easier for a QR scanner to read.

qr-masks

For more details on masks, see this article: QR Mask Patterns Explained.

2️⃣ Error correction

Data is stored in a QR in words that are 8 bits long and use the Reed–Solomon error correction algorithm with 4 error correction levels.

LevelApproximate error correction capability
Low7% of codewords can be restored.
Medium15% of codewords can be restored.
Quartile25% of codewords can be restored.
High30% of codewords can be restored.

This can be seen in the QR code below which is generated with an error correction of High but is damaged.

qr-damaged

Using the error correction algorithm, it can be repaired into a valid QR code:

qr-fixed

For more information on QR code error correction, see this link.

Reading and writing the QR code using C#

For C#, there are multiple opensource NuGet-packages available, however I decided to copy an existing project and create a new project on GitHub: StefH/QRCode which supports also more frameworks and can be used using Dependency Injection.

In addition to just decoding and encoding a QR code, I did add functionality to fix/repair a fuzzy/corrupt QR code. (This was needed for correctly decoding a QR code which was printed on paper and which was fuzzy and had some artifacts)

CoronaCheck QR code

Now that all the basics are in place to decoding the CoronaCheck QR code, I wanted to know if there were any open-source projects available which explained how the actual CoronaCheck QR code was designed and how to read this information and convert it into human readable data.

The main source of information was of course the GitHub from minvws. But this code was written in GO-lang and I do not have enough knowledge on this to fluently read this and convert this to C#.

Luckily I found two more links:

  1. A Reddit post which describes a tool to decode and dump the data in the CoronaCheck QR code.

  2. A blogpost Decoding the Dutch domestic CoronaCheck QR code which described in detail how the CoronaCheck QR code was designed and to decrypt all the parts. The code used in that link was Python but this provided be enough starting points to start writing the same logic in C#.

Decrypting the data

The QR code below is a sample CoronaCheck QR code which is valid on syntax-level, but will surely not give a green checkmark in the Scanner App.

coronacheck-qrcode.

Things to notice here are:

  • It’s rather big (it contains 13 alignment points and has a resolution of 86x86)
  • The error correction is Low

1. Decoding the image into data

When using the C# code from StefH/QRCode to decode this image, you get the following string data.

NL2:B4V.W9D:LWJ5W2S6A$XQ9N* Y252O4%%  ZNK**$840VPY8T7$J0GR$8L2%VMO/20/3C.C.L XO:FN%IWW.TI+G3KW2RA+ 
$6T1 BQAGU6HJ35D.2YPIT*6Y3C733IOBZIKEWP4L/$9TX6QUVQFFZWJ+RY/JV6N3%NX%Y4XX43J182O/.AELM1%E-D*Q+8*O1C
G*9/5ENUJ0HXT*PJXJ*XE-6QFMM7*B$IFEY04:-PN14PX3% Q5-JQF9$YJFVBSUD*P/AXHJRNUIA:SCX*SBIQ*BHZG$PJ+LG-S*
:0.GZ8M4HO.XLM$BKZG7H/BVRUW$7WH$B3$L-T58KK$20EDRZW1B*VJ1Q5VC:X/.5*OQJ/EA92-8J*-QL6J+3NX:C5%%XZ4LLIS
31KKPA9:1FP++KT:.QFRZ%M5R$I2*DM36M%BW/3.LS9MX6YE8KU4S-.Q%W2ZCI7CQ79E/X342+5T3ODK8X.-F02J-GMF18KCE.5
NDV2V8I/5L0GVNPQRF+T3A*$%HI3-$R3+*RO/X8N.RG7LBFJP5SO9QAD:KYRP978DTHFL39368JXWSO2CKLQTYDZ45CF0FE9J3$
$6+ZX RSNRQ6+HV%DE$V:O/Q8FYO+.NZFL6R8R8UE.0:A*Q9$8HYB+WZ26UM%.4R4 25AA7XQW.NYAJCO6+-C QZEPKLYS6G0Z/
YGHPR*+YKEDO*3LE:KP HT3NCJPRNRG9K0Y84*C-7N2-BQJXY/+D-VF IIQTJ6-AV83%1Y8JMXN1I6/JSHS+HEG+VU+8UX:LL*Y
%B*$G$D4H9HZVMHKWT2-87UTR+EZIWJ*IOTQ70.V%CLHVY  2-HFW4BA6-+FWIW6C:WDS /FU2I9G$LXL$B/MY*WQYMN*R00IQZ
JJ- 6QFX*$%-9ZGS3%G

ℹ️ Note that the above data is formatted to fit in this blogpost. The real data will not have any new-lines.

2. Decode this string using the ‘Dutch’ Base45 decoder

Normally, QR codes are encoded using standard Base45, however the Dutch government decided to use a different Base45 encoding.

In the blog post, there was Python code available (which was derived from Go-Lang) and this code looked like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def base45decode_nl(s: str) -> bytes:
    base45_nl_charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"

    s_len = len(s)
    res = 0
    for i, c in enumerate(s):
        f = base45_nl_charset.index(c)
        w = 45 ** (s_len - i - 1)
        res += f * w
    return res.to_bytes((res.bit_length() + 7) // 8, byteorder='big')

I converted this into C# code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const int BaseSize = 45;

static readonly char[] Base45Digits =
{
	'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C',
	'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
	'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ' ', '$', '%',
	'*', '+', '-', '.', '/', ':'
};

public static byte[] Decode(string inputString)
{
	var stringLength = inputString.Length;

	BigInteger result = 0;
	foreach (var item in inputString.Select((ch, index) => new { index, ch }))
	{
		var location = Array.FindIndex(Base45Digits, d => d == item.ch);
		var value = BigInteger.Pow(BaseSize, stringLength - item.index - 1);

		result += location * value;
	}

	return result.ToBigEndianByteArray();
}

ℹ️ Note that this is not the fastest code (uses BigInteger.Pow(...)) however it’s sufficient to decode this string data (only ~1000 characters of length).

ℹ️ Another thing to note that is that the CoronaCheck QR code uses BigEndian byte order, so that’s why I create an extension method ToBigEndianByteArray to convert the BigInteger into a BigEndian byte array.

ℹ️ And when converting the string data into bytes using Base45, make sure to skip the first 4 characters, so skip the NL2:-text;

1
byte[] base45Decoded = DutchCoronaCheckBase45Utils.Decode(qrCodeData.Substring(4));

3. Decode the Base45 string using an ASN1 decoder

The data itself is serialized using ASN1, see the overview below:

overview

For more details on IdemMix, see this repository.

The ASN1 schema looks like:

DHC DEFINITIONS ::= BEGIN
    ProofSerializationV2 ::= SEQUENCE {
        disclosureTimeSeconds  INTEGER,
        c                      INTEGER,
        a                      INTEGER,
        eResponse              INTEGER,
        vResponse              INTEGER,
        aResponse              INTEGER,
        aDisclosed             SEQUENCE OF INTEGER
    }

    CredentialMetadataSerialization ::= SEQUENCE {
        -- CredentialVersion identifies the credential version, and is always a single byte
        credentialVersion OCTET STRING,

        -- IssuerPkId identifies the public key to use for verification
        issuerPkId PrintableString
    }
END

In order to read an ASN1 document from a byte array, there are several NuGets available:

While the System.Formats.Asn1 can used, I decided to use the PeNet.Asn1 which automatically deserializes the whole document. Only thing left is to cast the Nodes to the correct Node type (Asn1Integer, Asn1OctetString and Asn1PrintableString) and get the data.

The ASN1 schema states that most of the properties are defined as an INTEGER, this is not a normal integer (int16/int32), but a BigInteger.

And the byte array in that BigInteger cannot be used directly as-is. Some manipulation needs to be done because the bits in that BigInteger have this structure:

biginteger-lsb.

So get the real data from that bit-stream, the LSB has to be checked:

  • If the LSB is 0, the integer contains no data. This is used to define this as an optional value.
  • If the LSB is 1, the integer contains data. And to get the data, all the bits need to be right-shifted.

In C# code this looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private static BigInteger? DecodeData(BigInteger value)
{
	if (value.IsEven)
	{
		// Least significant bit acting as a null flag
		return null;
	}

	// Right-shift to get the data
	return value >> 1;
}

With the above logic, we can decode the following properties:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[
    "metadata",
    "isSpecimen",
    "isPaperProof",
    "validFrom",
    "validForHours",
    "firstNameInitial",
    "lastNameInitial",
    "birthDay",
    "birthMonth"
]

The ‘metadata’ field is an ASN1 encoded structure. All the other properties are an UTF-8 encoded string.

The C# classes which describe the ASN1 schema are defined like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public class ProofSerializationV2
{
    public BigInteger DisclosureTimeSeconds { get; set; }
    public BigInteger C { get; set; }
    public BigInteger A { get; set; }
    public BigInteger EResponse { get; set; }
    public BigInteger VResponse { get; set; }
    public BigInteger AResponse { get; set; }
    public SecurityAspect? ADisclosed { get; set; }
}

public class SecurityAspect
{
    public CredentialMetadataSerialization? Metadata { get; set; } = null!;

    /// <summary>
    /// Indicates whether we're dealing with a demo or sample ("1"), or with a live one ("0").
    /// </summary>
    public string? IsSpecimen { get; set; } = null!;

    /// <summary>
    /// Indicates whether we're dealing with a QR that's been generated by the app, or one that's been printed out.
    /// The difference here is that QR codes generated by the app are only considered "fresh" for a very brief amount of time
    /// (a few minutes, from what I've been able to find), and won't be accepted once expired. Paper QRs are exempt from this check.
    /// </summary>
    public string? IsPaperProof { get; set; } = null!;

    /// <summary>
    /// Defines the start time of the QR's validity. Defined as Epoch.
    /// There is apparently some randomness involved with this value so as to avoid disclosing exactly when someone has been vaccinated or tested,
    /// even though this really only indicates when the QR has been created.
    /// </summary>
    public string? ValidFrom { get; set; } = null!;

    /// <summary>
    /// Defines how long the QR is valid for.
    /// This value is added to the "validFrom" time when determining if a QR is valid at a particular moment.
    /// For QRs generated by the app this value will always be 24 hours.
    /// Paper certificates are valid for 40 hours (in case of a negative test) or 28 days (in case of a vaccination or recovery).
    /// Digital certificates are currently valid for 8760 hours, which is 1 year.
    /// </summary>
    public string? ValidForHours { get; set; } = null!;

    /// <summary>
    /// The first initial of the person's first name.
    /// </summary>
    public string? FirstNameInitial { get; set; } = null!;

    /// <summary>
    /// The first initial of the person's last name
    /// </summary>
    public string? LastNameInitial { get; set; } = null!;

    /// <summary>
    /// Defines the day-of-month of the person's birthday.
    /// </summary>
    public string? BirthDay { get; set; } = null!;

    /// <summary>
    /// Defines the month of the person's birthday.
    /// </summary>
    public string? BirthMonth { get; set; } = null!;
}

public class CredentialMetadataSerialization
{
    /// <summary>
    /// CredentialVersion identifies the credential version, and is always a single byte.
    /// </summary>
    public byte CredentialVersion { get; set; }

    /// <summary>
    /// A string that identifies the public key of the issuer that should be used for verification.
    /// </summary>
    public string IssuerPkId { get; set; } = null!;
}

Use this code to decode the byte array to ASN1 and create that ProofSerializationV2 which contains all data.

1
var proofSerializationV2 = DutchCoronaCheckASN1Utils.Read(base45Decoded);

4. Print the info as json

Now that the bytes are correctly deserialized using ASN1, and the data is dumped into a C# class, it’s time to actually view the data.

ℹ️ Note that you need a specific BigIntegerJsonConverter to show the BigInteger value correctly.

1
2
3
4
5
6
var options = new JsonSerializerOptions
{
    WriteIndented = true
};
options.Converters.Add(new BigIntegerJsonConverter());
var json = JsonSerializer.Serialize(proofSerializationV2, options);

The structure as JSON string:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "DisclosureTimeSeconds": 0,
  "C": 8552691641371642315207690017304824071043033037718558536544370073022402689101,
  "A": 49268998317399832353736153768064371574626738522454049754099967437769709104310153609419233826877129804699795832362376712815509777453876457088841057013114352195643598128717108189839059529627445516705110559750713315964799043200407446690141492417247324685581653291574436663012628249066333485906592079172004705549,
  "EResponse": 49545515259154895522776867107959247032241047651980572443481917331143789403307139106555023968391340296360761238102083435099286993359857518,
  "VResponse": 7463954613777398261540270129601931803174222687130155761347994847425322491183229539732191198415826129672984792140024846081031144727556269931620272207040926338693607156199863455849443514025962426861121889546981280059686356901102682907001113258526002924172374302439709804314739830145508425336941142618773720272632415685260123356955161489134413604652892038340458427564488213207319660351733296444266493507445217438295664429771376840180237292580120261971321393055748802007069008017175471042800839302970504855484030669136380196262086189703554019361768087619371773861096653352702590133156777381984232268805659708171263123,
  "AResponse": 8995728107811269294519662596375119434427697506197921896277098071616873267360774813635548449786430125954107935654004828703451862806879219403683135630175078760507502448619423267753,
  "ADisclosed": {
    "Metadata": {
      "Version": 2,
      "PublicKey": "VWS-CC-2"
    },
    "IsSpecimen": "1",
    "IsPaperProof": "1",
    "ValidFrom": "1627466400",
    "ValidForHours": "25",
    "FirstNameInitial": "B",
    "LastNameInitial": "B",
    "BirthDay": "31",
    "BirthMonth": "7"
  }
}

The C, A, EResponse and VResponse are probably signed/encrypted data blocks.

Encrypting the data and generate a QR code

In addition to decrypt and dump the data, I also created C# code to create a new QR code.

  • Serialize a ProofSerializationV2 class to ASN1 document as a byte array
  • Encode this byte array using the Dutch version from the Base45 encoder
  • Generate a new QR Code
    (Note that this new QR code has the correct layout and syntax, but it’s not valid because it lacks the official signing. But this can be used for demo purposes.)

Conclusion

This article describes a proof-of-concept on how to decode the Dutch CoronaCheck QR code using C#.

And what I see in the decoded data is that there’s a minimal amount of data exposed in this Dutch CoronaCheck QR code:

  • initials only
  • no birth year
  • no details if vaccinated, negatively tested, or recovered

This is very different from the European Green Pass which can be seen in these articles:

📚 References