Skip to main content

confium_deployment/
signatif.rs

1//! SIGNATIF deployment manifests (SIGNATIF §18–19).
2//!
3//! A deployment manifest declares the whole trust topology
4//! declaratively and is signed by the root trust authority whose
5//! deployment it describes:
6//!
7//! - the topology profile (hierarchical, federated, cross-recognized,
8//!   mesh) — one of the four first-class conformance profiles;
9//! - every trust authority with its identifier, aggregate key or
10//!   fingerprint, quorum parameters, parent references, and scope;
11//! - the algorithms recognized by the deployment, from the framework's
12//!   algorithm registry, plus the migration phase;
13//! - the recognized transparency logs and mirrors, with the multi-log
14//!   attestation policy;
15//! - mutual-recognition credentials between roots (governance).
16//!
17//! Validation enforces: a valid trust graph with **no cycles**, the
18//! monotonic scope narrowing invariant across every delegation link,
19//! quorum consistency (1 <= T <= N; M <= K), and semantic versioning.
20
21use std::collections::BTreeMap;
22
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25
26use confium_signatif::SignatifError;
27use confium_signatif::SignatifResult;
28use confium_signatif::graph::{Quorum, SignatureVerifier};
29use confium_signatif::jcs;
30use confium_signatif::scope::ScopeDimensions;
31
32/// The four trust topology profiles (§19 `topology-declaration`).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum TopologyProfile {
36    /// Single root, strict delegation tree.
37    Hierarchical,
38    /// Threshold groups of independent organizations share aggregate
39    /// keys.
40    Federated,
41    /// Roots attest each other via signed cross-recognition
42    /// credentials.
43    CrossRecognized,
44    /// Many-to-many peer recognition without a distinguished root.
45    Mesh,
46}
47
48impl TopologyProfile {
49    /// The conformance-class identifier for this profile.
50    pub fn conformance_class(&self) -> &'static str {
51        match self {
52            TopologyProfile::Hierarchical => "/conf/hierarchical",
53            TopologyProfile::Federated => "/conf/federated",
54            TopologyProfile::CrossRecognized => "/conf/cross-recognized",
55            TopologyProfile::Mesh => "/conf/mesh",
56        }
57    }
58}
59
60/// The post-quantum migration phase (§20 `migration-declaration`).
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum MigrationPhase {
64    /// Classical signatures exclusively.
65    ClassicalOnly,
66    /// Composite (classical AND post-quantum) for new artifacts;
67    /// classical-only remain verifiable.
68    Composite,
69    /// Post-quantum exclusively; classical-only rejected.
70    PostQuantumOnly,
71}
72
73/// One declared trust authority.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct AuthorityDeclaration {
76    /// Stable authority identifier.
77    pub id: String,
78    /// Aggregate (or single) public key, when carried inline.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub aggregate_key: Option<String>,
81    /// Key fingerprint, when the key is distributed out of band.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub fingerprint: Option<String>,
84    /// Quorum parameters; `None` for a 1-of-1 authority.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub quorum: Option<Quorum>,
87    /// Parent authority identifiers (empty for roots).
88    #[serde(default)]
89    pub parents: Vec<String>,
90    /// The authority's scope.
91    #[serde(default = "default_scope")]
92    pub scope: ScopeDimensions,
93}
94
95fn default_scope() -> ScopeDimensions {
96    ScopeDimensions::unconstrained()
97}
98
99/// One recognized transparency log or mirror.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct LogDeclaration {
102    /// Log name.
103    pub name: String,
104    /// Log operator public key (verifies signed tree heads).
105    pub operator_key: String,
106    /// Endpoint.
107    pub endpoint: String,
108    /// Whether this entry is a mirror of another declared log.
109    #[serde(default)]
110    pub is_mirror: bool,
111}
112
113/// A mutual-recognition credential: one root attesting another
114/// (§19 `mutual-recognition`), recorded in both roots' logs.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CrossRecognition {
117    /// The attesting root.
118    pub from_root: String,
119    /// The attested root.
120    pub to_root: String,
121    /// The attested root's aggregate key fingerprint.
122    pub to_fingerprint: String,
123    /// The attested root's recognized scope.
124    pub recognized_scope: ScopeDimensions,
125    /// The attesting root's signature over the canonical credential.
126    pub signature: Vec<u8>,
127}
128
129impl CrossRecognition {
130    /// Canonical signing bytes.
131    ///
132    /// # Errors
133    ///
134    /// Propagates canonicalization errors.
135    pub fn signing_bytes(&self) -> SignatifResult<Vec<u8>> {
136        let v = serde_json::json!({
137            "from_root": self.from_root,
138            "to_root": self.to_root,
139            "to_fingerprint": self.to_fingerprint,
140            "recognized_scope": self.recognized_scope,
141        });
142        Ok(jcs::canonicalize(&v)?.into_bytes())
143    }
144
145    /// Verify the attesting root's signature over the credential
146    /// (§19 `mutual-recognition`).
147    ///
148    /// # Errors
149    ///
150    /// Signature errors when the attesting root's key does not verify.
151    pub fn verify(
152        &self,
153        from_root_key: &[u8],
154        verifier: &dyn SignatureVerifier,
155    ) -> SignatifResult<()> {
156        let msg = self.signing_bytes()?;
157        if verifier.verify(from_root_key, &msg, &self.signature) {
158            Ok(())
159        } else {
160            Err(SignatifError::BadSignature {
161                context: format!("cross-recognition {} -> {}", self.from_root, self.to_root),
162            })
163        }
164    }
165}
166
167/// The multi-log attestation policy.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169pub struct MultiLogPolicyDeclaration {
170    /// Required valid inclusion proofs.
171    pub m: usize,
172    /// Recognized independent logs.
173    pub k: usize,
174}
175
176/// A root-signed declarative deployment manifest (§18).
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SignatifManifest {
179    /// Manifest version (semantic compatibility).
180    pub manifest_version: u32,
181    /// The deployment's topology profile.
182    pub topology: TopologyProfile,
183    /// All declared trust authorities (roots have empty `parents`).
184    pub authorities: Vec<AuthorityDeclaration>,
185    /// Algorithms recognized by this deployment (algorithm registry
186    /// names).
187    pub algorithms: Vec<String>,
188    /// The active migration phase.
189    pub migration_phase: MigrationPhase,
190    /// Recognized transparency logs and mirrors.
191    pub transparency_logs: Vec<LogDeclaration>,
192    /// Multi-log attestation policy, when applicable.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub multi_log_policy: Option<MultiLogPolicyDeclaration>,
195    /// Cross-recognition credentials between roots.
196    #[serde(default)]
197    pub cross_recognitions: Vec<CrossRecognition>,
198    /// Manifest validity.
199    pub valid_from: DateTime<Utc>,
200    /// Manifest validity.
201    pub valid_until: DateTime<Utc>,
202    /// The root trust authority's signature over the canonical
203    /// manifest body.
204    pub root_signature: Vec<u8>,
205}
206
207impl SignatifManifest {
208    /// Canonical signing bytes (JCS of the manifest with the signature
209    /// cleared).
210    ///
211    /// # Errors
212    ///
213    /// Propagates canonicalization errors.
214    pub fn signing_bytes(&self) -> SignatifResult<Vec<u8>> {
215        let mut copy = self.clone();
216        copy.root_signature = Vec::new();
217        Ok(
218            jcs::canonicalize(&serde_json::to_value(&copy).expect("manifest serializes"))?
219                .into_bytes(),
220        )
221    }
222
223    /// Look up an authority declaration.
224    pub fn authority(&self, id: &str) -> Option<&AuthorityDeclaration> {
225        self.authorities.iter().find(|a| a.id == id)
226    }
227
228    /// The root authorities (no parents).
229    pub fn roots(&self) -> Vec<&AuthorityDeclaration> {
230        self.authorities
231            .iter()
232            .filter(|a| a.parents.is_empty())
233            .collect()
234    }
235
236    /// Verify the root signature of the manifest.
237    ///
238    /// # Errors
239    ///
240    /// Signature or root-key resolution errors.
241    pub fn verify_signature(&self, verifier: &dyn SignatureVerifier) -> SignatifResult<()> {
242        let msg = self.signing_bytes()?;
243        for root in self.roots() {
244            if let Some(key_hex) = &root.aggregate_key {
245                if let Ok(key) = hex::decode(key_hex) {
246                    if verifier.verify(&key, &msg, &self.root_signature) {
247                        return Ok(());
248                    }
249                }
250            }
251        }
252        Err(SignatifError::BadSignature {
253            context: "deployment manifest root signature".into(),
254        })
255    }
256
257    /// Validate the whole manifest (§18 `manifest-validation-*`):
258    ///
259    /// 1. acyclic delegation graph;
260    /// 2. monotonic scope narrowing on every parent -> child link;
261    /// 3. quorum consistency (1 <= T <= N per authority, M <= K for
262    ///    the multi-log policy);
263    /// 4. at least one root exists.
264    ///
265    /// # Errors
266    ///
267    /// Returns the first violation as an encoding error with a
268    /// precise message.
269    pub fn validate(&self) -> SignatifResult<()> {
270        if self.roots().is_empty() {
271            return Err(SignatifError::Encoding(
272                "manifest declares no root authority".into(),
273            ));
274        }
275
276        // Index authorities and check parent references exist.
277        let index: BTreeMap<&str, &AuthorityDeclaration> = self
278            .authorities
279            .iter()
280            .map(|a| (a.id.as_str(), a))
281            .collect();
282        for a in &self.authorities {
283            for p in &a.parents {
284                if !index.contains_key(p.as_str()) {
285                    return Err(SignatifError::Encoding(format!(
286                        "authority {} references unknown parent {p}",
287                        a.id
288                    )));
289                }
290            }
291        }
292
293        // Quorum consistency (T <= N).
294        for a in &self.authorities {
295            if let Some(q) = a.quorum {
296                Quorum::new(q.t, q.n)?;
297            }
298        }
299        if let Some(p) = &self.multi_log_policy {
300            if p.m == 0 || p.m > p.k {
301                return Err(SignatifError::Encoding(format!(
302                    "invalid multi-log policy {} of {}",
303                    p.m, p.k
304                )));
305            }
306        }
307
308        // Acyclic: DFS from every authority following parents.
309        // Unvisited nodes are absent from `marks` (tri-color DFS).
310        #[derive(PartialEq, Clone, Copy)]
311        enum Mark {
312            Grey,
313            Black,
314        }
315        fn visit(
316            manifest: &SignatifManifest,
317            id: &str,
318            marks: &mut BTreeMap<String, Mark>,
319        ) -> bool {
320            match marks.get(id).copied() {
321                Some(Mark::Grey) => false,
322                Some(Mark::Black) => true,
323                _ => {
324                    marks.insert(id.to_string(), Mark::Grey);
325                    if let Some(a) = manifest.authority(id) {
326                        for p in &a.parents {
327                            if !visit(manifest, p, marks) {
328                                return false;
329                            }
330                        }
331                    }
332                    marks.insert(id.to_string(), Mark::Black);
333                    true
334                }
335            }
336        }
337        let mut marks: BTreeMap<String, Mark> = BTreeMap::new();
338        if !self
339            .authorities
340            .iter()
341            .all(|a| visit(self, &a.id, &mut marks))
342        {
343            return Err(SignatifError::Encoding(
344                "manifest trust graph contains a cycle".into(),
345            ));
346        }
347
348        // Monotonic narrowing on every link.
349        for a in &self.authorities {
350            for p in &a.parents {
351                let parent = self.authority(p).expect("checked above");
352                if let Some(dim) = a.scope.first_widened_dimension(&parent.scope) {
353                    return Err(SignatifError::Encoding(format!(
354                        "scope widening on delegation {p} -> {} on dimension {dim}",
355                        a.id
356                    )));
357                }
358            }
359        }
360
361        Ok(())
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use confium_signatif::scope::ScopeValue;
369    use ed25519_dalek::Signer;
370    use rand_core::RngCore;
371
372    fn generate_key() -> ed25519_dalek::SigningKey {
373        let mut seed = [0u8; 32];
374        rand_core::OsRng.fill_bytes(&mut seed);
375        ed25519_dalek::SigningKey::from_bytes(&seed)
376    }
377
378    struct Ed25519Verifier;
379
380    impl SignatureVerifier for Ed25519Verifier {
381        fn verify(&self, pk: &[u8], msg: &[u8], sig: &[u8]) -> bool {
382            use ed25519_dalek::Signature;
383            use ed25519_dalek::Verifier;
384            let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pk.try_into().unwrap()) else {
385                return false;
386            };
387            let Ok(signature) = Signature::from_slice(sig) else {
388                return false;
389            };
390            vk.verify(msg, &signature).is_ok()
391        }
392    }
393
394    fn manifest() -> (SignatifManifest, ed25519_dalek::SigningKey) {
395        let root_sk = generate_key();
396        let mut root_scope = ScopeDimensions::unconstrained();
397        root_scope.set(
398            "domain",
399            ScopeValue::Set(["pharma"].iter().map(|s| s.to_string()).collect()),
400        );
401        let mut lab_scope = root_scope.clone();
402        lab_scope.set("subdomain", ScopeValue::Single("vaccines".into()));
403
404        let m = SignatifManifest {
405            manifest_version: 1,
406            topology: TopologyProfile::Hierarchical,
407            authorities: vec![
408                AuthorityDeclaration {
409                    id: "root".into(),
410                    aggregate_key: Some(hex::encode(root_sk.verifying_key().as_bytes())),
411                    fingerprint: Some("f0".into()),
412                    quorum: Some(Quorum { t: 2, n: 3 }),
413                    parents: vec![],
414                    scope: root_scope,
415                },
416                AuthorityDeclaration {
417                    id: "lab".into(),
418                    aggregate_key: None,
419                    fingerprint: Some("f1".into()),
420                    quorum: Some(Quorum { t: 2, n: 3 }),
421                    parents: vec!["root".into()],
422                    scope: lab_scope,
423                },
424            ],
425            algorithms: vec!["Ed25519".into(), "ECDSA-P256".into()],
426            migration_phase: MigrationPhase::ClassicalOnly,
427            transparency_logs: vec![LogDeclaration {
428                name: "log-1".into(),
429                operator_key: "00".into(),
430                endpoint: "https://log.example".into(),
431                is_mirror: false,
432            }],
433            multi_log_policy: Some(MultiLogPolicyDeclaration { m: 1, k: 1 }),
434            cross_recognitions: vec![],
435            valid_from: Utc::now() - chrono::Duration::hours(1),
436            valid_until: Utc::now() + chrono::Duration::days(365),
437            root_signature: vec![],
438        };
439        (m, root_sk)
440    }
441
442    #[test]
443    fn valid_manifest_validates_and_signs() {
444        let (mut m, sk) = manifest();
445        m.root_signature = sk.sign(&m.signing_bytes().unwrap()).to_bytes().to_vec();
446        assert!(m.validate().is_ok());
447        assert!(m.verify_signature(&Ed25519Verifier).is_ok());
448        assert_eq!(m.roots().len(), 1);
449    }
450
451    #[test]
452    fn cycle_is_rejected() {
453        let (mut m, _) = manifest();
454        let lab = m.authorities[1].clone();
455        m.authorities.push(AuthorityDeclaration {
456            id: "mid".into(),
457            aggregate_key: None,
458            fingerprint: Some("f2".into()),
459            quorum: None,
460            parents: vec!["lab".into()],
461            scope: lab.scope.clone(),
462        });
463        // lab -> mid -> lab cycle (root stays parentless).
464        m.authorities[1].parents = vec!["root".into(), "mid".into()];
465        let err = m.validate().unwrap_err();
466        assert!(err.to_string().contains("cycle"), "got {err}");
467    }
468
469    #[test]
470    fn widening_is_rejected() {
471        let (mut m, _) = manifest();
472        m.authorities[1].scope = ScopeDimensions::unconstrained();
473        let err = m.validate().unwrap_err();
474        assert!(err.to_string().contains("widening"));
475    }
476
477    #[test]
478    fn quorum_and_multilog_consistency() {
479        let (mut m, _) = manifest();
480        m.authorities[1].quorum = Some(Quorum { t: 4, n: 3 });
481        assert!(m.validate().is_err());
482        m.authorities[1].quorum = None;
483        m.multi_log_policy = Some(MultiLogPolicyDeclaration { m: 3, k: 2 });
484        assert!(m.validate().is_err());
485    }
486
487    #[test]
488    fn no_roots_rejected() {
489        let (mut m, _) = manifest();
490        m.authorities[0].parents = vec!["lab".into()];
491        m.authorities[1].parents = vec!["root".into()];
492        assert!(m.validate().is_err());
493    }
494
495    #[test]
496    fn tampered_signature_fails() {
497        let (mut m, sk) = manifest();
498        m.root_signature = sk.sign(&m.signing_bytes().unwrap()).to_bytes().to_vec();
499        m.algorithms.push("ML-DSA-65".into());
500        assert!(m.verify_signature(&Ed25519Verifier).is_err());
501    }
502
503    #[test]
504    fn cross_recognition_signature_verifies() {
505        use rand_core::RngCore;
506        let mut seed = [0u8; 32];
507        rand_core::OsRng.fill_bytes(&mut seed);
508        let root_a = ed25519_dalek::SigningKey::from_bytes(&seed);
509        let mut cred = CrossRecognition {
510            from_root: "root-a".into(),
511            to_root: "root-b".into(),
512            to_fingerprint: "fbb".into(),
513            recognized_scope: ScopeDimensions::unconstrained(),
514            signature: vec![],
515        };
516        cred.signature = root_a
517            .sign(&cred.signing_bytes().unwrap())
518            .to_bytes()
519            .to_vec();
520        assert!(
521            cred.verify(root_a.verifying_key().as_bytes(), &Ed25519Verifier)
522                .is_ok()
523        );
524        cred.to_root = "root-c".into();
525        assert!(
526            cred.verify(root_a.verifying_key().as_bytes(), &Ed25519Verifier)
527                .is_err()
528        );
529    }
530
531    #[test]
532    fn topology_conformance_classes() {
533        assert_eq!(
534            TopologyProfile::Hierarchical.conformance_class(),
535            "/conf/hierarchical"
536        );
537        assert_eq!(TopologyProfile::Mesh.conformance_class(), "/conf/mesh");
538    }
539}