云端 Mac 上验收 iOS Ed25519 签名:标准向量与消息字节契约

云端 Mac 上验收 iOS Ed25519 签名:标准向量与消息字节契约

跨区团队交接签名配置时,常遇到“服务端签成功,iOS 验证失败”:双方展示的 JSON 相同,重新生成密钥也没有改善。先在云端 Mac 建立一个不依赖业务接口的验签基线,再核对原始字节,才能分清密码学调用、传输解码与消息重建各自的问题。下面使用 Swift 与 CryptoKit,只接收公开测试数据,不需要生产私钥。

先固定算法与输入契约

约定算法为普通 Ed25519,公钥使用 32 字节原始表示,签名使用 64 字节原始表示。CryptoKit 对应 Curve25519.Signing.PublicKey;不要因为名称相似,换成用于密钥协商的类型。

签署方必须说明签的是响应正文原始字节,还是某种规范化结果。若使用原始正文,客户端应先验签再解析,不能解析 JSON 后重新编码再验证。若双方约定规范化,则要固定规则版本及测试样本。

Ed25519 签名证明消息与可信公钥相符,不负责保密,也不自动阻止重放。公钥若来自可被替换的同一条响应,验签成功仍不足以建立信任。

还要区分普通 Ed25519 与 Ed25519ph。对消息先做摘要再交给普通接口,并不等于切换到了预哈希变体;协议不能只写“都是 Ed25519”就省略这些条件。

用公开标准向量跑通最小验证

在已经装好开发工具的云端 Mac 中执行 xcrun swift --version,记录工具链与 sw_vers 输出。把下面内容保存为 verify.swift,再运行 xcrun swift verify.swift。代码采用 RFC 8032 的空消息测试向量;公开十六进制数据不是业务密钥。

import Foundation
import CryptoKit

enum FixtureError: Error {
    case invalidHex
    case checkFailed
}

func hex(_ text: String) throws -> Data {
    let chars = Array(text.utf8)
    guard chars.count % 2 == 0 else {
        throw FixtureError.invalidHex
    }
    var bytes = [UInt8]()
    for offset in stride(from: 0, to: chars.count, by: 2) {
        let pair = String(
            decoding: chars[offset..<(offset + 2)],
            as: UTF8.self
        )
        guard let value = UInt8(pair, radix: 16) else {
            throw FixtureError.invalidHex
        }
        bytes.append(value)
    }
    return Data(bytes)
}

func require(_ value: Bool) throws {
    guard value else {
        throw FixtureError.checkFailed
    }
}

let publicBytes = try hex(
    "d75a980182b10ab7d54bfed3c964073a0" +
    "ee172f3daa62325af021a68f707511a"
)
let signature = try hex(
    "e5564300c360ac729086e2cc806e828a8" +
    "4877f1eb8e5d974d873e0652249015555" +
    "5fb8821590a33bacc61e39701cf9b46bd" +
    "25bf5f0595bbe24655141438e7a100b"
)
try require(publicBytes.count == 32)
try require(signature.count == 64)

let key = try Curve25519.Signing.PublicKey(
    rawRepresentation: publicBytes
)
try require(key.isValidSignature(signature, for: Data()))
try require(!key.isValidSignature(
    signature, for: Data([0x00])
))

var altered = signature
altered[altered.startIndex] ^= 1
try require(!key.isValidSignature(altered, for: Data()))
try require(!key.isValidSignature(
    Data(signature.dropLast()), for: Data()
))

var rejectedKey = false
do {
    _ = try Curve25519.Signing.PublicKey(
        rawRepresentation: Data(publicBytes.dropLast())
    )
} catch {
    rejectedKey = true
}
try require(rejectedKey)

脚本成功时无输出,失败时抛出错误并以非零状态退出。这里用可抛出的检查而非 assert,避免优化编译时断言被移除。先确认这组固定数据能通过,再接入业务样本,否则同时排查网络与密码接口会扩大变量范围。

把文本差异缩小到字节差异

保留一份签署前的消息文件和一份客户端实际收到的文件。对公开夹具可用 cmp 比较,再用 xxd -g 1 查找首个差异;生产正文可能含敏感信息,不应直接加入共享日志。

检查顺序从容易被编辑器改变的内容开始:末尾是否多了换行、换行是 LF 还是 CRLF、文件开头是否带 UTF-8 BOM、字符是否发生了 Unicode 规范化。视觉相同的重音字符,也可能对应不同字节序列。

构造文件时不要随手用 echo,它可能附加换行。使用文件原始数据读取消息,而不是先转成字符串再转回数据。若协议确实签署字符串,就明确采用 UTF-8,并为末尾换行、空消息及组合字符分别建立夹具。

单独验收传输封装

如果接口传的是 Base64,先解码,再检查长度;十六进制的 128 个字符不是 128 字节签名。原始公钥也不等于包含头部信息的密钥文件,不能把封装后的整份数据直接传给 rawRepresentation。

禁止解码失败后退回空数据继续验证。应把“封装非法”“长度错误”“签名不匹配”分成内部错误类型,对外则避免暴露不必要的解析细节。

建立能发现接线错误的反例集

最小反例集至少包含:正确消息与正确签名、消息增加一个零字节、签名翻转一位、签名截断、公钥截断,以及另一把有效公钥验证原签名。最后一项必须使用真实有效的不同公钥,不能只靠损坏公钥触发初始化失败。

业务层再添加“正文正确但签名属于上一条消息”的样本,检查代码有没有错误复用缓存结果。若签署内容包含版本、有效期或用途标识,也要验证这些字段确实进入被签字节,而不是仅放在未签名的外层对象里。

这些测试验证的是拒绝路径,不应使用随机损坏代替固定夹具。固定样本才能让不同节点、不同班次的成员复现同一个失败。

用证据清单完成跨区交接

交接时记录提交号、系统与工具链版本、算法变体、公钥标识、消息字节数、签名字节数和用例结果。公开夹具可一并保存;真实业务消息应按敏感级别受控留存,摘要也不能替代原始消息完成验签。

命令行基线通过后,把相同夹具放进 iOS 测试目标,调用应用实际使用的验签封装。这样才能覆盖 Base64 解码、公钥选择和消息读取路径,而不只是证明 CryptoKit 本身能工作。

最终完成标准不是“验过一次”,而是正确输入稳定通过、每个反例明确失败、两端对被签字节没有歧义,并且另一班成员无需私钥就能重跑全部检查。

常见问题

服务端签名时用了私钥,客户端验收也需要私钥吗?

不需要。客户端只需可信公钥、原始消息字节与签名字节。验收仓库可以保存公开标准向量,但不应接收生产私钥。

两个 JSON 对象字段相同,为什么验签仍然失败?

Ed25519 验证消息字节,不验证 JSON 的语义等价性。键顺序、空白、换行或 Unicode 表示不同都会改变消息;应验证收到的原始字节,或由双方明确约定规范化格式。

为下一项任务选择云端 Mac

按团队主要所在地选择节点,使用整机独享的物理 Mac mini。浏览器远程桌面需要现代浏览器与稳定网络;配置、计费周期和实际可用状态请在下单前核对。

把下一次连接安排在近处

先核对配置与周期,再选择团队主要使用地区附近的节点。