Pass sanitaire

Le QR-code contient un fichier binaire encodé en base 45, précdé d'un en-tête HC1:.

Ce fichier est compressé au format zlib.

Sa décompression est une structure CBOR.

Le pass est rendu infalsifiable grâce à une signature numérique utilisant les courbes elliptiques.

Les modules base45, cbor2 et ecdsa sont à installer préalablement.

In [1]:
import base45, cbor2, zlib, json
from time import ctime
from base64 import *
import codecs
import ecdsa
In [4]:
# un exemple de données lues en flashant un QR-code
x = '''HC1:6BFOXN%TSMAHN-H3YS1IK47ES6IXJR4E47X5*T917VF+UOGIS1RYZV:X9:IMJZTCV4*XUA2PSGH.+H$NI4L6HUC%UG/YL WO*Z7ON13:LHNG7H8H%BFP8FG4T 9OKGUXI$NIUZUK*RIMI4UUIMI.J9WVHWVH+ZEOV1AT1HRI2UHD4TR/S09T./08H0AT1EYHEQMIE9WT0K3M9UVZSVV*001HW%8UE9.955B9-NT0 2$$0X4PCY0+-CVYCRMTB*05*9O%0HJP7NVDEBO584DKH78$ZJ*DJWP42W5P0QMO6C8PL353X7H1RU0P48PCA7T5MCH5:ZJ::AKU2UM97H98$QP3R8BH9LV3*O-+DV8QJHHY4I4GWU-LU7T9.V+ T%UNUWUG+M.1KG%VWE94%ALU47$71MFZJU*HFW.6$X50*MSYOJT1MR96/1Z%FV3O-0RW/Q.GMCQS%NE'''
In [5]:
x
Out[5]:
'HC1:6BFOXN%TSMAHN-H3YS1IK47ES6IXJR4E47X5*T917VF+UOGIS1RYZV:X9:IMJZTCV4*XUA2PSGH.+H$NI4L6HUC%UG/YL WO*Z7ON13:LHNG7H8H%BFP8FG4T 9OKGUXI$NIUZUK*RIMI4UUIMI.J9WVHWVH+ZEOV1AT1HRI2UHD4TR/S09T./08H0AT1EYHEQMIE9WT0K3M9UVZSVV*001HW%8UE9.955B9-NT0 2$$0X4PCY0+-CVYCRMTB*05*9O%0HJP7NVDEBO584DKH78$ZJ*DJWP42W5P0QMO6C8PL353X7H1RU0P48PCA7T5MCH5:ZJ::AKU2UM97H98$QP3R8BH9LV3*O-+DV8QJHHY4I4GWU-LU7T9.V+ T%UNUWUG+M.1KG%VWE94%ALU47$71MFZJU*HFW.6$X50*MSYOJT1MR96/1Z%FV3O-0RW/Q.GMCQS%NE'
In [6]:
len(set(x)) # il y a bien 45 caractères différents ...
Out[6]:
45
In [7]:
# Il faut retirer l'en-tête HC1:
base45.b45decode(x[4:])
Out[7]:
b'x\x9c\xbb\xd4\xe2\xbb\x88Q\x8d\xc5\xe3y\xa1_o\xfd\x8f\x8c\xd9\x0b"\x1e-aLq\xf6s\xf4e\x91J\xd5\xdc\xfb\x80M*\xb1\\\xeb\x9f%#\xf3B\xc6%\x89e\x8d\xab\x92\x923+dC\x83\xfc\xacB\xc3\x9c=\xad\x0c\x0c\xad\xdc\x82\xacBL]\xc2C\xbc"\x83M\xa2\x82,\x94M\x92\x92\xf3\x93\xdc\x82\x92R\xf2\x98\x92RJ\xb2\x8c\x0c\x8c\x0cu\r\rt\r\x0c\x932\x8b\xc1\xe6&\xe5&\xe6\xfa\x07\xb9\x03\x05\r\x0c\x8c\x81\xb2\xa6I\xb9\x059\xae\xa1\xfa\x86\xfaF\x06\xfa\x86\xa6F\x16I\xc5)LI%\xe9\x99\x16&\x06\xa6\xc6\x96\x06\x06fIe\x05\xe9^\x06\xe6N\x11\x06\xc6\xc9)\xf9IY\x86@A\xa0\x81@\x94\x9c\x97\x98\xbb$)-/\xcd\xc33\xc4\xc75()=/\xd5\xd1\xc5\xdf\xc7-9-\xaf\x04*\x96\x9c\x9eW\x02\x15,K-J5\xd43\xd63\x88p\xf8\xf1\xc4\xb6n\x06\xcf1\x89\x89\x8f8\xff\xf4\xadp\xe6\xbe\xfc\xa2\xeb\xe1\xbb[\xf3\x0c\xb5\x1e\x9e\x8b\xfb\xf5I\xc7U\xcc$\xff=\xe7z\x86\xf0\xc8y\xcb6\xf8-\x7f\xb4\xe1\xc3\xee\r\x15L\x06\x0f~}x\xbe~\xd5\xbc\xd5Y\xb1\x00\xe2\x1ar\xef'
In [8]:
data = _
In [9]:
zlib.decompress(data)
Out[9]:
b'\xd2\x84M\xa2\x01&\x04H\xe7qN\x8d\x7f\xf8h\x9b\xa0X\xe2\xa4\x01dCNAM\x04\x1ae)\xbd\xe0\x06\x1aaw*\xfe9\x01\x03\xa1\x01\xa4av\x81\xaabcix\x1dURN:UVCI:01:FR:T5DWTJYS4ZR8#4bcobFRbdn\x02bdtj2021-10-01bisdCNAMbmamORG-100030215bmplEU/1/20/1528bsd\x02btgi840539006bvpgJ07BX03cdobj1900-01-01cnam\xa4bfnfHITLERbgneADOLFcfntfHITLERcgnteADOLFcvere1.3.0X@\xf8\xe4=~\x98\x0c\xc6\x18\x91\xe2\t\xfc\x8e\xa8C\x0b\xd3\xe8\x8a\xe1\xee\xda\x9e1*\xe1\xce^\xfa\xf2,E\x164o\xef\t\xaf\x00WY\x9e\xa6\xb0N\xa7\xe2\xb0\xf0\xbb\xb0x\x020\xe0\xfa\xf0\xe7\xaf\xaa\x9e\xabj]'
In [10]:
# C'est une structure CBOR qu'il faut décoder
cb = _
In [11]:
cbor2.decoder.loads(cb)
Out[11]:
CBORTag(18, [b'\xa2\x01&\x04H\xe7qN\x8d\x7f\xf8h\x9b', {}, b'\xa4\x01dCNAM\x04\x1ae)\xbd\xe0\x06\x1aaw*\xfe9\x01\x03\xa1\x01\xa4av\x81\xaabcix\x1dURN:UVCI:01:FR:T5DWTJYS4ZR8#4bcobFRbdn\x02bdtj2021-10-01bisdCNAMbmamORG-100030215bmplEU/1/20/1528bsd\x02btgi840539006bvpgJ07BX03cdobj1900-01-01cnam\xa4bfnfHITLERbgneADOLFcfntfHITLERcgnteADOLFcvere1.3.0', b'\xf8\xe4=~\x98\x0c\xc6\x18\x91\xe2\t\xfc\x8e\xa8C\x0b\xd3\xe8\x8a\xe1\xee\xda\x9e1*\xe1\xce^\xfa\xf2,E\x164o\xef\t\xaf\x00WY\x9e\xa6\xb0N\xa7\xe2\xb0\xf0\xbb\xb0x\x020\xe0\xfa\xf0\xe7\xaf\xaa\x9e\xabj]'])

