Decodificare un Green Pass

Dato che il Green Pass è l'argomento del giorno (e del mese, a dire il vero) mi è venuta l'ispirazione di vedere come è fatto dentro. Cosa c'è dentro questo misterioso QR code che ci portiamo in giro tutti i giorni?

La risposta è facile da trovare nella ricca documentazione ufficiale, quindi non mi voglio soffermare troppo sull'argomento: è un formato standard e completamente aperto, di cui chiunque può leggere le specifiche.

Addentrandosi un pò nel torbido dei tecnicismi, si parte da un insieme di informazioni personali (nome, cognome, data di nascita, stato vaccinale, data ultima dose eccetera) scritti in formato JSON con uno schema predefinito e si arriva a un QR code, che contiene queste informazioni in maniera accessibile da un sistema informatico (tipo lo smartphone del cameriere fuori dal ristorante). Di mezzo c'è anche un meccanismo di verifica della veridicità del certificato mediante un meccanismo di certificazione digitale, che però approfondirò in un post successivo. Lo schema, preso dalla documentazione ufficiale, è questo:

Ho voluto provare a mettere insieme un programmino per percorrere i passaggi a ritroso, dal QR code alle informazioni contenute nel "JSON Document". Per fare i test mi sono procurato un QR Code di prova non valido, messo a disposizione dall'EU Digital COVID Certificates (EUDCC) project.

Nota importante: il QR code contiene dati personali (non molti, ma comunque), e quindi non va mai condiviso o pubblicato in giro. Inoltre ci sono in giro un sacco di mentecatti che potrebbero farne un cattivo uso. Se pubblicate esperimenti usate sempre dati di prova!

E' ora di un pò di Python. Per prima cosa bisogna importare un pò di dipendenze:

from pyzbar.pyzbar import decode
import cv2
import zlib
import base45
import cbor2
from cose.messages import CoseMessage
from cose.headers import Algorithm, KID
import json
from urllib.request import urlopen

Per prima cose bisogna leggere il QR code dal file "test.png" usando CV2, convertirlo in bianco e nero (passaggio standard per facilitarne la lettura) e darlo in pasto a pyzbar.decode, che interpreterà l'immagine e ce ne restituirà il valore come sequenza binaria.

# Lettura file
filename = "test.png"
img = cv2.imread(filename)

# Conversione in scala di grigi
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# Lettura del QR Code
decoded = decode(gray_img)

# Rimozione dei primi 4 byte (intestazione)
payload = decoded[0].data[4:]
print(payload)

Il risultato ha questo aspetto:

b'6BFOXN%TS3DH0YOJ58S S-W5HDC *M0II5XHC9B5G2+$N IOP-IA%NFQGRJPC%OQHIZC4.OI1RM8ZA.A5:S9MKN4NN3F85QNCY0O%0VZ001HOC9JU0D0HT0HB2PL/IB*09B9LW4T*8+DCMH0LDK2%K:XFE70*LP$V25$0Q:J:4MO1P0%0L0HD+9E/HY+4J6TH48S%4K.GJ2PT3QY:GQ3TE2I+-CPHN6D7LLK*2HG%89UV-0LZ 2ZJJ524-LH/CJTK96L6SR9MU9DHGZ%P WUQRENS431T1XCNCF+47AY0-IFO0500TGPN8F5G.41Q2E4T8ALW.INSV$ 07UV5SR+BNQHNML7 /KD3TU 4V*CAT3ZGLQMI/XI%ZJNSBBXK2:UG%UJMI:TU+MMPZ5$/PMX19UE:-PSR3/$NU44CBE6DQ3D7B0FBOFX0DV2DGMB$YPF62I$60/F$Z2I6IFX21XNI-LM%3/DF/U6Z9FEOJVRLVW6K$UG+BKK57:1+D10%4K83F+1VWD1NE'

A questo punto, secondo lo schema visto sopra, dovremmo avere tra le mani una stringa zippata e poi convertita in Base45. Procediamo a effettuare i passaggi inversi:

decoded = base45.b45decode(payload)
decompressed = zlib.decompress(decoded)
print(decompressed)

Il risultato è ancora una stringa binaria:

b'\xd2\x84M\xa2\x04H90\x17h\xcd\xda\x05\x13\x01&\xa0Y\x01\x01\xa4\x04\x1aa\x94\xe8\x98\x06\x1a`\xa7\x8c\x88\x01bIT9\x01\x03\xa1\x01\xa4av\x81\xaabdn\x02bmamORG-100030215bvpj1119349007bdtj2021-04-10bcobITbcix&01ITE7300E1AB2A84C719004F103DCB1F70A#6bmplEU/1/20/1528bisbITbsd\x02btgi840539006cnam\xa4cfntiDI<CAPRIObfniDi CapriocgntmMARILU<TERESAbgnnMaril\xc3\xb9 Teresacvere1.0.0cdobj1977-06-16X@\xa4\xee\x90\x16\xc1\xa7L\xcf\x9c\xaa\xb9\x05I-i\x8fi\x92\xa8\xfa0\xc2\r\xb6\x18\x0f\x06\x04\x0cHp\xa8E\xbbK:\x1c\xe3\xf4\xedR\x9c\xc7\x8ef2%G\xd6&7\xc7J\xb1y\x19\xc0\xaaR\xa6\x14y^\x9e'

