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