1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum TopologyProfile {
36 Hierarchical,
38 Federated,
41 CrossRecognized,
44 Mesh,
46}
47
48impl TopologyProfile {
49 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum MigrationPhase {
64 ClassicalOnly,
66 Composite,
69 PostQuantumOnly,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct AuthorityDeclaration {
76 pub id: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub aggregate_key: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub fingerprint: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub quorum: Option<Quorum>,
87 #[serde(default)]
89 pub parents: Vec<String>,
90 #[serde(default = "default_scope")]
92 pub scope: ScopeDimensions,
93}
94
95fn default_scope() -> ScopeDimensions {
96 ScopeDimensions::unconstrained()
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct LogDeclaration {
102 pub name: String,
104 pub operator_key: String,
106 pub endpoint: String,
108 #[serde(default)]
110 pub is_mirror: bool,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CrossRecognition {
117 pub from_root: String,
119 pub to_root: String,
121 pub to_fingerprint: String,
123 pub recognized_scope: ScopeDimensions,
125 pub signature: Vec<u8>,
127}
128
129impl CrossRecognition {
130 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169pub struct MultiLogPolicyDeclaration {
170 pub m: usize,
172 pub k: usize,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SignatifManifest {
179 pub manifest_version: u32,
181 pub topology: TopologyProfile,
183 pub authorities: Vec<AuthorityDeclaration>,
185 pub algorithms: Vec<String>,
188 pub migration_phase: MigrationPhase,
190 pub transparency_logs: Vec<LogDeclaration>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub multi_log_policy: Option<MultiLogPolicyDeclaration>,
195 #[serde(default)]
197 pub cross_recognitions: Vec<CrossRecognition>,
198 pub valid_from: DateTime<Utc>,
200 pub valid_until: DateTime<Utc>,
202 pub root_signature: Vec<u8>,
205}
206
207impl SignatifManifest {
208 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(©).expect("manifest serializes"))?
219 .into_bytes(),
220 )
221 }
222
223 pub fn authority(&self, id: &str) -> Option<&AuthorityDeclaration> {
225 self.authorities.iter().find(|a| a.id == id)
226 }
227
228 pub fn roots(&self) -> Vec<&AuthorityDeclaration> {
230 self.authorities
231 .iter()
232 .filter(|a| a.parents.is_empty())
233 .collect()
234 }
235
236 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 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 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 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 #[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 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 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}