密封存在性证明
Label 309 的加密信封——发送方如何将内容密封给一个或多个接收方密钥,而链上只承载明文哈希和封装后的密钥槽,绝不承载明文,也绝不暴露接收方。
密封存在性证明(密封 PoE) 在为明文锚定一个带时间戳的承诺的同时,让该明文只对选定的受众可读。链上记录承载的是明文哈希——和其他任何记录一样,它就是时间证明——外加一个 加密信封(enc),其中保存着恢复内容加密密钥所需的全部材料。密文本身从不上链,它存放在一个内容寻址 URI 处(ar:// 或 ipfs://)。链上没有任何东西会泄露明文,也没有任何东西会泄露接收方是谁。
本页规定 enc 信封的内容:它两条互斥的密钥投递路径、每个接收方对应的密钥槽、槽集 MAC、分段内容 STREAM,以及接收方为发现并打开发给自己的消息所执行的试解密。接收方密钥本身——即由种子派生的 X25519 与 X-Wing 密钥对——在 密钥 中定义,本页直接使用它们。enc map 在记录 map 中的位置,以及把它承载上链的整体传输,则在 记录 中定义。
模型及其隐私属性
发送方希望发布一个永久、带时间戳的承诺,证明某段特定明文在时刻 T 被密封给了某个特定受众——同时确保只有那个受众才能读到它。仅含哈希的 PoE 能给出时间主张,却没有受众绑定;而对公开密文做 PoE 则完全谈不上保密。密封 PoE 把两者结合起来:记录承诺明文哈希(公开、带时间戳),并在 enc 中承载密钥投递材料,而存放在 ar:// 或 ipfs:// URI 处的密文,没有匹配的解锁秘密就无法解密。
这套构造是刻意设计的,目的是让链上尽可能少地泄露关于消息的信息,并且不泄露任何关于受众的信息:
- 明文绝不上链。 链上只有它的哈希和封装后的密钥。任何日后拿到该明文的人都能证明「这段确切明文在区块时间 T 被承诺过」,而其他任何人都不会得知被密封的是什么。
- 接收方公钥绝不上链。 接收方的公钥不会出现在
enc的任何位置。接收方只能通过 成功试解密 某个密钥槽来识别某条消息是发给自己的——根本没有可读取的收件地址字段。一个手里没有任何候选密钥的观察者,只能获知槽位数量、KEM 族系(enc.kem),以及密封与非密封之分。那条更强的属性——一个 握有 候选接收方密钥的对手,仍然无法检验某个密钥槽是否(以及发给哪一个候选)寻址给它——叫做 密钥私密性,只为经典的x25519路径声称;混合的mlkem768x25519路径不声称它(参见 匿名性与因 KEM 而异的分野)。 - 接收方之间彼此一无所知。 每个接收方对应的密钥槽都是一个不透明的封装密钥。打开自己密钥槽的接收方既无法推导出任何其他接收方的密钥,也无从得知还有谁是收件对象。
- 密钥槽的排列顺序不泄露任何信息。 发送方列出接收方的次序(例如「主接收方排在前面」)属于敏感元数据。密钥槽数组 在发布前会用 CSPRNG 打乱,因此连位置次序都不携带任何信号。
- 未签名的密封 PoE 保护发送方的匿名性。 作者身份签名是可选的(参见 签名)。一条没有
sigs[]的密封记录不会在链上绑定任何发送方身份——这正是举报者投递、密封竞价拍卖和证据托管所需要的。
链上 确实 暴露的信息很有限:某条记录是密封 PoE(即存在 enc)、明文哈希、区块时间戳,以及密钥槽的 数量(数组长度)。这个数量是唯一与接收方沾边的暴露事实,而它只揭示「有多少个」,绝不揭示「是谁」。记录之间的时序关联是一个元数据层面的隐患,线上层面的密码学无法解决;需要挫败这种关联的发送方,必须把发布操作批量化,使其脱离敏感时间线。
接收方公钥是 带外 交换的。Label 309 不 规定任何发现机制:接收方可以把自己的密钥发布在个人网站、一条 DNS 记录、社交主页、二维码或一份链上自证里。验证器把接收方密钥字节当作输入,对这些密钥归属于谁不作任何断言——其出处是发送方的信任决策,正如给别人发送一份 PGP 密钥时一样。
信封及其两条路径
enc map 承载若干公共字段,外加两条互斥密钥投递路径中 恰好一条。结构校验器会强制这种互斥性;同时携带两条路径、或两条都不携带的记录都会被拒绝。
| 字段 | 状态 | 含义 |
|---|---|---|
scheme | REQUIRED | 构造族版本。v1 定义 scheme = 1。 |
aead | REQUIRED | 内容格式标识符。v1 定义 "chacha20-poly1305-stream64k"。 |
nonce | REQUIRED | 24 个随机字节——内容密钥与每个槽位 KEK 的信封唯一 salt。 |
kem | 仅 slots 路径 | 逐槽 KEM 选择器("x25519" 或 "mlkem768x25519")。 |
slots | 两条路径择一 | 逐接收方密钥槽数组(多接收方)。 |
slots_mac | 仅 slots 路径 | 把槽集与该项的哈希主张绑定到内容密钥的 32 字节 HMAC。 |
passphrase | 另一条路径 | 口令 KDF 块(由口令派生密钥)。 |
enc.slots——多接收方。 信封承载 N 个各自独立封装的密钥槽,每个接收方一个。没有与某个槽匹配的私钥就无法解密密文。详见下文 密钥槽与槽集 MAC。enc.passphrase——口令派生。 信封不承载任何密钥槽;内容密钥直接由一段规范化后的口令派生而来。详见下文 口令路径。
两条路径共享 scheme、aead 和 nonce。它们的区别在于存在的是哪种密钥,进而决定了 密钥承诺存放在何处。在 slots 路径上,承诺在链上:slots_mac 是一个由 CEK 加键的 HMAC,作用于一份钉死头部字段、槽集以及该项哈希主张的转录之上,因此接收方在拉取任何东西之前就能确认密钥无误。在口令路径上,没有密钥槽可绑定,于是承诺是一段承载于 密文数据块之内 的 32 字节头部——检验一次口令猜测需要数据块本身,而绝不只是公开链。每条路径都用同一个 canonicalEncode 函数序列化自己的转录,生产方或验证器则通过检查 slots / passphrase 哪一个存在来选定路径。这两条路径是穷尽且互斥的。
enc.scheme 命名的是构造 族,与记录的 v 字段无关。验证器 MUST 要求 enc.scheme === 1 并拒绝任何其他取值。该字段是为未来的某项横切性变更预留的——比如换用不同的槽集 MAC 方案或内容格式——而非用于新增 KEM:逐槽 KEM 由 enc.kem 选定,下文这两种 KEM 从首个版本起就都归在 scheme = 1 之下。更宽泛地说,enc.scheme: 1 标识的是整套密码学套件,而不仅仅是 MAC 和内容格式:canonicalEncode 规则、槽位 schema、HKDF 哈希、HMAC 哈希、逐槽包裹 AEAD、分段 STREAM 内容格式、slots 与 passphrase 转录 schema(含 hashes_hash 这道项绑定)、密文内的口令承诺、钉定的 X-Wing 修订版、域分隔标签、Argon2id 版本与配置,以及口令规范化配置,全都由它固定下来,因此其中任何一项发生改变,都要求换一个新的 enc.scheme 取值。
内容层
两条路径最终都汇聚到对明文的同一次对称运算,所用的密钥派生自单个 32 字节的 内容加密密钥(CEK)。CEK 是密钥槽所投递的(每个密钥槽都封装它),或者是口令 KDF 所产出的;内容 并非 直接用 CEK 加密。每条路径都改为派生出一把单独的 32 字节 内容密钥,作为 CEK 的一个 HKDF 派生叶——以信封唯一的 enc.nonce 加 salt、在一个逐路径的 info 之下——这样密钥投递层与内容层就绝不会在相同的字节上为同一个原语设密钥:
content_key = HKDF-SHA-256(ikm = CEK, salt = enc.nonce,
info = <"cardano-poe-payload-v1" on the slots path,
"cardano-poe-payload-passphrase-v1" on passphrase>,
L = 32)随后,内容被封装在一个 分段 STREAM 之中,由内容格式标识符 chacha20-poly1305-stream64k 命名。这就是 age v1 规范 的 STREAM 布局:在明文之上施加 ChaCha20-Poly1305(RFC 8439,12 字节 nonce 变体),把明文切成定长块,每块都在内容密钥之下、配一个逐块的计数器 nonce 加以封装:
cipher : ChaCha20-Poly1305 (RFC 8439; 12-byte nonce, 16-byte tag)
CHUNK_SIZE : 65536 plaintext bytes per non-final chunk
chunk nonce : uint88_be(counter) || final_flag ; 12 bytes
counter starts at 0, +1 per chunk;
final_flag = 0x01 on the final chunk, 0x00 otherwise
per-chunk AAD: empty
final chunk : 0 to 65536 plaintext bytes; every non-final chunk is exactly 65536
empty input : exactly one final chunk of zero-length plaintext (a lone 16-byte tag)
ciphertext = seal(chunk_0) || seal(chunk_1) || ... || seal(chunk_final)
; each sealed chunk = plaintext length + 16 bytes末块标志 把最后一块与其余各块域分隔开来,截断之所以能被检测出来正源于此:一条最后一块没有携带 0x01 标志的流、一块并非最后却带 0x01 标志的块、跟在最后一块之后的数据,或一块短于 CHUNK_SIZE 的非末块,都 MUST 导致解密失败(TAMPERED_CIPHERTEXT)。由于每个封装后的块都至少是它那 16 字节的标签,这套布局还隐含了一道结构性下限——一个良构的 slots 路径密文数据块绝不会短于 16 字节,即一个空末块那唯一的标签。
逐块 AAD 在设计上为空:所有上下文都是 传递性地 绑定到内容上的。内容密钥派生自 CEK,而 CEK 在 slots 路径上由 slots_mac 绑定到完整头部(其转录覆盖 scheme、path、aead、kem、nonce、槽集,以及该项的哈希主张),或在口令路径上由密文内的承诺绑定。翻动任何一个头部字段,接收方都会派生出或接受一把不同的密钥,于是解密失败;逐块 AAD 只会在每一块上把同样的上下文重新绑定一遍,并不增加任何安全性。
那些基于计数器的逐块 nonce 之所以安全,是因为内容密钥是一次性的:它派生自一个以信封唯一的 enc.nonce 加 salt 的全新 CEK,因此没有两条流会共用同一个 (key, nonce) 对,而无状态生产方——浏览器标签页、CLI 运行、worker、重试——也从不需要在多个信封之间协调 nonce。那个 88 位计数器允许 2^88 个块,远超任何可实现的载荷,所以这套格式 不施加任何密码学层面的载荷天花板;现实中的上限是一道部署期的拒绝服务策略,而非线上常量。
明文输入就是原始内容字节本身。这套构造 不会 在前面或后面附加、也不会加密任何文件名、MIME 类型、大小字段或清单——流解密回去得到的就是那些字节,而且仅是那些字节。
在哈希复核之前,已释放的分块都是暂定的
这套分段格式之所以存在,是为了让验证器能在有界内存下增量地认证并释放一份数 GiB 的载荷。每一块的标签都会在该块的明文被释放之前先行校验,截断也由末块标志捕获——但明文哈希的复核要在最后一块之后、在 整段 明文之上进行。因此一个流式消费方 MUST 把已释放的字节视为 暂定的——不产生任何副作用、不作任何确认、不打任何「已收到」状态——直到那次最终检查通过为止。
所发布的密文是单个对象。在 slots 路径上,它恰好就是那些 STREAM 块;在口令路径上,一段 32 字节的密钥承诺头部会被前置在 同一个数据块之内(同一个对象、同一个 URI、同一次拉取——绝不是第二个存储对象):
slots path : ciphertext blob = [ STREAM chunks ]
passphrase path : ciphertext blob = [ commitment: 32 bytes ] || [ STREAM chunks ]items[].hashes 中的明文哈希 始终承诺的是明文,即便存在 enc 也是如此。这是承重属性:无法解密的验证器仍然可以确认记录存在、其信封格式良好、URI 可拉取——但只有持有匹配接收方密钥的人才能解密密文,并通过重算哈希来确认这份承诺 到底 承诺的是什么。因此校验器 MUST NOT 为了「验证」哈希而去解密;明文哈希验证发生在接收方一侧,在字节被恢复之后。参见 内容与哈希 和 验证。
密钥槽与槽集 MAC
在多接收方路径上,enc.slots 是一个非空的逐接收方密钥槽数组。每个密钥槽都用一个逐接收方的密钥加密密钥(KEK)封装 同一个 CEK;接收方打开任意一个密钥槽,恢复出的都是那唯一一个能解密内容的 CEK。发送方:
- 为整条记录选定一种 KEM,并生成 CEK(32 个随机字节)和
nonce(24 个随机字节)。 - 为每个接收方派生一个逐槽 KEK,并用它封装 CEK(各 KEM 的细节见下文)。
- 用 CSPRNG 打乱 密钥槽数组(无偏的 Fisher–Yates)。
- 在打乱后的数组、跨 KEM 的头部字段以及该项的哈希主张之上构建 slots 转录,把它哈希成
slots_hash,再以一个由 CEK 加键的 HMAC 在该哈希之上算出slots_mac。 - 从 CEK 和
enc.nonce派生内容密钥,并把内容封装进上文那个分段 STREAM。
逐槽包裹
每个密钥槽用 ChaCha20-Poly1305(RFC 8439,12 字节 nonce 变体)在逐槽 KEK 之下封装 CEK,产出一个 48 字节的 wrap(32 字节 CEK 密文 + 16 字节 Poly1305 标签):
wrap = ChaCha20-Poly1305_seal(
key = KEK, ; per-slot, 32 bytes
nonce = bytes(12, 0x00), ; ZERO nonce
ad = <KEM info literal>, ; the KEK info string for the chosen KEM
plaintext = CEK)这个 12 字节全零 nonce 之所以安全,正是因为每个密钥槽的 KEK 在一条记录内都是唯一的:因此一个 KEK 恰好只用于一次包裹,nonce 在任何单一密钥下都绝不会发生碰撞。这是一条硬性不变式——倘若将来某次修订允许 KEK 被复用(缓存、确定性临时密钥、或重用密钥槽的接收方去重),那么在同一次改动中就必须把零 nonce 换成随机 nonce。
槽集 MAC
slots_mac 把整个密钥槽集——连同那些固定了密钥槽该如何解读的跨 KEM 头部字段,以及该项的明文哈希主张——绑定到 CEK,挫败密钥槽替换、密钥槽删除、密钥槽重排和信封拼接这类篡改。这道绑定是一个 两步 构造:先把一份 slots 转录 哈希一次,得到一个 32 字节的 slots_hash,再把这个哈希当作一个由 CEK 加键的 HMAC 的消息。
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes)) ; 32 bytes
SLOTS_TRANSCRIPT = { ; closed 7-key map; keys are a set, not an order
"scheme": 1, ; uint
"path": "slots", ; text
"aead": <enc.aead>, ; text: 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 map
slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT)) ; 32 bytes
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 bytesSLOTS_TRANSCRIPT 是一个恰好承载那七个键的封闭 map,用 canonicalEncode 序列化,因此两端产出逐字节一致的字节;它的键顺序是 RFC 8949 §4.2.1 的字节序排序,绝非手工排列。slots 的取值就是那个由封闭密钥槽 map 组成的打乱数组,与它们在线上的呈现完全一致(x25519 是 {epk, wrap},mlkem768x25519 是 {kem_ct, wrap}),因此每个密钥槽的全部逐槽线上内容都在转录之内。转录 额外 钉死了 scheme、path、aead、kem 和 nonce:一个中继若在保持密钥槽形状有效的同时翻动这些头部字段中的任何一个,都会得到不同的 slots_hash,于是 MAC 失败。slots_hash 与 hashes_hash 的 SHA-256 前缀(cardano-poe-slots-transcript-v1、cardano-poe-item-hashes-v1)是精确的 ASCII,不带终止符、也不带长度前缀。
hashes_hash 正是把信封绑定到 该项哈希主张 的那一环:它是在该项完整 hashes map 的 canonicalEncode 之上、一个带标签的 SHA-256。由于接收方仅凭链上字节就能重算 slots_mac,一次 MAC 匹配就确认了信封是为这份确切主张而密封的——一个被拼接到带有不同 hashes map 的项上的信封,会在任何密文拉取之前、就在链上的匹配那一步失败。该项的 uris[] 是刻意 不 被绑定的,因此密文可以在一个新的内容寻址 URI 处重新托管而不会使信封失效;若某个发送方把 URI 列表也视为主张的一部分,则改用一个记录级签名来绑定它。
在 HMAC_KEY 的派生里,salt = "" 是一个零长度八位组串,即 RFC 5869 §2.2 中缺省 salt 的约定(HKDF-Extract 以 HashLen 个零字节代入——SHA-256 即 32 个)。它由一个逐字节的一致性向量钉死,而非交给某个库的默认行为,因此一个对缺省 salt 处理有误的实现会直接在该向量上失败,而不是悄无声息地派生出一把不同的密钥。
slots_hash 每条记录 只算一次,且在接收方试解密循环中 保持不变——逐槽 MAC 检查会用每个候选 CEK 给 HMAC 重新设密钥,但始终是在那同一个 32 字节的 slots_hash 之上。承诺属性得以保留,是因为 HMAC 密钥仍然是 HKDF-SHA-256(CEK, …):预先哈希转录只是把 HMAC 的 消息 从完整转录换成了它的 SHA-256,那道由 CEK 加键的绑定原封不动。
槽集 MAC 由 enc.scheme 固定 下来:它在线上没有标识符,每个 scheme 取值恰好对应一种构造,并且对两种 KEM 都完全相同。slots_mac MUST 恰好为 32 字节(长度不对则报 ENC_SLOTS_MAC_INVALID_LENGTH),并 MUST 以恒定时间验证。
转录直接取决于每个密钥槽的线上字节。两个密钥槽字段都是单个 CBOR 字节串——epk 是 32 字节,kem_ct 是 1120 字节——因此既没有逐字段的分块需要规范化,也没有任何分块边界的歧义:Label 309 唯一执行的分块,是 记录 上那次整体传输切分,它在这一切运行之前就已被还原。密钥槽里任何一处的字节翻转都会改变 slots_hash 并令 MAC 失败。
内容层无需对槽集再作一道单独的逐趟绑定:内容密钥是 CEK 的一个 HKDF 派生叶,而 CEK 已经由 slots_mac 绑定到了完整头部——包括 hashes_hash。编辑任何密钥槽或头部字段,都会改变接收方所派生的东西,于是内容流根本打不开。逐块 AAD 因此为空(参见 内容层)。
两种 KEM
KEM 由 enc.kem 逐记录选定,它固定了密钥槽的形状和 KEK 的派生方式。两种 KEM 从首个版本起就都注册在 enc.scheme = 1 之下。
enc.kem | KEM | 接收方公钥 | 密钥槽形状 | KEK info 字符串 |
|---|---|---|---|---|
"x25519" | X25519(经典) | 32 字节 | { epk: bstr(32), wrap: bstr(48) } | "cardano-poe-kek-v1" |
"mlkem768x25519" | X-Wing = X25519 + ML-KEM-768 | 1216 字节 | { kem_ct: bstr(1120), wrap: bstr(48) } | "cardano-poe-kek-mlkem768x25519-v1" |
生产方 SHOULD 默认采用 mlkem768x25519。 这种混合 KEM 能同时抵御经典攻击者和「先收割、后解密」的量子攻击者,又把 X25519 的经典安全性作为安全下限保留——X-Wing 组合器把两个共享密钥都绑了进来。那条「绝不低于 X25519 经典安全性」的下限,是限定在合规生成的接收方密钥之上的:它的前提是公钥能通过钉定的 X-Wing 修订版的密钥有效性检查(在封装时施加,参见下文 混合:mlkem768x25519)。经典的 x25519 KEM 则继续可用,留给那些公布的密钥仅为 X25519 的接收方。标识符 mlkem768x25519 刻意写成不带连字符的形式,与 X-Wing/age 生态的拼写保持一致。
两种 KEM 都采用 同一套 age stanza 模式——逐接收方的 KEM 材料,加上对文件密钥的一次对称包裹——并采用 同样的 头部绑定(槽集 MAC),因此一套统一的构造就能覆盖两者,且不依赖 HPKE。经典的 x25519 路径与 age 原生的 X25519 接收方高度一致。混合的 mlkem768x25519 路径则刻意 背离 age 自己的后量子选择:age v1.3.0 交付了原生的后量子接收方(可见前缀 age1pq…),它通过 HPKE 的 SealBase(RFC 9180)在一个 ML-KEM-768 + X25519 KEM 之上封装文件密钥,而非 stanza 模式。为混合路径保留 stanza 包裹,正是让一套统一的包裹和一套统一的头部绑定得以覆盖两种 KEM 的原因。因此混合包裹 不 继承 age 的 HPKE 构造,也不为它作任何继承自 age 的声称;那个各不相同的 age1pqc 接收方编码(参见 密钥)反映出这两种混合编码彼此独立。
经典:x25519
发送方为每个接收方生成一对 全新临时 X25519 密钥对,对接收方公钥执行一次 ECDH,再用 HKDF(RFC 5869)在一个带标签哈希的 salt 之下派生 KEK:
shared = X25519(priv_epk, pub_R) ; per RFC 7748; reject all-zero output
kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || pub_epk || pub_R) ; 32 bytes
KEK = HKDF-SHA-256(ikm = shared,
salt = kek_salt, ; binds nonce, ephemeral, recipient
info = "cardano-poe-kek-v1",
L = 32)
slot = { "epk": pub_epk, "wrap": wrap } ; epk = 32 bytes32 字节的临时公钥 epk 是线上唯一的密钥材料;接收方公钥从不发布。这个 salt 是一个带标签的 SHA-256,绑定了三个值:pub_epk 让每个密钥槽的 KEK 唯一,pub_R 把它绑定到特定接收方(挫败任何把某个 epk 挪用到另一接收方身上的企图),而信封唯一的 enc.nonce 则把 KEK 锚定到唯一一个信封——因此一次在两个信封间重复了 KEM 随机数的 CSPRNG 故障,只会退化为跨信封可关联性,而绝不会退化为一对重复的 (KEK, 零 nonce) 包裹。X25519 实现 MUST 按 RFC 7748 §6.1 拒绝全零共享密钥;主流库会传递性地做到这一点。
混合:mlkem768x25519(X-Wing)
这种混合 KEM 即 X-Wing 构造(draft-connolly-cfrg-xwing-kem-10),它把 ML-KEM-768(FIPS 203)与 X25519 结合起来。每次封装都抽取全新的 ML-KEM 随机数和一个全新的 X25519 临时密钥,产出一个 1120 字节的密文和一个 32 字节的组合共享密钥。KEK 派生通过一个就着密钥槽自身线上字节算出的 外部 salt 来绑定接收方:
enc = XWing.Encapsulate(pub_R) ; named fields — MUST NOT consume positional order
kem_ct = enc.ct ; 1120 bytes
shared = enc.ss ; 32 bytes
kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R) ; 32 bytes
KEK = HKDF-SHA-256(ikm = shared,
salt = kek_salt, ; binds nonce, kem_ct, recipient
info = "cardano-poe-kek-mlkem768x25519-v1",
L = 32)
wrap = ChaCha20-Poly1305_seal(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 stringX-Wing 的密钥与密文尺寸:
| 组件 | 尺寸 | 组成 |
|---|---|---|
| 公钥 | 1216 字节 | ML-KEM-768 ek(1184)‖ X25519 pk(32) |
| 密文 | 1120 字节 | ML-KEM-768 ct(1088)‖ X25519 临时密钥(32) |
| 共享密钥 | 32 字节 | X-Wing 组合器输出 |
| 解封装密钥 | 32 字节 | 一个种子;公钥由它派生而来 |
混合密钥槽 不含 epk 字段——X25519 临时密钥就是那 1120 字节 kem_ct 的末尾 32 字节。XWing.Encapsulate MUST 对 pub_R 施加钉定的 X-Wing 修订版的公钥有效性检查,并拒绝向一个无效密钥封装、而不是径直对它封装;这正是混合下限不跌破 X25519 经典安全性所依赖的前置条件。这套构造通过一个只用具名字段的适配器来使用 X-Wing:Encapsulate(pk) 产出 .ct(1120 字节)和 .ss(32 字节);Decapsulate(sk, ct) 产出那个 32 字节的共享密钥。实现 MUST 按名字映射到钉定修订版的 API,且 MUST NOT 按位置去取返回值——钉定的修订版从封装返回的是 (ss, ct),并把解封装写作 Decapsulate(ct, sk),与从左到右朴素阅读所得的顺序恰好相反。KEK 派生通过一个 定长带标签 salt 来绑定接收方,即 SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || kem_ct || pub_R),其中 kem_ct 是密钥槽里所承载的那 1120 字节密文,pub_R 是 1216 字节的 X-Wing 接收方公钥。这与经典 salt 在自己的标签之下所用的三值形态一致——kem_ct 把 KEK 锚定到一个逐槽唯一的值,pub_R 把它绑定到特定接收方,而 enc.nonce 把它锚定到唯一一个信封——只不过因为混合输入对一个原始 salt 而言过大,所以改用一个 SHA-256 摘要来表达。在两个 salt 里,pub_R 这一项都是接收方密钥的规范线上编码:x25519 取的是那把恰好 32 字节的 x25519_publicKey(priv_R),mlkem768x25519 取的是那串钉定的、恰好 1216 字节的 X-Wing 公钥字节。生产方与验证器 MUST 使用这一确切编码,且 MUST NOT 拿任何非规范的或重新编码过的等价物去替换,否则两边会派生出不同的 KEK,一条诚实记录也就打不开了。关键在于,这条绑定是在 KEM 之外、就着密钥槽自身的线上字节算出的,因此整套构造把 X-Wing 当作一个 黑盒 KEM:它只用到公开的 KEM 接口(封装、解封装、那个 32 字节共享密钥),对组合器内部哈希 不作 任何假设。那个因 KEM 而异的 info 标签 cardano-poe-kek-mlkem768x25519-v1 还额外保证:为某种 KEM 派生的 KEK 绝不会等于为另一种 KEM 派生的 KEK,哪怕在完全相同的 32 字节共享密钥上。那 1120 字节的密文作为 单个 CBOR 字节串 承载于 slot.kem_ct——只有整条记录体会被分块以供传输(参见 记录),单个字段从不被分块。
每条记录一种 KEM
单个密封 PoE 条目 恰好 承载一个 enc.kem;每个密钥槽都使用该 KEM 的形状和 KEK 派生方式。一个文件要么全部走经典、要么全部走混合——不同 KEM 的密钥槽 MUST NOT 出现在同一个 slots 数组里,而且验证器 MUST 拒绝那种密钥槽形状与所声明的 enc.kem 不一致的记录(ENC_SLOT_INVALID_SHAPE)。
封装材料还 MUST 在 单个 slots 数组内彼此互异:对 x25519,所有 epk 值 MUST 各不相同;对 mlkem768x25519,所有 kem_ct 值 MUST 各不相同。出现重复时——在任何 KEM 或 AEAD 原语运行之前——会以 ENC_SLOTS_DUPLICATE_KEM_MATERIAL 被拒绝。这是零 nonce 包裹所依赖的逐槽 KEK 唯一性不变式中可验证的那一片:跨记录或跨密钥的 KEK 复用是验证器无法察觉的生产方义务,但记录内部的重复在结构上可见,且 MUST 失败。
接收方试解密
接收方持有一把私钥(x25519 是 32 字节的 X25519 标量,mlkem768x25519 是 32 字节的 X-Wing 解封装种子——两者都由种子派生;参见 密钥)。他们事先并不知道哪个密钥槽(如果有的话)是自己的,因此会对整个数组进行 试解密。有两条属性塑造了这个循环:槽集 MAC 检查被折 进 循环中(只有当某个密钥槽的候选 CEK 也能复现出线缆上的 slots_mac 时,它才被接受),并且循环会遍历 所有 密钥槽、不提前跳出,并以恒定时间方式选出命中项,使得一个有时延观测能力的观察者无法推断出是哪个槽位下标命中了。
在动用任何 KEM 或 AEAD 原语之前,验证器 MUST 先跑一遍结构形态检查(针对划分预言机的防御):scheme == 1、aead/kem 已注册、nonce 为 24 字节、slots_mac 为 32 字节、slots 非空、接收方私钥为 32 字节、每个 slot.wrap 恰好为 48 字节、每个 x25519 的 epk 为 32 字节且不含 kem_ct、每个 mlkem768x25519 的 kem_ct 恰好为 1120 字节且不含 epk,以及全部封装材料在 slots 内的互异性(否则报 ENC_SLOTS_DUPLICATE_KEM_MATERIAL)。
在同一趟原语之前的检查里,验证器还 MUST 给解析器的资源消耗划下边界:参考上限是 MAX_SLOTS = 1024 槽,以及解码后的 enc 信封 65536 字节。两者都远高于约束一条诚实记录的那道约 16 KiB 的 Cardano 交易元数据天花板,因此一条超过任一上限的记录即为畸形,并在此处被拒——槽位过多报 ENC_SLOTS_TOO_MANY,信封过大报 ENC_ENVELOPE_TOO_LARGE——这些都发生在任何 KEM 或 AEAD 原语运行之前。这些上限由验证器强制执行,是部署期钉定的常量,而非线上字段;某个部署 MAY 把它们收得更紧。
; hashes_hash, SLOTS_TRANSCRIPT and slots_hash are recomputed once, before the loop, and held constant:
hashes_hash = SHA-256("cardano-poe-item-hashes-v1" || canonicalEncode(item.hashes))
slots_hash = SHA-256("cardano-poe-slots-transcript-v1" || canonicalEncode(SLOTS_TRANSCRIPT))
if kem == "x25519": pub_R = x25519_publicKey(priv_R) ; recipient public key, 32 B
else: pub_R = XWing.publicKey(priv_R) ; recipient X-Wing public key, 1216 B
found = false
cek_conflict = false
selected_CEK = zeros(32)
for slot in enc.slots: ; iterate ALL slots — no early break
kem_ok = true
if kem == "x25519":
shared = x25519(priv_R, slot.epk)
kem_ok = NOT constant_time_eq(shared, zeros(32)) ; explicit all-zero reject, secret-independent
kek_salt = SHA-256("cardano-poe-x25519-kek-salt-v1" || enc.nonce || slot.epk || pub_R)
real_KEK = HKDF-SHA-256(shared, salt = kek_salt, info = "cardano-poe-kek-v1", L = 32)
dummy_KEK = HKDF-SHA-256(zeros(32), salt = kek_salt, info = "cardano-poe-kek-v1", L = 32)
KEK = ct_select(kem_ok, real_KEK, dummy_KEK) ; constant-time, no early exit
ad_wrap = "cardano-poe-kek-v1"
else: ; mlkem768x25519
shared = XWing.Decapsulate(sk=priv_R, ct=slot.kem_ct) ; pinned API writes Decapsulate(ct, sk)
kek_salt = SHA-256("cardano-poe-xwing-kek-salt-v1" || enc.nonce || slot.kem_ct || pub_R)
KEK = HKDF-SHA-256(shared, salt = kek_salt,
info = "cardano-poe-kek-mlkem768x25519-v1", L = 32)
ad_wrap = "cardano-poe-kek-mlkem768x25519-v1"
open_ok, candidate_CEK = ChaCha20-Poly1305_open_or_dummy(KEK, zeros(12), ad_wrap, slot.wrap)
HMAC_KEY = HKDF-SHA-256(candidate_CEK, salt = "", info = "cardano-poe-slots-mac-v1", L = 32)
mac_ok = constant_time_eq(HMAC-SHA-256(HMAC_KEY, slots_hash), enc.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 constant_time_eq(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) ; WRONG_RECIPIENT_KEY / TAMPERED_HEADER
if cek_conflict: reject (single generic failure) ; cek_conflict
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_CIPHERTEXTKEK 派生会按 enc.kem 分叉:对 x25519,接收方对 slot.epk 执行一次 ECDH,并就着 enc.nonce || slot.epk || pub_R 重新派生出那个相同的带标签 salt;对 mlkem768x25519,则直接对 slot.kem_ct(一个单独的 1120 字节字节串)做 X-Wing 解封装,并就着 enc.nonce || slot.kem_ct || pub_R 重新算出那个相同的带标签 salt,其中 pub_R 是它自己从所持种子派生出的那个 1216 字节 X-Wing 公钥。X25519 全零共享密钥的拒绝在这里是显式的,而不是依赖它被传递性地处理掉:一个被炮制来把共享密钥逼成 zeros(32) 的密钥槽(RFC 7748 §6.1),会把那个秘密无关的有效性比特 kem_ok 置为假,KEK 被恒定时间地选成一个在相同 salt 和 info 下、由 zeros(32) 派生出的 dummy_KEK,使循环仍做同样的工作,而 kem_ok 又被折进 ok——于是一个无效 ECDH 的密钥槽无论其包裹或 MAC 结果如何都绝不会被接受,若没有别的命中,这条记录就交出那个唯一的通用失败。包裹打开之后的一切——槽集 MAC 检查、内容密钥派生和内容解密——都与 KEM 无关。
两个 *_open_or_dummy AEAD 原语都是原子的:在标签校验失败时,它们不返回任何明文,而交回的候选值(包裹打开对应的 candidate_CEK、内容打开对应的 plaintext)是一个与那段失败密文无关的固定或伪随机虚值。任何未经校验的明文都绝不会被交给调用方,因此一次失败的打开无从沦为解密预言机。
为什么 MAC 检查要放在循环内部
恶意发送方可以炮制一个密钥槽,它能在接收方的密钥下被打开,却产出一个攻击者选定的
CEK(向接收方公钥做封装并不需要私钥)。如果接收方把首次 AEAD
成功当成「自己的」,这个伪造槽就会把数组里靠后的某个诚实槽遮蔽掉。把 slots_mac
检查折进循环,意味着只有当某个密钥槽的候选 CEK 能就着 slots_hash 复现出 MAC
时,它才被接受——于是伪造槽会被跳过,扫描继续推进。在任何 AEAD 调用 之前,MUST 先检查
slot.wrap 的长度为 48 字节,这也是 age v1 同样采用的一种针对划分预言机的防御。
多个命中的密钥槽:允许重复,不允许 CEK 冲突。 一把接收方私钥 MAY 合法地命中不止一个密钥槽。生产方可以把同一个 CEK、向同一个接收方、跨好几个密钥槽密封——每个槽配一份各自全新的逐槽临时密钥——以此把表面上的接收方数量撑大,这是一种正当的隐私技术。验证器选取第一个命中槽的 CEK,且 MUST NOT 仅因不止一个槽命中就拒绝。这与记录内部的重复封装材料拒绝(ENC_SLOTS_DUPLICATE_KEM_MATERIAL)截然不同,后者是在 epk 或 kem_ct 重复时触发的:诚实的重复会为每次出现抽取全新的逐槽 KEM 随机数,因此它的 epk / kem_ct 各不相同,绝不会撞上那项检查。验证器唯一 MUST 拒绝的反常情形,是两个命中的密钥槽恢复出不同的 CEK(以恒定时间比较):循环跨所有密钥槽携带一个 cek_conflict 比特,若任何靠后的命中恢复出与已选 CEK 不同的值,便交出那个唯一的通用失败。这是纵深防御——在所恢复 CEK 所提供的承诺属性下(槽集 MAC 把 CEK 绑定到单一 slots 转录;参见匿名性与因 KEM 而异的分野),一个不同 CEK 的命中本就不可行,因为那恰好是承诺所排除的那种多密钥碰撞,于是这项检查会对一个有缺陷的实现、或对该假设的未来削弱,安全地失败关闭(fail closed)。
一个通用失败形态,跨槽位恒定时间
无论解密因何失败——没有槽位打开、槽集被篡改,还是内容 AEAD 校验失败——一个不受信任的调用方都 MUST 收到恰好 一个 通用失败形态,而且响应 MUST NOT 把这几种情形区分开,也不得透露是哪个槽位命中。实现 MAY 把内部带类型代码——WRONG_RECIPIENT_KEY(没有槽位打开)、TAMPERED_HEADER(有槽位打开,但没有任何候选 CEK 能就着 slots_hash 复现出 slots_mac)、TAMPERED_CIPHERTEXT(恢复出某个 CEK 之后内容 AEAD 校验失败)——交给一个受信任的本地调用方用于诊断,但这些代码 MUST NOT 通过可区分的响应泄露给外部观察者。
在时延上,验证器 MAY 在无命中检查(if NOT found)处、早于内容解密返回。那次提前返回只透露 接收方 vs 非接收方——绝不透露是哪个槽位命中,也没有任何密钥材料——因为到达该检查时,上文那个跨槽位的循环已经完整跑完了。非接收方这一情形与「有槽位打开、但其内容密文无法打开」的接收方这一情形之间,时延一致并非必需,且 MUST NOT 强制要求一次虚值内容打开:硬要每个非接收方都付出整段内容解密的代价,并不能买到循环尚未提供的任何隐私。确实成立的那条恒定时间保证,是上面那条跨槽位不变量——循环对每把私钥处理固定数量的槽位运算、不提前跳出,因此一个网络层观察者只能获知槽位数量,绝不知道这把密钥解开了哪个槽位(如果有的话)。一个持有多把密钥的接收方(例如身份轮换中跨越的归档密钥)会按私钥 × 槽位来迭代,并从当前密钥重新派生那一半 pub_R salt;它 MAY 在密钥之间提前短路(这只泄露「哪把密钥命中了」这一弱信号),但 MUST 在任何单把密钥的各槽位之间保持恒定时间。
恢复出明文之后,接收方——在应用层、而非解密函数内——重算明文哈希并对照 items[].hashes 进行核对。不匹配就意味着记录在链上的承诺与解密出的字节对不上,此时接收方 MUST 拒绝据此明文采取行动。这一步才把整个闭环合上:链见证了在时刻 T 的 某个 承诺,而接收方确认它正是对这些确切字节的承诺。
口令路径
另一条密钥投递路径用口令取代了接收方密钥槽。这里没有 slots 数组,没有 slots_mac,没有逐槽临时密钥,也没有试解密循环:CEK 通过 Argon2id(RFC 9106)在一段链上的 salt 和参数之上,直接由规范化后的口令派生而来。slots_mac 在 slots 路径上所提供的那道密钥承诺,在这里改为存放于一段 密文数据块之内 的 32 字节头部,前置在 STREAM 块之前——同一个对象、同一个 URI、同一次拉取。
passphrase_bytes = utf8(normalize(passphrase)) ; cardano-poe-pw-norm-v1 (see below)
CEK = argon2id(passphrase_bytes,
salt = enc.passphrase.salt, ; 16–64 bytes, on chain
params = enc.passphrase.params, ; { m, t, p }, on chain
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, ; uint
"path": "passphrase", ; text
"aead": <enc.aead>, ; text: the content-format identifier
"nonce": <enc.nonce>, ; bytes(24)
"hashes_hash": hashes_hash, ; bytes(32), over this item's hashes
"passphrase": { ; closed sub-map
"alg": "argon2id", ; text
"salt": enc.passphrase.salt, ; bytes
"params": { "m": m, "t": t, "p": p }, ; closed map of uints
"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 bytes
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_key, as on the slots path线上的 enc.passphrase 块是 { alg, salt, params }——它给出 KDF 名称("argon2id")、salt 和参数。Label 309 固定了一个 参数下限:m ≥ 65536 KiB(64 MiB)、t ≥ 3、p ≥ 1;生产方选取的值要达到或高于该下限,salt 则在 16 到 64 字节(含两端)之间(64 字节的上限即元数据项的字节串上限)。在平台支持的前提下,生产方 SHOULD 采用 p = 4(RFC 9106 §4 推荐的第二套配置);验证器 MAY 接受任意 p ≥ 1,但要受下文那些部署期天花板的约束。
PASSPHRASE_TRANSCRIPT 把 KDF 参数、头部字段以及该项的哈希主张都绑进承诺:验证器会从收到的 enc map 和该项的 hashes 重算转录,因此篡改 salt、任何 params 取值、nonce、aead,或把信封拼接到一份不同的哈希主张上,都会产出一个不同的 pw_hash,承诺检查随之失败。随后内容会在与 slots 路径相同的那个分段 STREAM 之中、在口令路径的内容密钥之下被封装。"normalization" 取值是一个喂进转录的 scheme 固定常量,用来钉死 CEK 派生时所用的那个确切规范化档;它 绝不 在线上序列化。
验证顺序。 验证器从所输入的口令派生出候选 CEK,读取密文数据块的 前 32 字节,重算承诺,并以 恒定时间——在打开任何 STREAM 块之前 进行比较。一个短于 48 字节——即 32 字节承诺头部加上 16 字节的 STREAM 最小值——的口令路径数据块不可能良构,属于畸形密文(TAMPERED_CIPHERTEXT)。一旦不匹配——错误口令、被篡改的 salt / params、被篡改的头部,或一份被拼接的信封——验证器都交出与任何其他解密失败相同的那个唯一通用失败,且 MUST NOT 开始流式处理。于是一个错误口令与一条被篡改的记录无从区分。
在规范化和 Argon2id 之前,实现 MUST 给原始口令输入的长度划下边界,使一个超长口令无法驱动一次 KDF 之前的拒绝服务:参考上限是 4096 字节的原始 UTF-8 输入,在任何规范化或哈希工作之前就予以拒绝。和 slots 路径所强制的 MAX_SLOTS 与解码后 enc 信封的上限一样,这是一个由验证器强制执行、部署期钉定的常量——而非线上字段——某个部署 MAY 把它收得更紧。在参数下限之外,实现还 SHOULD 对 m、t、p 强制 上限 以防范验证器端的 DoS;那些天花板是非规范性的(取决于硬件),且 MUST NOT 与下限相混淆。
为什么承诺放在链下
一个链上口令承诺会白白送给每个观察者一个免费的离线测试预言机——从一段猜测口令派生出候选 CEK,再对照链检验——对每一条口令记录、永远有效,包括那些密文被扣留的记录。把承诺承载在密文数据块之内,意味着检验一次猜测需要数据块本身:一条密文被扣留的记录,不会在那条永久账本上暴露任何可供口令猜测的材料,而一个已经持有数据块的合法接收方,先读那 32 字节头部也分文不费。
规范化档
口令在送入 Argon2id 之前所做的规范化,是那个固定的档 cardano-poe-pw-norm-v1。它是规范性的:两个实现 MUST 从同一段口令派生出逐字节一致的 CEK,而保证这一点的唯一办法就是一个钉死的规范化。该档按顺序施加如下步骤:
- 拒绝未分配码点。 一段口令若包含任何在 Unicode 16.0 中未分配 的码点,会在任何规范化步骤运行之前以
ENC_PASSPHRASE_UNNORMALIZABLE被拒绝。 - NFKC。 按 UAX #15、在 Unicode 16.0 之下施加 Normalization Form KC。
- 空白。 把「空白」定义为在 Unicode 16.0 之下带有 Unicode
White_Space属性的每一个字符,并把每一段这样的字符的最大连续游程折叠成单个 U+0020 SPACE。 - 修剪。 去掉首尾的空白。
- 拒绝空串。 若结果为空字符串,则以
ENC_PASSPHRASE_EMPTY拒绝:一段只含空白、或在其他意义上空洞的口令会规范化成零字节,而 Argon2id 会悄然接受它——从而把记录设密钥到任何一方都能派生出的一个 CEK 上。 - 编码。 把结果编码为 UTF-8;这些字节就是 Argon2id 的口令输入。
第 1 步正是让这个档跨实现、跨时间都具确定性的关键。Unicode 规范化稳定性政策 保证:仅当一个字符串里的每个码点在它被规范化的那个版本中都是 已分配 时,该字符串的规范化才在未来的 Unicode 版本之间保持稳定;一个未分配的码点日后可能获得一个分解形式,从而悄然改变所派生的 CEK。拒绝未分配码点彻底堵上了这个漏洞,而且对诚实用户来说是不可见的——一切实际书写中用到的字符都是已分配的。
Unicode 版本被原原本本地钉死在 Unicode 16.0,且 MUST NOT 浮动:White_Space 属性集、已分配码点集和 NFKC 映射表全都依版本而定,一个对照不同 Unicode 版本去解析该档的验证器,可能会从同一段口令派生出不同的 CEK,从而无法解密一条诚实的记录。某个采用更新 Unicode 版本的未来修订版,会在一个新的档标识符之下这么做,而不会去重新解释 cardano-poe-pw-norm-v1。
口令熵是唯一的屏障
salt 和 Argon2id 参数会永久公开在链上,因此攻击者有无限的离线时间去对它们暴力破解口令。口令熵是这条路径上唯一的安全余量。生产方 SHOULD 使用由 CSPRNG 生成的 diceware 口令,而非人为选定的口令;在接受手动输入的口令时,SHOULD 给出醒目的警告,提示链上密文将永久暴露于离线攻击之下。
前向保密与逐槽相互独立
密钥槽构造使用的是 临时-静态 ECDH(或全新的 X-Wing 封装),且每个密钥槽都用一个全新的临时密钥,这换来了两项「静态-静态」或「共享临时密钥」设计会丢失的属性:
- 针对发送方被攻陷的前向保密。 发送方在该构造中不持有任何长期密钥;临时密钥在密封完成后即被清零。日后攻陷发送方的状态,也无法解密那些在攻陷之前已发布的记录。
- 逐槽相互独立。 不同接收方拿到不同的临时密钥,因而得到不同的共享密钥和 KEK。某个接收方泄露了自己封装后的 CEK,固然会暴露这个 CEK(这无法避免——它就是文件密钥),但绝不会暴露另一个接收方的 KEK。
密封 PoE 在设计上没有接收方前向保密:一条记录一旦被密封给某个长期接收方密钥,持有匹配私钥的人就能永远解密它。这是用长期密钥做公钥加密所固有的属性,而非缺陷。
匿名性与因 KEM 而异的分野
当一条密封 PoE 记录 不带 sigs 时,它的线上字节与发送方身份无关:每个密钥槽只承载逐记录、逐槽的临时 KEM 材料(slot.epk 里的 X25519 临时密钥,或 slot.kem_ct 里的 X-Wing 密文),发送方的长期密钥从不出现,密钥槽经 CSPRNG 打乱,线上没有任何接收方公钥,也没有任何描述性字段(文件名、MIME 类型、大小)。因此一条未签名的密封记录不会在链上绑定任何发送方身份——这正是举报者投递、密封竞价拍卖和证据托管所需要的。
对 两种 KEM 而言,那些诚实的泄露都完全一致、也无可避免:槽位数量、密封与非密封 之分,以及 经典与混合 的 KEM 族系(enc.kem)对任何观察者都可见;关于接收方,再没有别的会暴露了。
那条 更强 的声称——一个 握有一组候选接收方公钥 的对手,无法检验某个给定密钥槽是否寻址给其中之一(密钥私密性 / 接收方匿名性)——是一条因 KEM 而异的属性:
x25519——密钥私密。 逐槽封装是一个全新的临时公钥,与接收方密钥在统计上相互独立。一个握有候选接收方公钥的对手,仅凭slot.epk和slot.wrap,在没有匹配私钥的情况下无法判定某个密钥槽(若有的话)寻址给哪一个候选。因此经典路径是密钥私密的,这也带来跨记录不可关联性:发给同一接收方的两份密封 PoE,看上去就是互不相关的epk/wrap数据块。mlkem768x25519——不声称。 面对一个握有候选接收方密钥的对手所提供的接收方匿名性,是一条 并不由 混合 KEM 的 IND-CCA 安全性所蕴含的独立属性。除非且直到它针对 X-Wing 得到独立论证,Label 309 不 为 X-Wing 路径声称它。威胁模型要求面对握有密钥的对手仍保有接收方匿名性的部署,MUST NOT 在这一属性上依赖混合路径。
担心记录之间时序关联的发送方,MUST 把发布操作批量化、使其脱离关键时间线;线上层面的密码学无法解决元数据时序攻击。
承诺由槽集 MAC 提供;包裹无需是承诺式
恢复出的 CEK 是对接收方所匹配的那个密钥槽集的一道
承诺:恶意发送方无法构造出两个互不相同、却被同一个接收方都认作「自己的」槽位集合。这里所需的性质,是
RFC 9771 意义上、对信封 CEK 的
限制性密钥承诺(restricted key commitment)——恢复出的 CEK 绑定到单一 slots
转录——而不是一个对任意输入都成立的完整承诺式 AEAD。它凭借的是 CEK ↦ HMAC-SHA-256(HKDF-SHA-256(CEK, "cardano-poe-slots-mac-v1"), slots_hash) 对对手所选 CEK
和转录的多密钥抗碰撞性——一道约 128 位的通用碰撞余量(256
位输出上的生日界),对本威胁模型而言足矣。转录自身的防篡改性继承了 SHA-256 的约 2^128
碰撞界:对被承诺的头部字段或槽位字节的任何改动都会改变
slots_hash,而要在一个不同转录上伪造出未变的 slots_hash,恰恰就是那场约 2^128
的碰撞搜索。由于这道承诺由 slots_mac 提供,逐槽的 wrap AEAD 无需
是一个承诺式(committing)AEAD;默认那个非承诺式的 ChaCha20-Poly1305 在这里是稳妥的。
禁止的做法
合规的实现 MUST NOT:
- 跨密钥槽或跨记录复用某个逐槽临时密钥,或以其他方式让一个 KEK 重复出现——零 nonce 包裹依赖于逐槽 KEK 的唯一性。
- 跨信封复用某个 CEK——每个携带
enc的条目都要有一个全新的 CSPRNG CEK,记录之内、记录之间皆然。 - 复用某个口令 salt——为每个口令信封生成一个全新的 CSPRNG
enc.passphrase.salt;对一段被复用的口令而言,salt 是唯一的跨记录分隔符。 - 在同一个
slots数组内 混用 KEM(每条记录只能有一个enc.kem)。 - 按输入顺序发布密钥槽——CSPRNG 打乱是必需的。
- 用 12 字节零 nonce 以外的任何 nonce、或用空的包裹 AEAD AAD 来包裹 CEK——包裹 AAD 就是该 KEM 的
info标签字面量。 - 把接收方公钥放上线缆——试解密设计正是其隐私特性所在;发布公钥会令其失效。
- 跳过
slots_mac验证——少了它,密钥槽替换就会得逞。 - 把明文存放在
ar:///ipfs://URI 处——只发布密文;明文要么带外投递,要么由发送方持有。 - 通过
ar://或ipfs://以外的任何方案引用密文——内容寻址方案把 URI 绑定到了字节内容;而由主机提供服务的 URL 会需要一份单独的链上密文承诺,这是密封 PoE 不携带的。 - 记录或持久化 CEK、任何 KEK、槽集 HMAC 密钥、口令 MAC 密钥、内容密钥、某个 ECDH 共享密钥、某个临时私钥,或某个接收方私钥。