HarmonyOS 7 NFC Tag + HUKS:精准碰一碰票券的离线验签、单次消费与重放拦截【鸿蒙心迹】
闸机断网时,票券仍然要能验;同一张标签被拍照复制后,又不能在两个入口各用一次。这两个要求放在一起,才是离线碰一碰真正麻烦的地方。

我做了一个小型验票工程 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、数据库没有半条记录;把账本置为只读,页面必须停在存储失败而不是继续放行。碰一碰的交互只有一秒,资源释放和失败分支却必须按完整事务来设计。
参考资料:
更多推荐


所有评论(0)