Skip to content

Jud/ve-capsule

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ve-capsule

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 a private key to a recipient

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 anywhere

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

Prove it holds the right key, without opening it

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

Bundle a distributed key into a Case

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.

Compact the backup to a tag-sized core

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

Require authorizer consent to unseal

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 can be a key no one holds

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.

A full round trip

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());

Public surface

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.

Authorization keys are untrusted input

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.

Side channels

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.

Under the hood

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.

Install

Not on crates.io yet — depend on it from git:

[dependencies]
ve-capsule = { git = "https://github.com/Jud/ve-capsule" }

License

Licensed under either of Apache-2.0 or MIT, at your option.

About

secp256k1-native verifiable encryption: seal a secret to a fixed recipient, gate it behind a quorum, open it by consent — segmented EC-ElGamal + aggregated Bulletproofs++ range proof + batched DLEQ.

Topics

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages