Guides · Part 4 of 8
Build a sealed PoE
A plain PoE proves that some content existed. A sealed PoE proves the same thing while keeping the content itself secret: you encrypt the bytes to one or more recipient keys, store only the ciphertext, and anchor the record on-chain. Anyone can see that the record exists and verify its structure; only a holder of a matching private key can decrypt the payload. See Sealed PoE for the envelope format and Sealed until claimed for the threat model.
Address your recipients
A recipient is identified by an age-style string. There are two kinds, and the
prefix tells them apart:
age1…— a classical X25519 key (32 bytes).age1pqc…— an X-Wing hybrid key (ML-KEM-768 + X25519, 1216 bytes).
X-Wing (mlkem768x25519) is the default KEM — it stays secure against a
future quantum adversary, and every identity always has an age1pqc… address.
A recipient hands you their string out of band. You decode it back to the raw
public key the sealing helper needs with parseAgeRecipient:
import { parseAgeRecipient } from '@cardanowall/sdk-ts';
const them = parseAgeRecipient('age1pqc…'); // { kem: 'mlkem768x25519', publicKey: Uint8Array }If you hold a 32-byte seed yourself, recipientsFromSeed gives you both of your
own addresses — share one so others can seal to you, and include your own key in
the recipient list to keep read access to what you send:
import { recipientsFromSeed } from '@cardanowall/sdk-ts';
const me = recipientsFromSeed(mySeed); // { age: 'age1…', age1pqc: 'age1pqc…' }Seal and publish
Sealing goes through a gateway, which builds and broadcasts the Cardano transaction and stores each item's ciphertext for you. Point the client at the gateway you use; the SDK is gateway-agnostic.
publishSealed takes raw recipient public keys, so collect the publicKey from
each parsed address. All recipients must share one KEM — keep age1pqc… keys
together. items is a list, so you can seal a whole set of files to the same
recipients under one record; here it holds a single payload:
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);There is no separate quote call and no recordBytes guess of your own:
publishSealed measures the exact sealed record, locks a price, uploads each
ciphertext, and submits — all in one call. maxUsdMicros is your ceiling; the
publish refuses if the quoted price is above it, and re-checks the cap against a
fresh price if a slow upload outlived the first lock, so a large upload can never
outrun its quote. Your seed and the plaintext never leave your machine in the
clear.
By default each item's on-chain claim is its sha2-256 digest. To bind a second
digest into the same record, co-hash the item under both algorithms: pass
hashAlgs: ['sha2-256', 'blake2b-256'] to publishSealed in the SDK, or repeat
--hash-alg sha2-256 --hash-alg blake2b-256 on the CLI's seal. The two
registry algorithms behave identically in every seal mode.
Seal to a passphrase
Not everyone has a key. Sometimes you want to seal to people who share a secret
phrase, not a key — a team password, a phrase read out on a call, a value
already sitting in your CI secrets. Give seal a passphrase instead of --to,
and anyone who knows it can open the record: no age address, no key exchange.
cardanowall seal \
--file ./contract.pdf \
--passphrase-file ./pw.txt \
--base-url https://your-gateway.example \
--api-key "$CW_API_KEY"A record is sealed to recipients or to a passphrase, never both — passing
--passphrase together with --to/--to-self is refused. Keep the phrase off
the command line: --passphrase-file, --passphrase-stdin, or the
CARDANOWALL_PASSPHRASE environment variable all read it without it landing in
shell history. The content key is stretched from the phrase with Argon2id, so a
strong, high-entropy passphrase is what stands between the ciphertext and a
guesser — this path is only as safe as the phrase you choose.
Opening it needs nothing but the phrase. The standalone verifier decrypts and re-checks the content hash; the inbox writes the recovered plaintext to a file:
cardanowall verify <tx-hash> --passphrase-file ./pw.txt
cardanowall inbox decrypt <tx-hash> --passphrase-file ./pw.txt --out ./recovered.pdfSuperseding works the same under either mode. A re-seal is always a brand-new
record — the encryption is randomized every time (a fresh content key and nonce,
and a fresh Argon2id salt on the passphrase path), so the bytes never
deduplicate. To mark a fresh seal as the successor to an earlier one — a
corrected file, a rotated recipient set — point it back with --supersedes:
cardanowall seal --file ./contract-v2.pdf --to age1pqc…recipient --supersedes <tx-hash>Resume across a crash
Sealing draws a fresh content key, nonce, and per-slot KEM material every time,
so a naive retry would re-encrypt, pay for a second ciphertext upload, and
produce different record bytes. For CI jobs and multi-gigabyte payloads, split
the flow: sealPrepare encrypts offline and returns a portable artifact you can
persist; submitSealed runs the online half against it.
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 above is just sealPrepare + submitSealed in one call; the
Python and Rust SDKs expose the same pair.
If the publish fails partway
The CLI recovers the same way, without the two-phase code. A seal --to … run that dies after a
ciphertext upload writes a secret-free <seal-fingerprint>.l309-seal-resume.json (the completed
uploads, and no keys or plaintext). Re-run with cardanowall seal --resume <seal-fingerprint>.l309-seal-resume.json: it re-quotes, finishes only the uploads still owed, and
publishes. A signed run needs its seed again; passphrase seals keep no resume state, so re-run
those from the start.
With Python
cardanowall-sdk is a byte-for-byte twin — same KEM default, same envelope:
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())For a resumable flow, seal_prepare(...) (from cardanowall.client) returns the
prepared artifact and client.poe.submit_sealed(prepared=...) runs the online
half — the same two phases as above.
With Rust
The cardanowall crate seals through the same gateway client. parse_age_recipient
decodes each address to the raw key, and the KEM defaults to the X-Wing hybrid:
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(())
}For a resumable flow, seal_prepare returns a PreparedSeal you can persist and
client.poe().submit_sealed(&SubmitSealedInput::new(&prepared)) runs the online
half. The CLI seals from the command line too, with the same phases under the
hood: cardanowall seal --file <path> --to <address>.
Once the record settles, each recipient discovers it, decrypts the payload with their private key, and recomputes the plaintext hash to close the loop — that's the recipient half of Verify a record.
Seal to yourself too
publishSealed never adds you to the recipient list silently. If you don't include one of your
own keys, you publish a record you can never read back. Include me.age1pqc (or me.age) among
the recipients whenever you want to keep access to what you sent.