闸机断网时,票券仍然要能验;同一张标签被拍照复制后,又不能在两个入口各用一次。这两个要求放在一起,才是离线碰一碰真正麻烦的地方。

我做了一个小型验票工程 TapPass Lab。10:36 的测试票号为 ticket_7A_1842,触碰记录 tap_20261001_27,发行方 event_gate_v2,随机数 91F4C2,单调计数器 1842,有效期 90 秒。签名算法标记为 ECDSA P-256,本地验签耗时 18 ms,离线结果 PASS,首次消费状态 COMMITTED;随后用同一载荷再次触碰,重放测试拦截数为 1。

这篇不讨论“怎样把文本写进 NFC 标签”这种基础流程。我更在意的是:标签内容如何形成可验签的稳定字节序列,公钥怎样受控进入设备,验签通过和消费成功如何成为一个不可重复的事务,以及页面离开时怎样释放 Tag 会话。

一、能读到 NDEF,不代表这张票可信

最初的 Demo 只从 NDEF 文本中拿到 ticketId,然后请求服务端确认。在线时没有问题,断网后只能放行或拒绝二选一。为了演示离线能力,有人建议把 valid=true 一起写入标签;这个字段当然也能被复制,等于把判断权交给了任何能重写标签的人。

后来我们给 payload 加了签名,第二个坑又出现了:写卡端按 JSON 原始字符串签名,读卡端解析后再 JSON.stringify,字段顺序和空格一变,合法票也会验签失败。还有一次把时间当成本地格式字符串,设备语言切换后同一内容生成不同字节。密码学算法没有错,错的是签名对象从未被严格定义。

TapPass Lab 采用固定字段和固定顺序的 canonical payload:版本、发行方、票号、nonce、counter、签发时间、过期时间。标签 UID 只作为诊断线索,不作为票券身份,因为不同标签类型和复制方式下 UID 的安全属性不能被业务想当然地使用。

威胁模型也要写清楚。我们防的是复制 NDEF 内容、重复触碰同一载荷、篡改票号和过期时间;不声称一台永久离线的闸机能感知另一台离线闸机刚刚发生的消费。后者需要设备间同步或物理分区。把边界写进设计文档很重要,否则“本机重放拦截”很容易在汇报中被误写成“全局防双花”。

我还给每种拒绝原因分了稳定状态码:格式错误、未知发行方、签名错误、时间不可信、已过期、重复消费和本地存储失败。页面可以使用友好文案,日志和审计则保留状态码。所有失败都显示同样的红叉,会让现场人员无法判断该让用户重试、联网还是转人工通道。

二、NDEF 解析器只接受一种规范结果

下面这段代码解决的是“同一份业务数据被不同 JSON 表示方式影响验签”。解析器先做字段白名单、长度与格式检查,再按固定顺序生成 UTF-8 字节,签名不参与 canonical 内容。

export interface TapTicket {
  version: 1
  issuer: string
  ticketId: string
  nonce: string
  counter: number
  issuedAt: number
  expiresAt: number
  signature: Uint8Array
}

export function canonicalize(ticket: TapTicket): Uint8Array {
  if (ticket.version !== 1 || !/^ticket_[A-Z0-9_]+$/.test(ticket.ticketId)) {
    throw new Error('TICKET_FORMAT_INVALID')
  }
  if (!/^[A-F0-9]{6}$/.test(ticket.nonce) || ticket.counter < 1) {
    throw new Error('TICKET_NONCE_INVALID')
  }
  const text = [
    'v=1', `issuer=${ticket.issuer}`, `ticket=${ticket.ticketId}`,
    `nonce=${ticket.nonce}`, `counter=${ticket.counter}`,
    `iat=${ticket.issuedAt}`, `exp=${ticket.expiresAt}`
  ].join('&')
  return new TextEncoder().encode(text)
}

测试票最终得到的业务键是 event_gate_v2 / ticket_7A_1842 / 91F4C2 / 1842。数字全部使用十进制,时间使用 UTC epoch 秒,不允许本地化格式。解析失败会停在 FORMAT_REJECTED,不会进入 HUKS,也不会写消费账本。

读取 Tag 时还要限制 NDEF 记录数量和载荷长度。过大的 payload 不仅没有业务意义,也可能拖长解析和日志输出。Tag 会话从系统分发的 TagInfo 创建,读取结束后无论成功、超时还是异常都要在 finally 中重置连接。页面消失只取消 UI 等待还不够,底层会话如果继续占用,下一次触碰可能拿到异常连接状态。

