1use serde::{Deserialize, Serialize};
31
32pub const TYPE_APPROVAL_USE: &str = "treeship/approval-use/v1";
37pub const TYPE_APPROVAL_REVOCATION: &str = "treeship/approval-revocation/v1";
38pub const TYPE_JOURNAL_CHECKPOINT: &str = "treeship/journal-checkpoint/v1";
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ApprovalUse {
62 #[serde(rename = "type")]
63 pub type_: String,
64
65 pub use_id: String,
69
70 pub grant_id: String,
72
73 pub grant_digest: String,
77
78 pub nonce_digest: String,
83
84 pub actor: String,
85 pub action: String,
86 pub subject: String,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub session_id: Option<String>,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub action_artifact_id: Option<String>,
101
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub receipt_digest: Option<String>,
105
106 pub use_number: u32,
109
110 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub max_uses: Option<u32>,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub idempotency_key: Option<String>,
122
123 pub created_at: String,
124
125 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub expires_at: Option<String>,
130
131 #[serde(default)]
134 pub previous_record_digest: String,
135
136 #[serde(default)]
139 pub record_digest: String,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub signature: Option<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub signature_alg: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub signing_key_id: Option<String>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ApprovalRevocation {
165 #[serde(rename = "type")]
166 pub type_: String,
167 pub revocation_id: String,
168 pub grant_id: String,
169 pub grant_digest: String,
170 pub revoker: String,
171 pub reason: Option<String>,
172 pub created_at: String,
173 #[serde(default)]
174 pub previous_record_digest: String,
175 #[serde(default)]
176 pub record_digest: String,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub signature: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub signature_alg: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub signing_key_id: Option<String>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
205#[serde(rename_all = "kebab-case")]
206pub enum CheckpointKind {
207 #[default]
210 LocalJournal,
211 HubOrg,
214}
215
216impl CheckpointKind {
217 pub fn label(self) -> &'static str {
218 match self {
219 Self::LocalJournal => "local-journal",
220 Self::HubOrg => "hub-org",
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct JournalCheckpoint {
241 #[serde(rename = "type")]
242 pub type_: String,
243 pub checkpoint_id: String,
244
245 #[serde(default)]
248 pub checkpoint_kind: CheckpointKind,
249
250 pub from_record_index: u64,
253 pub to_record_index: u64,
254
255 pub merkle_root: String,
258 pub leaf_count: u64,
259
260 pub journal_id: String,
261 pub created_at: String,
262
263 #[serde(default, skip_serializing_if = "String::is_empty")]
266 pub hub_id: String,
267
268 #[serde(default, skip_serializing_if = "String::is_empty")]
273 pub hub_public_key: String,
274
275 #[serde(default, skip_serializing_if = "String::is_empty")]
279 pub hub_signature: String,
280
281 #[serde(default, skip_serializing_if = "String::is_empty")]
285 pub signed_at: String,
286
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
291 pub covered_use_ids: Vec<String>,
292
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
296 pub covered_grant_ids: Vec<String>,
297
298 #[serde(default)]
299 pub previous_record_digest: String,
300 #[serde(default)]
301 pub record_digest: String,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub signature: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub signature_alg: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub signing_key_id: Option<String>,
309}
310
311impl JournalCheckpoint {
312 pub fn is_hub_signed(&self) -> bool {
316 self.checkpoint_kind == CheckpointKind::HubOrg
317 && !self.hub_id.is_empty()
318 && !self.hub_public_key.is_empty()
319 && !self.hub_signature.is_empty()
320 && !self.signed_at.is_empty()
321 }
322
323 pub fn canonical_hub_signing_bytes(&self) -> Vec<u8> {
328 #[derive(Serialize)]
333 struct Signing<'a> {
334 #[serde(rename = "type")] type_: &'a str,
335 checkpoint_id: &'a str,
336 checkpoint_kind: CheckpointKind,
337 from_record_index: u64,
338 to_record_index: u64,
339 merkle_root: &'a str,
340 leaf_count: u64,
341 journal_id: &'a str,
342 created_at: &'a str,
343 hub_id: &'a str,
344 hub_public_key: &'a str,
345 signed_at: &'a str,
346 covered_use_ids: &'a [String],
347 covered_grant_ids: &'a [String],
348 previous_record_digest: &'a str,
349 }
350 let v = Signing {
351 type_: &self.type_,
352 checkpoint_id: &self.checkpoint_id,
353 checkpoint_kind: self.checkpoint_kind,
354 from_record_index: self.from_record_index,
355 to_record_index: self.to_record_index,
356 merkle_root: &self.merkle_root,
357 leaf_count: self.leaf_count,
358 journal_id: &self.journal_id,
359 created_at: &self.created_at,
360 hub_id: &self.hub_id,
361 hub_public_key: &self.hub_public_key,
362 signed_at: &self.signed_at,
363 covered_use_ids: &self.covered_use_ids,
364 covered_grant_ids: &self.covered_grant_ids,
365 previous_record_digest: &self.previous_record_digest,
366 };
367 serde_json::to_vec(&v).unwrap_or_default()
368 }
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "kebab-case")]
384pub enum ReplayCheckLevel {
385 NotPerformed,
387 PackageLocal,
390 LocalJournal,
392 HubOrg,
394}
395
396impl ReplayCheckLevel {
397 pub fn label(self) -> &'static str {
398 match self {
399 Self::NotPerformed => "not performed",
400 Self::PackageLocal => "package-local",
401 Self::LocalJournal => "local-journal",
402 Self::HubOrg => "hub-org",
403 }
404 }
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct ReplayCheck {
412 pub level: ReplayCheckLevel,
413
414 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub use_number: Option<u32>,
418
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub max_uses: Option<u32>,
422
423 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub passed: Option<bool>,
428
429 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub details: Option<String>,
432}
433
434impl ReplayCheck {
435 pub fn not_performed() -> Self {
436 Self { level: ReplayCheckLevel::NotPerformed, use_number: None, max_uses: None, passed: None, details: None }
437 }
438
439 pub fn package_local(passed: bool, details: impl Into<String>) -> Self {
440 Self {
441 level: ReplayCheckLevel::PackageLocal,
442 use_number: None,
443 max_uses: None,
444 passed: Some(passed),
445 details: Some(details.into()),
446 }
447 }
448}
449
450pub fn approval_use_record_digest(rec: &ApprovalUse) -> String {
463 use sha2::{Digest, Sha256};
464 let mut canon = rec.clone();
465 canon.record_digest = String::new();
466 let bytes = serde_json::to_vec(&canon).unwrap_or_default();
467 let mut hasher = Sha256::new();
468 hasher.update(&bytes);
469 let digest = hasher.finalize();
470 let mut hex = String::with_capacity(64 + 7);
471 hex.push_str("sha256:");
472 for b in digest.as_slice() {
473 use std::fmt::Write;
474 let _ = write!(hex, "{b:02x}");
475 }
476 hex
477}
478
479pub fn approval_revocation_record_digest(rec: &ApprovalRevocation) -> String {
480 use sha2::{Digest, Sha256};
481 let mut canon = rec.clone();
482 canon.record_digest = String::new();
483 let bytes = serde_json::to_vec(&canon).unwrap_or_default();
484 let mut hasher = Sha256::new();
485 hasher.update(&bytes);
486 let digest = hasher.finalize();
487 let mut hex = String::with_capacity(64 + 7);
488 hex.push_str("sha256:");
489 for b in digest.as_slice() {
490 use std::fmt::Write;
491 let _ = write!(hex, "{b:02x}");
492 }
493 hex
494}
495
496pub fn journal_checkpoint_record_digest(rec: &JournalCheckpoint) -> String {
497 use sha2::{Digest, Sha256};
498 let mut canon = rec.clone();
499 canon.record_digest = String::new();
500 let bytes = serde_json::to_vec(&canon).unwrap_or_default();
501 let mut hasher = Sha256::new();
502 hasher.update(&bytes);
503 let digest = hasher.finalize();
504 let mut hex = String::with_capacity(64 + 7);
505 hex.push_str("sha256:");
506 for b in digest.as_slice() {
507 use std::fmt::Write;
508 let _ = write!(hex, "{b:02x}");
509 }
510 hex
511}
512
513#[derive(Debug, Clone, PartialEq, Eq)]
515pub enum HubCheckpointVerification {
516 Valid,
519 MissingFields(&'static str),
524 Tampered,
527 NotHubKind,
531 UntrustedIssuer,
537}
538
539pub fn verify_hub_checkpoint_signature(
551 cp: &JournalCheckpoint,
552 trust: &crate::trust::TrustRootStore,
553) -> HubCheckpointVerification {
554 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
555 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
556 use crate::trust::TrustRootKind;
557
558 if cp.checkpoint_kind != CheckpointKind::HubOrg {
559 return HubCheckpointVerification::NotHubKind;
560 }
561 if cp.hub_id.is_empty() { return HubCheckpointVerification::MissingFields("hub_id"); }
562 if cp.hub_public_key.is_empty() { return HubCheckpointVerification::MissingFields("hub_public_key"); }
563 if cp.hub_signature.is_empty() { return HubCheckpointVerification::MissingFields("hub_signature"); }
564 if cp.signed_at.is_empty() { return HubCheckpointVerification::MissingFields("signed_at"); }
565
566 let pk_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_public_key.as_bytes()) {
567 Ok(b) if b.len() == 32 => b,
568 _ => return HubCheckpointVerification::Tampered,
569 };
570 let sig_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_signature.as_bytes()) {
571 Ok(b) if b.len() == 64 => b,
572 _ => return HubCheckpointVerification::Tampered,
573 };
574 let mut pk_arr = [0u8; 32];
575 pk_arr.copy_from_slice(&pk_bytes);
576 let mut sig_arr = [0u8; 64];
577 sig_arr.copy_from_slice(&sig_bytes);
578
579 let vk = match VerifyingKey::from_bytes(&pk_arr) {
580 Ok(k) => k,
581 Err(_) => return HubCheckpointVerification::Tampered,
582 };
583
584 if !trust.contains(&vk, TrustRootKind::Ship) {
591 return HubCheckpointVerification::UntrustedIssuer;
592 }
593
594 let sig = Signature::from_bytes(&sig_arr);
595 let payload = cp.canonical_hub_signing_bytes();
596 match vk.verify(&payload, &sig) {
597 Ok(()) => HubCheckpointVerification::Valid,
598 Err(_) => HubCheckpointVerification::Tampered,
599 }
600}
601
602pub fn nonce_digest(raw_nonce: &str) -> String {
605 use sha2::{Digest, Sha256};
606 let mut hasher = Sha256::new();
607 hasher.update(raw_nonce.as_bytes());
608 let digest = hasher.finalize();
609 let mut hex = String::with_capacity(64 + 7);
610 hex.push_str("sha256:");
611 for b in digest.as_slice() {
612 use std::fmt::Write;
613 let _ = write!(hex, "{b:02x}");
614 }
615 hex
616}
617
618#[cfg(test)]
623mod tests {
624 use super::*;
625
626 fn sample_use() -> ApprovalUse {
627 ApprovalUse {
628 type_: TYPE_APPROVAL_USE.into(),
629 use_id: "use_abc".into(),
630 grant_id: "art_grant_1".into(),
631 grant_digest: "sha256:00".into(),
632 nonce_digest: "sha256:11".into(),
633 actor: "agent://deployer".into(),
634 action: "deploy.production".into(),
635 subject: "env://production".into(),
636 session_id: Some("ssn_xyz".into()),
637 action_artifact_id: None,
638 receipt_digest: None,
639 use_number: 1,
640 max_uses: Some(1),
641 idempotency_key: None,
642 created_at: "2026-04-30T06:00:00Z".into(),
643 expires_at: None,
644 previous_record_digest: String::new(),
645 record_digest: String::new(),
646 signature: None,
647 signature_alg: None,
648 signing_key_id: None,
649 }
650 }
651
652 #[test]
653 fn approval_use_serialization_round_trips() {
654 let u = sample_use();
655 let bytes = serde_json::to_vec(&u).unwrap();
656 let back: ApprovalUse = serde_json::from_slice(&bytes).unwrap();
657 assert_eq!(back.use_id, u.use_id);
658 assert_eq!(back.grant_id, u.grant_id);
659 assert_eq!(back.use_number, 1);
660 }
661
662 #[test]
663 fn record_digest_is_stable_and_excludes_itself() {
664 let u1 = sample_use();
668 let mut u2 = u1.clone();
669 u2.record_digest = "sha256:cafe".into();
670 assert_eq!(approval_use_record_digest(&u1), approval_use_record_digest(&u2));
671 }
672
673 #[test]
674 fn previous_record_digest_chains() {
675 let mut a = sample_use();
679 a.use_number = 1;
680 a.record_digest = approval_use_record_digest(&a);
681
682 let mut b = sample_use();
683 b.use_number = 2;
684 b.use_id = "use_def".into();
685 b.previous_record_digest = a.record_digest.clone();
686 b.record_digest = approval_use_record_digest(&b);
687
688 assert_eq!(b.previous_record_digest, a.record_digest);
689 let mut c = sample_use();
691 c.use_id = "use_ghi".into();
692 c.use_number = 2;
693 c.previous_record_digest = "sha256:wrong".into();
694 c.record_digest = approval_use_record_digest(&c);
695 assert_ne!(b.record_digest, c.record_digest);
696 }
697
698 #[test]
699 fn nonce_digest_does_not_leak_raw_nonce() {
700 let raw = "n_abcdef0123";
704 let d = nonce_digest(raw);
705 assert!(d.starts_with("sha256:"));
706 assert!(!d.contains(raw), "digest must not contain the raw nonce");
707 }
708
709 #[test]
710 fn replay_check_level_labels() {
711 assert_eq!(ReplayCheckLevel::NotPerformed.label(), "not performed");
712 assert_eq!(ReplayCheckLevel::PackageLocal.label(), "package-local");
713 assert_eq!(ReplayCheckLevel::LocalJournal.label(), "local-journal");
714 assert_eq!(ReplayCheckLevel::HubOrg.label(), "hub-org");
715 }
716
717 #[test]
718 fn replay_check_serialization_uses_kebab_case() {
719 let r = ReplayCheck {
720 level: ReplayCheckLevel::LocalJournal,
721 use_number: Some(1),
722 max_uses: Some(1),
723 passed: Some(true),
724 details: Some("local Approval Use Journal passed".into()),
725 };
726 let v = serde_json::to_value(&r).unwrap();
727 assert_eq!(v["level"], "local-journal");
728 assert_eq!(v["use_number"], 1);
729 assert_eq!(v["max_uses"], 1);
730 assert_eq!(v["passed"], true);
731 }
732
733 #[test]
734 fn revocation_record_digest_stable() {
735 let rev = ApprovalRevocation {
736 type_: TYPE_APPROVAL_REVOCATION.into(),
737 revocation_id: "rev_1".into(),
738 grant_id: "art_grant_1".into(),
739 grant_digest: "sha256:00".into(),
740 revoker: "human://alice".into(),
741 reason: Some("rotated key".into()),
742 created_at: "2026-04-30T06:01:00Z".into(),
743 previous_record_digest: "sha256:00".into(),
744 record_digest: String::new(),
745 signature: None,
746 signature_alg: None,
747 signing_key_id: None,
748 };
749 let d1 = approval_revocation_record_digest(&rev);
750 let d2 = approval_revocation_record_digest(&rev);
751 assert_eq!(d1, d2);
752 }
753
754 fn sample_checkpoint(kind: CheckpointKind) -> JournalCheckpoint {
755 JournalCheckpoint {
756 type_: TYPE_JOURNAL_CHECKPOINT.into(),
757 checkpoint_id: "cp_1".into(),
758 checkpoint_kind: kind,
759 from_record_index: 1,
760 to_record_index: 10,
761 merkle_root: "sha256:abcd".into(),
762 leaf_count: 10,
763 journal_id: "journal_1".into(),
764 created_at: "2026-04-30T06:02:00Z".into(),
765 hub_id: String::new(),
766 hub_public_key: String::new(),
767 hub_signature: String::new(),
768 signed_at: String::new(),
769 covered_use_ids: Vec::new(),
770 covered_grant_ids: Vec::new(),
771 previous_record_digest: "sha256:00".into(),
772 record_digest: String::new(),
773 signature: None,
774 signature_alg: None,
775 signing_key_id: None,
776 }
777 }
778
779 #[test]
780 fn checkpoint_record_digest_stable() {
781 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
782 let d1 = journal_checkpoint_record_digest(&cp);
783 let d2 = journal_checkpoint_record_digest(&cp);
784 assert_eq!(d1, d2);
785 }
786
787 #[test]
788 fn checkpoint_kind_defaults_to_local_journal() {
789 let json = r#"{"type":"treeship/journal-checkpoint/v1","checkpoint_id":"cp_legacy",
793 "from_record_index":1,"to_record_index":10,"merkle_root":"sha256:00",
794 "leaf_count":10,"journal_id":"j","created_at":"2026-04-30T00:00:00Z"}"#;
795 let cp: JournalCheckpoint = serde_json::from_str(json).unwrap();
796 assert_eq!(cp.checkpoint_kind, CheckpointKind::LocalJournal);
797 assert!(!cp.is_hub_signed());
798 }
799
800 #[test]
801 fn checkpoint_kind_serializes_kebab_case() {
802 let cp = sample_checkpoint(CheckpointKind::HubOrg);
803 let v = serde_json::to_value(&cp).unwrap();
804 assert_eq!(v["checkpoint_kind"], "hub-org");
805 }
806
807 fn trust_with(pk: &ed25519_dalek::VerifyingKey) -> crate::trust::TrustRootStore {
811 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
812 TrustRootStore::with_roots(vec![TrustRoot {
813 key_id: "test_hub".into(),
814 public_key: encode_ed25519_pubkey(pk),
815 kind: TrustRootKind::Ship,
816 label: "test pin".into(),
817 added_at: "2026-05-15T00:00:00Z".into(),
818 }])
819 }
820
821 #[test]
822 fn local_journal_checkpoint_is_not_hub_signed() {
823 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
824 assert!(!cp.is_hub_signed());
825 assert_eq!(
826 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
827 HubCheckpointVerification::NotHubKind,
828 );
829 }
830
831 #[test]
832 fn hub_kind_without_fields_is_missing() {
833 let cp = sample_checkpoint(CheckpointKind::HubOrg);
834 assert!(!cp.is_hub_signed());
835 assert!(matches!(
836 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
837 HubCheckpointVerification::MissingFields(_),
838 ));
839 }
840
841 #[test]
846 fn hub_checkpoint_signature_round_trip() {
847 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
848 use ed25519_dalek::{Signer, SigningKey};
849
850 let mut sk_bytes = [0u8; 32];
851 for (i, b) in sk_bytes.iter_mut().enumerate() {
852 *b = i as u8 + 7;
853 }
854 let sk = SigningKey::from_bytes(&sk_bytes);
855 let pk = sk.verifying_key();
856 let pk_b64 = URL_SAFE_NO_PAD.encode(pk.to_bytes());
857
858 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
859 cp.hub_id = "hub://zerker-org".into();
860 cp.hub_public_key = pk_b64.clone();
861 cp.signed_at = "2026-04-30T07:00:00Z".into();
862 cp.covered_use_ids = vec!["use_alpha".into(), "use_beta".into()];
863
864 let payload = cp.canonical_hub_signing_bytes();
865 let sig = sk.sign(&payload);
866 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
867
868 assert!(cp.is_hub_signed());
869 let trust = trust_with(&pk);
870 assert_eq!(
871 verify_hub_checkpoint_signature(&cp, &trust),
872 HubCheckpointVerification::Valid,
873 );
874 }
875
876 #[test]
877 fn tampered_hub_checkpoint_fails_verification() {
878 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
879 use ed25519_dalek::{Signer, SigningKey};
880
881 let sk = SigningKey::from_bytes(&[1u8; 32]);
882 let pk = sk.verifying_key();
883
884 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
885 cp.hub_id = "hub://x".into();
886 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
887 cp.signed_at = "2026-04-30T07:00:00Z".into();
888 cp.covered_use_ids = vec!["use_alpha".into()];
889
890 let sig = sk.sign(&cp.canonical_hub_signing_bytes());
891 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
892 let trust = trust_with(&pk);
893 assert_eq!(verify_hub_checkpoint_signature(&cp, &trust), HubCheckpointVerification::Valid);
895
896 cp.covered_use_ids.push("use_smuggled".into());
899 assert_eq!(
900 verify_hub_checkpoint_signature(&cp, &trust),
901 HubCheckpointVerification::Tampered,
902 );
903 }
904
905 #[test]
906 fn wrong_key_fails_verification() {
907 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
908 use ed25519_dalek::{Signer, SigningKey};
909
910 let sk_real = SigningKey::from_bytes(&[2u8; 32]);
911 let sk_imp = SigningKey::from_bytes(&[3u8; 32]); let pk_imp = sk_imp.verifying_key();
913
914 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
915 cp.hub_id = "hub://x".into();
916 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk_imp.to_bytes());
918 cp.signed_at = "2026-04-30T07:00:00Z".into();
919 let sig = sk_real.sign(&cp.canonical_hub_signing_bytes());
920 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
921 let trust = trust_with(&pk_imp);
925 assert_eq!(
926 verify_hub_checkpoint_signature(&cp, &trust),
927 HubCheckpointVerification::Tampered,
928 );
929 }
930
931 #[test]
932 fn malformed_pubkey_or_signature_fails() {
933 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
934 cp.hub_id = "hub://x".into();
935 cp.hub_public_key = "not-base64!!".into();
936 cp.hub_signature = "also-not-base64".into();
937 cp.signed_at = "2026-04-30T07:00:00Z".into();
938 assert_eq!(
939 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
940 HubCheckpointVerification::Tampered,
941 );
942 }
943
944 #[test]
950 fn hub_checkpoint_rejects_untrusted_issuer() {
951 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
952 use ed25519_dalek::{Signer, SigningKey};
953
954 let attacker = SigningKey::from_bytes(&[42u8; 32]);
955 let pk = attacker.verifying_key();
956
957 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
958 cp.hub_id = "hub://attacker-claims-zerker".into();
959 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
960 cp.signed_at = "2026-04-30T07:00:00Z".into();
961 cp.covered_use_ids = vec!["use_alpha".into()];
962 let sig = attacker.sign(&cp.canonical_hub_signing_bytes());
963 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
964
965 let honest = SigningKey::from_bytes(&[7u8; 32]);
967 let trust = trust_with(&honest.verifying_key());
968
969 assert_eq!(
970 verify_hub_checkpoint_signature(&cp, &trust),
971 HubCheckpointVerification::UntrustedIssuer,
972 );
973 }
974
975 #[test]
978 fn hub_checkpoint_rejects_with_no_trust_configured() {
979 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
980 use ed25519_dalek::{Signer, SigningKey};
981
982 let sk = SigningKey::from_bytes(&[9u8; 32]);
983 let pk = sk.verifying_key();
984 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
985 cp.hub_id = "hub://anything".into();
986 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
987 cp.signed_at = "2026-04-30T07:00:00Z".into();
988 let sig = sk.sign(&cp.canonical_hub_signing_bytes());
989 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
990
991 assert_eq!(
992 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
993 HubCheckpointVerification::UntrustedIssuer,
994 );
995 }
996}