Jeder Reisepass hat unten auf der Datenseite zwei Zeilen grimmig aussehenden Text: Großbuchstaben, Ziffern und jede Menge <-Zeichen. Das ist die maschinenlesbare Zone (Machine Readable Zone, MRZ), definiert in ICAO Doc 9303, dem Standard für Reisedokumente. Sie enthält dieselben Kerndaten wie die bedruckte Seite (Name, Dokumentennummer, Staatsangehörigkeit, Geburtsdatum, Geschlecht, Ablaufdatum), und zwar in einer Form, die ein Scanner lesen kann, ohne Schriftarten oder Layouts erraten zu müssen.
Außerdem bringt sie ihre eigene Fehlererkennung mit. Einige Zeichen sind Prüfziffern: Jede wird aus einem bestimmten Feld berechnet, und eine letzte Ziffer deckt mehrere Felder zugleich ab. Wird ein einzelnes Zeichen falsch gelesen, passt die Ziffer, die es schützt, in der Regel nicht mehr. Damit ist die MRZ eines der wenigen Dinge bei der Verarbeitung von Ausweisdokumenten, die Sie selbst prüfen können – mit zwanzig Zeilen Code und ohne irgendeinem OCR vertrauen zu müssen.
Dieser Beitrag erklärt den Algorithmus, zeigt, wo die Ziffern in jedem der drei MRZ-Formate stehen, und liefert einen Validator in Python und JavaScript, den Sie in ein Projekt übernehmen können. Alle Beispiele verwenden das fiktive Musterdokument der ICAO selbst: Anna Maria Eriksson aus „Utopia“ (UTO, ein Ländercode, den es nur auf Mustern gibt). Ein echtes Dokument kommt nirgends vor.
Dies ist der Blog von doc.cheap, einer API zur Dokumentenerkennung, die die MRZ liest und diese Ziffern auf dem Server erneut prüft. Für alles Folgende brauchen Sie sie nicht; der Code läuft offline.
Das Alphabet
Eine MRZ verwendet genau 37 Zeichen: 0-9, A-Z und das Füllzeichen <. Es gibt keine Kleinbuchstaben, keine Leerzeichen und keine Satzzeichen. Namen mit Akzenten oder in nicht lateinischer Schrift werden transliteriert, und Leerzeichen innerhalb eines Felds werden zu <. Das Füllzeichen füllt außerdem jedes Feld auf seine feste Breite auf. ERIKSSON<<ANNA<MARIA<<<<<<< bedeutet also „Nachname ERIKSSON, Vornamen ANNA MARIA“, wobei das doppelte << den Nachnamen von den Vornamen trennt.
Der Algorithmus: Gewichte 7, 3, 1
Eine Prüfziffer wird für jedes Feld jedes Formats gleich berechnet:
- Jedes Zeichen in eine Zahl umwandeln. Eine Ziffer behält ihren Wert. Ein Buchstabe erhält seine Position im Alphabet plus 9, also
A= 10,B= 11, …Z= 35. Das Füllzeichen<ist 0. - Mit einem sich wiederholenden Gewicht multiplizieren, 7, 3, 1, 7, 3, 1, …, beginnend beim ersten Zeichen des Felds.
- Die Produkte addieren und den Rest modulo 10 nehmen. Diese eine Ziffer ist die Prüfziffer.
Durchgerechnet an der Passnummer des Musters, L898902C3, deren gedruckte Prüfziffer 6 ist:
character L 8 9 8 9 0 2 C 3
value 21 8 9 8 9 0 2 12 3
weight 7 3 1 7 3 1 7 3 1
product 147 24 9 56 27 0 14 36 3
sum = 316 316 mod 10 = 6 the zone prints 6
Warum 7-3-1? Die Gewichte sind so gewählt, dass die häufigsten Lesefehler die Summe verändern: ein einzelnes falsches Zeichen und viele Vertauschungen zweier benachbarter Zeichen. Eine kryptografische Prüfsumme ist das nicht. Jeder kann sie berechnen. Eine passende Ziffer beweist also nur, dass die Zone in sich stimmig ist, nicht, dass das Dokument echt ist.
Die drei Formate
ICAO 9303 definiert drei MRZ-Layouts. Man unterscheidet sie an der Zahl der Zeilen und der Zeichen pro Zeile:
| Format | Zeilen × Zeichen | Wo man es antrifft |
|---|---|---|
| TD1 | 3 × 30 | Personalausweise, Aufenthaltstitel |
| TD2 | 2 × 36 | Ältere Ausweise und einige Reisedokumente |
| TD3 | 2 × 44 | Reisepässe im Heftformat |
Die unten verwendeten Muster:
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
Lesen Sie die zweite TD3-Zeile von links nach rechts: L898902C3 Dokumentennummer, 6 ihre Prüfziffer, UTO Staatsangehörigkeit, 740812 Geburtsdatum (JJMMTT), 2 seine Prüfziffer, F Geschlecht, 120415 Ablaufdatum, 9 seine Prüfziffer, ZE184226B<<<<< optionale Daten (oft eine persönliche Kennnummer), 1 deren Prüfziffer und schließlich 0, die Gesamtprüfziffer.
Der MRZ-Parser enthält die Position jedes Felds und jeder Prüfziffer in allen drei Formaten als Referenztabelle, und die Seite zu den MRZ-Formaten geht jedes Layout einzeln durch.
Wo jede Prüfziffer steht
Die Positionen zählen ab 0, sodass Sie sie direkt in slice einsetzen können. Die Prüfziffer eines Felds steht unmittelbar hinter dem Feld.
| Feld | TD3 (Zeile 2) | TD2 (Zeile 2) | TD1 |
|---|---|---|---|
| Dokumentennummer | 0–8, Ziffer bei 9 | 0–8, Ziffer bei 9 | Zeile 1: 5–13, Ziffer bei 14 |
| Geburtsdatum | 13–18, Ziffer bei 19 | 13–18, Ziffer bei 19 | Zeile 2: 0–5, Ziffer bei 6 |
| Ablaufdatum | 21–26, Ziffer bei 27 | 21–26, Ziffer bei 27 | Zeile 2: 8–13, Ziffer bei 14 |
| Optionale Daten | 28–41, Ziffer bei 42 | keine | keine |
| Gesamtprüfziffer | Ziffer bei 43 | Ziffer bei 35 | Zeile 2: Ziffer bei 29 |
An der Gesamtprüfziffer scheitern die meisten selbst gebauten Validatoren, denn sie deckt nicht die ganze Zeile ab:
- TD3: Positionen 0–9, 13–19 und 21–42 der Zeile 2. Staatsangehörigkeit (10–12) und Geschlecht (20) werden übersprungen.
- TD2: Positionen 0–9, 13–19 und 21–34 der Zeile 2. Dieselben Lücken.
- TD1: Sie erstreckt sich über zwei Zeilen: Zeile 1, Positionen 5–29, dann Zeile 2, Positionen 0–6, 8–14 und 18–28.
Jeder Bereich schließt die darin liegenden Feldprüfziffern ein. Deshalb erkennt die Gesamtprüfziffer auch Fehler in den Prüfziffern selbst.
Ein Validator in Python
Ohne Abhängigkeiten. Er erkennt das Format an der Form, prüft jede Feldprüfziffer und die Gesamtprüfziffer und gibt ein Dict mit den Ergebnissen zurück.
WEIGHTS = (7, 3, 1)
def char_value(c):
if c.isdigit():
return int(c)
if "A" <= c <= "Z":
return ord(c) - ord("A") + 10
if c == "<":
return 0
raise ValueError(f"not an MRZ character: {c!r}")
def check_digit(data):
return sum(char_value(c) * WEIGHTS[i % 3] for i, c in enumerate(data)) % 10
def digit_ok(data, printed):
# Ein Feld, das nur aus Füllzeichen besteht, darf "<" als Prüfziffer drucken.
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# (Name, Zeilenindex, Anfang, Ende, Position der Prüfziffer) je Format
LAYOUTS = {
"TD3": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27), ("personal number", 1, 28, 42, 42)],
"TD2": [("document number", 1, 0, 9, 9), ("birth date", 1, 13, 19, 19),
("expiry date", 1, 21, 27, 27)],
"TD1": [("document number", 0, 5, 14, 14), ("birth date", 1, 0, 6, 6),
("expiry date", 1, 8, 14, 14)],
}
def composite(fmt, lines):
if fmt == "TD3":
l = lines[1]
return l[0:10] + l[13:20] + l[21:43], l[43]
if fmt == "TD2":
l = lines[1]
return l[0:10] + l[13:20] + l[21:35], l[35]
a, b = lines[0], lines[1]
return a[5:30] + b[0:7] + b[8:15] + b[18:29], b[29]
def detect(lines):
shape = (len(lines), len(lines[0]))
fmt = {(2, 44): "TD3", (2, 36): "TD2", (3, 30): "TD1"}.get(shape)
if fmt is None or any(len(l) != shape[1] for l in lines):
raise ValueError(f"unknown MRZ shape: {[len(l) for l in lines]}")
return fmt
def validate(lines):
fmt = detect(lines)
results = {}
for name, li, start, end, pos in LAYOUTS[fmt]:
results[name] = digit_ok(lines[li][start:end], lines[li][pos])
data, printed = composite(fmt, lines)
results["composite"] = digit_ok(data, printed)
return fmt, results
if __name__ == "__main__":
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10"]))
print(*validate(["I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<",
"D231458907UTO7408122F1204159<<<<<<<6"]))
print(*validate(["I<UTOD231458907<<<<<<<<<<<<<<<",
"7408122F1204159UTO<<<<<<<<<<<6",
"ERIKSSON<<ANNA<MARIA<<<<<<<<<<"]))
# Ein falsch gelesenes Zeichen: 3 als 4 gelesen in der Dokumentennummer
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
Ausgabe:
TD3 {'document number': True, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': True}
TD2 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD1 {'document number': True, 'birth date': True, 'expiry date': True, 'composite': True}
TD3 {'document number': False, 'birth date': True, 'expiry date': True, 'personal number': True, 'composite': False}
Um die letzte Zeile geht es bei der ganzen Übung: Ein Zeichen wird als sein Nachbar gelesen, und sowohl die Feldprüfziffer als auch die Gesamtprüfziffer schlagen an.
Derselbe Validator in JavaScript
Ein einfaches ES-Modul, läuft in Node oder im Browser.
const WEIGHTS = [7, 3, 1];
function charValue(c) {
if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48;
if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 55; // A = 10
if (c === "<") return 0;
throw new Error(`not an MRZ character: ${JSON.stringify(c)}`);
}
export function checkDigit(data) {
let sum = 0;
for (let i = 0; i < data.length; i++) sum += charValue(data[i]) * WEIGHTS[i % 3];
return sum % 10;
}
const digitOk = (data, printed) => checkDigit(data) === (printed === "<" ? 0 : Number(printed));
const LAYOUTS = {
TD3: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27], ["personal number", 1, 28, 42]],
TD2: [["document number", 1, 0, 9], ["birth date", 1, 13, 19], ["expiry date", 1, 21, 27]],
TD1: [["document number", 0, 5, 14], ["birth date", 1, 0, 6], ["expiry date", 1, 8, 14]],
};
function composite(fmt, [a, b]) {
if (fmt === "TD3") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 43), b[43]];
if (fmt === "TD2") return [b.slice(0, 10) + b.slice(13, 20) + b.slice(21, 35), b[35]];
return [a.slice(5, 30) + b.slice(0, 7) + b.slice(8, 15) + b.slice(18, 29), b[29]];
}
export function validate(lines) {
const fmt = { "2x44": "TD3", "2x36": "TD2", "3x30": "TD1" }[`${lines.length}x${lines[0].length}`];
if (!fmt || lines.some((l) => l.length !== lines[0].length)) throw new Error("unknown MRZ shape");
const results = {};
// Die Prüfziffer steht direkt hinter dem Feld, das sie schützt.
for (const [name, li, start, end] of LAYOUTS[fmt]) {
results[name] = digitOk(lines[li].slice(start, end), lines[li][end]);
}
const [data, printed] = composite(fmt, lines);
results.composite = digitOk(data, printed);
return { format: fmt, results };
}
console.log(validate([
"P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C36UTO7408122F1204159ZE184226B<<<<<10",
]));
node mrz.mjs gibt format: 'TD3' und für alle fünf Prüfungen true aus.
Die Fallstricke
Schneiden Sie die Füllzeichen nicht ab. Die <-Zeichen gehören zu den Daten, über die die Ziffern berechnet werden. Entfernen Sie abschließende < aus einer Zeile, schlägt die Gesamtprüfziffer bei einem völlig korrekten Dokument fehl.
Bauen Sie die Zone nicht aus geparsten Feldern neu auf. Wenn Sie die MRZ in Felder zerlegen, diese normalisieren (Datumsangaben nach ISO, Namen mit Leerzeichen) und sie dann zum Prüfen der Ziffern wieder serialisieren, prüfen Sie Ihren eigenen Serialisierer. Prüfen Sie die Rohzeilen so, wie sie gelesen wurden.
Normalisieren Sie die OCR-Ausgabe vor der Validierung – mit Vorsicht. OCR-Engines liefern gern Kleinbuchstaben, Leerzeichen oder « statt <. In Großbuchstaben umwandeln und Leerraum entfernen ist unbedenklich. O durch 0 zu ersetzen, „weil Dokumentennummern numerisch sind“, ist es nicht: Dokumentennummern können Buchstaben enthalten, und genau das zeigt L898902C3.
Eine passende Ziffer ist noch kein echtes Datum. 740812 besteht die Prüfung seiner Prüfziffer, ob der 12. August 1974 nun plausibel ist oder nicht, und JJMMTT enthält kein Jahrhundert. Bestimmen Sie das Jahrhundert aus dem Kontext: Ein Geburtsdatum liegt in der Vergangenheit, ein Ablaufdatum meist in der Zukunft.
Lange Dokumentennummern bei TD1. Die ICAO erlaubt, dass eine TD1-Dokumentennummer mit mehr als neun Zeichen in das Feld für optionale Daten überläuft, mit einem < an der üblichen Position der Prüfziffer und der Prüfziffer hinter dem letzten Zeichen der Nummer. Der Validator oben deckt diesen Fall nicht ab. Wenn Sie Ausweise von Ausstellern verarbeiten, die das nutzen, ergänzen Sie einen Zweig; der MRZ-Parser beherrscht den Fall, falls Sie etwas zum Vergleichen suchen.
Prüfziffern sind kein Echtheitsnachweis. Wer ein Bild bearbeiten kann, kann auch eine gültige Ziffer berechnen. Die MRZ sagt Ihnen, dass die Zone richtig gelesen wurde und in sich stimmig ist, nicht, dass das Dokument echt ist. Der Abgleich der MRZ mit der bedruckten Sichtzone ist ein stärkeres Signal, und selbst das ist keine Fälschungsprüfung.
Testdaten ohne echte Pässe
Um diesen Code zu testen, sollten Sie nie den Pass einer realen Person brauchen. Zwei Möglichkeiten:
- Die ICAO-Muster oben, die genau zu diesem Zweck veröffentlicht werden.
- Eigene erzeugen: Der MRZ-Generator baut im Browser aus eingegebenen Werten eine synthetische TD3-Zone mit korrekten Prüfziffern. Ändern Sie danach ein Zeichen, und Sie haben einen Fehlerfall.
Für die umgekehrte Richtung fügen Sie eine beliebige Zone (TD1, TD2 oder TD3) in den MRZ-Parser ein: Er erkennt das Format, liest jedes Feld und zeigt jede berechnete Prüfziffer neben der gedruckten, alles im Browser. Das ist praktisch, wenn Ihre Implementierung und eine andere zu unterschiedlichen Ergebnissen kommen.
Wo das in eine echte Pipeline passt
Wenn Sie MRZs mit eigenem OCR lesen, führen Sie diese Prüfungen bei jedem Lesevorgang aus und behandeln Sie einen Fehlschlag als „neu fotografieren“, nicht als „Person ablehnen“: Eine Spiegelung über einem Zeichen, ein abgenutztes Laminat oder eine geknickte Seite sind weit häufiger als Betrug.
Wenn Sie stattdessen eine gehostete Erkennungs-API nutzen, rechnen Sie die Ziffern trotzdem selbst nach, sobald das Ergebnis über Geld oder Zugang entscheidet. Es ist der einzige Teil der Antwort, den Sie prüfen können, ohne dem Anbieter zu vertrauen. Das gilt auch für uns: Die Antwort von doc.cheap gibt die Zone wortgetreu als mrz.lines und mrz.text aus (die Zeilen ohne Trennzeichen aneinandergehängt), neben dem eigenen Urteil mrz.status – genau damit Sie sie einer Funktion wie der obigen übergeben können. Check an MRZ in der Dokumentation beschreibt diesen Ablauf.
Wenn Sie einen Fall finden, den der Validator falsch beurteilt, schreiben Sie an admin@doc.cheap.
Beide Codeblöcke wurden ausgeführt, und ihre Ausgabe ist so wiedergegeben, wie sie ausgegeben wurde; jede Aussage über doc.cheap wurde am Code überprüft.