When Quebec announced that it would be sending out vaccination confirmation emails to everyone who was vaccinated with the attached QR code, my knees buckled a bit. I was eager to take it apart and shake my head at the amount of private health information that will no doubt be revealed in the process.
My vaccination confirmation has finally arrived, and the result is ... not bad at all. However, there is always some fun in zero knowledge hacks, so I decided to blog about my experience anyway.
My first impression was, "Oh my God, this is an unnecessarily large QR code." There is not much information listed under the QR code, so they probably encrypt all kinds of personal information without my knowledge. You know, like that barcode on the back of your driver's license .
Naturally, the first thing I did was scan the code using the QRcode app.
result
shc:/567629000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000413774
Interesting. I thought there would be good old JSON in binary format, but it was different. It seems that encoding a bunch of digits in base64 is inefficient, but they managed to cram everything into one QR code.
Unfortunately, this is where the zero knowledge part of the process ends, because I have a pretty clear indicator of where to go next: the URI scheme. It is clear that this is intended to communicate with some application on the device of the person verifying the code that will register to process this scheme
shc :
. But what is this scheme?
A little search led me to IANA's Big Book O 'URI Schemes where
shc
listed as pre-registered under the name SMART Health Cards Framework. So it's not just something that the Quebec government came up with on the go, it's actually part of a real project! This is encouraging and unexpected.
It turns out that this format has extensive documentation and very sensible design goals , which I find both a relief for the holder of such code and a little frustrating when someone is about to parse it in its entirety. But it doesn't matter! I have some code and a document to follow, so let's remove the lid and take a look inside.
According to the doc, using numeric mode to encode QR code data provides slightly higher data density than using binary mode, which explains the giant number URI rather than the more sensible base64 encoded string. The first riddle is solved.
The long string of numbers appears to be encoded from an ASCII string, where each pair of digits is a decimal number that is the character code. To make things even more confusing, the output is computed using Ord © -45 . It's time to write a script to reverse this process.
php -r '$o = ""; foreach (str_split(preg_replace("/[^0-9]/", "", file_get_contents("php://stdin")), 2) as $c) $o .= chr($c + 45); echo $o;' <input.txt | xxd
00000000: 6579 4a72 6157 5169 4f69 4a73 4d33 6c79 eyJraWQiOiJsM3ly
00000010: 5254 4632 526a 646d 6157 5270 6257 5649 RTF2RjdmaWRpbWVI
...
000003b0: 3561 6876 5265 336d 6368 7335 7836 4e49 5ahvRe3mchs5x6NI
000003c0: 4669 3556 5277 Fi5VRw
Several things can be learned from this. First, it's obvious that PHP is still my fast programming language. Sadly, we will put this personal revelation aside for further introspection.
From a technical point of view, everything now looks like base64 encoded strings. And of course the doc tells me that I should be looking at JWS, that is, a JSON signed web token.
I will pause and say that this is actually a great JWT use case. Basically, instead of some meaningless token or giant block of sensitive data, the JWT concept implies that I should expect a list of permissions to which I am entitled, wrapped in a blob that is cryptographically signed by the issuer (in this case, Quebec Santé et Services sociaux).
The good thing about this model is that it can be verified by anyone with the corresponding public key, even without an Internet connection. In addition, the answer to the question "does this person have the right to board an aircraft / attend a concert / visit a residence for the elderly?" should answer directly inline, not implicitly implied through the proprietary API or a bunch of secret fields related to vaccine lot numbers, etc.
Now I don't have a copy of the corresponding public key, but the body should be signed, not encrypted, so I'm still I can read it.
Perhaps, in the spirit of reverse engineering, I should manually disassemble the JWS, but this is a fairly well-documented (and importantly, well-implemented) specification. I'm going to go lazy out and use the web-token / jwt-framework Composer package for that .
$ composer require web-token/jwt-framework
<?php
require_once(__DIR__.'/vendor/autoload.php');
use Jose\Component\Signature\Serializer\JWSSerializerManager;
use Jose\Component\Signature\Serializer\CompactSerializer;
$serializerManager = new JWSSerializerManager([
new CompactSerializer(),
]);
$input_raw = file_get_contents('php://stdin');
$input_token = implode(
array_map(
function ($ord) { return chr($ord + 45); },
str_split(preg_replace('/[^0-9]+/', '', $input_raw), 2)
)
);
$jws = $serializerManager->unserialize($input_token);
var_dump($jws);
$ cat input.txt | php parse.php
object(Jose\Component\Signature\JWS)#5 (4) {
["isPayloadDetached":"Jose\Component\Signature\JWS":private]=>
bool(false)
["encodedPayload":"Jose\Component\Signature\JWS":private]=>
string(772) "hVNhb9..."
["signatures":"Jose\Component\Signature\JWS":private]=>
array(1) {
[0]=>
object(Jose\Component\Signature\Signature)#6 (4) {
["encodedProtectedHeader":"Jose\Component\Signature\Signature":private]=>
string(106) "eyJraW..."
["protectedHeader":"Jose\Component\Signature\Signature":private]=>
array(3) {
["kid"]=>
string(43) "l3yrE1..."
["zip"]=>
string(3) "DEF"
["alg"]=>
string(5) "ES256"
}
["header":"Jose\Component\Signature\Signature":private]=>
array(0) {
}
["signature":"Jose\Component\Signature\Signature":private]=>
string(64) "�Q�..."
}
}
["payload":"Jose\Component\Signature\JWS":private]=>
string(579) "�Sao..."
}
So, we successfully decode the header, but no body arrives. The hint here is "zip": "DEF" in the header, as also stated in the spec.
the payload is compressed using the DEFLATE algorithm (see RFC1951) before signing (note, this must be raw DEFLATE compression, without any zlib or gz headers
Let's try:
echo json_encode(json_decode(gzinflate($jws->getPayload())), JSON_PRETTY_PRINT);
NB: we decode and then recode the JSON object to add white space for readability by specifying the JSON_PRETTY_PRINT constant
{
"iss": "https:\/\/covid19.quebec.ca\/PreuveVaccinaleApi\/issuer",
"iat": 1621476457,
"vc": {
"@context": [
"https:\/\/www.w3.org\/2018\/credentials\/v1"
],
"type": [
"VerifiableCredential",
"https:\/\/smarthealth.cards#health-card",
"https:\/\/smarthealth.cards#immunization",
"https:\/\/smarthealth.cards#covid19"
],
"credentialSubject": {
"fhirVersion": "1.0.2",
"fhirBundle": {
"resourceType": "Bundle",
"type": "Collection",
"entry": [
{
"resource": {
"resourceType": "Patient",
"name": [
{
"family": [
"Paulson"
],
"given": [
"Mikkel"
]
}
],
"birthDate": "1987-xx-xx",
"gender": "Male"
}
},
{
"resource": {
"resourceType": "Immunization",
"vaccineCode": {
"coding": [
{
"system": "http:\/\/hl7.org\/fhir\/sid\/cvx",
"code": "208"
}
]
},
"patient": {
"reference": "resource:0"
},
"lotNumber": "xxxxxx",
"status": "Completed",
"occurrenceDateTime": "2021-xx-xxT04:00:00+00:00",
"location": {
"reference": "resource:0",
"display": "xxxxxxxxxxxxxxxxxx"
},
"protocolApplied": {
"doseNumber": 1,
"targetDisease": {
"coding": [
{
"system": "http:\/\/browser.ihtsdotools.org\/?perspective=full&conceptId1=840536004",
"code": "840536004"
}
]
}
},
"note": [
{
"text": "PB COVID-19"
}
]
}
}
]
}
}
}
}
There is a bit more personal information in there than is strictly necessary, although I believe that combining name and date of birth with photo ID is a reasonable process. They also provide specific information about vaccines rather than specific approvals as I was hoping. Again, this makes it all the more usable across jurisdictions and eliminates the need to re-release the JWS every time the policy changes, which in the case of Quebec happens about twice a week.
Throughout this analysis, I have wondered what might prevent someone from simply presenting perfectly valid proof of another person's vaccination. Since the entire body is cryptographically signed, you cannot change someone else's vaccination proof to add your name, which means combining the proof of vaccination with a photo ID is a perfectly reasonable plan. This will certainly be the case at airports, but I highly doubt that at sports venues, etc. E. Will ask for a second ID. They will simply scan the QR code, see a checkmark on their device, and move on to the next one.
One parting thought: While my process was geared towards figuring out which of my personal data is encoded in a QR code, the JWT model is notorious for being easy to mess up, either by forgetting to validate before parsing the data, or by allowing unsigned tokens ... If implementations don't respect a central whitelist of authorized signers, it would be trivially easy to create a perfectly valid token that you sign with your own key. As always, the security of the model really depends on how rigorously the relying party enforces the standard.
However, it turns out that the only personal information is exactly the information that is contained in the full PDF document about vaccinations: name, date of birth, gender (for some reason), as well as information about the date and specific doses that the owner received on present day. Once you are comfortable with the privacy implications of presenting your driver's license at a bar, you no longer have to worry about being asked to show proof of vaccination.
The code is a whole bunch of garbage, but if you want to see what's in your own QR code, you can check out the GitHub repository for this post.