Skip to main content

confium_signatif/
multilog.rs

1//! Multi-log attestation and gossip quorum (SIGNATIF §13).
2//!
3//! - [`MultiLogPolicy`] `{m, k}`: a federated authority's artifacts
4//!   must carry inclusion proofs from at least M of K independent
5//!   logs — no single log operator controls the record.
6//! - [`GossipQuorum`]: the signed tree head a verifier relies on must
7//!   be witnessed by at least N independent witnesses.
8//! - [`MultiLogAttestation`]: the verification side — which logs
9//!   produced valid inclusion proofs.
10
11use serde::{Deserialize, Serialize};
12
13/// M-of-K multi-log policy.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub struct MultiLogPolicy {
16    /// Required number of valid inclusion proofs.
17    pub m: usize,
18    /// Recognized independent logs.
19    pub k: usize,
20}
21
22impl MultiLogPolicy {
23    /// Validate M <= K and M >= 1.
24    ///
25    /// # Errors
26    ///
27    /// Returns an encoding error for inconsistent parameters.
28    pub fn validate(&self) -> crate::error::SignatifResult<()> {
29        if self.m == 0 || self.m > self.k {
30            return Err(crate::error::SignatifError::Encoding(format!(
31                "invalid multi-log policy {} of {}",
32                self.m, self.k
33            )));
34        }
35        Ok(())
36    }
37}
38
39/// One log's inclusion-verification result.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct LogInclusion {
42    /// Log name.
43    pub log: String,
44    /// Whether a valid inclusion proof was verified for the artifact.
45    pub included: bool,
46}
47
48/// The set of inclusion results evaluated against a policy.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct MultiLogAttestation {
51    /// Per-log results.
52    pub inclusions: Vec<LogInclusion>,
53}
54
55impl MultiLogAttestation {
56    /// Evaluate the M-of-K quorum.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error when the policy parameters are inconsistent.
61    pub fn satisfies(&self, policy: &MultiLogPolicy) -> crate::error::SignatifResult<bool> {
62        policy.validate()?;
63        let included = self.inclusions.iter().filter(|i| i.included).count();
64        Ok(included >= policy.m)
65    }
66}
67
68/// One witness's signature over a signed tree head.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct WitnessCosignature {
71    /// Witness identity.
72    pub witness: String,
73    /// The witnessed tree head bytes (root hash + size + timestamp).
74    pub tree_head_bytes: Vec<u8>,
75    /// The witness's signature over the tree head bytes.
76    pub signature: Vec<u8>,
77    /// The witness's public key.
78    pub public_key: Vec<u8>,
79}
80
81/// Gossip quorum verification: at least `min_sources` distinct,
82/// independently-signed observations of the *same* tree head.
83#[derive(Debug, Clone, Copy)]
84pub struct GossipQuorum {
85    /// Minimum independent witnesses required.
86    pub min_sources: usize,
87}
88
89impl GossipQuorum {
90    /// Check the quorum.
91    ///
92    /// # Errors
93    ///
94    /// Propagates signature-verification errors as hard failures.
95    pub fn check(
96        &self,
97        cosignatures: &[WitnessCosignature],
98        verifier: &dyn crate::graph::SignatureVerifier,
99    ) -> crate::error::SignatifResult<bool> {
100        use std::collections::BTreeMap;
101        // Group by tree head bytes; a quorum must agree on one head.
102        let mut by_head: BTreeMap<Vec<u8>, Vec<&WitnessCosignature>> = BTreeMap::new();
103        for c in cosignatures {
104            by_head
105                .entry(c.tree_head_bytes.clone())
106                .or_default()
107                .push(c);
108        }
109        for (_, witnesses) in by_head {
110            let mut distinct = std::collections::BTreeSet::new();
111            let mut all_valid = true;
112            for w in &witnesses {
113                if !verifier.verify(&w.public_key, &w.tree_head_bytes, &w.signature) {
114                    all_valid = false;
115                    break;
116                }
117                distinct.insert(w.witness.clone());
118            }
119            if all_valid && distinct.len() >= self.min_sources {
120                return Ok(true);
121            }
122        }
123        Ok(false)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use ed25519_dalek::{Signer, SigningKey};
131
132    fn generate_key() -> ed25519_dalek::SigningKey {
133        use rand_core::RngCore;
134        let mut seed = [0u8; 32];
135        rand_core::OsRng.fill_bytes(&mut seed);
136        ed25519_dalek::SigningKey::from_bytes(&seed)
137    }
138
139    struct Ed25519Verifier;
140
141    impl crate::graph::SignatureVerifier for Ed25519Verifier {
142        fn verify(&self, pk: &[u8], msg: &[u8], sig: &[u8]) -> bool {
143            use ed25519_dalek::Signature;
144            use ed25519_dalek::Verifier;
145            let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pk.try_into().unwrap()) else {
146                return false;
147            };
148            let Ok(signature) = Signature::from_slice(sig) else {
149                return false;
150            };
151            vk.verify(msg, &signature).is_ok()
152        }
153    }
154
155    #[test]
156    fn multilog_quorum() {
157        let policy = MultiLogPolicy { m: 2, k: 3 };
158        let att = MultiLogAttestation {
159            inclusions: vec![
160                LogInclusion {
161                    log: "a".into(),
162                    included: true,
163                },
164                LogInclusion {
165                    log: "b".into(),
166                    included: true,
167                },
168                LogInclusion {
169                    log: "c".into(),
170                    included: false,
171                },
172            ],
173        };
174        assert!(att.satisfies(&policy).unwrap());
175        let weak = MultiLogAttestation {
176            inclusions: vec![
177                LogInclusion {
178                    log: "a".into(),
179                    included: true,
180                },
181                LogInclusion {
182                    log: "b".into(),
183                    included: false,
184                },
185                LogInclusion {
186                    log: "c".into(),
187                    included: false,
188                },
189            ],
190        };
191        assert!(!weak.satisfies(&policy).unwrap());
192        assert!(policy.with_m_zero().validate().is_err());
193    }
194
195    impl MultiLogPolicy {
196        fn with_m_zero(&self) -> MultiLogPolicy {
197            MultiLogPolicy { m: 0, k: self.k }
198        }
199    }
200
201    #[test]
202    fn gossip_quorum_needs_agreement() {
203        let head = b"tree-head-v1".to_vec();
204        let witnesses: Vec<SigningKey> = (0..3).map(|_| generate_key()).collect();
205        let cosigns: Vec<WitnessCosignature> = witnesses
206            .iter()
207            .enumerate()
208            .map(|(i, sk)| WitnessCosignature {
209                witness: format!("w{i}"),
210                tree_head_bytes: head.clone(),
211                signature: sk.sign(&head).to_bytes().to_vec(),
212                public_key: sk.verifying_key().as_bytes().to_vec(),
213            })
214            .collect();
215        let q = GossipQuorum { min_sources: 3 };
216        assert!(q.check(&cosigns, &Ed25519Verifier).unwrap());
217
218        // Split view: two heads, no single head has the quorum.
219        let mut split = cosigns.clone();
220        split[2].tree_head_bytes = b"other-head".to_vec();
221        assert!(!q.check(&split, &Ed25519Verifier).unwrap());
222
223        // Forged signature invalidates that head's group.
224        let mut forged = cosigns.clone();
225        forged[1].signature[0] ^= 1;
226        assert!(!q.check(&forged, &Ed25519Verifier).unwrap());
227    }
228}