Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions tinycloud-threshold/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ BLS12-381 identity-based wrapping of 32-byte data keys and context-bound encrypt

The crate is a workspace member with `publish = false`. No other workspace crate depends on it; the node server remains the default member. Release-plz neither releases nor publishes it.

## Implemented surface through S0.5
## Implemented surface through S0.6

- Bounded, canonical scalar, point, identity, ciphertext, request and share encodings.
- Boneh–Franklin FullIdent wrapping and unwrapping with the TinyCloud domains in [SPEC.md](SPEC.md).
Expand All @@ -18,8 +18,9 @@ The crate is a workspace member with `publish = false`. No other workspace crate
- Genesis and re-key DKG (GJKR New-DKG) over owner-closed rounds, the re-key under an owner-authorized `re-key` ceremony pinned to the current certificate with the genesis fault budget: consume-self member states for dealing, complaints, answers, Feldman extraction, the common R5 round and R6 reconstruction, and `dkg_verify_transcript`, which recomputes the outcome from the raw signed round records.
- Redistribution for reshare and refresh over owner-closed rounds R1–R3: Feldman dealings from the current certificate's members to an authorized cohort with a new threshold, complaints and answers, deterministic selection of the first t eligible dealers, and `reshare_verify_transcript`. The master public key is unchanged; the output is a candidate for the successor certificate.
- A real-ceremony chain test: genesis 2-of-3, reshare to 3-of-4 and back to 2-of-3, refresh, re-key, and further reshares and re-keys, with a certificate, readiness receipts and activation at every generation and each retired generation's shares refused in the next context.
- A durable share store: `CandidateShare::to_bytes`/`from_bytes` (canonical bytes; the codec guards its own secret intermediates), one file per generation under a per-network directory, an exclusive per-network open lock, install before any readiness receipt, a pin recomputed from activations verified on every open, retirement whose `state` commit always precedes the unlink, and a durably-recorded freeze on activated equivocation that survives the loss of the fork evidence itself.

`VerifiedCertificate` supplies `KeyContext` and `PublicKeySet`; `KeyShare::bind` validates a candidate against its certificate. Raw candidate-scalar construction remains crate-private, and the dealer exists only under `#[cfg(test)]`. Redistribution does not erase earlier shares; old and new shares carry different key contexts. Durable storage, retirement and node-runtime paths remain separate slices. Their contracts and the implemented canonical byte layouts are recorded in [SPEC.md](SPEC.md).
`VerifiedCertificate` supplies `KeyContext` and `PublicKeySet`; `KeyShare::bind` validates a candidate against its certificate. Raw candidate-scalar construction remains crate-private, and the dealer exists only under `#[cfg(test)]`. Redistribution does not erase earlier shares; old and new shares carry different key contexts. The `store` module implements the file-volume durable share store and retirement; node-runtime wiring remains a separate slice. Their contracts and the implemented canonical byte layouts are recorded in [SPEC.md](SPEC.md).

The §3.6 request-digest amendment is explicit in SPEC.md. It makes an identity or transport-key transplant a context mismatch before share equations are evaluated.

Expand Down
18 changes: 16 additions & 2 deletions tinycloud-threshold/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ All integer fields below are big-endian. `lp` is u32 length followed by bytes.
- No S0.1 public decoder consumes a member list; lists introduced in later slices use the §3.2 sorted/count rules.
- An availability budget counts all unavailable ready signers, including dishonest claims: `q - b >= t′`. All four ready holders in 3-of-4 support one unavailable holder, not two. `WithOutageBudget(0)` is not accepted.

## Implemented surface through S0.5
## Implemented surface through S0.6

S0.1 implements §§3.1–3.6 and the typed parameters in §3.0. S0.2 adds the authenticated-context boundaries in §3.0, the certificate and fault-proof APIs in §3.9, and the receipt/activation verification in §3.12:

Expand All @@ -248,7 +248,9 @@ S0.4 adds the §3.8 redistribution for reshare and refresh: dealer and recipient

S0.5 runs the §3.7 DKG under a `re-key` ceremony (`dkg_start` and `dkg_verify_transcript` accept kinds `genesis` and `re-key` and refuse reshare and refresh with `InvalidCeremony`), executes the full genesis → reshare → reshare → refresh → re-key chain with real ceremonies, certificates, readiness receipts and activation, and freezes the chain and re-key vectors described in the S0.5 appendix.

The dealer remains test-only. Durable storage/retirement and node-runtime integration are separate slices. The crate remains an unwired workspace member.
S0.6 adds the `store` module: the durable file-volume share store of §3.11–3.12 — `CandidateShare` canonical byte persistence, one proof and one share file per certificate, an exclusive per-network open lock, receipt-only-after-install (with the persisted file read back and re-bound), the activated-chain pin recomputed from fully verified proofs at open, member/anchor/pin checks on accepted ceremonies, and positive-proof retirement whose `state` commit precedes every unlink (superseded generations, losing candidates, unnamed files and replayed ceremony ids). Its layouts and rules are in the S0.6 appendix below.

