commit cfbba3171167287279b06a37b25b0d745f8b1fed Author: Yohann D'ANELLO Date: Sun Aug 8 14:43:02 2021 +0200 Version fonctionnelle Signed-off-by: Yohann D'ANELLO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b247de --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +*.pyc +*.txt diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..5bc688c --- /dev/null +++ b/README.rst @@ -0,0 +1,64 @@ +Vérification de pass sanitaire +============================== + +Ce script permet de contrôler la validité d'un pass sanitaire +européen, tel que le fait l'application TAC-Vérif lors de +la crise sanitaire de Covid-19 en 2021. + + +Usage +----- + +.. code:: bash + + ./main.py [file] [--full] [--dontcheck] + +Le script prend en entrée un QR code au format textuel, et affiche l'état +de validité du pass sanitaire. + +Le QR code doit être donné dans sa version textuelle. +Il peut être combiné avec des outils tels que ``zbar``. +Si vous disposez d'une webcam : + +.. code:: bash + + zbarcam -1 --raw | ./main.py + +Ou en cas de QR code sur une image : + +.. code:: bash + + zbarimg -1 --raw [file] | ./main.py + + +En mode normal, seuls le nom, le prénom et la date de naissance sont +affichés, sous réserve de pass valide. Avec l'option ``--full``, +les informations de vaccination ou de test sont affichées. + +Enfin, l'option ``--dontcheck`` saute la vérification de la signature. +Cela peut être utile si vous ne disposez pas des certificats de signature. + + +Vérification de signature +------------------------- + +Les certificats de signature n'étant pas publics, ils ne sont pas partagés +avec le code. De fait, si vous ne les possédez pas, vous ne pourrez pas +vérifier les signatures, et vous devrez utiliser l'option ``--dontcheck``. + +Pour installer des certificats, placez-les dans le dossier ``certs`` avec +pour nom ``{{{KID}}.pem``, où ``{{KID}}`` est l'identifiant du certificat. +Si ``{{KID}}`` contient un ``/``, remplacez-le par un ``_``. + + +Modalités d'utilisation +----------------------- + +Ce module peut être utilisé à des fins privés afin de mieux comprendre +la structure d'un pass sanitaire et des données stockées, mais ne peut pas +être utilisé pour contrôle tel que la loi n° 2021-1040 le précise. + +Merci d'utiliser l'application officielle « TousAntiCovid - Vérif » sur +téléphone pour une utilisation normale de vérification de pass sanitaire, +si vous êtes autorisé⋅e à l'utiliser. + diff --git a/main.py b/main.py new file mode 100755 index 0000000..1ccd861 --- /dev/null +++ b/main.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python + +from pscheck import pscheck + + +if __name__ == '__main__': + pscheck.main() diff --git a/pscheck/__init__.py b/pscheck/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pscheck/base45.py b/pscheck/base45.py new file mode 100644 index 0000000..7671568 --- /dev/null +++ b/pscheck/base45.py @@ -0,0 +1,61 @@ +""" +BSD 2-Clause License + +Copyright (c) 2021, Kirei AB +All rights reserved. +""" + + +from typing import Union + + +BASE45_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" +BASE45_DICT = {v: i for i, v in enumerate(BASE45_CHARSET)} + + +def b45encode(buf: bytes) -> bytes: + """Convert bytes to base45-encoded string""" + res = "" + buflen = len(buf) + for i in range(0, buflen & ~1, 2): + x = (buf[i] << 8) + buf[i + 1] + e, x = divmod(x, 45 * 45) + d, c = divmod(x, 45) + res += BASE45_CHARSET[c] + BASE45_CHARSET[d] + BASE45_CHARSET[e] + if buflen & 1: + d, c = divmod(buf[-1], 45) + res += BASE45_CHARSET[c] + BASE45_CHARSET[d] + return res.encode() + + +def b45decode(s: Union[bytes, str]) -> bytes: + """Decode base45-encoded string to bytes""" + try: + if isinstance(s, str): + buf = [BASE45_DICT[c] for c in s.strip()] + elif isinstance(s, bytes): + buf = [BASE45_DICT[c] for c in s.decode()] + else: + raise TypeError("Type must be 'str' or 'bytes'") + + buflen = len(buf) + if buflen % 3 == 1: + raise ValueError("Invalid base45 string") + + res = [] + for i in range(0, buflen, 3): + if buflen - i >= 3: + x = buf[i] + buf[i + 1] * 45 + buf[i + 2] * 45 * 45 + if x > 0xFFFF: + raise ValueError + res.extend(divmod(x, 256)) + else: + x = buf[i] + buf[i + 1] * 45 + if x > 0xFF: + raise ValueError + res.append(x) + return bytes(res) + except (ValueError, KeyError, AttributeError): + raise ValueError("Invalid base45 string") + + diff --git a/pscheck/pscheck.py b/pscheck/pscheck.py new file mode 100644 index 0000000..e64edfb --- /dev/null +++ b/pscheck/pscheck.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python + +import argparse +import base64 +import cbor2 +from cryptography import x509 +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import \ + encode_dss_signature +import json +import os +import sys +from textwrap import wrap +import zlib + +from . import base45 + + +def read_qrcode(file) -> str: + """ + Lit le contenu du fichier. + Peut être combiné avec zbar pour directement lire le contenu du QRCode. + """ + with file: + content = file.read() + content = content.replace('\n', '') + return content + + +def analyse_qrcode(qrcode: str, additional_info: bool = False, + check_signature: bool = True) -> None: + """ + Analyse les données du QR code pour extraire les données + et vérifier la signature. + + Si `additional_info` est vrai, les informations de + vaccination/test sont affichées. + """ + + if not qrcode.startswith('HC1:'): + raise ValueError("QR code invalide.") + + plain_input = qrcode.replace('HC1:', '') + + # QR Code en base 45 + compressed_cose = base45.b45decode(plain_input) + + # Décompression ZIP si nécessaire + cose = compressed_cose + if compressed_cose[0] == 0x78: + fb = compressed_cose[1] + if fb == 0x01 or fb == 0x5E or fb == 0x9C or fb == 0xDA: + cose = zlib.decompress(compressed_cose) + + # Chargement de l'objet CBOR + obj = cbor2.loads(cose) + header = cbor2.loads(obj.value[0]) + payload = cbor2.loads(obj.value[2]) + signature = obj.value[3] + + if check_signature: + # Récupération du certificat utilisé + kid = base64.b64encode(header[4]).decode() + cert_name = kid.replace('/', '_') + + base_dir = os.path.dirname(__file__) + cert_path = os.path.join(base_dir, 'certs', f"{cert_name}.pem") + if not os.path.isfile(cert_path): + print(f"Le certificat {kid} n'a pas été trouvé.") + print("Utilisez l'option --dontcheck pour sauter " + "la vérification.") + print("Si vous disposez du certificat, installez-le dans", + cert_path) + exit(1) + with open(os.path.join(base_dir, 'certs', f"{cert_name}.pem")) as f: + cert_asc = f.read() + + cert = x509.load_pem_x509_certificate(cert_asc.encode()) + + public_key = cert.public_key() + + # Calcul de la bonne signature et des données signées + data = cbor2.dumps( + ["Signature1", cbor2.dumps(header), + bytes(), cbor2.dumps(payload)] + ) + + l = len(signature) + r = int.from_bytes(signature[:l // 2], 'big') + s = int.from_bytes(signature[l // 2:], 'big') + signature = encode_dss_signature(r, s) + try: + # Vérification de la signature + public_key.verify(signature, data, + ec.ECDSA(cert.signature_hash_algorithm)) + valid = True + except: + valid = False + else: + valid = True + print("Attention : la signature du QR code n'a pas été vérifiée.") + + # TODO: Vérifier les données d'un test + + if valid: + print("Pass sanitaire valide") + + # Information utile + p = payload[-260][1] + + print("Nom :", p['nam']['fn']) + print("Prénom :", p['nam']['gn']) + print("Date de naissance :", p['dob']) + if additional_info: + if 'v' in p: + # Vaccination + # TODO Meilleur affichage + print("Informations de vaccination :", p['v']) + else: + # TODO Gérer les tests positifs / négatifs + print("Informations de test :", p) + else: + print("Pass sanitaire invalide") + + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument('file', nargs='?', type=argparse.FileType('r'), + default=sys.stdin, + help="QR Code à lire, en format texte.") + parser.add_argument('--full', '-f', action='store_true', + help="Affiche toutes les informations.") + parser.add_argument('--dontcheck', action='store_true', + help="Ne pas vérifier la signature.") + + args = parser.parse_args() + + qrcode = read_qrcode(args.file) + analyse_qrcode(qrcode, args.full, not args.dontcheck)