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")]
335 type_: &'a str,
336 checkpoint_id: &'a str,
337 checkpoint_kind: CheckpointKind,
338 from_record_index: u64,
339 to_record_index: u64,
340 merkle_root: &'a str,
341 leaf_count: u64,
342 journal_id: &'a str,
343 created_at: &'a str,
344 hub_id: &'a str,
345 hub_public_key: &'a str,
346 signed_at: &'a str,
347 covered_use_ids: &'a [String],
348 covered_grant_ids: &'a [String],
349 previous_record_digest: &'a str,
350 }
351 let v = Signing {
352 type_: &self.type_,
353 checkpoint_id: &self.checkpoint_id,
354 checkpoint_kind: self.checkpoint_kind,
355 from_record_index: self.from_record_index,
356 to_record_index: self.to_record_index,
357 merkle_root: &self.merkle_root,
358 leaf_count: self.leaf_count,
359 journal_id: &self.journal_id,
360 created_at: &self.created_at,
361 hub_id: &self.hub_id,
362 hub_public_key: &self.hub_public_key,
363 signed_at: &self.signed_at,
364 covered_use_ids: &self.covered_use_ids,
365 covered_grant_ids: &self.covered_grant_ids,
366 previous_record_digest: &self.previous_record_digest,
367 };
368 serde_json::to_vec(&v)
379 .expect("approval_use canonical_hub_signing_bytes encode must not fail; report bug")
380 }
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(rename_all = "kebab-case")]
396pub enum ReplayCheckLevel {
397 NotPerformed,
399 PackageLocal,
402 LocalJournal,
404 HubOrg,
406}
407
408impl ReplayCheckLevel {
409 pub fn label(self) -> &'static str {
410 match self {
411 Self::NotPerformed => "not performed",
412 Self::PackageLocal => "package-local",
413 Self::LocalJournal => "local-journal",
414 Self::HubOrg => "hub-org",
415 }
416 }
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct ReplayCheck {
424 pub level: ReplayCheckLevel,
425
426 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub use_number: Option<u32>,
430
431 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub max_uses: Option<u32>,
434
435 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub passed: Option<bool>,
440
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub details: Option<String>,
444}
445
446impl ReplayCheck {
447 pub fn not_performed() -> Self {
448 Self {
449 level: ReplayCheckLevel::NotPerformed,
450 use_number: None,
451 max_uses: None,
452 passed: None,
453 details: None,
454 }
455 }
456
457 pub fn package_local(passed: bool, details: impl Into<String>) -> Self {
458 Self {
459 level: ReplayCheckLevel::PackageLocal,
460 use_number: None,
461 max_uses: None,
462 passed: Some(passed),
463 details: Some(details.into()),
464 }
465 }
466}
467
468pub fn approval_use_record_digest(rec: &ApprovalUse) -> String {
481 use sha2::{Digest, Sha256};
482 let mut canon = rec.clone();
483 canon.record_digest = String::new();
484 let bytes = serde_json::to_vec(&canon)
492 .expect("approval_use_record_digest encode must not fail; report bug");
493 let mut hasher = Sha256::new();
494 hasher.update(&bytes);
495 let digest = hasher.finalize();
496 let mut hex = String::with_capacity(64 + 7);
497 hex.push_str("sha256:");
498 for b in digest.as_slice() {
499 use std::fmt::Write;
500 let _ = write!(hex, "{b:02x}");
501 }
502 hex
503}
504
505pub fn approval_revocation_record_digest(rec: &ApprovalRevocation) -> String {
506 use sha2::{Digest, Sha256};
507 let mut canon = rec.clone();
508 canon.record_digest = String::new();
509 let bytes = serde_json::to_vec(&canon)
513 .expect("approval_revocation_record_digest encode must not fail; report bug");
514 let mut hasher = Sha256::new();
515 hasher.update(&bytes);
516 let digest = hasher.finalize();
517 let mut hex = String::with_capacity(64 + 7);
518 hex.push_str("sha256:");
519 for b in digest.as_slice() {
520 use std::fmt::Write;
521 let _ = write!(hex, "{b:02x}");
522 }
523 hex
524}
525
526pub fn journal_checkpoint_record_digest(rec: &JournalCheckpoint) -> String {
527 use sha2::{Digest, Sha256};
528 let mut canon = rec.clone();
529 canon.record_digest = String::new();
530 let bytes = serde_json::to_vec(&canon)
536 .expect("journal_checkpoint_record_digest encode must not fail; report bug");
537 let mut hasher = Sha256::new();
538 hasher.update(&bytes);
539 let digest = hasher.finalize();
540 let mut hex = String::with_capacity(64 + 7);
541 hex.push_str("sha256:");
542 for b in digest.as_slice() {
543 use std::fmt::Write;
544 let _ = write!(hex, "{b:02x}");
545 }
546 hex
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
551pub enum HubCheckpointVerification {
552 Valid,
555 MissingFields(&'static str),
560 Tampered,
563 NotHubKind,
567 UntrustedIssuer,
573}
574
575pub fn verify_hub_checkpoint_signature(
587 cp: &JournalCheckpoint,
588 trust: &crate::trust::TrustRootStore,
589) -> HubCheckpointVerification {
590 use crate::trust::TrustRootKind;
591 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
592 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
593
594 if cp.checkpoint_kind != CheckpointKind::HubOrg {
595 return HubCheckpointVerification::NotHubKind;
596 }
597 if cp.hub_id.is_empty() {
598 return HubCheckpointVerification::MissingFields("hub_id");
599 }
600 if cp.hub_public_key.is_empty() {
601 return HubCheckpointVerification::MissingFields("hub_public_key");
602 }
603 if cp.hub_signature.is_empty() {
604 return HubCheckpointVerification::MissingFields("hub_signature");
605 }
606 if cp.signed_at.is_empty() {
607 return HubCheckpointVerification::MissingFields("signed_at");
608 }
609
610 let pk_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_public_key.as_bytes()) {
611 Ok(b) if b.len() == 32 => b,
612 _ => return HubCheckpointVerification::Tampered,
613 };
614 let sig_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_signature.as_bytes()) {
615 Ok(b) if b.len() == 64 => b,
616 _ => return HubCheckpointVerification::Tampered,
617 };
618 let mut pk_arr = [0u8; 32];
619 pk_arr.copy_from_slice(&pk_bytes);
620 let mut sig_arr = [0u8; 64];
621 sig_arr.copy_from_slice(&sig_bytes);
622
623 let vk = match VerifyingKey::from_bytes(&pk_arr) {
624 Ok(k) => k,
625 Err(_) => return HubCheckpointVerification::Tampered,
626 };
627
628 if !trust.contains(&vk, TrustRootKind::HubOrg) {
637 return HubCheckpointVerification::UntrustedIssuer;
638 }
639
640 let sig = Signature::from_bytes(&sig_arr);
641 let payload = cp.canonical_hub_signing_bytes();
642 match vk.verify_strict(&payload, &sig) {
643 Ok(()) => HubCheckpointVerification::Valid,
644 Err(_) => HubCheckpointVerification::Tampered,
645 }
646}
647
648pub fn nonce_digest(raw_nonce: &str) -> String {
651 use sha2::{Digest, Sha256};
652 let mut hasher = Sha256::new();
653 hasher.update(raw_nonce.as_bytes());
654 let digest = hasher.finalize();
655 let mut hex = String::with_capacity(64 + 7);
656 hex.push_str("sha256:");
657 for b in digest.as_slice() {
658 use std::fmt::Write;
659 let _ = write!(hex, "{b:02x}");
660 }
661 hex
662}
663
664#[cfg(test)]
669mod tests {
670 use super::*;
671
672 fn sample_use() -> ApprovalUse {
673 ApprovalUse {
674 type_: TYPE_APPROVAL_USE.into(),
675 use_id: "use_abc".into(),
676 grant_id: "art_grant_1".into(),
677 grant_digest: "sha256:00".into(),
678 nonce_digest: "sha256:11".into(),
679 actor: "agent://deployer".into(),
680 action: "deploy.production".into(),
681 subject: "env://production".into(),
682 session_id: Some("ssn_xyz".into()),
683 action_artifact_id: None,
684 receipt_digest: None,
685 use_number: 1,
686 max_uses: Some(1),
687 idempotency_key: None,
688 created_at: "2026-04-30T06:00:00Z".into(),
689 expires_at: None,
690 previous_record_digest: String::new(),
691 record_digest: String::new(),
692 signature: None,
693 signature_alg: None,
694 signing_key_id: None,
695 }
696 }
697
698 #[test]
699 fn approval_use_serialization_round_trips() {
700 let u = sample_use();
701 let bytes = serde_json::to_vec(&u).unwrap();
702 let back: ApprovalUse = serde_json::from_slice(&bytes).unwrap();
703 assert_eq!(back.use_id, u.use_id);
704 assert_eq!(back.grant_id, u.grant_id);
705 assert_eq!(back.use_number, 1);
706 }
707
708 #[test]
709 fn record_digest_is_stable_and_excludes_itself() {
710 let u1 = sample_use();
714 let mut u2 = u1.clone();
715 u2.record_digest = "sha256:cafe".into();
716 assert_eq!(
717 approval_use_record_digest(&u1),
718 approval_use_record_digest(&u2)
719 );
720 }
721
722 #[test]
723 fn previous_record_digest_chains() {
724 let mut a = sample_use();
728 a.use_number = 1;
729 a.record_digest = approval_use_record_digest(&a);
730
731 let mut b = sample_use();
732 b.use_number = 2;
733 b.use_id = "use_def".into();
734 b.previous_record_digest = a.record_digest.clone();
735 b.record_digest = approval_use_record_digest(&b);
736
737 assert_eq!(b.previous_record_digest, a.record_digest);
738 let mut c = sample_use();
740 c.use_id = "use_ghi".into();
741 c.use_number = 2;
742 c.previous_record_digest = "sha256:wrong".into();
743 c.record_digest = approval_use_record_digest(&c);
744 assert_ne!(b.record_digest, c.record_digest);
745 }
746
747 #[test]
748 fn nonce_digest_does_not_leak_raw_nonce() {
749 let raw = "n_abcdef0123";
753 let d = nonce_digest(raw);
754 assert!(d.starts_with("sha256:"));
755 assert!(!d.contains(raw), "digest must not contain the raw nonce");
756 }
757
758 #[test]
759 fn replay_check_level_labels() {
760 assert_eq!(ReplayCheckLevel::NotPerformed.label(), "not performed");
761 assert_eq!(ReplayCheckLevel::PackageLocal.label(), "package-local");
762 assert_eq!(ReplayCheckLevel::LocalJournal.label(), "local-journal");
763 assert_eq!(ReplayCheckLevel::HubOrg.label(), "hub-org");
764 }
765
766 #[test]
767 fn replay_check_serialization_uses_kebab_case() {
768 let r = ReplayCheck {
769 level: ReplayCheckLevel::LocalJournal,
770 use_number: Some(1),
771 max_uses: Some(1),
772 passed: Some(true),
773 details: Some("local Approval Use Journal passed".into()),
774 };
775 let v = serde_json::to_value(&r).unwrap();
776 assert_eq!(v["level"], "local-journal");
777 assert_eq!(v["use_number"], 1);
778 assert_eq!(v["max_uses"], 1);
779 assert_eq!(v["passed"], true);
780 }
781
782 #[test]
783 fn revocation_record_digest_stable() {
784 let rev = ApprovalRevocation {
785 type_: TYPE_APPROVAL_REVOCATION.into(),
786 revocation_id: "rev_1".into(),
787 grant_id: "art_grant_1".into(),
788 grant_digest: "sha256:00".into(),
789 revoker: "human://alice".into(),
790 reason: Some("rotated key".into()),
791 created_at: "2026-04-30T06:01:00Z".into(),
792 previous_record_digest: "sha256:00".into(),
793 record_digest: String::new(),
794 signature: None,
795 signature_alg: None,
796 signing_key_id: None,
797 };
798 let d1 = approval_revocation_record_digest(&rev);
799 let d2 = approval_revocation_record_digest(&rev);
800 assert_eq!(d1, d2);
801 }
802
803 fn sample_checkpoint(kind: CheckpointKind) -> JournalCheckpoint {
804 JournalCheckpoint {
805 type_: TYPE_JOURNAL_CHECKPOINT.into(),
806 checkpoint_id: "cp_1".into(),
807 checkpoint_kind: kind,
808 from_record_index: 1,
809 to_record_index: 10,
810 merkle_root: "sha256:abcd".into(),
811 leaf_count: 10,
812 journal_id: "journal_1".into(),
813 created_at: "2026-04-30T06:02:00Z".into(),
814 hub_id: String::new(),
815 hub_public_key: String::new(),
816 hub_signature: String::new(),
817 signed_at: String::new(),
818 covered_use_ids: Vec::new(),
819 covered_grant_ids: Vec::new(),
820 previous_record_digest: "sha256:00".into(),
821 record_digest: String::new(),
822 signature: None,
823 signature_alg: None,
824 signing_key_id: None,
825 }
826 }
827
828 #[test]
829 fn checkpoint_record_digest_stable() {
830 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
831 let d1 = journal_checkpoint_record_digest(&cp);
832 let d2 = journal_checkpoint_record_digest(&cp);
833 assert_eq!(d1, d2);
834 }
835
836 #[test]
849 fn record_digests_never_match_empty_bytes_sha256() {
850 use sha2::{Digest, Sha256};
851
852 let mut hasher = Sha256::new();
858 let empty: &[u8] = &[];
859 hasher.update(empty);
860 let empty_digest = hasher.finalize();
861 let mut empty_hex = String::with_capacity(64 + 7);
862 empty_hex.push_str("sha256:");
863 for b in empty_digest.as_slice() {
864 use std::fmt::Write;
865 let _ = write!(empty_hex, "{b:02x}");
866 }
867
868 let u = sample_use();
869 let use_digest = approval_use_record_digest(&u);
870 assert_ne!(
871 use_digest, empty_hex,
872 "approval_use_record_digest must never match sha256-of-empty (audit lane C)",
873 );
874
875 let rev = ApprovalRevocation {
876 type_: TYPE_APPROVAL_REVOCATION.into(),
877 revocation_id: "rev_x".into(),
878 grant_id: "art_grant_x".into(),
879 grant_digest: "sha256:00".into(),
880 revoker: "human://x".into(),
881 reason: None,
882 created_at: "2026-04-30T06:01:00Z".into(),
883 previous_record_digest: "sha256:00".into(),
884 record_digest: String::new(),
885 signature: None,
886 signature_alg: None,
887 signing_key_id: None,
888 };
889 let rev_digest = approval_revocation_record_digest(&rev);
890 assert_ne!(rev_digest, empty_hex);
891
892 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
893 let cp_digest = journal_checkpoint_record_digest(&cp);
894 assert_ne!(cp_digest, empty_hex);
895
896 let cp_hub = sample_checkpoint(CheckpointKind::HubOrg);
898 let signing_bytes = cp_hub.canonical_hub_signing_bytes();
899 assert!(
900 !signing_bytes.is_empty(),
901 "canonical_hub_signing_bytes must never be empty (would let two checkpoints cross-validate)",
902 );
903
904 let mut u2 = sample_use();
907 u2.use_id = "use_zzz".into();
908 u2.use_number = 99;
909 let use_digest_2 = approval_use_record_digest(&u2);
910 assert_ne!(use_digest, use_digest_2);
911 }
912
913 #[test]
914 fn checkpoint_kind_defaults_to_local_journal() {
915 let json = r#"{"type":"treeship/journal-checkpoint/v1","checkpoint_id":"cp_legacy",
919 "from_record_index":1,"to_record_index":10,"merkle_root":"sha256:00",
920 "leaf_count":10,"journal_id":"j","created_at":"2026-04-30T00:00:00Z"}"#;
921 let cp: JournalCheckpoint = serde_json::from_str(json).unwrap();
922 assert_eq!(cp.checkpoint_kind, CheckpointKind::LocalJournal);
923 assert!(!cp.is_hub_signed());
924 }
925
926 #[test]
927 fn checkpoint_kind_serializes_kebab_case() {
928 let cp = sample_checkpoint(CheckpointKind::HubOrg);
929 let v = serde_json::to_value(&cp).unwrap();
930 assert_eq!(v["checkpoint_kind"], "hub-org");
931 }
932
933 fn trust_with(pk: &ed25519_dalek::VerifyingKey) -> crate::trust::TrustRootStore {
937 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
938 TrustRootStore::with_roots(vec![TrustRoot {
939 key_id: "test_hub".into(),
940 public_key: encode_ed25519_pubkey(pk),
941 kind: TrustRootKind::HubOrg,
942 label: "test pin".into(),
943 added_at: "2026-05-15T00:00:00Z".into(),
944 }])
945 }
946
947 #[test]
948 fn local_journal_checkpoint_is_not_hub_signed() {
949 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
950 assert!(!cp.is_hub_signed());
951 assert_eq!(
952 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
953 HubCheckpointVerification::NotHubKind,
954 );
955 }
956
957 #[test]
958 fn hub_kind_without_fields_is_missing() {
959 let cp = sample_checkpoint(CheckpointKind::HubOrg);
960 assert!(!cp.is_hub_signed());
961 assert!(matches!(
962 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
963 HubCheckpointVerification::MissingFields(_),
964 ));
965 }
966
967 #[test]
972 fn hub_checkpoint_signature_round_trip() {
973 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
974 use ed25519_dalek::{Signer, SigningKey};
975
976 let mut sk_bytes = [0u8; 32];
977 for (i, b) in sk_bytes.iter_mut().enumerate() {
978 *b = i as u8 + 7;
979 }
980 let sk = SigningKey::from_bytes(&sk_bytes);
981 let pk = sk.verifying_key();
982 let pk_b64 = URL_SAFE_NO_PAD.encode(pk.to_bytes());
983
984 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
985 cp.hub_id = "hub://zerker-org".into();
986 cp.hub_public_key = pk_b64.clone();
987 cp.signed_at = "2026-04-30T07:00:00Z".into();
988 cp.covered_use_ids = vec!["use_alpha".into(), "use_beta".into()];
989
990 let payload = cp.canonical_hub_signing_bytes();
991 let sig = sk.sign(&payload);
992 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
993
994 assert!(cp.is_hub_signed());
995 let trust = trust_with(&pk);
996 assert_eq!(
997 verify_hub_checkpoint_signature(&cp, &trust),
998 HubCheckpointVerification::Valid,
999 );
1000 }
1001
1002 #[test]
1006 fn deprecated_ship_pin_no_longer_authorizes_hub_checkpoint() {
1007 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
1008 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1009 use ed25519_dalek::{Signer, SigningKey};
1010 let sk = SigningKey::from_bytes(&[9u8; 32]);
1011 let pk = sk.verifying_key();
1012 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1013 cp.hub_id = "hub://zerker-org".into();
1014 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1015 cp.signed_at = "2026-04-30T07:00:00Z".into();
1016 cp.covered_use_ids = vec!["use_alpha".into()];
1017 cp.hub_signature =
1018 URL_SAFE_NO_PAD.encode(sk.sign(&cp.canonical_hub_signing_bytes()).to_bytes());
1019
1020 let pin = |kind| {
1021 TrustRootStore::with_roots(vec![TrustRoot {
1022 key_id: "h".into(),
1023 public_key: encode_ed25519_pubkey(&pk),
1024 kind,
1025 label: String::new(),
1026 added_at: "2026-05-15T00:00:00Z".into(),
1027 }])
1028 };
1029
1030 assert_eq!(
1032 verify_hub_checkpoint_signature(&cp, &pin(TrustRootKind::Ship)),
1033 HubCheckpointVerification::UntrustedIssuer,
1034 "a `ship` pin must no longer authorize hub-org checkpoints",
1035 );
1036 assert_eq!(
1038 verify_hub_checkpoint_signature(&cp, &pin(TrustRootKind::HubOrg)),
1039 HubCheckpointVerification::Valid,
1040 );
1041 assert_eq!(
1043 verify_hub_checkpoint_signature(&cp, &pin(TrustRootKind::CertIssuer)),
1044 HubCheckpointVerification::UntrustedIssuer,
1045 );
1046 }
1047
1048 #[test]
1049 fn tampered_hub_checkpoint_fails_verification() {
1050 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1051 use ed25519_dalek::{Signer, SigningKey};
1052
1053 let sk = SigningKey::from_bytes(&[1u8; 32]);
1054 let pk = sk.verifying_key();
1055
1056 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1057 cp.hub_id = "hub://x".into();
1058 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1059 cp.signed_at = "2026-04-30T07:00:00Z".into();
1060 cp.covered_use_ids = vec!["use_alpha".into()];
1061
1062 let sig = sk.sign(&cp.canonical_hub_signing_bytes());
1063 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1064 let trust = trust_with(&pk);
1065 assert_eq!(
1067 verify_hub_checkpoint_signature(&cp, &trust),
1068 HubCheckpointVerification::Valid
1069 );
1070
1071 cp.covered_use_ids.push("use_smuggled".into());
1074 assert_eq!(
1075 verify_hub_checkpoint_signature(&cp, &trust),
1076 HubCheckpointVerification::Tampered,
1077 );
1078 }
1079
1080 #[test]
1081 fn wrong_key_fails_verification() {
1082 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1083 use ed25519_dalek::{Signer, SigningKey};
1084
1085 let sk_real = SigningKey::from_bytes(&[2u8; 32]);
1086 let sk_imp = SigningKey::from_bytes(&[3u8; 32]); let pk_imp = sk_imp.verifying_key();
1088
1089 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1090 cp.hub_id = "hub://x".into();
1091 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk_imp.to_bytes());
1093 cp.signed_at = "2026-04-30T07:00:00Z".into();
1094 let sig = sk_real.sign(&cp.canonical_hub_signing_bytes());
1095 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1096 let trust = trust_with(&pk_imp);
1100 assert_eq!(
1101 verify_hub_checkpoint_signature(&cp, &trust),
1102 HubCheckpointVerification::Tampered,
1103 );
1104 }
1105
1106 #[test]
1107 fn malformed_pubkey_or_signature_fails() {
1108 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1109 cp.hub_id = "hub://x".into();
1110 cp.hub_public_key = "not-base64!!".into();
1111 cp.hub_signature = "also-not-base64".into();
1112 cp.signed_at = "2026-04-30T07:00:00Z".into();
1113 assert_eq!(
1114 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
1115 HubCheckpointVerification::Tampered,
1116 );
1117 }
1118
1119 #[test]
1125 fn hub_checkpoint_rejects_untrusted_issuer() {
1126 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1127 use ed25519_dalek::{Signer, SigningKey};
1128
1129 let attacker = SigningKey::from_bytes(&[42u8; 32]);
1130 let pk = attacker.verifying_key();
1131
1132 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1133 cp.hub_id = "hub://attacker-claims-zerker".into();
1134 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1135 cp.signed_at = "2026-04-30T07:00:00Z".into();
1136 cp.covered_use_ids = vec!["use_alpha".into()];
1137 let sig = attacker.sign(&cp.canonical_hub_signing_bytes());
1138 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1139
1140 let honest = SigningKey::from_bytes(&[7u8; 32]);
1142 let trust = trust_with(&honest.verifying_key());
1143
1144 assert_eq!(
1145 verify_hub_checkpoint_signature(&cp, &trust),
1146 HubCheckpointVerification::UntrustedIssuer,
1147 );
1148 }
1149
1150 #[test]
1153 fn hub_checkpoint_rejects_with_no_trust_configured() {
1154 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1155 use ed25519_dalek::{Signer, SigningKey};
1156
1157 let sk = SigningKey::from_bytes(&[9u8; 32]);
1158 let pk = sk.verifying_key();
1159 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1160 cp.hub_id = "hub://anything".into();
1161 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1162 cp.signed_at = "2026-04-30T07:00:00Z".into();
1163 let sig = sk.sign(&cp.canonical_hub_signing_bytes());
1164 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1165
1166 assert_eq!(
1167 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
1168 HubCheckpointVerification::UntrustedIssuer,
1169 );
1170 }
1171}