密钥
Label 309 的密钥模型——一个 32 字节种子,通过按域分离的 HKDF-SHA-256 从中派生出三套算法密钥对,以及一份密封 PoE 在其之上再派生的逐槽位密钥加密密钥,还有接收方公钥与私钥的编码方式。
Label 309 需要三类非对称密钥:一把用于对记录签名的 Ed25519 密钥,一把用于接收经典密封载荷的 X25519 密钥,以及一把用于接收后量子密封载荷的 X-Wing(mlkem768x25519)混合密钥。本标准并不把它们当作三份各自独立、需要分别存储和倒腾的秘密,而是只定义一份秘密——一个 32 字节种子——再加上一条确定性规则,把它展开成全部三套密钥对。
本页规定的就是这套派生:种子本身、产出每种算法私钥的三次按域分离的 HKDF 展开、为什么要把这些域彼此隔开、一份密封 PoE 在它们之上再派生的逐槽位密钥加密密钥,以及由此得到的接收方公钥与私钥如何编码以供交换。除此之外实现拿种子做了什么——它存放在哪里、如何解锁、是否由同一个人持有多份——都不在本标准的范围内。Label 309 只关心一件事:给定同样的这 32 字节,每个合规实现都派生出完全相同的密钥。
种子
一套 Label 309 密钥以单一的一个值为根:
| 属性 | 值 |
|---|---|
| 长度 | 32 字节(256 位) |
| 来源 | 密码学安全的随机数生成器,或用户自有的任意 32 字节值 |
| 作用 | 作为下文三次 HKDF 展开的输入密钥材料 |
种子是一个纯熵源,并不是任何单一算法意义上的密钥。它不绑定任何曲线,长度也不与某个原语挂钩,更不需要任何编码仪式。实现实际使用的密钥是在每次派生时确定的;种子的寿命比在它之上所做的算法选择更长。生产方 MAY 用平台 CSPRNG 现场生成种子,也可以导入一个已有的 32 字节值;无论哪种方式,它 MUST 解码为恰好 32 字节。派生层不拒绝任何低熵模式——全零种子也是合法输入,正是这一点让全零种子可以用作可复现的一致性测试夹具。
种子就是完整的身份
Label 309 关于某一方所表达的每一项公钥事实——为某条记录作担保的密钥、接收某份密封载荷的密钥——都是这 32 字节的确定性函数。复现出种子,你就能逐字节复现出全部三套密钥对。
为备份编码种子
由于那个 32 字节种子就是身份,它正是用户要备份、导出和导入的那个值——而一段裸的 32 字节数据很容易被悄然截断或损坏。Label 309 为它定义了一种带校验和的字符串编码,在任何接受种子作为输入的地方,都与原始十六进制并列地被接受。
字符串形式是 Bech32(BIP-173,经典版,并解除了 90 字符长度上限),在人类可读前缀 l309-seed- 之下——尾部的连字符是 HRP 的一部分,因此 Bech32 分隔符渲染出可见前缀 l309-seed-1…。编码返回的是大写显示形式 L309-SEED-1…:秘密理应醒目,而大写渲染在视觉上与小写的 age1… 接收方字符串区分得很清楚。全小写形式是对同一串字节的等价有效编码。
seed (32 bytes) 0000…0000 -> L309-SEED-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQLFUN82解析器接受两种表示,并依形态分派:
- Bech32 字符串,须为单一大小写(依 BIP-173 拒绝混合大小写),校验和被核验,解码后的载荷恰好 32 字节。
- 原始十六进制——64 个十六进制位,大小写不敏感,容许一个
0x前缀以及首尾或内部的空白。
每一种被拒绝的输入都映射到一个各不相同的构造 API 错误码,这样调用方就能把一个拼写错误与一个错误的密钥类型区分开:
| 输入 | 错误码 |
|---|---|
| 校验和失败的 Bech32 字符串(一个字符翻转、一次截断) | SEED_STRING_BAD_CHECKSUM |
| 大小写混合的 Bech32 字符串 | SEED_STRING_MIXED_CASE |
一个 HRP 不同的有效 Bech32 字符串(例如一个 age1… 接收方) | SEED_STRING_WRONG_HRP |
| 一个解码后 ≠ 32 字节的 Bech32 字符串或十六进制字符串 | SEED_STRING_WRONG_LENGTH |
| 任何既不是可识别 Bech32 字符串也不是十六进制的东西(含空串) | SEED_STRING_UNRECOGNIZED |
这些码描述的是种子字符串编解码器,它是围绕派生的一种密钥处理便利;它们与结构校验器所发出的线上错误码注册表(验证)截然不同。该编码只承载那 32 字节本身、别无其他——没有版本、没有派生参数——因为种子的含义由下文那三个 info 字符串固定,而非由它如何被传输来决定。
派生三套密钥对
每种算法的私钥都是同一个种子的一次独立 HKDF-SHA-256 展开,依据 RFC 5869。这三次展开共用相同的输入密钥材料和相同的(缺省的)盐,仅在一个参数上不同——指明算法的 info 字符串:
| 算法 | info 字符串 | 输出 |
|---|---|---|
| Ed25519 | cardano-poe-ed25519-v1 | 32 字节 Ed25519 秘密种子 |
| X25519 | cardano-poe-x25519-v1 | 32 字节 X25519 秘密种子 |
mlkem768x25519 | cardano-poe-mlkem768x25519-v1 | 32 字节 X-Wing 解封装密钥种子 |
派生过程的伪代码:
ed25519_priv = HKDF-SHA-256(ikm = seed, salt = "", info = "cardano-poe-ed25519-v1", length = 32)
x25519_priv = HKDF-SHA-256(ikm = seed, salt = "", info = "cardano-poe-x25519-v1", length = 32)
mlkem768x25519_priv = HKDF-SHA-256(ikm = seed, salt = "", info = "cardano-poe-mlkem768x25519-v1", length = 32)有三条规则让这些输出在各实现之间可互操作:
- 盐为空。 HKDF 的盐 MUST 是零长度字节串。依据 RFC 5869 §2.2,缺省的盐会被当作
HashLen个零字节处理——对 SHA-256 即 32 个零字节——因此每个合规库都会到达相同的提取步骤。 - 输出为 32 字节。 每次展开都恰好请求 32 字节(对 SHA-256 而言是单个 HKDF 块)。
info字符串是精确的 ASCII。 每个info值 MUST 按所示字节精确编码——没有首尾空白、没有零终止符、没有字节序标记、没有结尾换行。三个字符串分别为 22、21 和 29 字节。
那 32 个输出字节是该算法的秘密种子,而非其展开后的曲线标量。RFC 8032 §5.1.5 为 Ed25519 作了这一区分:秘密种子是 32 字节,签名库会在内部把它(先经 SHA-512,再做位钳制)展开为真正的标量和签名前缀。X25519 同理,其位钳制按 RFC 7748 §5 在原语内部完成。实现 MUST 把原始的 32 字节 HKDF 输出直接交给原语,由库去执行展开和钳制——它自己不做预钳制或预展开。对 X-Wing 而言,那 32 字节输出是 X-Wing 解封装密钥种子,完整的密钥对——包括 1216 字节的公钥——由 X-Wing 密钥生成从中确定性地重新生成。在所有情况下,那个紧凑的 32 字节种子,而非展开后的密钥,才是用于存储和传输的规范形式。
为什么是三个域,而非一个
HKDF 的 info 参数就是它的域分离标签:它把展开后的输出绑定到特定的应用上下文,而 RFC 5869 §3.1 强烈建议在有上下文可用时提供这样一个标签。Label 309 为每种算法提供一个各不相同的标签,而不是把同一次展开复用到全部三套上——即便这三把私钥的宽度恰好都是 32 字节。理由在于隔离:
- 故障被限制在局部。 如果两套密钥对共用相同的字节,那么某一算法特有的弱点——某个 nonce 派生缺陷、某次标量乘法上的侧信道——就可能暴露另一个毫不相干算法的密钥。域分离保证了三把私钥是种子的彼此独立的函数,因此其中一把被攻破,攻击者对其余几把仍一无所知。
- 迁移是可叠加的。 每个
info字符串都以-v1结尾。将来某次修订若采用不同的曲线或不同的混合方案,会在新标签下从同一个种子派生出一套全新的-v2密钥,与已部署的 v1 密钥不会发生冲突。这与线格式本身所依赖的算法可演进(algorithm-agile)如出一辙。
第三个标签 cardano-poe-mlkem768x25519-v1 给后量子混合方案分配了它自己的域,即便其解封装密钥种子与经典 X25519 秘密一样都是 32 字节宽。这样一来,ML-KEM-768、X25519 或 X-Wing 组合器中的任何缺陷,都无法殃及经典加密密钥或签名密钥。
这个身份密钥标签 cardano-poe-mlkem768x25519-v1 还不含 -kek- 这一段:它与下文那个逐记录的 KEK 派生标签 cardano-poe-kek-mlkem768x25519-v1 截然不同,因此种子 → 身份密钥的展开,与一份密封 PoE 的逐槽位密钥包裹,绝不会共用同一个 info 字符串。
逐槽位密钥加密密钥
上面那三套由种子派生的密钥对是长期身份密钥。一份密封 PoE 还会再叠加一层逐记录的 HKDF-SHA-256:对每个接收方槽位,发送方都派生出一把全新的 32 字节密钥加密密钥(KEK),用它来包裹这条记录的内容加密密钥。KEK 的派生属于密钥模型的一部分,所以在这里加以规定;至于被包裹的密钥随后如何乘上信封,见 密封 PoE。
两种 KEM 都用 HKDF-SHA-256 和一个 KEM 特有的 info 来派生 KEK,且都在一个 带标签哈希盐 之下进行,该盐绑定三个值:槽位自身的 KEM 材料(让 KEK 在每个槽位上唯一)、接收方公钥 pub_R(让为某位接收方构造的封装无法被转手用到另一位接收方身上),以及信封唯一的 enc.nonce(把 KEK 锚定到唯一一个信封)。共享密钥就是该 KEM 自身的 ECDH(经典)或 X-Wing 解封装(混合)输出——无论是发送方做封装还是接收方做解封装,都是同一个 32 字节值——而 salt 与 info 在两端完全一致:
; x25519 (classical) — salt is a labelled SHA-256 over the ephemeral and recipient keys
kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R) ; 32 bytes
KEK = HKDF-SHA-256(ikm = shared, ; the X25519 ECDH shared secret
salt = kek_salt,
info = "cardano-poe-kek-v1",
L = 32)
; mlkem768x25519 (hybrid) — same labelled-salt shape under the hybrid's own label
kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) ; 32 bytes
KEK = HKDF-SHA-256(ikm = shared, ; the X-Wing shared secret
salt = kek_salt,
info = "cardano-poe-kek-mlkem768x25519-v1",
L = 32)两个盐有同样的形态——SHA-256(label || enc.nonce || <slot KEM material> || pub_R)——仅在逐 KEM 的标签、以及它们所携带的是哪种 KEM 材料上不同:经典路径上是 32 字节的临时公钥 pub_epk,混合路径上是 1120 字节的 X-Wing 密文 kem_ct。两者都被折叠进一个定长的 SHA-256 摘要,因为混合输入对一个原始盐而言过大,而一套统一的形态让两条路径保持一致。这条绑定是在 KEM 之外、就着槽位自身的线上字节算出来的,因此它把 X-Wing 当作一个黑盒 KEM 来对待,不依赖组合器内部哈希的任何性质。KEM 各不相同的 info 标签还额外保证:哪怕共享密钥是同一个 32 字节值,在某种 KEM 下派生出的 KEK 也绝不会等于在另一种 KEM 下派生出的 KEK。
两个盐里的 pub_R 都是接收方密钥的规范线上编码——x25519 取的是那把恰好 32 字节的 X25519 公钥,mlkem768x25519 取的是那串钉定的、恰好 1216 字节的 X-Wing 公钥字节。生产方与接收方 MUST 使用这一确切编码,且 MUST NOT 拿任何非规范的或重新编码过的等价物去替换:否则两边会把不同的盐喂进 HKDF,派生出不同的 KEK,那个槽位也就永远打不开。
每把 KEK 以及它的盐前缀都是 enc.scheme: 1 的内部构建块:它们不携带任何线上标识符,也不可被选用。这里的两个盐前缀标签和那两个 info 标签,是 算法注册表 上列出的十一个密封构造标签字面量中的四个;验证器 MUST 逐字节地使用每一个。
接收方公钥编码
密封 PoE 的发送方需要拿到接收方的公钥,且要是便于携带的字符串形式,而接收方则要以配套的形式备份自己的私钥。Label 309 复用了 age 生态的 Bech32 接收方编码,每种已注册的密钥封装机制对应一个人类可读前缀(HRP)。
在 Bech32 中,1 是 HRP 与数据部分之间的分隔符,所以一个字符串人类可见的前缀,是它的 HRP 加上那个 1。HRP 与可见前缀因此并不相同,下表把它们分列在不同的列里:
KEM(enc.kem) | 公钥 | 公钥 HRP | 公钥可见前缀 | 私钥 HRP | 私钥可见前缀 |
|---|---|---|---|---|---|
x25519 | 32 字节 X25519 公钥 | age | age1…(62 字符) | AGE-SECRET-KEY- | AGE-SECRET-KEY-1… |
mlkem768x25519 | 1216 字节 X-Wing 公钥 | age1pqc | age1pqc1…(1960 字符) | AGE-SECRET-KEY-PQ- | AGE-SECRET-KEY-PQ-1… |
经典 x25519 接收方字符串的 HRP 是 age,采用标准的 age v1 形式 age1…。混合公钥是把一个 ML-KEM-768 封装密钥(1184 字节)与一个 X25519 公钥(32 字节)拼接而成;它共 1216 字节,其 age1pqc1… 接收方字符串长 1960 个字符。
实现备份和导入的私钥,在两条路径上都是那个 32 字节种子——X25519 秘密种子放在 AGE-SECRET-KEY- 之下,X-Wing 解封装密钥种子(上文第三个 HKDF 输出,info = "cardano-poe-mlkem768x25519-v1")放在 AGE-SECRET-KEY-PQ- 之下。1216 字节的混合公钥由那个种子派生而来;用于存储的规范私钥,是那个紧凑的种子,而绝不是展开后的密钥。
BIP-173 把 Bech32 字符串上限定为 90 个字符,但那个上限是为人工键入的支付地址设的,在这里并不适用。实现 MUST 在不强制 90 字符限制的前提下对 age1pqc1… 字符串进行编码和解码,同时仍然套用 Bech32 校验和与字符集规则。各不相同的 HRP age1pqc 让混合接收方不会与任何经典的 age 接收方相撞——而且它特意不采用 age1pq,那个更短的前缀已被某个上游原生的 ML-KEM-768 + X25519 编码为同一原语占用,于是两种接收方编码在线上绝不会相撞。经典编码停留在普通长度范围内,原样处理即可。
这些字符串仅为方便接收方发现而存在。接收方公钥绝不会出现在 Label 309 记录的加密信封中——一个 enc.slots[] 条目携带每槽的密钥材料和一个 wrap 值,而 KEM 标识符在 enc.kem 处出现一次。信封和槽位如何构建,参见密封 PoE。
用作签名 kid 的 Ed25519 公钥
Ed25519 公钥不承担任何接收方角色;它是验证器据以核验某个签名的密钥标识符。当生产方对一条记录签名时,原始的 32 字节 Ed25519 公钥就是 COSE_Sign1 受保护头部中的 kid(标签 4),依据 RFC 9052。验证器直接从链上签名中读取那个 32 字节值,并据此核验记录体——公钥与签名一同传递,因此无需另行查表即可验证作者身份。完整的签名构造、被签名的载荷以及验证规则,规定于签名。
带外密钥交换
Label 309 规定的是接收方公钥如何编码,而不是它们如何被发现。本标准没有为接收方密钥规定任何目录、任何注册表,也没有任何链上公告格式。想要接收密封载荷的一方,会通过双方业已信任的某个渠道发布自己的 age1… 或 age1pqc1… 字符串——当面交接、用自己 Ed25519 密钥签名的一条记录、或放在某个稳定的 Web 位置或内容寻址位置的一条记录——而对所加密到的任何密钥的来源,由发送方负责把关。
这是一条刻意划下的边界。正是那条让记录无需信任服务器即可验证的性质,要求密钥交换不能把一个受信任的中间人偷偷塞回来。摆在密钥旁边的一个名字,只是放置它的那一方所作的一份声明,绝不是密码学层面的断言:两方使用同一个标识符,照样会产出字节不同的密钥,而验证器比对的是字节本身。把人类可读的名字映射到密钥,是建立在 Label 309 之上的应用 MAY 提供的功能,但它属于应用层特性,处在协议之外。