实现者指南
如何构建一个符合规范的 Label 309 实现——推荐的分层架构、跨语言字节级一致的契约,以及定义互操作性的一致性测试向量。
Label 309 是一套线格式和一组密码学构造,而不是一款产品。 任意数量的独立实现——用 TypeScript、Python、Rust、Go,或某个原生移动端运行时编写——都可以共存,并且一个实现产出的记录 MUST 能在另一个实现下通过验证。本页面写给打算构建这类实现的团队。它描述了让密码学暴露面始终可审计的架构、让两个实现得以互操作的精确契约,以及用机械方式判定你是否达标的一致性套件。
有两点让 Label 309 能够跨语言互操作。第一点是 确定性:这些构造都锚定在公开标准之上 (RFC 8949 规范 CBOR、 RFC 8032 Ed25519、 RFC 7748 X25519、 RFC 5869 HKDF、 RFC 9106 Argon2id、 RFC 9052 COSE),因此相同的输入在任何地方都会产出相同的字节。第二点是 一致性套件:一组字节级精确的测试向量,一个实现要么能复现它们,要么不能。一致性是你可以核查的属性,而不是你单方面声称的主张。
分层架构
一个符合规范的实现 SHOULD 把密码学原语与应用逻辑分隔到不同的层中,每一层只依赖紧挨着它的下一层。下面给出的名称是角色,不是包名;具体命名由你自己决定。
┌─────────────────────────────────────────────────────────┐
│ application │
│ UI, routing, persistence, payments, background jobs │
├─────────────────────────────────────────────────────────┤
│ SDK │
│ service client + standalone verifier + helpers │
├─────────────────────────────────────────────────────────┤
│ wire-format library │
│ schema · structural validator · canonical-CBOR codec │
├─────────────────────────────────────────────────────────┤
│ cryptographic core │
│ hashes · KDFs · signatures · KEM · AEAD · CBOR · COSE │
│ no application or framework dependencies │
└─────────────────────────────────────────────────────────┘这些边界是承重墙,而不是装饰。每一层只做一件事,并且各有一份明确禁止它知道的事项清单。
密码学核心
最底层只存放原语:哈希函数、KDF、签名与 KEM 操作、AEAD 内容层、规范 CBOR、COSE_Sign1、密封 PoE 的封装/解封装构造、Merkle 根与证明,以及它们抛出的带类型错误类。它 不 包含任何领域逻辑、不 涉及 HTTP、不 访问数据库,也 不 引入任何 UI 或服务端框架。
这一层 MUST 与任何绑定应用或服务端的东西保持零依赖,并且 MUST 在浏览器中安全可用,原因有三点,都很具体:
- 它无处不在地运行。 对文件计算哈希、构建信封,以及——这一点尤为关键——可独立验证(standalone-verifiable)的验证器,全都能在浏览器里、在 serverless worker 里、在命令行上运行,跟在服务器上一样自然。一旦引入仅限服务端的依赖(数据库驱动、绑定到某个运行时的日志框架、UI 库),就会破坏这些目标,并让每一个把核心打包进去的使用方都变得臃肿。
- 它就是审计暴露面。 审阅者可以对照 RFC,把一个只含原语的包从头读到尾。一旦应用代码渗进来,安全审阅者需要在脑中容纳的暴露面就会无止境地膨胀。
- 它是第三方嵌入的对象。 一个独立的验证方——只信任链、不信任任何服务的人——只会引入这一层,而不会引入它之上的任何东西。让它保持小巧且可移植,正是让「自己来验证」切实可行的关键。
具体而言,核心 MUST NOT 引入 ORM 或数据库驱动、UI 框架、绑定服务端的日志框架,或任何应用模块。随机数 MUST 来自平台 CSPRNG(Web Crypto 的 getRandomValues,或某个等价的再导出),绝不能来自仅限 Node 的来源,这样同一份代码在浏览器里也能原样运行。
在 CI 里强制边界,而不是靠代码评审
零依赖规则一旦有个图省事的 import 溜进来就会松动。一个实现 SHOULD 运行一项依赖图 lint,遍历核心层和线格式库里的每一个 import,并在出现任何不在该层白名单内的指定符时让构建失败。评审者会忘,linter 不会。
线格式库
往上一层负责 Label 309 本身:记录的 schema、结构校验器,以及规范 CBOR 的编码器和解码器。它依赖密码学核心(用于哈希、COSE 和 CBOR 编解码),除此之外不依赖任何绑定应用的东西。它的暴露面小而纯粹:
- encode —— 为一条已校验的记录产出规范 CBOR 字节。
- decode —— encode 的逆操作。
- validate —— 对一条已解码的记录运行本标准的结构与语义检查,并返回一个带类型的结果(参见验证)。
这一层正是 记录 中各项规则的代码归处:封闭的键集合、分块重组的纪律、items-或-merkle 不变式、规范 CBOR 的各项要求。和核心一样,它不引入 HTTP 客户端、数据库驱动和框架。
SDK 与应用
SDK 把下层封装成顺手的辅助工具——一个服务客户端、信封的构建/解锁辅助函数,以及 可独立验证的验证器,也就是那个解码一条记录、检查其结构、对照链上密钥验证任何记录签名,并仅凭公开数据给出裁定的函数。这个可独立验证的验证器 MUST 在不访问任何实现者运营的服务的情况下工作;它唯一的外部输入,是验证方自行选定的一个公开区块链浏览器。SDK SHOULD 同样保持在浏览器中安全可用。
应用层——UI、路由、持久化、计费、后台作业——是全新构建的,不承担任何互操作义务。本标准对你如何构建它不作任何约束,只要求它坐落在已验证的密码学暴露面之上,而不是伸手探进其内部。
字节级一致的契约
互操作性是字节的属性,而不是意图的属性。两个实现,当且仅当那些在输出上没有任何自由度的原语对相同输入产出 相同字节 时,才能互操作。这就是一致性契约,也是一致性的核心所在。
这份契约干净利落地一分为二。凡是输出完全由其输入决定的操作,MUST 在各实现之间字节级一致。凡是消耗随机数的操作,逐次调用不可能字节相等;对它们而言,契约是 可跨实现消费性——一个实现产出的值 MUST 能被任何其他实现消费(在一种语言里密封的密文,在另一种语言里能解密)。
字节级一致的原语
下面的每一个操作都是其输入的纯函数,并且 MUST 在每一个符合规范的实现中产出字节级一致的输出:
| 原语 | 锚定标准 | 必须匹配的输出 |
|---|---|---|
| 种子 → Ed25519 / X25519 密钥对 | HKDF-SHA-256,配合已注册的 info 常量 | 派生出的公钥与私钥 |
| HKDF-SHA-256 | RFC 5869 | 固定输入对应的输出密钥材料 |
| HMAC-SHA-256 槽集 MAC | RFC 2104 | 固定 CEK 与槽集对应的 slots_hash 和 slots_mac 标签字节 |
| Argon2id(口令 KDF) | RFC 9106 | 固定 (m, t, p, salt, len, password) 对应的派生密钥 |
| SHA-256 | FIPS 180-4 | 摘要 |
| BLAKE2b-256 | RFC 7693 | 摘要 |
| 规范 CBOR 编码 | RFC 8949 §4.2.1 | 固定输入对应的编码字节 |
| COSE_Sign1 编码 | RFC 9052 | 固定头部、载荷、签名对应的结构字节 |
| Ed25519 签名/验证 | RFC 8032(严格模式) | 签名;裁定 |
| X25519 ECDH | RFC 7748 | 固定标量对应的共享密钥 |
| 密封 PoE 封装/解封装 | 密封 PoE | 注入临时密钥与 CEK 时,每个槽的字节及 MAC |
| Merkle 根 + 包含证明 | RFC 9162 §2.1.1 | 有序叶子列表对应的根与每叶证明 |
有两点值得着重指出。Ed25519 是严格的:一个符合规范的验证器 MUST 应用 RFC 8032 §5.1.7 的规范 S 规则与拒绝低阶点规则,这样两个实现不仅在它们接受的签名上达成一致,在它们拒绝的签名上也达成一致。Argon2id 跨越生态边界:不同语言会取用不同的 Argon2 库,但每一个符合规范的库都实现了 RFC 9106,并且 MUST 对相同参数产出相同输出——契约是参数集,而不是具体的库。
消耗随机数的操作
密钥生成、在新鲜的逐槽临时密钥下进行的密封 PoE 封装,以及信封加密,全都抽取新鲜的随机数,因此它们的输出每次调用都不同,无法被字节级钉死。它们的契约是 可跨实现消费性:一个实现产出的输出 MUST 能被其他每一个实现消费。在一种语言里密封的记录 MUST 能在另一种语言里解密;在一种语言里铸出的密钥对 MUST 能在另一种语言里通过验证、并被用作加密目标。一致性套件用确定性测试钩子来钉住这些操作,这些钩子注入临时密钥——让封装可复现——并配合往返固定向量,在一种语言里加密,再在另一种语言里解密。
构建密封 PoE 这套构造
密封 PoE 是线格式中最稠密的部分,也是「一个字节错了——一个排错位的映射键、一个差了一个字符的标签、一次不规范的分块——就会产出一个只在你自己实现里能打开、在任何别的实现里都打不开的信封」的那个部分。本节是构建清单:那些精确的配方、每个 AEAD 所覆盖的附加认证数据、试解密循环,以及每个生产方和验证器都必须强制执行的各项防护。密封 PoE 上的构造参考是叙述性的散文;这里则是你把它接起来、让一致性关卡变绿的接法。把下面这些外部草案原原本本地钉死,因为它们的内部细节固定了你必须复现的字节:
chacha20-poly1305-stream64k——内容格式——是 ChaCha20-Poly1305(RFC 8439)采用 age v1 规范 的 64 KiB 分段 STREAM 布局。把分块大小(65536)、那个 12 字节逐块 nonceuint88_be(counter) ‖ final_flag、空的逐块 AAD,以及末块标志规则原原本本地钉死——它们固定了你必须复现的字节。- X-Wing(即
mlkem768x25519KEM)是 draft-connolly-cfrg-xwing-kem-10。把它当作一个 黑盒 KEM:这套构造把接收方公钥和密文绑进密钥派生步骤本身,因此它不依赖组合器内部哈希的任何性质。XWing.EncapsulateMUST 施加钉定修订版的公钥有效性检查,并拒绝向一把通不过该检查的密钥封装;那条「绝不低于 X25519 经典安全性」的下限,是限定在合规生成的密钥之上的,跳过这项检查就为那个接收方放弃了这道下限。一致性的 KEM 向量把封装钉死在 draft-10 上,于是任何草案修订版的不匹配都会立刻浮现。
一个 CEK,两条密钥投递路径
一条密封记录把明文 只 加密一次,置于单个内容加密密钥(CEK)之下,然后通过两条互斥路径之一来投递这个 CEK,靠字段是否存在来区分——没有任何模式标签:
- slots 路径——CEK 在一把逐槽密钥加密密钥之下被独立封装给每个接收方。
enc承载slots(以及kem、slots_mac)。 - passphrase 路径——CEK 经 Argon2id 直接从一段规范化后的口令派生而来。
enc承载passphrase;它不承载kem、slots或slots_mac。
两条路径共享 enc.scheme(始终为 1;任何其他值都拒绝)、enc.aead(chacha20-poly1305-stream64k)和 enc.nonce(24 字节)。它们的区别在于密钥承诺存放在何处:slots 路径在链上、放在 slots_mac 里,口令路径则放在密文数据块之内的一段 32 字节头部里。两条路径都把该项的哈希主张绑进各自的转录,也都把内容封装进同一个分段 STREAM;区别在于密钥投递与承诺,而非内容层。
逐槽封装(slots 路径)
为整条记录挑 一种 KEM——绝不在单个 slots[] 内混用 KEM。对 N 个接收方中的每一个,派生一把新鲜的逐槽密钥加密密钥,并用 ChaCha20-Poly1305 配 12 字节零 nonce 在其之下封装 同一个 CEK,AAD 设为该 KEM 的 info 标签字面量(绝不用空 AAD),恰好产出 48 字节(32 字节 CEK 密文 + 16 字节标签)。零 nonce 之所以安全,仅仅因为那把密钥加密密钥是逐槽的;见下文的唯一性防护。
x25519(经典)。 每个槽位一对新鲜的临时 X25519 密钥对:
priv_epk : randomBytes(32) ; fresh per slot
pub_epk : x25519_publicKey(priv_epk)
shared : x25519_sharedSecret(priv_epk, pub_R) ; reject all-zero result
kek_salt : SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R) ; 32 B
KEK : HKDF-SHA-256(ikm = shared, salt = kek_salt,
info = "cardano-poe-kek-v1", L = 32)
wrap : ChaCha20-Poly1305(key = KEK, nonce = zeros(12),
ad = "cardano-poe-kek-v1", plaintext = CEK) ; 48 B
slot : { "epk": pub_epk, "wrap": wrap }mlkem768x25519(混合;X-Wing)。 每个槽位一次新鲜的 X-Wing 封装:
enc = XWing.Encapsulate(pub_R) ; named fields — MUST NOT consume positional order
kem_ct = enc.ct ; 1120 B
shared = enc.ss ; 32 B
kek_salt : SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) ; 32 B
KEK : HKDF-SHA-256(ikm = shared, salt = kek_salt,
info = "cardano-poe-kek-mlkem768x25519-v1", L = 32)
wrap : ChaCha20-Poly1305(key = KEK, nonce = zeros(12),
ad = "cardano-poe-kek-mlkem768x25519-v1", plaintext = CEK)
slot : { "kem_ct": kem_ct, "wrap": wrap } ; kem_ct = single 1120-byte byte string两个 salt 有同一种形态——SHA-256(label || enc.nonce || <slot KEM material> || pub_R)——经典路径携带 32 字节的临时公钥 pub_epk,混合路径携带 1120 字节的 X-Wing 密文 kem_ct;|| 是字节拼接,而每个 salt 前缀字面量都是精确的 ASCII,不带终止符、也不带长度前缀。pub_R 是接收方的规范线上密钥(x25519 为 32 字节,mlkem768x25519 为那钉定的 1216 字节)。混合槽位 不 携带单独的 epk——X25519 临时密钥就是 kem_ct 的末尾 32 字节——而 kem_ct 是一个 恰好 1120 字节的单个 CBOR 字节串:只有整条记录体会被分块以供传输,单个字段从不被分块。
这个 salt 绑定三个值:槽位的 KEM 材料(让 KEK 逐槽唯一)、pub_R(挫败针对另一接收方的混淆代理转手)、以及 enc.nonce(把 KEK 锚定到唯一一个信封,于是重复的 KEM 随机数只会退化为跨信封可关联性)。那两个各不相同的 info 标签提供了跨 KEM 的域分隔,于是在某种 KEM 下派生的 KEK 绝不会等于在另一种 KEM 下、就同一个共享密钥派生的 KEK。逐字节地使用这十一个内部标签——cardano-poe-kek-v1、cardano-poe-kek-mlkem768x25519-v1、cardano-poe-x25519-kek-salt-v1、cardano-poe-xwing-kek-salt-v1、cardano-poe-item-hashes-v1、cardano-poe-slots-transcript-v1、cardano-poe-slots-mac-v1、cardano-poe-passphrase-transcript-v1、cardano-poe-passphrase-mac-v1、cardano-poe-payload-v1、cardano-poe-payload-passphrase-v1。它们没有一个会在线上序列化;它们是固定常量,不可在注册表里选用。哪怕只差一个字节,都会产出一个诚实生产方无法复现的 slots_mac、承诺或 AEAD 标签。
先打乱,再算 MAC。 输入顺序(「主接收方排在前面」)是敏感元数据;按输入顺序发布密钥槽会把它泄露出去。用一个 CSPRNG、以一次 无偏 的 Fisher-Yates 置换来打乱 slots[]——一个朴素的 u32 % m 下标抽取会偏向低残值,必须以拒绝采样收敛到一个均匀下标——之后 再计算槽集 MAC,由它绑定打乱后的线上顺序。
槽集 MAC:先哈希转录,再用 CEK 做 HMAC
槽集 MAC 把整个密钥槽集、连同那些固定了密钥槽该如何读取的头部字段,绑定到 CEK。分两步构建它——先把一份封闭转录哈希一次,再对那个哈希做 HMAC:
hashes_hash : SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) ; 32 B
SLOTS_TRANSCRIPT = { ; closed 7-key map; keys are a set, not an order
"scheme": 1,
"path": "slots",
"aead": <enc.aead>, ; the content-format identifier
"kem": <enc.kem>, ; "x25519" | "mlkem768x25519"
"nonce": <enc.nonce>, ; bytes(24)
"slots": <slots>, ; the shuffled on-wire slot array
"hashes_hash": hashes_hash ; bytes(32), over this item's hashes
}
slots_hash : SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))
HMAC_KEY : HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
slots_mac : HMAC-SHA-256(key = HMAC_KEY, msg = slots_hash) ; 32 B这里有三件事决定着一致性的成败:
- 转录是一个由
canonicalEncode序列化的封闭映射。 它的键顺序是 RFC 8949 §4.2.1 的排序,绝非手工排列。把scheme、path、aead、kem和nonce与密钥槽一并钉死,意味着一个中继若翻动任何头部字段——哪怕保持密钥槽形状有效——都会改变slots_hash并破坏 MAC。 - 转录绑定该项的哈希主张。
hashes_hash是在该项完整hashes映射的canonicalEncode之上、一个带标签的 SHA-256。由于接收方仅凭链上字节就能重算slots_mac,一次 MAC 匹配就确认了信封是为 这份确切哈希主张 而密封的——一个被拼接到带有不同hashes映射的项上的信封,会在任何密文拉取之前、就在链上的匹配那一步失败。slots的取值就是那个由线上密钥槽映射组成的打乱数组本身:每个密钥槽字段都是单个字节串(epk32 B,kem_ct1120 B),因此没有逐字段的分块需要规范化。 slots_hash只算一次,并在整个试解密循环里保持不变。逐槽 MAC 检查会用每个候选 CEK 给 HMAC 重新设密钥,但始终在那同一个 32 字节的slots_hash之上。预先哈希让那道由 CEK 加键的承诺原封不动:它只是把 HMAC 的消息从完整转录换成它的 SHA-256,仅此而已。
MAC 算法、它的密钥派生,以及转录 schema,全都由 enc.scheme = 1 固定下来,且对两种 KEM 都相同;线上没有任何 MAC 标识符。slots_mac 恰好为 32 字节,并以恒定时间验证。
内容加密:分段 STREAM
把明文在分段 STREAM 之中、在一把派生自 CEK 的 内容密钥 之下加密一次。内容密钥是 CEK 的一个单独 HKDF 派生叶——以 enc.nonce 加 salt、在一个逐路径的 info 之下——因此包裹层与内容层绝不会在相同的字节上为同一个原语设密钥:
content_key : HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32)
; STREAM (chacha20-poly1305-stream64k):
CHUNK_SIZE : 65536 plaintext bytes per non-final chunk
chunk nonce : uint88_be(counter) || final_flag ; 12 B; counter from 0, +1 per chunk;
; final_flag = 0x01 on the last chunk, else 0x00
per-chunk AAD : empty
ciphertext : seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final)
; each chunk sealed with ChaCha20-Poly1305 under content_key; sealed = plaintext + 16 B逐块 AAD 为空——而这是对的,并非疏漏:内容密钥派生自 CEK,而 CEK 已经由 slots_mac 绑定到了完整头部(包括 hashes_hash)。翻动任何头部字段,接收方就派生出一把不同的内容密钥,于是流根本打不开;逐块 AAD 只会在每一块上把同样的上下文重新绑定一遍,并不增加任何安全性。那些计数器 nonce 之所以安全,是因为内容密钥是一次性的(一个以信封唯一的 enc.nonce 加 salt 的全新 CEK),因此没有两条流会共用同一个 (key, nonce) 对。
把 STREAM 建成让截断可被检测:每个非末块都恰好 65536 字节明文,末块携带 final_flag = 0x01 和 0–65536 字节(空明文是一个零长度末块——一个孤零零的 16 字节标签),而验证器对缺失的末块标志、落在非末块上的末块标志、跟在末块之后的数据,或一个短的非末块,都 MUST 失败(TAMPERED_CIPHERTEXT)。在释放某块的明文之前先校验该块的标签,并把已释放的字节视为 暂定的,直到解密后的哈希复核通过为止。
明文就是原始内容字节本身;这套构造不会在前面或后面附加、也不会加密任何文件名、MIME 类型、大小字段或元数据包装。所发布的密文数据块就是那些 STREAM 块(在口令路径上,前置着下文那段 32 字节承诺头部)。组装好的 enc 映射和最终的 URI 上链;密文字节不上链——把它们发布到一个内容寻址存储,并把 ar:// 或 ipfs:// URI 放进该条目的 uris[]。
口令路径
当没有接收方时,用 Argon2id 从一段规范化后的口令派生 CEK。这里没有 epk、没有逐槽包裹、没有槽集 MAC,也没有试解密循环。slots_mac 在 slots 路径上所提供的那道密钥承诺,在这里改为存放于一段 密文数据块之内的 32 字节头部,前置在 STREAM 块之前:
passphrase_bytes = utf8(normalize(passphrase)) ; cardano-poe-pw-norm-v1
CEK = argon2id(passphrase_bytes, salt = enc.passphrase.salt,
params = enc.passphrase.params, L = 32)
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))
PASSPHRASE_TRANSCRIPT = { ; closed 6-key map; keys are a set, not an order
"scheme": 1,
"path": "passphrase",
"aead": <enc.aead>,
"nonce": <enc.nonce>, ; bytes(24)
"hashes_hash": hashes_hash, ; bytes(32), over this item's hashes
"passphrase": { ; closed sub-map
"alg": "argon2id",
"salt": enc.passphrase.salt,
"params": { "m": m, "t": t, "p": p },
"normalization": "cardano-poe-pw-norm-v1" ; scheme-fixed constant, NOT on the wire
}
}
pw_hash = SHA-256("cardano-poe-passphrase-transcript-v1" || canonicalEncode(PASSPHRASE_TRANSCRIPT))
PW_MAC_KEY = HKDF-SHA-256(ikm = CEK, salt = "", info = "cardano-poe-passphrase-mac-v1", L = 32)
commitment = HMAC-SHA-256(key = PW_MAC_KEY, msg = pw_hash) ; 32 B
content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce, info = "cardano-poe-payload-passphrase-v1", L = 32)
ciphertext blob = commitment || STREAM chunks ; STREAM under content_keyPASSPHRASE_TRANSCRIPT 把 KDF 参数、头部字段以及该项的哈希主张都绑进承诺:篡改 salt、任何 params 取值、nonce、aead,或把信封拼接到一份不同的哈希主张上,都会产出一个不同的 pw_hash,承诺检查随之失败。"normalization" 取值是一个喂进转录的 scheme 固定常量,用来钉死 CEK 派生时所用的那个确切规范化档;它 绝不 在线上序列化(生产方只发出 { alg, salt, params })。
在验证一侧,派生出候选 CEK,读取密文数据块的 前 32 字节,重算承诺,并 以恒定时间、在打开任何 STREAM 块之前 进行比较。一个短于 48 字节(32 字节承诺 + 16 字节 STREAM 最小值)的数据块即为畸形(TAMPERED_CIPHERTEXT)。一旦不匹配——错误口令、被篡改的 salt / params / 头部,或一份被拼接的信封——交出与其他任何解密失败相同的那个唯一通用失败,且不要开始流式处理;一个错误口令与一条被篡改的记录无从区分。这道承诺刻意放在链下:一个链上承诺会为每一条口令记录——包括那些密文被扣留的记录——白白送上一个免费的离线猜测预言机。
强制执行参数下限:salt 长度 16–64 字节;m ≥ 65536 KiB(约 64 MiB)、t ≥ 3、p ≥ 1。把 Argon2 版本钉定在 0x13(19);在 enc.scheme: 1 之下没有别的版本可接受,线上也没有版本字段。在平台支持的前提下,生产方 SHOULD 发出 p = 4(RFC 9106 §4 推荐的第二套配置);验证器 MAY 接受任意 p ≥ 1,但要受部署期天花板的约束。Argon2id 干净利落地跨越生态边界——契约是参数集,而不是具体的库——因此一个固定的 (m, t, p, salt, len, password) 必须在每个实现里产出字节级一致的输出。
在规范化和 Argon2id 之前 给原始口令设界:拒绝任何长于参考上限 MAX_PASSPHRASE_INPUT_BYTES = 4096 UTF-8 字节的输入,使一段病态的口令无法驱动一次 KDF 之前的拒绝服务。与 slots 路径上的 MAX_SLOTS 和解码后信封上限一样,这是一个部署期钉定的常量,你 MAY 把它收得更紧,而非线上字段。
规范化档是规范性的
两个实现 MUST 从同一段口令派生出字节级一致的 CEK,而保证这一点的唯一办法是一个钉死的规范化。档 cardano-poe-pw-norm-v1 按顺序施加如下步骤:
- 拒绝未分配码点——一段口令若包含任何在 Unicode 16.0 中未分配的码点,会在任何规范化运行之前以
ENC_PASSPHRASE_UNNORMALIZABLE被拒绝。Unicode 仅就已分配码点保证规范化稳定性,因此这一步堵上了一个未来漂移的漏洞,且对诚实用户不可见。 - NFKC——按 UAX #15、在 Unicode 16.0 之下的 Normalization Form KC。
- 空白——把空白定义为在 Unicode 16.0 之下带有 Unicode
White_Space属性的每一个字符;把每一段这样的最大连续游程折叠成单个 U+0020 SPACE。 - 修剪——去掉首尾的空白。
- 拒绝空串——若结果为空字符串,则以
ENC_PASSPHRASE_EMPTY拒绝;否则一段只含空白的口令会把记录设密钥到任何一方都能派生出的一个 CEK 上。 - 编码——UTF-8;那些字节就是 Argon2id 的口令输入。
把 Unicode 原原本本地钉死在 16.0,别让它浮动:White_Space 属性集、已分配码点集和 NFKC 映射表全都依版本而定,对照不同 Unicode 版本去解析该档,可能会从同一段口令派生出不同的 CEK,从而无法打开一条诚实的记录。某个采用更新 Unicode 版本的未来修订版,会在一个 新 的档标识符之下这么做,绝不会去重新解释 cardano-poe-pw-norm-v1。
试解密:打开每个槽位,折进 MAC,以通用方式失败
接收方持有一把 KEM 私钥,并通过尝试打开每一个槽位来发现属于自己的那个——接收方公钥不在线上。在动用任何 KEM 或 AEAD 原语之前,先跑资源边界,再跑结构防护。先给解析器的资源消耗设界: 拒绝解码后大小超过 65536 字节的信封(ENC_ENVELOPE_TOO_LARGE),或 slots[] 超过 MAX_SLOTS = 1024 的信封(ENC_SLOTS_TOO_MANY)。这两个参考上限都远高于约束任何诚实记录的那道约 16 KiB 的 Cardano 交易元数据天花板;它们是部署期钉定的常量,你 MAY 把它们收得更紧,绝非线上字段。然后跑结构防护:scheme == 1;aead、kem 已注册;nonce 为 24 字节;slots_mac 为 32 字节;slots 非空;接收方私钥为 32 字节;每个 wrap 为 48 字节;按 KEM——每个 epk 恰好为 32 字节且无 kem_ct(x25519),或每个 kem_ct 恰好为 1120 字节且无 epk(mlkem768x25519)。
在此处、在任何原语之前,拒绝记录内部重复的封装。 经典路径上所有 epk 值必须各不相同,混合路径上所有 kem_ct 值必须各不相同;出现重复则抛出 ENC_SLOTS_DUPLICATE_KEM_MATERIAL。这是零 nonce 封装所依赖的逐槽 KEK 唯一性不变式中可由验证器检查的那一片;跨记录或跨密钥的复用是任何验证器都无法察觉的生产方义务。这项拒绝只在 epk / kem_ct 重复时触发——用全新的逐槽临时密钥向同一个接收方密封两次是合法的,并不会触发它(参见下文的多命中规则)。unwrap-negative 携带了那个「epk 重复且 KEK 复用」的用例。
然后跑这个循环,在它之前把 slots_hash 算一次并保持不变:
found = false
cek_conflict = false
selected_CEK = 0^32
for slot in slots: ; iterate ALL slots — no early break
; derive KEK per-KEM, as in the wrap recipe. For x25519 the all-zero shared
; secret is rejected via a secret-independent bit, not an early branch:
; kem_ok = NOT constantTimeEqual(shared, 0^32)
; KEK = ct_select(kem_ok, real_KEK, dummy_KEK) ; dummy_KEK from ikm=0^32, same salt/info
; (XWing.Decapsulate has no all-zero case; kem_ok stays true on the hybrid path.)
open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), kem_info_label, slot.wrap)
HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
mac_ok = constantTimeEqual(HMAC-SHA-256(HMAC_KEY, slots_hash), slots_mac)
ok = kem_ok AND open_ok AND mac_ok ; kem_ok folded into acceptance
first = ok AND NOT found ; first matching slot
cek_conflict = cek_conflict OR (ok AND found AND NOT constantTimeEqual(candidate_CEK, selected_CEK))
selected_CEK = ct_select(first, candidate_CEK, selected_CEK) ; constant-time
found = found OR ok
if NOT found: reject (single generic failure)
if cek_conflict: reject (single generic failure)
content_key = HKDF-SHA-256(selected_CEK, salt = enc.nonce, info = "cardano-poe-payload-v1", L = 32)
plaintext = STREAM_open(content_key, ciphertext) ; per-chunk authenticated release
if STREAM_open fails at any chunk: reject (single generic failure) ; TAMPERED_CIPHERTEXT这个循环里有几条不可让步的要点:
- 原子地打开;绝不释放未经校验的明文。 两个
*_open_or_dummy原语都是原子的:在 AEAD 标签校验失败时,它们不返回任何明文,而交回的候选值(被封装的 CEK,或内容明文)是一个与那段失败密文无关的固定或伪随机虚值。正是这一点,才让循环能把一个candidate_CEK越过一次失败的封装打开继续携带下去,而绝不暴露任何未经鉴别的字节。 - 把全零检查折进一个秘密无关的
kem_ok比特。 对x25519路径计算kem_ok = NOT constantTimeEqual(shared, 0^32),在真实 KEK 与一个由0^32在相同 salt 和 info 下派生出的虚值 KEK 之间以恒定时间选取 KEK,并把kem_ok折进接受判定(ok = kem_ok AND open_ok AND mac_ok)。绝不要在一个无效份额上提前分支退出——一个无效 ECDH 的槽位永远不可能被接受,而循环依然做着同样的工作。(XWing.Decapsulate没有全零情形,因此混合路径上kem_ok恒为真。) - 把
slots_mac检查折进循环。 一个恶意发送方可以炮制一个槽位,它能在接收方密钥下被打开、却产出一个攻击者选定的 CEK(无需知道私钥)。把首次 AEAD 成功当成「自己的」,就会让那个伪造槽把一个诚实槽遮蔽掉。要求候选 CEK 还得就着slots_hash复现出slots_mac,就挫败了密钥槽替换、密钥槽删除和密钥槽重排。绝不要跳过它。 - 允许多次命中;只拒绝 CEK 冲突。 一把接收方密钥 MAY 合法地命中不止一个槽位——把同一个 CEK、向同一个接收方、跨好几个槽位(每个配各自全新的临时密钥)密封,是一种正当的接收方数量填充,并不会触发
epk/kem_ct重复拒绝。选取第一个命中槽的 CEK,且不要仅因不止一个槽命中就拒绝。唯一要拒绝的反常情形,是两个命中槽恢复出不同的 CEK(以恒定时间比较):追踪一个cek_conflict比特,若它被置位就交出那个唯一的通用失败。这是纵深防御——在槽集承诺之下,一个不同 CEK 的命中本就不可行——因此它会对一个有缺陷的实现安全地失败关闭。 - 在单把私钥的那一遍里遍历所有槽位——每把密钥固定数量的槽位运算,不提前跳出——这样一个时延观察者就无法推断是哪个槽位命中了。把全零拒绝经由
kem_ok和虚值工作来驱动,而不是提前退出。一个持有多把密钥的接收方会按密钥 × 槽位来迭代,并 MAY 在密钥之间提前短路(这只泄露「哪把密钥命中了」这一弱信号),但必须在任何单把密钥的各槽位之间保持恒定时间——并且必须按密钥重新派生那一半pub_Rsalt,因为两种 KEM 都把接收方自己的公钥绑进 KEK salt。把那个 salt 绑定到密钥的规范线上编码——x25519取的是那把恰好 32 字节的 X25519 公钥,X-Wing 取的是那串钉定的、恰好 1216 字节的 X-Wing 公钥字节——绝不用任何非规范的重新编码,否则两边会派生出不同的 KEK。 - 向不受信任的调用方只浮现一种通用失败形态。 在内部,你 MAY 为本地诊断追踪带类型的结果——
WRONG_RECIPIENT_KEY(没有槽位打开)、TAMPERED_HEADER(有槽位打开,但没有候选 CEK 复现出slots_mac)、TAMPERED_CIPHERTEXT(恢复出某个 CEK 且 MAC 通过之后,内容 AEAD 校验失败)——但一个外部观察者 MUST NOT 能凭响应形态把它们区分开。在时延上,这套模型是刻意收窄的:验证器 MAY 在无命中检查(if NOT found)处、在内容解密之前返回,这会把一个非接收方与一个其密文打不开的接收方分开。那只透露 接收方 vs 非接收方,绝不透露是哪个槽位命中、也没有任何密钥材料;那两种情形之间时延一致并非必需,且 MUST NOT 强制要求一次虚值内容打开。确实成立的那条恒定时间保证,是上文那条跨槽位不变量。 - 解密后重算并比对明文哈希。 链上的
hashes映射承诺的是 明文、而非密文,因此接收方(在应用层)必须重算摘要并比对:sha2-256条目必须匹配,若存在blake2b-256也必须匹配。不匹配就意味着记录的哈希主张与解密出的字节对不上——拒绝据此明文采取行动。结构校验器从不解密。
在两端给载荷设界
分段 STREAM 不施加 任何密码学层面的载荷天花板:那个 88 位逐块计数器允许 2^88 个块,而每一块都在一个各不相同的 (content_key, nonce) 对之下、稳稳处于 RFC 8439 单次调用上限之内被封装,因此没有任何计数器溢出风险需要防范。于是一个生产方或验证器所强制的上限,是一道 部署期拒绝服务策略,而非线上常量——随着流被写入或读取而增量地强制它,并在缓冲一份过大载荷之前就中止。截断由末块标志在结构上捕获,而非靠一道大小上限。同一道姿态在 slots 路径和 passphrase 路径上都适用。
密封 PoE 的一致性固定向量
语料库中密封 PoE 这一角,是大多数跨语言 bug 浮现的地方。让你的实现把它全部跑一遍。正面 固定向量为两种 KEM 钉住确定性封装和试解密循环——单接收方与多接收方、混合 N,以及多私钥的最坏情形——外加一个接收方命中两个槽位的合法情形(临时密钥各自全新、CEK 相同、MUST 解密成功,因此一个拒绝多次命中的实现会在这里失败)和口令路径(承诺头部加 STREAM 块合在一个数据块里)。一个专门的 STREAM 布局 集钉住一份空明文(一个零长度末块)、一份单块载荷,以及一份跨越 65536 字节边界的多块载荷。若干针对性的 KAT 钉住两个 KEK salt(SHA-256(label ‖ enc.nonce ‖ <KEM material> ‖ pub_R))、hashes_hash 及其在两份转录中的位置、对照 draft-10 的 X-Wing 封装、零长度 salt 的 HKDF 提取(RFC 5869 §2.2 中缺省 salt 的约定,镜像 slots_mac 的密钥派生)、Bech32 的接收方/私钥编码,以及带校验和的身份种子编码。
反面 固定向量钉住各个拒绝码:一个排在诚实槽之前的伪造遮蔽槽(记录 MUST 仍能在诚实 CEK 下解密);一次让槽位形状保持有效的头部翻动(kem/aead/scheme);一次把信封拼接到带有不同哈希主张的项上的 hashes 拼接;口令承诺的各种失败(错误口令、被篡改的 salt/params、被篡改的头部——全都在任何块打开之前失败);口令规范化的各种拒绝(一个含未分配码点的输入和一个只含空白的输入);全零的 X25519 共享密钥;记录内部的重复槽位;以及 STREAM 篡改的各种情形(翻动的块标签、被截断的流、尾随数据、短的非末块)。有两条性质没有字节向量,改以行为方式断言:CEK 冲突拒绝(构造一个出来,恰恰就是标准所假定不可行的那场多密钥承诺碰撞)和跨槽位恒定时间保证。复现每一个被钉住的字节串,并为每一个反例给出确切的码。
密封 PoE 有一条性质没有字节向量:CEK 冲突拒绝——两个命中槽位恢复出不同的 CEK——无法构造成一个固定向量,因为构造一个出来,恰恰就是标准所假定不可行的那场多密钥承诺碰撞。改用一个实现层面的行为测试来钉住它,断言你的试解密循环在一次被强制的冲突上失败关闭,就如同那条跨槽位恒定时间性质同样是以行为方式、而非字节串来断言的。
一致性与测试向量
规范性的测试向量 就是 互操作契约本身。一个实现,当且仅当 它从相同的输入复现一致性套件中每一个被钉住的字节串,并为每一个反例固定向量给出正确的带类型错误码时,才算符合规范。这里没有部分得分,也没有上诉余地:一旦比对失败,就是实现错了,向量永远没错。
这些向量存放在标准的一致性套件中,按原语类别组织:记录固定向量、密封 PoE 封装/解封装、COSE_Sign1 签名、HKDF、种子派生、Argon2id,以及规范 CBOR。每一个都钉住小写十六进制的输入和期望的输出。使用方法是:把这些输入喂给你的实现,对每一个具名输出逐字节比对,一旦不匹配就修你的代码。
每个实现必须满足的三项义务
复现正例向量。 对每一条记录固定向量,encode(record) == expected_cbor 与往返 encode(decode(expected_cbor)) == expected_cbor 两半 MUST 同时成立。这条往返还能推广到固定向量之外:对任意良构输入,encode(decode(x)) == x 都应成立。一个会丢失或重排信息的解码器,或一个不规范的编码器,都会破坏这一点而无法通过一致性检验。
给出正确的拒绝码。 反例固定向量把一条蓄意畸形的记录,与结构校验器 MUST 抛出的那个确切带类型错误码配对在一起。复现合法记录的字节只是契约的一半;用 正确的 码拒绝非法记录是另一半。一个校验器若以错误的理由拒绝一条坏记录——或者干脆接受了它——就不符合规范。反例固定向量是跨语言拒绝一致性的唯一权威来源:同一份畸形输入 MUST 在每一个实现中抛出相同的码。所有错误码及其含义的完整目录见验证。
与注册表保持一致。 算法标识符是从算法注册表中取来的具名字符串。一个无法识别的标识符 MUST 浮现出确切的「不支持的算法」码,绝不能默默接受或直接崩溃。
修实现,绝不改向量
这些向量锚定在上游 RFC 和本标准的确定性构造之上。当一次比对失败时,bug 在被测实现里。为了让某个套件通过而去编辑向量,等于把一个真实的互操作故障转化成潜伏的故障,而它只会在一条记录在链上跨实现流转时才暴露——那是发现它最糟糕的时机。
每次改动都跑一遍一致性
一个支持不止一种语言的实现——或者想要证明与另一个实现互操作的实现——SHOULD 运行一个统一的持续集成作业,它构建每一个包、用共享固定向量跑每种语言的测试套件、强制执行依赖图 lint,并检查固定向量集合在两侧 完全一致。某一侧加了一个固定向量而另一侧没加,就会让这道关卡失败:两个实现已经悄悄分叉,构建会在一条真实记录暴露问题之前就抓住它。固定向量是权威源头;每种语言持有一份字节级一致的镜像,关卡断言这份镜像完整且精确。
命名与线格式约定
少数几条约定让一个实现保持可读,让线格式保持稳定:
- 线格式字段名是
snake_case——leaf_count、cose_sign1、slots_mac。这一点跨语言成立:即便某种语言在它的内存 API 里习惯用camelCase,编码后的记录也仍然使用snake_case键,因为这些键是签名所覆盖的规范字节的一部分。 - 标识符是注册表字符串,而不是写死进代码的枚举。哈希、AEAD、KEM、KDF 和签名全都引用具名标识符;新增一种算法(比如某个后量子 KEM)只是往注册表里增添一个条目,绝不会造成线格式中断。
- 跨语言的方法名在语义上互为镜像。 一种语言里的某个函数,在另一种语言里有一个同名对应物(
encode_canonical_cbor↔encodeCanonicalCbor),这样精通任一语言的读者都能把一个暴露面映射到另一个之上,仅凭审读就能推理出二者的一致性。 - 先把密码学层引导起来。 先对照向量把密码学核心和线格式库搭起来,让一致性关卡变绿,再去写一行应用代码。可独立验证的验证器是最贴近应用的最小暴露面,也是接下来要构建的东西;其余一切都坐落在一个你已经证明正确的密码学层之上。