ID TECH
全部技术文章

技术文章

如何在 JavaScript 中解析 TLV

芯片卡(ICC)的一大优点在于,从卡片中读取的数据几乎始终采用一种标准格式,即 BER-TLV。用通俗的话来说,就是基本编码规则(Basic Encoding Rules)下的标签-长度-值(Tag-Length-Value)格式(一篇关于它的有趣且富有信息量的文章可参见 此处).

BER-TLV 格式是 ASN.1(抽象语法标记)编码之一,由 ITU X.690所定义,这是一套非常古老的标准,可追溯到互联网诞生之前的远古时代。

芯片卡使用 TLV 方案对卡片数据进行编码。最简单地说,Tag-Length-Value 方案的含义是:假设有一个名为"5A"的标签,其值为 8 个八位字节(例如十六进制数值"41 11 12 34 56 78 9A BC"),那么 TLV 编码就形如 5A084111123456789ABC,其中 5A 是标签,08 是长度,4111123456789ABC 是值。

EMVCo(推动整个芯片卡发展的发卡机构联盟)为芯片卡交易定义了一系列标准标签。例如 5A 始终编码 PAN(主账号,即卡号),9F02 编码交易的授权金额,5F2D 编码语言偏好,以此类推。EMVco 定义的完整标签列表(及其含义)可参见 https://www.eftlab.co.uk/index.php/site-map/knowledge-base/145-emv-nfc-tags.

既然 TLV 自带长度信息,那么解析 TLV 数据应该是小菜一碟,对吧?

嗯,是的。差不多吧。算是吧。

如果每个标签都是简单的单字节标识符(比如 5A),那么解析 TLV 数据流的确会超级超级简单。但如果标识符只能取 256 个可能值之一,TLV 方案就没多大用处了。

为了让标签标识符具有可扩展性,基本编码规则允许使用多字节标签。规则规定:如果第一个标签字节的低 5 位全部置 1,则后面还会有更多的标签标识符字节。在后续字节中,若最高位为 1,表示还有更多字节;若最高位为 0,则为最后一个字节。例如,5F24 是合法的 2 字节标签标识符,DFEF01 是合法的 3 字节标签,以此类推。

EMVCo(在 EMV 规范第 3 册附录 B 中引用并纳入了 BER-TLV)还引入了"包装"标签的概念,以支持 TLV 之间的层级父子关系(即嵌套)。根据 EMV 规则,如果标签首字节的第 6 位被置 1,则该标签被称为"构造型"(constructed,我更偏向使用 复合这一术语)。因此,一个 3 字节标签 FFEE01 可以用于包装(虚构的)TLV 3F0188 和 3F025544,结果如下:FFEE01073F01883F025544。父标签 FFEE01 拥有 7 字节数据,包含一个 3 字节 TLV 和一个 4 字节 TLV。使用此方案,标签组可以嵌套至任意所需的深度。

请特别注意,TLV 的长度字节也可以是多字节的。这里的扩展规则(摘自 EMV 第 3 册附录 B2)是:

若长度字节的最高位置 1,则需将低 7 位视为"长度的长度"。换句话说,长度字节为 0x82 意味着接下来的两个字节是长度信息。在(虚构的)TLV 5F0F8103AABBCC 中,标签为 5F0F,长度的长度为 1 字节,实际长度为 3 字节,值为 AABBCC。

是不是越说越糊涂了?

那么,理解了上述这些之后,我们就可以用约 75 行 JavaScript 代码创建一个完全通用的递归下降 TLV 解析器,如下所示。

// 'data' 应类似于 "95050010203000…" 等

// 换句话说:将多个 TLV 序列化为一个长字符串。

// 函数将返回一个 TLV 对象,可用其按标签名查找对应的值。

// TLV['95'] 将包含标签 95 的值。

// TLV['9F26'] 将包含标签 9F26 的值,以此类推。

我们这里采用的策略简单到令人发指:

首先,准备一个庞大的标签标识符字典,包含所有已知的 EMVCo(行业标准)标签,以及所有已知的 ID TECH 专有标签。我们将此字典命名为 _KnownTags,你可以通过查看 _KnownTags[ '5A' ] 是否返回 true,来检测某个标识符(如 '5A')是否存在。

接下来:开始解析!

我们的解析算法非常简单:

一次读取两个半字节,存入 tag 变量中,然后测试该标签是否存在于字典中。字典中所有标签的长度均为 1、2 或 3 字节,所以如果读取了 6 个半字节仍未找到已知标签,就将读取帧推进 2 个半字节,然后若无其事地继续(在输出"期望找到标签,但未找到"的控制台消息后)。如果你想较真并在此抛出异常,也是可以的,但我的理念是(当然要视具体情况而定)解析器在默认情况下应当具备容错性(fail-soft),以防你仍想使用已解析数据中的其余部分。

一旦找到标签,就调用一个工作方法——在本例中是名为 readData() 的内部函数——读过标签部分、读取长度,然后依据长度读取值。(这里需要小心检查推定长度字节的最高位,以判断是否需遵循前面提到的"长度的长度"扩展规则。)

将该值存入一个存储对象,查找键为 tag.

最后返回该存储对象。

我们来看一个实际示例。假设你有一个 ID TECH Augusta 芯片卡读卡器,并在键盘模式下使用它来捕获 Quick Chip 数据。当您刷卡时,设备输出的数据流可能如下所示:

这是一大段 TLV 数据,以 ID TECH 专有标签 DFEE25 开头。(您可以通过下载以下文档了解更多 ID TECH 标签的含义: ID TECH TLV 标签参考指南 来源: https://idtechproducts.atlassian.net/wiki/spaces/KB/overview。)不过,该数据块中的大多数标签都是行业标准的 EMVCo 标签。如果我们将此数据块作为字符串赋值给名为 tagblock 的 JS 变量,然后加载上述解析器并运行 parseTags( tagblock ),我们将得到一个包含标签和值的对象,如下所示:

其中一些标签为空。一些(如 9F27)的值为 00。还有一些是加密的。但基本上,您在这里已经拥有运行 EMV 交易所需的所有标签。

为什么要使用 JavaScript 进行 TLV 解析?嗯,如果我告诉您真正的答案,那就会破坏您现在无疑正感受到的悬念——比如如何在支付应用环境中使用 Node.js 、如何用 JavaScript 与信用卡读卡器通信、如何使用 Servlet 和 AJAX 访问后端测试服务器等。所有这些内容都将在这里陆续推出,敬请收藏本博客,欢迎常来!