Guides

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.pdf

Superseding 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.