The dealer remains test-only. Node-runtime integration (volume mount, peer/DB pin sources, boot freshness, audit rows) is a separate slice. The crate remains an unwired workspace member.

## Verifier conformance

Expand Down Expand Up @@ -545,3 +547,15 @@ A chain of generations is: genesis; then any sequence of reshare, refresh and re
`vectors/v1/dkg-rekey.json` has the shape of `dkg-genesis.json` with `"spec_sections"` naming `S0.5 re-key appendix`, and each case adds `inputs.current_certificate`, the signed pinned certificate (`body` and `owner_signature`, as in `reshare.json`). The cases mirror the genesis cases under a re-key ceremony; names begin `rekey_` (`insufficient_*` keep their names).

`vectors/v1/chain.json` has one case per generation, in chain order, with operation `chain_step`. A case's inputs are the signed ceremony statement and its digest, the initial transcript link, every owner closure and member message, `previous_certificate` (absent for genesis, otherwise the previous case's `certificate`), the candidate `certificate` (`body` and `owner_signature`), one readiness receipt (`member`, `signature`) per holder, `quorum` `all_holders` and the owner's `activation` statement (`body` is the exact 32-byte activation preimage). The outputs are the recomputed kind, epoch, re-key counter, genesis t, threshold, members, mpk, commitments, public shares, transcript hash, certificate digest, activation preimage and the ceremony outcome (`qual` and `reconstructed` for genesis and re-key, `dealers` for reshare and refresh). A verifier recomputes the ceremony outcome from the transcript (S0.3 or S0.4 rules), rebuilds the certificate body from that outcome and the pinned predecessor, requires it to equal the supplied body byte for byte, verifies the owner signature and lineage (S0.2 appendix), verifies every receipt and the activation, and compares its own results with the outputs; it never takes an output field as input to its decision.

## S0.6 durable share-store appendix

The `store` module keeps one member's share material for one network under `<dir>/<sha256(network)[..16]-hex>/` (directory mode 0700, files 0600). Every write is write-temp, fsync, rename, fsync-directory; erasure is zero-overwrite, fsync, unlink, fsync-directory. Shares exist only as files named `share-<cert-digest-hex>`; `proofs/<cert-digest-hex>` holds certificate and activation evidence; `dealt/<ceremony-id-hex>` is an empty marker; `state` is the single mutable record.

**Candidate share file.** `version:u8=1 || transcript_hash:32 || index:u16 || scalar:32` (67 bytes). Decode is canonical only: exact length, version 1, index 1..=16, scalar < r and non-zero. Secret bytes travel inside `Zeroizing` and `bind` re-verifies them against the certificate before use.

**Proof file.** `version:u8=1 || lp(signed_certificate) || activation_flag:u8 || [lp(activation_bytes) || lp(owner_signature) || receipt_count:u16 || (index:u16 || lp(signature))…]`. The signed certificate is `version:u8=1 || lp(certificate_body) || lp(owner_signature)`.

**State file** (version 3). `version:u8=3 || option(pin:32) || option(candidate: digest:32 || ceremony_id:32 || option(predecessor:32) || receipt_sent:u8) || option(active_ceremony: id:32 || option(predecessor:32)) || seen_count:u32 || seen_ceremony_id:32… || retired_count:u32 || (digest:32 || option(predecessor:32) || erased_at:u64)… || seq:u64 || frozen:u8`. `seen_ceremonies` is history only; `active_ceremony` is the single admissible id, replaced by every fresh accept and cleared on every pin advance, so a superseded or retired id can never install again. Each `retired` entry records the certificate the retired generation descended from: it is reachable — and must survive — only while the pin equals that predecessor; entries keyed to superseded pins are provably dead and compacted away only when the list nears its 65536-entry bound, and an operational mutation that would overflow the bound is refused rather than letting the record grow past it; `open`'s recovery path cannot refuse, so it compacts dead entries before appending — recovery converges toward the bound without evicting reachable records. `frozen` records activated equivocation durably: discovering equivocation writes it before the freeze is honored, so losing the fork's own proof can never thaw the store; only operator recovery rewrites a frozen directory. Versions 1–2 and unknown higher versions are refused at decode; a `receipt_sent` candidate whose share file is missing, a pin whose share or proof is missing, or a chain tip that is also a retired digest is corruption or a freeze — never silent repair.