Si comincia a intravedere qualcosa, ma c'è ancora strada da fare. Secondo lo schema, a questo punto dovremmo avere tra le mani un messaggio CBOR (Concise Binary Object Representation, una modalità di rappresentazione di oggetti binari) contenuto a sua volta in un messaggio COSE (CBOR Object Signing and Encryption, un oggetto che contiene al suo interno un messaggio COSE e delle informazioni aggiuntive tipo firma digitale e simili).

Cominciamo a decodificare il contenitore, ovvero l'oggetto COSE, e vediamo che faccia ha:

cose = CoseMessage.decode(decompressed)
print(cose)

Il risultato è:

<COSE_Sign1: [{'KID': b'90\x17h\xcd\xda\x05\x13', 'Algorithm': 'Es256'}, {}, b'\xa4\x04\x1aa\x94' ... (257 B), b'\xa4\xee\x90\x16\xc1' ... (64 B)]>

Dal COSE possiamo tirare fuori il payload, che sarà il nostro messaggio CBOR (la rappresentazione dei dati dentro il Green Pass).

data = cbor2.loads(cose.payload)
print(data)

Il risultato, finalmente, è:

{4: 1637148824,
 6: 1621593224,
 1: 'IT',
 -260: {1: {'v': [{'dn': 2,
     'ma': 'ORG-100030215',
     'vp': '1119349007',
     'dt': '2021-04-10',
     'co': 'IT',
     'ci': '01ITE7300E1AB2A84C719004F103DCB1F70A#6',
     'mp': 'EU/1/20/1528',
     'is': 'IT',
     'sd': 2,
     'tg': '840539006'}],
   'nam': {'fnt': 'DI<CAPRIO',
    'fn': 'Di Caprio',
    'gnt': 'MARILU<TERESA',
    'gn': 'Marilù Teresa'},
   'ver': '1.0.0',
   'dob': '1977-06-16'}}}

Direi che ci siamo quasi. I dati ci sono tutti, e il significato delle chiavi è descritto nella documentazione ufficiale del formato. Per esempio:

  • FN è il cognome
  • GN è il nome
  • DOB è la data di nascita
  • eccetera..

Come ultimo passo, possiamo scaricare lo schema ufficiale e dare in automatico un nome alle chiavi. Ho trovato in giro un pezzettino di codice che mi risparmia la fatica di reinventare l'acqua calda, e che riporto qui sotto (con il link alla fonte originale).

# from Tobias Girstmair (https://gir.st)
# https://gist.github.com/dsoares/dbd784615defd8800e93e4df4c783ce1

sch = urlopen('https://raw.githubusercontent.com/ehn-dcc-development/ehn-dcc-schema/release/1.3.0/DCC.combined-schema.json')
glb_schema = json.load(sch)

def annotate(data, schema, level=0):
    for key, value in data.items():
        description = schema[key].get('title') or schema[key].get('description') or key
        description, _, _ = description.partition(' - ')
        if type(value) is dict:
            print('  '*level, description)
            _, _, sch_ref = schema[key]['$ref'].rpartition('/')
            annotate(value, glb_schema['$defs'][sch_ref]['properties'], level+1)
        elif type(value) is list:
            print('  '*level, description)
            _, _, sch_ref = schema[key]['items']['$ref'].rpartition('/')
            for v in value:
                annotate(v, glb_schema['$defs'][sch_ref]['properties'], level+1)
        else: # value is scalar
            print('  '*level, description, ':', value)

data = cbor2.loads(cose.payload)
annotate(data[-260][1], glb_schema['properties'])

Il risultato è finalmente decoroso:

 Vaccination Group
   Dose Number : 2
   Marketing Authorization Holder : ORG-100030215
   vaccine or prophylaxis : 1119349007
   ISO8601 complete date: Date of Vaccination : 2021-04-10
   Country of Vaccination : IT
   Unique Certificate Identifier: UVCI : 01ITE7300E1AB2A84C719004F103DCB1F70A#6
   vaccine medicinal product : EU/1/20/1528
   Certificate Issuer : IT
   Total Series of Doses : 2
   disease or agent targeted : 840539006
 Surname(s), forename(s)
   Standardised surname : DI<CAPRIO
   Surname : Di Caprio
   Standardised forename : MARILU<TERESA
   Forename : Marilù Teresa
 Schema version : 1.0.0
 Date of birth : 1977-06-16

Il passo successivo è trovare il modo di verificare se un Green Pass è vero o falso, verificando la firma digitale. Ma quelle cose lì mi fanno venire il mal di testa quindi le lascio al prossimo post.

Alla prossima!