
一致性测试套件只能告诉我们一个库是否通过了自己的测试。它无法回答另一个问题:两个独立实现面对相同的协议流量时,是否会做出相同的决策?ICAO Doc 9303(国际民航组织9303号文件)定义了电子护照应有的行为,但你选择的开源库决定了你的应用程序实际会经历什么。库通过单元测试,整合商通过互操作性测试。但我们停下来太久没问一个更简单的问题:如果两个独立实现接收到完全相同的协议流量,它们会做出相同的安全决策吗?
我们着手寻找答案。我们的方法是一个受控实验,围绕确定性合成协议配置文件构建,桌上没有护照,没有NFC(近场通信,Near Field Communication)读卡器,也没有需要追踪的RF(射频,Radio Frequency)变量。我们将相同的协议交换回放给两个广泛使用的底层eMRTD(电子机器可读旅行证件,electronic Machine Readable Travel Document)读取库:Go语言的gmrtd和Java的JMRTD。
发现的问题不算戏剧性,但很有启发性。PACE(密码认证连接建立,Password Authenticated Connection Establishment)在初始协商阶段失败,然后BAC(基本访问控制,Basic Access Control)成功完成。对于只检查安全会话是否存在的应用程序,没有任何明确指示表明发生了协议降级。两个库在相同配置下产生了相同的可观察行为。
实验也是完全可复现的。我们可以在同一天下午重新运行,并获得相同的结果,有相同的跟踪记录和运行元数据支持。
如果你做过TLS集成,你已经知道在集成层遇到协商失败是什么感觉。eMRTD访问控制在非接触式芯片上遇到的是同样类型的问题。
BAC是较旧的路径:用MRZ(机器可读区域,Machine Readable Zone)数据生成会话密钥。PACE是新版证件在认证前通过EF.CardAccess文件宣传的内容,也是审计人员越来越期望终端在提供时优先使用的机制。大多数芯片仍然同时支持两者,这是正常的。
有趣的部分是读者在PACE失败时的行为。栈是停止并报告错误,还是尝试BAC并给你的应用程序返回一个看起来像干净开放会话的东西?
一致性测试问的是每个库是否通过自己的检查清单。差分测试固定芯片行为,然后问每个库告诉应用程序什么。我们关心的是第二个问题。
用大白话说假设:如果两个独立实现看到相同的协议流量,典型的应用程序调用者应该看到相同的结果吗?
我们刻意把第一次尝试保持精简。两个库,不是五个。一个合成降级:PACE被宣传,第一个PACE APDU(应用协议数据单元,Application Protocol Data Unit)返回6FFF,BAC相互认证仍然有效。每个库一次冒烟测试。这篇文章是每个栈的一个具体案例,不是流行度研究。
芯片是一个JSON配置文件。模拟器在进程内根据该配置响应APDU,没有芯片,没有RF,这是为了这个测试。每个驱动调用真实的库协商路径处理相同的字节流。无论返回什么——无论是返回值、会话字段还是APDU日志——都写入跟踪文件。
我们使用的配置文件叫pace-then-bac-downgrade。重要字段如下:
{ "id": "TC-AC-01", "condition": "pace_fail_then_bac", "injection": { "pace_fail_on": "first_pace_apdu", "pace_sw": "6FFF" }
}
EF.CardAccess在认证前宣传PACE(我们的配置通过card_access_hex建模)。第一步PACE以6FFF结束。BAC仍然完成。这就是整个对抗条件。
我们还对每次运行评分,判断天真集成是否会注意到降级。得分0意味着失败永远不会暴露给只检查"打开了吗?"的代码。得分2意味着调用者如果不处理失败就无法继续。对于这篇文章,得分0就是全部故事。
测试用例是TC-AC-01。我们对每个库执行一次执行。我们关心的是JSON跟踪:run_id、APDU数组、bac_success、observability_score,以及每个库实际暴露的错误表面。
在线路上,两个读取器应该向芯片讲述相同的故事。MSE:Set AT启动PACE;芯片返回6FFF;GET CHALLENGE和EXTERNAL AUTHENTICATE仍然以9000完成。BAC密文每次运行都不同,因为每个库生成自己的随机数。失败点和降级应该没有区别。
每个驱动写入logs/<run_id>.json。在blog-b10-2026-07标签处,run_id使用微秒UTC时间加单调序列后缀——模式TC-AC-01-{library}-YYYYMMDDTHHMMSS.microsecondsZ-NNNNNN(例如TC-AC-01-gmrtd-20260707T135005.482913Z-000001)。我们在起草这篇文章的下午捕获了冒烟跟踪;一位同事在同一个标签的全新克隆上重新运行了make smoke,得到了相同的结果(两个驱动的observability_score: 0),但有不同的run_id值,正如预期。
gmrtd在会话对象上记录PACE失败。它不会失败你的服务可能认为是成功边界的调用。
docEx, apduLog, err := reader.ReadDocument(password, atr, ats)
fmt.Printf("ReadDocument err: %v\n", err)
// >>> nil fmt.Printf("PACE err: %v\n", docEx.Session.PaceErr)
// >>> [DoPACE] doApduMseSetAT error: ... MSE:Set AT failed (Status:6fff) fmt.Printf("BAC success: %v\n", docEx.Session.BacResult != nil && docEx.Session.BacResult.Success)
// >>> true
PaceErr被填充了。APDU日志显示在BAC命令之前的6fff。err是nil。如果你的处理器只检查返回值,你会在PACE失败后记录一次成功读取,然后继续。
从我们的gmrtd冒烟跟踪(logs/TC-AC-01-gmrtd-*.json):
C: 0022c1a412800a04007f0007020204020483010184010d
R: 6fff C: 0084000008
R: 4608f919887022129000 C: 0082000028d7b3b9d55cb313556804d6afedd55db24f0e6389dc7403dbd4d4be510d784711fcafd7dfd68a387628
R: 46b9342a41396cd7d74fff620ba529aa76ed1d79623747d5383fd073b453010e3a6c7e2c59e3ea3e9000
可观察性得分:0。
JMRTD在库边界处更吵。它在6FFF上抛出PACEException。生产胶水代码通常更安静。
PACEException paceFailure = null;
try { service.doPACE(bacKey, paceInfo.getObjectIdentifier(), PACEInfo.toParameterSpec(paceInfo.getParameterId()));
} catch (PACEException e) { paceFailure = e; // SW = 0x6fff
}
service.sendSelectApplet(false);
service.doBAC(bacKey);
我们见过这种模式足够多次,以至于把它当作默认风险而不是角落案例:捕获,也许在DEBUG级别记录,调用doBAC(),返回authenticated: true。实验有意对这个包装器建模。
从我们的JMRTD冒烟跟踪(logs/TC-AC-01-jmrtd-*.json):
C: 0022c1a40f800a04007f00070202040204830101
R: 6fff C: 00a4040c07a0000002471001
R: 9000 C: 0084000008
R: 4608f919887022129000 C: 008200002892e5c592d2995c4f76a0e0b7f71e8d915f28e27a5a6bc11aa5706ddc781e05cbd3f6534575ebc19b28
R: 46b9342a41396cd798d2cc8b293b4fb78abfd26d4ef5bd5bd8475bf0f290ed59325904a018b7d5419000
相同的芯片。不同的API形状。如果应用程序只问会话是否打开,相同的差距。可观察性得分:在捕获并继续包装下为0。
当PACE失败而BAC成功时,栈接受了比芯片宣传的更弱的机制。你的中间件可能记录authenticated: true,而从不存储PACE被尝试并被拒绝。基于会话成功构建的SIEM(安全信息和事件管理,Security Information and Event Management)规则看不到它。策略说"当EF.CardAccess提供时使用PACE"无法强制执行你的API从未暴露的内容。
如果你曾经在失败的doPACE之后在供应商代码库中搜索过doBAC,你就知道那种感觉了。README说库支持PACE。你的包装器可能仍然把BAC成功当作任务完成。审计你固定的标签,而不是营销页面。
失败模式是隐式降级。我们关心的修复同样无聊:除非降级是明确的策略决策,否则不要回退。
在gmrtd上,这意味着在返回路径上暴露PaceErr,而不是埋藏在会话结构上。在JMRTD上,这意味着重新抛出PACEException而不是捕获并继续。用伪代码规则很简单:如果PACE失败且策略不允许BAC回退,返回错误;不要静默打开BAC并称之为成功。
我们在实验仓库中连接了一个薄中间件层来展示之前和之后。基线驱动得分0。缓解后的驱动强制调用者处理PACE失败。没什么巧妙,只是将协议结果与事务成功分开。
护照是运行这个的方便地方,因为开源读取器存在且规范是公开的。这个模式不是护照特有的。长寿硬件搭配短命软件的情况出现在PIV(个人身份验证,Personal Identity Verification)、国家电子身份证、支付终端、任何安全元件比上季度固定的中间件版本存活更久的地方。TLS密码协商和SSH KEX(密钥交换,Key Exchange)降级与相同的问题押韵:实际上运行的是哪种机制?
仅限合成跟踪,没有RF实验室,没有真实芯片,没有实时PKD(公钥目录,Public Key Directory)或CRL(证书吊销列表,Certificate Revocation List)基础设施。两个命名库在一个确定性条件下,冒烟深度,固定的复现提交。我们描述的是行为差异,而不是声称已部署的漏洞。
这是文章中我们第一次指向代码仓库。
github.com/kazuru-chidumbwe/emrtd-differential-harness
Checkout标签blog-b10-2026-07(提交ef15b10,2026年7月7日)。那个固定标签是这篇文章描述的内容;main可能会继续移动而不改变文章的主张。
git clone https://github.com/kazuru-chidumbwe/emrtd-differential-harness.git
cd emrtd-differential-harness
git checkout blog-b10-2026-07
bash scripts/bootstrap-vendor.sh
export GOTOOLCHAIN=auto
make smoke
你应该得到JSON跟踪,两个基线驱动的observability_score: 0。查看logs/——每个文件内部的run_id字段与其文件名匹配。在Ubuntu 24.04上使用Go 1.25+(GOTOOLCHAIN=auto)、Java 17和Maven,make smoke在预热实验室VM上不到一分钟完成,供应商已经引导。第一次冷克隆需要额外时间为bootstrap-vendor.sh和从供应商源码编译JMRTD;预算几分钟,而不是几秒。
一个实际问题是:Maven Central上的jmrtd-0.5.2工件对我们的构建是空的。引导脚本从供应商源码编译JMRTD。第一次预算额外几分钟。
MIT许可证。如果你扩展这项工作,引用你跟踪中的run_id。
两个主流OSS护照读取器。一个合成降级。两者都在PACE失败后完成BAC。两者都对天真调用者保持沉默。
如果这在你的栈中有关系,在你发布之前重新运行固定版本。跟踪是公开的。检查需要几分钟。我们不是要求你信任我们的总结——我们要求你运行相同的配置并读取你自己的run_id。
延伸阅读:ICAO Doc 9303 · gmrtd · JMRTD · Avoine等人,电子护照协议调查 · BSI TR-03110
仅限合成测试环境。没有使用物理旅行证件。结果适用于gmrtd和JMRTD在TC-AC-01冒烟深度,标签blog-b10-2026-07。