每本护照的资料页底部都有两行看上去颇不友好的文字:大写字母、数字,外加一大串 < 符号。这就是机读区(MRZ,machine-readable zone),由国际民航组织(ICAO)的旅行证件标准 Doc 9303 定义。它承载的核心信息与印刷页面相同(姓名、证件号码、国籍、出生日期、性别、有效期),但采用了扫描设备无需猜测字体或版式就能读取的形式。

MRZ 还自带错误检测。其中有几个字符是校验位(check digit):每个校验位由某个特定字段计算得出,最后还有一个校验位同时覆盖多个字段。只要有一个字符识别错误,保护它的那个校验位通常就会对不上。因此,在身份证件处理中,MRZ 是少数几样您可以自己验证的东西之一:只需二十行代码,也不必信任任何人的 OCR。

本文将讲解这套算法、校验位在三种 MRZ 格式中各自的位置,并提供一份可以直接放进项目的 Python 与 JavaScript 校验代码。所有示例都使用 ICAO 自己的虚构样本:来自“乌托邦”(UTO,一个只存在于样本中的国家代码)的 Anna Maria Eriksson。文中不出现任何真实证件。

这里是 doc.cheap 的博客。doc.cheap 是一个证件识别 API,它会读取 MRZ 并在服务器端重新核验这些校验位。下文的内容都不依赖它,代码可以离线运行。

字符集

MRZ 只使用 37 个字符:0-9A-Z 以及填充符 <。没有小写字母,没有空格,也没有标点。带重音符号或非拉丁文字的姓名会被转写成拉丁字母,字段内的空格变成 <。填充符还用于把每个字段补足到固定宽度,所以 ERIKSSON<<ANNA<MARIA<<<<<<< 表示“姓 ERIKSSON,名 ANNA MARIA”,中间的双 << 用来分隔姓和名。

算法:权重 7、3、1

无论哪种格式、哪个字段,校验位的计算方法都相同:

  1. 把每个字符转换成数字。数字字符取其本身的值。字母取其在字母表中的序号加 9,即 A = 10、B = 11、…… Z = 35。填充符 < 为 0。
  2. 乘以循环的权重 7、3、1、7、3、1……,从字段的第一个字符开始。
  3. 把乘积相加,再对 10 取余。得到的这一位数就是校验位。

以样本护照号码 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?这组权重的选择,是为了让最常见的识别错误都会改变总和:单个字符读错,以及许多相邻两个字符互换的情况。它不是密码学意义上的校验和。任何人都能算出它,所以校验位匹配只能证明这个区域内部一致,并不能证明证件是真的。

三种格式

ICAO 9303 定义了三种 MRZ 版式,靠行数和每行字符数来区分:

格式 行数 × 字符数 常见于
TD1 3 × 30 身份证、居留许可
TD2 2 × 36 较旧的身份证和部分旅行证件
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 的第二行:L898902C3 是证件号码,6 是它的校验位,UTO 是国籍,740812 是出生日期(YYMMDD),2 是它的校验位,F 是性别,120415 是有效期截止日期,9 是它的校验位,ZE184226B<<<<< 是可选数据(通常是个人编号),1 是它的校验位,最后的 0 是复合校验位。

MRZ 解析器提供了一张参考表,列出三种格式中每个字段和校验位的位置;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:跨越两行:先是第 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<<<<<<<<<<"]))
    # 一个字符读错:证件号码中的 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}

最后一行正是这一切的意义所在:一个字符被错认成相近的字符,字段校验位和复合校验位都会把它标出来。

JavaScript 版校验代码

一个普通的 ES 模块,可在 Node 或浏览器中运行。

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',五项检查全部为 true

常见陷阱

不要去掉填充符< 字符是参与校验位计算的数据的一部分。去掉一行末尾的 <,一份完全正常的证件也会复合校验失败。

不要用解析后的字段重新拼出 MRZ。如果您先把 MRZ 解析成字段、做了规范化(日期转成 ISO 格式、姓名加上空格),再重新序列化来检查校验位,那您检查的其实是自己的序列化代码。请直接检查读取到的原始行。

校验前先规范化 OCR 输出,但要小心。OCR 引擎常常返回小写字母、空格,或者用 « 代替 <。转成大写、去掉空白字符是安全的。以“证件号码都是数字”为由把 O 替换成 0 则不安全:证件号码可以包含字母,L898902C3 恰好说明了这一点。

校验位通过不代表日期真实。无论 1974 年 8 月 12 日是否合理,740812 都能通过校验,而且 YYMMDD 不包含世纪信息。世纪要根据上下文判断:出生日期在过去,有效期通常在未来。

TD1 上的长证件号码。ICAO 允许 TD1 中超过九个字符的证件号码延续到可选数据字段中,此时正常的校验位位置上是 <,校验位则放在号码最后一个字符之后。上面的校验代码没有处理这种情况。如果您要处理采用这种做法的签发机构的身份证,请加一个分支;MRZ 解析器支持这种情况,可供对照。

校验位不等于真伪。任何会编辑图片的人都能算出有效的校验位。MRZ 只能说明这个区域被正确读取且内部一致,而不能说明证件是真的。把 MRZ 与印刷的视读区进行比对是更强的信号,但即便如此,也不是伪造检测。

不用真实护照的测试数据

测试这段代码,您永远不需要真人的护照。有两种选择:

  • 上文的 ICAO 样本,它们正是为此目的而公开发布的。
  • 自己生成:MRZ 生成器会在浏览器中根据您输入的值,生成一个带正确校验位的合成 TD3 机读区。生成后改动一个字符,就得到了一个校验失败的用例。

反过来,您可以把任意机读区(TD1、TD2 或 TD3)粘贴到 MRZ 解析器中:它会识别格式、读出每个字段,并把计算出的每个校验位与印刷的校验位并排显示,全部在浏览器中完成。当您的实现和别人的实现结果不一致时,这很方便。

在实际流程中的位置

如果您用自己的 OCR 读取 MRZ,请对每一次读取都运行这些检查,并把校验失败当作“重新拍照”,而不是“拒绝这个人”:某个字符上的反光、磨损的覆膜或折痕,远比欺诈常见。

如果您改用托管的识别 API,那么当结果关系到资金或访问权限时,仍然应该自己重新计算校验位。这是响应中唯一一部分您无需信任供应商就能自行核验的内容。这也包括我们:doc.cheap 的响应在给出自己的 mrz.status 判定的同时,会原样提供 mrz.linesmrz.text(各行直接首尾相连),正是为了让您可以把它传给上面那样的函数。文档中的 Check an MRZ 指南(英文)介绍了这一流程。

如果您发现校验代码处理错误的情况,请写信至 admin@doc.cheap

文中两段代码都实际运行过,输出按原样粘贴;关于 doc.cheap 的每一项说法都已对照其代码核实。