Skip to main content

confium_signatif/
fta.rs

1//! Federated trust authorities and nested threshold (SIGNATIF §10).
2//!
3//! A federated trust authority (FTA) is a threshold group of
4//! **independent organizations** sharing a single aggregate key: M-of-K
5//! organizations must cooperate, and each organization may itself be a
6//! threshold authority (nested threshold). No single organization
7//! controls the authority, and an FTA may span hierarchies — its
8//! artifacts are verifiable against the FTA's aggregate key and
9//! recognized by all member hierarchies.
10//!
11//! Ceremony robustness (`ceremony-robustness`, `rogue-key-prevention`):
12//!
13//! - every member demonstrates **proof of possession** of the private
14//!   key behind its contributed share during key generation, so a
15//!   malicious member cannot register a crafted key that makes the
16//!   aggregate equal to its solo key;
17//! - **nonce commitment** (commit-then-reveal) prevents nonce bias;
18//! - **identifiable abort** attributes a failed ceremony to the member
19//!   causing the failure.
20
21use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25
26use crate::error::{SignatifError, SignatifResult};
27use crate::graph::{Quorum, SignatureVerifier};
28
29/// One member organization of a federated trust authority.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct MemberOrg {
32    /// Organization identity.
33    pub id: String,
34    /// The organization's contribution public key (its share or its
35    /// inner authority's aggregate key when nested).
36    pub public_key: Vec<u8>,
37    /// The inner quorum when this member is itself a threshold
38    /// authority (nested threshold).
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub inner_quorum: Option<Quorum>,
41}
42
43/// A federated trust authority: M-of-K organizations, one aggregate key.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct FederatedTrustAuthority {
46    /// FTA identifier.
47    pub id: String,
48    /// Member organizations.
49    pub members: Vec<MemberOrg>,
50    /// Organizational quorum: M of K organizations must cooperate.
51    pub org_quorum: Quorum,
52    /// The FTA's aggregate public key.
53    pub aggregate_key: Vec<u8>,
54    /// Whether members may come from independent root hierarchies
55    /// (hierarchy-spanning FTA — a trust bridge).
56    pub hierarchy_spanning: bool,
57}
58
59impl FederatedTrustAuthority {
60    /// Validate structural invariants: M <= K <= members, unique
61    /// member ids, and — for nested members — inner quorum sanity.
62    ///
63    /// # Errors
64    ///
65    /// Encoding errors with a precise message for each violation.
66    pub fn validate(&self) -> SignatifResult<()> {
67        if self.org_quorum.t == 0 || self.org_quorum.t as usize > self.members.len() {
68            return Err(SignatifError::Encoding(format!(
69                "FTA {}: org quorum {} of {} inconsistent with {} members",
70                self.id,
71                self.org_quorum.t,
72                self.org_quorum.n,
73                self.members.len()
74            )));
75        }
76        if self.org_quorum.n as usize != self.members.len() {
77            return Err(SignatifError::Encoding(format!(
78                "FTA {}: K ({}) must equal member count ({})",
79                self.id,
80                self.org_quorum.n,
81                self.members.len()
82            )));
83        }
84        let mut seen = std::collections::BTreeSet::new();
85        for m in &self.members {
86            if !seen.insert(m.id.clone()) {
87                return Err(SignatifError::Encoding(format!(
88                    "FTA {}: duplicate member {}",
89                    self.id, m.id
90                )));
91            }
92            if let Some(q) = m.inner_quorum {
93                Quorum::new(q.t, q.n)?;
94            }
95        }
96        Ok(())
97    }
98
99    /// Whether signing by this FTA requires the given inner authority
100    /// to first reach its own quorum (nested threshold composition).
101    pub fn requires_inner_quorum(&self, org: &str) -> Option<Quorum> {
102        self.members
103            .iter()
104            .find(|m| m.id == org)
105            .and_then(|m| m.inner_quorum)
106    }
107}
108
109/// Proof of possession contributed during DKG: the member's signature
110/// over the session binding (authority id + nonce + its public key).
111/// Verifying every contribution against its claimed key is the
112/// rogue-key defense.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct ProofOfPossession {
115    /// Contributing member.
116    pub member: String,
117    /// The member's public key.
118    pub public_key: Vec<u8>,
119    /// Signature over the session binding bytes.
120    pub signature: Vec<u8>,
121}
122
123/// A DKG session for forming (or re-sharing) an FTA.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct DkgSession {
126    /// The authority being formed.
127    pub authority_id: String,
128    /// Session nonce — unique per ceremony.
129    pub session_nonce: [u8; 32],
130    /// The ceremony type (dkg, reshare).
131    pub ceremony_type: String,
132}
133
134impl DkgSession {
135    /// The bytes each member's proof of possession signs.
136    pub fn pop_bytes(&self, member: &str, public_key: &[u8]) -> Vec<u8> {
137        let mut bytes = Vec::new();
138        bytes.extend_from_slice(self.authority_id.as_bytes());
139        bytes.push(0);
140        bytes.extend_from_slice(&self.session_nonce);
141        bytes.push(0);
142        bytes.extend_from_slice(member.as_bytes());
143        bytes.push(0);
144        bytes.extend_from_slice(public_key);
145        bytes
146    }
147
148    /// Verify one contribution's proof of possession.
149    fn verify_pop(&self, pop: &ProofOfPossession, verifier: &dyn SignatureVerifier) -> bool {
150        verifier.verify(
151            &pop.public_key,
152            &self.pop_bytes(&pop.member, &pop.public_key),
153            &pop.signature,
154        )
155    }
156
157    /// Verify every contribution: each proof of possession checks out
158    /// against its claimed key, and at least T of N members contributed.
159    /// This is the rogue-key-attack prevention requirement.
160    ///
161    /// # Errors
162    ///
163    /// Ceremony errors naming the first offending member.
164    pub fn verify_contributions(
165        &self,
166        pops: &[ProofOfPossession],
167        quorum: Quorum,
168        verifier: &dyn SignatureVerifier,
169    ) -> SignatifResult<()> {
170        let mut distinct = std::collections::BTreeSet::new();
171        for pop in pops {
172            if !self.verify_pop(pop, verifier) {
173                return Err(SignatifError::Ceremony(format!(
174                    "rogue-key defense: member {} failed proof of possession",
175                    pop.member
176                )));
177            }
178            distinct.insert(pop.member.clone());
179        }
180        if distinct.len() < quorum.t as usize {
181            return Err(SignatifError::Ceremony(format!(
182                "quorum not met: {} of {} required, {} contributed",
183                quorum.t,
184                quorum.n,
185                distinct.len()
186            )));
187        }
188        Ok(())
189    }
190}
191
192/// A commit-then-reveal nonce commitment preventing nonce bias.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct NonceCommitment {
195    /// Committing member.
196    pub member: String,
197    /// SHA-256 commitment over the secret nonce.
198    pub commitment: [u8; 32],
199}
200
201/// The reveal phase of a nonce commitment.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct NonceReveal {
204    /// Revealing member.
205    pub member: String,
206    /// The secret nonce.
207    pub nonce: [u8; 32],
208}
209
210impl NonceCommitment {
211    /// Commit to a nonce.
212    pub fn commit(member: impl Into<String>, nonce: &[u8; 32]) -> Self {
213        Self {
214            member: member.into(),
215            commitment: Sha256::digest(nonce).into(),
216        }
217    }
218
219    /// Verify a reveal against this commitment.
220    pub fn verify(&self, reveal: &NonceReveal) -> bool {
221        self.member == reveal.member
222            && self.commitment == <[u8; 32]>::from(Sha256::digest(reveal.nonce))
223    }
224}
225
226/// Identifiable abort: which member caused a ceremony failure and why.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228pub struct AbortAttribution {
229    /// The member at fault.
230    pub faulty_member: String,
231    /// Machine-readable reason.
232    pub reason: AbortReason,
233}
234
235/// Why a ceremony aborted.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum AbortReason {
239    /// The member's proof of possession failed.
240    BadProofOfPossession,
241    /// The member's nonce reveal did not match its commitment.
242    NonceCommitmentMismatch,
243    /// The member failed to respond in its round.
244    Timeout,
245    /// The member's round contribution was malformed.
246    MalformedContribution,
247}
248
249/// Attribute a failed DKG: find the first member whose contribution
250/// fails (identifiable abort), or attribute a timeout to the members
251/// that never contributed.
252///
253/// # Errors
254///
255/// Returns a ceremony error carrying the attribution.
256pub fn attribute_dkg_failure(
257    session: &DkgSession,
258    expected: &[MemberOrg],
259    pops: &[ProofOfPossession],
260    commitments: &[NonceCommitment],
261    reveals: &[NonceReveal],
262    verifier: &dyn SignatureVerifier,
263) -> Result<(), AbortAttribution> {
264    let by_member: BTreeMap<&str, &ProofOfPossession> =
265        pops.iter().map(|p| (p.member.as_str(), p)).collect();
266
267    for org in expected {
268        let Some(pop) = by_member.get(org.id.as_str()) else {
269            return Err(AbortAttribution {
270                faulty_member: org.id.clone(),
271                reason: AbortReason::Timeout,
272            });
273        };
274        if !session.verify_pop(pop, verifier) {
275            return Err(AbortAttribution {
276                faulty_member: org.id.clone(),
277                reason: AbortReason::BadProofOfPossession,
278            });
279        }
280    }
281
282    let commits: BTreeMap<&str, &NonceCommitment> =
283        commitments.iter().map(|c| (c.member.as_str(), c)).collect();
284    let revealed: BTreeMap<&str, &NonceReveal> =
285        reveals.iter().map(|r| (r.member.as_str(), r)).collect();
286    for (member, commit) in &commits {
287        match revealed.get(*member) {
288            None => {
289                return Err(AbortAttribution {
290                    faulty_member: (*member).to_string(),
291                    reason: AbortReason::Timeout,
292                });
293            }
294            Some(reveal) => {
295                if !commit.verify(reveal) {
296                    return Err(AbortAttribution {
297                        faulty_member: (*member).to_string(),
298                        reason: AbortReason::NonceCommitmentMismatch,
299                    });
300                }
301            }
302        }
303    }
304    Ok(())
305}
306
307/// A membership change (join or leave) that must preserve the
308/// aggregate key so historical artifacts remain valid.
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct MembershipChange {
311    /// The change kind.
312    pub kind: MembershipChangeKind,
313    /// The aggregate key before the change.
314    pub aggregate_key_before: Vec<u8>,
315    /// The aggregate key after the change.
316    pub aggregate_key_after: Vec<u8>,
317}
318
319/// Join or leave.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(tag = "kind", rename_all = "snake_case")]
322pub enum MembershipChangeKind {
323    /// A member organization joins; its share commitment is included.
324    Join {
325        /// Joining organization.
326        org: MemberOrg,
327    },
328    /// A member organization leaves.
329    Leave {
330        /// Leaving organization id.
331        org_id: String,
332    },
333    /// The FTA dissolves: the aggregate key retires, but artifacts
334    /// signed before dissolution stay valid.
335    Dissolve,
336}
337
338impl MembershipChange {
339    /// Validate the aggregate-key continuity invariant: join/leave
340    /// MUST preserve the aggregate key (re-share); dissolution retires
341    /// it.
342    ///
343    /// # Errors
344    ///
345    /// Encoding errors when continuity is violated.
346    pub fn validate(&self) -> SignatifResult<()> {
347        match &self.kind {
348            MembershipChangeKind::Dissolve => Ok(()),
349            _ => {
350                if self.aggregate_key_before != self.aggregate_key_after {
351                    return Err(SignatifError::Encoding(
352                        "membership change altered the aggregate key — join/leave must re-share while preserving it"
353                            .into(),
354                    ));
355                }
356                Ok(())
357            }
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use ed25519_dalek::Signer;
366    use rand_core::RngCore;
367
368    fn generate_key() -> ed25519_dalek::SigningKey {
369        let mut seed = [0u8; 32];
370        rand_core::OsRng.fill_bytes(&mut seed);
371        ed25519_dalek::SigningKey::from_bytes(&seed)
372    }
373
374    struct Ed25519Verifier;
375
376    impl SignatureVerifier for Ed25519Verifier {
377        fn verify(&self, pk: &[u8], msg: &[u8], sig: &[u8]) -> bool {
378            use ed25519_dalek::Signature;
379            use ed25519_dalek::Verifier;
380            let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pk.try_into().unwrap()) else {
381                return false;
382            };
383            let Ok(signature) = Signature::from_slice(sig) else {
384                return false;
385            };
386            vk.verify(msg, &signature).is_ok()
387        }
388    }
389
390    fn org(id: &str, sk: &ed25519_dalek::SigningKey, inner: Option<Quorum>) -> MemberOrg {
391        MemberOrg {
392            id: id.into(),
393            public_key: sk.verifying_key().as_bytes().to_vec(),
394            inner_quorum: inner,
395        }
396    }
397
398    fn session() -> DkgSession {
399        let mut nonce = [0u8; 32];
400        rand_core::OsRng.fill_bytes(&mut nonce);
401        DkgSession {
402            authority_id: "fta-pharma".into(),
403            session_nonce: nonce,
404            ceremony_type: "dkg".into(),
405        }
406    }
407
408    fn pops(
409        session: &DkgSession,
410        keys: &[(&str, &ed25519_dalek::SigningKey)],
411    ) -> Vec<ProofOfPossession> {
412        keys.iter()
413            .map(|(id, sk)| {
414                let pk = sk.verifying_key().as_bytes().to_vec();
415                let signature = sk.sign(&session.pop_bytes(id, &pk)).to_bytes().to_vec();
416                ProofOfPossession {
417                    member: (*id).into(),
418                    public_key: pk,
419                    signature,
420                }
421            })
422            .collect()
423    }
424
425    #[test]
426    fn fta_structure_validation() {
427        let (a, b, c) = (generate_key(), generate_key(), generate_key());
428        let fta = FederatedTrustAuthority {
429            id: "fta".into(),
430            members: vec![
431                org("org-a", &a, Some(Quorum { t: 2, n: 3 })),
432                org("org-b", &b, None),
433                org("org-c", &c, None),
434            ],
435            org_quorum: Quorum { t: 2, n: 3 },
436            aggregate_key: vec![1],
437            hierarchy_spanning: true,
438        };
439        assert!(fta.validate().is_ok());
440        assert_eq!(
441            fta.requires_inner_quorum("org-a"),
442            Some(Quorum { t: 2, n: 3 })
443        );
444        assert_eq!(fta.requires_inner_quorum("org-b"), None);
445
446        let mut bad = fta.clone();
447        bad.members.push(bad.members[0].clone());
448        assert!(bad.validate().is_err());
449        let mut badq = fta;
450        badq.org_quorum = Quorum { t: 5, n: 3 };
451        assert!(badq.validate().is_err());
452    }
453
454    #[test]
455    fn rogue_key_defense_rejects_forged_pop() {
456        let session = session();
457        let keys = [("org-a", generate_key()), ("org-b", generate_key())];
458        let refs: Vec<(&str, &ed25519_dalek::SigningKey)> =
459            keys.iter().map(|(id, sk)| (*id, sk)).collect();
460        let mut good = pops(&session, &refs);
461        // org-b forges org-a's contribution with its own key.
462        let mut forged = good[1].clone();
463        forged.member = "org-a".into();
464        good[1] = forged;
465
466        let err = session
467            .verify_contributions(&good, Quorum { t: 2, n: 2 }, &Ed25519Verifier)
468            .unwrap_err();
469        assert!(err.to_string().contains("rogue-key"), "got {err}");
470    }
471
472    #[test]
473    fn dkg_quorum_enforced() {
474        let session = session();
475        let a = generate_key();
476        let keys = [("org-a", &a)];
477        let good = pops(&session, &keys);
478        // 2-of-2 required but only one contributed.
479        assert!(
480            session
481                .verify_contributions(&good, Quorum { t: 2, n: 2 }, &Ed25519Verifier)
482                .is_err()
483        );
484    }
485
486    #[test]
487    fn nonce_commitment_round_trip() {
488        let mut nonce = [0u8; 32];
489        rand_core::OsRng.fill_bytes(&mut nonce);
490        let c = NonceCommitment::commit("org-a", &nonce);
491        assert!(c.verify(&NonceReveal {
492            member: "org-a".into(),
493            nonce,
494        }));
495        let mut wrong = nonce;
496        wrong[0] ^= 1;
497        assert!(!c.verify(&NonceReveal {
498            member: "org-a".into(),
499            nonce: wrong,
500        }));
501    }
502
503    #[test]
504    fn identifiable_abort_names_the_culprit() {
505        let session = session();
506        let a = generate_key();
507        let b = generate_key();
508        let expected = vec![org("org-a", &a, None), org("org-b", &b, None)];
509        let keys = [("org-a", &a)];
510        let only_a = pops(&session, &keys);
511        let err = attribute_dkg_failure(&session, &expected, &only_a, &[], &[], &Ed25519Verifier)
512            .unwrap_err();
513        assert_eq!(err.faulty_member, "org-b");
514        assert_eq!(err.reason, AbortReason::Timeout);
515
516        // Nonce commitment mismatch attributes to the liar (both
517        // members contributed PoPs; org-a's reveal contradicts its
518        // commitment).
519        let both = pops(&session, &[("org-a", &a), ("org-b", &b)]);
520        let mut nonce = [7u8; 32];
521        let commitment = NonceCommitment::commit("org-a", &nonce);
522        nonce[0] ^= 1;
523        let reveal = NonceReveal {
524            member: "org-a".into(),
525            nonce,
526        };
527        let err = attribute_dkg_failure(
528            &session,
529            &expected,
530            &both,
531            &[commitment],
532            &[reveal],
533            &Ed25519Verifier,
534        )
535        .unwrap_err();
536        assert_eq!(err.reason, AbortReason::NonceCommitmentMismatch);
537    }
538
539    #[test]
540    fn membership_changes_preserve_aggregate() {
541        let key = vec![9u8; 32];
542        let join = MembershipChange {
543            kind: MembershipChangeKind::Join {
544                org: org("org-d", &generate_key(), None),
545            },
546            aggregate_key_before: key.clone(),
547            aggregate_key_after: key.clone(),
548        };
549        assert!(join.validate().is_ok());
550
551        let leave = MembershipChange {
552            kind: MembershipChangeKind::Leave {
553                org_id: "org-a".into(),
554            },
555            aggregate_key_before: key.clone(),
556            aggregate_key_after: vec![8u8; 32],
557        };
558        assert!(leave.validate().is_err());
559
560        let dissolve = MembershipChange {
561            kind: MembershipChangeKind::Dissolve,
562            aggregate_key_before: key,
563            aggregate_key_after: vec![],
564        };
565        assert!(dissolve.validate().is_ok());
566    }
567}