一次跨设备业务交接是否可信,不能只看"碰一碰已经触发"和"UnifiedData 已经取到"。系统能力负责发现设备、建立交互并传递统一数据;业务仍要判断收到的字段是否被改动、消息是否过期、同一份载荷是否被重复消费。
本文构造一个名为 TapSeal 的演示项目。发送端把业务字段规范化后计算 HMAC-SHA256,接收端按 RECEIVED → CANONICALIZED → MAC_VERIFIED → FRESH → ACCEPTED 五个阶段推进。示例任务固定为 TAP-SEAL-0066,nonce 为 n-9f31,载荷大小 864 B,生存期 90 s,允许时钟偏差 ±15 s。文中的结果是可复现的演示口径,不代表真实设备安全认证,也不替代系统传输链路自身的安全机制。

一、传输成功不是业务可信的终点
"精准碰一碰"很容易把注意力吸引到触发成功率、设备发现和页面跳转上。真正进入支付确认、工单交接或门店核销时,风险却集中在接收后的几百毫秒:字段顺序不同会不会得到不同摘要;时间戳来自谁;应用重启后 nonce 缓存是否还在;密钥是否被写进代码;重复消息究竟返回成功、忽略还是报错。
TapSeal 的数据只包含 taskId、action、amountFen、issuedAtMs、nonce、schema 六个业务字段,签名不参与自身计算。接收端先得到统一数据记录,再把 JSON 解成受控结构。它不直接执行 CONFIRM_PICKUP,而是进入审计页,所有门禁通过以后才提交业务事务。
这里需要先划清边界。HMAC 解决的是"持有同一密钥的一方是否对这串规范字节做过认证",它不提供公钥签名意义上的发送者不可否认性,也不能阻止已被完全控制的客户端滥用密钥。重放窗口同样不是永久去重系统;它只在短时间内拒绝同一 nonce,再由订单号、核销码等业务幂等键承担长期一致性。
二、先固定字节,再讨论签名
很多签名失败并不是算法问题,而是两端对"同一消息"的字节理解不一致。JSON 对象字段天然没有业务级签名顺序,数字、空值、转义和 Unicode 规范化也可能产生差异。若发送端对原始 JSON 字符串计算 HMAC,接收端解析后重新序列化,二者很可能得到不同结果。
TapSeal 不对任意对象排序,而是为 v1 协议明确规定字段次序、类型和缺省策略。字符串先做 NFC 规范化;整数禁止浮点表示;缺字段直接失败;未知字段可以随载荷传输,但不悄悄进入 v1 签名域。这样升级字段时,旧接收端不会因为它不理解的内容改变认证结果。
这段代码解决什么问题:把同一业务载荷稳定地转换为完全一致的 UTF-8 字节,避免 JSON 字段顺序和隐式类型转换导致 MAC 不一致。
ts
interface TapEnvelopeV1 {
taskId: string
action: string
amountFen: number
issuedAtMs: number
nonce: string
schema: 1
}
function clean(value: string): string {
return value.normalize('NFC').trim()
}
function canonicalText(v: TapEnvelopeV1): string {
if (!Number.isSafeInteger(v.amountFen) || v.amountFen < 0) {
throw new Error('INVALID_AMOUNT')
}
if (!Number.isSafeInteger(v.issuedAtMs)) {
throw new Error('INVALID_ISSUED_AT')
}
return [
`schema=${v.schema}`,
`taskId=${clean(v.taskId)}`,
`action=${clean(v.action)}`,
`amountFen=${v.amountFen}`,
`issuedAtMs=${v.issuedAtMs}`,
`nonce=${clean(v.nonce)}`
].join('\n')
}
function canonicalBytes(v: TapEnvelopeV1): Uint8Array {
return new TextEncoder().encode(canonicalText(v))
}
这段代码故意没有使用通用 JSON.stringify。固定字段表比"递归排序任意 JSON"更容易审核,也能把协议升级变成显式设计。状态从 RECEIVED 进入 CANONICALIZED 时,页面记录规范字节长度和协议号,但不打印完整敏感内容。容易出错的地方有三个:把金额当小数、允许未定义值静默消失、发送端与接收端使用不同的 Unicode 处理。实际项目应为规范化函数准备黄金向量,至少覆盖中文、emoji、反斜线、空字符串和整数边界。
三、密钥只负责计算,不应该被业务层看见
示例将 HMAC 密钥别名固定为 tap_payload_hmac_v1。业务代码只调用 MacSigner.sign(alias, bytes) 或 MacSigner.verify(alias, bytes, mac),不读取原始密钥。密钥生成、使用约束和销毁策略放在 HUKS 适配层中;生产环境还要根据威胁模型决定密钥是设备级、账号级还是短期会话级。
HarmonyOS HUKS 提供密钥生成和会话式密码运算接口。本文不把具体枚举值硬编码进业务页面,因为不同 API 级别和算法参数要求需要以当前官方文档、SDK 类型声明及目标设备为准。下面的桥接接口展示所有权和调用顺序,HuksMacProvider 内部必须按"生成/存在检查---初始化会话---完成运算"的官方流程实现。
这段代码解决什么问题:把密钥句柄和 HMAC 运算隔离到安全适配层,页面只能获得摘要,不能接触原始密钥材料。
ts
export interface MacProvider {
ensureKey(alias: string): Promise<void>
sign(alias: string, input: Uint8Array): Promise<Uint8Array>
}
export class TapSealService {
private readonly alias = 'tap_payload_hmac_v1'
constructor(private readonly mac: MacProvider) {}
async createMac(envelope: TapEnvelopeV1): Promise<Uint8Array> {
await this.mac.ensureKey(this.alias)
const input = canonicalBytes(envelope)
return this.mac.sign(this.alias, input)
}
async verifyMac(
envelope: TapEnvelopeV1,
expected: Uint8Array
): Promise<boolean> {
const actual = await this.createMac(envelope)
return constantTimeEqual(actual, expected)
}
}
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
return diff === 0
}
这里把"校验"实现为重新计算后做恒定路径比较,避免普通字符串比较在提前退出时暴露明显的时间差。状态只有在比较成功后才进入 MAC_VERIFIED。需要注意,ArkTS 层的循环只是说明比较语义;若安全要求较高,应优先使用经过验证的原生密码库或平台校验能力,并结合实际编译器行为评估,而不是把这十行代码视为密码学保证。
密钥别名不是秘密,但日志中仍只记录它的版本后缀。HuksMacProvider 不应缓存可导出的原始 key,也不应在失败重试时重复生成新 key。更换密钥时要保留 keyId,让接收端在短暂迁移期内按白名单选择旧、新版本,不能看到未知 keyId 就"尝试所有密钥"。
四、重放窗口需要原子占位
HMAC 正确只说明消息内容未被改动,不说明它是第一次出现。TapSeal 用 issuedAtMs 判断新鲜度,用 nonce 标识短期唯一消息。当前演示时间为 08:24,样本签发时间 08:23:42,消息年龄 18 s,未超过 90 s;同时允许 ±15 s 的时钟偏差,避免轻微不同步把合法消息误判为未来消息。
检查顺序不能写反。先校验 MAC,再使用消息中的时间戳和 nonce;否则攻击者可以构造大量无效签名占满缓存。校验后也不能先查询 has(nonce)、执行业务、最后再写入,因为两个并发回调会同时看到"不存在"。需要一个原子的 reserve:首次写入成功者获得提交权,其余请求返回 NONCE_REUSED。
这段代码解决什么问题:在限定时间内原子占用 nonce,阻止同一份已认证载荷被并发或延迟重复执行。
ts
interface ReplayStore {
reserve(key: string, expiresAtMs: number): Promise<boolean>
remove(key: string): Promise<void>
}
class ReplayWindow {
private readonly ttlMs = 90_000
private readonly skewMs = 15_000
constructor(
private readonly store: ReplayStore,
private readonly now: () => number
) {}
async reserve(taskId: string, nonce: string, issuedAtMs: number): Promise<void> {
const age = this.now() - issuedAtMs
if (age < -this.skewMs) throw new Error('ISSUED_IN_FUTURE')
if (age > this.ttlMs + this.skewMs) throw new Error('MESSAGE_EXPIRED')
const replayKey = `${taskId}:${nonce}`
const ok = await this.store.reserve(replayKey, issuedAtMs + this.ttlMs)
if (!ok) throw new Error('NONCE_REUSED')
}
}
在首个样本中,缓存条目从 0 变为 1,状态进入 FRESH。第二次提交相同的 TAP-SEAL-0066:n-9f31 时,reserve 返回 false,页面停在 NONCE_REUSED,不会再次调用业务提交。实际项目要选择跨页面、跨进程还是跨设备的存储范围;内存 Map 只能覆盖页面存活期。若应用进程重启后重复执行仍有成本,就应使用持久化事务或服务端幂等键。