连接释放采用“谁创建、谁关闭”的约定。NdefTicketReader 返回的是已经复制到应用内存的字节,不把 TagSession 暴露给页面;页面取消只触发 reader 的 abort 标记,reader 在当前 I/O 返回后统一 reset。这样不会出现页面和仓储层各关一次,也避免异常分支忘记释放。连续触碰时,新任务必须等待上一会话结束,不能并发复用同一标签连接。

三、公钥不是跟着票券一起“自证”

离线验签需要设备提前拥有可信公钥。公钥可以不是秘密,但它的来源和版本必须受控。TapPass Lab 的 event_gate_v2 在部署阶段映射到固定 key alias,票券只能声明发行方,不能携带一个新公钥让客户端临时相信。

这段代码解决的是“业务页面直接拼 HUKS 参数,算法、摘要和 key alias 容易漂移”。适配器固定 ECDSA P-256 + SHA-256 验签策略,把平台会话创建、数据更新和结束收口在一个方法里。

export class HuksTicketVerifier {
  async verify(ticket: TapTicket): Promise<boolean> {
    const keyAlias = `ticket_pub_${ticket.issuer}`
    const properties: huks.HuksParam[] = [
      { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_ECC },
      { tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_VERIFY },
      { tag: huks.HuksTag.HUKS_TAG_DIGEST, value: huks.HuksKeyDigest.HUKS_DIGEST_SHA256 },
      { tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_ECC_KEY_SIZE_256 }
    ]
    const data = canonicalize(ticket)
    const session = await huks.initSession(keyAlias, { properties, inData: data })
    try {
      await huks.updateSession(session.handle, { properties, inData: data })
      await huks.finishSession(session.handle, { properties, inData: ticket.signature })
      return true
    } catch (_) {
      await huks.abortSession(session.handle, { properties })
      return false
    }
  }
}

代码保留了最关键的生命周期:创建 session、输入 canonical 数据、用签名结束验签,失败时 abort。不同 SDK 版本的参数结构应以当前 HUKS 接口为准,所以正式工程把它封在适配层,并用真实设备测试 key alias、算法支持和错误码;业务层只接收 true / false 与归一化失败原因。

验签通过只说明载荷确实由受信发行方签发,没有说明它仍在有效期,也没有说明尚未消费。校验顺序是版本与格式、发行方、公钥与签名、时间窗口、单调计数器,最后才进入消费事务。日志不记录完整签名和 NDEF 原文,只记录摘要、ticketId 和结果码。

公钥轮换在 Demo 中也做了版本预留。当前别名对应 event_gate_v2,旧票仍可能由 v1 签发,所以更新不能先删旧 key 再导入新 key。设备先验证新公钥包的管理签名,成功导入 v2 后切换发行策略,等 v1 票券全部过期并超过审计窗口再删除旧别名。任何一步失败都继续保留原来的可信集合,不留下“半轮换”状态。

四、90 秒有效期不是靠设备时间硬扛

离线票券必须面对设备时间不准。Demo 允许小范围时钟偏差,但不会把窗口无限放宽。最近一次联网校时会保存可信时间锚点与单调时钟值,离线时用单调增量推算当前时刻;如果设备重启且可信锚点已经过旧,状态进入 TIME_UNCERTAIN,需要人工通道或重新联网,而不是悄悄放行。

本票 expiresAt - issuedAt = 90s。过期判断在签名之后执行,避免让大量伪造载荷借时间字段走不同分支;同时在写消费记录之前再读一次推算时间,防止验签与提交之间跨过有效期。18 ms 是本次验签耗时,不包含触碰动画和页面渲染,性能指标不能混用。

计数器 1842 也不是替代 nonce。counter 用来发现发行序列明显回退,nonce 用来标识单次凭证。发行端如果因为故障重复使用同一 counter,但 nonce 不同,设备可以按策略告警而不是误判为同一票;同一 issuer、ticketId、nonce 的组合则必须被视为同一次消费。

五、验签与写账之间不能留一条缝

最危险的实现是先查询 nonce 是否存在,没找到就放行,然后异步写入数据库。两个闸机线程几乎同时执行时,都可能看到“未使用”。即便只有一台设备,快速连续触碰也能打进这个时间窗。

下面这段代码解决的是“验证通过后只允许一个请求提交消费”。消费账本对 issuer + nonce 建唯一约束,并在事务中插入记录;约束冲突直接映射为 REPLAY_BLOCKED。

