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)
378 .expect("approval_use canonical_hub_signing_bytes encode must not fail; report bug")
379 }
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "kebab-case")]
395pub enum ReplayCheckLevel {
396 NotPerformed,
398 PackageLocal,
401 LocalJournal,
403 HubOrg,
405}
406
407impl ReplayCheckLevel {
408 pub fn label(self) -> &'static str {
409 match self {
410 Self::NotPerformed => "not performed",
411 Self::PackageLocal => "package-local",
412 Self::LocalJournal => "local-journal",
413 Self::HubOrg => "hub-org",
414 }
415 }
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct ReplayCheck {
423 pub level: ReplayCheckLevel,
424
425 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub use_number: Option<u32>,
429
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub max_uses: Option<u32>,
433
434 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub passed: Option<bool>,
439
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub details: Option<String>,
443}
444
445impl ReplayCheck {
446 pub fn not_performed() -> Self {
447 Self { level: ReplayCheckLevel::NotPerformed, use_number: None, max_uses: None, passed: None, details: None }
448 }
449
450 pub fn package_local(passed: bool, details: impl Into<String>) -> Self {
451 Self {
452 level: ReplayCheckLevel::PackageLocal,
453 use_number: None,
454 max_uses: None,
455 passed: Some(passed),
456 details: Some(details.into()),
457 }
458 }
459}
460
461pub fn approval_use_record_digest(rec: &ApprovalUse) -> String {
474 use sha2::{Digest, Sha256};
475 let mut canon = rec.clone();
476 canon.record_digest = String::new();
477 let bytes = serde_json::to_vec(&canon)
485 .expect("approval_use_record_digest encode must not fail; report bug");
486 let mut hasher = Sha256::new();
487 hasher.update(&bytes);
488 let digest = hasher.finalize();
489 let mut hex = String::with_capacity(64 + 7);
490 hex.push_str("sha256:");
491 for b in digest.as_slice() {
492 use std::fmt::Write;
493 let _ = write!(hex, "{b:02x}");
494 }
495 hex
496}
497
498pub fn approval_revocation_record_digest(rec: &ApprovalRevocation) -> String {
499 use sha2::{Digest, Sha256};
500 let mut canon = rec.clone();
501 canon.record_digest = String::new();
502 let bytes = serde_json::to_vec(&canon)
506 .expect("approval_revocation_record_digest encode must not fail; report bug");
507 let mut hasher = Sha256::new();
508 hasher.update(&bytes);
509 let digest = hasher.finalize();
510 let mut hex = String::with_capacity(64 + 7);
511 hex.push_str("sha256:");
512 for b in digest.as_slice() {
513 use std::fmt::Write;
514 let _ = write!(hex, "{b:02x}");
515 }
516 hex
517}
518
519pub fn journal_checkpoint_record_digest(rec: &JournalCheckpoint) -> String {
520 use sha2::{Digest, Sha256};
521 let mut canon = rec.clone();
522 canon.record_digest = String::new();
523 let bytes = serde_json::to_vec(&canon)
529 .expect("journal_checkpoint_record_digest encode must not fail; report bug");
530 let mut hasher = Sha256::new();
531 hasher.update(&bytes);
532 let digest = hasher.finalize();
533 let mut hex = String::with_capacity(64 + 7);
534 hex.push_str("sha256:");
535 for b in digest.as_slice() {
536 use std::fmt::Write;
537 let _ = write!(hex, "{b:02x}");
538 }
539 hex
540}
541
542#[derive(Debug, Clone, PartialEq, Eq)]
544pub enum HubCheckpointVerification {
545 Valid,
548 MissingFields(&'static str),
553 Tampered,
556 NotHubKind,
560 UntrustedIssuer,
566}
567
568pub fn verify_hub_checkpoint_signature(
580 cp: &JournalCheckpoint,
581 trust: &crate::trust::TrustRootStore,
582) -> HubCheckpointVerification {
583 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
584 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
585 use crate::trust::TrustRootKind;
586
587 if cp.checkpoint_kind != CheckpointKind::HubOrg {
588 return HubCheckpointVerification::NotHubKind;
589 }
590 if cp.hub_id.is_empty() { return HubCheckpointVerification::MissingFields("hub_id"); }
591 if cp.hub_public_key.is_empty() { return HubCheckpointVerification::MissingFields("hub_public_key"); }
592 if cp.hub_signature.is_empty() { return HubCheckpointVerification::MissingFields("hub_signature"); }
593 if cp.signed_at.is_empty() { return HubCheckpointVerification::MissingFields("signed_at"); }
594
595 let pk_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_public_key.as_bytes()) {
596 Ok(b) if b.len() == 32 => b,
597 _ => return HubCheckpointVerification::Tampered,
598 };
599 let sig_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_signature.as_bytes()) {
600 Ok(b) if b.len() == 64 => b,
601 _ => return HubCheckpointVerification::Tampered,
602 };
603 let mut pk_arr = [0u8; 32];
604 pk_arr.copy_from_slice(&pk_bytes);
605 let mut sig_arr = [0u8; 64];
606 sig_arr.copy_from_slice(&sig_bytes);
607
608 let vk = match VerifyingKey::from_bytes(&pk_arr) {
609 Ok(k) => k,
610 Err(_) => return HubCheckpointVerification::Tampered,
611 };
612
613 if !trust.contains(&vk, TrustRootKind::HubOrg) {
622 return HubCheckpointVerification::UntrustedIssuer;
623 }
624
625 let sig = Signature::from_bytes(&sig_arr);
626 let payload = cp.canonical_hub_signing_bytes();
627 match vk.verify_strict(&payload, &sig) {
628 Ok(()) => HubCheckpointVerification::Valid,
629 Err(_) => HubCheckpointVerification::Tampered,
630 }
631}
632
633pub fn nonce_digest(raw_nonce: &str) -> String {
636 use sha2::{Digest, Sha256};
637 let mut hasher = Sha256::new();
638 hasher.update(raw_nonce.as_bytes());
639 let digest = hasher.finalize();
640 let mut hex = String::with_capacity(64 + 7);
641 hex.push_str("sha256:");
642 for b in digest.as_slice() {
643 use std::fmt::Write;
644 let _ = write!(hex, "{b:02x}");
645 }
646 hex
647}
648
649#[cfg(test)]
654mod tests {
655 use super::*;
656
657 fn sample_use() -> ApprovalUse {
658 ApprovalUse {
659 type_: TYPE_APPROVAL_USE.into(),
660 use_id: "use_abc".into(),
661 grant_id: "art_grant_1".into(),
662 grant_digest: "sha256:00".into(),
663 nonce_digest: "sha256:11".into(),
664 actor: "agent://deployer".into(),
665 action: "deploy.production".into(),
666 subject: "env://production".into(),
667 session_id: Some("ssn_xyz".into()),
668 action_artifact_id: None,
669 receipt_digest: None,
670 use_number: 1,
671 max_uses: Some(1),
672 idempotency_key: None,
673 created_at: "2026-04-30T06:00:00Z".into(),
674 expires_at: None,
675 previous_record_digest: String::new(),
676 record_digest: String::new(),
677 signature: None,
678 signature_alg: None,
679 signing_key_id: None,
680 }
681 }
682
683 #[test]
684 fn approval_use_serialization_round_trips() {
685 let u = sample_use();
686 let bytes = serde_json::to_vec(&u).unwrap();
687 let back: ApprovalUse = serde_json::from_slice(&bytes).unwrap();
688 assert_eq!(back.use_id, u.use_id);
689 assert_eq!(back.grant_id, u.grant_id);
690 assert_eq!(back.use_number, 1);
691 }
692
693 #[test]
694 fn record_digest_is_stable_and_excludes_itself() {
695 let u1 = sample_use();
699 let mut u2 = u1.clone();
700 u2.record_digest = "sha256:cafe".into();
701 assert_eq!(approval_use_record_digest(&u1), approval_use_record_digest(&u2));
702 }
703
704 #[test]
705 fn previous_record_digest_chains() {
706 let mut a = sample_use();
710 a.use_number = 1;
711 a.record_digest = approval_use_record_digest(&a);
712
713 let mut b = sample_use();
714 b.use_number = 2;
715 b.use_id = "use_def".into();
716 b.previous_record_digest = a.record_digest.clone();
717 b.record_digest = approval_use_record_digest(&b);
718
719 assert_eq!(b.previous_record_digest, a.record_digest);
720 let mut c = sample_use();
722 c.use_id = "use_ghi".into();
723 c.use_number = 2;
724 c.previous_record_digest = "sha256:wrong".into();
725 c.record_digest = approval_use_record_digest(&c);
726 assert_ne!(b.record_digest, c.record_digest);
727 }
728
729 #[test]
730 fn nonce_digest_does_not_leak_raw_nonce() {
731 let raw = "n_abcdef0123";
735 let d = nonce_digest(raw);
736 assert!(d.starts_with("sha256:"));
737 assert!(!d.contains(raw), "digest must not contain the raw nonce");
738 }
739
740 #[test]
741 fn replay_check_level_labels() {
742 assert_eq!(ReplayCheckLevel::NotPerformed.label(), "not performed");
743 assert_eq!(ReplayCheckLevel::PackageLocal.label(), "package-local");
744 assert_eq!(ReplayCheckLevel::LocalJournal.label(), "local-journal");
745 assert_eq!(ReplayCheckLevel::HubOrg.label(), "hub-org");
746 }
747
748 #[test]
749 fn replay_check_serialization_uses_kebab_case() {
750 let r = ReplayCheck {
751 level: ReplayCheckLevel::LocalJournal,
752 use_number: Some(1),
753 max_uses: Some(1),
754 passed: Some(true),
755 details: Some("local Approval Use Journal passed".into()),
756 };
757 let v = serde_json::to_value(&r).unwrap();
758 assert_eq!(v["level"], "local-journal");
759 assert_eq!(v["use_number"], 1);
760 assert_eq!(v["max_uses"], 1);
761 assert_eq!(v["passed"], true);
762 }
763
764 #[test]
765 fn revocation_record_digest_stable() {
766 let rev = ApprovalRevocation {
767 type_: TYPE_APPROVAL_REVOCATION.into(),
768 revocation_id: "rev_1".into(),
769 grant_id: "art_grant_1".into(),
770 grant_digest: "sha256:00".into(),
771 revoker: "human://alice".into(),
772 reason: Some("rotated key".into()),
773 created_at: "2026-04-30T06:01:00Z".into(),
774 previous_record_digest: "sha256:00".into(),
775 record_digest: String::new(),
776 signature: None,
777 signature_alg: None,
778 signing_key_id: None,
779 };
780 let d1 = approval_revocation_record_digest(&rev);
781 let d2 = approval_revocation_record_digest(&rev);
782 assert_eq!(d1, d2);
783 }
784
785 fn sample_checkpoint(kind: CheckpointKind) -> JournalCheckpoint {
786 JournalCheckpoint {
787 type_: TYPE_JOURNAL_CHECKPOINT.into(),
788 checkpoint_id: "cp_1".into(),
789 checkpoint_kind: kind,
790 from_record_index: 1,
791 to_record_index: 10,
792 merkle_root: "sha256:abcd".into(),
793 leaf_count: 10,
794 journal_id: "journal_1".into(),
795 created_at: "2026-04-30T06:02:00Z".into(),
796 hub_id: String::new(),
797 hub_public_key: String::new(),
798 hub_signature: String::new(),
799 signed_at: String::new(),
800 covered_use_ids: Vec::new(),
801 covered_grant_ids: Vec::new(),
802 previous_record_digest: "sha256:00".into(),
803 record_digest: String::new(),
804 signature: None,
805 signature_alg: None,
806 signing_key_id: None,
807 }
808 }
809
810 #[test]
811 fn checkpoint_record_digest_stable() {
812 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
813 let d1 = journal_checkpoint_record_digest(&cp);
814 let d2 = journal_checkpoint_record_digest(&cp);
815 assert_eq!(d1, d2);
816 }
817
818 #[test]
831 fn record_digests_never_match_empty_bytes_sha256() {
832 use sha2::{Digest, Sha256};
833
834 let mut hasher = Sha256::new();
840 let empty: &[u8] = &[];
841 hasher.update(empty);
842 let empty_digest = hasher.finalize();
843 let mut empty_hex = String::with_capacity(64 + 7);
844 empty_hex.push_str("sha256:");
845 for b in empty_digest.as_slice() {
846 use std::fmt::Write;
847 let _ = write!(empty_hex, "{b:02x}");
848 }
849
850 let u = sample_use();
851 let use_digest = approval_use_record_digest(&u);
852 assert_ne!(
853 use_digest, empty_hex,
854 "approval_use_record_digest must never match sha256-of-empty (audit lane C)",
855 );
856
857 let rev = ApprovalRevocation {
858 type_: TYPE_APPROVAL_REVOCATION.into(),
859 revocation_id: "rev_x".into(),
860 grant_id: "art_grant_x".into(),
861 grant_digest: "sha256:00".into(),
862 revoker: "human://x".into(),
863 reason: None,
864 created_at: "2026-04-30T06:01:00Z".into(),
865 previous_record_digest: "sha256:00".into(),
866 record_digest: String::new(),
867 signature: None,
868 signature_alg: None,
869 signing_key_id: None,
870 };
871 let rev_digest = approval_revocation_record_digest(&rev);
872 assert_ne!(rev_digest, empty_hex);
873
874 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
875 let cp_digest = journal_checkpoint_record_digest(&cp);
876 assert_ne!(cp_digest, empty_hex);
877
878 let cp_hub = sample_checkpoint(CheckpointKind::HubOrg);
880 let signing_bytes = cp_hub.canonical_hub_signing_bytes();
881 assert!(
882 !signing_bytes.is_empty(),
883 "canonical_hub_signing_bytes must never be empty (would let two checkpoints cross-validate)",
884 );
885
886 let mut u2 = sample_use();
889 u2.use_id = "use_zzz".into();
890 u2.use_number = 99;
891 let use_digest_2 = approval_use_record_digest(&u2);
892 assert_ne!(use_digest, use_digest_2);
893 }
894
895 #[test]
896 fn checkpoint_kind_defaults_to_local_journal() {
897 let json = r#"{"type":"treeship/journal-checkpoint/v1","checkpoint_id":"cp_legacy",
901 "from_record_index":1,"to_record_index":10,"merkle_root":"sha256:00",
902 "leaf_count":10,"journal_id":"j","created_at":"2026-04-30T00:00:00Z"}"#;
903 let cp: JournalCheckpoint = serde_json::from_str(json).unwrap();
904 assert_eq!(cp.checkpoint_kind, CheckpointKind::LocalJournal);
905 assert!(!cp.is_hub_signed());
906 }
907
908 #[test]
909 fn checkpoint_kind_serializes_kebab_case() {
910 let cp = sample_checkpoint(CheckpointKind::HubOrg);
911 let v = serde_json::to_value(&cp).unwrap();
912 assert_eq!(v["checkpoint_kind"], "hub-org");
913 }
914
915 fn trust_with(pk: &ed25519_dalek::VerifyingKey) -> crate::trust::TrustRootStore {
919 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
920 TrustRootStore::with_roots(vec![TrustRoot {
921 key_id: "test_hub".into(),
922 public_key: encode_ed25519_pubkey(pk),
923 kind: TrustRootKind::HubOrg,
924 label: "test pin".into(),
925 added_at: "2026-05-15T00:00:00Z".into(),
926 }])
927 }
928
929 #[test]
930 fn local_journal_checkpoint_is_not_hub_signed() {
931 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
932 assert!(!cp.is_hub_signed());
933 assert_eq!(
934 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
935 HubCheckpointVerification::NotHubKind,
936 );
937 }
938
939 #[test]
940 fn hub_kind_without_fields_is_missing() {
941 let cp = sample_checkpoint(CheckpointKind::HubOrg);
942 assert!(!cp.is_hub_signed());
943 assert!(matches!(
944 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
945 HubCheckpointVerification::MissingFields(_),
946 ));
947 }
948
949 #[test]
954 fn hub_checkpoint_signature_round_trip() {
955 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
956 use ed25519_dalek::{Signer, SigningKey};
957
958 let mut sk_bytes = [0u8; 32];
959 for (i, b) in sk_bytes.iter_mut().enumerate() {
960 *b = i as u8 + 7;
961 }
962 let sk = SigningKey::from_bytes(&sk_bytes);
963 let pk = sk.verifying_key();
964 let pk_b64 = URL_SAFE_NO_PAD.encode(pk.to_bytes());
965
966 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
967 cp.hub_id = "hub://zerker-org".into();
968 cp.hub_public_key = pk_b64.clone();
969 cp.signed_at = "2026-04-30T07:00:00Z".into();
970 cp.covered_use_ids = vec!["use_alpha".into(), "use_beta".into()];
971
972 let payload = cp.canonical_hub_signing_bytes();
973 let sig = sk.sign(&payload);
974 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
975
976 assert!(cp.is_hub_signed());
977 let trust = trust_with(&pk);
978 assert_eq!(
979 verify_hub_checkpoint_signature(&cp, &trust),
980 HubCheckpointVerification::Valid,
981 );
982 }
983
984 #[test]
988 fn deprecated_ship_pin_no_longer_authorizes_hub_checkpoint() {
989 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
990 use ed25519_dalek::{Signer, SigningKey};
991 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
992 let sk = SigningKey::from_bytes(&[9u8; 32]);
993 let pk = sk.verifying_key();
994 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
995 cp.hub_id = "hub://zerker-org".into();
996 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
997 cp.signed_at = "2026-04-30T07:00:00Z".into();
998 cp.covered_use_ids = vec!["use_alpha".into()];
999 cp.hub_signature = URL_SAFE_NO_PAD.encode(sk.sign(&cp.canonical_hub_signing_bytes()).to_bytes());
1000
1001 let pin = |kind| TrustRootStore::with_roots(vec![TrustRoot {
1002 key_id: "h".into(),
1003 public_key: encode_ed25519_pubkey(&pk),
1004 kind,
1005 label: String::new(),
1006 added_at: "2026-05-15T00:00:00Z".into(),
1007 }]);
1008
1009 assert_eq!(
1011 verify_hub_checkpoint_signature(&cp, &pin(TrustRootKind::Ship)),
1012 HubCheckpointVerification::UntrustedIssuer,
1013 "a `ship` pin must no longer authorize hub-org checkpoints",
1014 );
1015 assert_eq!(
1017 verify_hub_checkpoint_signature(&cp, &pin(TrustRootKind::HubOrg)),
1018 HubCheckpointVerification::Valid,
1019 );
1020 assert_eq!(
1022 verify_hub_checkpoint_signature(&cp, &pin(TrustRootKind::CertIssuer)),
1023 HubCheckpointVerification::UntrustedIssuer,
1024 );
1025 }
1026
1027 #[test]
1028 fn tampered_hub_checkpoint_fails_verification() {
1029 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1030 use ed25519_dalek::{Signer, SigningKey};
1031
1032 let sk = SigningKey::from_bytes(&[1u8; 32]);
1033 let pk = sk.verifying_key();
1034
1035 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1036 cp.hub_id = "hub://x".into();
1037 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1038 cp.signed_at = "2026-04-30T07:00:00Z".into();
1039 cp.covered_use_ids = vec!["use_alpha".into()];
1040
1041 let sig = sk.sign(&cp.canonical_hub_signing_bytes());
1042 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1043 let trust = trust_with(&pk);
1044 assert_eq!(verify_hub_checkpoint_signature(&cp, &trust), HubCheckpointVerification::Valid);
1046
1047 cp.covered_use_ids.push("use_smuggled".into());
1050 assert_eq!(
1051 verify_hub_checkpoint_signature(&cp, &trust),
1052 HubCheckpointVerification::Tampered,
1053 );
1054 }
1055
1056 #[test]
1057 fn wrong_key_fails_verification() {
1058 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1059 use ed25519_dalek::{Signer, SigningKey};
1060
1061 let sk_real = SigningKey::from_bytes(&[2u8; 32]);
1062 let sk_imp = SigningKey::from_bytes(&[3u8; 32]); let pk_imp = sk_imp.verifying_key();
1064
1065 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1066 cp.hub_id = "hub://x".into();
1067 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk_imp.to_bytes());
1069 cp.signed_at = "2026-04-30T07:00:00Z".into();
1070 let sig = sk_real.sign(&cp.canonical_hub_signing_bytes());
1071 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1072 let trust = trust_with(&pk_imp);
1076 assert_eq!(
1077 verify_hub_checkpoint_signature(&cp, &trust),
1078 HubCheckpointVerification::Tampered,
1079 );
1080 }
1081
1082 #[test]
1083 fn malformed_pubkey_or_signature_fails() {
1084 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1085 cp.hub_id = "hub://x".into();
1086 cp.hub_public_key = "not-base64!!".into();
1087 cp.hub_signature = "also-not-base64".into();
1088 cp.signed_at = "2026-04-30T07:00:00Z".into();
1089 assert_eq!(
1090 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
1091 HubCheckpointVerification::Tampered,
1092 );
1093 }
1094
1095 #[test]
1101 fn hub_checkpoint_rejects_untrusted_issuer() {
1102 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1103 use ed25519_dalek::{Signer, SigningKey};
1104
1105 let attacker = SigningKey::from_bytes(&[42u8; 32]);
1106 let pk = attacker.verifying_key();
1107
1108 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1109 cp.hub_id = "hub://attacker-claims-zerker".into();
1110 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1111 cp.signed_at = "2026-04-30T07:00:00Z".into();
1112 cp.covered_use_ids = vec!["use_alpha".into()];
1113 let sig = attacker.sign(&cp.canonical_hub_signing_bytes());
1114 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1115
1116 let honest = SigningKey::from_bytes(&[7u8; 32]);
1118 let trust = trust_with(&honest.verifying_key());
1119
1120 assert_eq!(
1121 verify_hub_checkpoint_signature(&cp, &trust),
1122 HubCheckpointVerification::UntrustedIssuer,
1123 );
1124 }
1125
1126 #[test]
1129 fn hub_checkpoint_rejects_with_no_trust_configured() {
1130 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1131 use ed25519_dalek::{Signer, SigningKey};
1132
1133 let sk = SigningKey::from_bytes(&[9u8; 32]);
1134 let pk = sk.verifying_key();
1135 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1136 cp.hub_id = "hub://anything".into();
1137 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1138 cp.signed_at = "2026-04-30T07:00:00Z".into();
1139 let sig = sk.sign(&cp.canonical_hub_signing_bytes());
1140 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1141
1142 assert_eq!(
1143 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
1144 HubCheckpointVerification::UntrustedIssuer,
1145 );
1146 }
1147}