上图是与本文数据口径一致的 DevEco Studio 风格演示配图,不是实际 IDE 截图或测试证据。右侧模拟器显示 MAC_VERIFIED 与 18 s 消息年龄,底部 HiLog 只输出 taskId、状态、耗时和错误码,避免记录 MAC、完整载荷或个人数据。
五、状态机比一串 try-catch 更容易审计
认证链路最怕"某个异常被 catch 后仍继续执行"。TapSeal 把每一步的输入、成功状态和失败码固定下来,只有 MAC_VERIFIED 才能进入新鲜度检查,只有 FRESH 才能进入业务提交。任何异常都转换为 REJECTED,同时保留最后一个成功状态供诊断。
这段代码解决什么问题:把认证、重放门禁和业务提交组织成单向状态机,保证失败后不会穿透到后续动作。
ts
type AuditState =
'RECEIVED' | 'CANONICALIZED' | 'MAC_VERIFIED' |
'FRESH' | 'ACCEPTED' | 'REJECTED'
async function acceptEnvelope(input: SignedEnvelope): Promise<AuditState> {
audit.move('RECEIVED')
const value = parseAndValidate(input.payload)
canonicalBytes(value)
audit.move('CANONICALIZED')
if (!(await seal.verifyMac(value, input.mac))) {
audit.reject('MAC_MISMATCH')
return 'REJECTED'
}
audit.move('MAC_VERIFIED')
try {
await replay.reserve(value.taskId, value.nonce, value.issuedAtMs)
audit.move('FRESH')
await commandBus.commitOnce(value.taskId, value.action)
audit.move('ACCEPTED')
return 'ACCEPTED'
} catch (error) {
audit.reject(toSafeCode(error))
return 'REJECTED'
}
}
状态只能向前移动,不允许 UI 自己把 REJECTED 改回 FRESH。这里还有一个取舍:nonce 已经占位后,业务提交失败是否释放?TapSeal 默认不释放,而是让相同 taskId 进入可查询的失败结果,避免攻击者利用重试反复触发有副作用的前置步骤。只有在业务事务确定完全没有产生外部影响时,才可以设计受控补偿,不能简单 remove(nonce)。
六、运行页只展示决策所需信息
运行页的目标不是把所有技术字段塞满屏幕,而是回答四个问题:收到哪项任务、当前走到哪一步、这份消息还有多长有效期、接受按钮是否可用。演示样本显示 taskId TAP-SEAL-0066、nonce n-9f31、864 B、消息年龄 18 s、TTL 90 s,进度 86%。

