どのパスポートにも、顔写真のページの下部にいかめしい2行の文字列があります。大文字と数字、そして大量の < 記号です。これが機械読取領域(MRZ)で、旅券の国際標準であるICAO Doc 9303で定められています。印字されたページと同じ基本情報(氏名、文書番号、国籍、生年月日、性別、有効期限)を、スキャナーがフォントやレイアウトを推測せずに読み取れる形で持っています。
MRZには独自の誤り検出の仕組みもあります。いくつかの文字はチェックディジット(check digit)です。それぞれ特定のフィールドから計算され、最後の1桁は複数のフィールドをまとめて保護します。1文字でも読み違えると、その文字を保護しているチェックディジットはたいてい一致しなくなります。そのためMRZは、本人確認書類の処理の中で、20行ほどのコードで、誰のOCRも信用せずに自分で検証できる数少ない要素の1つです。
この記事では、アルゴリズム、3種類のMRZフォーマットそれぞれでのチェックディジットの位置、そしてプロジェクトに貼り付けて使えるPythonとJavaScriptのバリデーターを順に説明します。例はすべて、ICAO自身が公開している架空の見本、「Utopia」のAnna Maria Eriksson(UTO は見本にしか存在しない国コードです)を使います。実在の書類は一切登場しません。
これは、MRZを読み取ってサーバー側でこれらのチェックディジットを再検証する書類認識API、doc.cheapのブログです。以下の内容にdoc.cheapは必要ありません。コードはオフラインで動きます。
使われる文字
MRZで使う文字はちょうど37種類です。0-9、A-Z、そしてフィラー文字の < です。小文字、空白、句読点はありません。アクセント付きの名前や非ラテン文字の名前は翻字され、フィールド内の空白は < になります。フィラー文字は各フィールドを固定幅まで埋める役割も持つため、ERIKSSON<<ANNA<MARIA<<<<<<< は「姓 ERIKSSON、名 ANNA MARIA」を表し、2つ続く << が姓と名を区切っています。
アルゴリズム:重み7、3、1
チェックディジットは、どのフォーマットのどのフィールドでも同じ方法で計算します。
- 各文字を数値に変換します。数字はその値のままです。英字はアルファベット順の位置に9を足した値で、
A= 10、B= 11、…Z= 35 です。フィラー文字<は0です。 - 繰り返しの重みを掛けます。重みはフィールドの先頭文字から7、3、1、7、3、1、…の順です。
- 積を合計し、10で割った余りを取ります。その1桁がチェックディジットです。
見本の旅券番号 L898902C3 で実際に計算してみます。印字されているチェックディジットは 6 です。
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
なぜ7-3-1なのでしょうか。重みは、よくある読み取りエラー、つまり1文字の誤りや隣り合う2文字の入れ替わりの多くで合計が変わるように選ばれています。暗号学的なチェックサムではありません。誰でも計算できるので、チェックディジットが一致しても証明できるのは領域の中身に矛盾がないことだけで、書類が本物であることではありません。
3種類のフォーマット
ICAO 9303はMRZのレイアウトを3種類定めています。行数と1行あたりの文字数で区別できます。
| フォーマット | 行数 × 文字数 | 主な用途 |
|---|---|---|
| TD1 | 3 × 30 | IDカード、在留許可証 |
| TD2 | 2 × 36 | 旧型のIDカードと一部の渡航文書 |
| TD3 | 2 × 44 | 冊子型のパスポート |
以下で使う見本は次のとおりです。
TD3 P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<
L898902C36UTO7408122F1204159ZE184226B<<<<<10
TD2 I<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<
D231458907UTO7408122F1204159<<<<<<<6
TD1 I<UTOD231458907<<<<<<<<<<<<<<<
7408122F1204159UTO<<<<<<<<<<<6
ERIKSSON<<ANNA<MARIA<<<<<<<<<<
TD3の2行目を左から読むと、L898902C3 が文書番号、6 がそのチェックディジット、UTO が国籍、740812 が生年月日(YYMMDD)、2 がそのチェックディジット、F が性別、120415 が有効期限、9 がそのチェックディジット、ZE184226B<<<<< が任意データ(個人番号であることが多い)、1 がそのチェックディジット、そして最後の 0 が複合チェックディジットです。
MRZパーサーには3つのフォーマットすべてについて各フィールドとチェックディジットの位置を示す参照表があり、MRZフォーマットのページでは各レイアウトを順に解説しています。
チェックディジットの位置
位置は0始まりなので、そのまま slice に渡せます。各フィールドのチェックディジットは、そのフィールドの直後にあります。
| フィールド | TD3(2行目) | TD2(2行目) | TD1 |
|---|---|---|---|
| 文書番号 | 0–8、チェックディジットは9 | 0–8、チェックディジットは9 | 1行目:5–13、チェックディジットは14 |
| 生年月日 | 13–18、チェックディジットは19 | 13–18、チェックディジットは19 | 2行目:0–5、チェックディジットは6 |
| 有効期限 | 21–26、チェックディジットは27 | 21–26、チェックディジットは27 | 2行目:8–13、チェックディジットは14 |
| 任意データ | 28–41、チェックディジットは42 | なし | なし |
| 複合 | チェックディジットは43 | チェックディジットは35 | 2行目:チェックディジットは29 |
自作のバリデーターがいちばん間違えやすいのが複合チェックディジットです。行全体を対象にしていないからです。
- TD3: 2行目の位置0–9、13–19、21–42。国籍(10–12)と性別(20)は飛ばします。
- TD2: 2行目の位置0–9、13–19、21–34。飛ばす箇所は同じです。
- TD1: 2行にまたがります。1行目の位置5–29、続いて2行目の位置0–6、8–14、18–28です。
どの範囲にも、その中にある各フィールドのチェックディジットが含まれます。そのおかげで、複合チェックディジットはチェックディジット自体の誤りも検出できます。
Pythonのバリデーター
依存ライブラリはありません。形からフォーマットを判定し、各フィールドのチェックディジットと複合チェックディジットを検証して、結果をdictで返します。
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):
# フィラー文字だけのフィールドでは、チェックディジットとして "<" が印字されることがある。
expected = 0 if printed == "<" else int(printed)
return check_digit(data) == expected
# フォーマットごとの (名前, 行インデックス, 開始, 終了, チェックディジットの位置)
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<<<<<<<<<<"]))
# 1文字の読み違い:文書番号の 3 を 4 と読んだ場合
print(*validate(["P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<",
"L898902C46UTO7408122F1204159ZE184226B<<<<<10"]))
出力:
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}
最後の行がこの話の核心です。1文字が隣の文字と取り違えられると、そのフィールドのチェックディジットと複合チェックディジットの両方がそれを示します。
JavaScriptの同じバリデーター
NodeでもブラウザでもそのままのESモジュールとして動きます。
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 = {};
// チェックディジットは、保護するフィールドの直後にある。
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 を実行すると、format: 'TD3' と、5つのチェックすべてについて true が出力されます。
落とし穴
フィラー文字を削らないこと。< はチェックディジットの計算対象となるデータの一部です。行末の < を取り除くと、まったく問題のない書類でも複合チェックディジットが一致しなくなります。
解析済みのフィールドから領域を組み立て直さないこと。MRZをフィールドに分解し、正規化して(日付をISO形式に、名前を空白区切りに)から、チェックディジットを検証するために再びシリアライズすると、検証しているのは自分のシリアライザーになってしまいます。読み取ったままの生の行を検証してください。
検証の前にOCRの出力を正規化する場合は慎重に。OCRエンジンは小文字や空白、< の代わりに « を返しがちです。大文字化と空白の除去は安全です。「文書番号は数字だから」と O を 0 に置き換えるのは安全ではありません。文書番号には英字が含まれることがあり、L898902C3 がまさにその例です。
チェックディジットが合っていても、実在する日付とは限りません。740812 は、1974年8月12日がもっともらしいかどうかに関係なくチェックディジットを通過しますし、YYMMDDには世紀の情報がありません。世紀は文脈で決めてください。生年月日は過去、有効期限はたいてい未来です。
TD1の長い文書番号。ICAOは、TD1で9文字を超える文書番号を任意データのフィールドにはみ出させることを認めています。その場合、通常のチェックディジットの位置には < が入り、チェックディジットは番号の最後の文字の後に置かれます。上のバリデーターはこのケースに対応していません。この形式を使う発行者のIDカードを扱うなら、分岐を追加してください。比較対象がほしければ、MRZパーサーはこのケースに対応しています。
チェックディジットは真正性の証明ではありません。画像を編集できる人なら誰でも正しいチェックディジットを計算できます。MRZからわかるのは、領域が正しく読み取られ、中身に矛盾がないことであって、書類が本物であることではありません。MRZと印字された視覚領域を突き合わせるほうが強い手がかりになりますが、それでも偽造の検査にはなりません。
本物のパスポートを使わないテストデータ
このコードをテストするのに、実在する人のパスポートが必要になることはないはずです。選択肢は2つあります。
- 上で使ったICAOの見本。まさにこの目的のために公開されています。
- 自分で生成する。MRZジェネレーターは、入力した値から正しいチェックディジットを持つ合成のTD3領域をブラウザ内で作ります。その後で1文字変えれば、失敗するケースになります。
逆方向の確認には、任意の領域(TD1、TD2、TD3のいずれか)をMRZパーサーに貼り付けてください。フォーマットを判定し、各フィールドを読み取り、計算したチェックディジットを印字されたものと並べて表示します。すべてブラウザ内で完結します。自分の実装と他の人の実装で結果が食い違うときに便利です。
実際のパイプラインでの位置づけ
独自のOCRでMRZを読み取るなら、読み取るたびにこのチェックを実行し、失敗したときは「その人を拒否する」ではなく「撮り直す」として扱ってください。1文字にかかった反射、すり減ったラミネート、折れたページのほうが、不正よりはるかによくあります。
ホスティング型の認識APIを使う場合でも、結果がお金やアクセス権を左右するなら、チェックディジットは自分で再計算してください。応答の中で、提供元を信用せずに検証できる唯一の部分です。これは私たちにも当てはまります。doc.cheapの応答は、独自の判定 mrz.status と並べて、領域をそのまま mrz.lines と mrz.text(各行を区切りなしで連結したもの)として返します。上のような関数にそのまま渡せるようにするためです。その流れはドキュメントの Check an MRZ ガイド(英語)で説明しています。
バリデーターが誤判定するケースを見つけたら、admin@doc.cheap までお知らせください。
2つのコードブロックはどちらも実行し、その出力を表示されたとおりに掲載しています。doc.cheapに関する記述はすべて、そのコードと照合して確認済みです。