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 importsmosaik.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 slotS. 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.memberis 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_pubunder the threshold scheme (BLS12-381 pairing check).
SealSlot. Idempotent per slot. When the apply handler observesSharesFor(S) >= t, it runsunseal::core::combineinside apply, producesUnsealedRound { slot: S, ... }, appends it toUnsealedPool, and discards the shares. This command is issued by every committee member once they seetshares; the first one to apply wins, the others are silent no-ops.MarkDone. Garbage collection. After slotShas been unsealed and the downstreamtallyhas committedRefunds[S], any committee member can issueMarkDone(S)to drop the slot’s state. Idempotent.
Apply invariants
- 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.
- At most one
UnsealedPoolentry per slot. Enforced by theSealSlothandler checking for existing-slot before combining. - Deterministic cleartext. Given the same set of
tshares, combine always produces the same cleartext — every committee member’s replica ofUnsealedPoolconverges. - No share admission after finalize. Once
SealSlothas run for slotS, subsequentSubmitSharecommands for slotSare 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:
- Every prospective committee member generates an X25519 keypair and a BLS12-381 share secret locally.
- The operator’s
builder lattice updriver runs a Pedersen DKG over authenticated SSH: each member publishes its commitment polynomial, exchanges shares pairwise, and collects verification shares. - The output is the aggregate public key that seals payloads, plus each member’s secret share.
- The aggregate public key and every member’s
ShareBundleland in theunseal::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?
unsealseals to the committee aggregate public key; recovery is unambiguous oncetshares land. No trial-decrypt cost per recipient. But this also meansunsealcannot 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
schemevalue 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
- The six organisms
- zipnet — anonymous submission — upstream.
- offer — sealed-bid auction — the parallel organism that reuses the threshold pattern.
- cryptography.md — unseal
- threat-model.md — unseal