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
14 changes: 11 additions & 3 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.2
## Implemented surface through S0.3

- 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 @@ -14,8 +14,10 @@ The crate is a workspace member with `publish = false`. No other workspace crate
- Owner-authorized ceremony envelopes and closed rounds with signed recipient/dealer cohorts and retained authenticated payload-decode outcomes.
- Externally anchored certificates, pinned succession and certificate-bound candidate shares.
- Signed response fault classification, readiness receipts and all-holder or explicit outage-budget activation verification.
- Ceremony-scoped RFC 9180 sealed dealings.
- Genesis DKG (GJKR New-DKG) over owner-closed rounds: 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.

`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)]`. DKG/redistribution rounds, HPKE dealings, durable storage 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 rounds, durable storage and node-runtime paths remain separate slices. 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 All @@ -37,7 +39,7 @@ cargo deny --manifest-path tinycloud-threshold/Cargo.toml --locked --config tiny

The `threshold` job in `.github/workflows/rust.yml` runs the full release suite, the explicitly selected cargo-deny policy and the independent primitive checker. Workspace test, build, formatting and clippy jobs also include this member.

The checkpoint prints nine executed outcomes. Required RFC tests are separately named `rfc9380_appendix_j_g1_sswu_ro`, `rfc9380_appendix_j_g2_sswu_ro` and `rfc9380_appendix_k1_expand_xmd_sha256`.
The `checkpoint_2of3` filter runs two checkpoints: the test-dealer checkpoint prints nine executed outcomes, and `checkpoint_2of3_dkg` prints five outcomes for a signed 2-of-3 DKG with one reconstructed dealer, its certificate binding and threshold derivation. Required RFC tests are separately named `rfc9380_appendix_j_g1_sswu_ro`, `rfc9380_appendix_j_g2_sswu_ro` and `rfc9380_appendix_k1_expand_xmd_sha256`.

## Vectors

Expand All @@ -51,6 +53,12 @@ TC_THRESHOLD_REGEN_VECTORS=1 cargo test -p tinycloud-threshold primitive_vectors

`vectors/v1/cert-core.json` contains 18 certificate, lineage, share-binding, fault and activation cases. The certificate vector test replays these cases through the verification APIs with real test signatures.

`vectors/v1/dkg-genesis.json` contains six genesis DKG transcripts from fixed seeds: the signed ceremony statement, the initial transcript link, every owner closure and member message, and either the recomputed QUAL, disqualification reasons, reconstructed dealers, commitments, public shares and transcript hash, or the expected error. It lists the Ed25519 test keys for its DIDs. `dkg_vectors_regenerate_byte_for_byte_and_replay` regenerates the file in memory, compares its complete bytes and replays every case through `dkg_verify_transcript`. To update it deliberately:

```sh
TC_THRESHOLD_REGEN_VECTORS=1 cargo test -p tinycloud-threshold dkg_vectors_regenerate_byte_for_byte_and_replay
```

## Dependencies and licence

This crate uses the workspace's [TinyCloud EGPL v1.5](../LICENSE.md) through `license-file`. Production dependencies: `blst` (Apache-2.0), `sha2` (MIT OR Apache-2.0), `zeroize` (Apache-2.0 OR MIT), `subtle` (BSD-3-Clause), `rand_core` (MIT OR Apache-2.0), and `thiserror` (MIT OR Apache-2.0). Development dependencies: `proptest`, `rand_chacha`, `serde`, `serde_json`, and `hex` (MIT OR Apache-2.0).
Expand Down
90 changes: 88 additions & 2 deletions tinycloud-threshold/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,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.2
## Implemented surface through S0.3

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 @@ -231,7 +231,9 @@ S0.1 implements §§3.1–3.6 and the typed parameters in §3.0. S0.2 adds the a
- Fault verification checks certificate context, request/share binding and the member signature before classifying a failed share equation.
- Readiness and activation APIs construct the defined preimages and verify every supplied receipt signature before quorum evaluation, receipt hashing or sorting.

The dealer remains test-only. DKG and redistribution round implementations in §§3.7–3.8, HPKE dealings in §3.10, durable storage/retirement and node-runtime integration are separate slices. The crate remains an unwired workspace member.
S0.3 adds the §3.10 sealed dealings with ceremony-scoped recipient keys and the §3.7 genesis DKG: consume-self member states over owner-closed rounds R1–R6, and `dkg_verify_transcript`, which recomputes the outcome from the raw signed round records. Its byte layouts and deterministic rules are in the S0.3 genesis DKG appendix below.

