ve-capsule · verifiable encryption on secp256k1

Sealedby one. Verifiedby anyone. Openedby the one.

ve-capsule seals a secp256k1 key into opaque bytes that can prove what they hold. Sealing, verifying, and opening are three separate powers — so the one who locks a secret away, whoever stores and audits it, and the one who finally gets it back never have to trust each other.

secp256k1 · segmented EC-ElGamal · Bulletproofs++ · 789-byte recovery · no unsafe
01 · the sealer

Has the secret. Locks it to a key that isn't theirs, hands the bytes off, walks away.

02 · the verifier

Holds the capsule. Proves the right key is inside — today, next year — and can never open it.

03 · the unsealer

Holds the key. Wasn't there for any of it. Opens the capsule when it finally arrives.

The split

Three jobs that were never the same job.

A plain ciphertext welds them together: whoever stores it learns nothing and can vouch for nothing, so you end up trusting the custodian's word until the day you open it. A capsule takes the three roles apart and gives each one exactly what it needs.

01The sealer

Needs the secret and the unsealer's public key — no ceremony, no counterparty online. Seal, hand the bytes to whoever keeps them, done. The capsule never needs its maker again.

let capsule = Capsule::builder(&secret, &recipient, &ctx).seal()?;
let bytes   = capsule.to_canonical_bytes();   // opaque — store them anywhere

02The verifier

Needs the capsule and public keys, nothing else: a short non-interactive proof shows it holds exactly the private key for public_key. Check it at deposit; check it every night for years. Nothing the verifier holds can open it.

// no secret anywhere in this call
capsule.verify_ungated(&public_key, &recipient, &ctx)?;
// proven: the right key is inside. Still sealed.

03The unsealer

Needs the recipient key — and wasn't there for any of it, not the sealing, not the audits. When the capsule arrives, from the custodian or off a tag, verify once more and open.

let verified = Capsule::from_canonical_bytes(&bytes)?
    .verify_ungated(&public_key, &recipient, &ctx)?;
let secret   = verified.unseal(&recipient_key, &[])?;

ctx binds every one of these calls to your application, so a capsule can't be lifted out of it and replayed somewhere else.

Why it matters

Escrow, minus the trusted part.

Anywhere a secret has to wait with someone who must prove they have it but must never read it, the roles come apart cleanly:

  • Custodial backup

    A service stores your key backup and proves, on demand, that it still holds the right key. It can't use what it can't open.

  • Inheritance

    A capsule sits sealed for years. The executor can show everyone it's the real key; only the heir's device opens it.

  • Counterparty escrow

    Prove the key is really on deposit before anyone performs — without releasing it, and without the escrow agent ever being able to take it.

Consent

When opening should need permission.

Seal with gates — extra public keys whose holders must each contribute before the capsule opens. The unsealer's key alone is no longer enough: recovery becomes a quorum decision, and a gate can itself be a threshold key no single member holds.

let capsule = Capsule::builder(&secret, &recipient, &ctx)
    .access_keys(&quorum)
    .seal()?;   // now unsealing requires their contributions too

For storage at NFC scale a capsule can drop its proof, and a quorum signature over its canonical statement takes the proof's place — BIP-340 Schnorr, FROST(secp256k1)-TR among them — so an authorizer can't be duped into helping open an attacker's fabricated ciphertext.

Compact

A whole backup, tag-sized.

When a distributed key generation hands a key out as shares that sum to the whole, seal each share and bundle the capsules into a Case. Verifying it is one equation — Σ Mⱼ == M — every share present, and together they reconstitute the certified key. And because EC-ElGamal is additively homomorphic, a verified Case collapses: any number of helpers aggregate into one synthetic capsule.

≈149 KB × helpers 789 bytes Constant — the same 789 bytes whether two helpers sealed the split or two hundred. An NFC tag, a QR code, a slip of paper.
// verify the bundle once (every piece present, Σ Mⱼ == M), then compact it
let aggregate = verified.aggregate()?;            // H pieces → one synthetic capsule
let bytes     = aggregate.to_canonical_bytes();   // 789 B, independent of H
One curve

Native to secp256k1, end to end.

The recipient, the gates, the recovered secret, and the quorum signature all live on secp256k1 — no class group, pairing curve, or RSA modulus standing beside your stack. It reuses the key types and signers Bitcoin, FROST, and Taproot already speak, and a gated recovery is authorized by the same kind of quorum signature your threshold signer already produces.

Under the hood

Segmented EC-ElGamal, one transcript.

The sealed scalar is encrypted in small segments, and a single aggregated Bulletproofs++ range proof covers every segment and carry in the statement at once, alongside a linking sigma and a batched DLEQ, all on one Fiat–Shamir transcript. That's what keeps a capsule at a few kilobytes and verification at a handful of multiscalar equations: the proof is logarithmic in the statement; the proven ranges are exact, so a capsule that verifies is a capsule that decrypts; and there's no trusted setup — every generator is a nothing-up-my-sleeve point each party re-derives locally (the EUROCRYPT 2024 construction, implemented with the corrections from Cypher Stack's independent review). Every capsule binds to exactly its participants and its context, checkable by anyone without the secret. The surface is blob-in / blob-out: #![forbid(unsafe_code)], secrets zeroize on drop, and the proof algebra stays crate-private so a caller can't reach past the consent-gated API. Sealing and unsealing aren't constant-time — run them where the secret already lives.

The full construction and its security argument are in the specification →