use serde::{Deserialize, Serialize};
pub const TYPE_APPROVAL_USE: &str = "treeship/approval-use/v1";
pub const TYPE_APPROVAL_REVOCATION: &str = "treeship/approval-revocation/v1";
pub const TYPE_JOURNAL_CHECKPOINT: &str = "treeship/journal-checkpoint/v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalUse {
#[serde(rename = "type")]
pub type_: String,
pub use_id: String,
pub grant_id: String,
pub grant_digest: String,
pub nonce_digest: String,
pub actor: String,
pub action: String,
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_artifact_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub receipt_digest: Option<String>,
pub use_number: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_uses: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default)]
pub previous_record_digest: String,
#[serde(default)]
pub record_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_alg: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_key_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRevocation {
#[serde(rename = "type")]
pub type_: String,
pub revocation_id: String,
pub grant_id: String,
pub grant_digest: String,
pub revoker: String,
pub reason: Option<String>,
pub created_at: String,
#[serde(default)]
pub previous_record_digest: String,
#[serde(default)]
pub record_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_alg: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_key_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CheckpointKind {
#[default]
LocalJournal,
HubOrg,
}
impl CheckpointKind {
pub fn label(self) -> &'static str {
match self {
Self::LocalJournal => "local-journal",
Self::HubOrg => "hub-org",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalCheckpoint {
#[serde(rename = "type")]
pub type_: String,
pub checkpoint_id: String,
#[serde(default)]
pub checkpoint_kind: CheckpointKind,
pub from_record_index: u64,
pub to_record_index: u64,
pub merkle_root: String,
pub leaf_count: u64,
pub journal_id: String,
pub created_at: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub hub_id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub hub_public_key: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub hub_signature: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub signed_at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub covered_use_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub covered_grant_ids: Vec<String>,
#[serde(default)]
pub previous_record_digest: String,
#[serde(default)]
pub record_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_alg: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_key_id: Option<String>,
}
impl JournalCheckpoint {
pub fn is_hub_signed(&self) -> bool {
self.checkpoint_kind == CheckpointKind::HubOrg
&& !self.hub_id.is_empty()
&& !self.hub_public_key.is_empty()
&& !self.hub_signature.is_empty()
&& !self.signed_at.is_empty()
}
pub fn canonical_hub_signing_bytes(&self) -> Vec<u8> {
#[derive(Serialize)]
struct Signing<'a> {
#[serde(rename = "type")] type_: &'a str,
checkpoint_id: &'a str,
checkpoint_kind: CheckpointKind,
from_record_index: u64,
to_record_index: u64,
merkle_root: &'a str,
leaf_count: u64,
journal_id: &'a str,
created_at: &'a str,
hub_id: &'a str,
hub_public_key: &'a str,
signed_at: &'a str,
covered_use_ids: &'a [String],
covered_grant_ids: &'a [String],
previous_record_digest: &'a str,
}
let v = Signing {
type_: &self.type_,
checkpoint_id: &self.checkpoint_id,
checkpoint_kind: self.checkpoint_kind,
from_record_index: self.from_record_index,
to_record_index: self.to_record_index,
merkle_root: &self.merkle_root,
leaf_count: self.leaf_count,
journal_id: &self.journal_id,
created_at: &self.created_at,
hub_id: &self.hub_id,
hub_public_key: &self.hub_public_key,
signed_at: &self.signed_at,
covered_use_ids: &self.covered_use_ids,
covered_grant_ids: &self.covered_grant_ids,
previous_record_digest: &self.previous_record_digest,
};
serde_json::to_vec(&v)
.expect("approval_use canonical_hub_signing_bytes encode must not fail; report bug")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ReplayCheckLevel {
NotPerformed,
PackageLocal,
LocalJournal,
HubOrg,
}
impl ReplayCheckLevel {
pub fn label(self) -> &'static str {
match self {
Self::NotPerformed => "not performed",
Self::PackageLocal => "package-local",
Self::LocalJournal => "local-journal",
Self::HubOrg => "hub-org",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayCheck {
pub level: ReplayCheckLevel,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_number: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_uses: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub passed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl ReplayCheck {
pub fn not_performed() -> Self {
Self { level: ReplayCheckLevel::NotPerformed, use_number: None, max_uses: None, passed: None, details: None }
}
pub fn package_local(passed: bool, details: impl Into<String>) -> Self {
Self {
level: ReplayCheckLevel::PackageLocal,
use_number: None,
max_uses: None,
passed: Some(passed),
details: Some(details.into()),
}
}
}
pub fn approval_use_record_digest(rec: &ApprovalUse) -> String {
use sha2::{Digest, Sha256};
let mut canon = rec.clone();
canon.record_digest = String::new();
let bytes = serde_json::to_vec(&canon)
.expect("approval_use_record_digest encode must not fail; report bug");
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
let mut hex = String::with_capacity(64 + 7);
hex.push_str("sha256:");
for b in digest.as_slice() {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
pub fn approval_revocation_record_digest(rec: &ApprovalRevocation) -> String {
use sha2::{Digest, Sha256};
let mut canon = rec.clone();
canon.record_digest = String::new();
let bytes = serde_json::to_vec(&canon)
.expect("approval_revocation_record_digest encode must not fail; report bug");
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
let mut hex = String::with_capacity(64 + 7);
hex.push_str("sha256:");
for b in digest.as_slice() {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
pub fn journal_checkpoint_record_digest(rec: &JournalCheckpoint) -> String {
use sha2::{Digest, Sha256};
let mut canon = rec.clone();
canon.record_digest = String::new();
let bytes = serde_json::to_vec(&canon)
.expect("journal_checkpoint_record_digest encode must not fail; report bug");
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = hasher.finalize();
let mut hex = String::with_capacity(64 + 7);
hex.push_str("sha256:");
for b in digest.as_slice() {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HubCheckpointVerification {
Valid,
MissingFields(&'static str),
Tampered,
NotHubKind,
UntrustedIssuer,
}
pub fn verify_hub_checkpoint_signature(
cp: &JournalCheckpoint,
trust: &crate::trust::TrustRootStore,
) -> HubCheckpointVerification {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use crate::trust::TrustRootKind;
if cp.checkpoint_kind != CheckpointKind::HubOrg {
return HubCheckpointVerification::NotHubKind;
}
if cp.hub_id.is_empty() { return HubCheckpointVerification::MissingFields("hub_id"); }
if cp.hub_public_key.is_empty() { return HubCheckpointVerification::MissingFields("hub_public_key"); }
if cp.hub_signature.is_empty() { return HubCheckpointVerification::MissingFields("hub_signature"); }
if cp.signed_at.is_empty() { return HubCheckpointVerification::MissingFields("signed_at"); }
let pk_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_public_key.as_bytes()) {
Ok(b) if b.len() == 32 => b,
_ => return HubCheckpointVerification::Tampered,
};
let sig_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_signature.as_bytes()) {
Ok(b) if b.len() == 64 => b,
_ => return HubCheckpointVerification::Tampered,
};
let mut pk_arr = [0u8; 32];
pk_arr.copy_from_slice(&pk_bytes);
let mut sig_arr = [0u8; 64];
sig_arr.copy_from_slice(&sig_bytes);
let vk = match VerifyingKey::from_bytes(&pk_arr) {
Ok(k) => k,
Err(_) => return HubCheckpointVerification::Tampered,
};
if !trust.contains(&vk, TrustRootKind::Ship) {
return HubCheckpointVerification::UntrustedIssuer;
}
let sig = Signature::from_bytes(&sig_arr);
let payload = cp.canonical_hub_signing_bytes();
match vk.verify(&payload, &sig) {
Ok(()) => HubCheckpointVerification::Valid,
Err(_) => HubCheckpointVerification::Tampered,
}
}
pub fn nonce_digest(raw_nonce: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(raw_nonce.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(64 + 7);
hex.push_str("sha256:");
for b in digest.as_slice() {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
hex
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_use() -> ApprovalUse {
ApprovalUse {
type_: TYPE_APPROVAL_USE.into(),
use_id: "use_abc".into(),
grant_id: "art_grant_1".into(),
grant_digest: "sha256:00".into(),
nonce_digest: "sha256:11".into(),
actor: "agent://deployer".into(),
action: "deploy.production".into(),
subject: "env://production".into(),
session_id: Some("ssn_xyz".into()),
action_artifact_id: None,
receipt_digest: None,
use_number: 1,
max_uses: Some(1),
idempotency_key: None,
created_at: "2026-04-30T06:00:00Z".into(),
expires_at: None,
previous_record_digest: String::new(),
record_digest: String::new(),
signature: None,
signature_alg: None,
signing_key_id: None,
}
}
#[test]
fn approval_use_serialization_round_trips() {
let u = sample_use();
let bytes = serde_json::to_vec(&u).unwrap();
let back: ApprovalUse = serde_json::from_slice(&bytes).unwrap();
assert_eq!(back.use_id, u.use_id);
assert_eq!(back.grant_id, u.grant_id);
assert_eq!(back.use_number, 1);
}
#[test]
fn record_digest_is_stable_and_excludes_itself() {
let u1 = sample_use();
let mut u2 = u1.clone();
u2.record_digest = "sha256:cafe".into();
assert_eq!(approval_use_record_digest(&u1), approval_use_record_digest(&u2));
}
#[test]
fn previous_record_digest_chains() {
let mut a = sample_use();
a.use_number = 1;
a.record_digest = approval_use_record_digest(&a);
let mut b = sample_use();
b.use_number = 2;
b.use_id = "use_def".into();
b.previous_record_digest = a.record_digest.clone();
b.record_digest = approval_use_record_digest(&b);
assert_eq!(b.previous_record_digest, a.record_digest);
let mut c = sample_use();
c.use_id = "use_ghi".into();
c.use_number = 2;
c.previous_record_digest = "sha256:wrong".into();
c.record_digest = approval_use_record_digest(&c);
assert_ne!(b.record_digest, c.record_digest);
}
#[test]
fn nonce_digest_does_not_leak_raw_nonce() {
let raw = "n_abcdef0123";
let d = nonce_digest(raw);
assert!(d.starts_with("sha256:"));
assert!(!d.contains(raw), "digest must not contain the raw nonce");
}
#[test]
fn replay_check_level_labels() {
assert_eq!(ReplayCheckLevel::NotPerformed.label(), "not performed");
assert_eq!(ReplayCheckLevel::PackageLocal.label(), "package-local");
assert_eq!(ReplayCheckLevel::LocalJournal.label(), "local-journal");
assert_eq!(ReplayCheckLevel::HubOrg.label(), "hub-org");
}
#[test]
fn replay_check_serialization_uses_kebab_case() {
let r = ReplayCheck {
level: ReplayCheckLevel::LocalJournal,
use_number: Some(1),
max_uses: Some(1),
passed: Some(true),
details: Some("local Approval Use Journal passed".into()),
};
let v = serde_json::to_value(&r).unwrap();
assert_eq!(v["level"], "local-journal");
assert_eq!(v["use_number"], 1);
assert_eq!(v["max_uses"], 1);
assert_eq!(v["passed"], true);
}
#[test]
fn revocation_record_digest_stable() {
let rev = ApprovalRevocation {
type_: TYPE_APPROVAL_REVOCATION.into(),
revocation_id: "rev_1".into(),
grant_id: "art_grant_1".into(),
grant_digest: "sha256:00".into(),
revoker: "human://alice".into(),
reason: Some("rotated key".into()),
created_at: "2026-04-30T06:01:00Z".into(),
previous_record_digest: "sha256:00".into(),
record_digest: String::new(),
signature: None,
signature_alg: None,
signing_key_id: None,
};
let d1 = approval_revocation_record_digest(&rev);
let d2 = approval_revocation_record_digest(&rev);
assert_eq!(d1, d2);
}
fn sample_checkpoint(kind: CheckpointKind) -> JournalCheckpoint {
JournalCheckpoint {
type_: TYPE_JOURNAL_CHECKPOINT.into(),
checkpoint_id: "cp_1".into(),
checkpoint_kind: kind,
from_record_index: 1,
to_record_index: 10,
merkle_root: "sha256:abcd".into(),
leaf_count: 10,
journal_id: "journal_1".into(),
created_at: "2026-04-30T06:02:00Z".into(),
hub_id: String::new(),
hub_public_key: String::new(),
hub_signature: String::new(),
signed_at: String::new(),
covered_use_ids: Vec::new(),
covered_grant_ids: Vec::new(),
previous_record_digest: "sha256:00".into(),
record_digest: String::new(),
signature: None,
signature_alg: None,
signing_key_id: None,
}
}
#[test]
fn checkpoint_record_digest_stable() {
let cp = sample_checkpoint(CheckpointKind::LocalJournal);
let d1 = journal_checkpoint_record_digest(&cp);
let d2 = journal_checkpoint_record_digest(&cp);
assert_eq!(d1, d2);
}
#[test]
fn record_digests_never_match_empty_bytes_sha256() {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let empty: &[u8] = &[];
hasher.update(empty);
let empty_digest = hasher.finalize();
let mut empty_hex = String::with_capacity(64 + 7);
empty_hex.push_str("sha256:");
for b in empty_digest.as_slice() {
use std::fmt::Write;
let _ = write!(empty_hex, "{b:02x}");
}
let u = sample_use();
let use_digest = approval_use_record_digest(&u);
assert_ne!(
use_digest, empty_hex,
"approval_use_record_digest must never match sha256-of-empty (audit lane C)",
);
let rev = ApprovalRevocation {
type_: TYPE_APPROVAL_REVOCATION.into(),
revocation_id: "rev_x".into(),
grant_id: "art_grant_x".into(),
grant_digest: "sha256:00".into(),
revoker: "human://x".into(),
reason: None,
created_at: "2026-04-30T06:01:00Z".into(),
previous_record_digest: "sha256:00".into(),
record_digest: String::new(),
signature: None,
signature_alg: None,
signing_key_id: None,
};
let rev_digest = approval_revocation_record_digest(&rev);
assert_ne!(rev_digest, empty_hex);
let cp = sample_checkpoint(CheckpointKind::LocalJournal);
let cp_digest = journal_checkpoint_record_digest(&cp);
assert_ne!(cp_digest, empty_hex);
let cp_hub = sample_checkpoint(CheckpointKind::HubOrg);
let signing_bytes = cp_hub.canonical_hub_signing_bytes();
assert!(
!signing_bytes.is_empty(),
"canonical_hub_signing_bytes must never be empty (would let two checkpoints cross-validate)",
);
let mut u2 = sample_use();
u2.use_id = "use_zzz".into();
u2.use_number = 99;
let use_digest_2 = approval_use_record_digest(&u2);
assert_ne!(use_digest, use_digest_2);
}
#[test]
fn checkpoint_kind_defaults_to_local_journal() {
let json = r#"{"type":"treeship/journal-checkpoint/v1","checkpoint_id":"cp_legacy",
"from_record_index":1,"to_record_index":10,"merkle_root":"sha256:00",
"leaf_count":10,"journal_id":"j","created_at":"2026-04-30T00:00:00Z"}"#;
let cp: JournalCheckpoint = serde_json::from_str(json).unwrap();
assert_eq!(cp.checkpoint_kind, CheckpointKind::LocalJournal);
assert!(!cp.is_hub_signed());
}
#[test]
fn checkpoint_kind_serializes_kebab_case() {
let cp = sample_checkpoint(CheckpointKind::HubOrg);
let v = serde_json::to_value(&cp).unwrap();
assert_eq!(v["checkpoint_kind"], "hub-org");
}
fn trust_with(pk: &ed25519_dalek::VerifyingKey) -> crate::trust::TrustRootStore {
use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
TrustRootStore::with_roots(vec![TrustRoot {
key_id: "test_hub".into(),
public_key: encode_ed25519_pubkey(pk),
kind: TrustRootKind::Ship,
label: "test pin".into(),
added_at: "2026-05-15T00:00:00Z".into(),
}])
}
#[test]
fn local_journal_checkpoint_is_not_hub_signed() {
let cp = sample_checkpoint(CheckpointKind::LocalJournal);
assert!(!cp.is_hub_signed());
assert_eq!(
verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
HubCheckpointVerification::NotHubKind,
);
}
#[test]
fn hub_kind_without_fields_is_missing() {
let cp = sample_checkpoint(CheckpointKind::HubOrg);
assert!(!cp.is_hub_signed());
assert!(matches!(
verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
HubCheckpointVerification::MissingFields(_),
));
}
#[test]
fn hub_checkpoint_signature_round_trip() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signer, SigningKey};
let mut sk_bytes = [0u8; 32];
for (i, b) in sk_bytes.iter_mut().enumerate() {
*b = i as u8 + 7;
}
let sk = SigningKey::from_bytes(&sk_bytes);
let pk = sk.verifying_key();
let pk_b64 = URL_SAFE_NO_PAD.encode(pk.to_bytes());
let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
cp.hub_id = "hub://zerker-org".into();
cp.hub_public_key = pk_b64.clone();
cp.signed_at = "2026-04-30T07:00:00Z".into();
cp.covered_use_ids = vec!["use_alpha".into(), "use_beta".into()];
let payload = cp.canonical_hub_signing_bytes();
let sig = sk.sign(&payload);
cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
assert!(cp.is_hub_signed());
let trust = trust_with(&pk);
assert_eq!(
verify_hub_checkpoint_signature(&cp, &trust),
HubCheckpointVerification::Valid,
);
}
#[test]
fn tampered_hub_checkpoint_fails_verification() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[1u8; 32]);
let pk = sk.verifying_key();
let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
cp.hub_id = "hub://x".into();
cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
cp.signed_at = "2026-04-30T07:00:00Z".into();
cp.covered_use_ids = vec!["use_alpha".into()];
let sig = sk.sign(&cp.canonical_hub_signing_bytes());
cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
let trust = trust_with(&pk);
assert_eq!(verify_hub_checkpoint_signature(&cp, &trust), HubCheckpointVerification::Valid);
cp.covered_use_ids.push("use_smuggled".into());
assert_eq!(
verify_hub_checkpoint_signature(&cp, &trust),
HubCheckpointVerification::Tampered,
);
}
#[test]
fn wrong_key_fails_verification() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signer, SigningKey};
let sk_real = SigningKey::from_bytes(&[2u8; 32]);
let sk_imp = SigningKey::from_bytes(&[3u8; 32]); let pk_imp = sk_imp.verifying_key();
let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
cp.hub_id = "hub://x".into();
cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk_imp.to_bytes());
cp.signed_at = "2026-04-30T07:00:00Z".into();
let sig = sk_real.sign(&cp.canonical_hub_signing_bytes());
cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
let trust = trust_with(&pk_imp);
assert_eq!(
verify_hub_checkpoint_signature(&cp, &trust),
HubCheckpointVerification::Tampered,
);
}
#[test]
fn malformed_pubkey_or_signature_fails() {
let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
cp.hub_id = "hub://x".into();
cp.hub_public_key = "not-base64!!".into();
cp.hub_signature = "also-not-base64".into();
cp.signed_at = "2026-04-30T07:00:00Z".into();
assert_eq!(
verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
HubCheckpointVerification::Tampered,
);
}
#[test]
fn hub_checkpoint_rejects_untrusted_issuer() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signer, SigningKey};
let attacker = SigningKey::from_bytes(&[42u8; 32]);
let pk = attacker.verifying_key();
let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
cp.hub_id = "hub://attacker-claims-zerker".into();
cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
cp.signed_at = "2026-04-30T07:00:00Z".into();
cp.covered_use_ids = vec!["use_alpha".into()];
let sig = attacker.sign(&cp.canonical_hub_signing_bytes());
cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
let honest = SigningKey::from_bytes(&[7u8; 32]);
let trust = trust_with(&honest.verifying_key());
assert_eq!(
verify_hub_checkpoint_signature(&cp, &trust),
HubCheckpointVerification::UntrustedIssuer,
);
}
#[test]
fn hub_checkpoint_rejects_with_no_trust_configured() {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ed25519_dalek::{Signer, SigningKey};
let sk = SigningKey::from_bytes(&[9u8; 32]);
let pk = sk.verifying_key();
let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
cp.hub_id = "hub://anything".into();
cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
cp.signed_at = "2026-04-30T07:00:00Z".into();
let sig = sk.sign(&cp.canonical_hub_signing_bytes());
cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
assert_eq!(
verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
HubCheckpointVerification::UntrustedIssuer,
);
}
}