Warning
Research toy. ve-capsule is experimental and unaudited — not reviewed, not stable, and not safe for protecting real keys yet. Explore freely; don't rely on it.
Verifiable encryption on secp256k1 — the curve your signer already speaks.
Seal a private key to a recipient, let anyone confirm it holds the right key without opening it, gate recovery behind a quorum's consent, and compact the whole backup down to a tag-sized artifact. Everything lives on the native secp256k1 curve — the same one Bitcoin, FROST, and Taproot already speak — so it reuses your existing key types and signers instead of standing up a second curve, a class group, or an RSA modulus.
The surface is blob-in / blob-out: #![forbid(unsafe_code)], secret material
zeroizes on drop, and the proof algebra stays crate-private, so a caller cannot
reach past the consent-gated API.
Seal one secp256k1 scalar (a private key, a key share) to a recipient. What you get back is a capsule: a few kilobytes of opaque bytes that give nothing away. Store it, copy it, hand it around. It stays shut until the recipient opens it.
let capsule = Capsule::builder(&secret, &recipient, &ctx).seal()?;
let bytes = capsule.to_canonical_bytes(); // just bytes; keep them anywhereThe recipient is a public key. Sealing needs only that; opening needs the
private key behind it. ctx ties the capsule to your application, so it cannot
be lifted and replayed somewhere else.
Give someone the capsule and a public key, and they can confirm it really holds that key's private half. They open nothing and learn nothing. A backup can be audited the moment it's made — not discovered to be empty or wrong years later when you finally reach for it.
// Anyone holding the capsule and the public key can run this; no secret needed.
capsule.verify_ungated(&public_key, &recipient, &ctx)?;
// Proven: the capsule holds the private key for `public_key`.When a distributed key generation produces a key as shares that add up to the whole, seal each share and bundle the capsules into a Case: one artifact to store, replicate, or hand to a backup. Verifying the Case confirms, in a single equation, that every share is present and that together they reconstitute the whole key.
let case = Case::new(capsules)?;
let verified = case.verify(&whole_key, &recipient, &[], &ctx)?;
// Every share present, and together they reconstitute the whole key.The proof is for the auditor; recovery doesn't need it. Once a Case is verified,
collapse the whole split — any number of helpers — into one synthetic capsule
with aggregate. EC-ElGamal is additively homomorphic and the pieces share one
recovery key, so the serialized result is 789 bytes and constant: the same
size whether two helpers sealed the split or two hundred. Small enough for an NFC
tag, a QR code, or a slip of paper, with the per-piece proofs (≈5.4 KB each) left
behind.
let aggregate = verified.aggregate()?; // H pieces → one synthetic capsule
let bytes = aggregate.to_canonical_bytes(); // 789 B, independent of H
// …later, from the tag…
let recovered = AggregateCase::from_canonical_bytes(&bytes)?
.bind(&whole_key, &recipient, &[], &ctx)? // re-pin to the certified target
.unseal(&recipient_key, &[])?; // back to s = Σ sⱼ
assert_eq!(recovered.public_key(), whole_key);This recipient-only path is self-securing: it carries no signature, and
recovery rechecks s·G == M against the certified target, so a tampered or
forged core fails closed rather than yielding a wrong secret. (strip keeps the
per-piece cores instead of aggregating, when you need them individually.)
Put the secret behind one or more authorizers, and it opens only by consent: the recipient plus every authorizer has to take part. No one opens it alone, not even the recipient.
let capsule = Capsule::builder(&secret, &recipient, &ctx)
.access_key(&authorizer) // an authorizer whose consent is required
.seal()?;Authorizers are public keys too. Gating needs only those; contributing needs the private key behind each.
To open, verify against the same authorization, gather each authorizer's
contribution, and unseal. The contributions are independent Partials — each is
a standalone, canonically-serializable artifact, so they can be gathered across
devices — and anyone can check that one is genuine without learning anything
about the secret.
// each authorizer confirms the capsule, then creates its contribution:
let verified = capsule.verify(&public_key, &recipient, &[authorizer], &ctx)?;
let partial = verified.contribute(&authorizer_key)?;
// the recipient gathers the contributions and unseals:
let recovered = verified.unseal(&recipient_key, &[partial])?;A compact core can be gated the same way — here a signature takes the place of
the proof. A quorum signs the capsule's canonical attestation statement,
promoting the core to a contributable token (verify_signed). The crate
verifies BIP-340 Schnorr signatures today (FROST(secp256k1)-TR among them,
against the group's key), and the signature backing is an open set, so other
schemes can be added without changing the recovery path — "require another
quorum to authorize release," on the same curve.
Why a signature at all? A stripped core has dropped its proof, so nothing otherwise stops an attacker from presenting a fabricated core to an authorizer and harvesting a partial decryption — a static-DH oracle on the authorizer's key. The signature re-establishes that the core is a genuine, quorum-attested capsule before any authorizer contributes, the role the seal proof plays for a full capsule. (Recipient-only recovery needs no signature: no one contributes, so there is no oracle to open.)
An authorizer does not have to be one person with one key. It can be a multi-party key from a distributed key generation, held jointly by a group so that no single member ever holds the whole. To the capsule it is just another authorizer, and the group produces its contribution together.
Each member contributes its share toward that one authorizer with
contribute_for_gate, naming the group's aggregate key as the gate; the
recipient accepts the gate once the members' shares sum to it.
use ve_capsule::{Capsule, PrivateKey};
// Each party derives its public key from its own secret.
let recipient = PrivateKey::from_secret(/* 32 bytes */)?;
let authorizer = PrivateKey::from_secret(/* 32 bytes */)?;
let secret = PrivateKey::from_secret(/* the scalar to protect */)?;
// `ctx` implements `Context`: it binds each capsule to your application's
// transcript (package id, epoch, purpose, and so on).
// Seal `secret` to the recipient, gated behind one authorizer.
let capsule = Capsule::builder(&secret, &recipient.public_key(), &ctx)
.access_key(&authorizer.public_key())
.seal()?;
let bytes = capsule.to_canonical_bytes();
// ...later, to recover...
let capsule = Capsule::from_canonical_bytes(&bytes)?;
let vc = capsule.verify(
&secret.public_key(),
&recipient.public_key(),
&[authorizer.public_key()],
&ctx,
)?;
let partial = vc.contribute(&authorizer)?;
let opened = vc.unseal(&recipient, &[partial])?;
assert_eq!(opened.public_key(), secret.public_key());| Type | Role |
|---|---|
Capsule / CapsuleBuilder |
The sealed, opaque ciphertext and its builder. |
VerifiedCapsule |
A confirmed capsule, and the home of contribute / unseal / strip. |
Case / VerifiedCase |
A distributed key sealed as additive shares to one recipient. |
AggregateCase / VerifiedAggregate |
The homomorphic aggregate — a whole split collapsed to one 789 B artifact, independent of helper count — and its signature-promoted token. |
StrippedCapsule / StrippedCase |
A verified capsule with its proof dropped — the compact opening core. |
BoundCapsule / BoundCase / BoundAggregate |
A compact core re-pinned to its target, ready to unseal or verify_signed. |
Partial |
One authorizer's gate-tagged contribution toward opening; canonically serializable. |
Signature / Backing / Scheme |
A quorum signature that stands in for the proof, the scheme it uses (BIP-340 Schnorr today, an open set), and how a recovery is backed (Proof or Signature). |
PrivateKey / PublicKey |
A zeroizing secret scalar; a validated secp256k1 point. |
Context |
The trait your application implements to bind each capsule to its own transcript. |
Params |
Frozen protocol parameters (Params::FROZEN). |
Error |
#[non_exhaustive]: PointDecode, DegenerateInput, Verification. |
Seal / Contribute / Unseal |
Verb-traits mirroring the inherent methods. |
A capsule's recipient and access keys arrive as public keys, and ve-capsule treats them as untrusted. Gated capsules aggregate them under deterministic per-key coefficients and reject publicly enumerable components, so a key chosen to cancel another against the aggregate gains the attacker nothing here. The crate only detects small public relations, so your integration still owns enrollment: possession-certify and enrollment-bind each key, and require keys to be independently generated (no relation among their secrets known to any party), before you present them as authorization material.
ve-capsule does not aim for constant-time execution. Sealing and unsealing branch on secret data (the digit decomposition inside the range prover; the bounded discrete-log recovery), so a fine-grained local observer — timing, cache, power — could learn the secret being sealed or recovered. Run them where the secret already lives: sealing on the device that holds the plaintext scalar, unsealing on the recipient's device. Verification handles only public data.
ve-capsule is segmented EC-ElGamal verifiable encryption on secp256k1. The sealed scalar is encrypted in small segments, and a single aggregated Bulletproofs++ range proof covers every segment and carry bit in the statement at once — one short argument, not one per segment — alongside a linking sigma that ties the ciphertexts to the committed key and a batched DLEQ for each authorizer contribution, all non-interactive on one Fiat–Shamir transcript.
Bulletproofs++ is what keeps a whole capsule at ~5.4 KB and verification at a handful of multiscalar equations (about 150 ms on an Apple-silicon laptop):
- Aggregated and logarithmic. One proof spans the entire statement — every segment of the secret and every carry of its integer decomposition — and grows with the logarithm of that statement, not its size.
- Exact ranges. The proven interval is exactly the decryption search window, with zero slack — so a capsule that verifies is a capsule that decrypts. No approximate-range escape hatch for a malicious sealer to hide in.
- No trusted setup. Every generator is a nothing-up-my-sleeve point that each party re-derives locally from a fixed hash-to-curve tag and checks against pinned vectors; nothing is negotiated and nothing arrives on the wire.
- Native curve. The proof system runs on secp256k1's own group, so the range argument lives on the same curve as your keys, with no second curve smuggled in for the proofs.
The construction is the reciprocal range proof of Eagen, Kanjalkar, Ruffing, and Nick (EUROCRYPT 2024), implemented from the paper together with the corrections from Cypher Stack's independent review of it, under a strict absorb-everything Fiat–Shamir discipline with byte-pinned challenge derivation.
Every capsule binds to exactly its participants and its context, checkable by anyone without the secret. Proof-stripping and the homomorphic aggregate reuse the same opening kernel — recovery never re-runs the proof, it re-anchors on the certified commitment.
The full construction and its security argument are in the specification.
Not on crates.io yet — depend on it from git:
[dependencies]
ve-capsule = { git = "https://github.com/Jud/ve-capsule" }Licensed under either of Apache-2.0 or MIT, at your option.