红色标注指向 MAC_VERIFIED → FRESH,强调页面进度不是网络下载进度,而是门禁完成度。按钮在 FRESH 前不可点击;进入 ACCEPTED 后变为只读结果。这样即使页面重组或回调晚到,也不会让用户在认证尚未结束时触发业务动作。
演示里 86% 是 UI 对五阶段任务的视觉映射,不是加密强度,也不是系统传输质量。实际产品若展示百分比,应说明其语义;否则用离散状态比伪精确进度更诚实。
七、诊断页必须同时呈现正例和反例
只展示"首个请求通过"无法证明重放窗口工作。TapSeal 的诊断页连续执行三组固定向量:原始载荷首次进入,结果 ACCEPTED;原样再次提交,结果 NONCE_REUSED;把金额从 1280 改为 1290 但保留原 MAC,结果 MAC_MISMATCH。这三条结果分别验证正常路径、时间窗口和完整性门禁。

图中缓存变化为 0 → 1,第二次请求不再增加。状态链清楚区分 MAC_VERIFIED 与 FRESH,因为前者通过不代表后者一定通过。调试时最常见的误判是把"MAC 正确但 nonce 重复"写成签名错误,这会让线上分析完全走错方向。
日志建议使用稳定错误码而不是异常全文:TAP_1001 MAC_MISMATCH、TAP_1002 MESSAGE_EXPIRED、TAP_1003 NONCE_REUSED、TAP_1004 KEY_UNAVAILABLE。错误码可以统计,异常全文可能包含底层路径、参数或敏感数据。安全日志还要限制保留周期和访问权限。
八、密钥轮换、时钟和离线场景的边界
HMAC 的难点常常不在计算,而在密钥分发。若两个设备从未共享密钥,仅靠碰一碰动作无法凭空获得安全的共同秘密。可行方案包括服务端下发账号级短期密钥、预配设备组密钥,或使用非对称签名让发送端持私钥、接收端只持公钥。具体选型取决于设备归属、离线要求和密钥撤销能力。
完全离线时,时钟校验也只能作为近似门禁。设备时间被手动修改后,18 s 可能不再可信。高价值操作应将服务端序列号、挑战值或一次性票据纳入协议;离线业务则应缩短窗口,并在联网后对账。本文的 90 s 和 ±15 s 只是演示参数,不是通用安全标准。
密钥轮换要与消息窗口配合。若 08:24 切换 key v2,仍可能收到 08:23:42 由 v1 产生且处于 TTL 内的消息。接收端可以在短暂重叠期接受 v1,但必须限定 keyId、签发时间和下线点。到期后删除旧密钥与旧 nonce 条目,不能无限期保留"兼容路径"。
九、测试不只比较两个摘要
单元测试至少要覆盖规范化黄金向量、MAC 篡改、未来时间、过期时间、重复 nonce、并发 reserve、应用重启和密钥不可用。并发测试必须同时发出两个相同请求,断言只有一个获得提交权;顺序调用两次不足以暴露 has-then-set 竞态。
集成测试还要验证 UDMF 记录提取边界:没有目标记录、记录超过大小上限、schema 不支持、字段类型错误、额外字段和编码异常。解析层先做尺寸限制,再解码和构造对象,避免用一份超大文本消耗过多内存。测试日志中使用虚构数据,不能把生产密钥或真实核销码复制进测试包。
性能上,864 B 的 HMAC 开销通常不是瓶颈;密钥会话初始化、持久化 nonce 和页面回调才更值得测量。建议分别记录 canonicalizeMs、macMs、reserveMs、commitMs,而不是只报一个总耗时。慢在存储时就优化索引与清理策略,不要通过扩大重放窗口掩盖问题。
十、把安全结论写到边界上
TapSeal 最终得到一条可解释的链路:统一数据到达后先固定字节;HUKS 适配层完成 HMAC;比较通过后再检查时间与 nonce;原子占位成功后才允许业务幂等提交。首个样本 ACCEPTED,重复样本 NONCE_REUSED,篡改样本 MAC_MISMATCH。这些结果是演示向量,不是未经展示的真机测量。
工程上最重要的结论不是"加了 SHA256 就安全",而是每层只做自己能证明的事。UDMF 负责统一数据表达,HMAC 负责应用载荷完整性,重放窗口负责短期首次消费,业务事务负责长期幂等。只要把四者混成一个布尔值,问题就会在异常恢复和密钥轮换时重新出现。
1. 规范化协议要有可审查的版本
真正落地时,我会把规范化规则当成独立协议资产,而不是某个页面中的工具函数。版本说明至少写明字段次序、字符编码、换行符、整数表示、空值策略、最大长度和未知字段处理。发送端与接收端共享同一组黄金向量:输入对象、规范文本、UTF-8 十六进制和期望 MAC 四项缺一不可。这样代码重构、编译器升级或跨语言实现后,只要向量没有变化,就能确认字节契约仍然一致。
字段也不宜无限加入签名域。展示文案、主题色、临时调试字段经常变化,放入核心签名域会扩大兼容成本;真正影响业务决策的 taskId、动作、金额、签发时间和 nonce 则必须被认证。若新增字段改变旧字段语义,应升级 schema 并重新定义完整规范,不要让同一个版本在不同客户端里拥有两套解释。
2. 接收入口先限流、限长,再做密码运算
安全链路不是越早调用密码算法越好。接收端首先检查记录数量、媒体类型和载荷上限,例如本 Demo 只接受一个目标业务记录,文本与 MAC 合计不得超过预设阈值。明显不符合结构的数据直接拒绝,避免攻击者用大量超长消息反复建立 HUKS 会话。结构检查不读取业务敏感字段,也不根据未认证的 action 分流到不同服务。
通过尺寸门禁后,再解析为严格对象。ArkTS 的结构类型不会自动验证运行时 JSON,仍需逐字段检查字符串、整数和枚举范围。amountFen: "1280" 不能因为看起来能转成数字就被接受,schema: 1.0 也应按协议规则处理。宽松转换会让发送端和接收端得到不同规范字节,最后把数据质量问题伪装成签名故障。
3. 页面生命周期不能拥有安全结论
认证任务可能在页面切换后才返回。若 ViewModel 销毁,旧回调不应更新新页面,也不应再次弹出"已接受"。TapSeal 给每次接收创建 auditId,并让安全服务独立持有状态;页面只是订阅只读快照。取消订阅只停止 UI 更新,不取消已经进入业务事务的提交。反过来,尚未获得 nonce 的任务可以安全取消并释放 HUKS 会话。
同一 taskId 多次到达时,UI 列表按 auditId 展示,业务提交仍按 taskId 幂等。二者不能混用:taskId 表达业务对象,auditId 表达一次观察,nonce 表达短期消息实例。把三者都叫 requestId,会让重试、回放与日志关联变得无法解释。
4. 清理策略也属于重放窗口设计
nonce 记录不能永久增长。清理线程可以按 expiresAt 删除过期条目,但删除只表示短期窗口结束,不代表业务允许再次执行。长期重复仍由 commandBus 的幂等表阻止。为了避免应用启动时一次清理过多数据,可采用分批删除或按日期分区;清理失败不应默认放行,而应进入保守拒绝或降级到服务端确认。
离线设备需要考虑存储回滚。用户恢复旧备份后,nonce 表可能倒退,而业务数据已经执行。高价值操作应把单调服务端序列、设备证明或一次性挑战纳入设计。本文的本地 ReplayStore 更适合低风险、短窗口的交接,不适合脱离中心账本的高价值支付。
5. 可观测性应记录判定,不记录秘密
建议为每个阶段记录耗时、结果码、schema、keyId 和脱敏 taskId 哈希。不要记录规范文本、完整 nonce、MAC、密钥别名映射关系或用户内容。定位 MAC_MISMATCH 时,开发环境可以输出黄金向量编号和字节长度,生产环境只输出双方协议版本与安全错误码。这样既能判断是兼容问题还是攻击流量,又不把调试系统变成第二份敏感数据仓库。
告警也应按比例而不是单条触发。偶发 MESSAGE_EXPIRED 可能只是用户停留过久;同设备短时间大量 MAC_MISMATCH 或 NONCE_REUSED 才值得提升等级。安全结论来自模式、上下文与业务损失,不能由一条红色日志代替。
6. 上线前的最小验收表
发布前可以用六个问题做最后检查:规范字节是否有版本和黄金向量;HUKS 适配层是否从不导出原始密钥;MAC 比较是否避免明显的提前退出;nonce 占位是否原子且先于业务副作用;页面退出后晚到回调是否失去提交权;错误日志是否只包含安全码和脱敏标识。任意一项回答不确定,都应该停留在灰度环境继续验证。
同时要在至少两台时钟存在偏差的设备上演练过期边界,在进程被系统回收后验证持久化窗口,在并发压测中确认同一 nonce 只有一次业务提交。把这三类场景跑通,比再增加一种摘要算法更能提升链路可信度。
参考资料: