1use std::path::Path;
4
5use auths_core::ports::network::{NetworkError, RegistryClient};
6use auths_keri::witness::independence::{
7 IndependencePolicy, Infrastructure, Jurisdiction, OperatorId, Organization, WitnessOperatorInfo,
8};
9use auths_transparency::{
10 BundleVerificationReport, ConsistencyProof, LogOrigin, OfflineBundle, SignedCheckpoint,
11 TrustRoot, TrustRootWitness,
12};
13use auths_verifier::Ed25519PublicKey;
14use chrono::{DateTime, Utc};
15use thiserror::Error;
16
17#[derive(Debug, Error)]
19pub enum TransparencyWorkflowError {
20 #[error("bundle verification found issues")]
22 VerificationFailed(Box<BundleVerificationReport>),
23
24 #[error("checkpoint inconsistent: {0}")]
26 CheckpointInconsistent(String),
27
28 #[error("cache I/O error: {0}")]
30 CacheError(#[source] std::io::Error),
31
32 #[error("deserialization error: {0}")]
34 DeserializationError(String),
35
36 #[error("network error: {0}")]
38 NetworkError(#[source] NetworkError),
39}
40
41#[derive(Debug, serde::Deserialize)]
43struct TrustRootResponse {
44 log_origin: String,
45 log_public_key: String,
46 witnesses: Vec<TrustRootWitnessResponse>,
47 #[allow(dead_code)]
48 version: u32,
49}
50
51#[derive(Debug, serde::Deserialize)]
53struct TrustRootWitnessResponse {
54 name: String,
55 public_key: String,
56 #[allow(dead_code)]
57 url: String,
58 #[serde(default)]
62 organization: Option<String>,
63 #[serde(default)]
64 jurisdiction: Option<String>,
65 #[serde(default)]
66 infrastructure: Option<String>,
67}
68
69fn operator_info_from_wire(w: &TrustRootWitnessResponse) -> Option<WitnessOperatorInfo> {
73 Some(WitnessOperatorInfo {
74 operator: OperatorId::new(w.name.clone()).ok()?,
75 organization: Organization::new(w.organization.clone()?).ok()?,
76 jurisdiction: Jurisdiction::new(w.jurisdiction.clone()?).ok()?,
77 infrastructure: Infrastructure::new(w.infrastructure.clone()?).ok()?,
78 })
79}
80
81pub async fn fetch_trust_root(
95 registry_url: &str,
96 client: &impl RegistryClient,
97) -> Result<TrustRoot, TransparencyWorkflowError> {
98 let bytes = client
99 .fetch_registry_data(registry_url, "v1/trust-root")
100 .await
101 .map_err(TransparencyWorkflowError::NetworkError)?;
102
103 let resp: TrustRootResponse = serde_json::from_slice(&bytes)
104 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
105
106 let log_public_key_bytes: [u8; 32] = hex::decode(&resp.log_public_key)
107 .map_err(|e| {
108 TransparencyWorkflowError::DeserializationError(format!(
109 "invalid hex in log_public_key: {e}"
110 ))
111 })?
112 .try_into()
113 .map_err(|_| {
114 TransparencyWorkflowError::DeserializationError(
115 "log_public_key must be exactly 32 bytes".into(),
116 )
117 })?;
118
119 let log_origin = LogOrigin::new(&resp.log_origin).map_err(|e| {
120 TransparencyWorkflowError::DeserializationError(format!("invalid log origin: {e}"))
121 })?;
122
123 let witnesses = resp
124 .witnesses
125 .into_iter()
126 .filter(|w| !w.public_key.is_empty())
127 .filter_map(|w| {
128 let pk_bytes: [u8; 32] = hex::decode(&w.public_key).ok()?.try_into().ok()?;
129 let public_key = Ed25519PublicKey::from_bytes(pk_bytes);
130 let witness_did = auths_verifier::CanonicalDid::from_public_key_did_key(
131 public_key.as_bytes(),
132 auths_crypto::CurveType::Ed25519,
133 );
134 let operator_info = operator_info_from_wire(&w);
135 Some(TrustRootWitness {
136 witness_did,
137 name: w.name,
138 public_key,
139 operator_info,
140 })
141 })
142 .collect();
143
144 Ok(TrustRoot {
145 log_public_key: Ed25519PublicKey::from_bytes(log_public_key_bytes),
146 log_origin,
147 witnesses,
148 signature_algorithm: Default::default(),
149 ecdsa_log_public_key_der: None,
150 independence_policy: IndependencePolicy::unconstrained(),
153 })
154}
155
156pub struct BundleVerifyConfig {
158 pub bundle_json: String,
160 pub trust_root_json: String,
162}
163
164#[derive(Debug)]
166pub struct ConsistencyReport {
167 pub old_size: u64,
169 pub new_size: u64,
171 pub consistent: bool,
173}
174
175pub fn verify_artifact_bundle(
191 config: &BundleVerifyConfig,
192 now: DateTime<Utc>,
193) -> Result<BundleVerificationReport, TransparencyWorkflowError> {
194 let bundle: OfflineBundle = serde_json::from_str(&config.bundle_json)
195 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
196 let trust_root: TrustRoot = serde_json::from_str(&config.trust_root_json)
197 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
198
199 let report = auths_transparency::verify_bundle(&bundle, &trust_root, now);
200
201 if !report.is_valid() {
202 return Err(TransparencyWorkflowError::VerificationFailed(Box::new(
203 report,
204 )));
205 }
206
207 Ok(report)
208}
209
210#[allow(clippy::disallowed_methods)] pub fn update_checkpoint_cache(
239 cache_path: &Path,
240 new_checkpoint: &SignedCheckpoint,
241 consistency_proof: &ConsistencyProof,
242 _trust_root: &TrustRoot,
243 _now: DateTime<Utc>,
244) -> Result<ConsistencyReport, TransparencyWorkflowError> {
245 let old_checkpoint = match std::fs::read_to_string(cache_path) {
246 Ok(json) => {
247 let cp: SignedCheckpoint = serde_json::from_str(&json)
248 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
249 Some(cp)
250 }
251 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
252 Err(e) => return Err(TransparencyWorkflowError::CacheError(e)),
253 };
254
255 if let Some(ref old) = old_checkpoint {
256 auths_transparency::verify_consistency(
257 old.checkpoint.size,
258 new_checkpoint.checkpoint.size,
259 &consistency_proof.hashes,
260 &old.checkpoint.root,
261 &new_checkpoint.checkpoint.root,
262 )
263 .map_err(|e| TransparencyWorkflowError::CheckpointInconsistent(e.to_string()))?;
264 }
265
266 let json = serde_json::to_string_pretty(new_checkpoint)
267 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
268
269 if let Some(parent) = cache_path.parent() {
270 std::fs::create_dir_all(parent).map_err(TransparencyWorkflowError::CacheError)?;
271 }
272 std::fs::write(cache_path, json.as_bytes()).map_err(TransparencyWorkflowError::CacheError)?;
273
274 let old_size = old_checkpoint.map(|c| c.checkpoint.size).unwrap_or(0);
275
276 Ok(ConsistencyReport {
277 old_size,
278 new_size: new_checkpoint.checkpoint.size,
279 consistent: true,
280 })
281}
282
283#[allow(clippy::disallowed_methods)] pub fn try_cache_checkpoint(
306 cache_path: &Path,
307 new_checkpoint: &SignedCheckpoint,
308 consistency_proof: Option<&ConsistencyProof>,
309) -> Result<ConsistencyReport, TransparencyWorkflowError> {
310 let old_checkpoint = match std::fs::read_to_string(cache_path) {
311 Ok(json) => {
312 let cp: SignedCheckpoint = serde_json::from_str(&json)
313 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
314 Some(cp)
315 }
316 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
317 Err(e) => return Err(TransparencyWorkflowError::CacheError(e)),
318 };
319
320 if let Some(ref old) = old_checkpoint {
321 if old.checkpoint.size == new_checkpoint.checkpoint.size
323 && old.checkpoint.root != new_checkpoint.checkpoint.root
324 {
325 return Err(TransparencyWorkflowError::CheckpointInconsistent(format!(
326 "equivocation detected: same tree size {} but different roots",
327 old.checkpoint.size
328 )));
329 }
330
331 if new_checkpoint.checkpoint.size < old.checkpoint.size {
333 return Err(TransparencyWorkflowError::CheckpointInconsistent(format!(
334 "new checkpoint size {} is smaller than cached size {}",
335 new_checkpoint.checkpoint.size, old.checkpoint.size
336 )));
337 }
338
339 if old.checkpoint.size == new_checkpoint.checkpoint.size {
341 return Ok(ConsistencyReport {
342 old_size: old.checkpoint.size,
343 new_size: new_checkpoint.checkpoint.size,
344 consistent: true,
345 });
346 }
347
348 if let Some(proof) = consistency_proof {
350 auths_transparency::verify_consistency(
351 old.checkpoint.size,
352 new_checkpoint.checkpoint.size,
353 &proof.hashes,
354 &old.checkpoint.root,
355 &new_checkpoint.checkpoint.root,
356 )
357 .map_err(|e| TransparencyWorkflowError::CheckpointInconsistent(e.to_string()))?;
358 }
359 }
360
361 let json = serde_json::to_string_pretty(new_checkpoint)
362 .map_err(|e| TransparencyWorkflowError::DeserializationError(e.to_string()))?;
363
364 if let Some(parent) = cache_path.parent() {
365 std::fs::create_dir_all(parent).map_err(TransparencyWorkflowError::CacheError)?;
366 }
367 std::fs::write(cache_path, json.as_bytes()).map_err(TransparencyWorkflowError::CacheError)?;
368
369 let old_size = old_checkpoint.map(|c| c.checkpoint.size).unwrap_or(0);
370
371 Ok(ConsistencyReport {
372 old_size,
373 new_size: new_checkpoint.checkpoint.size,
374 consistent: true,
375 })
376}
377
378#[cfg(test)]
379#[allow(clippy::unwrap_used, clippy::expect_used, clippy::disallowed_methods)]
380mod tests {
381 use super::*;
382 use auths_transparency::checkpoint::{Checkpoint, SignedCheckpoint};
383 use auths_transparency::entry::{Entry, EntryBody, EntryContent, EntryType};
384 use auths_transparency::proof::InclusionProof;
385 use auths_transparency::types::{LogOrigin, MerkleHash};
386 use auths_verifier::{CanonicalDid, Ed25519PublicKey, Ed25519Signature};
387
388 fn dummy_signed_checkpoint(size: u64, root: MerkleHash) -> SignedCheckpoint {
389 SignedCheckpoint {
390 checkpoint: Checkpoint {
391 origin: LogOrigin::new("test.dev/log").unwrap(),
392 size,
393 root,
394 timestamp: chrono::DateTime::parse_from_rfc3339("2025-06-15T00:00:00Z")
395 .unwrap()
396 .with_timezone(&Utc),
397 },
398 log_signature: Ed25519Signature::from_bytes([0u8; 64]),
399 log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
400 witnesses: vec![],
401 ecdsa_checkpoint_signature: None,
402 ecdsa_checkpoint_key: None,
403 }
404 }
405
406 fn dummy_trust_root() -> TrustRoot {
407 TrustRoot {
408 log_public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
409 log_origin: LogOrigin::new("test.dev/log").unwrap(),
410 witnesses: vec![],
411 signature_algorithm: Default::default(),
412 ecdsa_log_public_key_der: None,
413 independence_policy: IndependencePolicy::unconstrained(),
414 }
415 }
416
417 struct CannedRegistry {
419 trust_root_json: Vec<u8>,
420 }
421
422 impl RegistryClient for CannedRegistry {
423 async fn fetch_registry_data(
424 &self,
425 _registry_url: &str,
426 _path: &str,
427 ) -> Result<Vec<u8>, NetworkError> {
428 Ok(self.trust_root_json.clone())
429 }
430
431 async fn push_registry_data(
432 &self,
433 _registry_url: &str,
434 _path: &str,
435 _data: &[u8],
436 ) -> Result<(), NetworkError> {
437 Ok(())
438 }
439
440 async fn post_json(
441 &self,
442 _registry_url: &str,
443 _path: &str,
444 _json_body: &[u8],
445 ) -> Result<auths_core::ports::network::RegistryResponse, NetworkError> {
446 Ok(auths_core::ports::network::RegistryResponse {
447 status: 200,
448 body: vec![],
449 rate_limit: None,
450 })
451 }
452 }
453
454 #[tokio::test]
455 async fn fetch_trust_root_round_trips_independence_attributes() {
456 let log_pk = hex::encode([0u8; 32]);
457 let w_pk = hex::encode([7u8; 32]);
458 let json = format!(
459 r#"{{"version":1,"log_origin":"test.dev/log","log_public_key":"{log_pk}","witnesses":[{{"name":"w1","public_key":"{w_pk}","url":"http://w1","organization":"org-a","jurisdiction":"US","infrastructure":"aws/us-east-1"}}]}}"#
460 );
461 let client = CannedRegistry {
462 trust_root_json: json.into_bytes(),
463 };
464
465 let trust_root = fetch_trust_root("https://registry.test", &client)
466 .await
467 .unwrap();
468
469 assert_eq!(trust_root.witnesses.len(), 1);
470 let attrs = trust_root.witnesses[0]
471 .operator_attributes()
472 .expect("operator attributes survive ingestion");
473 assert_eq!(attrs.organization.as_str(), "org-a");
474 assert_eq!(attrs.jurisdiction.as_str(), "US");
475 assert_eq!(attrs.infrastructure.as_str(), "aws/us-east-1");
476 }
477
478 #[test]
479 fn verify_artifact_bundle_invalid_bundle_json() {
480 let config = BundleVerifyConfig {
481 bundle_json: "not valid json".into(),
482 trust_root_json: "{}".into(),
483 };
484 let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
485 .unwrap()
486 .with_timezone(&Utc);
487 let err = verify_artifact_bundle(&config, now).unwrap_err();
488 assert!(matches!(
489 err,
490 TransparencyWorkflowError::DeserializationError(_)
491 ));
492 }
493
494 fn dummy_bundle() -> OfflineBundle {
495 let ts = chrono::DateTime::parse_from_rfc3339("2025-06-15T00:00:00Z")
496 .unwrap()
497 .with_timezone(&Utc);
498 let entry = Entry {
499 sequence: 0,
500 timestamp: ts,
501 content: EntryContent {
502 entry_type: EntryType::DeviceBind,
503 body: EntryBody::DeviceBind {
504 device_did: CanonicalDid::new_unchecked("did:key:z6MkTest"),
505 public_key: Ed25519PublicKey::from_bytes([0u8; 32]),
506 },
507 actor_did: CanonicalDid::new_unchecked("did:key:z6MkTest"),
508 },
509 actor_sig: Ed25519Signature::empty(),
510 };
511 let root = MerkleHash::from_bytes([0u8; 32]);
512 OfflineBundle {
513 entry,
514 inclusion_proof: InclusionProof {
515 index: 0,
516 size: 1,
517 root,
518 hashes: vec![],
519 },
520 signed_checkpoint: dummy_signed_checkpoint(1, root),
521 delegation_chain: vec![],
522 }
523 }
524
525 #[test]
526 fn verify_artifact_bundle_invalid_trust_root_json() {
527 let bundle = dummy_bundle();
528 let bundle_json = serde_json::to_string(&bundle).unwrap();
529
530 let config = BundleVerifyConfig {
531 bundle_json,
532 trust_root_json: "not valid json".into(),
533 };
534 let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
535 .unwrap()
536 .with_timezone(&Utc);
537 let err = verify_artifact_bundle(&config, now).unwrap_err();
538 assert!(matches!(
539 err,
540 TransparencyWorkflowError::DeserializationError(_)
541 ));
542 }
543
544 #[test]
545 fn update_checkpoint_cache_writes_new_file() {
546 let dir = tempfile::tempdir().unwrap();
547 let cache_path = dir.path().join("checkpoint.json");
548
549 let root = MerkleHash::from_bytes([0xaa; 32]);
550 let new_cp = dummy_signed_checkpoint(10, root);
551 let proof = ConsistencyProof {
552 old_size: 0,
553 new_size: 10,
554 old_root: MerkleHash::from_bytes([0u8; 32]),
555 new_root: root,
556 hashes: vec![],
557 };
558 let trust_root = dummy_trust_root();
559 let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
560 .unwrap()
561 .with_timezone(&Utc);
562
563 let report =
564 update_checkpoint_cache(&cache_path, &new_cp, &proof, &trust_root, now).unwrap();
565
566 assert_eq!(report.old_size, 0);
567 assert_eq!(report.new_size, 10);
568 assert!(report.consistent);
569 assert!(cache_path.exists());
570
571 let written: SignedCheckpoint =
572 serde_json::from_str(&std::fs::read_to_string(&cache_path).unwrap()).unwrap();
573 assert_eq!(written.checkpoint.size, 10);
574 }
575
576 #[test]
577 fn update_checkpoint_cache_creates_parent_dirs() {
578 let dir = tempfile::tempdir().unwrap();
579 let cache_path = dir
580 .path()
581 .join("nested")
582 .join("dir")
583 .join("checkpoint.json");
584
585 let root = MerkleHash::from_bytes([0xbb; 32]);
586 let new_cp = dummy_signed_checkpoint(5, root);
587 let proof = ConsistencyProof {
588 old_size: 0,
589 new_size: 5,
590 old_root: MerkleHash::from_bytes([0u8; 32]),
591 new_root: root,
592 hashes: vec![],
593 };
594 let trust_root = dummy_trust_root();
595 let now = chrono::DateTime::parse_from_rfc3339("2025-07-01T00:00:00Z")
596 .unwrap()
597 .with_timezone(&Utc);
598
599 let report =
600 update_checkpoint_cache(&cache_path, &new_cp, &proof, &trust_root, now).unwrap();
601
602 assert!(report.consistent);
603 assert!(cache_path.exists());
604 }
605
606 #[test]
607 fn try_cache_checkpoint_tofu_writes_new_file() {
608 let dir = tempfile::tempdir().unwrap();
609 let cache_path = dir.path().join("log_checkpoint.json");
610
611 let root = MerkleHash::from_bytes([0xaa; 32]);
612 let cp = dummy_signed_checkpoint(10, root);
613
614 let report = try_cache_checkpoint(&cache_path, &cp, None).unwrap();
615 assert_eq!(report.old_size, 0);
616 assert_eq!(report.new_size, 10);
617 assert!(report.consistent);
618 assert!(cache_path.exists());
619 }
620
621 #[test]
622 fn try_cache_checkpoint_same_checkpoint_is_noop() {
623 let dir = tempfile::tempdir().unwrap();
624 let cache_path = dir.path().join("log_checkpoint.json");
625
626 let root = MerkleHash::from_bytes([0xaa; 32]);
627 let cp = dummy_signed_checkpoint(10, root);
628
629 try_cache_checkpoint(&cache_path, &cp, None).unwrap();
630 let report = try_cache_checkpoint(&cache_path, &cp, None).unwrap();
631 assert_eq!(report.old_size, 10);
632 assert_eq!(report.new_size, 10);
633 assert!(report.consistent);
634 }
635
636 #[test]
637 fn try_cache_checkpoint_detects_equivocation() {
638 let dir = tempfile::tempdir().unwrap();
639 let cache_path = dir.path().join("log_checkpoint.json");
640
641 let root1 = MerkleHash::from_bytes([0xaa; 32]);
642 let cp1 = dummy_signed_checkpoint(10, root1);
643 try_cache_checkpoint(&cache_path, &cp1, None).unwrap();
644
645 let root2 = MerkleHash::from_bytes([0xbb; 32]);
646 let cp2 = dummy_signed_checkpoint(10, root2);
647 let err = try_cache_checkpoint(&cache_path, &cp2, None).unwrap_err();
648 assert!(matches!(
649 err,
650 TransparencyWorkflowError::CheckpointInconsistent(_)
651 ));
652 }
653
654 #[test]
655 fn try_cache_checkpoint_rejects_smaller_size() {
656 let dir = tempfile::tempdir().unwrap();
657 let cache_path = dir.path().join("log_checkpoint.json");
658
659 let cp1 = dummy_signed_checkpoint(10, MerkleHash::from_bytes([0xaa; 32]));
660 try_cache_checkpoint(&cache_path, &cp1, None).unwrap();
661
662 let cp2 = dummy_signed_checkpoint(5, MerkleHash::from_bytes([0xbb; 32]));
663 let err = try_cache_checkpoint(&cache_path, &cp2, None).unwrap_err();
664 assert!(matches!(
665 err,
666 TransparencyWorkflowError::CheckpointInconsistent(_)
667 ));
668 }
669}