The dealer remains test-only. Redistribution rounds in §3.8, durable storage/retirement and node-runtime integration are separate slices. The crate remains an unwired workspace member.

## S0.2 canonical byte appendix

Expand Down Expand Up @@ -352,3 +354,87 @@ activation.bytes = TH(ACTIVATE,
The owner signs exactly those 32 activation bytes. Unlike ceremony/closure statements, activation `SignedStatement.bytes` already contains the domain-separated signing preimage rather than a raw body.

The default quorum is all `n` holders. `WithOutageBudget(b)` requires `b > 0` and checked `q = t+b <= n`; at least `q` unique verified members must be ready. The budget includes all unavailable ready signers. Receipts are signed installation statements; durable storage, monotonic activation state and retirement remain integration operations.

## S0.3 genesis DKG appendix

This appendix records the byte layouts and deterministic rules that members and `dkg_verify_transcript` apply to §3.7. Integers are unsigned big-endian; `lp`, `TH`, point and scalar encodings are as in §3.2 and the S0.2 appendix. Rounds are numbered R1 = 1 through R6 = 6. Every DKG message uses the S0.2 member envelope with cohort `1` (authorized recipients); genesis has no dealer cohort.

### Initial link and transcript

```text
H0 = TH("TC-THRESHOLD-V1-DKG-START", authorized_ceremony_digest:32)
```

`dkg_initial_transcript(c)` returns `H0`. The tag is specific to this link and is not part of the §3.3 table; it is distinct from, and not a prefix of, every §3.3 tag. Closed R1 names `H0` as its previous hash; each later round names the S0.2 transcript update of the round before it. The DKG transcript hash is the transcript update after closed R5, or after closed R6 when R6 is required.

Each member transition first requires the closed round's authorized-ceremony digest, round number and previous hash to equal the values the consuming state holds; otherwise it returns `InvalidRound` before reading any message.

`DkgTranscript` is the list of raw round records: the exact owner closure (body and signature) and the exact member statements in owner order. `dkg_verify_transcript` first sums the byte lengths of every closure body, closure signature, member envelope and member signature and returns `LimitExceeded` above 4194304 bytes, before any signature check. It then verifies R1–R5 as closed rounds, verifies R6 exactly when the R5 rules require it, and rejects missing or additional records with `InvalidRound`. It derives the outcome from the authenticated records only.

### Pedersen generator and dealing context

`h = hash_to_curve_G2("pedersen-h", PEDERSEN_H DST)` as in §3.3. The commitment-vector hash used as HPKE AAD is:

```text
TH(COMMITMENTS, t:u16 || C_0:96 || … || C_(t−1):96)
```

HPKE `info` is `DEALING || authorized_ceremony_digest:32 || 01 || dealer:u16 || recipient:u16`. The sealed plaintext is exactly `s_ij:32 || s′_ij:32`, both canonical scalars.

Dealers draw randomness in this order: the t coefficients of f_i (constant first), then the t coefficients of f′_i, each as 64 bytes reduced mod r and redrawn if zero; then one HPKE encapsulation per recipient in index order.

### Payloads

```text
R1 deal = 01 || t:u16 || [C_k:96] || n:u16 || [(recipient:u16 || lp(sealed_dealing))]
R2 complaints = 01 || count:u16 || [dealer:u16]
R3 answers = 01 || count:u16 || [(recipient:u16 || s:32 || s′:32)]
R4 feldman = 01 || t:u16 || [A_k:96]
R5 extraction = 01 || count:u16 || [(dealer:u16 || s:32 || s′:32)]
R6 values = 01 || count:u16 || [(dealer:u16 || s:32 || s′:32)]
```

`sealed_dealing` is the §3.10 envelope `01 || enc:32 || lp(ciphertext)`. Every count is at most 16 and is checked before allocation; index lists are strictly increasing member indices 1..=16; point coefficients are canonical G2 encodings and individual coefficients may be the identity (§3.2 role table); scalars are canonical. Trailing bytes and other versions are rejected. A payload that fails this decoding is *malformed*.

### Classifying a round

Within a closed round, authenticated messages are grouped by sender. Messages with byte-identical envelopes are one message regardless of their signature bytes. Two messages from one sender with different envelope bytes are *conflicting*; this compares the signed content, including malformed payloads. A sender with no message is *missing*.

### R1–R3 and QUAL

For each member index i, in this order, the first applicable reason disqualifies dealer i:

1. `MissingDeal`: no R1 from i.
2. `Equivocated { round: 1 }`: conflicting R1.
3. `MalformedDeal`: R1 is malformed, has a commitment count other than t, or its recipient list is not exactly 1..=n.
4. `Equivocated { round: 2 }`: conflicting R2 from i.
5. `Equivocated { round: 3 }`: conflicting R3 from i.
6. `Complaints`: at least t distinct complainers counted against i (below).
7. `InvalidAnswer`: i's decoded R3 contains any answer that fails `s·g2 + s′·h = Σ_k j^k·C_ik` at its recipient j, whether or not that answer was required.
8. `MissingAnswer`: a required answer is absent: i's R3 is missing or malformed, or omits a required recipient.

R2 counting: a complaint by j against i counts once per distinct j, only from a non-conflicting, decodable R2 whose dealer indices are all at most n, and never when j = i. A malformed R2 supplies no complaints; a conflicting R2 supplies no complaints and disqualifies its sender as a dealer under rule 4. The required answers for dealer i are exactly the counted complainers against i.

R3 rules: an R3 is used only if it decodes and all recipient indices are at most n; otherwise it is malformed. A missing or malformed R3 disqualifies i only when i has required answers. A valid answer that was not required is ignored and creates no complaint. A valid required answer replaces the complainer's private pair from i.

QUAL is every dealer not disqualified, fixed after closed R3. If |QUAL| < t the ceremony returns `InsufficientQualified`. A member that lacks a Pedersen-valid pair from some QUAL dealer after R3 returns `LocalShareUnavailable`; this only arises when a member's own complaint is absent from closed R2.

Members complain in R2 about every dealer whose valid R1 dealing to them fails to open, decode or satisfy the Pedersen relation. A dealer answers in R3 every counted complaint against it when its own R1 was accepted. Every member sends R2 and R3, possibly empty.

### R4–R6 and output

For each i in QUAL, an *extraction fault* is a missing, conflicting or malformed R4, or an R4 whose coefficient count is not t. R4 from a sender outside QUAL is ignored.

Every member sends R5, possibly empty, listing each other QUAL dealer i whose valid R4 fails `s_ij·g2 = Σ_k j^k·A_ik` for its own pair. An R5 entry by sender j against dealer i is *upheld* when i ≠ j, i is in QUAL without an extraction fault, the pair satisfies the Pedersen relation for C_i at j, and the Feldman relation for A_i at j fails. Other entries are ignored. Missing, malformed and conflicting R5 messages count as empty lists. No member finishes before consuming closed R5.

The *reconstructed* set is the extraction faults plus every dealer with an upheld R5 entry. If it is empty the ceremony finishes after R5. Otherwise every member sends R6 with its Pedersen-valid pair for each reconstructed dealer, and the ceremony finishes after R6. For each reconstructed dealer i, the values used are those from non-conflicting, decodable R6 messages whose entry for i satisfies the Pedersen relation at the sender index; the t lowest sender indices are interpolated to the coefficients of f_i, and A_ik = a_ik·g2 replaces i's published vector. Dealer i stays in QUAL. Fewer than t such values return `InsufficientRecovery`. Missing, malformed and conflicting R6 messages contribute no values.

Output: A_k = Σ_{i∈QUAL} A_ik; mpk = A_0; pk_j = Σ_k j^k·A_k; identity mpk, A_(t−1) or pk_j is rejected (§3.2). The member's candidate scalar is Σ_{i∈QUAL} s_ij using R3 replacements. `DkgOutput` carries the public material, QUAL, the disqualification reasons, the reconstructed set and the transcript hash.

### Scope

Agreement among members and the verifier relies on the §3.0 owner relay closing each round once and delivering every conforming member's message. Round equivocation compares conflicting authenticated message content from the same sender; a different signature encoding alone is not equivocation. A relay that omits or substitutes messages produces the failure outcomes above, not agreement; the protocol does not implement reliable broadcast.

### In-memory lifecycle

`CeremonyHpkeSecretKey` is dropped when R1 has been processed. Polynomial coefficients are dropped when R4 has been produced. Received private pairs remain in the state until the member finishes after R5 or R6. States are consumed by each transition, so an error drops the state; scalars clear their storage on drop and states have redacted `Debug`. The caller's RNG state and any durable copies are outside this crate (§3.11).
101 changes: 101 additions & 0 deletions tinycloud-threshold/src/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,3 +680,104 @@ proptest! {
if let Ok(v)=IbeCiphertext::decode(&c) { prop_assert_eq!(v.encode(),c); }
}
}

/// Same checkpoint name filter, driven by real signed DKG candidates. Details: TC-2220
#[test]
fn checkpoint_2of3_dkg() {
use crate::ceremony::Decode;
use crate::dkg::{DkgFeldman, DkgOutput};
use crate::dkg_relay::{agreed, members, run, sum, vanishing_feldman, Run};

let mut constants = Vec::new();
let dkg = run(2, 3, 0x71, |out| {
if out.round() == 4 {
constants = (1..=3)
.map(|i| DkgFeldman::decode(&out.payload(i)).unwrap().commitments[0].encode())
.collect();
let forged = vanishing_feldman(&out.payload(3), &[1]);
out.replace(3, &forged);
}
});
let output: DkgOutput = agreed(&dkg, &[1, 2, 3]);
assert_eq!(dkg.transcript.rounds.len(), 6);
println!(
"1. PASS: signed 2-of-3 genesis DKG closed R1-R6; members 1, 2, 3 and dkg_verify_transcript agree on QUAL, commitments, mpk and transcript hash {}",
hex::encode(output.transcript_hash.as_bytes())
);
assert!(dkg.reconstruct.values().all(|r| *r));
assert_eq!(output.qual, members(&[1, 2, 3]));
assert_eq!(output.reconstructed, members(&[3]));
assert!(output.disqualified.is_empty());
println!("2. PASS: dealer 3's R4 passed only member 1; every member waited for the common closed R5, and R6 recovered dealer 3, which stayed in QUAL");
assert_eq!(
output.public.master_public_key().encode(),
sum(constants.iter().copied())
);
println!("3. PASS: mpk equals the sum of all three dealers' committed constants, including dealer 3's recovered contribution");

let Run {
fixture, outcomes, ..
} = dkg;
let anchor = fixture.signers.anchor();
let body = CertificateBody::genesis(
&anchor,
&fixture.ceremony,
&output.public,
&output.transcript_hash,
)
.unwrap();
let cert = verify_certificate(
&anchor,
&crate::cert::tests::sign(body, &fixture.signers),
&fixture.signers,
)
.unwrap();
let shares: Vec<KeyShare> = outcomes
.into_values()
.map(|outcome| KeyShare::bind(outcome.unwrap().0, &cert).unwrap())
.collect();
assert_eq!(shares.len(), 3);
println!("4. PASS: owner-signed genesis certificate verified under the anchor; all three DKG candidate shares bound to it");

let mut rng = ChaCha20Rng::from_seed([0x72; 32]);
let tsk = TransportSecretKey::generate(&mut rng);
let req = DecryptRequest::new(
&cert.key_context(),
identity(&cert.key_context(), "records/dkg"),
tsk.public_key(),
)
.unwrap();
let auth = authorize(req.clone());
let issued: Vec<_> = shares
.iter()
.map(|s| issue_share(s, &auth, &mut rng).unwrap())
.collect();
for share in &issued {
verify_share(cert.public_key_set(), &req, share).unwrap();
}
let data = DataKey::new([0x24; 32]);
let ct = wrap_key(
cert.public_key_set().master_public_key(),
req.identity(),
&data,
&mut rng,
)
.unwrap();
let mut keys = Vec::new();
for (a, b) in [(0, 1), (0, 2), (1, 2)] {
let encrypted = combine(
cert.public_key_set(),
&req,
&[issued[a].clone(), issued[b].clone()],
)
.unwrap();
let key = decrypt_derived_key(&encrypted, &tsk, cert.public_key_set(), &req).unwrap();
assert_eq!(
unwrap_key(&key, req.identity(), &ct).unwrap().as_bytes(),
data.as_bytes()
);
keys.push(key.key.encode());
}
assert!(keys.windows(2).all(|p| p[0] == p[1]));
println!("5. PASS: each DKG share verified; pairs {{1,2}}, {{1,3}}, {{2,3}} derived the same key and unwrapped the data key");
}
Loading
Loading