**Rules.** `open` holds an exclusive advisory `lock` file in the network directory for the store's lifetime, sweeps any `*.tmp-*`/`*.tmp` leftovers, and fsyncs every parent directory it creates. At most one candidate record exists at a time and only for the pinned predecessor (or for genesis before any pin); a `receipt_sent` flag may be set only after proof and share files are durable and the persisted share has been read back and re-bound. Open recomputes the activated chain from `proofs/`, verifying the certificate AND the `AllHolders` activation of every flagged proof (genesis included) before it can extend the pin or count toward a fork; a recorded pin must be a verified activated certificate (chain member or equivocated fork child), anything else is corruption. Activated equivocation freezes the pin durably: the fork's activated proof is persisted first, and a frozen store refuses export, install, accept, activation and receipt recording — every mutator rejects without touching the durable record. A ceremony is accepted only when it anchors to this store's owner/network, names this member among its recipients, and pins the current certificate; a fresh id then erases any unactivated candidate for that predecessor (C1), consumes the id, and a replayed id changes nothing. The store keeps a lock file for its whole lifetime (Busy for a second opener, `LockUnsupported` where advisory locking is unavailable — local POSIX filesystems only); open sweeps leftover temp files and refuses to open when one cannot be removed. Descendants of forked branches stay verified so a frozen store reopens frozen, not corrupt; recovery is operator-only (audit `proofs/`, abandon the directory, provision fresh). Every parent directory of the created hierarchy is fsynced. A share file is erased when the chain passes its certificate (R1), when a candidate's predecessor is covered or superseded (C2), when a fresh ceremony id arrives for its predecessor (C1), or when no record names it (S1). Every removal is committed to `state` BEFORE its unlink, so a crash is always recovered as erasure-owed, never as a missing promised share; a receipt-bearing candidate that still needs its share is the only missing-file corruption. `record_activation` is idempotent per certificate digest. Erasure is best-effort zero-overwrite + fsync + unlink: on journaling or copy-on-write filesystems the plaintext may survive in snapshots or journals and is not guaranteed erased (design v5 §5.9).
58 changes: 58 additions & 0 deletions tinycloud-threshold/src/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,53 @@ impl CandidateShare {
pub fn index(&self) -> MemberIndex {
self.index
}
/// Secret bytes: version(1) ‖ transcript hash(32) ‖ index u16be(2) ‖ scalar(32). Details: TC-2221
///
/// The caller owns the returned buffer's erasure; it is wrapped in
/// [`zeroize::Zeroizing`] so a dropped copy clears itself. [`CandidateShare::from_bytes`]
/// restores the candidate for [`KeyShare::bind`] against the pinned certificate.
/// The encoding is written directly into the guarded output and the raw
/// scalar is guarded; no unguarded secret copy is produced.
pub fn to_bytes(&self) -> zeroize::Zeroizing<[u8; 67]> {
let mut out = zeroize::Zeroizing::new([0u8; 67]);
let scalar = zeroize::Zeroizing::new(self.scalar.to_bytes());
let (version, rest) = out.split_at_mut(1);
version.copy_from_slice(&[1u8]);
let (transcript, rest) = rest.split_at_mut(32);
transcript.copy_from_slice(self.transcript.as_bytes());
let (index, scalar_slot) = rest.split_at_mut(2);
index.copy_from_slice(&self.index.get().to_be_bytes());
scalar_slot.copy_from_slice(&*scalar);
out
}

/// Canonical decode only: version 1, index 1..=16, scalar < r and != 0, exact length. Details: TC-2221
pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() != 67 || bytes.first() != Some(&1) {
return Err(Error::InvalidEncoding);
}
let mut transcript = [0u8; 32];
transcript.copy_from_slice(bytes.get(1..33).ok_or(Error::InvalidEncoding)?);
let index = MemberIndex::new(u16::from_be_bytes([
*bytes.get(33).ok_or(Error::InvalidEncoding)?,
*bytes.get(34).ok_or(Error::InvalidEncoding)?,
]))?;
let raw = zeroize::Zeroizing::new(
<[u8; 32]>::try_from(bytes.get(35..67).ok_or(Error::InvalidEncoding)?)
.map_err(|_| Error::InvalidEncoding)?,
);
let scalar = Scalar::from_bytes(&*raw)?;
if scalar.is_zero() {
return Err(Error::InvalidScalar);
}
Ok(Self {
transcript: TranscriptHash::new(transcript),
index,
scalar,
})
}
}

impl fmt::Debug for CandidateShare {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("CandidateShare([REDACTED])")
Expand Down Expand Up @@ -417,6 +463,18 @@ impl CertificateBody {
pub fn transcript_hash(&self) -> TranscriptHash {
self.transcript
}
/// Pinned predecessor digest, absent only at genesis. Details: TC-2221
pub fn previous(&self) -> Option<&CertDigest> {
self.previous.as_ref()
}
/// Certificate epoch. Details: TC-2221
pub fn epoch(&self) -> Epoch {
self.epoch
}
/// Re-key counter. Details: TC-2221
pub fn rekey(&self) -> RekeyCounter {
self.rekey
}
}

/// Raw owner-signed certificate. Details: TC-2222
Expand Down
1 change: 1 addition & 0 deletions tinycloud-threshold/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod ids;
mod poly;
pub mod reshare;
pub mod seal;
pub mod store;

pub use activation::{
activation_preimage, readiness_preimage, verify_activation, ReadinessReceipt, ReadyQuorum,
Expand Down
Loading
Loading