Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

unseal — threshold decryption

audience: contributors

Proposed source: crates/unseal/.

The threshold-decryption organism that unwraps zipnet::Broadcasts into cleartext UnsealedPool for the downstream lattice. Any t of n TDX-attested committee members can combine their shares to recover a slot’s cleartext; fewer than t colluding members learn nothing.

Crate layout

Following the zipnet purity rule. Three layers:

  • unseal::proto — wire types, threshold-crypto primitives. No I/O, no mosaik, no tokio.
  • unseal::core — pure functions: seal, partial_decrypt, combine. No I/O.
  • unseal::node — the only module that imports mosaik. UnsealMachine, declare! items, role event loops, ticket validators.

Public facade:

  • unseal::Unseal::<D>::watch(&network, &Config) -> Watch<D> — read-side for organisms that subscribe to cleartext (offer, atelier, authorised audit integrators).
  • unseal::seal(&Config, plaintext: &[u8]) -> Sealed<D> — pure function used by integrators to encrypt a payload before writing it into a zipnet envelope.
  • unseal::Config — const-constructible fingerprint input.

No submit verb. The only write into UnsealedPool is via the committee’s state machine apply; there is no external submit primitive.

Public surface

Two collections on the shared universe.

UnsealedPool

declare::collection! {
    pub UnsealedPool = Vec<UnsealedRound>,
    derive_id: UNSEAL_ROOT.derive("pool"),
    consumer require_ticket: LATTICE_READ_TICKET,
    writer   require_ticket: UnsealMember,
}

pub struct UnsealedRound {
    pub slot:      u64,
    pub round:     zipnet::RoundId,
    pub cleartext: Vec<Cleartext>,
}

pub struct Cleartext {
    pub slot_index: usize,   // slot index inside the zipnet round
    pub payload:    Vec<u8>, // decrypted, AEAD-authenticated
}

One entry per zipnet-finalized slot. Appended in slot order.

ShareRegistry

declare::collection! {
    pub ShareRegistry = Map<UnsealMemberId, ShareBundle>,
    derive_id: UNSEAL_ROOT.derive("share-registry"),
    consumer require_ticket: LATTICE_READ_TICKET,
    writer   require_ticket: UnsealMember,
}

pub struct ShareBundle {
    pub member:  UnsealMemberId,
    pub dh_pub:  [u8; 32],           // X25519 pubkey for share-gossip encryption
    pub ts_pub:  [u8; 48],           // BLS12-381 threshold-share pubkey
}

Static after DKG; republished only on DKG rerun. Downstream organisms consult this for cryptographic verification of UnsealedPool commits.

Internal plumbing

A derived private network keyed off UNSEAL_ROOT.derive("private") carries one stream:

  • Shares — per-slot threshold shares gossiped between committee members, encrypted pairwise via member X25519 pubkeys. Never surfaced to the public universe.

The committee Group itself stays on the public universe (UNSEAL_ROOT.derive("committee") derives its GroupId) because UnsealedPool and ShareRegistry are backed by it.

State machine

impl StateMachine for UnsealMachine {
    type Command     = Command;
    type Query       = Query;
    type QueryResult = QueryResult;
    type StateSync   = Snapshot;

    fn signature(&self) -> UniqueId { ... }
    fn apply(&mut self, cmd: Command, ctx: &dyn ApplyContext) { ... }
    fn query(&self, q: Query) -> QueryResult { ... }
    fn state_sync(&self) -> Snapshot { ... }
}

pub enum Command {
    SubmitShare(ShareCommit),
    SealSlot(SealCommit),
    MarkDone(u64),
}

pub enum Query {
    SharesFor(u64),              // how many shares landed for slot S
    UnsealedSince(u64),          // slots from cursor to head
    Member(UnsealMemberId),      // roster lookup
}