Le tag 18 indique un COSE Single Signer Data Object.

In [12]:
loads = _
In [13]:
loads.value
Out[13]:
[b'\xa2\x01&\x04H\xe7qN\x8d\x7f\xf8h\x9b',
 {},
 b'\xa4\x01dCNAM\x04\x1ae)\xbd\xe0\x06\x1aaw*\xfe9\x01\x03\xa1\x01\xa4av\x81\xaabcix\x1dURN:UVCI:01:FR:T5DWTJYS4ZR8#4bcobFRbdn\x02bdtj2021-10-01bisdCNAMbmamORG-100030215bmplEU/1/20/1528bsd\x02btgi840539006bvpgJ07BX03cdobj1900-01-01cnam\xa4bfnfHITLERbgneADOLFcfntfHITLERcgnteADOLFcvere1.3.0',
 b'\xf8\xe4=~\x98\x0c\xc6\x18\x91\xe2\t\xfc\x8e\xa8C\x0b\xd3\xe8\x8a\xe1\xee\xda\x9e1*\xe1\xce^\xfa\xf2,E\x164o\xef\t\xaf\x00WY\x9e\xa6\xb0N\xa7\xe2\xb0\xf0\xbb\xb0x\x020\xe0\xfa\xf0\xe7\xaf\xaa\x9e\xabj]']

Ce premier décodage produit une liste de 4 champs.

Le premier est un dictionnaire dont la clé 1 (valeur -7) indique l'algorithme cryptographique utilisé, et la clé 4 est une valeur binaire dont l'encodage en base 64 donne l'identifiant (kid) de la clé publique.

Le second champ est un dictionnaire inutilisé (vide).

Le troisième contient les informations du certificat, et le quatrième est une signature numérique.

In [14]:
# Ici, les données sont un peu étranges, le pass n'est certainement pas valide !
import pprint
for a in loads.value:
    try:
        pprint.pprint (cbor2.decoder.loads(a))
    except: print ('****', str(a))
{1: -7, 4: b'\xe7qN\x8d\x7f\xf8h\x9b'}
**** {}
{-260: {1: {'dob': '1900-01-01',
            'nam': {'fn': 'HITLER',
                    'fnt': 'HITLER',
                    'gn': 'ADOLF',
                    'gnt': 'ADOLF'},
            'v': [{'ci': 'URN:UVCI:01:FR:T5DWTJYS4ZR8#4',
                   'co': 'FR',
                   'dn': 2,
                   'dt': '2021-10-01',
                   'is': 'CNAM',
                   'ma': 'ORG-100030215',
                   'mp': 'EU/1/20/1528',
                   'sd': 2,
                   'tg': '840539006',
                   'vp': 'J07BX03'}],
            'ver': '1.3.0'}},
 1: 'CNAM',
 4: 1697234400,
 6: 1635199742}
CBORSimpleValue(value=228)
In [15]:
ctime(1697234400) # 4: est censé être la date d'émission (2023=erreur ?)
Out[15]:
'Sat Oct 14 00:00:00 2023'
In [16]:
ctime(1635199742) # 6: est censé être la date de fin de validité (erreur ?)
Out[16]:
'Tue Oct 26 00:09:02 2021'

D'après le site :

