HarmonyOS 7 + ArkTS-HUKS:精准碰一碰载荷的签名校验与重放窗口【鸿蒙心迹】

一次跨设备业务交接是否可信,不能只看"碰一碰已经触发"和"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 只有一次业务提交。把这三类场景跑通,比再增加一种摘要算法更能提升链路可信度。

参考资料:

相关推荐
花花_11 小时前
HarmonyOS 7 状态手记 01|页面状态别乱放
华为·harmonyos
李游Leo2 小时前
HarmonyOS 7 + Hvigor-Localization Kit:多语言资源占位符签名与回退链路预检【鸿蒙心迹】
harmonyos
李游Leo2 小时前
HarmonyOS 7 AVSession + Float Window:闪控窗播放控制权的会话移交与重复指令去重【鸿蒙心迹】
华为·harmonyos
李游Leo3 小时前
HarmonyOS 7 + ArkTS-UDMF:精准碰一碰载荷的协议版本协商与字段降级【鸿蒙心迹】
华为·harmonyos
李游Leo4 小时前
HarmonyOS 7 + Core Vision Kit-TaskPool:文搜图相似度阈值分桶与硬负样本回流【鸿蒙心迹】
华为·harmonyos
李游Leo4 小时前
HarmonyOS 7 EasyGo + ArkUI Navigation:平行视界导航模式的列表选中态、右栏路由栈与全局返回收口【鸿蒙心迹】
华为·harmonyos
李游Leo5 小时前
HarmonyOS 7 + ArkUI Accessibility:闪控窗紧凑态的语义树裁剪与读屏焦点收敛【鸿蒙心迹】
华为·harmonyos
李游Leo5 小时前
HarmonyOS 7 ArkUI Navigation + WindowStage:折叠屏窗口宽度驱动的单双栏切换与草稿状态保持【鸿蒙心迹】
华为·harmonyos
HwJack205 小时前
【共创稿事节】HarmonyOS 7材质与光照:真实感的营造与性能的平衡
pytorch·harmonyos·材质