From 53145ce3fc872bd8aa2fcc9296b7f382227faee6 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:33:15 +0000 Subject: [PATCH 1/5] test(threshold): specify certificate and ceremony behavior. Details: TC-2222 --- tinycloud-threshold/src/activation.rs | 59 ++++++ tinycloud-threshold/src/ceremony.rs | 260 ++++++++++++++++++++++++++ tinycloud-threshold/src/cert.rs | 213 +++++++++++++++++++++ tinycloud-threshold/src/fault.rs | 44 +++++ 4 files changed, 576 insertions(+) create mode 100644 tinycloud-threshold/src/activation.rs create mode 100644 tinycloud-threshold/src/ceremony.rs create mode 100644 tinycloud-threshold/src/cert.rs create mode 100644 tinycloud-threshold/src/fault.rs diff --git a/tinycloud-threshold/src/activation.rs b/tinycloud-threshold/src/activation.rs new file mode 100644 index 00000000..2512f666 --- /dev/null +++ b/tinycloud-threshold/src/activation.rs @@ -0,0 +1,59 @@ +#[cfg(test)] +use crate::ids::MemberIndex; + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ceremony::tests::Signers, cert::tests}; + + fn receipts(cert: &VerifiedCertificate, signers: &Signers) -> Vec { + (1..=cert.key_context().threshold().n()).map(|i| { + let index = MemberIndex::new(i).unwrap(); + ReadinessReceipt { index, signature: signers.sign(&Signers::did(i as u8), &readiness_preimage(cert.body(), index).unwrap()) } + }).collect() + } + fn activation(cert: &VerifiedCertificate, receipts: &[ReadinessReceipt], signers: &Signers) -> SignedStatement { + let bytes = activation_preimage(cert.body(), receipts).unwrap(); + SignedStatement { signature: signers.sign(&cert.anchor().expected_owner, &bytes), bytes } + } + + #[test] + fn activation_requires_unique_exact_candidate_receipts_and_owner_approval() { + let signers = Signers::new(); + let (cert, _) = tests::genesis(&signers); + let mut receipts = receipts(&cert, &signers); + let signed = activation(&cert, &receipts, &signers); + assert!(verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &signed, &signers).is_ok()); + receipts.reverse(); + assert!(verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &signed, &signers).is_ok()); + assert!(verify_activation(&cert, &receipts[..2], ReadyQuorum::AllHolders, &signed, &signers).is_err()); + assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(0), &signed, &signers).is_err()); + assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(1), &signed, &signers).is_ok()); + assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(2), &signed, &signers).is_err()); + assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(u16::MAX), &signed, &signers).is_err()); + let duplicate = vec![receipts[0].clone(), receipts[0].clone(), receipts[1].clone()]; + assert!(verify_activation(&cert, &duplicate, ReadyQuorum::AllHolders, &signed, &signers).is_err()); + let mut wrong = receipts.clone(); wrong[0].index = MemberIndex::new(4).unwrap(); + assert!(verify_activation(&cert, &wrong, ReadyQuorum::AllHolders, &signed, &signers).is_err()); + let mut wrong = receipts.clone(); wrong[0].signature[0] ^= 1; + assert!(verify_activation(&cert, &wrong, ReadyQuorum::AllHolders, &signed, &signers).is_err()); + let mut wrong = signed.clone(); wrong.signature[0] ^= 1; + assert!(verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &wrong, &signers).is_err()); + let (other, _, _) = tests::successor(&signers, &cert, crate::ceremony::CeremonyKind::Refresh, cert.key_context().threshold(), 11); + let other = crate::cert::verify_successor(&cert, &other, &signers).unwrap(); + assert!(verify_activation(&other, &receipts, ReadyQuorum::AllHolders, &signed, &signers).is_err()); + } + + #[test] + fn declared_budget_counts_every_unavailable_ready_holder() { + let signers = Signers::new(); + let (genesis, _) = tests::genesis(&signers); + let (candidate, _, _) = tests::successor(&signers, &genesis, crate::ceremony::CeremonyKind::Reshare, crate::ids::Threshold::new(3, 4).unwrap(), 11); + let cert = crate::cert::verify_successor(&genesis, &candidate, &signers).unwrap(); + let receipts = receipts(&cert, &signers); + let signed = activation(&cert, &receipts, &signers); + assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(1), &signed, &signers).is_ok()); + assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(2), &signed, &signers).is_err()); + assert!(verify_activation(&cert, &receipts[..3], ReadyQuorum::WithOutageBudget(1), &signed, &signers).is_err()); + } +} diff --git a/tinycloud-threshold/src/ceremony.rs b/tinycloud-threshold/src/ceremony.rs new file mode 100644 index 00000000..911ec0a8 --- /dev/null +++ b/tinycloud-threshold/src/ceremony.rs @@ -0,0 +1,260 @@ +#[cfg(test)] +use crate::{error::{Error, SignatureError}, ids::*}; + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; + use std::collections::BTreeMap; + + pub(crate) struct Signers { + keys: BTreeMap, + } + impl Signers { + pub(crate) fn new() -> Self { + let keys = (0..=16) + .map(|i| (Self::did(i), SigningKey::from_bytes(&[i; 32]))) + .collect(); + Self { keys } + } + pub(crate) fn did(i: u8) -> Did { + Did::new(format!("did:key:tc2222-fixture-{i}")).unwrap() + } + pub(crate) fn anchor(&self) -> TrustAnchor { + TrustAnchor { + expected_owner: Self::did(0), + network: NetworkUrn::new("tinycloud:test:certificates").unwrap(), + } + } + pub(crate) fn sign(&self, did: &Did, preimage: &[u8]) -> Vec { + self.keys[did].sign(preimage).to_bytes().to_vec() + } + pub(crate) fn statement(&self, body: &CeremonyStatement) -> SignedStatement { + SignedStatement { + bytes: body.encode(), + signature: self.sign(&body.owner, &body.signing_preimage()), + } + } + pub(crate) fn genesis(&self) -> AuthorizedCeremony { + let body = self.body(CeremonyKind::Genesis, Threshold::new(2, 3).unwrap()); + AuthorizedCeremony::verify_genesis(&self.anchor(), &self.statement(&body), self).unwrap() + } + pub(crate) fn body(&self, kind: CeremonyKind, threshold: Threshold) -> CeremonyStatement { + CeremonyStatement { + kind, + id: CeremonyId::new([41; 32]), + network: self.anchor().network, + owner: Self::did(0), + current: None, + threshold, + rekey: RekeyCounter::new(0), + epoch: Epoch::new(0), + recipients: (1..=threshold.n()) + .map(|i| CeremonyRecipient { + index: MemberIndex::new(i).unwrap(), + node: Self::did(i as u8), + hpke_public_key: [i as u8; 32], + }) + .collect(), + } + } + } + impl DidSignatureVerifier for Signers { + fn verify(&self, signer: &Did, message: &[u8], signature: &[u8]) -> Result<(), SignatureError> { + let key: VerifyingKey = self.keys.get(signer).ok_or(SignatureError::Invalid)?.verifying_key(); + let signature = ed25519_dalek::Signature::from_slice(signature).map_err(|_| SignatureError::Invalid)?; + key.verify_strict(message, &signature).map_err(|_| SignatureError::Invalid) + } + } + + #[test] + fn ceremony_substitutions_require_the_owner_signature() { + let signers = Signers::new(); + let body = signers.body(CeremonyKind::Genesis, Threshold::new(2, 3).unwrap()); + let signed = signers.statement(&body); + let verified = AuthorizedCeremony::verify_genesis(&signers.anchor(), &signed, &signers).unwrap(); + assert_eq!(verified.threshold(), body.threshold); + for mutation in 0..7 { + let mut changed = body.clone(); + match mutation { + 0 => changed.recipients[0].hpke_public_key = [99; 32], + 1 => changed.recipients[0].node = Signers::did(9), + 2 => changed.id = CeremonyId::new([42; 32]), + 3 => changed.network = NetworkUrn::new("other").unwrap(), + 4 => changed.owner = Signers::did(9), + 5 => changed.epoch = Epoch::new(1), + _ => changed.threshold = Threshold::new(3, 3).unwrap(), + } + let forged = SignedStatement { bytes: changed.encode(), signature: signed.signature.clone() }; + assert!(AuthorizedCeremony::verify_genesis(&signers.anchor(), &forged, &signers).is_err()); + } + let other = TrustAnchor { expected_owner: Signers::did(9), ..signers.anchor() }; + assert!(AuthorizedCeremony::verify_genesis(&other, &signed, &signers).is_err()); + } + + #[test] + fn ceremony_decoding_checks_budget_directory_and_canonical_bytes() { + let signers = Signers::new(); + let invalid_budget = signers.body(CeremonyKind::Genesis, Threshold::new(3, 4).unwrap()); + assert!(matches!(AuthorizedCeremony::verify_genesis(&signers.anchor(), &signers.statement(&invalid_budget), &signers), Err(Error::UnsupportedFaultBudget))); + let body = signers.body(CeremonyKind::Genesis, Threshold::new(2, 3).unwrap()); + assert_eq!(CeremonyStatement::decode(&body.encode()).unwrap().encode(), body.encode()); + for mutation in 0..4 { + let mut changed = body.clone(); + match mutation { + 0 => changed.recipients.swap(0, 1), + 1 => changed.recipients[1].node = changed.recipients[0].node.clone(), + 2 => changed.recipients[1].index = changed.recipients[0].index, + _ => { changed.recipients.pop(); } + } + assert!(CeremonyStatement::decode(&changed.encode()).is_err()); + } + let mut bytes = body.encode(); + bytes.push(0); + assert!(CeremonyStatement::decode(&bytes).is_err()); + for length in 0..body.encode().len() { + assert!(CeremonyStatement::decode(&body.encode()[..length]).is_err()); + } + } +} + +#[cfg(test)] +mod round_tests { + use super::*; + use super::tests::Signers; + + #[derive(Debug, Eq, PartialEq)] + struct Byte(u8); + impl Decode for Byte { + fn decode(bytes: &[u8]) -> Result { + match bytes { [byte] => Ok(Self(*byte)), _ => Err(Error::InvalidEncoding) } + } + } + fn message(c: &AuthorizedCeremony, member: u16, payload: &[u8], signers: &Signers) -> SignedStatement { + let bytes = ceremony_message_body(c, 1, MemberCohort::Recipients, MemberIndex::new(member).unwrap(), payload).unwrap(); + SignedStatement { + signature: signers.sign(&Signers::did(member as u8), &ceremony_message_preimage(&bytes).unwrap()), bytes, + } + } + fn close(c: &AuthorizedCeremony, prev: &TranscriptHash, messages: &[SignedStatement], signers: &Signers) -> SignedStatement { + let bytes = round_closure_body(c, 1, prev, messages).unwrap(); + SignedStatement { + signature: signers.sign(&signers.anchor().expected_owner, &round_closure_preimage(&bytes).unwrap()), bytes, + } + } + + #[test] + fn closure_commits_owner_order_member_signatures_ceremony_round_and_previous() { + let signers = Signers::new(); + let ceremony = signers.genesis(); + let prev = TranscriptHash::new([0; 32]); + let messages = vec![message(&ceremony, 1, &[7], &signers), message(&ceremony, 2, &[8], &signers)]; + let closure = close(&ceremony, &prev, &messages, &signers); + let round = ClosedRound::::verify(&ceremony, 1, &prev, &closure, &messages, &signers).unwrap(); + assert_eq!(round.messages()[0].payload(), &Ok(Byte(7))); + assert_ne!(round.transcript_hash(), prev); + let mut reordered = messages.clone(); reordered.reverse(); + assert!(ClosedRound::::verify(&ceremony, 1, &prev, &closure, &reordered, &signers).is_err()); + assert!(ClosedRound::::verify(&ceremony, 2, &prev, &closure, &messages, &signers).is_err()); + assert!(ClosedRound::::verify(&ceremony, 1, &TranscriptHash::new([9; 32]), &closure, &messages, &signers).is_err()); + let mut changed = signers.body(CeremonyKind::Genesis, ceremony.threshold()); changed.id = CeremonyId::new([99; 32]); + let other = AuthorizedCeremony::verify_genesis(&signers.anchor(), &signers.statement(&changed), &signers).unwrap(); + assert!(ClosedRound::::verify(&other, 1, &prev, &closure, &messages, &signers).is_err()); + let mut invalid = messages.clone(); invalid[0].signature[0] ^= 1; + let signed_invalid = close(&ceremony, &prev, &invalid, &signers); + assert!(matches!(ClosedRound::::verify(&ceremony, 1, &prev, &signed_invalid, &invalid, &signers), Err(Error::InvalidSignature))); + let mut invalid_owner = closure.clone(); invalid_owner.signature[0] ^= 1; + assert!(matches!(ClosedRound::::verify(&ceremony, 1, &prev, &invalid_owner, &messages, &signers), Err(Error::InvalidSignature))); + } + + #[test] + fn closed_round_preserves_authenticated_equivocation_and_malformed_payloads() { + let signers = Signers::new(); + let ceremony = signers.genesis(); + let prev = TranscriptHash::new([0; 32]); + let one = message(&ceremony, 1, &[7], &signers); + let messages = vec![one.clone(), one, message(&ceremony, 1, &[8], &signers), message(&ceremony, 2, &[], &signers)]; + let closure = close(&ceremony, &prev, &messages, &signers); + let round = ClosedRound::::verify(&ceremony, 1, &prev, &closure, &messages, &signers).unwrap(); + assert_eq!(round.messages()[0].payload(), &Ok(Byte(7))); + assert_eq!(round.messages()[2].payload(), &Ok(Byte(8))); + assert_eq!(round.messages()[3].payload(), &Err(Error::InvalidEncoding)); + assert_eq!(round.messages()[0].member(), round.messages()[2].member()); + assert!(ceremony_message_body(&ceremony, 1, MemberCohort::Current, MemberIndex::new(1).unwrap(), &[7]).is_err()); + } +} + +#[cfg(test)] +mod cohort_tests { + use super::*; + use super::tests::Signers; + + struct Payload; + impl Decode for Payload { + fn decode(_: &[u8]) -> Result { Ok(Self) } + } + + #[test] + fn overlapping_indices_resolve_only_in_the_signed_cohort() { + let signers = Signers::new(); + let (current, _) = crate::cert::tests::genesis(&signers); + let mut body = signers.body(CeremonyKind::Reshare, current.key_context().threshold()); + body.current = Some(current.digest()); + body.epoch = current.key_context().epoch().checked_next().unwrap(); + body.recipients[0].node = Signers::did(9); + let ceremony = AuthorizedCeremony::verify_for(¤t, &signers.statement(&body), &signers).unwrap(); + let index = MemberIndex::new(1).unwrap(); + let mut messages = Vec::new(); + for (cohort, did) in [(MemberCohort::Current, Signers::did(1)), (MemberCohort::Recipients, Signers::did(9))] { + let bytes = ceremony_message_body(&ceremony, 1, cohort, index, &[7]).unwrap(); + messages.push(SignedStatement { signature: signers.sign(&did, &ceremony_message_preimage(&bytes).unwrap()), bytes }); + } + let prev = TranscriptHash::new([0; 32]); + let close = |messages: &[SignedStatement]| { + let bytes = round_closure_body(&ceremony, 1, &prev, messages).unwrap(); + SignedStatement { signature: signers.sign(&signers.anchor().expected_owner, &round_closure_preimage(&bytes).unwrap()), bytes } + }; + let round = ClosedRound::::verify(&ceremony, 1, &prev, &close(&messages), &messages, &signers).unwrap(); + assert_eq!(round.messages()[0].cohort(), MemberCohort::Current); + assert_eq!(round.messages()[1].cohort(), MemberCohort::Recipients); + assert_eq!(round.messages()[0].statement(), &messages[0]); + let mut forged = messages.clone(); + forged[1].signature = signers.sign(&Signers::did(1), &ceremony_message_preimage(&forged[1].bytes).unwrap()); + assert!(matches!(ClosedRound::::verify(&ceremony, 1, &prev, &close(&forged), &forged, &signers), Err(Error::InvalidSignature))); + let mut stale = body.clone(); stale.current = Some(CertDigest::new([99; 32])); + assert!(AuthorizedCeremony::verify_for(¤t, &signers.statement(&stale), &signers).is_err()); + let (rekey, _, rekey_ceremony) = crate::cert::tests::successor(&signers, ¤t, CeremonyKind::Rekey, current.key_context().threshold(), 12); + assert!(ceremony_message_body(&rekey_ceremony, 1, MemberCohort::Current, index, &[7]).is_err()); + assert!(crate::cert::verify_successor(¤t, &rekey, &signers).is_ok()); + } +} + +#[cfg(test)] +mod bounds_tests { + use super::*; + use super::tests::Signers; + + struct Payload; + impl Decode for Payload { + fn decode(_: &[u8]) -> Result { Ok(Self) } + } + + #[test] + fn round_byte_and_signature_caps_precede_authentication() { + let signers = Signers::new(); + let c = signers.genesis(); + let index = MemberIndex::new(1).unwrap(); + assert!(ceremony_message_body(&c, 1, MemberCohort::Recipients, index, &vec![0; 65_536]).is_err()); + let bytes = ceremony_message_body(&c, 1, MemberCohort::Recipients, index, &[0; 60_000]).unwrap(); + let message = SignedStatement { signature: signers.sign(&Signers::did(1), &ceremony_message_preimage(&bytes).unwrap()), bytes }; + let prev = TranscriptHash::new([0; 32]); + assert!(round_closure_body(&c, 1, &prev, &vec![message.clone(); 70]).is_err()); + let mut oversized = message.clone(); oversized.signature = vec![0; 513]; + assert!(round_closure_body(&c, 1, &prev, &[oversized]).is_err()); + let mut malformed = message.clone(); malformed.bytes.push(0); + let mut closure = SignedStatement { bytes: vec![0; 98], signature: vec![0; 64] }; + assert!(ClosedRound::::verify(&c, 1, &prev, &closure, &[malformed], &signers).is_err()); + closure.bytes = vec![0; 65_537]; + assert!(ClosedRound::::verify(&c, 1, &prev, &closure, &[message], &signers).is_err()); + } +} diff --git a/tinycloud-threshold/src/cert.rs b/tinycloud-threshold/src/cert.rs new file mode 100644 index 00000000..b8df7397 --- /dev/null +++ b/tinycloud-threshold/src/cert.rs @@ -0,0 +1,213 @@ +#[cfg(test)] +use crate::{context::KeyContext, curve::{Scalar, G2}, derive::{KeyShare, MasterPublicKey}, ids::*}; + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::ceremony::{tests::Signers, CeremonyKind, CeremonyStatement}; + + pub(crate) fn material(seed: u8, threshold: Threshold) -> (PublicMaterial, Vec) { + let context = KeyContext::test( + Signers::new().anchor().network, + RekeyCounter::new(0), Epoch::new(0), CertDigest::new([0; 32]), threshold, + ); + let (public, shares, _) = crate::checkpoint::dealer(seed, context); + let constant = public.mpk.0.clone(); + let first = public.public_shares[0].1.sub(&constant); + let commitments = if threshold.t() == 2 { + vec![constant, first] + } else { + assert_eq!(threshold.t(), 3); + let second = public.public_shares[1].1.sub(&constant); + let two = Scalar::from_u64(2); + let quadratic = second.sub(&first.mul(&two)).mul(&two.inverse().unwrap()); + let linear = first.sub(&quadratic); + vec![constant, linear, quadratic] + }; + let material = PublicMaterial::from_commitments(threshold, commitments).unwrap(); + for (i, pk) in &material.public_shares { + assert_eq!(pk, &public.public_shares.iter().find(|(j, _)| j == i).unwrap().1); + } + (material, shares) + } + pub(crate) fn sign(body: CertificateBody, signers: &Signers) -> SignedCertificate { + let owner_signature = signers.sign(&body.owner, &body.owner_statement()); + SignedCertificate { body, owner_signature } + } + pub(crate) fn genesis(signers: &Signers) -> (VerifiedCertificate, Vec) { + let (public, raw) = material(11, Threshold::new(2, 3).unwrap()); + let transcript = TranscriptHash::new([51; 32]); + let body = CertificateBody::genesis(&signers.anchor(), &signers.genesis(), &public, &transcript).unwrap(); + let cert = verify_certificate(&signers.anchor(), &sign(body, signers), signers).unwrap(); + let bound = raw.into_iter().map(|mut raw| { + let scalar = core::mem::replace(&mut raw.scalar, Scalar::from_u64(0)); + KeyShare::bind(CandidateShare::new(transcript, raw.index, scalar), &cert).unwrap() + }).collect(); + (cert, bound) + } + pub(crate) fn successor( + signers: &Signers, prev: &VerifiedCertificate, kind: CeremonyKind, + threshold: Threshold, seed: u8, + ) -> (SignedCertificate, Vec, AuthorizedCeremony) { + let mut stmt = signers.body(kind, threshold); + stmt.id = CeremonyId::new([52; 32]); + stmt.current = Some(prev.digest()); + stmt.epoch = prev.body.epoch.checked_next().unwrap(); + stmt.rekey = if kind == CeremonyKind::Rekey { prev.body.rekey.checked_next().unwrap() } else { prev.body.rekey }; + let ceremony = AuthorizedCeremony::verify_for(prev, &signers.statement(&stmt), signers).unwrap(); + let (public, shares) = material(seed, threshold); + let body = CertificateBody::successor(prev, &ceremony, &public, &TranscriptHash::new([53; 32])).unwrap(); + (sign(body, signers), shares, ceremony) + } + + #[test] + fn dealer_certificate_chain_preserves_master_until_rekey_and_binds_candidates() { + let signers = Signers::new(); + let (genesis, shares) = genesis(&signers); + assert_eq!(shares[0].context(), &genesis.key_context()); + let (reshare, raw, _) = successor(&signers, &genesis, CeremonyKind::Reshare, Threshold::new(3, 4).unwrap(), 11); + let reshare = verify_successor(&genesis, &reshare, &signers).unwrap(); + assert_eq!(reshare.public_key_set().master_public_key(), genesis.public_key_set().master_public_key()); + assert_eq!(reshare.key_context().epoch().get(), 1); + assert_eq!(reshare.key_context().threshold(), Threshold::new(3, 4).unwrap()); + for mut raw in raw { + let scalar = core::mem::replace(&mut raw.scalar, Scalar::from_u64(0)); + assert!(KeyShare::bind(CandidateShare::new(reshare.body.transcript, raw.index, scalar), &reshare).is_ok()); + } + let (rekey, _, _) = successor(&signers, &reshare, CeremonyKind::Rekey, Threshold::new(2, 3).unwrap(), 12); + let rekey = verify_successor(&reshare, &rekey, &signers).unwrap(); + assert_ne!(rekey.public_key_set().master_public_key(), reshare.public_key_set().master_public_key()); + assert_eq!(rekey.key_context().epoch().get(), 2); + assert_eq!(rekey.key_context().rekey().get(), 1); + assert!(verify_successor(&rekey, &sign(genesis.body.clone(), &signers), &signers).is_err()); + assert!(KeyShare::bind(CandidateShare::new(genesis.body.transcript, shares[0].index, Scalar::from_u64(1)), &genesis).is_err()); + assert!(KeyShare::bind(CandidateShare::new(TranscriptHash::new([99; 32]), shares[0].index, Scalar::from_u64(1)), &genesis).is_err()); + } + + #[test] + fn decoded_certificate_invariants_apply_before_verified_contexts_exist() { + let signers = Signers::new(); + let (cert, _) = genesis(&signers); + assert_eq!(CertificateBody::decode(&cert.body.encode()).unwrap().encode(), cert.body.encode()); + let threshold_offset = 1 + 4 + cert.body.network.as_bytes().len() + + 4 + cert.body.owner.as_bytes().len() + 8 + 8 + 2; + for (t, n) in [(1u16, 3u16), (4, 3), (2, 17)] { + let mut bytes = cert.body.encode(); + bytes[threshold_offset..threshold_offset + 2].copy_from_slice(&t.to_be_bytes()); + bytes[threshold_offset + 2..threshold_offset + 4].copy_from_slice(&n.to_be_bytes()); + assert!(CertificateBody::decode(&bytes).is_err()); + } + for mutation in 0..9 { + let mut body = cert.body.clone(); + match mutation { + 0 => body.members.swap(0, 1), + 1 => body.members[1].node = body.members[0].node.clone(), + 2 => body.public.commitments.pop().map(|_| ()).unwrap(), + 3 => body.public.commitments[0] = G2::generator(), + 4 => body.public.commitments[1] = G2::identity(), + 5 => body.epoch = Epoch::new(1), + 6 => body.rekey = RekeyCounter::new(1), + 7 => body.genesis_t = 1, + _ => body.previous_mpk = Some(body.public.mpk.clone()), + } + assert!(CertificateBody::decode(&body.encode()).is_err()); + } + let mut body = cert.body.clone(); + body.public.commitments = vec![G2::generator(), G2::identity().sub(&G2::generator())]; + body.public.mpk = MasterPublicKey(G2::generator()); + assert!(CertificateBody::decode(&body.encode()).is_err()); + let mut bytes = cert.body.encode(); bytes.push(0); + assert!(CertificateBody::decode(&bytes).is_err()); + for length in 0..cert.body.encode().len() { + assert!(CertificateBody::decode(&cert.body.encode()[..length]).is_err()); + } + let mut impostor = cert.body.clone(); impostor.owner = Signers::did(9); + assert!(matches!(verify_certificate(&signers.anchor(), &sign(impostor, &signers), &signers), Err(CertError::AnchorMismatch))); + } + + #[test] + fn pinned_lineage_checks_every_generation_field_and_overflow() { + let signers = Signers::new(); + let (prev, _) = genesis(&signers); + let (valid, _, _) = successor(&signers, &prev, CeremonyKind::Reshare, Threshold::new(2, 3).unwrap(), 11); + for mutation in 0..6 { + let mut body = valid.body.clone(); + match mutation { + 0 => body.previous = Some(CertDigest::new([99; 32])), + 1 => body.epoch = Epoch::new(2), + 2 => body.rekey = RekeyCounter::new(1), + 3 => body.genesis_t = 3, + 4 => body.public = material(12, body.public.threshold).0, + _ => body.network = NetworkUrn::new("other").unwrap(), + } + assert!(verify_successor(&prev, &sign(body, &signers), &signers).is_err()); + } + let (rekey, _, _) = successor(&signers, &prev, CeremonyKind::Rekey, Threshold::new(2, 3).unwrap(), 12); + let mut missing = rekey.body.clone(); missing.previous_mpk = None; + assert!(verify_successor(&prev, &sign(missing, &signers), &signers).is_err()); + let mut body = valid.body.clone(); body.epoch = Epoch::new(u64::MAX); + let max = verify_certificate(&signers.anchor(), &sign(body, &signers), &signers).unwrap(); + assert!(matches!(verify_successor(&max, &valid, &signers), Err(CertError::Invalid(Error::CounterOverflow)))); + let mut body = rekey.body.clone(); body.epoch = Epoch::new(u64::MAX); body.rekey = RekeyCounter::new(u64::MAX); + let max = verify_certificate(&signers.anchor(), &sign(body, &signers), &signers).unwrap(); + let mut stmt: CeremonyStatement = signers.body(CeremonyKind::Rekey, Threshold::new(2, 3).unwrap()); + stmt.current = Some(max.digest()); + assert!(matches!(AuthorizedCeremony::verify_for(&max, &signers.statement(&stmt), &signers), Err(Error::CounterOverflow))); + } +} + +#[cfg(test)] +mod smoke { + use super::*; + use crate::{ + activation::{activation_preimage, readiness_preimage, verify_activation, ReadinessReceipt, ReadyQuorum}, + ceremony::tests::Signers, + fault::{verify_fault_proof, FaultProof, FaultVerdict, NotAFaultReason}, + *, + }; + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + + #[test] + fn certificate_authorization_share_fault_activation_smoke() { + let signers = Signers::new(); + let ceremony = signers.genesis(); + let (cert, shares) = tests::genesis(&signers); + assert_eq!(ceremony.threshold(), cert.key_context().threshold()); + println!("1. PASS: owner-signed ceremony and anchored test-dealer certificate"); + let mut rng = ChaCha20Rng::from_seed([71; 32]); + let tsk = TransportSecretKey::generate(&mut rng); + let request = DecryptRequest::new( + &cert.key_context(), crate::checkpoint::identity(&cert.key_context(), "smoke/key"), tsk.public_key(), + ).unwrap(); + let auth = crate::checkpoint::authorize(request.clone()); + let issued: Vec<_> = shares.iter().map(|s| issue_share(s, &auth, &mut rng).unwrap()).collect(); + let encrypted = combine(cert.public_key_set(), &request, &issued).unwrap(); + let derived = decrypt_derived_key(&encrypted, &tsk, cert.public_key_set(), &request).unwrap(); + let data = DataKey::new([0x42; 32]); + let wrapped = wrap_key(cert.public_key_set().master_public_key(), request.identity(), &data, &mut rng).unwrap(); + assert_eq!(unwrap_key(&derived, request.identity(), &wrapped).unwrap().as_bytes(), data.as_bytes()); + println!("2. PASS: certificate-bound shares issue, combine, derive and unwrap"); + let mut bad = issued[0].clone(); + bad.c3 = bad.c3.add(&crate::curve::G1::generator()); + let signature = signers.sign(&Signers::did(1), &bad.signing_preimage(&request).unwrap()); + let proof = FaultProof { request: request.clone(), share: bad, signature }; + assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::Fault(shares[0].index())); + println!("3. PASS: genuine correctly bound signed bad response attributes its signer"); + let (next, _, _) = tests::successor(&signers, &cert, crate::ceremony::CeremonyKind::Rekey, cert.key_context().threshold(), 12); + let next = verify_successor(&cert, &next, &signers).unwrap(); + assert_eq!(verify_fault_proof(&next, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); + println!("4. PASS: old response under re-keyed certificate is NotAFault"); + let receipts: Vec<_> = shares.iter().map(|share| ReadinessReceipt { + index: share.index(), + signature: signers.sign(&cert.member(share.index()).unwrap().node, &readiness_preimage(cert.body(), share.index()).unwrap()), + }).collect(); + let bytes = activation_preimage(cert.body(), &receipts).unwrap(); + let activation = crate::ceremony::SignedStatement { + signature: signers.sign(&cert.anchor().expected_owner, &bytes), bytes, + }; + verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &activation, &signers).unwrap(); + assert!(verify_activation(&cert, &receipts[..2], ReadyQuorum::AllHolders, &activation, &signers).is_err()); + println!("5. PASS: all-holder receipts activate; under-quorum receipt set does not"); + } +} diff --git a/tinycloud-threshold/src/fault.rs b/tinycloud-threshold/src/fault.rs new file mode 100644 index 00000000..12e1b433 --- /dev/null +++ b/tinycloud-threshold/src/fault.rs @@ -0,0 +1,44 @@ +#[cfg(test)] +use crate::derive::DecryptRequest; + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ceremony::{tests::Signers, CeremonyKind}, cert::tests, curve::{G1, G2}, ids::Threshold}; + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + + #[test] + fn attribution_requires_exact_request_context_and_a_real_member_signature() { + let signers = Signers::new(); + let (cert, shares) = tests::genesis(&signers); + let mut rng = ChaCha20Rng::from_seed([61; 32]); + let tsk = crate::TransportSecretKey::generate(&mut rng); + let req = DecryptRequest::new(&cert.key_context(), crate::checkpoint::identity(&cert.key_context(), "fault/key"), tsk.public_key()).unwrap(); + let valid = crate::issue_share(&shares[0], &crate::checkpoint::authorize(req.clone()), &mut rng).unwrap(); + let mut proof = FaultProof { signature: signers.sign(&Signers::did(1), &valid.signing_preimage(&req).unwrap()), request: req.clone(), share: valid.clone() }; + assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ValidShare)); + for equation in 0..2 { + proof.share = valid.clone(); + if equation == 0 { proof.share.c2 = proof.share.c2.add(&G2::generator()); } + else { proof.share.c3 = proof.share.c3.add(&G1::generator()); } + proof.signature = signers.sign(&Signers::did(1), &proof.share.signing_preimage(&req).unwrap()); + assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::Fault(shares[0].index())); + } + proof.signature[0] ^= 1; + assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::InvalidSignature)); + proof.share = valid.clone(); + proof.signature = signers.sign(&Signers::did(1), &valid.signing_preimage(&req).unwrap()); + let (next, _, _) = tests::successor(&signers, &cert, CeremonyKind::Rekey, Threshold::new(2, 3).unwrap(), 12); + let next = crate::cert::verify_successor(&cert, &next, &signers).unwrap(); + assert_eq!(verify_fault_proof(&next, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); + proof.request = DecryptRequest::new(&cert.key_context(), crate::checkpoint::identity(&cert.key_context(), "different/key"), tsk.public_key()).unwrap(); + assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); + assert!(matches!(proof.share.signing_preimage(&proof.request), Err(crate::Error::ContextMismatch))); + proof.request = DecryptRequest::new(&cert.key_context(), crate::checkpoint::identity(&cert.key_context(), "fault/key"), crate::TransportSecretKey::generate(&mut rng).public_key()).unwrap(); + assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); + proof.request = req; + proof.share.member = crate::ids::MemberIndex::new(4).unwrap(); + assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); + } +} From 90ef643cc13e607cf19e1f1803a74cc5c78d9a83 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:33:15 +0000 Subject: [PATCH 2/5] feat(threshold): add authenticated certificate ceremonies. Details: TC-2222 --- tinycloud-threshold/src/activation.rs | 415 ++++++- tinycloud-threshold/src/ceremony.rs | 857 +++++++++++++- tinycloud-threshold/src/cert.rs | 1054 ++++++++++++++++- tinycloud-threshold/src/fault.rs | 231 +++- tinycloud-threshold/vectors/v1/cert-core.json | 515 ++++++++ 5 files changed, 2907 insertions(+), 165 deletions(-) create mode 100644 tinycloud-threshold/vectors/v1/cert-core.json diff --git a/tinycloud-threshold/src/activation.rs b/tinycloud-threshold/src/activation.rs index 2512f666..d0a660ad 100644 --- a/tinycloud-threshold/src/activation.rs +++ b/tinycloud-threshold/src/activation.rs @@ -1,5 +1,168 @@ -#[cfg(test)] -use crate::ids::MemberIndex; +//! Candidate-bound readiness claims and owner-authorized activation. Details: TC-2222 + +use crate::{ + ceremony::{verify_signature, DidSignatureVerifier, SignedStatement}, + cert::{CertificateBody, VerifiedCertificate}, + dst, + encoding::Encoder, + error::Error, + ids::{CertDigest, MemberIndex, TranscriptHash}, +}; + +/// Member-signed claim of durable candidate installation. Details: TC-2222 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReadinessReceipt { + /// Candidate member coordinate. Details: TC-2222 + pub index: MemberIndex, + /// Node signature over the exact candidate readiness preimage. Details: TC-2222 + pub signature: Vec, +} + +/// Explicit ready-holder quorum policy. Details: TC-2222 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReadyQuorum { + /// Every new holder must supply a receipt. Details: TC-2222 + AllHolders, + /// Require t plus this positive total unavailable-ready-signer budget, including dishonest claims. Details: TC-2222 + WithOutageBudget(u16), +} + +/// Exact candidate/transcript/member/public-share readiness signing preimage. Details: TC-2222 +pub fn readiness_preimage( + candidate: &CertificateBody, + index: MemberIndex, +) -> Result, Error> { + let point = candidate.public_material().public_share(index)?; + Ok(readiness_hash( + &candidate.digest(), + candidate.transcript_hash(), + index, + &point, + ) + .to_vec()) +} + +fn readiness_hash( + digest: &CertDigest, + transcript: TranscriptHash, + index: MemberIndex, + point: &[u8; 96], +) -> [u8; 32] { + let mut e = Encoder::new(); + e.fixed(digest.as_bytes()); + e.fixed(transcript.as_bytes()); + e.u16(index.get()); + e.fixed(point); + dst::tagged_hash(dst::READY, &e.finish()) +} + +fn receipt_indices( + candidate: &CertificateBody, + receipts: &[ReadinessReceipt], +) -> Result<(), Error> { + if receipts.len() > usize::from(candidate.public_material().threshold().n()) { + return Err(Error::InvalidReadiness); + } + let mut seen = [false; 16]; + for receipt in receipts { + if !candidate.members().iter().any(|m| m.index == receipt.index) + || receipt.signature.is_empty() + || receipt.signature.len() > 512 + { + return Err(Error::InvalidReadiness); + } + let position = usize::from(receipt.index.get()) + .checked_sub(1) + .ok_or(Error::InvalidReadiness)?; + let entry = seen.get_mut(position).ok_or(Error::InvalidReadiness)?; + if *entry { + return Err(Error::InvalidReadiness); + } + *entry = true; + } + Ok(()) +} + +fn activation_hash(digest: &CertDigest, receipts: &[ReadinessReceipt]) -> Vec { + let mut hashes: Vec<_> = receipts + .iter() + .map(|receipt| { + let mut e = Encoder::new(); + e.u8(1); + e.u16(receipt.index.get()); + e.lp(&receipt.signature); + dst::tagged_hash(dst::READY, &e.finish()) + }) + .collect(); + hashes.sort_unstable(); + let mut e = Encoder::new(); + e.fixed(digest.as_bytes()); + for hash in hashes { + e.fixed(&hash); + } + dst::tagged_hash(dst::ACTIVATE, &e.finish()).to_vec() +} + +/// Build the owner's 32-byte activation preimage; this helper does not authenticate receipt claims. Details: TC-2222 +pub fn activation_preimage( + candidate: &CertificateBody, + receipts: &[ReadinessReceipt], +) -> Result, Error> { + receipt_indices(candidate, receipts)?; + Ok(activation_hash(&candidate.digest(), receipts)) +} + +/// Verify every unique member claim before quorum counting, hashing and owner approval. Details: TC-2222 +pub fn verify_activation( + candidate: &VerifiedCertificate, + receipts: &[ReadinessReceipt], + q: ReadyQuorum, + activation: &SignedStatement, + v: &impl DidSignatureVerifier, +) -> Result<(), Error> { + receipt_indices(candidate.body(), receipts)?; + let digest = candidate.digest(); + let transcript = candidate.body().transcript_hash(); + for receipt in receipts { + let member = candidate + .member(receipt.index) + .map_err(|_| Error::InvalidReadiness)?; + let point = candidate + .body() + .public_material() + .public_share(receipt.index)?; + let preimage = readiness_hash(&digest, transcript, receipt.index, &point); + verify_signature(v, &member.node, &preimage, &receipt.signature)?; + } + let threshold = candidate.public_key_set().context().threshold(); + let required = match q { + ReadyQuorum::AllHolders => threshold.n(), + ReadyQuorum::WithOutageBudget(0) => return Err(Error::InvalidReadiness), + ReadyQuorum::WithOutageBudget(budget) => { + let required = threshold + .t() + .checked_add(budget) + .ok_or(Error::InvalidReadiness)?; + if required > threshold.n() { + return Err(Error::InvalidReadiness); + } + required + } + }; + if receipts.len() < usize::from(required) { + return Err(Error::NotEnoughReady); + } + let expected = activation_hash(&digest, receipts); + if activation.bytes != expected { + return Err(Error::InvalidReadiness); + } + verify_signature( + v, + &candidate.anchor().expected_owner, + &expected, + &activation.signature, + ) +} #[cfg(test)] mod tests { @@ -7,14 +170,29 @@ mod tests { use crate::{ceremony::tests::Signers, cert::tests}; fn receipts(cert: &VerifiedCertificate, signers: &Signers) -> Vec { - (1..=cert.key_context().threshold().n()).map(|i| { - let index = MemberIndex::new(i).unwrap(); - ReadinessReceipt { index, signature: signers.sign(&Signers::did(i as u8), &readiness_preimage(cert.body(), index).unwrap()) } - }).collect() + (1..=cert.key_context().threshold().n()) + .map(|i| { + let index = MemberIndex::new(i).unwrap(); + ReadinessReceipt { + index, + signature: signers.sign( + &Signers::did(i as u8), + &readiness_preimage(cert.body(), index).unwrap(), + ), + } + }) + .collect() } - fn activation(cert: &VerifiedCertificate, receipts: &[ReadinessReceipt], signers: &Signers) -> SignedStatement { + fn activation( + cert: &VerifiedCertificate, + receipts: &[ReadinessReceipt], + signers: &Signers, + ) -> SignedStatement { let bytes = activation_preimage(cert.body(), receipts).unwrap(); - SignedStatement { signature: signers.sign(&cert.anchor().expected_owner, &bytes), bytes } + SignedStatement { + signature: signers.sign(&cert.anchor().expected_owner, &bytes), + bytes, + } } #[test] @@ -23,37 +201,216 @@ mod tests { let (cert, _) = tests::genesis(&signers); let mut receipts = receipts(&cert, &signers); let signed = activation(&cert, &receipts, &signers); - assert!(verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &signed, &signers).is_ok()); + assert!( + verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &signed, &signers).is_ok() + ); receipts.reverse(); - assert!(verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &signed, &signers).is_ok()); - assert!(verify_activation(&cert, &receipts[..2], ReadyQuorum::AllHolders, &signed, &signers).is_err()); - assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(0), &signed, &signers).is_err()); - assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(1), &signed, &signers).is_ok()); - assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(2), &signed, &signers).is_err()); - assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(u16::MAX), &signed, &signers).is_err()); - let duplicate = vec![receipts[0].clone(), receipts[0].clone(), receipts[1].clone()]; - assert!(verify_activation(&cert, &duplicate, ReadyQuorum::AllHolders, &signed, &signers).is_err()); - let mut wrong = receipts.clone(); wrong[0].index = MemberIndex::new(4).unwrap(); - assert!(verify_activation(&cert, &wrong, ReadyQuorum::AllHolders, &signed, &signers).is_err()); - let mut wrong = receipts.clone(); wrong[0].signature[0] ^= 1; - assert!(verify_activation(&cert, &wrong, ReadyQuorum::AllHolders, &signed, &signers).is_err()); - let mut wrong = signed.clone(); wrong.signature[0] ^= 1; - assert!(verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &wrong, &signers).is_err()); - let (other, _, _) = tests::successor(&signers, &cert, crate::ceremony::CeremonyKind::Refresh, cert.key_context().threshold(), 11); + assert!( + verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &signed, &signers).is_ok() + ); + assert!(verify_activation( + &cert, + &receipts[..2], + ReadyQuorum::AllHolders, + &signed, + &signers + ) + .is_err()); + assert!(verify_activation( + &cert, + &receipts, + ReadyQuorum::WithOutageBudget(0), + &signed, + &signers + ) + .is_err()); + assert!(verify_activation( + &cert, + &receipts, + ReadyQuorum::WithOutageBudget(1), + &signed, + &signers + ) + .is_ok()); + assert!(verify_activation( + &cert, + &receipts, + ReadyQuorum::WithOutageBudget(2), + &signed, + &signers + ) + .is_err()); + assert!(verify_activation( + &cert, + &receipts, + ReadyQuorum::WithOutageBudget(u16::MAX), + &signed, + &signers + ) + .is_err()); + let duplicate = vec![ + receipts[0].clone(), + receipts[0].clone(), + receipts[1].clone(), + ]; + assert!(verify_activation( + &cert, + &duplicate, + ReadyQuorum::AllHolders, + &signed, + &signers + ) + .is_err()); + let mut wrong = receipts.clone(); + wrong[0].index = MemberIndex::new(4).unwrap(); + assert!( + verify_activation(&cert, &wrong, ReadyQuorum::AllHolders, &signed, &signers).is_err() + ); + let mut wrong = receipts.clone(); + wrong[0].signature[0] ^= 1; + assert!( + verify_activation(&cert, &wrong, ReadyQuorum::AllHolders, &signed, &signers).is_err() + ); + let mut wrong = signed.clone(); + wrong.signature[0] ^= 1; + assert!( + verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &wrong, &signers).is_err() + ); + let (other, _, _) = tests::successor( + &signers, + &cert, + crate::ceremony::CeremonyKind::Refresh, + cert.key_context().threshold(), + 11, + ); let other = crate::cert::verify_successor(&cert, &other, &signers).unwrap(); - assert!(verify_activation(&other, &receipts, ReadyQuorum::AllHolders, &signed, &signers).is_err()); + assert!(verify_activation( + &other, + &receipts, + ReadyQuorum::AllHolders, + &signed, + &signers + ) + .is_err()); } #[test] fn declared_budget_counts_every_unavailable_ready_holder() { let signers = Signers::new(); let (genesis, _) = tests::genesis(&signers); - let (candidate, _, _) = tests::successor(&signers, &genesis, crate::ceremony::CeremonyKind::Reshare, crate::ids::Threshold::new(3, 4).unwrap(), 11); + let (candidate, _, _) = tests::successor( + &signers, + &genesis, + crate::ceremony::CeremonyKind::Reshare, + crate::ids::Threshold::new(3, 4).unwrap(), + 11, + ); let cert = crate::cert::verify_successor(&genesis, &candidate, &signers).unwrap(); let receipts = receipts(&cert, &signers); let signed = activation(&cert, &receipts, &signers); - assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(1), &signed, &signers).is_ok()); - assert!(verify_activation(&cert, &receipts, ReadyQuorum::WithOutageBudget(2), &signed, &signers).is_err()); - assert!(verify_activation(&cert, &receipts[..3], ReadyQuorum::WithOutageBudget(1), &signed, &signers).is_err()); + assert!(verify_activation( + &cert, + &receipts, + ReadyQuorum::WithOutageBudget(1), + &signed, + &signers + ) + .is_ok()); + assert!(verify_activation( + &cert, + &receipts, + ReadyQuorum::WithOutageBudget(2), + &signed, + &signers + ) + .is_err()); + assert!(verify_activation( + &cert, + &receipts[..3], + ReadyQuorum::WithOutageBudget(1), + &signed, + &signers + ) + .is_err()); + } +} + +#[cfg(test)] +mod verification_order_tests { + use super::*; + use crate::ceremony::tests::Signers; + use crate::error::SignatureError; + use crate::ids::Did; + use std::cell::Cell; + + struct Observed<'a> { + signers: &'a Signers, + calls: Cell, + } + impl DidSignatureVerifier for Observed<'_> { + fn verify( + &self, + signer: &Did, + message: &[u8], + signature: &[u8], + ) -> Result<(), SignatureError> { + self.calls.set(self.calls.get() + 1); + self.signers.verify(signer, message, signature) + } + } + #[test] + fn under_quorum_or_zero_budget_never_skips_supplied_member_signatures() { + let signers = Signers::new(); + let (cert, _) = crate::cert::tests::genesis(&signers); + let receipt = ReadinessReceipt { + index: MemberIndex::new(1).unwrap(), + signature: signers.sign( + &Signers::did(1), + &readiness_preimage(cert.body(), MemberIndex::new(1).unwrap()).unwrap(), + ), + }; + let observed = Observed { + signers: &signers, + calls: Cell::new(0), + }; + let dummy = SignedStatement { + bytes: vec![0; 32], + signature: vec![0; 64], + }; + assert_eq!( + verify_activation( + &cert, + std::slice::from_ref(&receipt), + ReadyQuorum::AllHolders, + &dummy, + &observed + ), + Err(Error::NotEnoughReady) + ); + assert_eq!(observed.calls.get(), 1); + observed.calls.set(0); + assert_eq!( + verify_activation( + &cert, + std::slice::from_ref(&receipt), + ReadyQuorum::WithOutageBudget(0), + &dummy, + &observed + ), + Err(Error::InvalidReadiness) + ); + assert_eq!(observed.calls.get(), 1); + observed.calls.set(0); + assert_eq!( + verify_activation( + &cert, + &[receipt.clone(), receipt], + ReadyQuorum::AllHolders, + &dummy, + &observed + ), + Err(Error::InvalidReadiness) + ); + assert_eq!(observed.calls.get(), 0); } } diff --git a/tinycloud-threshold/src/ceremony.rs b/tinycloud-threshold/src/ceremony.rs index 911ec0a8..5d044c95 100644 --- a/tinycloud-threshold/src/ceremony.rs +++ b/tinycloud-threshold/src/ceremony.rs @@ -1,5 +1,572 @@ -#[cfg(test)] -use crate::{error::{Error, SignatureError}, ids::*}; +//! Authenticated ceremony directories and owner-ordered closed rounds. Details: TC-2222 + +use crate::{ + cert::{CertMember, VerifiedCertificate}, + dst, + encoding::{Decoder, Encoder}, + error::{Error, SignatureError}, + ids::*, +}; + +const MESSAGE_CAP: usize = 65_536; +const TRANSCRIPT_CAP: usize = 4_194_304; +const SIGNATURE_CAP: usize = 512; + +/// Caller-supplied authorized owner and network. Details: TC-2222 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrustAnchor { + /// Expected space owner. Details: TC-2222 + pub expected_owner: Did, + /// Expected network. Details: TC-2222 + pub network: NetworkUrn, +} + +/// Signature verification supplied by the integrating authentication layer. Details: TC-2222 +pub trait DidSignatureVerifier { + /// Verify exactly these bytes for the specified DID. Details: TC-2222 + fn verify(&self, signer: &Did, message: &[u8], signature: &[u8]) -> Result<(), SignatureError>; +} + +/// Unauthenticated statement body and signature. Details: TC-2222 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SignedStatement { + /// Canonical statement body, or the activation's 32-byte preimage. Details: TC-2222 + pub bytes: Vec, + /// Signature under the statement's resolved signer. Details: TC-2222 + pub signature: Vec, +} + +/// Owner-authorized ceremony operation. Details: TC-2222 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum CeremonyKind { + /// First master key. Details: TC-2222 + Genesis = 1, + /// Redistribution to an authorized cohort. Details: TC-2222 + Reshare = 2, + /// Redistribution at the next epoch. Details: TC-2222 + Refresh = 3, + /// Replacement master key. Details: TC-2222 + Rekey = 4, +} + +/// Recipient identity and ceremony-scoped HPKE public key. Details: TC-2222 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CeremonyRecipient { + /// Shamir coordinate. Details: TC-2222 + pub index: MemberIndex, + /// Recipient node identity. Details: TC-2222 + pub node: Did, + /// Exactly 32 public-key bytes, authenticated independently of HPKE transport. Details: TC-2222 + pub hpke_public_key: [u8; 32], +} + +/// Raw owner statement; authentication requires the external anchor or pinned predecessor. Details: TC-2222 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CeremonyStatement { + /// Authorized operation. Details: TC-2222 + pub kind: CeremonyKind, + /// Fresh caller-generated ceremony identifier. Details: TC-2222 + pub id: CeremonyId, + /// Authorized network. Details: TC-2222 + pub network: NetworkUrn, + /// Authorizing owner. Details: TC-2222 + pub owner: Did, + /// Pinned predecessor digest, absent only at genesis. Details: TC-2222 + pub current: Option, + /// New threshold and cohort size. Details: TC-2222 + pub threshold: Threshold, + /// New re-key counter. Details: TC-2222 + pub rekey: RekeyCounter, + /// New epoch. Details: TC-2222 + pub epoch: Epoch, + /// Index-ordered recipient directory. Details: TC-2222 + pub recipients: Vec, +} + +impl CeremonyStatement { + /// Encode the version-one authorization body. Details: TC-2222 + pub fn encode(&self) -> Vec { + let mut e = Encoder::new(); + e.u8(1); + e.u8(self.kind as u8); + e.fixed(self.id.as_bytes()); + e.lp(self.network.as_bytes()); + e.lp(self.owner.as_bytes()); + e.u8(u8::from(self.current.is_some())); + if let Some(current) = self.current { + e.fixed(current.as_bytes()); + } + e.u16(self.threshold.t()); + e.u16(self.threshold.n()); + e.u64(self.rekey.get()); + e.u64(self.epoch.get()); + e.u16(self.recipients.len() as u16); + for r in &self.recipients { + e.u16(r.index.get()); + e.lp(r.node.as_bytes()); + e.fixed(&r.hpke_public_key); + } + e.finish() + } + + /// Decode bounded canonical fields and a complete unique recipient directory. Details: TC-2222 + pub fn decode(bytes: &[u8]) -> Result { + let mut d = Decoder::new(bytes)?; + if d.u8()? != 1 { + return Err(Error::InvalidEncoding); + } + let kind = match d.u8()? { + 1 => CeremonyKind::Genesis, + 2 => CeremonyKind::Reshare, + 3 => CeremonyKind::Refresh, + 4 => CeremonyKind::Rekey, + _ => return Err(Error::InvalidEncoding), + }; + let id = CeremonyId::new(d.fixed()?); + let network = NetworkUrn::new(text(d.lp(1024)?)?)?; + let owner = Did::new(text(d.lp(256)?)?)?; + let current = match d.u8()? { + 0 => None, + 1 => Some(CertDigest::new(d.fixed()?)), + _ => return Err(Error::InvalidEncoding), + }; + let threshold = Threshold::new(d.u16()?, d.u16()?)?; + let rekey = RekeyCounter::new(d.u64()?); + let epoch = Epoch::new(d.u64()?); + let count = d.u16()?; + if count != threshold.n() { + return Err(Error::InvalidCeremony); + } + let mut recipients: Vec = Vec::with_capacity(usize::from(count)); + for expected in 1..=count { + let index = MemberIndex::new(d.u16()?)?; + let node = Did::new(text(d.lp(256)?)?)?; + if index.get() != expected || recipients.iter().any(|r| r.node == node) { + return Err(Error::InvalidCeremony); + } + recipients.push(CeremonyRecipient { + index, + node, + hpke_public_key: d.fixed()?, + }); + } + d.finish()?; + Ok(Self { + kind, + id, + network, + owner, + current, + threshold, + rekey, + epoch, + recipients, + }) + } + + /// Domain-separated owner signing preimage and authorized-ceremony digest. Details: TC-2222 + pub fn signing_preimage(&self) -> Vec { + dst::tagged_hash(dst::CEREMONY_AUTH, &self.encode()).to_vec() + } +} + +pub(crate) fn text(bytes: &[u8]) -> Result<&str, Error> { + core::str::from_utf8(bytes).map_err(|_| Error::InvalidEncoding) +} + +pub(crate) fn verify_signature( + v: &impl DidSignatureVerifier, + signer: &Did, + message: &[u8], + signature: &[u8], +) -> Result<(), Error> { + if signature.is_empty() || signature.len() > SIGNATURE_CAP { + return Err(Error::InvalidSignature); + } + v.verify(signer, message, signature) + .map_err(|_| Error::InvalidSignature) +} + +/// Authenticated ceremony metadata, directory and pinned dealer identities. Details: TC-2222 +#[derive(Clone, Debug)] +pub struct AuthorizedCeremony { + body: CeremonyStatement, + anchor: TrustAnchor, + digest: [u8; 32], + dealers: Vec, +} + +impl AuthorizedCeremony { + /// Verify genesis under the caller's expected owner and network. Details: TC-2222 + pub fn verify_genesis( + anchor: &TrustAnchor, + statement: &SignedStatement, + v: &impl DidSignatureVerifier, + ) -> Result { + let body = CeremonyStatement::decode(&statement.bytes)?; + if body.kind != CeremonyKind::Genesis + || body.current.is_some() + || body.rekey.get() != 0 + || body.epoch.get() != 0 + { + return Err(Error::InvalidCeremony); + } + body.threshold.check_genesis_budget()?; + Self::authenticate(anchor, statement, body, Vec::new(), v) + } + + /// Verify the exact successor of the certificate pinned by the caller's log head. Details: TC-2222 + pub fn verify_for( + current: &VerifiedCertificate, + statement: &SignedStatement, + v: &impl DidSignatureVerifier, + ) -> Result { + let body = CeremonyStatement::decode(&statement.bytes)?; + let context = current.public_key_set().context(); + let epoch = context.epoch().checked_next()?; + let rekey = if body.kind == CeremonyKind::Rekey { + body.threshold.check_genesis_budget()?; + context.rekey().checked_next()? + } else { + context.rekey() + }; + if body.kind == CeremonyKind::Genesis + || body.current != Some(current.digest()) + || body.epoch != epoch + || body.rekey != rekey + { + return Err(Error::InvalidCeremony); + } + Self::authenticate( + current.anchor(), + statement, + body, + current.body().members().to_vec(), + v, + ) + } + + fn authenticate( + anchor: &TrustAnchor, + signed: &SignedStatement, + body: CeremonyStatement, + dealers: Vec, + v: &impl DidSignatureVerifier, + ) -> Result { + if body.owner != anchor.expected_owner || body.network != anchor.network { + return Err(Error::ContextMismatch); + } + let digest = dst::tagged_hash(dst::CEREMONY_AUTH, &signed.bytes); + verify_signature(v, &anchor.expected_owner, &digest, &signed.signature)?; + Ok(Self { + body, + anchor: anchor.clone(), + digest, + dealers, + }) + } + + /// Digest of the canonical owner-authorized body. Details: TC-2222 + pub fn digest(&self) -> [u8; 32] { + self.digest + } + /// Authenticated operation. Details: TC-2222 + pub fn kind(&self) -> CeremonyKind { + self.body.kind + } + /// Authenticated threshold and cohort size. Details: TC-2222 + pub fn threshold(&self) -> Threshold { + self.body.threshold + } + /// Ceremony-scoped recipient directory. Details: TC-2222 + pub fn recipients(&self) -> &[CeremonyRecipient] { + &self.body.recipients + } + /// Authenticated statement fields. Details: TC-2222 + pub fn statement(&self) -> &CeremonyStatement { + &self.body + } + /// External owner/network anchor retained during verification. Details: TC-2222 + pub fn anchor(&self) -> &TrustAnchor { + &self.anchor + } + + fn signer(&self, cohort: MemberCohort, index: MemberIndex) -> Result<&Did, Error> { + match cohort { + MemberCohort::Recipients => self + .body + .recipients + .iter() + .find(|r| r.index == index) + .map(|r| &r.node) + .ok_or(Error::InvalidRound), + MemberCohort::Current => { + if !matches!(self.kind(), CeremonyKind::Reshare | CeremonyKind::Refresh) { + return Err(Error::InvalidRound); + } + self.dealers + .iter() + .find(|d| d.index == index) + .map(|d| &d.node) + .ok_or(Error::InvalidRound) + } + } + } +} + +/// Signed sender role; a coordinate is resolved only within its named cohort. Details: TC-2222 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum MemberCohort { + /// Authorized new recipient directory. Details: TC-2222 + Recipients = 1, + /// Pinned current-certificate dealer directory. Details: TC-2222 + Current = 2, +} + +/// Decoder supplied by a DKG or resharing message type. Details: TC-2222 +pub trait Decode: Sized { + /// Decode only the bounded inner payload; phase rules interpret failure. Details: TC-2222 + fn decode(bytes: &[u8]) -> Result; +} + +struct MessageEnvelope<'a> { + ceremony: [u8; 32], + round: u8, + cohort: MemberCohort, + member: MemberIndex, + payload: &'a [u8], +} + +fn message_envelope(bytes: &[u8]) -> Result, Error> { + let mut d = Decoder::new(bytes)?; + if d.u8()? != 1 { + return Err(Error::InvalidEncoding); + } + let ceremony = d.fixed()?; + let round = d.u8()?; + let cohort = match d.u8()? { + 1 => MemberCohort::Recipients, + 2 => MemberCohort::Current, + _ => return Err(Error::InvalidEncoding), + }; + let member = MemberIndex::new(d.u16()?)?; + let payload = d.lp(MESSAGE_CAP)?; + d.finish()?; + Ok(MessageEnvelope { + ceremony, + round, + cohort, + member, + payload, + }) +} + +/// Encode a member payload with its authorized ceremony, round and sender role. Details: TC-2222 +pub fn ceremony_message_body( + c: &AuthorizedCeremony, + round: u8, + cohort: MemberCohort, + member: MemberIndex, + payload: &[u8], +) -> Result, Error> { + c.signer(cohort, member)?; + if payload.len().checked_add(41).ok_or(Error::LimitExceeded)? > MESSAGE_CAP { + return Err(Error::LimitExceeded); + } + let mut e = Encoder::new(); + e.u8(1); + e.fixed(&c.digest); + e.u8(round); + e.u8(cohort as u8); + e.u16(member.get()); + e.lp(payload); + Ok(e.finish()) +} + +/// Member signature preimage after canonical outer-envelope decoding. Details: TC-2222 +pub fn ceremony_message_preimage(bytes: &[u8]) -> Result, Error> { + message_envelope(bytes)?; + Ok(dst::tagged_hash(dst::CEREMONY_MSG, bytes).to_vec()) +} + +fn message_list_hash(messages: &[SignedStatement]) -> Result<[u8; 32], Error> { + let count = u16::try_from(messages.len()).map_err(|_| Error::LimitExceeded)?; + let mut size = 2usize; + for message in messages { + if message.bytes.len() > MESSAGE_CAP + || message.signature.is_empty() + || message.signature.len() > SIGNATURE_CAP + { + return Err(Error::LimitExceeded); + } + size = size + .checked_add(8) + .and_then(|s| s.checked_add(message.bytes.len())) + .and_then(|s| s.checked_add(message.signature.len())) + .ok_or(Error::LimitExceeded)?; + if size > TRANSCRIPT_CAP { + return Err(Error::LimitExceeded); + } + } + let mut e = Encoder::new(); + e.u16(count); + for message in messages { + e.lp(&message.bytes); + e.lp(&message.signature); + } + Ok(dst::tagged_hash(dst::TRANSCRIPT, &e.finish())) +} + +/// Encode the owner's ordered-list closure and exact preceding transcript hash. Details: TC-2222 +pub fn round_closure_body( + c: &AuthorizedCeremony, + round: u8, + previous: &TranscriptHash, + messages: &[SignedStatement], +) -> Result, Error> { + let hash = message_list_hash(messages)?; + let mut e = Encoder::new(); + e.u8(1); + e.fixed(&c.digest); + e.u8(round); + e.fixed(&hash); + e.fixed(previous.as_bytes()); + Ok(e.finish()) +} + +/// Owner signing preimage after canonical closure decoding. Details: TC-2222 +pub fn round_closure_preimage(bytes: &[u8]) -> Result, Error> { + let mut d = Decoder::new(bytes)?; + if d.u8()? != 1 { + return Err(Error::InvalidEncoding); + } + d.fixed::<32>()?; + d.u8()?; + d.fixed::<32>()?; + d.fixed::<32>()?; + d.finish()?; + Ok(dst::tagged_hash(dst::ROUND_CLOSE, bytes).to_vec()) +} + +/// Authenticated raw message and its independently decoded inner payload. Details: TC-2222 +#[derive(Debug)] +pub struct RoundMessage { + statement: SignedStatement, + member: MemberIndex, + cohort: MemberCohort, + payload: Result, +} +impl RoundMessage { + /// Exact member-signed bytes and signature, retained for phase rules. Details: TC-2222 + pub fn statement(&self) -> &SignedStatement { + &self.statement + } + /// Authenticated member coordinate. Details: TC-2222 + pub fn member(&self) -> MemberIndex { + self.member + } + /// Authenticated sender role. Details: TC-2222 + pub fn cohort(&self) -> MemberCohort { + self.cohort + } + /// Inner decoding result; authenticated malformed bytes remain in statement(). Details: TC-2222 + pub fn payload(&self) -> &Result { + &self.payload + } +} + +/// Owner-closed ordered messages authenticated to one ceremony and preceding hash. Details: TC-2222 +#[derive(Debug)] +pub struct ClosedRound { + closure: SignedStatement, + messages: Vec>, + ceremony: [u8; 32], + round: u8, + previous: TranscriptHash, + transcript: TranscriptHash, +} +impl ClosedRound { + /// Authenticate the owner closure and every member before exposing any authenticated payload. Details: TC-2222 + pub fn verify( + c: &AuthorizedCeremony, + round: u8, + prev: &TranscriptHash, + closure: &SignedStatement, + messages: &[SignedStatement], + v: &impl DidSignatureVerifier, + ) -> Result { + let preimage = round_closure_preimage(&closure.bytes)?; + let expected = round_closure_body(c, round, prev, messages)?; + if closure.bytes != expected { + return Err(Error::InvalidRound); + } + verify_signature(v, &c.anchor.expected_owner, &preimage, &closure.signature)?; + for message in messages { + let envelope = message_envelope(&message.bytes)?; + if envelope.ceremony != c.digest || envelope.round != round { + return Err(Error::InvalidRound); + } + let signer = c.signer(envelope.cohort, envelope.member)?; + verify_signature( + v, + signer, + &dst::tagged_hash(dst::CEREMONY_MSG, &message.bytes), + &message.signature, + )?; + } + let mut entries = Vec::with_capacity(messages.len()); + for message in messages { + let envelope = message_envelope(&message.bytes)?; + entries.push(RoundMessage { + member: envelope.member, + cohort: envelope.cohort, + payload: M::decode(envelope.payload), + statement: message.clone(), + }); + } + let mut e = Encoder::new(); + e.fixed(prev.as_bytes()); + e.lp(&closure.bytes); + e.lp(&closure.signature); + let transcript = TranscriptHash::new(dst::tagged_hash(dst::TRANSCRIPT, &e.finish())); + Ok(Self { + closure: closure.clone(), + messages: entries, + ceremony: c.digest, + round, + previous: *prev, + transcript, + }) + } +} +impl ClosedRound { + /// Authenticated entries in owner order, including retransmissions and equivocations. Details: TC-2222 + pub fn messages(&self) -> &[RoundMessage] { + &self.messages + } + /// Exact owner closure and signature. Details: TC-2222 + pub fn closure(&self) -> &SignedStatement { + &self.closure + } + /// Authorized ceremony digest. Details: TC-2222 + pub fn ceremony_digest(&self) -> [u8; 32] { + self.ceremony + } + /// Closed round number. Details: TC-2222 + pub fn round(&self) -> u8 { + self.round + } + /// Exact prior hash supplied to verification. Details: TC-2222 + pub fn previous_hash(&self) -> TranscriptHash { + self.previous + } + /// Hash of the authenticated closure chained to the previous transcript. Details: TC-2222 + pub fn transcript_hash(&self) -> TranscriptHash { + self.transcript + } +} #[cfg(test)] pub(crate) mod tests { @@ -37,7 +604,8 @@ pub(crate) mod tests { } pub(crate) fn genesis(&self) -> AuthorizedCeremony { let body = self.body(CeremonyKind::Genesis, Threshold::new(2, 3).unwrap()); - AuthorizedCeremony::verify_genesis(&self.anchor(), &self.statement(&body), self).unwrap() + AuthorizedCeremony::verify_genesis(&self.anchor(), &self.statement(&body), self) + .unwrap() } pub(crate) fn body(&self, kind: CeremonyKind, threshold: Threshold) -> CeremonyStatement { CeremonyStatement { @@ -60,10 +628,21 @@ pub(crate) mod tests { } } impl DidSignatureVerifier for Signers { - fn verify(&self, signer: &Did, message: &[u8], signature: &[u8]) -> Result<(), SignatureError> { - let key: VerifyingKey = self.keys.get(signer).ok_or(SignatureError::Invalid)?.verifying_key(); - let signature = ed25519_dalek::Signature::from_slice(signature).map_err(|_| SignatureError::Invalid)?; - key.verify_strict(message, &signature).map_err(|_| SignatureError::Invalid) + fn verify( + &self, + signer: &Did, + message: &[u8], + signature: &[u8], + ) -> Result<(), SignatureError> { + let key: VerifyingKey = self + .keys + .get(signer) + .ok_or(SignatureError::Invalid)? + .verifying_key(); + let signature = ed25519_dalek::Signature::from_slice(signature) + .map_err(|_| SignatureError::Invalid)?; + key.verify_strict(message, &signature) + .map_err(|_| SignatureError::Invalid) } } @@ -72,7 +651,8 @@ pub(crate) mod tests { let signers = Signers::new(); let body = signers.body(CeremonyKind::Genesis, Threshold::new(2, 3).unwrap()); let signed = signers.statement(&body); - let verified = AuthorizedCeremony::verify_genesis(&signers.anchor(), &signed, &signers).unwrap(); + let verified = + AuthorizedCeremony::verify_genesis(&signers.anchor(), &signed, &signers).unwrap(); assert_eq!(verified.threshold(), body.threshold); for mutation in 0..7 { let mut changed = body.clone(); @@ -85,10 +665,18 @@ pub(crate) mod tests { 5 => changed.epoch = Epoch::new(1), _ => changed.threshold = Threshold::new(3, 3).unwrap(), } - let forged = SignedStatement { bytes: changed.encode(), signature: signed.signature.clone() }; - assert!(AuthorizedCeremony::verify_genesis(&signers.anchor(), &forged, &signers).is_err()); + let forged = SignedStatement { + bytes: changed.encode(), + signature: signed.signature.clone(), + }; + assert!( + AuthorizedCeremony::verify_genesis(&signers.anchor(), &forged, &signers).is_err() + ); } - let other = TrustAnchor { expected_owner: Signers::did(9), ..signers.anchor() }; + let other = TrustAnchor { + expected_owner: Signers::did(9), + ..signers.anchor() + }; assert!(AuthorizedCeremony::verify_genesis(&other, &signed, &signers).is_err()); } @@ -96,16 +684,28 @@ pub(crate) mod tests { fn ceremony_decoding_checks_budget_directory_and_canonical_bytes() { let signers = Signers::new(); let invalid_budget = signers.body(CeremonyKind::Genesis, Threshold::new(3, 4).unwrap()); - assert!(matches!(AuthorizedCeremony::verify_genesis(&signers.anchor(), &signers.statement(&invalid_budget), &signers), Err(Error::UnsupportedFaultBudget))); + assert!(matches!( + AuthorizedCeremony::verify_genesis( + &signers.anchor(), + &signers.statement(&invalid_budget), + &signers + ), + Err(Error::UnsupportedFaultBudget) + )); let body = signers.body(CeremonyKind::Genesis, Threshold::new(2, 3).unwrap()); - assert_eq!(CeremonyStatement::decode(&body.encode()).unwrap().encode(), body.encode()); + assert_eq!( + CeremonyStatement::decode(&body.encode()).unwrap().encode(), + body.encode() + ); for mutation in 0..4 { let mut changed = body.clone(); match mutation { 0 => changed.recipients.swap(0, 1), 1 => changed.recipients[1].node = changed.recipients[0].node.clone(), 2 => changed.recipients[1].index = changed.recipients[0].index, - _ => { changed.recipients.pop(); } + _ => { + changed.recipients.pop(); + } } assert!(CeremonyStatement::decode(&changed.encode()).is_err()); } @@ -120,26 +720,54 @@ pub(crate) mod tests { #[cfg(test)] mod round_tests { - use super::*; use super::tests::Signers; + use super::*; #[derive(Debug, Eq, PartialEq)] struct Byte(u8); impl Decode for Byte { fn decode(bytes: &[u8]) -> Result { - match bytes { [byte] => Ok(Self(*byte)), _ => Err(Error::InvalidEncoding) } + match bytes { + [byte] => Ok(Self(*byte)), + _ => Err(Error::InvalidEncoding), + } } } - fn message(c: &AuthorizedCeremony, member: u16, payload: &[u8], signers: &Signers) -> SignedStatement { - let bytes = ceremony_message_body(c, 1, MemberCohort::Recipients, MemberIndex::new(member).unwrap(), payload).unwrap(); + fn message( + c: &AuthorizedCeremony, + member: u16, + payload: &[u8], + signers: &Signers, + ) -> SignedStatement { + let bytes = ceremony_message_body( + c, + 1, + MemberCohort::Recipients, + MemberIndex::new(member).unwrap(), + payload, + ) + .unwrap(); SignedStatement { - signature: signers.sign(&Signers::did(member as u8), &ceremony_message_preimage(&bytes).unwrap()), bytes, + signature: signers.sign( + &Signers::did(member as u8), + &ceremony_message_preimage(&bytes).unwrap(), + ), + bytes, } } - fn close(c: &AuthorizedCeremony, prev: &TranscriptHash, messages: &[SignedStatement], signers: &Signers) -> SignedStatement { + fn close( + c: &AuthorizedCeremony, + prev: &TranscriptHash, + messages: &[SignedStatement], + signers: &Signers, + ) -> SignedStatement { let bytes = round_closure_body(c, 1, prev, messages).unwrap(); SignedStatement { - signature: signers.sign(&signers.anchor().expected_owner, &round_closure_preimage(&bytes).unwrap()), bytes, + signature: signers.sign( + &signers.anchor().expected_owner, + &round_closure_preimage(&bytes).unwrap(), + ), + bytes, } } @@ -148,23 +776,58 @@ mod round_tests { let signers = Signers::new(); let ceremony = signers.genesis(); let prev = TranscriptHash::new([0; 32]); - let messages = vec![message(&ceremony, 1, &[7], &signers), message(&ceremony, 2, &[8], &signers)]; + let messages = vec![ + message(&ceremony, 1, &[7], &signers), + message(&ceremony, 2, &[8], &signers), + ]; let closure = close(&ceremony, &prev, &messages, &signers); - let round = ClosedRound::::verify(&ceremony, 1, &prev, &closure, &messages, &signers).unwrap(); + let round = ClosedRound::::verify(&ceremony, 1, &prev, &closure, &messages, &signers) + .unwrap(); assert_eq!(round.messages()[0].payload(), &Ok(Byte(7))); assert_ne!(round.transcript_hash(), prev); - let mut reordered = messages.clone(); reordered.reverse(); - assert!(ClosedRound::::verify(&ceremony, 1, &prev, &closure, &reordered, &signers).is_err()); - assert!(ClosedRound::::verify(&ceremony, 2, &prev, &closure, &messages, &signers).is_err()); - assert!(ClosedRound::::verify(&ceremony, 1, &TranscriptHash::new([9; 32]), &closure, &messages, &signers).is_err()); - let mut changed = signers.body(CeremonyKind::Genesis, ceremony.threshold()); changed.id = CeremonyId::new([99; 32]); - let other = AuthorizedCeremony::verify_genesis(&signers.anchor(), &signers.statement(&changed), &signers).unwrap(); - assert!(ClosedRound::::verify(&other, 1, &prev, &closure, &messages, &signers).is_err()); - let mut invalid = messages.clone(); invalid[0].signature[0] ^= 1; + let mut reordered = messages.clone(); + reordered.reverse(); + assert!( + ClosedRound::::verify(&ceremony, 1, &prev, &closure, &reordered, &signers) + .is_err() + ); + assert!( + ClosedRound::::verify(&ceremony, 2, &prev, &closure, &messages, &signers) + .is_err() + ); + assert!(ClosedRound::::verify( + &ceremony, + 1, + &TranscriptHash::new([9; 32]), + &closure, + &messages, + &signers + ) + .is_err()); + let mut changed = signers.body(CeremonyKind::Genesis, ceremony.threshold()); + changed.id = CeremonyId::new([99; 32]); + let other = AuthorizedCeremony::verify_genesis( + &signers.anchor(), + &signers.statement(&changed), + &signers, + ) + .unwrap(); + assert!( + ClosedRound::::verify(&other, 1, &prev, &closure, &messages, &signers).is_err() + ); + let mut invalid = messages.clone(); + invalid[0].signature[0] ^= 1; let signed_invalid = close(&ceremony, &prev, &invalid, &signers); - assert!(matches!(ClosedRound::::verify(&ceremony, 1, &prev, &signed_invalid, &invalid, &signers), Err(Error::InvalidSignature))); - let mut invalid_owner = closure.clone(); invalid_owner.signature[0] ^= 1; - assert!(matches!(ClosedRound::::verify(&ceremony, 1, &prev, &invalid_owner, &messages, &signers), Err(Error::InvalidSignature))); + assert!(matches!( + ClosedRound::::verify(&ceremony, 1, &prev, &signed_invalid, &invalid, &signers), + Err(Error::InvalidSignature) + )); + let mut invalid_owner = closure.clone(); + invalid_owner.signature[0] ^= 1; + assert!(matches!( + ClosedRound::::verify(&ceremony, 1, &prev, &invalid_owner, &messages, &signers), + Err(Error::InvalidSignature) + )); } #[test] @@ -173,25 +836,40 @@ mod round_tests { let ceremony = signers.genesis(); let prev = TranscriptHash::new([0; 32]); let one = message(&ceremony, 1, &[7], &signers); - let messages = vec![one.clone(), one, message(&ceremony, 1, &[8], &signers), message(&ceremony, 2, &[], &signers)]; + let messages = vec![ + one.clone(), + one, + message(&ceremony, 1, &[8], &signers), + message(&ceremony, 2, &[], &signers), + ]; let closure = close(&ceremony, &prev, &messages, &signers); - let round = ClosedRound::::verify(&ceremony, 1, &prev, &closure, &messages, &signers).unwrap(); + let round = ClosedRound::::verify(&ceremony, 1, &prev, &closure, &messages, &signers) + .unwrap(); assert_eq!(round.messages()[0].payload(), &Ok(Byte(7))); assert_eq!(round.messages()[2].payload(), &Ok(Byte(8))); assert_eq!(round.messages()[3].payload(), &Err(Error::InvalidEncoding)); assert_eq!(round.messages()[0].member(), round.messages()[2].member()); - assert!(ceremony_message_body(&ceremony, 1, MemberCohort::Current, MemberIndex::new(1).unwrap(), &[7]).is_err()); + assert!(ceremony_message_body( + &ceremony, + 1, + MemberCohort::Current, + MemberIndex::new(1).unwrap(), + &[7] + ) + .is_err()); } } #[cfg(test)] mod cohort_tests { - use super::*; use super::tests::Signers; + use super::*; struct Payload; impl Decode for Payload { - fn decode(_: &[u8]) -> Result { Ok(Self) } + fn decode(_: &[u8]) -> Result { + Ok(Self) + } } #[test] @@ -202,41 +880,81 @@ mod cohort_tests { body.current = Some(current.digest()); body.epoch = current.key_context().epoch().checked_next().unwrap(); body.recipients[0].node = Signers::did(9); - let ceremony = AuthorizedCeremony::verify_for(¤t, &signers.statement(&body), &signers).unwrap(); + let ceremony = + AuthorizedCeremony::verify_for(¤t, &signers.statement(&body), &signers).unwrap(); let index = MemberIndex::new(1).unwrap(); let mut messages = Vec::new(); - for (cohort, did) in [(MemberCohort::Current, Signers::did(1)), (MemberCohort::Recipients, Signers::did(9))] { + for (cohort, did) in [ + (MemberCohort::Current, Signers::did(1)), + (MemberCohort::Recipients, Signers::did(9)), + ] { let bytes = ceremony_message_body(&ceremony, 1, cohort, index, &[7]).unwrap(); - messages.push(SignedStatement { signature: signers.sign(&did, &ceremony_message_preimage(&bytes).unwrap()), bytes }); + messages.push(SignedStatement { + signature: signers.sign(&did, &ceremony_message_preimage(&bytes).unwrap()), + bytes, + }); } let prev = TranscriptHash::new([0; 32]); let close = |messages: &[SignedStatement]| { let bytes = round_closure_body(&ceremony, 1, &prev, messages).unwrap(); - SignedStatement { signature: signers.sign(&signers.anchor().expected_owner, &round_closure_preimage(&bytes).unwrap()), bytes } + SignedStatement { + signature: signers.sign( + &signers.anchor().expected_owner, + &round_closure_preimage(&bytes).unwrap(), + ), + bytes, + } }; - let round = ClosedRound::::verify(&ceremony, 1, &prev, &close(&messages), &messages, &signers).unwrap(); + let round = ClosedRound::::verify( + &ceremony, + 1, + &prev, + &close(&messages), + &messages, + &signers, + ) + .unwrap(); assert_eq!(round.messages()[0].cohort(), MemberCohort::Current); assert_eq!(round.messages()[1].cohort(), MemberCohort::Recipients); assert_eq!(round.messages()[0].statement(), &messages[0]); let mut forged = messages.clone(); - forged[1].signature = signers.sign(&Signers::did(1), &ceremony_message_preimage(&forged[1].bytes).unwrap()); - assert!(matches!(ClosedRound::::verify(&ceremony, 1, &prev, &close(&forged), &forged, &signers), Err(Error::InvalidSignature))); - let mut stale = body.clone(); stale.current = Some(CertDigest::new([99; 32])); - assert!(AuthorizedCeremony::verify_for(¤t, &signers.statement(&stale), &signers).is_err()); - let (rekey, _, rekey_ceremony) = crate::cert::tests::successor(&signers, ¤t, CeremonyKind::Rekey, current.key_context().threshold(), 12); - assert!(ceremony_message_body(&rekey_ceremony, 1, MemberCohort::Current, index, &[7]).is_err()); + forged[1].signature = signers.sign( + &Signers::did(1), + &ceremony_message_preimage(&forged[1].bytes).unwrap(), + ); + assert!(matches!( + ClosedRound::::verify(&ceremony, 1, &prev, &close(&forged), &forged, &signers), + Err(Error::InvalidSignature) + )); + let mut stale = body.clone(); + stale.current = Some(CertDigest::new([99; 32])); + assert!( + AuthorizedCeremony::verify_for(¤t, &signers.statement(&stale), &signers).is_err() + ); + let (rekey, _, rekey_ceremony) = crate::cert::tests::successor( + &signers, + ¤t, + CeremonyKind::Rekey, + current.key_context().threshold(), + 12, + ); + assert!( + ceremony_message_body(&rekey_ceremony, 1, MemberCohort::Current, index, &[7]).is_err() + ); assert!(crate::cert::verify_successor(¤t, &rekey, &signers).is_ok()); } } #[cfg(test)] mod bounds_tests { - use super::*; use super::tests::Signers; + use super::*; struct Payload; impl Decode for Payload { - fn decode(_: &[u8]) -> Result { Ok(Self) } + fn decode(_: &[u8]) -> Result { + Ok(Self) + } } #[test] @@ -244,17 +962,36 @@ mod bounds_tests { let signers = Signers::new(); let c = signers.genesis(); let index = MemberIndex::new(1).unwrap(); - assert!(ceremony_message_body(&c, 1, MemberCohort::Recipients, index, &vec![0; 65_536]).is_err()); - let bytes = ceremony_message_body(&c, 1, MemberCohort::Recipients, index, &[0; 60_000]).unwrap(); - let message = SignedStatement { signature: signers.sign(&Signers::did(1), &ceremony_message_preimage(&bytes).unwrap()), bytes }; + assert!( + ceremony_message_body(&c, 1, MemberCohort::Recipients, index, &vec![0; 65_536]) + .is_err() + ); + let bytes = + ceremony_message_body(&c, 1, MemberCohort::Recipients, index, &[0; 60_000]).unwrap(); + let message = SignedStatement { + signature: signers.sign( + &Signers::did(1), + &ceremony_message_preimage(&bytes).unwrap(), + ), + bytes, + }; let prev = TranscriptHash::new([0; 32]); assert!(round_closure_body(&c, 1, &prev, &vec![message.clone(); 70]).is_err()); - let mut oversized = message.clone(); oversized.signature = vec![0; 513]; + let mut oversized = message.clone(); + oversized.signature = vec![0; 513]; assert!(round_closure_body(&c, 1, &prev, &[oversized]).is_err()); - let mut malformed = message.clone(); malformed.bytes.push(0); - let mut closure = SignedStatement { bytes: vec![0; 98], signature: vec![0; 64] }; - assert!(ClosedRound::::verify(&c, 1, &prev, &closure, &[malformed], &signers).is_err()); + let mut malformed = message.clone(); + malformed.bytes.push(0); + let mut closure = SignedStatement { + bytes: vec![0; 98], + signature: vec![0; 64], + }; + assert!( + ClosedRound::::verify(&c, 1, &prev, &closure, &[malformed], &signers).is_err() + ); closure.bytes = vec![0; 65_537]; - assert!(ClosedRound::::verify(&c, 1, &prev, &closure, &[message], &signers).is_err()); + assert!( + ClosedRound::::verify(&c, 1, &prev, &closure, &[message], &signers).is_err() + ); } } diff --git a/tinycloud-threshold/src/cert.rs b/tinycloud-threshold/src/cert.rs index b8df7397..d19b96a6 100644 --- a/tinycloud-threshold/src/cert.rs +++ b/tinycloud-threshold/src/cert.rs @@ -1,5 +1,545 @@ -#[cfg(test)] -use crate::{context::KeyContext, curve::{Scalar, G2}, derive::{KeyShare, MasterPublicKey}, ids::*}; +//! Canonical anchored certificates and candidate-share binding. Details: TC-2222 + +use crate::{ + ceremony::{text, AuthorizedCeremony, CeremonyKind, DidSignatureVerifier, TrustAnchor}, + context::KeyContext, + curve::{Scalar, G2}, + derive::{KeyShare, MasterPublicKey, PublicKeySet}, + dst, + encoding::{Decoder, Encoder}, + error::{CertError, Error}, + ids::*, +}; +use core::fmt; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Context-free public polynomial output shared by certificate and ceremony APIs. Details: TC-2222 +#[derive(Clone, Debug)] +pub struct PublicMaterial { + threshold: Threshold, + mpk: MasterPublicKey, + commitments: Vec, + public_shares: Vec<(MemberIndex, G2)>, +} +impl PublicMaterial { + pub(crate) fn from_commitments( + threshold: Threshold, + commitments: Vec, + ) -> Result { + if commitments.len() != usize::from(threshold.t()) { + return Err(Error::InvalidLength); + } + let constant = commitments.first().ok_or(Error::InvalidLength)?; + let top = commitments.last().ok_or(Error::InvalidLength)?; + if constant.is_identity() || top.is_identity() { + return Err(Error::IdentityPoint); + } + let mut public_shares = Vec::with_capacity(usize::from(threshold.n())); + for i in 1..=threshold.n() { + let x = Scalar::from_u64(u64::from(i)); + let point = commitments + .iter() + .rev() + .fold(G2::identity(), |sum, coefficient| { + sum.mul(&x).add(coefficient) + }); + if point.is_identity() { + return Err(Error::IdentityPoint); + } + public_shares.push((MemberIndex::new(i)?, point)); + } + Ok(Self { + threshold, + mpk: MasterPublicKey(constant.clone()), + commitments, + public_shares, + }) + } + + /// Threshold and cohort size. Details: TC-2222 + pub fn threshold(&self) -> Threshold { + self.threshold + } + /// Non-identity master public key. Details: TC-2222 + pub fn master_public_key(&self) -> &MasterPublicKey { + &self.mpk + } + /// Encoded coefficients in ascending polynomial degree. Details: TC-2222 + pub fn commitments(&self) -> impl ExactSizeIterator + '_ { + self.commitments.iter().map(G2::encode) + } + /// Canonical evaluated public share at the given coordinate. Details: TC-2222 + pub fn public_share(&self, index: MemberIndex) -> Result<[u8; 96], Error> { + self.public_shares + .iter() + .find(|(i, _)| *i == index) + .map(|(_, point)| point.encode()) + .ok_or(Error::InvalidIndex) + } +} + +/// Ceremony output scalar awaiting transcript and certificate binding. Details: TC-2222 +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct CandidateShare { + #[zeroize(skip)] + pub(crate) transcript: TranscriptHash, + #[zeroize(skip)] + pub(crate) index: MemberIndex, + pub(crate) scalar: Scalar, +} +impl CandidateShare { + #[cfg(test)] + pub(crate) fn new(transcript: TranscriptHash, index: MemberIndex, scalar: Scalar) -> Self { + Self { + transcript, + index, + scalar, + } + } + /// Transcript from which the candidate was produced. Details: TC-2222 + pub fn transcript_hash(&self) -> TranscriptHash { + self.transcript + } + /// Candidate's Shamir coordinate. Details: TC-2222 + pub fn index(&self) -> MemberIndex { + self.index + } +} +impl fmt::Debug for CandidateShare { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CandidateShare([REDACTED])") + } +} + +impl KeyShare { + /// Bind the candidate's transcript, coordinate and scalar to the verified certificate. Details: TC-2222 + pub fn bind(mut candidate: CandidateShare, cert: &VerifiedCertificate) -> Result { + if candidate.transcript != cert.body.transcript { + return Err(Error::CandidateMismatch); + } + let public_share = cert + .public + .public_shares + .iter() + .find(|(i, _)| *i == candidate.index) + .map(|(_, point)| point) + .ok_or(Error::CandidateMismatch)?; + if G2::generator().mul(&candidate.scalar) != *public_share { + return Err(Error::CandidateMismatch); + } + let scalar = core::mem::replace(&mut candidate.scalar, Scalar::from_u64(0)); + Ok(Self { + context: cert.key_context(), + index: candidate.index, + scalar, + }) + } +} + +/// A certificate's authenticated member identity. Details: TC-2222 +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CertMember { + /// Shamir coordinate. Details: TC-2222 + pub index: MemberIndex, + /// Node identity for response and readiness signatures. Details: TC-2222 + pub node: Did, +} + +/// Canonical certificate body without an owner signature or freshness assertion. Details: TC-2222 +#[derive(Clone, Debug)] +pub struct CertificateBody { + network: NetworkUrn, + owner: Did, + rekey: RekeyCounter, + epoch: Epoch, + genesis_t: u16, + members: Vec, + public: PublicMaterial, + transcript: TranscriptHash, + previous: Option, + previous_mpk: Option, +} + +impl CertificateBody { + /// Build genesis from an anchored ceremony and its public output. Details: TC-2222 + pub fn genesis( + anchor: &TrustAnchor, + ceremony: &AuthorizedCeremony, + public: &PublicMaterial, + t: &TranscriptHash, + ) -> Result { + if ceremony.kind() != CeremonyKind::Genesis || ceremony.anchor() != anchor { + return Err(Error::ContextMismatch); + } + Self::from_ceremony(ceremony, public, t, public.threshold.t(), None, None) + } + + /// Build a successor of the exact pinned certificate authorized by the ceremony. Details: TC-2222 + pub fn successor( + prev: &VerifiedCertificate, + ceremony: &AuthorizedCeremony, + public: &PublicMaterial, + t: &TranscriptHash, + ) -> Result { + if ceremony.anchor() != prev.anchor() + || ceremony.statement().current != Some(prev.digest) + || ceremony.statement().epoch != prev.body.epoch.checked_next()? + { + return Err(Error::ContextMismatch); + } + let (genesis_t, previous_mpk) = match ceremony.kind() { + CeremonyKind::Genesis => return Err(Error::InvalidCeremony), + CeremonyKind::Rekey => { + if public.mpk == prev.body.public.mpk + || ceremony.statement().rekey != prev.body.rekey.checked_next()? + { + return Err(Error::ContextMismatch); + } + (public.threshold.t(), Some(prev.body.public.mpk.clone())) + } + CeremonyKind::Reshare | CeremonyKind::Refresh => { + if public.mpk != prev.body.public.mpk + || ceremony.statement().rekey != prev.body.rekey + { + return Err(Error::ContextMismatch); + } + (prev.body.genesis_t, None) + } + }; + Self::from_ceremony( + ceremony, + public, + t, + genesis_t, + Some(prev.digest), + previous_mpk, + ) + } + + fn from_ceremony( + c: &AuthorizedCeremony, + public: &PublicMaterial, + transcript: &TranscriptHash, + genesis_t: u16, + previous: Option, + previous_mpk: Option, + ) -> Result { + if c.threshold() != public.threshold { + return Err(Error::ContextMismatch); + } + let body = Self { + network: c.anchor().network.clone(), + owner: c.anchor().expected_owner.clone(), + epoch: c.statement().epoch, + rekey: c.statement().rekey, + genesis_t, + members: c + .recipients() + .iter() + .map(|r| CertMember { + index: r.index, + node: r.node.clone(), + }) + .collect(), + public: public.clone(), + transcript: *transcript, + previous, + previous_mpk, + }; + body.validate()?; + Ok(body) + } + + fn validate(&self) -> Result<(), Error> { + let threshold = self.public.threshold; + if self.members.len() != usize::from(threshold.n()) || !(2..=16).contains(&self.genesis_t) { + return Err(Error::InvalidEncoding); + } + for (index, member) in (1..=threshold.n()).zip(&self.members) { + if member.index.get() != index + || self + .members + .iter() + .filter(|m| m.node == member.node) + .count() + != 1 + { + return Err(Error::InvalidEncoding); + } + } + if self.public.commitments.first() != Some(&self.public.mpk.0) { + return Err(Error::InvalidPoint); + } + match (&self.previous, &self.previous_mpk) { + (None, None) => { + if self.epoch.get() != 0 || self.rekey.get() != 0 || self.genesis_t != threshold.t() + { + return Err(Error::InvalidEncoding); + } + threshold.check_genesis_budget()?; + } + (Some(_), old_mpk) => { + if self.epoch.get() == 0 || self.rekey.get() > self.epoch.get() { + return Err(Error::InvalidEncoding); + } + if let Some(old_mpk) = old_mpk { + if self.rekey.get() == 0 + || old_mpk == &self.public.mpk + || self.genesis_t != threshold.t() + { + return Err(Error::InvalidEncoding); + } + threshold.check_genesis_budget()?; + } + } + _ => return Err(Error::InvalidEncoding), + } + Ok(()) + } + + /// Decode bounded canonical fields and validate every evaluated public share. Details: TC-2222 + pub fn decode(bytes: &[u8]) -> Result { + let mut d = Decoder::new(bytes)?; + if d.u8()? != 1 { + return Err(Error::InvalidEncoding); + } + let network = NetworkUrn::new(text(d.lp(1024)?)?)?; + let owner = Did::new(text(d.lp(256)?)?)?; + let rekey = RekeyCounter::new(d.u64()?); + let epoch = Epoch::new(d.u64()?); + let genesis_t = d.u16()?; + let threshold = Threshold::new(d.u16()?, d.u16()?)?; + let count = d.u16()?; + if count != threshold.n() { + return Err(Error::InvalidLength); + } + let mut members = Vec::with_capacity(usize::from(count)); + for _ in 0..count { + members.push(CertMember { + index: MemberIndex::new(d.u16()?)?, + node: Did::new(text(d.lp(256)?)?)?, + }); + } + let mpk = MasterPublicKey::decode(&d.fixed::<96>()?)?; + let count = d.u16()?; + if count != threshold.t() { + return Err(Error::InvalidLength); + } + let mut commitments = Vec::with_capacity(usize::from(count)); + for _ in 0..count { + commitments.push(G2::decode(&d.fixed::<96>()?, true)?); + } + let public = PublicMaterial::from_commitments(threshold, commitments)?; + if public.mpk != mpk { + return Err(Error::InvalidPoint); + } + let transcript = TranscriptHash::new(d.fixed()?); + let previous = match d.u8()? { + 0 => None, + 1 => Some(CertDigest::new(d.fixed()?)), + _ => return Err(Error::InvalidEncoding), + }; + let previous_mpk = match d.u8()? { + 0 => None, + 1 => Some(MasterPublicKey::decode(&d.fixed::<96>()?)?), + _ => return Err(Error::InvalidEncoding), + }; + d.finish()?; + let body = Self { + network, + owner, + rekey, + epoch, + genesis_t, + members, + public, + transcript, + previous, + previous_mpk, + }; + body.validate()?; + Ok(body) + } + + /// Encode the normative fixed certificate field order. Details: TC-2222 + pub fn encode(&self) -> Vec { + let mut e = Encoder::new(); + e.u8(1); + e.lp(self.network.as_bytes()); + e.lp(self.owner.as_bytes()); + e.u64(self.rekey.get()); + e.u64(self.epoch.get()); + e.u16(self.genesis_t); + e.u16(self.public.threshold.t()); + e.u16(self.public.threshold.n()); + e.u16(self.members.len() as u16); + for member in &self.members { + e.u16(member.index.get()); + e.lp(member.node.as_bytes()); + } + e.fixed(&self.public.mpk.encode()); + e.u16(self.public.commitments.len() as u16); + for commitment in &self.public.commitments { + e.fixed(&commitment.encode()); + } + e.fixed(self.transcript.as_bytes()); + e.u8(u8::from(self.previous.is_some())); + if let Some(previous) = self.previous { + e.fixed(previous.as_bytes()); + } + e.u8(u8::from(self.previous_mpk.is_some())); + if let Some(previous_mpk) = &self.previous_mpk { + e.fixed(&previous_mpk.encode()); + } + e.finish() + } + /// Certificate digest under its dedicated domain. Details: TC-2222 + pub fn digest(&self) -> CertDigest { + CertDigest::new(dst::tagged_hash(dst::NETWORK_CERT, &self.encode())) + } + /// Exact owner signing preimage. Details: TC-2222 + pub fn owner_statement(&self) -> Vec { + dst::tagged_hash(dst::CERT_STATEMENT, self.digest().as_bytes()).to_vec() + } + /// Ordered node identity directory. Details: TC-2222 + pub fn members(&self) -> &[CertMember] { + &self.members + } + /// Context-free public polynomial. Details: TC-2222 + pub fn public_material(&self) -> &PublicMaterial { + &self.public + } + /// Transcript committed by this certificate. Details: TC-2222 + pub fn transcript_hash(&self) -> TranscriptHash { + self.transcript + } +} + +/// Raw owner-signed certificate. Details: TC-2222 +#[derive(Clone, Debug)] +pub struct SignedCertificate { + /// Decoded canonical body. Details: TC-2222 + pub body: CertificateBody, + /// Owner signature over body.owner_statement(). Details: TC-2222 + pub owner_signature: Vec, +} + +/// Anchored verified certificate; newest-head selection remains with the caller. Details: TC-2222 +#[derive(Clone, Debug)] +pub struct VerifiedCertificate { + body: CertificateBody, + anchor: TrustAnchor, + digest: CertDigest, + public: PublicKeySet, +} + +/// Validate the decoded body and owner signature under the external anchor. Details: TC-2222 +pub fn verify_certificate( + anchor: &TrustAnchor, + signed: &SignedCertificate, + v: &impl DidSignatureVerifier, +) -> Result { + let body = &signed.body; + if body.owner != anchor.expected_owner || body.network != anchor.network { + return Err(CertError::AnchorMismatch); + } + body.validate()?; + let digest = body.digest(); + let owner_preimage = dst::tagged_hash(dst::CERT_STATEMENT, digest.as_bytes()); + if signed.owner_signature.is_empty() + || signed.owner_signature.len() > 512 + || v.verify( + &anchor.expected_owner, + &owner_preimage, + &signed.owner_signature, + ) + .is_err() + { + return Err(CertError::InvalidSignature); + } + let context = KeyContext { + network: body.network.clone(), + rekey: body.rekey, + epoch: body.epoch, + certificate: digest, + threshold: body.public.threshold, + }; + let public = PublicKeySet { + context, + mpk: body.public.mpk.clone(), + public_shares: body.public.public_shares.clone(), + }; + Ok(VerifiedCertificate { + body: body.clone(), + anchor: anchor.clone(), + digest, + public, + }) +} + +/// Verify one checked-generation transition from an externally pinned predecessor. Details: TC-2222 +pub fn verify_successor( + pinned: &VerifiedCertificate, + next: &SignedCertificate, + v: &impl DidSignatureVerifier, +) -> Result { + let epoch = pinned.body.epoch.checked_next()?; + let body = &next.body; + if body.previous != Some(pinned.digest) || body.epoch != epoch { + return Err(CertError::InvalidLineage); + } + match &body.previous_mpk { + None => { + if body.public.mpk != pinned.body.public.mpk + || body.rekey != pinned.body.rekey + || body.genesis_t != pinned.body.genesis_t + { + return Err(CertError::InvalidLineage); + } + } + Some(previous_mpk) => { + if body.rekey != pinned.body.rekey.checked_next()? + || previous_mpk != &pinned.body.public.mpk + || body.public.mpk == pinned.body.public.mpk + || body.genesis_t != body.public.threshold.t() + { + return Err(CertError::InvalidLineage); + } + } + } + verify_certificate(&pinned.anchor, next, v) +} + +impl VerifiedCertificate { + /// Canonical body authenticated by the owner. Details: TC-2222 + pub fn body(&self) -> &CertificateBody { + &self.body + } + /// External anchor used during verification. Details: TC-2222 + pub fn anchor(&self) -> &TrustAnchor { + &self.anchor + } + /// Canonical body digest. Details: TC-2222 + pub fn digest(&self) -> CertDigest { + self.digest + } + /// Certificate-bound key context. Details: TC-2222 + pub fn key_context(&self) -> KeyContext { + self.public.context.clone() + } + /// Certificate-bound public shares. Details: TC-2222 + pub fn public_key_set(&self) -> &PublicKeySet { + &self.public + } + /// Resolve the specified member in this certificate only. Details: TC-2222 + pub fn member(&self, index: MemberIndex) -> Result<&CertMember, CertError> { + self.body + .members + .iter() + .find(|m| m.index == index) + .ok_or(CertError::InvalidMembers) + } +} #[cfg(test)] pub(crate) mod tests { @@ -9,7 +549,10 @@ pub(crate) mod tests { pub(crate) fn material(seed: u8, threshold: Threshold) -> (PublicMaterial, Vec) { let context = KeyContext::test( Signers::new().anchor().network, - RekeyCounter::new(0), Epoch::new(0), CertDigest::new([0; 32]), threshold, + RekeyCounter::new(0), + Epoch::new(0), + CertDigest::new([0; 32]), + threshold, ); let (public, shares, _) = crate::checkpoint::dealer(seed, context); let constant = public.mpk.0.clone(); @@ -26,37 +569,58 @@ pub(crate) mod tests { }; let material = PublicMaterial::from_commitments(threshold, commitments).unwrap(); for (i, pk) in &material.public_shares { - assert_eq!(pk, &public.public_shares.iter().find(|(j, _)| j == i).unwrap().1); + assert_eq!( + pk, + &public.public_shares.iter().find(|(j, _)| j == i).unwrap().1 + ); } (material, shares) } pub(crate) fn sign(body: CertificateBody, signers: &Signers) -> SignedCertificate { let owner_signature = signers.sign(&body.owner, &body.owner_statement()); - SignedCertificate { body, owner_signature } + SignedCertificate { + body, + owner_signature, + } } pub(crate) fn genesis(signers: &Signers) -> (VerifiedCertificate, Vec) { let (public, raw) = material(11, Threshold::new(2, 3).unwrap()); let transcript = TranscriptHash::new([51; 32]); - let body = CertificateBody::genesis(&signers.anchor(), &signers.genesis(), &public, &transcript).unwrap(); + let body = + CertificateBody::genesis(&signers.anchor(), &signers.genesis(), &public, &transcript) + .unwrap(); let cert = verify_certificate(&signers.anchor(), &sign(body, signers), signers).unwrap(); - let bound = raw.into_iter().map(|mut raw| { - let scalar = core::mem::replace(&mut raw.scalar, Scalar::from_u64(0)); - KeyShare::bind(CandidateShare::new(transcript, raw.index, scalar), &cert).unwrap() - }).collect(); + let bound = raw + .into_iter() + .map(|mut raw| { + let scalar = core::mem::replace(&mut raw.scalar, Scalar::from_u64(0)); + KeyShare::bind(CandidateShare::new(transcript, raw.index, scalar), &cert).unwrap() + }) + .collect(); (cert, bound) } pub(crate) fn successor( - signers: &Signers, prev: &VerifiedCertificate, kind: CeremonyKind, - threshold: Threshold, seed: u8, + signers: &Signers, + prev: &VerifiedCertificate, + kind: CeremonyKind, + threshold: Threshold, + seed: u8, ) -> (SignedCertificate, Vec, AuthorizedCeremony) { let mut stmt = signers.body(kind, threshold); stmt.id = CeremonyId::new([52; 32]); stmt.current = Some(prev.digest()); stmt.epoch = prev.body.epoch.checked_next().unwrap(); - stmt.rekey = if kind == CeremonyKind::Rekey { prev.body.rekey.checked_next().unwrap() } else { prev.body.rekey }; - let ceremony = AuthorizedCeremony::verify_for(prev, &signers.statement(&stmt), signers).unwrap(); + stmt.rekey = if kind == CeremonyKind::Rekey { + prev.body.rekey.checked_next().unwrap() + } else { + prev.body.rekey + }; + let ceremony = + AuthorizedCeremony::verify_for(prev, &signers.statement(&stmt), signers).unwrap(); let (public, shares) = material(seed, threshold); - let body = CertificateBody::successor(prev, &ceremony, &public, &TranscriptHash::new([53; 32])).unwrap(); + let body = + CertificateBody::successor(prev, &ceremony, &public, &TranscriptHash::new([53; 32])) + .unwrap(); (sign(body, signers), shares, ceremony) } @@ -65,32 +629,84 @@ pub(crate) mod tests { let signers = Signers::new(); let (genesis, shares) = genesis(&signers); assert_eq!(shares[0].context(), &genesis.key_context()); - let (reshare, raw, _) = successor(&signers, &genesis, CeremonyKind::Reshare, Threshold::new(3, 4).unwrap(), 11); + let (reshare, raw, _) = successor( + &signers, + &genesis, + CeremonyKind::Reshare, + Threshold::new(3, 4).unwrap(), + 11, + ); let reshare = verify_successor(&genesis, &reshare, &signers).unwrap(); - assert_eq!(reshare.public_key_set().master_public_key(), genesis.public_key_set().master_public_key()); + assert_eq!( + reshare.public_key_set().master_public_key(), + genesis.public_key_set().master_public_key() + ); assert_eq!(reshare.key_context().epoch().get(), 1); - assert_eq!(reshare.key_context().threshold(), Threshold::new(3, 4).unwrap()); + assert_eq!( + reshare.key_context().threshold(), + Threshold::new(3, 4).unwrap() + ); for mut raw in raw { let scalar = core::mem::replace(&mut raw.scalar, Scalar::from_u64(0)); - assert!(KeyShare::bind(CandidateShare::new(reshare.body.transcript, raw.index, scalar), &reshare).is_ok()); + assert!(KeyShare::bind( + CandidateShare::new(reshare.body.transcript, raw.index, scalar), + &reshare + ) + .is_ok()); } - let (rekey, _, _) = successor(&signers, &reshare, CeremonyKind::Rekey, Threshold::new(2, 3).unwrap(), 12); + let (rekey, _, _) = successor( + &signers, + &reshare, + CeremonyKind::Rekey, + Threshold::new(2, 3).unwrap(), + 12, + ); let rekey = verify_successor(&reshare, &rekey, &signers).unwrap(); - assert_ne!(rekey.public_key_set().master_public_key(), reshare.public_key_set().master_public_key()); + assert_ne!( + rekey.public_key_set().master_public_key(), + reshare.public_key_set().master_public_key() + ); assert_eq!(rekey.key_context().epoch().get(), 2); assert_eq!(rekey.key_context().rekey().get(), 1); assert!(verify_successor(&rekey, &sign(genesis.body.clone(), &signers), &signers).is_err()); - assert!(KeyShare::bind(CandidateShare::new(genesis.body.transcript, shares[0].index, Scalar::from_u64(1)), &genesis).is_err()); - assert!(KeyShare::bind(CandidateShare::new(TranscriptHash::new([99; 32]), shares[0].index, Scalar::from_u64(1)), &genesis).is_err()); + assert!(KeyShare::bind( + CandidateShare::new( + genesis.body.transcript, + shares[0].index, + Scalar::from_u64(1) + ), + &genesis + ) + .is_err()); + assert!(KeyShare::bind( + CandidateShare::new( + TranscriptHash::new([99; 32]), + shares[0].index, + Scalar::from_u64(1) + ), + &genesis + ) + .is_err()); } #[test] fn decoded_certificate_invariants_apply_before_verified_contexts_exist() { let signers = Signers::new(); let (cert, _) = genesis(&signers); - assert_eq!(CertificateBody::decode(&cert.body.encode()).unwrap().encode(), cert.body.encode()); - let threshold_offset = 1 + 4 + cert.body.network.as_bytes().len() - + 4 + cert.body.owner.as_bytes().len() + 8 + 8 + 2; + assert_eq!( + CertificateBody::decode(&cert.body.encode()) + .unwrap() + .encode(), + cert.body.encode() + ); + let threshold_offset = 1 + + 4 + + cert.body.network.as_bytes().len() + + 4 + + cert.body.owner.as_bytes().len() + + 8 + + 8 + + 2; for (t, n) in [(1u16, 3u16), (4, 3), (2, 17)] { let mut bytes = cert.body.encode(); bytes[threshold_offset..threshold_offset + 2].copy_from_slice(&t.to_be_bytes()); @@ -116,20 +732,31 @@ pub(crate) mod tests { body.public.commitments = vec![G2::generator(), G2::identity().sub(&G2::generator())]; body.public.mpk = MasterPublicKey(G2::generator()); assert!(CertificateBody::decode(&body.encode()).is_err()); - let mut bytes = cert.body.encode(); bytes.push(0); + let mut bytes = cert.body.encode(); + bytes.push(0); assert!(CertificateBody::decode(&bytes).is_err()); for length in 0..cert.body.encode().len() { assert!(CertificateBody::decode(&cert.body.encode()[..length]).is_err()); } - let mut impostor = cert.body.clone(); impostor.owner = Signers::did(9); - assert!(matches!(verify_certificate(&signers.anchor(), &sign(impostor, &signers), &signers), Err(CertError::AnchorMismatch))); + let mut impostor = cert.body.clone(); + impostor.owner = Signers::did(9); + assert!(matches!( + verify_certificate(&signers.anchor(), &sign(impostor, &signers), &signers), + Err(CertError::AnchorMismatch) + )); } #[test] fn pinned_lineage_checks_every_generation_field_and_overflow() { let signers = Signers::new(); let (prev, _) = genesis(&signers); - let (valid, _, _) = successor(&signers, &prev, CeremonyKind::Reshare, Threshold::new(2, 3).unwrap(), 11); + let (valid, _, _) = successor( + &signers, + &prev, + CeremonyKind::Reshare, + Threshold::new(2, 3).unwrap(), + 11, + ); for mutation in 0..6 { let mut body = valid.body.clone(); match mutation { @@ -142,17 +769,34 @@ pub(crate) mod tests { } assert!(verify_successor(&prev, &sign(body, &signers), &signers).is_err()); } - let (rekey, _, _) = successor(&signers, &prev, CeremonyKind::Rekey, Threshold::new(2, 3).unwrap(), 12); - let mut missing = rekey.body.clone(); missing.previous_mpk = None; + let (rekey, _, _) = successor( + &signers, + &prev, + CeremonyKind::Rekey, + Threshold::new(2, 3).unwrap(), + 12, + ); + let mut missing = rekey.body.clone(); + missing.previous_mpk = None; assert!(verify_successor(&prev, &sign(missing, &signers), &signers).is_err()); - let mut body = valid.body.clone(); body.epoch = Epoch::new(u64::MAX); + let mut body = valid.body.clone(); + body.epoch = Epoch::new(u64::MAX); let max = verify_certificate(&signers.anchor(), &sign(body, &signers), &signers).unwrap(); - assert!(matches!(verify_successor(&max, &valid, &signers), Err(CertError::Invalid(Error::CounterOverflow)))); - let mut body = rekey.body.clone(); body.epoch = Epoch::new(u64::MAX); body.rekey = RekeyCounter::new(u64::MAX); + assert!(matches!( + verify_successor(&max, &valid, &signers), + Err(CertError::Invalid(Error::CounterOverflow)) + )); + let mut body = rekey.body.clone(); + body.epoch = Epoch::new(u64::MAX); + body.rekey = RekeyCounter::new(u64::MAX); let max = verify_certificate(&signers.anchor(), &sign(body, &signers), &signers).unwrap(); - let mut stmt: CeremonyStatement = signers.body(CeremonyKind::Rekey, Threshold::new(2, 3).unwrap()); + let mut stmt: CeremonyStatement = + signers.body(CeremonyKind::Rekey, Threshold::new(2, 3).unwrap()); stmt.current = Some(max.digest()); - assert!(matches!(AuthorizedCeremony::verify_for(&max, &signers.statement(&stmt), &signers), Err(Error::CounterOverflow))); + assert!(matches!( + AuthorizedCeremony::verify_for(&max, &signers.statement(&stmt), &signers), + Err(Error::CounterOverflow) + )); } } @@ -160,7 +804,10 @@ pub(crate) mod tests { mod smoke { use super::*; use crate::{ - activation::{activation_preimage, readiness_preimage, verify_activation, ReadinessReceipt, ReadyQuorum}, + activation::{ + activation_preimage, readiness_preimage, verify_activation, ReadinessReceipt, + ReadyQuorum, + }, ceremony::tests::Signers, fault::{verify_fault_proof, FaultProof, FaultVerdict, NotAFaultReason}, *, @@ -178,36 +825,331 @@ mod smoke { let mut rng = ChaCha20Rng::from_seed([71; 32]); let tsk = TransportSecretKey::generate(&mut rng); let request = DecryptRequest::new( - &cert.key_context(), crate::checkpoint::identity(&cert.key_context(), "smoke/key"), tsk.public_key(), - ).unwrap(); + &cert.key_context(), + crate::checkpoint::identity(&cert.key_context(), "smoke/key"), + tsk.public_key(), + ) + .unwrap(); let auth = crate::checkpoint::authorize(request.clone()); - let issued: Vec<_> = shares.iter().map(|s| issue_share(s, &auth, &mut rng).unwrap()).collect(); + let issued: Vec<_> = shares + .iter() + .map(|s| issue_share(s, &auth, &mut rng).unwrap()) + .collect(); let encrypted = combine(cert.public_key_set(), &request, &issued).unwrap(); - let derived = decrypt_derived_key(&encrypted, &tsk, cert.public_key_set(), &request).unwrap(); + let derived = + decrypt_derived_key(&encrypted, &tsk, cert.public_key_set(), &request).unwrap(); let data = DataKey::new([0x42; 32]); - let wrapped = wrap_key(cert.public_key_set().master_public_key(), request.identity(), &data, &mut rng).unwrap(); - assert_eq!(unwrap_key(&derived, request.identity(), &wrapped).unwrap().as_bytes(), data.as_bytes()); + let wrapped = wrap_key( + cert.public_key_set().master_public_key(), + request.identity(), + &data, + &mut rng, + ) + .unwrap(); + assert_eq!( + unwrap_key(&derived, request.identity(), &wrapped) + .unwrap() + .as_bytes(), + data.as_bytes() + ); println!("2. PASS: certificate-bound shares issue, combine, derive and unwrap"); let mut bad = issued[0].clone(); bad.c3 = bad.c3.add(&crate::curve::G1::generator()); let signature = signers.sign(&Signers::did(1), &bad.signing_preimage(&request).unwrap()); - let proof = FaultProof { request: request.clone(), share: bad, signature }; - assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::Fault(shares[0].index())); + let proof = FaultProof { + request: request.clone(), + share: bad, + signature, + }; + assert_eq!( + verify_fault_proof(&cert, &proof, &signers), + FaultVerdict::Fault(shares[0].index()) + ); println!("3. PASS: genuine correctly bound signed bad response attributes its signer"); - let (next, _, _) = tests::successor(&signers, &cert, crate::ceremony::CeremonyKind::Rekey, cert.key_context().threshold(), 12); - let next = verify_successor(&cert, &next, &signers).unwrap(); - assert_eq!(verify_fault_proof(&next, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); + let (reshared, raw, _) = tests::successor( + &signers, + &cert, + crate::ceremony::CeremonyKind::Reshare, + Threshold::new(3, 4).unwrap(), + 11, + ); + let reshared = verify_successor(&cert, &reshared, &signers).unwrap(); + let bound: Vec<_> = raw + .into_iter() + .map(|mut raw| { + let scalar = core::mem::replace(&mut raw.scalar, Scalar::from_u64(0)); + KeyShare::bind( + CandidateShare::new(reshared.body.transcript, raw.index, scalar), + &reshared, + ) + .unwrap() + }) + .collect(); + let ready: Vec<_> = bound + .iter() + .map(|share| ReadinessReceipt { + index: share.index(), + signature: signers.sign( + &reshared.member(share.index()).unwrap().node, + &readiness_preimage(reshared.body(), share.index()).unwrap(), + ), + }) + .collect(); + let bytes = activation_preimage(reshared.body(), &ready).unwrap(); + let activate_reshare = crate::ceremony::SignedStatement { + signature: signers.sign(&reshared.anchor().expected_owner, &bytes), + bytes, + }; + verify_activation( + &reshared, + &ready, + ReadyQuorum::AllHolders, + &activate_reshare, + &signers, + ) + .unwrap(); + assert_eq!( + reshared.public_key_set().master_public_key(), + cert.public_key_set().master_public_key() + ); + println!("3a. PASS: 2-of-3 genesis to 3-of-4 reshare; four bound candidate shares and all-holder activation"); + let (next, _, _) = tests::successor( + &signers, + &reshared, + crate::ceremony::CeremonyKind::Rekey, + cert.key_context().threshold(), + 12, + ); + let next = verify_successor(&reshared, &next, &signers).unwrap(); + assert_eq!( + verify_fault_proof(&next, &proof, &signers), + FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch) + ); println!("4. PASS: old response under re-keyed certificate is NotAFault"); - let receipts: Vec<_> = shares.iter().map(|share| ReadinessReceipt { - index: share.index(), - signature: signers.sign(&cert.member(share.index()).unwrap().node, &readiness_preimage(cert.body(), share.index()).unwrap()), - }).collect(); + let receipts: Vec<_> = shares + .iter() + .map(|share| ReadinessReceipt { + index: share.index(), + signature: signers.sign( + &cert.member(share.index()).unwrap().node, + &readiness_preimage(cert.body(), share.index()).unwrap(), + ), + }) + .collect(); let bytes = activation_preimage(cert.body(), &receipts).unwrap(); let activation = crate::ceremony::SignedStatement { - signature: signers.sign(&cert.anchor().expected_owner, &bytes), bytes, + signature: signers.sign(&cert.anchor().expected_owner, &bytes), + bytes, }; - verify_activation(&cert, &receipts, ReadyQuorum::AllHolders, &activation, &signers).unwrap(); - assert!(verify_activation(&cert, &receipts[..2], ReadyQuorum::AllHolders, &activation, &signers).is_err()); + verify_activation( + &cert, + &receipts, + ReadyQuorum::AllHolders, + &activation, + &signers, + ) + .unwrap(); + assert!(verify_activation( + &cert, + &receipts[..2], + ReadyQuorum::AllHolders, + &activation, + &signers + ) + .is_err()); println!("5. PASS: all-holder receipts activate; under-quorum receipt set does not"); } } + +#[cfg(test)] +mod vector_contracts { + use super::*; + use crate::{ + activation::{verify_activation, ReadinessReceipt, ReadyQuorum}, + ceremony::{tests::Signers, ClosedRound, Decode, SignedStatement}, + derive::{DecryptRequest, EncryptedKeyShare}, + fault::{verify_fault_proof, FaultProof, FaultVerdict, NotAFaultReason}, + }; + use serde_json::Value; + + fn bytes(value: &Value) -> Vec { + hex::decode(value.as_str().unwrap()).unwrap() + } + fn statement(body: &Value, signature: &Value) -> SignedStatement { + SignedStatement { + bytes: bytes(body), + signature: bytes(signature), + } + } + fn fixed(value: &Value) -> [u8; 32] { + bytes(value).try_into().unwrap() + } + struct Payload; + impl Decode for Payload { + fn decode(_: &[u8]) -> Result { + Ok(Self) + } + } + + #[test] + fn published_certificate_vectors_execute_the_real_verification_contracts() { + let vectors: Value = + serde_json::from_str(include_str!("../vectors/v1/cert-core.json")).unwrap(); + let signers = Signers::new(); + let mut certificates = Vec::::new(); + for case in vectors["cases"].as_array().unwrap() { + let inputs = &case["inputs"]; + let outputs = &case["outputs"]; + match case["operation"].as_str().unwrap() { + "verify_certificate" => { + let body = CertificateBody::decode(&bytes(&inputs["body"])).unwrap(); + let anchor = TrustAnchor { + expected_owner: Did::new(inputs["expected_owner"].as_str().unwrap()) + .unwrap(), + network: NetworkUrn::new(inputs["network"].as_str().unwrap()).unwrap(), + }; + let signed = SignedCertificate { + body, + owner_signature: bytes(&inputs["owner_signature"]), + }; + let cert = if let Some(prev) = inputs["pinned_predecessor"].as_str() { + let pinned = certificates + .iter() + .find(|c| hex::encode(c.digest.as_bytes()) == prev) + .unwrap(); + verify_successor(pinned, &signed, &signers).unwrap() + } else { + verify_certificate(&anchor, &signed, &signers).unwrap() + }; + assert_eq!(cert.digest.as_bytes(), &fixed(&outputs["digest"])); + assert_eq!( + cert.body.owner_statement(), + bytes(&outputs["owner_statement"]) + ); + for share in outputs["public_shares"].as_array().unwrap() { + let index = + MemberIndex::new(share["index"].as_u64().unwrap() as u16).unwrap(); + assert_eq!( + cert.body.public.public_share(index).unwrap().as_slice(), + bytes(&share["point"]) + ); + } + certificates.push(cert); + } + "decode_certificate" => { + let error = CertificateBody::decode(&bytes(&inputs["body"])).unwrap_err(); + assert_eq!(format!("{error:?}"), case["error"].as_str().unwrap()); + } + "authorize_ceremony" => { + let signed = statement(&inputs["body"], &inputs["signature"]); + let result = if let Some(prev) = inputs["pinned_predecessor"].as_str() { + let pinned = certificates + .iter() + .find(|c| hex::encode(c.digest.as_bytes()) == prev) + .unwrap(); + AuthorizedCeremony::verify_for(pinned, &signed, &signers) + } else { + AuthorizedCeremony::verify_genesis(&signers.anchor(), &signed, &signers) + }; + if let Some(error) = case["error"].as_str() { + assert_eq!(format!("{:?}", result.unwrap_err()), error); + } else { + assert_eq!(result.unwrap().digest(), fixed(&outputs["digest"])); + } + } + "verify_closed_round" => { + let ceremony = signers.genesis(); + let previous = TranscriptHash::new(fixed(&inputs["previous"])); + let closure = statement(&inputs["closure"], &inputs["owner_signature"]); + let messages: Vec<_> = inputs["messages"] + .as_array() + .unwrap() + .iter() + .map(|m| statement(&m["body"], &m["signature"])) + .collect(); + let closed = ClosedRound::::verify( + &ceremony, + inputs["round"].as_u64().unwrap() as u8, + &previous, + &closure, + &messages, + &signers, + ) + .unwrap(); + assert_eq!( + closed.transcript_hash().as_bytes(), + &fixed(&outputs["transcript_hash"]) + ); + } + "verify_activation" => { + let cert = certificates + .iter() + .find(|c| c.digest.as_bytes() == &fixed(&inputs["candidate"])) + .unwrap(); + let receipts: Vec<_> = inputs["receipts"] + .as_array() + .unwrap() + .iter() + .map(|r| ReadinessReceipt { + index: MemberIndex::new(r["index"].as_u64().unwrap() as u16).unwrap(), + signature: bytes(&r["signature"]), + }) + .collect(); + let preimage = if case["error"].is_string() { + &inputs["activation_preimage"] + } else { + &outputs["activation_preimage"] + }; + let signed = statement(preimage, &inputs["owner_signature"]); + let q = inputs["budget"] + .as_u64() + .map(|b| ReadyQuorum::WithOutageBudget(b as u16)) + .unwrap_or(ReadyQuorum::AllHolders); + let result = verify_activation(cert, &receipts, q, &signed, &signers); + if let Some(error) = case["error"].as_str() { + assert_eq!(format!("{:?}", result.unwrap_err()), error); + } else { + result.unwrap(); + } + } + "verify_fault_proof" => { + let extra = if inputs["certificate_body"].is_string() { + let signed = SignedCertificate { + body: CertificateBody::decode(&bytes(&inputs["certificate_body"])) + .unwrap(), + owner_signature: bytes(&inputs["owner_signature"]), + }; + Some(verify_certificate(&signers.anchor(), &signed, &signers).unwrap()) + } else { + None + }; + let cert = extra.as_ref().unwrap_or_else(|| { + certificates + .iter() + .find(|c| c.digest.as_bytes() == &fixed(&inputs["certificate"])) + .unwrap() + }); + let proof = FaultProof { + request: DecryptRequest::decode(&bytes(&inputs["request"])).unwrap(), + share: EncryptedKeyShare::decode(&bytes(&inputs["share"])).unwrap(), + signature: bytes(&inputs["signature"]), + }; + let verdict = verify_fault_proof(cert, &proof, &signers); + let expected = if outputs["verdict"] == "Fault" { + FaultVerdict::Fault( + MemberIndex::new(outputs["member"].as_u64().unwrap() as u16).unwrap(), + ) + } else { + FaultVerdict::NotAFault(match outputs["reason"].as_str().unwrap() { + "ValidShare" => NotAFaultReason::ValidShare, + "ContextMismatch" => NotAFaultReason::ContextMismatch, + _ => panic!("unknown vector reason"), + }) + }; + assert_eq!(verdict, expected); + } + _ => panic!("unknown vector operation"), + } + } + } +} diff --git a/tinycloud-threshold/src/fault.rs b/tinycloud-threshold/src/fault.rs index 12e1b433..0f4273c8 100644 --- a/tinycloud-threshold/src/fault.rs +++ b/tinycloud-threshold/src/fault.rs @@ -1,10 +1,91 @@ -#[cfg(test)] -use crate::derive::DecryptRequest; +//! Attribution of member-signed responses under the anchored certificate. Details: TC-2222 + +use crate::{ + ceremony::{verify_signature, DidSignatureVerifier}, + cert::VerifiedCertificate, + derive::{verify_share, DecryptRequest, EncryptedKeyShare}, + error::ShareError, + ids::MemberIndex, +}; + +/// Member response, supplied request and raw node signature. Details: TC-2222 +#[derive(Clone, Debug)] +pub struct FaultProof { + /// Exact request answered by the encrypted share. Details: TC-2222 + pub request: DecryptRequest, + /// Request-digest-bound encrypted response. Details: TC-2222 + pub share: EncryptedKeyShare, + /// Signature over the amended fallible response preimage. Details: TC-2222 + pub signature: Vec, +} + +/// Reason that evidence does not establish the named member's fault. Details: TC-2222 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NotAFaultReason { + /// Request, share, member or certificate bindings differ. Details: TC-2222 + ContextMismatch, + /// Matching member signature is absent or invalid. Details: TC-2222 + InvalidSignature, + /// Both share equations hold. Details: TC-2222 + ValidShare, +} + +/// Only a bound, member-signed response can produce attribution. Details: TC-2222 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FaultVerdict { + /// Correct-context signed share fails an equation for this member. Details: TC-2222 + Fault(MemberIndex), + /// The supplied evidence does not establish a fault. Details: TC-2222 + NotAFault(NotAFaultReason), +} + +/// Check all bindings before the node signature, and the signature before algebra. Details: TC-2222 +pub fn verify_fault_proof( + cert: &VerifiedCertificate, + p: &FaultProof, + v: &impl DidSignatureVerifier, +) -> FaultVerdict { + let context = cert.public_key_set().context(); + let req = &p.request; + let share = &p.share; + let context_digest = context.digest(); + if req.context_digest != context_digest + || req.network != *context.network() + || req.identity.network() != context.network() + || req.rekey != context.rekey() + || req.epoch != context.epoch() + || req.certificate != *context.certificate() + || share.context_digest != context_digest + { + return FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch); + } + let Ok(member) = cert.member(share.index()) else { + return FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch); + }; + let Ok(preimage) = share.signing_preimage(req) else { + return FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch); + }; + if verify_signature(v, &member.node, &preimage, &p.signature).is_err() { + return FaultVerdict::NotAFault(NotAFaultReason::InvalidSignature); + } + match verify_share(cert.public_key_set(), req, share) { + Ok(()) => FaultVerdict::NotAFault(NotAFaultReason::ValidShare), + Err(ShareError::ContextMismatch) => { + FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch) + } + Err(ShareError::Invalid { member }) => FaultVerdict::Fault(member), + } +} #[cfg(test)] mod tests { use super::*; - use crate::{ceremony::{tests::Signers, CeremonyKind}, cert::tests, curve::{G1, G2}, ids::Threshold}; + use crate::{ + ceremony::{tests::Signers, CeremonyKind}, + cert::tests, + curve::{G1, G2}, + ids::Threshold, + }; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; @@ -14,31 +95,141 @@ mod tests { let (cert, shares) = tests::genesis(&signers); let mut rng = ChaCha20Rng::from_seed([61; 32]); let tsk = crate::TransportSecretKey::generate(&mut rng); - let req = DecryptRequest::new(&cert.key_context(), crate::checkpoint::identity(&cert.key_context(), "fault/key"), tsk.public_key()).unwrap(); - let valid = crate::issue_share(&shares[0], &crate::checkpoint::authorize(req.clone()), &mut rng).unwrap(); - let mut proof = FaultProof { signature: signers.sign(&Signers::did(1), &valid.signing_preimage(&req).unwrap()), request: req.clone(), share: valid.clone() }; - assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ValidShare)); + let req = DecryptRequest::new( + &cert.key_context(), + crate::checkpoint::identity(&cert.key_context(), "fault/key"), + tsk.public_key(), + ) + .unwrap(); + let valid = crate::issue_share( + &shares[0], + &crate::checkpoint::authorize(req.clone()), + &mut rng, + ) + .unwrap(); + let mut proof = FaultProof { + signature: signers.sign(&Signers::did(1), &valid.signing_preimage(&req).unwrap()), + request: req.clone(), + share: valid.clone(), + }; + assert_eq!( + verify_fault_proof(&cert, &proof, &signers), + FaultVerdict::NotAFault(NotAFaultReason::ValidShare) + ); for equation in 0..2 { proof.share = valid.clone(); - if equation == 0 { proof.share.c2 = proof.share.c2.add(&G2::generator()); } - else { proof.share.c3 = proof.share.c3.add(&G1::generator()); } - proof.signature = signers.sign(&Signers::did(1), &proof.share.signing_preimage(&req).unwrap()); - assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::Fault(shares[0].index())); + if equation == 0 { + proof.share.c2 = proof.share.c2.add(&G2::generator()); + } else { + proof.share.c3 = proof.share.c3.add(&G1::generator()); + } + proof.signature = signers.sign( + &Signers::did(1), + &proof.share.signing_preimage(&req).unwrap(), + ); + assert_eq!( + verify_fault_proof(&cert, &proof, &signers), + FaultVerdict::Fault(shares[0].index()) + ); } proof.signature[0] ^= 1; - assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::InvalidSignature)); + assert_eq!( + verify_fault_proof(&cert, &proof, &signers), + FaultVerdict::NotAFault(NotAFaultReason::InvalidSignature) + ); proof.share = valid.clone(); proof.signature = signers.sign(&Signers::did(1), &valid.signing_preimage(&req).unwrap()); - let (next, _, _) = tests::successor(&signers, &cert, CeremonyKind::Rekey, Threshold::new(2, 3).unwrap(), 12); + let (next, _, _) = tests::successor( + &signers, + &cert, + CeremonyKind::Rekey, + Threshold::new(2, 3).unwrap(), + 12, + ); let next = crate::cert::verify_successor(&cert, &next, &signers).unwrap(); - assert_eq!(verify_fault_proof(&next, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); - proof.request = DecryptRequest::new(&cert.key_context(), crate::checkpoint::identity(&cert.key_context(), "different/key"), tsk.public_key()).unwrap(); - assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); - assert!(matches!(proof.share.signing_preimage(&proof.request), Err(crate::Error::ContextMismatch))); - proof.request = DecryptRequest::new(&cert.key_context(), crate::checkpoint::identity(&cert.key_context(), "fault/key"), crate::TransportSecretKey::generate(&mut rng).public_key()).unwrap(); - assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); + assert_eq!( + verify_fault_proof(&next, &proof, &signers), + FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch) + ); + proof.request = DecryptRequest::new( + &cert.key_context(), + crate::checkpoint::identity(&cert.key_context(), "different/key"), + tsk.public_key(), + ) + .unwrap(); + assert_eq!( + verify_fault_proof(&cert, &proof, &signers), + FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch) + ); + assert!(matches!( + proof.share.signing_preimage(&proof.request), + Err(crate::Error::ContextMismatch) + )); + proof.request = DecryptRequest::new( + &cert.key_context(), + crate::checkpoint::identity(&cert.key_context(), "fault/key"), + crate::TransportSecretKey::generate(&mut rng).public_key(), + ) + .unwrap(); + assert_eq!( + verify_fault_proof(&cert, &proof, &signers), + FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch) + ); proof.request = req; proof.share.member = crate::ids::MemberIndex::new(4).unwrap(); - assert_eq!(verify_fault_proof(&cert, &proof, &signers), FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch)); + assert_eq!( + verify_fault_proof(&cert, &proof, &signers), + FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch) + ); + } +} + +#[cfg(test)] +mod replay_tests { + use super::*; + use crate::{ + ceremony::tests::Signers, + cert::{tests, CertificateBody}, + ids::NetworkUrn, + }; + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + + #[test] + fn genuine_response_is_not_a_fault_for_a_different_network_certificate() { + let signers = Signers::new(); + let (cert, shares) = tests::genesis(&signers); + let mut rng = ChaCha20Rng::from_seed([62; 32]); + let req = DecryptRequest::new( + &cert.key_context(), + crate::checkpoint::identity(&cert.key_context(), "replay/key"), + crate::TransportSecretKey::generate(&mut rng).public_key(), + ) + .unwrap(); + let share = crate::issue_share( + &shares[0], + &crate::checkpoint::authorize(req.clone()), + &mut rng, + ) + .unwrap(); + let proof = FaultProof { + signature: signers.sign(&Signers::did(1), &share.signing_preimage(&req).unwrap()), + request: req, + share, + }; + let mut encoded = cert.body().encode(); + encoded[5] = b'x'; + let body = CertificateBody::decode(&encoded).unwrap(); + let anchor = crate::ceremony::TrustAnchor { + network: NetworkUrn::new("xinycloud:test:certificates").unwrap(), + ..signers.anchor() + }; + let other = + crate::cert::verify_certificate(&anchor, &tests::sign(body, &signers), &signers) + .unwrap(); + assert_eq!( + verify_fault_proof(&other, &proof, &signers), + FaultVerdict::NotAFault(NotAFaultReason::ContextMismatch) + ); } } diff --git a/tinycloud-threshold/vectors/v1/cert-core.json b/tinycloud-threshold/vectors/v1/cert-core.json new file mode 100644 index 00000000..0c3b6a74 --- /dev/null +++ b/tinycloud-threshold/vectors/v1/cert-core.json @@ -0,0 +1,515 @@ +{ + "cases": [ + { + "inputs": { + "body": "010000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d300000000000000000000000000000000000020002000300030001000000186469643a6b65793a7463323232322d666978747572652d310002000000186469643a6b65793a7463323232322d666978747572652d320003000000186469643a6b65793a7463323232322d666978747572652d33b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f260002b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26804e74038644dc4cb66863b4c0aee636e5135b8807a02eb8ad69df866ce621a10522a9a9c6113800bf154484c290406d0995867148eb0cfce714a54edd74c5866aaca1cdcd2b0b697f98d175c51156b727d1dcce86294f4cc2484d57e168831b33333333333333333333333333333333333333333333333333333333333333330000", + "expected_owner": "did:key:tc2222-fixture-0", + "network": "tinycloud:test:certificates", + "owner_signature": "8b2ba369370c6e657546de5be183154a0b656aaaa3e3bbf80ee03f93cfaa5201371469f8a37ebc8faff966d7a25c4566ea83792c271f7d159ad1b1c900843903", + "pinned_predecessor": null + }, + "name": "genesis-2of3", + "operation": "verify_certificate", + "outputs": { + "commitments": [ + "b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26", + "804e74038644dc4cb66863b4c0aee636e5135b8807a02eb8ad69df866ce621a10522a9a9c6113800bf154484c290406d0995867148eb0cfce714a54edd74c5866aaca1cdcd2b0b697f98d175c51156b727d1dcce86294f4cc2484d57e168831b" + ], + "digest": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "epoch": 0, + "genesis_t": 2, + "members": 3, + "mpk": "b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26", + "owner_statement": "97a50ba6c908477e4fd85c64a9eff486b7498d1edfdc88cf80bb73ea221bde96", + "public_shares": [ + { + "index": 1, + "point": "a8e6fe5955d6d69b2ba5275c14faad458e2a9eb5ebd4c6bc6e91e2340efc8ef64954bd8bf23b47fdd9c870080501ea2f061edb1e5dd207594048b2a7e6bcce92c1b0e44d2a25097035d15d767443224e1541a518535a01244b383115bf8ee2db" + }, + { + "index": 2, + "point": "b6e92fe9a4a7aada5d4151e009ab0d35aab13cb579aeb7e7df7efff50206394be5de2e94149145193740621ad4b7382c0aff3791a0b888574045ee11061679cdfef9b081cc09794b60115f5181b07943816f0a39a2d54973c053d190c0261fc5" + }, + { + "index": 3, + "point": "acd61752315afa5019e2522faef66d5482eed81e18ca74ddb6df79a4cb40f4b074cb9c582deaa8f2744c0247deb9ebfe1673135cbf20b67132c87363f3706889a56f995fd23856bfa6fcdd0aebe437174b271d92afca73b14e377cff2a3caa42" + } + ], + "rekey": 0, + "threshold": 2 + } + }, + { + "inputs": { + "body": "010000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d300000000000000000000000000000000100020003000400040001000000186469643a6b65793a7463323232322d666978747572652d310002000000186469643a6b65793a7463323232322d666978747572652d320003000000186469643a6b65793a7463323232322d666978747572652d330004000000186469643a6b65793a7463323232322d666978747572652d34b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f260003b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26804e74038644dc4cb66863b4c0aee636e5135b8807a02eb8ad69df866ce621a10522a9a9c6113800bf154484c290406d0995867148eb0cfce714a54edd74c5866aaca1cdcd2b0b697f98d175c51156b727d1dcce86294f4cc2484d57e168831ba57fe3bd88c4bb06dca684bc9971b5bb781675041e046ce9f6e0da0e058a1862d9ed872f27165851018d690fe880842118c688d5b01dccb6a5e8e0314919ab77c579bc4c51f8c838c3d132e9dcd23ebac6f8f059a172957b3a3bcca8eea2848235353535353535353535353535353535353535353535353535353535353535350104e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f00", + "expected_owner": "did:key:tc2222-fixture-0", + "network": "tinycloud:test:certificates", + "owner_signature": "0ccfc0227d816d8bed6ffabb96a04223bdd4419f42cc206b98c7cc19b47bd99a4ba67ca7ee59c9677d231260fdb213adf6694a950b079d0b84a40ed0b9d5cf0b", + "pinned_predecessor": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f" + }, + "name": "reshare-3of4", + "operation": "verify_certificate", + "outputs": { + "commitments": [ + "b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26", + "804e74038644dc4cb66863b4c0aee636e5135b8807a02eb8ad69df866ce621a10522a9a9c6113800bf154484c290406d0995867148eb0cfce714a54edd74c5866aaca1cdcd2b0b697f98d175c51156b727d1dcce86294f4cc2484d57e168831b", + "a57fe3bd88c4bb06dca684bc9971b5bb781675041e046ce9f6e0da0e058a1862d9ed872f27165851018d690fe880842118c688d5b01dccb6a5e8e0314919ab77c579bc4c51f8c838c3d132e9dcd23ebac6f8f059a172957b3a3bcca8eea28482" + ], + "digest": "3ee4658caba290293573318065e52ed25ec99a9742cfa142198e42f32e3dd975", + "epoch": 1, + "genesis_t": 2, + "members": 4, + "mpk": "b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26", + "owner_statement": "ef26ddb8118c8d8132ca44d975beb137477cdaa00411d6efbb038665021ba321", + "public_shares": [ + { + "index": 1, + "point": "922848d3814c7ad950711072908704961b49f3b6308cfbf055ab14f92fffcf6403a86908e21978a8cbb95c81f14fdb2518b4af334729a27caf9a2ed0e2197619ae89615dc4ed6cd4d614c99456d3cb8f9e5430fae32f9e3a38f9f8b05a07440d" + }, + { + "index": 2, + "point": "a7ebd0be43d255ba677a71f15a9f82171d78402c1bd6a7a47392e60da30e3ddaee5a770a407255b45909f71d9a3d41b3029d406b524315512a73345b4f78284f8dab0250d9d06fb82020bb77d6fca78a1cd1be329a7ef2ff33516a22039ef503" + }, + { + "index": 3, + "point": "92692eba50529ad8729e57071a9cc7843dcd5efea93aa36247af5fe70c5b4c8bad6a39b5202cabf4effdc67dd8eed887069aeb6ce0945b5c03d530ad43e20b23d8db623dbbceffe28bd47f7cc64816b73266d50c983fc134c120320919963e75" + }, + { + "index": 4, + "point": "90faa4b86716c0ebe83a0e9e9230dd7e585cddbe3d6323abd625757acc3351d1caeb8793c85371f3a4d5a290f4b2aeef052fb73df5a904018ef0f2294b2d73aadfd98848beec25116047034007dc86fb76a2a74c2dc3d44dc13dcd0d0a34f7eb" + } + ], + "rekey": 0, + "threshold": 3 + } + }, + { + "inputs": { + "body": "010000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d300000000000000001000000000000000200020002000300030001000000186469643a6b65793a7463323232322d666978747572652d310002000000186469643a6b65793a7463323232322d666978747572652d320003000000186469643a6b65793a7463323232322d666978747572652d33a7694928d07cc1464e289593d0e6d2968f274f23410c1c3e059ccdbce038d9df59136bd63c32db08f7f1bf5acbaf20f00a71bb857bd6328adc2fd10c9cf4e20a2d46b40db94f2e4788628626ffd8761293e5a724d84369fe237b16e12d8604290002a7694928d07cc1464e289593d0e6d2968f274f23410c1c3e059ccdbce038d9df59136bd63c32db08f7f1bf5acbaf20f00a71bb857bd6328adc2fd10c9cf4e20a2d46b40db94f2e4788628626ffd8761293e5a724d84369fe237b16e12d8604298408cabb8cac14cbbb0d79e117745feff2f8dcaa63d421cfa1f42675b884a3d489076387e184d134e8d0e40de1cee54f0f0fc2c84030fa120a8a2ca5edb57a803818ccfdc2d00086ac400780ada89f48b08354151c33eceea09444397163edb23535353535353535353535353535353535353535353535353535353535353535013ee4658caba290293573318065e52ed25ec99a9742cfa142198e42f32e3dd97501b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26", + "expected_owner": "did:key:tc2222-fixture-0", + "network": "tinycloud:test:certificates", + "owner_signature": "d2a0b7ba25053c9809bc1091ba7e37aaff89e55c8875b88ee446917754ffa251f809b9dbc72f567f48e7a6be3683845fffd8bebf3b0a71ef61e79947da00b106", + "pinned_predecessor": "3ee4658caba290293573318065e52ed25ec99a9742cfa142198e42f32e3dd975" + }, + "name": "rekey-2of3", + "operation": "verify_certificate", + "outputs": { + "commitments": [ + "a7694928d07cc1464e289593d0e6d2968f274f23410c1c3e059ccdbce038d9df59136bd63c32db08f7f1bf5acbaf20f00a71bb857bd6328adc2fd10c9cf4e20a2d46b40db94f2e4788628626ffd8761293e5a724d84369fe237b16e12d860429", + "8408cabb8cac14cbbb0d79e117745feff2f8dcaa63d421cfa1f42675b884a3d489076387e184d134e8d0e40de1cee54f0f0fc2c84030fa120a8a2ca5edb57a803818ccfdc2d00086ac400780ada89f48b08354151c33eceea09444397163edb2" + ], + "digest": "1dfeded0ff47c7c763a63e1b3aa51c107a7d8512f6747f25c194bfafdca51555", + "epoch": 2, + "genesis_t": 2, + "members": 3, + "mpk": "a7694928d07cc1464e289593d0e6d2968f274f23410c1c3e059ccdbce038d9df59136bd63c32db08f7f1bf5acbaf20f00a71bb857bd6328adc2fd10c9cf4e20a2d46b40db94f2e4788628626ffd8761293e5a724d84369fe237b16e12d860429", + "owner_statement": "b1c56b0129f46977c677e703ac0530742a615a3e7c248981e07ceb059a1e1e6d", + "public_shares": [ + { + "index": 1, + "point": "b7cdf2f23393d72fd2cb2ffb63d383805d3940b84e7802242550dfd96a03bf5e69a83475088c0e96f7b6be03b482da6c01fbbdeafd96591eff6977d307be9adc89ad0f770c0a6913e5de5eeae7299d0752ede6434665f7ff3b846c6637f08a6f" + }, + { + "index": 2, + "point": "8f5580c34950552360e3936a70e2b3ab26da9bc1931c73b4e494c6a434de9504593a4dad0b4119f6063d588841802ce9042faa24471e8927be3f05868b5763923f15c0894c8fd6ffaa36d78dc8cccfeea410ef739d1638b553bdcc5680cbb092" + }, + { + "index": 3, + "point": "8b6d49e87fa2fa09421d3aca7d8a8f5cd41c3c660c13fc1816938eb92554bdf7b27e61dcf7ebd9d96431bdbe7f204701042266465c84cc099ce0c09d7bea44933c4ce7519e2e462995c0805dedb06452df4120d9461879aff77c27b8e3b80d2b" + } + ], + "rekey": 1, + "threshold": 2 + } + }, + { + "inputs": { + "body": "010129292929292929292929292929292929292929292929292929292929292929290000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d3000000200030000000000000000000000000000000000030001000000186469643a6b65793a7463323232322d666978747572652d3101010101010101010101010101010101010101010101010101010101010101010002000000186469643a6b65793a7463323232322d666978747572652d3202020202020202020202020202020202020202020202020202020202020202020003000000186469643a6b65793a7463323232322d666978747572652d330303030303030303030303030303030303030303030303030303030303030303", + "expected_owner": "did:key:tc2222-fixture-0", + "network": "tinycloud:test:certificates", + "pinned_predecessor": null, + "signature": "61a93131470441ce31ac72e2692dab05b01d9a8159a72a6353598e2e719e549d53bb3be06af711a4709b057ded976985ec4195c481aa4e0523e2b48a5726cd09" + }, + "name": "ceremony-epoch-0", + "operation": "authorize_ceremony", + "outputs": { + "digest": "ed9af308ec368e9f6cee2feb58e1cfcc860bf9630e2b897b4b6ac6cd4cd061ef", + "signing_preimage": "ed9af308ec368e9f6cee2feb58e1cfcc860bf9630e2b897b4b6ac6cd4cd061ef" + } + }, + { + "inputs": { + "body": "010229292929292929292929292929292929292929292929292929292929292929290000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d300104e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f000300040000000000000000000000000000000100040001000000186469643a6b65793a7463323232322d666978747572652d3101010101010101010101010101010101010101010101010101010101010101010002000000186469643a6b65793a7463323232322d666978747572652d3202020202020202020202020202020202020202020202020202020202020202020003000000186469643a6b65793a7463323232322d666978747572652d3303030303030303030303030303030303030303030303030303030303030303030004000000186469643a6b65793a7463323232322d666978747572652d340404040404040404040404040404040404040404040404040404040404040404", + "expected_owner": "did:key:tc2222-fixture-0", + "network": "tinycloud:test:certificates", + "pinned_predecessor": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "signature": "81d62d5c8e1a10e7319a24d2ead2f81d2eaf73f7177a22e6a6437c21ab27b635bfee5aad25129188bb4be7ae7c35562dcecd97b55f47d34109d6d390ca056c05" + }, + "name": "ceremony-epoch-1", + "operation": "authorize_ceremony", + "outputs": { + "digest": "a0ca5dc11c264a7310e562426676bbc1a116d64bb8379030589277eaad8609a1", + "signing_preimage": "a0ca5dc11c264a7310e562426676bbc1a116d64bb8379030589277eaad8609a1" + } + }, + { + "inputs": { + "body": "010429292929292929292929292929292929292929292929292929292929292929290000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d30013ee4658caba290293573318065e52ed25ec99a9742cfa142198e42f32e3dd975000200030000000000000001000000000000000200030001000000186469643a6b65793a7463323232322d666978747572652d3101010101010101010101010101010101010101010101010101010101010101010002000000186469643a6b65793a7463323232322d666978747572652d3202020202020202020202020202020202020202020202020202020202020202020003000000186469643a6b65793a7463323232322d666978747572652d330303030303030303030303030303030303030303030303030303030303030303", + "expected_owner": "did:key:tc2222-fixture-0", + "network": "tinycloud:test:certificates", + "pinned_predecessor": "3ee4658caba290293573318065e52ed25ec99a9742cfa142198e42f32e3dd975", + "signature": "04128aafac28cdceb1bd036e7589c2e47bf180f79bc52d2f73851db2cda443db6b0c1c4e19808a5061cd02afc9c3e5ad5cd928a59ded7a9493b64c599a7f5500" + }, + "name": "ceremony-epoch-2", + "operation": "authorize_ceremony", + "outputs": { + "digest": "ef95fff0b666e5b8aa504ee7e01ba236c36ae10d94f657be9acdce928cc2cbc6", + "signing_preimage": "ef95fff0b666e5b8aa504ee7e01ba236c36ae10d94f657be9acdce928cc2cbc6" + } + }, + { + "inputs": { + "ceremony_digest": "ed9af308ec368e9f6cee2feb58e1cfcc860bf9630e2b897b4b6ac6cd4cd061ef", + "closure": "01ed9af308ec368e9f6cee2feb58e1cfcc860bf9630e2b897b4b6ac6cd4cd061ef017ea69f6a8de2a2f7e3bc07e323d3c53dec4098e06ef89c2f4fcb058c007ddeb80000000000000000000000000000000000000000000000000000000000000000", + "messages": [ + { + "body": "01ed9af308ec368e9f6cee2feb58e1cfcc860bf9630e2b897b4b6ac6cd4cd061ef010100010000000101", + "signature": "c59e90588b4fb2bb93d0ba36a54048ae8fd55ffa75836f03c1fffee162942d25b68a0041692846ef50beb65a6b098d6ff921ced56743dcc2fd08745af84d2002" + }, + { + "body": "01ed9af308ec368e9f6cee2feb58e1cfcc860bf9630e2b897b4b6ac6cd4cd061ef010100020000000102", + "signature": "daf0b2785e2e227b87fa630c61db5bad55cd45913b942500486d3626340077c094ce60b68684f265ddeaa09e0c0de2b69d142fa9ff6620e256f48d188274fc00" + }, + { + "body": "01ed9af308ec368e9f6cee2feb58e1cfcc860bf9630e2b897b4b6ac6cd4cd061ef010100030000000103", + "signature": "4e70e236a6f0ebf65e92c2a4c142a69bfc51012dd92dc726ef2358ec5d02b7851cee7a5f9ba4fef8dcc939069e54f18a4ec16b8dcf2cd4fdbbce61ee3538bc08" + } + ], + "owner_signature": "de774abda92ddbded124d004ea6104e6c481ad3879c1ba44a7f2fbe5fdfb7033833951a21d6cf0c4eb3e3815405f41f9ff993161e87b028d39080b365a97870f", + "previous": "0000000000000000000000000000000000000000000000000000000000000000", + "round": 1 + }, + "name": "closed-round-1", + "operation": "verify_closed_round", + "outputs": { + "owner_preimage": "0c9f756f32fab6d23b037c3bb071e7d7ddbfbf09da54a33cb39cc5df8f07286a", + "transcript_hash": "3231f44c42204c8717dcf5135d6a6ab16a604394338d242dddf0fffcd5e8a5e1" + } + }, + { + "inputs": { + "candidate": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "owner_signature": "e51d95c0d3afed1c6ccc7af01bd6d2e730776c4ea4c3ae4e9ae2dc8db51219d18866e6916cfdae542520411fe9c9b0567a2d08ba8c1c73bfd7c1d577fffa1c0d", + "quorum": "all", + "receipts": [ + { + "encoding": "010001000000401c6921f4088aaf035556bf75ef41a90f0bf97289c22d5c439f712b2836db5e83226b55c1f1c8bccf5ccf74a9e9724d826219fb306d007acd32fdff02e0299600", + "index": 1, + "readiness_preimage": "0a310c77e09f6acd5a2666e7a4c2b928039908462bd8ea3f27451fafe7991884", + "receipt_hash": "d9242003c82f11ba1c9a27d70e986f2da9a69a8f642716dafd0b567147ecebd5", + "signature": "1c6921f4088aaf035556bf75ef41a90f0bf97289c22d5c439f712b2836db5e83226b55c1f1c8bccf5ccf74a9e9724d826219fb306d007acd32fdff02e0299600" + }, + { + "encoding": "01000200000040b0c4c826e9a8b3cf0e829cfaf28939e393be55a1ec8853ed28240936fc129ea1ad87093e384e0149288144fbbac29cdf0d1bc1619c63b85c9b6f1f952783650f", + "index": 2, + "readiness_preimage": "5027fd2d81cf76a0a7e60f4721bfa63b9097d7946cda2a9907d26c635bf9f386", + "receipt_hash": "9d14cf8886c2aca6db97a05c886dbdf7549f0953ee6012ff939403617701ad78", + "signature": "b0c4c826e9a8b3cf0e829cfaf28939e393be55a1ec8853ed28240936fc129ea1ad87093e384e0149288144fbbac29cdf0d1bc1619c63b85c9b6f1f952783650f" + }, + { + "encoding": "0100030000004001d1b36f584dde54759dc55de62c52aa5bc0934c9bc193b378cfda48f4e2158c1bcec72197f04493beedc819a9aaf06d47a03c09c58e554e7ab50d583fb67c0b", + "index": 3, + "readiness_preimage": "a26b61317be00cf71d3639ba70abbd1d68adbd26e4acafd2b1c102359f7e23b8", + "receipt_hash": "ea04ace1a545f6b62cdd04554219c3bd1b268e662c4b8d582f46efe5377ef956", + "signature": "01d1b36f584dde54759dc55de62c52aa5bc0934c9bc193b378cfda48f4e2158c1bcec72197f04493beedc819a9aaf06d47a03c09c58e554e7ab50d583fb67c0b" + } + ] + }, + "name": "activate-all-2of3", + "operation": "verify_activation", + "outputs": { + "accepted": true, + "activation_preimage": "c954345188b05bea296e0e75dfae7c5f50e9c2444a562cff0e24b21f3bd38b46" + } + }, + { + "error": "InvalidEncoding", + "inputs": { + "body": "010000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d300000000000000000000000000000000000020002000300030001000000186469643a6b65793a7463323232322d666978747572652d310002000000186469643a6b65793a7463323232322d666978747572652d310003000000186469643a6b65793a7463323232322d666978747572652d33b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f260002b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26804e74038644dc4cb66863b4c0aee636e5135b8807a02eb8ad69df866ce621a10522a9a9c6113800bf154484c290406d0995867148eb0cfce714a54edd74c5866aaca1cdcd2b0b697f98d175c51156b727d1dcce86294f4cc2484d57e168831b33333333333333333333333333333333333333333333333333333333333333330000" + }, + "name": "duplicate-member-did", + "operation": "decode_certificate" + }, + { + "error": "InvalidEncoding", + "inputs": { + "body": "010000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d300000000000000000000000000000000000020002000300030001000000186469643a6b65793a7463323232322d666978747572652d310002000000186469643a6b65793a7463323232322d666978747572652d320003000000186469643a6b65793a7463323232322d666978747572652d33b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f260002b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26804e74038644dc4cb66863b4c0aee636e5135b8807a02eb8ad69df866ce621a10522a9a9c6113800bf154484c290406d0995867148eb0cfce714a54edd74c5866aaca1cdcd2b0b697f98d175c51156b727d1dcce86294f4cc2484d57e168831b3333333333333333333333333333333333333333333333333333333333333333000000" + }, + "name": "trailing-certificate-byte", + "operation": "decode_certificate" + }, + { + "error": "InvalidCeremony", + "inputs": { + "body": "010229292929292929292929292929292929292929292929292929292929292929290000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d30016363636363636363636363636363636363636363636363636363636363636363000200030000000000000000000000000000000100030001000000186469643a6b65793a7463323232322d666978747572652d3101010101010101010101010101010101010101010101010101010101010101010002000000186469643a6b65793a7463323232322d666978747572652d3202020202020202020202020202020202020202020202020202020202020202020003000000186469643a6b65793a7463323232322d666978747572652d330303030303030303030303030303030303030303030303030303030303030303", + "pinned_predecessor": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "signature": "42a780e26e8b4c7146e9e71d5986fb1886fd1af7fd88cdcbf607f59872c07ddbb308d42f8579671290d6a4ad97f53a2349421c701ced12a2a488a43088c24d07" + }, + "name": "stale-ceremony-predecessor", + "operation": "authorize_ceremony" + }, + { + "inputs": { + "certificate": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "request": "014539c15bf79f78ce5fe04e6447082521ac112d258bf00821bb9e75b75bb037fe0000001b74696e79636c6f75643a746573743a6365727469666963617465730000000000000000000000000000000004e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f000000670000001a54432d5448524553484f4c442d56312d4942452d49442d454e4301010000001b74696e79636c6f75643a746573743a6365727469666963617465730000000c766563746f722f6661756c740000000000000004090909090909090909090909090909099585bd138318a9e5d365da6a55fd028c29539c48b3c0f99ddce77dffd6cea80488e75985aad5d4bdcee2733b38b468d6", + "share": "0100014539c15bf79f78ce5fe04e6447082521ac112d258bf00821bb9e75b75bb037fee50cb64f857ec32f51a10934bd1cc0ee7516c0eb0c4ea24b4dd85058fad6441dad38615712f87b8f4de3d36bf094dc7ec569e013e8d3462ef6039641d9c55cfea3f647a843544c5adf2d4ff33b930c1e9996c233d6a81f08d7a480a2a53f93ecf171322aa3e1877c7f02440efa3e8db1fbfeda430d9238dcf2f6af06bb02c32c168ad8c96b87f9bc0d968b08925fbb6c35f8a227d2d3b0b8ef84e85fc0cbddd59c49025ec00126498ce828539ce4d65d8a475e6be035b516f4dff6e67892342b733459de708508117d02c6167518aed2310321fab85c32a4aa4925dae22b442d", + "signature": "27b5e06a3e7f76608ac11e5661a40562b23e423690aab344d82f3713382ab7c45acb359064b800d59b28c6411b660b0a31286605f486a26282aaee2bb8c39602" + }, + "name": "signed-valid-share", + "operation": "verify_fault_proof", + "outputs": { + "signing_preimage": "38d4e4b8cf40b756aecabb0aa2feb53068daa636a865b27c5c594872cccb98c8", + "verdict": "NotAFault", + "reason": "ValidShare" + } + }, + { + "inputs": { + "certificate": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "request": "014539c15bf79f78ce5fe04e6447082521ac112d258bf00821bb9e75b75bb037fe0000001b74696e79636c6f75643a746573743a6365727469666963617465730000000000000000000000000000000004e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f000000670000001a54432d5448524553484f4c442d56312d4942452d49442d454e4301010000001b74696e79636c6f75643a746573743a6365727469666963617465730000000c766563746f722f6661756c740000000000000004090909090909090909090909090909099585bd138318a9e5d365da6a55fd028c29539c48b3c0f99ddce77dffd6cea80488e75985aad5d4bdcee2733b38b468d6", + "share": "0100014539c15bf79f78ce5fe04e6447082521ac112d258bf00821bb9e75b75bb037fee50cb64f857ec32f51a10934bd1cc0ee7516c0eb0c4ea24b4dd85058fad6441dad38615712f87b8f4de3d36bf094dc7ec569e013e8d3462ef6039641d9c55cfea3f647a843544c5adf2d4ff33b930c1e9996c233d6a81f08d7a480a2a53f93ecf171322aa3e1877c7f02440efa3e8db1fbfeda430d9238dcf2f6af06bb02c32c168ad8c96b87f9bc0d968b08925fbb6c35f8a227d2d3b0b8ef84e85fc0cbddd59c49025ec00126498ce828539ce4d65dabaf58064820c33df1a952e3de40a94a666f0f07bc527008014e213a21512e56e33ee82dd292adf20b08a7814a18d7f4", + "signature": "aa73893654d2a69955ea0eb0d35369128d183bfcc94ba385869ef5c5abdc937f0fc8c97546bfaef5645e45b3e299a8e2001d394e92b41655b0df9a8661b9080c" + }, + "name": "signed-bad-share", + "operation": "verify_fault_proof", + "outputs": { + "signing_preimage": "1b679b866128a578bacbacb699354cdc293ecb8191762247d6ab21f8ce477358", + "verdict": "Fault", + "member": 1 + } + }, + { + "inputs": { + "certificate_body": "010000001b74696e79636c6f75643a746573743a636572746966696361746573000000186469643a6b65793a7463323232322d666978747572652d300000000000000001000000000000000100020002000300030001000000186469643a6b65793a7463323232322d666978747572652d310002000000186469643a6b65793a7463323232322d666978747572652d320003000000186469643a6b65793a7463323232322d666978747572652d33a7694928d07cc1464e289593d0e6d2968f274f23410c1c3e059ccdbce038d9df59136bd63c32db08f7f1bf5acbaf20f00a71bb857bd6328adc2fd10c9cf4e20a2d46b40db94f2e4788628626ffd8761293e5a724d84369fe237b16e12d8604290002a7694928d07cc1464e289593d0e6d2968f274f23410c1c3e059ccdbce038d9df59136bd63c32db08f7f1bf5acbaf20f00a71bb857bd6328adc2fd10c9cf4e20a2d46b40db94f2e4788628626ffd8761293e5a724d84369fe237b16e12d8604298408cabb8cac14cbbb0d79e117745feff2f8dcaa63d421cfa1f42675b884a3d489076387e184d134e8d0e40de1cee54f0f0fc2c84030fa120a8a2ca5edb57a803818ccfdc2d00086ac400780ada89f48b08354151c33eceea09444397163edb235353535353535353535353535353535353535353535353535353535353535350104e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f01b8843eef09af04752ff7fb7311e00e8245d132fecd6d7191d801799e359e505072faf996c3a22b85bbf41e92bdac244a00e157246cacd65288575147db7a5a467dd56fa826076ae55e1007ba369872fb4d56d5c4052dcf96fdb27bc8003c5f26", + "owner_signature": "2f00a965212dea3f3e5977015738a9b3dec9b16efa55e7ac27a1c9daae7149005714029a056c0a408460ac6458b924114982c61acde4da7e238dfcaf93f9e601", + "request": "014539c15bf79f78ce5fe04e6447082521ac112d258bf00821bb9e75b75bb037fe0000001b74696e79636c6f75643a746573743a6365727469666963617465730000000000000000000000000000000004e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f000000670000001a54432d5448524553484f4c442d56312d4942452d49442d454e4301010000001b74696e79636c6f75643a746573743a6365727469666963617465730000000c766563746f722f6661756c740000000000000004090909090909090909090909090909099585bd138318a9e5d365da6a55fd028c29539c48b3c0f99ddce77dffd6cea80488e75985aad5d4bdcee2733b38b468d6", + "share": "0100014539c15bf79f78ce5fe04e6447082521ac112d258bf00821bb9e75b75bb037fee50cb64f857ec32f51a10934bd1cc0ee7516c0eb0c4ea24b4dd85058fad6441dad38615712f87b8f4de3d36bf094dc7ec569e013e8d3462ef6039641d9c55cfea3f647a843544c5adf2d4ff33b930c1e9996c233d6a81f08d7a480a2a53f93ecf171322aa3e1877c7f02440efa3e8db1fbfeda430d9238dcf2f6af06bb02c32c168ad8c96b87f9bc0d968b08925fbb6c35f8a227d2d3b0b8ef84e85fc0cbddd59c49025ec00126498ce828539ce4d65d8a475e6be035b516f4dff6e67892342b733459de708508117d02c6167518aed2310321fab85c32a4aa4925dae22b442d", + "signature": "27b5e06a3e7f76608ac11e5661a40562b23e423690aab344d82f3713382ab7c45acb359064b800d59b28c6411b660b0a31286605f486a26282aaee2bb8c39602" + }, + "name": "genuine-old-response-after-rekey", + "operation": "verify_fault_proof", + "outputs": { + "verdict": "NotAFault", + "reason": "ContextMismatch" + } + }, + { + "error": "NotEnoughReady", + "inputs": { + "activation_preimage": "c954345188b05bea296e0e75dfae7c5f50e9c2444a562cff0e24b21f3bd38b46", + "budget": null, + "candidate": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "owner_signature": "e51d95c0d3afed1c6ccc7af01bd6d2e730776c4ea4c3ae4e9ae2dc8db51219d18866e6916cfdae542520411fe9c9b0567a2d08ba8c1c73bfd7c1d577fffa1c0d", + "receipts": [ + { + "index": 1, + "signature": "1c6921f4088aaf035556bf75ef41a90f0bf97289c22d5c439f712b2836db5e83226b55c1f1c8bccf5ccf74a9e9724d826219fb306d007acd32fdff02e0299600" + }, + { + "index": 2, + "signature": "b0c4c826e9a8b3cf0e829cfaf28939e393be55a1ec8853ed28240936fc129ea1ad87093e384e0149288144fbbac29cdf0d1bc1619c63b85c9b6f1f952783650f" + } + ] + }, + "name": "activation-under-quorum", + "operation": "verify_activation" + }, + { + "error": "InvalidReadiness", + "inputs": { + "activation_preimage": "c954345188b05bea296e0e75dfae7c5f50e9c2444a562cff0e24b21f3bd38b46", + "budget": 0, + "candidate": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "owner_signature": "e51d95c0d3afed1c6ccc7af01bd6d2e730776c4ea4c3ae4e9ae2dc8db51219d18866e6916cfdae542520411fe9c9b0567a2d08ba8c1c73bfd7c1d577fffa1c0d", + "receipts": [ + { + "index": 1, + "signature": "1c6921f4088aaf035556bf75ef41a90f0bf97289c22d5c439f712b2836db5e83226b55c1f1c8bccf5ccf74a9e9724d826219fb306d007acd32fdff02e0299600" + }, + { + "index": 2, + "signature": "b0c4c826e9a8b3cf0e829cfaf28939e393be55a1ec8853ed28240936fc129ea1ad87093e384e0149288144fbbac29cdf0d1bc1619c63b85c9b6f1f952783650f" + }, + { + "index": 3, + "signature": "01d1b36f584dde54759dc55de62c52aa5bc0934c9bc193b378cfda48f4e2158c1bcec72197f04493beedc819a9aaf06d47a03c09c58e554e7ab50d583fb67c0b" + } + ] + }, + "name": "activation-zero-budget", + "operation": "verify_activation" + }, + { + "error": "InvalidReadiness", + "inputs": { + "activation_preimage": "c954345188b05bea296e0e75dfae7c5f50e9c2444a562cff0e24b21f3bd38b46", + "budget": 65535, + "candidate": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "owner_signature": "e51d95c0d3afed1c6ccc7af01bd6d2e730776c4ea4c3ae4e9ae2dc8db51219d18866e6916cfdae542520411fe9c9b0567a2d08ba8c1c73bfd7c1d577fffa1c0d", + "receipts": [ + { + "index": 1, + "signature": "1c6921f4088aaf035556bf75ef41a90f0bf97289c22d5c439f712b2836db5e83226b55c1f1c8bccf5ccf74a9e9724d826219fb306d007acd32fdff02e0299600" + }, + { + "index": 2, + "signature": "b0c4c826e9a8b3cf0e829cfaf28939e393be55a1ec8853ed28240936fc129ea1ad87093e384e0149288144fbbac29cdf0d1bc1619c63b85c9b6f1f952783650f" + }, + { + "index": 3, + "signature": "01d1b36f584dde54759dc55de62c52aa5bc0934c9bc193b378cfda48f4e2158c1bcec72197f04493beedc819a9aaf06d47a03c09c58e554e7ab50d583fb67c0b" + } + ] + }, + "name": "activation-budget-overflow", + "operation": "verify_activation" + }, + { + "error": "InvalidReadiness", + "inputs": { + "activation_preimage": "c954345188b05bea296e0e75dfae7c5f50e9c2444a562cff0e24b21f3bd38b46", + "budget": null, + "candidate": "04e8e9d6ee5ed5afd83017d9ef4b0fe3c7dd7e11cce8ef81e0ce5396575dd85f", + "owner_signature": "e51d95c0d3afed1c6ccc7af01bd6d2e730776c4ea4c3ae4e9ae2dc8db51219d18866e6916cfdae542520411fe9c9b0567a2d08ba8c1c73bfd7c1d577fffa1c0d", + "receipts": [ + { + "index": 1, + "signature": "1c6921f4088aaf035556bf75ef41a90f0bf97289c22d5c439f712b2836db5e83226b55c1f1c8bccf5ccf74a9e9724d826219fb306d007acd32fdff02e0299600" + }, + { + "index": 1, + "signature": "1c6921f4088aaf035556bf75ef41a90f0bf97289c22d5c439f712b2836db5e83226b55c1f1c8bccf5ccf74a9e9724d826219fb306d007acd32fdff02e0299600" + }, + { + "index": 2, + "signature": "b0c4c826e9a8b3cf0e829cfaf28939e393be55a1ec8853ed28240936fc129ea1ad87093e384e0149288144fbbac29cdf0d1bc1619c63b85c9b6f1f952783650f" + } + ] + }, + "name": "activation-duplicate-member", + "operation": "verify_activation" + } + ], + "dst": { + "activation": "TC-THRESHOLD-V1-ACTIVATE", + "ceremony": "TC-THRESHOLD-V1-CEREMONY-AUTH", + "certificate": "TC-THRESHOLD-V1-NETWORK-CERT", + "closure": "TC-THRESHOLD-V1-ROUND-CLOSE", + "message": "TC-THRESHOLD-V1-CEREMONY-MSG", + "readiness": "TC-THRESHOLD-V1-READY", + "statement": "TC-THRESHOLD-V1-CERT-STATEMENT", + "transcript": "TC-THRESHOLD-V1-TRANSCRIPT" + }, + "encoding": "lowercase hex; unsigned integers big-endian; lp=u32be length followed by bytes", + "format_version": 1, + "seeds": { + "ed25519": [ + { + "did": "did:key:tc2222-fixture-0", + "public_key": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29", + "seed": "0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "did": "did:key:tc2222-fixture-1", + "public_key": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c", + "seed": "0101010101010101010101010101010101010101010101010101010101010101" + }, + { + "did": "did:key:tc2222-fixture-2", + "public_key": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394", + "seed": "0202020202020202020202020202020202020202020202020202020202020202" + }, + { + "did": "did:key:tc2222-fixture-3", + "public_key": "ed4928c628d1c2c6eae90338905995612959273a5c63f93636c14614ac8737d1", + "seed": "0303030303030303030303030303030303030303030303030303030303030303" + }, + { + "did": "did:key:tc2222-fixture-4", + "public_key": "ca93ac1705187071d67b83c7ff0efe8108e8ec4530575d7726879333dbdabe7c", + "seed": "0404040404040404040404040404040404040404040404040404040404040404" + }, + { + "did": "did:key:tc2222-fixture-5", + "public_key": "6e7a1cdd29b0b78fd13af4c5598feff4ef2a97166e3ca6f2e4fbfccd80505bf1", + "seed": "0505050505050505050505050505050505050505050505050505050505050505" + }, + { + "did": "did:key:tc2222-fixture-6", + "public_key": "8a875fff1eb38451577acd5afee405456568dd7c89e090863a0557bc7af49f17", + "seed": "0606060606060606060606060606060606060606060606060606060606060606" + }, + { + "did": "did:key:tc2222-fixture-7", + "public_key": "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c", + "seed": "0707070707070707070707070707070707070707070707070707070707070707" + }, + { + "did": "did:key:tc2222-fixture-8", + "public_key": "1398f62c6d1a457c51ba6a4b5f3dbd2f69fca93216218dc8997e416bd17d93ca", + "seed": "0808080808080808080808080808080808080808080808080808080808080808" + }, + { + "did": "did:key:tc2222-fixture-9", + "public_key": "fd1724385aa0c75b64fb78cd602fa1d991fdebf76b13c58ed702eac835e9f618", + "seed": "0909090909090909090909090909090909090909090909090909090909090909" + }, + { + "did": "did:key:tc2222-fixture-10", + "public_key": "43a72e714401762df66b68c26dfbdf2682aaec9f2474eca4613e424a0fbafd3c", + "seed": "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a" + }, + { + "did": "did:key:tc2222-fixture-11", + "public_key": "66be7e332c7a453332bd9d0a7f7db055f5c5ef1a06ada66d98b39fb6810c473a", + "seed": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b" + }, + { + "did": "did:key:tc2222-fixture-12", + "public_key": "0b513ad9b4924015ca0902ed079044d3ac5dbec2306f06948c10da8eb6e39f2d", + "seed": "0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c" + }, + { + "did": "did:key:tc2222-fixture-13", + "public_key": "91a28a0b74381593a4d9469579208926afc8ad82c8839b7644359b9eba9a4b3a", + "seed": "0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d" + }, + { + "did": "did:key:tc2222-fixture-14", + "public_key": "0beef5a9e679e6a3e134fe27837bff32c7cb5f5d44ea09bcb0e542bad6a4c0cc", + "seed": "0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e" + }, + { + "did": "did:key:tc2222-fixture-15", + "public_key": "d9bf2148748a85c89da5aad8ee0b0fc2d105fd39d41a4c796536354f0ae2900c", + "seed": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f" + }, + { + "did": "did:key:tc2222-fixture-16", + "public_key": "5c9c6df261c9cb840475776aaefcd944b405328fab28f9b3a95ef40490d3de84", + "seed": "1010101010101010101010101010101010101010101010101010101010101010" + } + ], + "test_dealer_genesis": 11, + "test_dealer_rekey": 12, + "test_dealer_reshare": 11 + }, + "spec_sections": [ + "3.0", + "3.8", + "3.9", + "3.12", + "S0.1 amendment" + ], + "suite": "TC-THRESHOLD-V1" +} From 999ec5dfbf7ba31cfd69448beb00175d91256a5c Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:37:36 +0000 Subject: [PATCH 3/5] chore(threshold): wire certificate core on main Details: TC-2222 --- Cargo.lock | 1 + tinycloud-threshold/Cargo.toml | 1 + tinycloud-threshold/src/error.rs | 36 ++++++++++++++++++++++++++++++++ tinycloud-threshold/src/lib.rs | 17 ++++++++++++++- 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 13497c8b..6c775ae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10094,6 +10094,7 @@ name = "tinycloud-threshold" version = "0.1.0" dependencies = [ "blst", + "ed25519-dalek", "hex", "proptest", "rand_chacha 0.3.1", diff --git a/tinycloud-threshold/Cargo.toml b/tinycloud-threshold/Cargo.toml index 9281a1be..a938d951 100644 --- a/tinycloud-threshold/Cargo.toml +++ b/tinycloud-threshold/Cargo.toml @@ -16,6 +16,7 @@ rand_core = "0.6" thiserror = "2" [dev-dependencies] +ed25519-dalek = "=2.2.0" proptest = "1.6" rand_chacha = "0.3" serde = { version = "1", features = ["derive"] } diff --git a/tinycloud-threshold/src/error.rs b/tinycloud-threshold/src/error.rs index 6341bf64..6a5afb2d 100644 --- a/tinycloud-threshold/src/error.rs +++ b/tinycloud-threshold/src/error.rs @@ -36,6 +36,18 @@ pub enum Error { InvalidCiphertext, #[error("derived key does not match the request")] InvalidDerivedKey, + #[error("invalid signature")] + InvalidSignature, + #[error("invalid ceremony")] + InvalidCeremony, + #[error("invalid round")] + InvalidRound, + #[error("invalid readiness receipt")] + InvalidReadiness, + #[error("not enough ready holders")] + NotEnoughReady, + #[error("candidate does not match certificate")] + CandidateMismatch, } /// Verification distinguishes context selection from a member's share equation. @@ -59,3 +71,27 @@ pub enum CombineError { #[error("not enough distinct shares")] NotEnoughShares, } + +/// Failure reported by the caller's DID signature verifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum SignatureError { + #[error("invalid signature")] + Invalid, +} + +/// Certificate validation against an external anchor and pinned predecessor. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CertError { + #[error(transparent)] + Invalid(#[from] Error), + #[error("certificate anchor mismatch")] + AnchorMismatch, + #[error("invalid certificate signature")] + InvalidSignature, + #[error("invalid certificate lineage")] + InvalidLineage, + #[error("invalid certificate members")] + InvalidMembers, + #[error("invalid certificate commitments")] + InvalidCommitments, +} diff --git a/tinycloud-threshold/src/lib.rs b/tinycloud-threshold/src/lib.rs index d93316ce..bc623b7a 100644 --- a/tinycloud-threshold/src/lib.rs +++ b/tinycloud-threshold/src/lib.rs @@ -14,6 +14,9 @@ ) )] +pub mod activation; +pub mod ceremony; +pub mod cert; pub mod context; #[allow(unsafe_code)] pub mod curve; @@ -21,14 +24,26 @@ pub mod derive; pub mod dst; mod encoding; pub mod error; +pub mod fault; pub mod ibe; pub mod identity; pub mod ids; mod poly; +pub use activation::{ + activation_preimage, readiness_preimage, verify_activation, ReadinessReceipt, ReadyQuorum, +}; +pub use ceremony::{ + AuthorizedCeremony, ClosedRound, DidSignatureVerifier, SignedStatement, TrustAnchor, +}; +pub use cert::{ + verify_certificate, verify_successor, CandidateShare, CertMember, CertificateBody, + PublicMaterial, SignedCertificate, VerifiedCertificate, +}; pub use context::{AuthorizedDerivation, KeyContext}; pub use derive::*; -pub use error::{CombineError, Error, ShareError}; +pub use error::{CertError, CombineError, Error, ShareError, SignatureError}; +pub use fault::{verify_fault_proof, FaultProof, FaultVerdict, NotAFaultReason}; pub use ibe::*; pub use identity::IbeIdentity; pub use ids::*; From 6b409d647b85bf32e42c4f1d9ba2d8f8342ce435 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:41:22 +0000 Subject: [PATCH 4/5] docs(threshold): describe certificate core byte layouts Details: TC-2222 --- tinycloud-threshold/README.md | 12 ++- tinycloud-threshold/SPEC.md | 133 +++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 4 deletions(-) diff --git a/tinycloud-threshold/README.md b/tinycloud-threshold/README.md index dca83576..53879070 100644 --- a/tinycloud-threshold/README.md +++ b/tinycloud-threshold/README.md @@ -4,15 +4,18 @@ 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. -## S0.1 surface +## Implemented surface through S0.2 - 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). - Capability-asserted derivation requests, individual member-share verification, lowest-index threshold interpolation and reader unmasking. - Keys retain their identity and certificate/generation context. Shares carry both the key-context digest and the request digest. - The blst FFI boundary is confined to `src/curve.rs`; its header inventories the entry points and buffer invariants. +- 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. -`KeyContext`, `PublicKeySet` and `KeyShare` have no public S0.1 constructor. The dealer exists only under `#[cfg(test)]`. Certificate, ceremony, storage and node-runtime paths are outside this slice. Their protocol contracts are recorded in SPEC.md §§3.7–3.12. +`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). 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. @@ -23,6 +26,7 @@ Use the workspace's pinned Rust 1.97.1 toolchain: ```sh cargo test --locked -p tinycloud-threshold cargo test -p tinycloud-threshold checkpoint_2of3 -- --nocapture +cargo test --locked -p tinycloud-threshold certificate_authorization_share_fault_activation_smoke -- --nocapture cargo clippy -p tinycloud-threshold --all-targets -- -D warnings cargo fmt --all -- --check PROPTEST_CASES=1024 cargo test --locked -p tinycloud-threshold --release @@ -43,8 +47,12 @@ TC_THRESHOLD_REGEN_VECTORS=1 cargo test -p tinycloud-threshold primitive_vectors `rfc9380-{g1,g2,k1}.json` contains the published Appendix J.9.1, J.10.1 and K.1 cases. The separate-session checker consumes SPEC.md and the JSON vector format, without consulting the Rust implementation. Its CI entry point is `bun install --frozen-lockfile && bun run check` in `vectors/check/`. +`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. + ## 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). +Certificate tests additionally use development-only `ed25519-dalek = "=2.2.0"` for signing and verification. It is included in the crate-scoped dependency policy below. + The crate-scoped `deny.toml` includes development and build dependencies. It allows Apache-2.0, MIT, BSD-3-Clause, ISC and Unicode-3.0, with a hash-pinned EGPL clarification and exception for this crate only. Registry sources are restricted to crates.io. diff --git a/tinycloud-threshold/SPEC.md b/tinycloud-threshold/SPEC.md index 0ba88630..8c7406f3 100644 --- a/tinycloud-threshold/SPEC.md +++ b/tinycloud-threshold/SPEC.md @@ -215,6 +215,135 @@ 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 +## Implemented surface through S0.2 -S0.1 implements §§3.1–3.6 and the typed parameters in §3.0. §§3.7–3.12 specify later slices and integration contracts; no ceremony, certificate, activation, storage or node-runtime implementation is included here. `KeyContext` and `KeyShare` have no public constructor in S0.1. The dealer is test-only. +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: + +- `TrustAnchor` and the injected `DidSignatureVerifier` establish the owner/network and signature inputs. +- `AuthorizedCeremony` verifies owner-authorized genesis and successor statements. `ClosedRound` verifies the signed member envelopes, owner closure, ordered-message commitment and previous transcript hash. +- `verify_certificate` validates decoded certificate fields and the external anchor. `verify_successor` checks a pinned predecessor and generation/lineage fields. +- `VerifiedCertificate` supplies `KeyContext` and `PublicKeySet`; `KeyShare::bind` checks a candidate's transcript, member index and scalar against that certificate's public share. Candidate scalar construction remains crate-private. +- 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.2 canonical byte appendix + +All integers below are unsigned big-endian. `lp(x)` is `len(x):u32 || x`; strings use exact UTF-8 without normalization. `option(x)` is `00` when absent or `01 || x` when present; other markers are rejected. Symbolic tag names refer to the literal domain strings in §3.3, and `TH` is defined in §3.2. + +Directory indices are contiguous `1..=n`, strictly increasing, with distinct node DIDs within each directory. Thresholds satisfy `2 <= t <= n <= 16`. DIDs contain 1..=256 bytes; network URNs contain 1..=1024 bytes. Signatures are at most 512 bytes, each message at most 65536 bytes, and ordered round/transcript inputs at most 4194304 bytes. Decoding checks lengths before allocation and rejects trailing bytes. Point encodings and role rules remain those of §3.2. + +### Owner-authorized ceremony + +The canonical authorization body is: + +```text +01 || kind:u8 || ceremony_id:32 || lp(network) || lp(owner) + || option(current_certificate_digest:32) + || t:u16 || n:u16 || rekey:u64 || epoch:u64 || count:u16 + || [(index:u16 || lp(node_DID) || ceremony_HPKE_public_key:32)] +``` + +Kinds are genesis `1`, reshare `2`, refresh `3`, and re-key `4`. The owner signs `TH(CEREMONY_AUTH, body)`; those same 32 bytes are the authorized-ceremony digest. The raw `SignedStatement.bytes` contains the canonical body, with its signature stored separately. + +Genesis has no current certificate, zero counters and `n >= 2t-1`. A successor names the exact externally pinned certificate and uses checked `epoch+1`; re-key also uses checked `rekey+1` and the genesis parameter budget. Other successor kinds preserve the re-key counter. Owner and network match the supplied anchor. + +### Member envelope and closed round + +The canonical member envelope is: + +```text +01 || authorized_ceremony_digest:32 || round:u8 || cohort:u8 + || index:u16 || lp(payload) +``` + +The member signs `TH(CEREMONY_MSG, exact envelope)`. Cohort `1` identifies authorized recipients; cohort `2` identifies current-certificate dealers. Resolve the index and DID only in the signed cohort, with no fallback. Genesis/re-key use recipients only. Reshare/refresh consumers apply the phase roles: R1/R3 old dealers and R2 new recipients. A member in both cohorts has distinct signed roles. + +After validating the canonical outer envelope, ceremony/round/cohort/member and signature, a closed round retains the exact raw `SignedStatement` and the inner payload's `Result`. Authenticated malformed inner payloads, byte-identical retransmissions and distinct messages from one sender remain in owner order for later phase-specific handling. + +The ordered message-list encoding and hash are: + +```text +ordered_list = count:u16 || [lp(exact member envelope) || lp(member signature)] +ordered_message_list_hash = TH(TRANSCRIPT, ordered_list) +``` + +The list preserves owner order; it is not sorted by member. The canonical owner-closure body is: + +```text +01 || authorized_ceremony_digest:32 || round:u8 + || ordered_message_list_hash:32 || previous_transcript_hash:32 +``` + +The owner signs `TH(ROUND_CLOSE, exact closure body)`. Closure verification checks the supplied previous hash, ceremony, round and ordered-list commitment. The transcript update is: + +```text +TH(TRANSCRIPT, + previous_hash:32 || lp(exact closure body) || lp(owner closure signature)) +``` + +The trusted owner-relay model in §3.0 applies. + +### Certificate body and candidate binding + +The canonical certificate body is: + +```text +01 || lp(network) || lp(owner) || rekey:u64 || epoch:u64 + || genesis_t:u16 || t:u16 || n:u16 || member_count:u16 + || [(index:u16 || lp(node_DID))] + || mpk:96 || commitment_count:u16 + || [commitment:96 in ascending polynomial degree] + || transcript_hash:32 || option(previous_certificate_digest:32) + || option(previous_mpk:96) +``` + +The certificate digest is `TH(NETWORK_CERT, body)`. The owner signs `TH(CERT_STATEMENT, certificate_digest:32)`. + +Certificates contain no HPKE keys. The commitment count equals `t`; the constant equals `mpk`; constant/top coefficients and evaluated public shares are non-identity. Genesis has absent predecessor fields and zero counters. A pinned successor has the exact previous digest and checked `epoch+1`. Reshare/refresh preserve `mpk`, `rekey` and `genesis_t`; re-key changes `mpk`, names the previous `mpk`, increments `rekey` and sets `genesis_t` to the new `t`. + +Verification uses the caller's expected owner/network. The caller's authorization-log integration selects the current pinned certificate. Candidate binding checks transcript and member index, then `scalar*g2` against the certificate's evaluated public share. + +### Signed response and fault classification + +The S0.1 amended 259-byte share and signing body remain unchanged: + +```text +share = 01 || member:u16 || context_digest:32 || request_digest:32 + || c1:48 || c2:96 || c3:48 +preimage = TH(DECRYPT_SHARE, + request_digest:32 || certificate_digest:32 || member:u16 || encode(share)) +``` + +`EncryptedKeyShare::signing_preimage` returns `Result, Error>` and requires equality between the stored and supplied request digests before computing the signing hash. A mismatch returns `ContextMismatch`. Fault verification maps certificate-context, binding/helper or signature failures to `NotAFault` before algebra; only a correctly bound, member-signed response that fails a share equation yields `Fault(member)`. + +### Readiness receipts and activation + +The readiness signing preimage is: + +```text +TH(READY, + candidate_certificate_digest:32 || transcript_hash:32 + || index:u16 || candidate_public_share:96) +``` + +The canonical signed receipt and its hash are: + +```text +receipt = 01 || index:u16 || lp(signature) +receipt_hash = TH(READY, exact receipt encoding) +``` + +`verify_activation` rejects duplicate/nonmember indices and verifies **every** supplied node signature against the exact candidate/transcript/index/public-share readiness preimage before quorum evaluation, receipt hashing or sorting. Duplicate indices or repeated signatures add no vote. + +Sort the resulting 32-byte receipt hashes in binary lexicographic order, independent of input order: + +```text +activation.bytes = TH(ACTIVATE, + candidate_certificate_digest:32 || concatenated_sorted_receipt_hashes) +``` + +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. From 5cd7867d831fb153c9dc522031caf3857b2aa9d2 Mon Sep 17 00:00:00 2001 From: "tinycloud-bot[bot]" <348376441+tinycloud-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:29:38 +0000 Subject: [PATCH 5/5] test(threshold): verify certificate owner signatures. Details: TC-2222 test-ruling: TC-2222 Details: TC-2222 --- tinycloud-threshold/src/cert.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tinycloud-threshold/src/cert.rs b/tinycloud-threshold/src/cert.rs index d19b96a6..b28b2514 100644 --- a/tinycloud-threshold/src/cert.rs +++ b/tinycloud-threshold/src/cert.rs @@ -689,6 +689,32 @@ pub(crate) mod tests { .is_err()); } + #[test] + fn certificate_rejects_wrong_owner_signature() { + let signers = Signers::new(); + let (cert, _) = genesis(&signers); + let non_owner = Signers::did(9); + let preimage = cert.body.owner_statement(); + let wrong_owner = SignedCertificate { + body: cert.body.clone(), + owner_signature: signers.sign(&non_owner, &preimage), + }; + signers + .verify(&non_owner, &preimage, &wrong_owner.owner_signature) + .unwrap(); + assert!(matches!( + verify_certificate(&signers.anchor(), &wrong_owner, &signers), + Err(CertError::InvalidSignature) + )); + + let mut tampered = sign(cert.body.clone(), &signers); + tampered.owner_signature[0] ^= 1; + assert!(matches!( + verify_certificate(&signers.anchor(), &tampered, &signers), + Err(CertError::InvalidSignature) + )); + } + #[test] fn decoded_certificate_invariants_apply_before_verified_contexts_exist() { let signers = Signers::new();