Command semantics

  • SubmitShare. A committee member commits its share for slot S. Validation:
    • ShareCommit.slot == some observed zipnet::Broadcasts[S] — the upstream must have committed; shares for a slot that does not exist upstream are rejected.
    • ShareCommit.member is in the committee roster as of the slot’s effective-at.
    • Exactly one share per (slot, member); duplicates are silently dropped.
    • The share verifies against the member’s ts_pub under the threshold scheme (BLS12-381 pairing check).
  • SealSlot. Idempotent per slot. When the apply handler observes SharesFor(S) >= t, it runs unseal::core::combine inside apply, produces UnsealedRound { slot: S, ... }, appends it to UnsealedPool, and discards the shares. This command is issued by every committee member once they see t shares; the first one to apply wins, the others are silent no-ops.
  • MarkDone. Garbage collection. After slot S has been unsealed and the downstream tally has committed Refunds[S], any committee member can issue MarkDone(S) to drop the slot’s state. Idempotent.

Apply invariants

  1. Shares are never materialised outside apply. The combine step runs in apply’s synchronous body; once combined, the in-memory shares for that slot are zeroed before apply returns.
  2. At most one UnsealedPool entry per slot. Enforced by the SealSlot handler checking for existing-slot before combining.
  3. Deterministic cleartext. Given the same set of t shares, combine always produces the same cleartext — every committee member’s replica of UnsealedPool converges.
  4. No share admission after finalize. Once SealSlot has run for slot S, subsequent SubmitShare commands for slot S are rejected. This prevents a laggard member from inadvertently keeping the share state alive.

Signature versioning

fn signature(&self) -> UniqueId {
    let tag = format!(
        "unseal.v{WIRE_VERSION}.t={}.n={}.scheme={}",
        self.config.threshold.t,
        self.config.threshold.n,
        self.config.scheme, // e.g. "bls12381-threshold-v1"
    );
    UniqueId::from(tag.as_str())
}

Bumping WIRE_VERSION, changing t, changing n, or switching the threshold scheme produces a different GroupId. Two lattices that accidentally picked the same threshold but different schemes do not bond.

DKG ceremony

A one-off at lattice bring-up; rerun on rotation. The ceremony is not a state machine command — it happens before the UnsealMachine Group exists. Shape:

  1. Every prospective committee member generates an X25519 keypair and a BLS12-381 share secret locally.
  2. The operator’s builder lattice up driver runs a Pedersen DKG over authenticated SSH: each member publishes its commitment polynomial, exchanges shares pairwise, and collects verification shares.
  3. The output is the aggregate public key that seals payloads, plus each member’s secret share.
  4. The aggregate public key and every member’s ShareBundle land in the unseal::Config, which folds into the lattice fingerprint.

Losing a member’s share before DKG rerun reduces the effective committee by one; if this brings it below t, the lattice can no longer unseal and must retire + re-DKG. Rotation procedure lives in operators/rotations-and-upgrades.md.

ACL composition

impl Config {
    pub fn ticket_validator(&self) -> Validators {
        Validators::stacked()
            .with(JwtIssuer::from(self.operator_jwt_key))
            .with(Tdx::new().require_mrtd(self.mrtd))
    }
}

JwtIssuer gates on lattice-level membership (separating members of one lattice’s unseal from another’s, even when the MR_TD image is the same). Tdx::require_mrtd gates on the committee image’s reproducible build measurement. Both folds into the ShareRegistry ACL and into the committee’s admission.

Trust shape

t-of-n threshold on anonymity; majority-honest is sufficient for liveness of UnsealedPool commits (since SealSlot is idempotent and any member can trigger it).

See threat-model.md — unseal for the composition argument.

Open questions

  • Trial-decrypt vs deterministic recipient? unseal seals to the committee aggregate public key; recovery is unambiguous once t shares land. No trial-decrypt cost per recipient. But this also means unseal cannot mark a payload for selective decryption (e.g. “only unseal if the on-chain block at slot S was mined by the lattice’s relay”). Conditional decryption is research-open.
  • Post-quantum migration. BLS12-381 is not post-quantum. Migration path is a new scheme value in the signature plus a second DKG ceremony. The organism surface does not change. See roadmap.md — post-quantum unseal.
  • Re-randomised shares? Currently shares are deterministic per (slot, member). A member who recovers their share secret can compute past shares. Forward-secure rotation is deferred until zipnet’s own ratcheting lands.

Cross-references