Impfpass ohne App, Apple und Google

Morgen werden zwei Wochen seit meiner zweiten Corona-Impfung vergangen sein. Damit wird die Impfpassfrage für mich relevant: Mit so einem Ding könnte ich wieder in die Mensa gehen!

Allerdings habe ich, soweit ich das sehe, keinen Zugang zur offiziellen Covpass-App, weil ich mich von Apples Appstore und Googles Playstore fernhalte und eigentlich auch die Toolchains der beiden nicht auf meinem Rechner haben will. Immerhin gibt es (es lebe der Datenschutz-Aktivismus, der für offene Entwicklung von dem Kram gesorgt hat) die Quellen der Apps (wenn auch leider auf github). Wie kompliziert kann es schon sein, das ohne den ganzen proprietären Zauber nachzubauen?

Stellt sich raus: schon etwas, aber es ist ein wenig wie eine Kurzgeschichte von H.P. Lovecraft: Die Story entwickelt sich wie beim Schälen einer Zwiebel. Schale um Schale zeigt sich, dass die Wahrheit immer noch tiefer liegt.

Bei Lovecraft sind nach dem Abpulen der letzten Schale meist alle ProtagonistInnen tot oder wahnsinnig. Im Fall der Covpass-App hingegen ist der Kram sogar dokumentiert: So finden sich die halbwegs lesbare Dokumentation des Datenstroms im QR-Code und das JSON-Schema – leider schon wieder auf github.

Schale 1: QR-Code scannen

Ich dachte mir, zum Nachbauen der Covpass-App sollte ich erstmal ihre Eingabe verstehen, also die Daten aus den beiden Impfzertifikaten lesbar darstellen. Der erste Schritt dazu ist etwas, das QR-Codes lesen kann. Ich hatte anderweitig schon mit zbar gespielt, für das es das Debian-Paket python3-zbar gibt. Damit (und mit der unverwüstlichen Python Imaging Library aus python3-pillow) geht das so, wenn das Foto mit dem Zertifikat in der Datei foto.jpeg liegt:

def get_single_qr_payload(img):
  img = img.convert("L")

  scanner = zbar.ImageScanner()
  scanner.parse_config('enable')
  image = zbar.Image(img.size[0], img.size[1], 'Y800', data=img.tobytes())
  if not scanner.scan(image):
    raise ValueError("No QR code found")

  decoded = list(image)
  if len(decoded)>1:
    raise ValueError("Multiple QR codes found")

  return decoded[0].data

encoded_cert = get_single_qr_payload(Image.open("foto.jpeg"))

Im Groben wandele ich in der Funktion das Bild (das wahrscheinlich in Farbe sein wird) in Graustufen, denn nur damit kommt zbar zurecht. Dann baue ich eine Scanner-Instanz, also das Ding, das nachher in Bildern nach QR-Codes sucht. Die API hier ist nicht besonders pythonesk, und ich habe längst vergessen, was parse_config('enable') eigentlich tut – egal, das ist gut abgehangene Software mit einem C-Kern, da motze ich nicht, noch nicht mal über diesen fourcc-Unsinn, mit dem vor allem im Umfeld von MPEG allerlei Medienformate bezeichnet werden; bei der Konstruktion des zbar.Image heißt „8 bit-Graustufen“ drum "Y800". Na ja.

Der Rest der Funktion ist dann nur etwas Robustheit und wirft ValueErrors, wenn im Foto nicht genau ein QR-Code gefunden wurde. Auch hier ist die zbar-API vielleicht nicht ganz preiswürdig schön, aber nach dem Scan kann mensch über zbar.Image iterieren, was die verschiedenen gefundenen Barcodes zurückgibt, zusammen mit (aus meiner Sicht eher knappen) Metadaten. Das .data-Attribut ist der gelesene Kram, hier als richtiger String (im Gegensatz zu bytes, was ich hier nach der python3-Migration eher erwartet hätte).

Schale 2: base45-Kodierung

Das Ergebnis sieht nach üblichem in ASCII übersetzten Binärkram aus. Bei mir fängt der etwa (ich habe etwas manipuliert, das dürfte so also nicht dekodieren) so an: HC1:6B-ORN*TS0BI$ZDFRH%. Insgesamt sind das fast 600 Zeichen.

Als ich im Wikipedia-Artikel zum Digitalen Impfnachweis gelesen habe, das seien base45-kodierte Daten, habe ich erst an einen Tippfehler gedacht und es mit base85 versucht, das es in Pythons base64-Modul gibt. Aber nein, weit gefehlt, das wird nichts. War eigentlich klar: die Wahrscheinlichkeit, dass was halbwegs Zufälliges base85-kodiert keine Kleinbuchstaben enthält, ist echt überschaubar. Und base45 gibts wirklich, seit erstem Juli in einem neuen RFC-Entwurf, der sich explizit auf QR-Codes bezieht. Hintergrund ist, dass der QR-Standard eine Kodierungsform (0010, alphanumeric mode) vorsieht, die immer zwei Zeichen in 11 bit packt und dafür nur (lateinische) Großbuchstaben, Ziffern und ein paar Sonderzeichen kann. Extra dafür ist base45 erfunden worden. Fragt mich bloß nicht, warum die Leute nicht einfach binäre QR-Codes verwenden.

Es gibt bereits ein Python-Modul für base45, aber das ist noch nicht in Debian bullseye, und so habe ich mir den Spaß gemacht, selbst einen Dekodierer zu schreiben. Technisch baue ich das als aufrufbares (also mit einer __call__-Methode) Objekt, weil ich die nötigen Tabellen aus dem globalen Namensraum des Skripts draußenhalten wollte. Das ist natürlich ein Problem, das verschwindet, wenn sowas korrekt in ein eigenes Modul geht.

Aber so gehts eben auch:

class _B45Decoder:
  chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
  code_for_char = dict((c, i) for i, c in enumerate(chars))
  sig_map = [1, 45, 45*45]

  def __call__(self, str):
    raw_bytes = [self.code_for_char[s] for s in str]
    cooked_bytes = []

    for offset in range(0, len(raw_bytes), 3):
      next_raw = raw_bytes[offset:offset+3]
      next_bytes = sum(w*v for w,v in
        zip(self.sig_map, next_raw))
      if len(next_raw)>2:
        cooked_bytes.append(next_bytes//256)
      cooked_bytes.append(next_bytes%256)

    return bytes(cooked_bytes)

b45decode = _B45Decoder()

Die b45decode-Funktion ist, wenn mensch so will, ein Singleton-Objekt der _B45Decoder-Klasse (so hätten das jedenfalls die IBMlerInnen der CovPass-App beschrieben, vgl. unten). Ansonsten bildet das den Grundgedanken von base45 ziemlich direkt ab: Immer drei Bytes werden zur Basis 45 interpretiert, wobei die Wertigkeit der verschiedenen Zeichen im Dictionary code_for_char liegt. Die resultierende Zahl lässt sich in zwei dekodierte Bytes aufteilen. Nur am Ende vom Bytestrom muss mensch aufpassen: Wenn die dekodierte Länge ungerade ist, stehen kodiert nur zwei Byte.

Und ja: vielleicht wärs hübscher mit dem grouper-Rezept aus der itertools-Doku, aber die next_raw-Zuweisung schien mir klar genug.

Schale 3: zlib

Ein b45decode(encoded_cert) gibt erwartungsgemäß wilden Binärkram aus, etwas wie b'x\x9c\xbb\xd4\xe2\xbc\x90Qm!… Das sind, ganz wie die Wikipedia versprochen hat, zlib-komprimierte Daten. Ein zlib.decompress wirkt und führt auf etwas, in dem zum ersten Mal irgendetwas zu erkennen ist; neben viel Binärkram findet sich etwa:

...2bdtj2021-07-01bistRobert Koch-InstitutbmamOR...

Keine Frage: Ich mache Fortschritte.

Schale 4: CBOR/COSE

An dieser Stelle bin ich auf mir unbekanntes Terrain vorgestoßen: CBOR, die Concise Binary Object Representation (Nerd-Humor: erfunden hat das Carsten Bormann, weshalb ich auch sicher bin, dass das Akronym vor der ausgeschriebenen Form kam) alias RFC 7049. Gedacht ist das als so eine Art binäres JSON; wo es auf die Größe so ankommt wie bei QR-Codes, habe ich schon Verständnis für diese Sorte Sparsamkeit. Dennoch: Sind Protocol Buffers eigentlich tot?

Praktischerweise gibt es in bullseye das Paket python3-cbor2. Munter habe ich cbor2.loads(raw_payload) geschrieben und war doch etwas enttäuscht, als ich etwas wie:

CBORTag(18, [b'\xa1\x01&',
  {4: b'^EVf\xa5\x1exW'},
  b'\xa4\x01bDE...',
  b'\xf9\xe9\x9f...'])

zurückbekommen habe. Das ist deutlich mehr viel Binärrauschen als ich erhofft hatte. Aber richtig, das Zeug sollte ja signiert sein, damit die Leute sich ihre Impf- und Testzertifikate nicht einfach selbst schreiben. Die hier genutzte Norm heißt COSE (nämlich CBOR Object Signing and Encryption, RFC 8152 aus dem Jahr 2017). Ich habe das nicht wirklich gelesen, aber Abschnitt 2 verrät gleich mal, dass, wer an einer Signaturprüfung nicht interessiert ist (und das bin ich nicht, solange ich nicht vermuten muss, dass meine Apotheke mich betrogen hat), einfach nur aufs dritte Arrayelement schauen muss. Befriedigenderweise ist das auch das längste Element.

Ein wenig neugierig war ich aber schon, was da noch so drinsteht. Die 18 aus dem CBORTag heißt nach Abschnitt 4.2, dass das eine Nachricht mit nur einer Unterschrift ist, und der letzte Binärkram ist eben diese eine Unterschrift. Das erste Array-Element sind Header, die mit unterschrieben werden, wieder CBOR-kodiert. Dekodiert ist das {1: -7}, und Überfliegen der COSE-Spezifikation (Tabellen 2 und 5) schlägt vor, dass das heißt: der Kram ist per ECDSA mit einem SHA-256-Hash unterschrieben.

Tabelle 2 von COSE erklärt auch das nächste Element, die Header, die nicht unterschrieben werden (und über die mensch also Kram in die Nachrichten einfummeln könnte). Das sieht auch erstmal binär aus, ist aber ein „entpacktes“ Dictionary: In meiner Nachricht steht da nur ein Header 4, was der „Key Identifier“ ist. Der Binärkram ist schlicht eine 64-bit-Zahl, die angibt, mit welchem Schlüssel die Unterschrift gemacht wurde (bei PGP wären das die letzten 8 byte des Fingerabdrucks, viel anders wird das bei COSE auch nicht sein).

Schale 5: CBOR lesbar machen

Zurück zu den Zwiebelschalen. Wenn also die Nutzdaten im dritten Element des Array von oben sind, sage ich:

cbor_payload = cbor2.loads(cose_payload.value[2])

Heraus kommt dabei etwas wie:

{1: 'DE', 4: 1657705736, 6: 1626169736,
-260: {1: {'v': [{'co': 'DE', 'dn': 2, 'dt': '2021-07-01',
'is': 'Robert Koch-Institut', 'ma': 'ORG-100030215',
'mp': 'EU/1/20/1528', 'sd': 2, 'tg': '840539006',...}]}}}

– das ist ziemlich offensichtlich die Datenstruktur, die der Zauber liefern sollte. Nur sind die Schlüssel wirklich unklar. v könnte wohl „Vaccination“ sein, is der Issuer, also der Herausgeber des Impfpasses; die Werte von 4 und 6 sehen verdächtig nach Unix-Timestamps in der nächsten Zeit aus (ja, es sind schon sowas wie 1,6 Milliarden Sekunden vergangen seit dem 1.1.1970).

Aber Raten ist doof, wenn es Doku gibt. Wie komme ich also zu lesbaren Schlüsseln?

Die Schlüssel 1, 4, 6 und -260 im äußeren Dictionary sind in der Dokumentation des Datenstroms im QR-Code erklärt und bedeuten jeweils das Herkunftsland des Zertifikats, wann das Ding abläuft, wann es herausgegeben wurde, und was eigentlich zertifiziert wird.

Die Schlüssel im Zertifikat hingegen sind im oben erwähnten JSON-Schema enthalten und bekommen dort Titel und/oder Beschreibungen. Beim Rumsuchen, ob es für die Annotation solcher CBOR-Strukturen mit JSON-Schemata schon bequemen Code gibt, bin ich auf einen Post von Tobias Girstmair gestoßen, der tatsächlich schon gemacht hat, was ich wollte, nämlich all die Schalen um das QR-Impfzertifikat abgezwiebelt.

Aber na ja. Ich mochte meinen Code lieber als seinen (immerhin schlägt der ein Mal pro Prorammstart bei, ach, github auf) und drum habe ihn doch fertiggeschrieben. Anders als Tobias mache ich die Annotation auch direkt in den Schlüsseln, so dass Pythons pprint die Formatierung der Ausgabe für mich erledigen kann. Der entsprechende Code ist nicht so arg spannend. Wer ihn sehen will, möge im fertigen Programm nach der Annotator-Klasse suchen.

Dieses fertige Programm könnt ihr einfach als decode-cert.py haben (Disclaimer: ich habe mir die NutzerInnenschnittstelle dabei gespart). Wenn ihr ein Foto eures QR-Codes habt, könnt ihr auf einem geeigneten Debian-System (also beispielsweise bullseye):

sudo apt install python3-zbar python3-pillow python3-cbar2
python3 decode-cert.py foto.jpeg

laufen lassen und bekommt dann eine Ausgabe wie:

{'Date of birth': 'SEUFZ"
 'Schema version': '1.3.0',
 'Surname(s), forename(s) ': {'Forename': 'Anselm'
     'Standardised forename': 'ANSELM<HIERONYMUS',
     'Standardised surname': 'DR<<FLUEGEL',
     'Surname': 'Dr. Flügel'},
 'Vaccination Group': [{'Certificate Issuer': 'Robert Koch-Institut',
     'Country of Vaccination': 'DE',
     'Dose Number': 2,
     'ISO8601 complete date: Date of Vaccination': '2021-07-01',
     'Marketing Authorization Holder ': 'ORG-100030215',
     'Total Series of Doses': 2,
     'Unique Certificate Identifier: UVCI': 'URN:UVCI:NONE-OF-YOUR-BUSINESS'
     'disease or agent targeted': '840539006',
     'vaccine medicinal product': 'EU/1/20/1528',
     'vaccine or prophylaxis': '1119349007'}]}

(das ist natürlich nicht ganz das, was in meinem Impfzertifikat steht); und das ist, was jedeR speichern könnte, der/die den QR-Code scannt. Ich finde es ehrlich gesagt ganz ok, dass da keine Rede von Wohnorten und sowas ist.

Der Zwiebel Kern: Leer

All das war eigentlich nur Vorbereitung: Ich wollte die Struktur der Impfzertifikate verstehen, weil ich (warum auch immer) überzeugt war, die Covpass-App würde mit irgendeiner Magie verschiedene Impfzertifikate zusammenkopieren und sich das dann irgendwo signieren lassen, um irgendwie einen kompletten, na ja, „Ausweis“ zu bekommen. Im Papier-Impfpass steht ja auch die ganze Impfgeschichte, und der Gedanke liegt vielleicht auch deshalb nicht fern, weil die Vaccination Group im Zertifikat ein Array ist.

Ich habe mir also die Quellen der Apps geclont und mich auf die Suche nach den entsprechenden Operationen gemacht.

Das README der Android-Version war in dieser Hinsicht völlig unergiebig; es stellte sich als eine Art Besinnungsaufsatz zu Software Engineering von IBM-MitarbeiterInnen heraus. Ein paar Ausschnitte:

Note: We explicitly avoid using flavors because they are problematic in many ways. They cause unnecessary Gradle scripts complexity for even non-trivial customizations, they interact badly with module substitution, the variant switcher doesn't work properly in all situations […]

The architecture in this project is based on our experience with significantly larger projects at IBM. At a certain project size, you start to see which patterns break down and turn into bug sources and time wasters. […]

Important architectural concerns and pitfalls that have to be taken care of in the whole codebase are abstracted away instead of plastering the code with error-prone copy-paste logic. […]

What is a DI [Dependency Injection] framework doing, anyway?

  • It's (internally) defining a global variable that holds the dependencies. => That's just a global variable.
  • It provides lazy singletons. => That's just by lazy.
  • It provides factories. => That's just a function.
  • It provides scoped dependencies. => That's just a factory function taking e.g. a Fragment.

[…] Some of the modules in this repo were taken from our internal IBM projects. The code-based navigation system is one of them. Among the IBM developers who have worked with Android's Navigation component the experience was more on the negative side.

Und so fort.

Der Kram hat auch wirklich eine beeindruckende Größe: in der Android-Version 177 Dateien mit Kotlin (also irgendeiner Sorte Quellcode) in 12259 Zeilen, Java-inspiriert in sehr tiefen Verzeichnishierarchien (der Fairness halber: da ist auch gleich die App zur Zertifikatsprüfung dabei). Von meinem vermuteten Code fürs zusammenkopieren von Zertifikaten aber keine Spur.

Mit jeder Menge Selbstzweifeln habe anschließend auch die iOS-Version angeschaut; als Außenseiter, der mit der ganzen Welt proprietärer Telefonbetriebssysteme nichts zu tun hat, schien mir die übrigens lesbarer[1]. Aber auch dort: keine Spur von Zusammenkopier-Operationen, vom Hochladen von Zertifikaten, die vielleicht serverseitig summarisch beglaubigt werden oder was immer.

Ein Anruf bei Leuten, die den Kram auf ihren Telefonen haben, bestätigt: Was die Covpass-App anzeigt, ist einfach nur der QR-Code der zweiten Impfung. All die vielen Klassen sind eigentlich nur eine Implementation unserer fünf Schalen (vermutlich zur Erzeugung eines Menüs), dazu eine COSE-Prüfung und ein Bildbetrachter. Kein Wunder, dass die IBM-Leute sich im README an Erklärungen für die große Menge Code versuchen.

Weil mir die COSE-Prüfung wie gesagt nicht wichtig ist, reicht für mich als CovPass-Ersatz also ein Programm, das den QR-Code scannt, dann sauber und ohne Foto-Artefakte neu generiert und das dann schließlich anzeigt. Ganz ohne Apple und Google gibts hier also: covpass.py.

Schiebt die Datei irgendwo in euren Pfad, sagt chmod +x covpass.py und sudo apt install python3-pillow python3-zbar python3-qrcode. Dann könnt ihr Folgendes tun:

  1. covpass.py cert-photo.jpeg – parst den QR-Code aus einem Foto des Papier-Zertifikats (das zweite reicht wie gesagt) und erzeugt ihn neu als ordentliches, rauschfeies PNG, und zwar in der Datei .current-covpass.png in eurem Home.
  2. covpass.py ohne Argument zeigt den zuletzt generierten QR-Code an (mit PILs .show()-Methode, was schon verbesserungsfähig wäre, es aber für mich tut).

Natürlich könnt ihr auch einfach feh -FZ ~/.current-covpass.png auf eine geeignete Tastenkombination legen. Oder das Bild auf euer Telefon legen. Oder platt ausdrucken, um das Papier von der Apotheke zu schonen.

Wenn da noch wer die COSE-Prüfung reinbauen mag: Ich nehme gerne Patches, aber ein schnelles apt search deutet darauf hin, dass es da nichts paketiert gibt, und irgendeine Pipware hätte ich nicht gern als Abhängigkeit.

[1]Wenn ich mal von der üblen Unsitte absehe, Leerzeichen in Dateinamen zu packen. find -print0 nervt, ohne dass irgendwer was davon hat.

Zitiert in: Die Intensiv-Antwort

Category: edv