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.
Has the secret. Locks it to a key that isn't theirs, hands the bytes off, walks away.
Holds the capsule. Proves the right key is inside — today, next year — and can never open it.
Holds the key. Wasn't there for any of it. Opens the capsule when it finally arrives.
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.
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
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.
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.
Anywhere a secret has to wait with someone who must prove they have it but must never read it, the roles come apart cleanly:
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.
A capsule sits sealed for years. The executor can show everyone it's the real key; only the heir's device opens it.
Prove the key is really on deposit before anyone performs — without releasing it, and without the escrow agent ever being able to take it.
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.
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.
// 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
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.
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 →