指南 · 第 4 部分,共 8 部分
构建一份密封 PoE
普通的 PoE 证明的是某份内容曾存在过。密封 PoE 在证明同一件事的同时,让内容本身保持机密:你把字节加密给一个或多个接收方公钥,只存储密文,再把记录锚定到链上。任何人都能看到这条记录存在、并验证它的结构;但只有持有匹配私钥的人才能解密载荷。信封格式参见密封 PoE,威胁模型参见密封起来,等人认领。
指定接收方
接收方由一个 age 风格的字符串来标识。它有两种,靠前缀区分:
age1…— 经典的 X25519 密钥(32 字节)。age1pqc…— X-Wing 混合密钥(ML-KEM-768 + X25519,1216 字节)。
X-Wing(mlkem768x25519)是默认的 KEM——它能抵御未来的量子攻击者,而且每个身份始终都有一个 age1pqc… 地址。
接收方会通过带外渠道把字符串给你。你用 parseAgeRecipient 把它解码回密封辅助函数所需的原始公钥:
import { parseAgeRecipient } from '@cardanowall/sdk-ts';
const them = parseAgeRecipient('age1pqc…'); // { kem: 'mlkem768x25519', publicKey: Uint8Array }如果你自己手里有一个 32 字节的种子,recipientsFromSeed 会同时给出你的两个地址——把其中一个分享出去,别人就能密封给你;同时把你自己的密钥也放进接收方列表,这样你才保得住对自己所发内容的读取权:
import { recipientsFromSeed } from '@cardanowall/sdk-ts';
const me = recipientsFromSeed(mySeed); // { age: 'age1…', age1pqc: 'age1pqc…' }密封并发布
密封要经过网关,由它构建并广播 Cardano 交易,还替你存储每个条目的密文。把客户端指向你所用的网关即可;SDK 不依赖特定网关。
publishSealed 接收的是原始接收方公钥,所以你要从每个解析出的地址里取出 publicKey。所有接收方必须共用同一个 KEM——把 age1pqc… 密钥放在一组里。items 是一个列表,因此你可以把一整组文件密封给同一批接收方,放进同一条记录;这里它只包含单个载荷:
import { Label309Client, parseAgeRecipient } from '@cardanowall/sdk-ts';
const client = new Label309Client({
baseUrl: 'https://your-gateway.example',
apiKey: process.env.CW_API_KEY,
});
const content = new TextEncoder().encode('the secret payload');
const recipients = ['age1pqc…recipient', me.age1pqc].map((r) => parseAgeRecipient(r).publicKey);
const result = await client.poe.publishSealed({
items: [{ content }],
recipients,
maxUsdMicros: 2_000_000n, // refuse to spend more than $2
// kem defaults to 'mlkem768x25519' (X-Wing); pass 'x25519' only for age1… keys.
});
console.log(result.response.id, result.uris);不再需要单独调用 quote,也不用自己估算 recordBytes:publishSealed 会测量密封记录的确切大小、锁定价格、上传每段密文并提交——全部在一次调用中完成。maxUsdMicros 是你的上限;如果报价超过它,发布会被拒绝;如果某次上传太慢、超过了首次锁定的有效期,还会对刷新后的价格重新校验上限,因此大文件上传永远不会超出它的报价。你的种子和明文始终不会以明文形式离开你的机器。
默认情况下,每个条目的链上主张是它的 sha2-256 摘要。要把第二个摘要绑进同一条记录,就对该条目做联合哈希:在 SDK 里给 publishSealed 传入 hashAlgs: ['sha2-256', 'blake2b-256'],或在 CLI 的 seal 上重复 --hash-alg sha2-256 --hash-alg blake2b-256。这两种注册表算法在每一种密封模式下的行为都完全一致。
密封给一句口令
不是每个人都有密钥。有时你想密封给的,是共享同一句秘密口令、而非密钥的人——一个团队密码、一句在通话里念出来的短语、一个早已躺在你 CI 机密里的值。给 seal 一句口令来代替 --to,任何知道它的人都能打开这条记录:无需 age 地址,也无需密钥交换。
cardanowall seal \
--file ./contract.pdf \
--passphrase-file ./pw.txt \
--base-url https://your-gateway.example \
--api-key "$CW_API_KEY"一条记录要么密封给接收方,要么密封给一句口令,绝不会两者兼有——把 --passphrase 与 --to/--to-self 一起传是会被拒绝的。把口令挡在命令行之外:--passphrase-file、--passphrase-stdin 或 CARDANOWALL_PASSPHRASE 环境变量都能读取它,而不会让它落入 shell 历史。内容密钥是用 Argon2id 从这句口令拉伸出来的,因此,挡在密文与猜测者之间的,就是一句强壮、高熵的口令——这条路径的安全上限,就是你所选口令的强度。
打开它,除了这句口令别无所需。独立验证器会解密并重新核对内容哈希;收件箱则把还原出的明文写入文件:
cardanowall verify <tx-hash> --passphrase-file ./pw.txt
cardanowall inbox decrypt <tx-hash> --passphrase-file ./pw.txt --out ./recovered.pdf无论哪种模式,取代(supersede)的方式都一样。重新密封永远是一条全新的记录——每次加密都是随机化的(新的内容密钥和 nonce,口令路径上还有新的 Argon2id 盐),因此字节从不会去重。要把一次全新的密封标记为早先某条记录的后继——一份更正过的文件、一组轮换过的接收方——就用 --supersedes 把它指回去:
cardanowall seal --file ./contract-v2.pdf --to age1pqc…recipient --supersedes <tx-hash>崩溃后恢复
每次密封都会抽取新的内容密钥、nonce 以及每个槽位的 KEM 材料,因此简单重试会重新加密、为第二次密文上传付费,并产生不同的记录字节。对于 CI 任务和数 GB 的载荷,请拆分流程:sealPrepare 在离线状态下加密,返回一个可保存的可移植工件;submitSealed 针对它执行在线的那一半。
import { sealPrepare, preparedSealToJson, preparedSealFromJson } from '@cardanowall/sdk-ts';
// Phase 1 — pure and offline: encrypt every item to the recipient set.
const prepared = sealPrepare({ items: [{ content }], recipients });
await save(preparedSealToJson(prepared)); // the portable prepared_seal_json_v1 artifact
// Phase 2 — online: quote, upload each ciphertext, publish. If it throws after
// an upload, the error's `uploads` carry validated receipts — pass them back as
// `uploaded` and the finished uploads are never paid for again.
const submission = await client.poe.submitSealed({
prepared: preparedSealFromJson(await load()),
maxUsdMicros: 2_000_000n,
});
console.log(submission.response.id, submission.uris);上面的 publishSealed 只是把 sealPrepare + submitSealed 合并到一次调用中;Python 和 Rust SDK 也提供同样的这一对。
如果发布中途失败
CLI 也能这样恢复,而无需两阶段代码。一次 seal --to … 运行如果在某段密文上传之后中断,会写出一个不含机密的
<seal-fingerprint>.l309-seal-resume.json(只有那些已完成的上传,没有任何密钥或明文)。用 cardanowall seal --resume <seal-fingerprint>.l309-seal-resume.json
重新运行:它会重新取价,只补完仍欠着的那些上传,然后发布。带签名的运行需要再次提供它的种子;口令密封不保留任何恢复状态,所以那些要从头重跑。
使用 Python
cardanowall-sdk 是逐字节一致的孪生实现——同样的 KEM 默认值,同样的信封:
import asyncio
import os
from cardanowall import Label309Client, parse_age_recipient
async def main():
content = b"the secret payload"
recipients = [parse_age_recipient("age1pqc…recipient").public_key]
async with Label309Client(
base_url="https://your-gateway.example",
api_key=os.environ["CW_API_KEY"],
) as client:
submission = await client.poe.publish_sealed(
items=[content], # a list of plaintext items (bytes or str)
recipients=recipients,
max_usd_micros=2_000_000, # refuse to spend more than $2
)
print(submission.response["id"], submission.uris)
asyncio.run(main())如果需要可恢复的流程,seal_prepare(...)(来自 cardanowall.client)返回准备好的工件,client.poe.submit_sealed(prepared=...) 执行在线的那一半——与上面相同的两个阶段。
使用 Rust
cardanowall crate 通过同一个网关客户端来密封。parse_age_recipient 把每个地址解码成原始密钥,而 KEM 默认使用 X-Wing 混合方案:
use cardanowall::client::{
Label309Client, Label309ClientConfig, PublishSealedInput, SealPrepareItem,
};
use cardanowall::recipient::parse_age_recipient;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Label309Client::new(Label309ClientConfig {
base_url: Some("https://your-gateway.example".into()),
api_key: std::env::var("CW_API_KEY").ok(),
})?;
let content = b"the secret payload".to_vec();
let recipients = vec![parse_age_recipient("age1pqc…recipient")?.public_key];
// One-shot: seal, quote the exact record size, upload, publish. The KEM
// defaults to mlkem768x25519 (X-Wing); `with_kem` selects x25519.
let submission = client.poe().publish_sealed(
&PublishSealedInput::new(vec![SealPrepareItem::new(&content)], recipients)
.with_max_usd_micros(2_000_000), // refuse to spend more than $2
)?;
println!("{}", submission.response.id);
Ok(())
}如果需要可恢复的流程,seal_prepare 返回一个可保存的 PreparedSeal,client.poe().submit_sealed(&SubmitSealedInput::new(&prepared)) 执行在线的那一半。CLI 也能从命令行密封,底层是相同的阶段:cardanowall seal --file <path> --to <address>。
记录最终确认后,每位接收方都能发现它,用自己的私钥解密载荷,并重新计算明文哈希来闭合整个流程——这正是验证一条记录中属于接收方的那一半。
别忘了也密封给自己
publishSealed
绝不会悄悄把你加进接收方列表。如果你不把自己的某个密钥放进去,发出的记录将永远无法被你读回。只要你想保住对自己所发内容的访问权,就把
me.age1pqc(或 me.age)一并放进接收方列表。