Le champ annoté -260 contient les faits à l'origine du certificat.

  • version du schéma de certificat utilisé (clé ver).
  • une structure nom (clé nam), également encodé comme en bas des cartes d'identité ou des passeports.
  • date de naissance (clé dob).
  • liste des vaccins (clé v).
  • Pour chaque acte de "vaccination", un identifiant unique appelé UVCI (clé ci),
  • pays émetteur (clé co).
  • nombre de doses reçues (clé sd)
  • nombre de doses nécessaires (clé dn),
  • date de la dernière dose (clé dt),
  • code du fabricant du vaccin (clé ma)
  • code produit administré (clé mp)
  • agent ciblé (clé tg — c'est la Covid-19), le type d'administration.
In [17]:
# le kid (key identifier)
b64encode(b'\xe7qN\x8d\x7f\xf8h\x9b')
Out[17]:
b'53FOjX/4aJs='
In [18]:
kid = _.decode('ascii')

Les clés publiques du système sont rassemblées dans un fichier json publiquement accessible.

In [19]:
kid
Out[19]:
'53FOjX/4aJs='
In [21]:
!grep -B 0 -C 16  53FOjX/4aJs= Digital_Green_Certificate_Signing_Keys.json
	"53FOjX/4aJs=": {
		"serialNumber": "6e3a243ea72c2bd04f6bd51d59ec497b212a8a5a",
		"subject": "C=FR, O=CNAM, OU=180035024, CN=DSC_FR_023",
		"issuer": "C=FR, O=Gouv, CN=CSCA-FRANCE",
		"notBefore": "2021-10-14T22:00:00.000Z",
		"notAfter": "2023-10-14T22:00:00.000Z",
		"signatureAlgorithm": "RSASSA-PKCS1-v1_5",
		"fingerprint": "be552cc91a5fe934268b9db169db6349311ad693",
		"publicKeyAlgorithm": {
			"hash": {
				"name": "SHA-256"
			},
			"name": "ECDSA",
			"namedCurve": "P-256"
		},
		"publicKeyPem": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgu/WJBn1Q+RCOfQx3NLT5oIGUCHsqSRXuu7EZsqfqZN5PvHk6/E++88wvj2fMrfmAptk5tVld2xBH4P4tRs8JQ=="
	},
In [22]:
kid = b64encode(cbor2.decoder.loads(loads.value[0])[4]).decode('ascii')
kid
Out[22]:
'53FOjX/4aJs='
In [23]:
with open('Digital_Green_Certificate_Signing_Keys.json') as f:
    keys = json.load(f)
    keypem = keys[kid]['publicKeyPem']

keypem
Out[23]:
'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgu/WJBn1Q+RCOfQx3NLT5oIGUCHsqSRXuu7EZsqfqZN5PvHk6/E++88wvj2fMrfmAptk5tVld2xBH4P4tRs8JQ=='

Le module ecdsa (Elliptic Curve Digital Signature Algorithm) permet de vérifier la signature (à condition de savoir comment le message est composé). Il faut préciser la fonction de hachage (sha256 ici).

In [24]:
ecdsa.VerifyingKey.from_pem(keypem, hashfunc=ecdsa.util.sha256)
Out[24]:
VerifyingKey.from_string(b'\x03\x82\xef\xd6$\x19\xf5C\xe4B9\xf41\xdc\xd2\xd3\xe6\x82\x06P!\xec\xa9$W\xba\xee\xc4f\xca\x9f\xa9\x93', NIST256p, sha256)
In [25]:
vk = _
In [26]:
# On récupère la signature
sg = loads.value[3]
sg
Out[26]:
b'\xf8\xe4=~\x98\x0c\xc6\x18\x91\xe2\t\xfc\x8e\xa8C\x0b\xd3\xe8\x8a\xe1\xee\xda\x9e1*\xe1\xce^\xfa\xf2,E\x164o\xef\t\xaf\x00WY\x9e\xa6\xb0N\xa7\xe2\xb0\xf0\xbb\xb0x\x020\xe0\xfa\xf0\xe7\xaf\xaa\x9e\xabj]'
In [27]:
len(sg)
Out[27]:
64

Le message à signer est un tableau CBOR composé de la chaîne "Signature1", du champ 0, une chaîne vide, et le champ 2 (données) de la structure originale.

In [28]:
info  = loads.value[2]
xx  = [ "Signature1", loads.value[0], b'', info]
msg = cbor2.encoder.dumps(xx)
In [29]:
xx
Out[29]:
['Signature1',
 b'\xa2\x01&\x04H\xe7qN\x8d\x7f\xf8h\x9b',
 b'',
 b'\xa4\x01dCNAM\x04\x1ae)\xbd\xe0\x06\x1aaw*\xfe9\x01\x03\xa1\x01\xa4av\x81\xaabcix\x1dURN:UVCI:01:FR:T5DWTJYS4ZR8#4bcobFRbdn\x02bdtj2021-10-01bisdCNAMbmamORG-100030215bmplEU/1/20/1528bsd\x02btgi840539006bvpgJ07BX03cdobj1900-01-01cnam\xa4bfnfHITLERbgneADOLFcfntfHITLERcgnteADOLFcvere1.3.0']
In [30]:
cbor2.encoder.dumps(xx)
Out[30]:
b'\x84jSignature1M\xa2\x01&\x04H\xe7qN\x8d\x7f\xf8h\x9b@X\xe2\xa4\x01dCNAM\x04\x1ae)\xbd\xe0\x06\x1aaw*\xfe9\x01\x03\xa1\x01\xa4av\x81\xaabcix\x1dURN:UVCI:01:FR:T5DWTJYS4ZR8#4bcobFRbdn\x02bdtj2021-10-01bisdCNAMbmamORG-100030215bmplEU/1/20/1528bsd\x02btgi840539006bvpgJ07BX03cdobj1900-01-01cnam\xa4bfnfHITLERbgneADOLFcfntfHITLERcgnteADOLFcvere1.3.0'
In [31]:
msg=_
In [32]:
vk.verify(sg,msg)
Out[32]:
True

La signature est valide ! L'infalsifiable a été falsifié !

Que s'est il passé ?

L'enquête est en cours, voir cet article.

In [ ]: