签名
可选的记录级 `sigs` 数组——覆盖整个记录体的分离式 COSE_Sign1、经域分隔的待签名载荷、两种签名者公钥承载方式,以及严格的 Ed25519 验证。
一条 Label 309 记录 MAY 在可选的顶层 sigs 数组中携带一个或多个作者身份签名。每一项都是一个覆盖记录体的分离式
COSE_Sign1(RFC 9052),用以证明某把密钥为该记录背书。作者身份始终是可选的——标准从不要求附带签名,一条不带
sigs 字段的记录本身就是一份完整、可被完全验证的存在性证明(PoE)。
签名是叠加性的:它在时间戳声明之上回答「这把密钥也为它背书」,而绝不是用来取代时间戳声明。内容哈希是首要声明;签名只是关于「谁为该声明背书」的元数据。尤为关键的是,验证器无法核验的签名——算法不受支持、密钥无法解析——绝不会让内容或时间戳声明失效。签名失败是柔性的;存在性不会因此动摇。
本页定义签名覆盖的范围、被签名的确切字节、签名者公钥的两种承载方式,以及公开验证器执行的严格验证。Ed25519 密钥本身在 密钥 中定义;线上的
sigs 字段——其中 cose_sign1 和 cose_key 各自都是单个 CBOR 字节串——在 记录 中定义。
签名覆盖什么
单个 sigs[i] 项统一地证明整个记录体。它没有逐项、逐 URI 或逐字段的签名粒度:一个签名承诺覆盖每一个 item、每一个存储 URI、每一个加密信封、(若存在的)supersedes
指针,以及记录携带的每一个扩展键。中继方事后无法增删或改写其中任何一项而不破坏签名。
被签名的记录体是移除了 sigs 字段的记录映射——remove_keys(record_map, ["sigs"]),本文记作 record_body。每一项的签名都把 sigs
数组排除在签名范围之外,因为签名无法覆盖自身,也因为每个签名者只为声明本身背书,而不为联署人名单背书。具体而言,每一项签名的都是 {v, items?, merkle?, supersedes?, crit?, <extensions?>}——对每一项而言都是同一份 record_body 字节——但没有任何一项会签名 sigs 中的其他项。因此,一个签名者证明的是「我所签的这份记录体,正是其他每一项所绑定的同一份记录体」;没有任何签名者去证明哪些其他签名者参与了联署。
签名的覆盖范围是记录体,而非交易
一个通过验证的签名证明:某把密钥对记录体生成了一个签名。它并不证明同一把密钥提交了承载它的交易、支付了交易费,或选定了区块时间。任何一方都 MAY 在之后的交易中重新发布一份完全相同的记录体——这正是有意为之的记录可移植性。请把通过验证的签名呈现为「由 <key> 签名」,而绝不要呈现为 「<key> 提交了此交易」或「由 <key> 在 <time> 发布」。
待签名的载荷
每一项携带的都是分离式 COSE_Sign1,因此 COSE 的 payload 字段为空,真正被签名的字节由验证器从链上记录重建。签名者计算:
record_body = remove_keys(record_map, ["sigs"])
record_body_bytes = canonical_cbor(record_body)
SIG_DOMAIN_RECORD = utf8("cardano-poe-record-sig-v1") ; 25 bytes
to_sign = SIG_DOMAIN_RECORD || record_body_bytes ; concatenation
Sig_structure = [ "Signature1", protected, h'', to_sign ]
signature = Sign(canonical_cbor(Sig_structure), signer_key)record_body 按 RFC 8949 §4.2.1
序列化为规范 CBOR——与整条记录采用的确定性编码一致。正是这种确定性让签名可以互操作:两套实现对同一份逻辑记录体编码后,会得到逐字节相同的
record_body_bytes,因此一套实现产生的签名能在另一套实现下通过验证。
域分隔前缀
to_sign 是把 25 字节的 UTF-8 字符串 cardano-poe-record-sig-v1 拼接在 record_body_bytes
前面所得。该前缀把签名绑定到它在 Label 309 中的角色上,并防止跨协议重放。即便某个未来的 Cardano 元数据方案恰好与本记录体的 CBOR
结构相同(键相同、类型相同),它也无法把一个 Label 309 签名拿来对自己复用:它的 to_sign
会带有不同的前缀,或者根本没有前缀,于是被签名的字节序列就会不同,签名也就无法通过。实现 MUST 把这串字面字节序列原样嵌入为
to_sign 的起始字节;只签名裸的规范 CBOR、不加前缀,是不合规的。
为什么 external_aad 为空
Label 309 把域分隔符放在 to_sign 内部,而不是放在 COSE 的 external_aad 里。external_aad
槽位(Sig_structure[2])始终是空字节串 h''。这是对「把域字符串塞进 external_aad」这一惯常 COSE
做法的有意偏离,原因在于钱包互操作性:
CIP-30
signData——Cardano 上标准的钱包签名路径——明确规定不使用 external_aad,并且不给 dApp
任何途径去提供一个。一个非空的 external_aad 会让每一个由钱包产生的签名都验证失败。把前缀嵌入载荷,既完整保留了相同的防重放性质,又让钱包产生的字节与验证器重算的字节逐字节相等。
Sig_structure
Sig_structure 是 RFC 9052 §4.4
中那个由 4 个元素构成的 COSE_Sign1 签名数组:
| 槽位 | 取值 | 说明 |
|---|---|---|
[0] | "Signature1" | 固定的 COSE 上下文标识符,作为完整的 CBOR 文本字符串(11 字节)发出,绝不发为裸 UTF-8。 |
[1] | protected | 签名者经 bstr 包裹的规范 CBOR 受保护头字节,原样使用——验证器绝不重新做规范化。 |
[2] | external_aad | 始终为 h''(零长度 bstr)。 |
[3] | to_sign | 25 字节前缀拼接上 record_body_bytes。 |
发布出来的 COSE_Sign1 把它的 payload 字段(COSE_Sign1[2])置为 CBOR null(0xF6)——即分离式形式。附带载荷(包括零长度字节串)会被拒绝。把载荷分离出来,正是为了把被签名的字节钉死在验证器独立重算出的那份记录体上;附带形式则会让产生方去签名一些与链上声明毫无关系的「借来的字节」。
硬件钱包的 hashed 模式
CIP-30 / CIP-8
定义了一个可选的非受保护头标志 "hashed": true,受限的硬件联署方可以设置它。当它存在且为真时,Sig_structure[3] 是 28 字节的
Blake2b-224(to_sign) 摘要,而不是 to_sign 本身;其余三个槽位保持不变。验证器在执行严格 Ed25519
验证之前 MUST 检查非受保护头并完成这一替换。软件与 SDK 产生方 SHOULD NOT
设置它——它既不节省线上字节,又使验证器的代码路径复杂化。
签名算法
v1 中唯一的签名算法是 EdDSA over Ed25519
(RFC 8032),由 COSE
alg = -8(RFC 9053 §2.2)标识,存放在
COSE_Sign1 的受保护头中。v1 验证器的强制基线是 {-8};它 MAY 额外接受 -19(Ed25519,完全指定形式),并在同一套 Ed25519
原语下验证这两个码点。注册表是可扩展的——未来的修订以叠加方式加入后量子签名,而绝不会构成破坏性变更。
签名者公钥的解析
公开验证器必须在不联系任何服务的前提下解析出签名者的公钥,因此每个签名都把它的密钥(或一个在签名内部、无歧义指向该密钥的引用)携带在链上。v1 中恰好有两种承载形式,且二者在单个项内互斥——同时使用两者的项是结构错误。
路径 1——身份签名(签名内的 kid)
32 字节的原始 Ed25519 公钥放在 COSE_Sign1 受保护头中的 COSE 头标签 4(kid,RFC 9052
§3.1)处。该项不携带 cose_key 字段。按照
Label 309 的约定,一个恰为 32 字节的受保护头 kid 就是公钥本身——而不是一个需要带外查找、指向某把公钥的不透明指针。32
字节这一长度是无歧义的判别依据:Ed25519 公钥永远是 32 字节。把密钥放在受保护头(而非非受保护头)中,会把它绑定到签名上;篡改它的攻击者会破坏验证。
这一约定是对 RFC 9052 中把 kid 读作不透明标识符那种解读的有意且有据可查的偏离;正是它让身份路径不依赖任何服务,无需任何密钥目录。密钥模型在
密钥 中定义。
路径 2——钱包签名(内联 cose_key)
一个 CIP-30 signData 签名会把签名者的公钥作为一个独立的 cbor<COSE_Key> 块返回,而不是放在 COSE_Sign1
内部。把这样一个签名串入记录的产生方 MUST 把那个 COSE_Key 作为单个 CBOR 字节串放进同一个 sigs[i] 项、置于键
cose_key 之下。验证器将其解码为 COSE_Key,并从标签 -2 读取 Ed25519 公钥。该 COSE_Key MUST 只描述公钥的那一半——kty = OKP (1)、crv = Ed25519 (6)、位于标签 -2 处的 32 字节 x——并且 MUST NOT 携带私钥材料(标签 -4
之类);把私钥标量发布到永久账本上,是一次不可逆的密钥泄露。
互斥
这两条路径在线级是互斥的。一个项要么携带一个 32 字节的受保护头 kid 且不带 cose_key(路径 1),要么携带一个
cose_key 字段且不带 32 字节的受保护头 kid(路径 2)——绝不会两者兼有。同时携带两者的项会被拒绝;验证器在验证时从不需要去消歧。因此,解析是一次线级的判别,而不是一个有优先级排序的取舍:
| 路径 | 条件 | 签名者公钥 |
|---|---|---|
| 1 | 32 字节受保护 kid,无 cose_key | 那个 32 字节的 kid 值,直接使用。 |
| 2 | 存在 cose_key,无 32 字节 kid | COSE_Key 标签 -2 处的 Ed25519 密钥。 |
仅携带在非受保护头中的 kid 不是一条受认可的解析路径:它处在签名信封之外,因此中继方可以改写它而不破坏签名。验证器
MUST 在解析时忽略非受保护头中的 kid 值。如果没有任何许可的路径能得出一把 32 字节的 Ed25519 密钥,则该项被报告为「未解析」,且不贡献任何作者身份声明。
验证
公开验证器按以下顺序独立核查每一个 sigs[i]:
- 解码。 把
sigs[i].cose_sign1字节串解析为一个 COSE_Sign1。payload 字段 MUST 为null(分离式);任何非 null 或非空的 payload 都属畸形。 - 算法。 读取受保护头中的
alg。如果它落在验证器支持的集合之外,则该项为不受支持(见下文)——而不是记录上的错误。 - 解析密钥。 套用上文的路径 1 / 路径 2 判别,得出 32 字节的 Ed25519 公钥。如果没有任何路径能得出一把,则该项为未解析。
- 重建并验证。 重建
to_sign与Sig_structure = ["Signature1", protected, h'', to_sign],对其做规范 CBOR 编码,并用严格 Ed25519 验证签名。(若非受保护头携带"hashed": true,则先把to_sign替换为Blake2b-224(to_sign)。) - 钱包绑定(仅路径 2)。 从解析出的密钥重算出质押地址,并把它与受保护头中的
address逐字节比对;即便 Ed25519 签名本身已通过验证,不匹配也会让该绑定失败。这项仅适用于路径 2 的检查,正是让 UI 能把一条记录呈现为「钱包绑定」的依据;路径 1 的项跳过它。
严格 Ed25519
验证遵循 RFC 8032 §5.1.7 的严格规则——对任意给定的密钥、消息与签名,恰好只有一个可接受的答案:
R或签名标量S的非规范编码(尤其是任何S ≥ ℓ,即群的阶)MUST 被拒绝。- 小阶 / 小子群 / 带挠分量的公钥与
R值 MUST 被拒绝。 - 带余因子的验证方程(ZIP-215 / 利于批量验证的那种形式)MUST NOT 用来替代严格方程。
正是这种严格性让裁决在各套实现间可复现:一个带余因子的验证器会接受严格验证器拒绝的签名,于是两个合规的验证器就会出现分歧。实现必须选用一个执行严格、不带余因子验证的库——或者库的某种模式。
裁决语义
签名是叠加性的,因此一个无法验证的签名是报告在该项上,而不是上升为记录级的失败。每个 sigs[i] 都会归结为下列某个带类型的逐项结果;完整的错误目录与记录级裁决规则见
验证:
| 结果 | 含义 |
|---|---|
| verified | 严格 Ed25519(对路径 2,还包括地址绑定)通过。 |
| signature unsupported | 受保护头中的 alg 落在验证器的集合之外。仅为信息,绝非错误。 |
| signer key unresolved | 没有任何许可的路径能得出一把 32 字节的 Ed25519 公钥。 |
| signature invalid | 严格 Ed25519 对重建出的 Sig_structure 返回了 false。 |
| wallet address mismatch | 路径 2:签名通过了验证,但重算出的质押地址 ≠ 声称的那个。 |
一个不受支持的签名绝不会让证明失效
一个无法识别或不受支持的签名算法,会产生一个带类型、信息级严重度的 signature-unsupported
结果。内容与时间戳声明——链上的 hashes
承诺——无论验证器实现了哪些签名算法,都在结构上有效。一条只携带未来算法签名的记录,照样会呈现为一份有效的存在性证明,而每一个这样的项都被标记为不受支持。签名是叠加性的;存在性并不依赖它们。