云端 Mac 上的 iOS 请求签名验收:HMAC、规范化与重放边界

云端 Mac 上的 iOS 请求签名验收:HMAC、规范化与重放边界

Windows 上开发的跨平台应用接入签名接口后,普通请求通过,带中文查询或 JSON 正文的请求却失败。把签名模块放到云端 Mac 单独运行,先验证字节契约,再接回 iOS 工程,比在远程桌面里反复点接口更容易定位。

下面用 Swift 与 CryptoKit 建立离线验收脚本。不需要真实接口、生产密钥或签名证书;客户端和服务端应共同保存这组测试夹具。

先定契约

HMAC-SHA256 处理的是字节,不是“看起来相同”的请求。先约定六个字段:协议版本、方法、请求目标、时间戳、随机数,以及正文的 SHA-256 摘要。

本例的方法固定为大写;请求目标包含路径和原始查询串,不含片段;时间戳使用十进制整数秒;随机数使用每次请求新生成的值。正文摘要输出小写十六进制。

不要自行排序查询参数。重复键、空值、百分号编码及参数顺序是否等价,必须由接口协议决定。客户端应在最终编码完成后签名,服务端则在框架改写路径或查询参数之前取得约定表示。

HMAC 证明消息与密钥匹配,不加密正文,也不自动阻止重放。它不能替代传输加密,更不能把内置在应用包里的共享密钥变成可靠的身份凭据。

固定字节

不要简单用换行连接字段:字段内出现换行就可能产生歧义。本例给每个 UTF-8 字段加八字节无符号大端长度前缀,再连接字段内容。服务端必须实现完全相同的规则。

将下面内容保存为 request-signing.swift。先在云端 Mac 执行 xcrun swift --version 确认工具链,再运行 xcrun swift request-signing.swift。脚本成功时正常退出,断言失败则返回非零状态。

import Foundation
import CryptoKit

func hex<S: Sequence>(_ bytes: S) -> String
where S.Element == UInt8 {
    bytes.map { String(format: "%02x", $0) }.joined()
}

func frame(_ fields: [String]) -> Data {
    var output = Data()
    for field in fields {
        let bytes = Data(field.utf8)
        var length = UInt64(bytes.count).bigEndian
        withUnsafeBytes(of: &length) {
            output.append(contentsOf: $0)
        }
        output.append(bytes)
    }
    return output
}

func canonical(
    target: String,
    body: Data,
    timestamp: String,
    nonce: String
) -> Data {
    frame([
        "request-v1",
        "POST",
        target,
        timestamp,
        nonce,
        hex(SHA256.hash(data: body))
    ])
}

let vectorKey = SymmetricKey(
    data: Data(repeating: 0x0b, count: 20)
)
let vector = HMAC<SHA256>.authenticationCode(
    for: Data("Hi There".utf8),
    using: vectorKey
)
precondition(
    hex(vector) ==
    "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
)

let key = SymmetricKey(data: Data(repeating: 0x42, count: 32))
let body = Data(#"{"count":1}"#.utf8)
let target = "/v1/orders?a=1&b=2"
let timestamp = "1700000000"
let nonce = "fixture-nonce-001"

let message = canonical(
    target: target, body: body,
    timestamp: timestamp, nonce: nonce
)
let tag = Data(HMAC<SHA256>.authenticationCode(
    for: message, using: key
))

func accepts(_ candidate: Data) -> Bool {
    HMAC<SHA256>.isValidAuthenticationCode(
        tag, authenticating: candidate, using: key
    )
}

precondition(accepts(message))
precondition(!accepts(canonical(
    target: target, body: Data(#"{"count":2}"#.utf8),
    timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
    target: "/v1/orders?b=2&a=1", body: body,
    timestamp: timestamp, nonce: nonce
)))
precondition(!accepts(canonical(
    target: target, body: body,
    timestamp: timestamp, nonce: "fixture-nonce-002"
)))

固定密钥、时间戳和随机数仅用于夹具。标准向量验证密码算法调用,后面的断言验证协议字段是否真正参与签名;两类测试不能互相替代。

补齐差异

接入应用时,只生成一次最终正文 Data,同时交给摘要计算和网络请求。不要签名后再次执行 JSON 编码,也不要让中间件静默替换正文。

检查编码

增加三组夹具:中文与表情查询、重复查询键、空正文与空 JSON 对象。每组保存字段内容、规范化字节的十六进制值及预期签名,由两端分别读取,禁止由被测函数现场生成预期答案。

特别区分空字符串、缺失字段和字符串 "null"。如果协议不允许某种输入,应在进入 HMAC 前拒绝,而不是悄悄补默认值。

检查传输

反向代理可能重写路径前缀,框架也可能把 %20 与加号解码成同样的空格。应明确签名针对外部请求目标还是内部路由,并在对应边界采集字节。

排错时只记录夹具或脱敏后的摘要。生产正文、认证头和密钥不要进入构建日志;密钥也不要通过命令行参数传入。

拦住重放

签名通过后,服务端仍需检查时间戳的格式和允许偏差。窗口长度由业务定义,测试至少覆盖窗口内、恰好到边界、刚越界及未来时间四种输入。

随后以“凭据标识+随机数”为去重键执行原子写入,并保留到该请求不再可能通过时间验证。先查询再插入存在竞态:两个并发请求可能一起通过,必须依赖原子条件写入或唯一约束。

离线脚本中的旧时间戳不能用于在线验收。在线请求应读取当前时间,并生成新的随机数。重试是否复用业务幂等键,需要单独约定,不能把随机数去重当成订单幂等。

接入门禁

先把脚本纳入提交检查,再将同一夹具接入服务端测试。除了正文变化,还要增加错误密钥、截断签名和非法签名编码的拒绝用例。接收端应使用密码库的验证函数,不用普通字符串相等比较代替。

最后并发发送两份完全相同的有效请求,确认只有一份进入业务处理;再模拟有效窗口内的合法重试,检查是否获得约定的幂等结果。

保留工具链版本、夹具版本和失败断言编号即可。验收完成的标准不是“一次请求成功”,而是双方对同一组字节得出同一签名,对非法输入和重复请求给出一致、可重复的拒绝结果。

常见问题

HMAC 校验通过,是否就能阻止重复请求?

不能。捕获到的完整请求仍可重复提交。服务端还要验证时间窗口,并按凭据标识与随机数组合执行原子去重;业务重试则需要另行定义幂等键。

签名时应该先把 JSON 解析后重新编码吗?

本方案不重新编码。客户端对最终发送的正文 Data 求摘要,服务端对收到的原始正文执行相同操作,避免键顺序、空格和数字格式变化导致签名不一致。

租一台云端 Mac

按天、周、月、季租用独享物理机。按项目安排构建,到期不续即停。

选机型

开始安排出包

选好机型与租用周期,核对到期日。到期不续即停。