export async function commitOnce(ticket: TapTicket, tapId: string): Promise<ConsumeResult> {
  const tx = await ledger.createTransaction()
  try {
    await tx.insert('tap_ledger', {
      issuer: ticket.issuer,
      nonce: ticket.nonce,
      ticketId: ticket.ticketId,
      counter: ticket.counter,
      tapId,
      consumedAt: trustedClock.nowSeconds()
    }, ConflictResolution.ON_CONFLICT_ABORT)
    await tx.commit()
    return { state: 'COMMITTED', replayCount: 0 }
  } catch (error) {
    await tx.rollback()
    if (isUniqueConstraint(error)) {
      return { state: 'REPLAY_BLOCKED', replayCount: 1 }
    }
    throw error
  }
}

第一次 tap_20261001_27 插入成功,页面显示 COMMITTED;重放同一 91F4C2 时,唯一约束保证只有一个事务获胜,第二次返回 REPLAY_BLOCKED,测试计数为 1。不要用内存 Set 代替账本,应用重启后它会清空;也不要在提交前先播放“通过”动画,数据库失败时用户已经越过闸机。

账本清理同样需要边界。记录至少保留到所有相关票券过期并超过审计窗口,不能一到 90 秒就删除,否则旧载荷会重新变成“未见过”。清理任务按发行批次执行,失败只影响空间回收,不改变当前验证结果。多设备闸机完全离线时无法天然共享消费账本,这属于部署架构限制:需要短周期联机同步、区域主设备或物理分区策略,单机事务不能假装解决跨设备双花。

同步恢复后,上传的是消费摘要与设备序列,不上传完整 NDEF。服务端发现同一 nonce 出现在两个离线设备时,不能让客户端回滚已经发生的物理放行,只能进入审计与后续处置。离线方案的价值是让现场继续工作,不是把分布式一致性问题变没;因此离线时长、票券价值和人工兜底必须一起评估。

DevEco Studio 图中,左侧目录包含 nfc / security / ledger / model,中间打开 HuksTicketVerifier.ets,右侧模拟器显示 TapPass Lab。底部 HiLog 把 tap_20261001_27、ECDSA_P256、verify=18ms、offline=PASS、consume=COMMITTED 和 replayBlocked=1 放在一条调试链里。代码区域标出 HUKS 验签会话,运行区标出第二次触碰已被账本拦截。

六、页面状态不把“验签通过”写成最终成功

运行页的状态机是 READING → SIGNATURE_OK → TIME_OK → COMMITTED。如果同一载荷再次触碰,状态从读取直接进入 REPLAY_BLOCKED,不会复用上一次绿色结果。每次触碰都有独立 tapId,异步回调回来时先比对 generation,旧触碰不能覆盖新触碰的页面。

我特意把网络断开,再完成整条路径。公钥已预置、时间锚点仍有效、账本可写,因此离线结果为 PASS。随后把签名字节改一位,流程停在 SIGNATURE_REJECTED;把设备快照恢复到消费前再重放,数据库记录仍然存在,状态为 REPLAY_BLOCKED。这三次测试分别覆盖可信来源、时间与持久化防重,不是只看一次绿色页面。

10:36 的手机截图保持同一组数据:票号 ticket_7A_1842,发行方 event_gate_v2,nonce 91F4C2,counter 1842,TTL 90 s,算法 ECDSA P-256,验签 18 ms,离线 PASS,消费 COMMITTED,重放拦截 1。红色批注只指出“本地验签 18 ms”和“同 nonce 再次触碰已拦截”。

七、碰一下很轻,背后的承诺不能轻

用户看到的是一次短促触碰,工程上却包含四个不同结论:读到了数据、数据来自可信发行方、数据当前有效、数据尚未消费。把任何两步合并成一个布尔值,都会让异常恢复和审计变得含糊。

正式项目还要补足密钥轮换。设备应同时保留当前与上一版公钥,票券明确携带 issuer/key version,轮换完成且旧票过期后再删除旧 alias。导入公钥的管理通道需要验来源,普通 NDEF 载荷无权更新信任根。HUKS 会话、NFC Tag 连接和数据库事务都必须成对结束,页面销毁时还要取消尚未提交的任务。

TapPass Lab 最终验证的不是“NFC 能读卡”,而是一条可解释的离线消费链:规范化载荷进入 HUKS,签名与时间门禁依次通过,唯一约束原子提交,重复 nonce 明确被挡住。只有到 COMMITTED,页面才显示通过;在此之前,每个中间状态都可以失败,也都知道该怎样收尾。

现场验收除了功能,还要看操作节奏。我连续触碰十次不同票,确保上一张票的绿色状态不会短暂覆盖下一张;在 HUKS 会话中途离开页面,确认 session abort、Tag 连接 reset、数据库没有半条记录;把账本置为只读,页面必须停在存储失败而不是继续放行。碰一碰的交互只有一秒,资源释放和失败分支却必须按完整事务来设计。

参考资料:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