Skip to main content

treeship_core/statements/
approval_use.rs

1//! Approval Authority schemas — the journal-side companions to ApprovalScope.
2//!
3//! v0.9.6 introduced `ApprovalScope` to say *what* an approval allows
4//! (actor / action / subject / max_uses) and `verify` reports the
5//! authorization posture honestly: binding, scope, and a "package-local
6//! only" replay note. These schemas add the *consumed* side: a per-use
7//! record that, with a local Approval Use Journal (PR 2) and the
8//! consume-before-action flow (PR 3), turns
9//!
10//! ```text
11//! ⚠ replay check     package-local only -- no global ledger consulted
12//! ```
13//!
14//! into
15//!
16//! ```text
17//! ✓ replay check     local Approval Use Journal passed, use 1/1
18//! ```
19//!
20//! v0.9.9 (this file) ships only the schema. PR 2 wires the journal,
21//! PR 3 the consume flow, PR 4 package export, PR 5 report polish, PR 6
22//! the optional Hub-checkpoint scaffold.
23//!
24//! Privacy rule baked into the schema: the journal stores
25//! `nonce_digest`, never the raw nonce. Raw nonces stay in the signed
26//! grant + package where they need to. The journal is private append-only
27//! local memory, not a public ledger -- "no SQLite source of truth, no
28//! public approval-use ledger" is a release rule.
29
30use serde::{Deserialize, Serialize};
31
32// ---------------------------------------------------------------------------
33// Type constants
34// ---------------------------------------------------------------------------
35
36pub 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// ---------------------------------------------------------------------------
41// ApprovalUse
42// ---------------------------------------------------------------------------
43
44/// Records that a specific Approval Grant was consumed by a specific
45/// Action. One record per use; an approval with `max_actions = 3` produces
46/// up to three of these (subject to the journal's max_uses enforcement).
47///
48/// Designed for the local Approval Use Journal (PR 2). Two fields anchor
49/// the journal's hash chain:
50///   - `record_digest`        : sha256 of this record's canonical JSON,
51///                              minus `record_digest` itself.
52///   - `previous_record_digest`: the previous record's `record_digest`,
53///                              giving the journal an append-only hash
54///                              chain. The genesis record has this empty.
55///
56/// `signature` is optional in the schema because the journal can be signed
57/// either per-record or via signed checkpoints over a range of records;
58/// PR 2 picks the strategy. Keeping the field optional keeps the schema
59/// stable across that decision.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ApprovalUse {
62    #[serde(rename = "type")]
63    pub type_: String,
64
65    /// Stable per-record identifier. Independent of the action artifact
66    /// id so the journal can write the use *before* the action signs
67    /// (consume-before-action, PR 3).
68    pub use_id: String,
69
70    /// The grant being consumed (artifact id of the ApprovalStatement).
71    pub grant_id: String,
72
73    /// sha256 of the signed grant envelope. Pinning the digest detects
74    /// drift if the grant is tampered or rotated; verify can reject any
75    /// use that points at a digest different from the live grant.
76    pub grant_digest: String,
77
78    /// sha256 of the approval's `nonce` field. The journal indexes by
79    /// this so duplicate consumption attempts collapse on lookup; raw
80    /// nonces stay in the signed grant and are never written to disk
81    /// outside the package they live in.
82    pub nonce_digest: String,
83
84    pub actor: String,
85    pub action: String,
86    /// Subject URI / artifact id the action targets. Mirrors
87    /// `ApprovalScope.allowed_subjects` so journal records carry the
88    /// resolved value used at consume time.
89    pub subject: String,
90
91    /// Session this use was recorded under. Optional because uses can
92    /// happen outside any active session (e.g. a CLI one-shot).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub session_id: Option<String>,
95
96    /// Action artifact this use authorized. Set when the action is
97    /// signed; left None during the brief "reserved" window between
98    /// journal write and action sign in the consume-before-action flow.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub action_artifact_id: Option<String>,
101
102    /// Receipt this use will appear in. None until the receipt is built.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub receipt_digest: Option<String>,
105
106    /// Which use of this grant this is. 1-indexed. Reads as "use 1/1"
107    /// or "use 2/3" in verify output.
108    pub use_number: u32,
109
110    /// Mirror of the grant's `max_actions` at consume time. Stored on
111    /// the use record so a later journal verifier doesn't need to
112    /// re-resolve the grant.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub max_uses: Option<u32>,
115
116    /// Caller-supplied idempotency key. If present, a retry with the
117    /// same `(grant_id, idempotency_key)` collapses to the existing use
118    /// rather than allocating a new one. Lets a flaky network produce
119    /// at-most-once consumption without burning a use slot per retry.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub idempotency_key: Option<String>,
122
123    pub created_at: String,
124
125    /// Optional expiry on the use itself, distinct from grant expiry.
126    /// The grant's `valid_until` is the outer bound; this is for "this
127    /// reserved use must commit by X or be released" semantics.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub expires_at: Option<String>,
130
131    /// Genesis record carries the empty string. All others carry the
132    /// previous record's `record_digest`. Pinning the chain.
133    #[serde(default)]
134    pub previous_record_digest: String,
135
136    /// sha256 of this record's canonical JSON with `record_digest`
137    /// itself omitted. Computed and stamped at write time.
138    #[serde(default)]
139    pub record_digest: String,
140
141    /// Optional per-record signature. The journal can also sign by
142    /// checkpoint over many records; PR 2 picks one. `signature_alg`
143    /// names the algorithm so a future migration can introspect.
144    #[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// ---------------------------------------------------------------------------
153// ApprovalRevocation
154// ---------------------------------------------------------------------------
155
156/// Records that an approver revoked a previously-signed grant. Replayed
157/// from the journal, this short-circuits any subsequent consume attempt
158/// against the revoked grant -- "wrong actor / action / subject" fails
159/// in scope, "grant revoked" fails in journal lookup.
160///
161/// Schema sibling of ApprovalUse so revocations live in the same
162/// append-only chain and inherit the same digest discipline.
163#[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// ---------------------------------------------------------------------------
186// JournalCheckpoint
187// ---------------------------------------------------------------------------
188
189/// What a `JournalCheckpoint` is committing to. The discriminator lets a
190/// verifier physically distinguish a local-journal record from a
191/// Hub/org checkpoint, so a checkpoint can never accidentally promote
192/// `replay-hub-org` just because the on-disk shape happens to match.
193///
194/// PR 6 v0.9.9 release rule: a verifier emits `replay-hub-org` PASS
195/// ONLY when:
196///   1. checkpoint_kind == HubOrg AND
197///   2. hub_id is set AND
198///   3. hub_public_key is set AND
199///   4. hub_signature is set AND verifies AND
200///   5. covered_use_ids includes every use under verification
201///
202/// Default value is LocalJournal so checkpoints written before PR 6
203/// (which serialized without this field) deserialize as local-only.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
205#[serde(rename_all = "kebab-case")]
206pub enum CheckpointKind {
207    /// Internal local-journal commitment. Cannot promote replay
208    /// posture beyond `included-checkpoint`.
209    #[default]
210    LocalJournal,
211    /// Signed by a Hub / org. May promote `replay-hub-org` if the
212    /// signature verifies and coverage is asserted.
213    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/// A signed Merkle commitment to a contiguous range of journal records.
226/// Lets a verifier check journal continuity (and, with a Hub-signed
227/// variant, replay across machines) without reading every record.
228///
229/// Two kinds with the same shape:
230///
231/// - `LocalJournal` (default): committed by the local journal as a
232///   compaction primitive. Verify only emits `replay-included-checkpoint`.
233///
234/// - `HubOrg`: signed by a Hub/org. Carries `hub_id`, `hub_public_key`,
235///   `hub_signature`, `signed_at`, and `covered_use_ids` listing every
236///   use the checkpoint asserts coverage over. Verify emits
237///   `replay-hub-org` PASS only when every Hub-signature check passes
238///   and every embedded use is in `covered_use_ids`.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct JournalCheckpoint {
241    #[serde(rename = "type")]
242    pub type_: String,
243    pub checkpoint_id: String,
244
245    /// Discriminator. Defaults to LocalJournal for back-compat with
246    /// pre-PR-6 records that didn't serialize this field.
247    #[serde(default)]
248    pub checkpoint_kind: CheckpointKind,
249
250    /// Inclusive range of `use_number`s (or revocation_ids) covered by
251    /// this checkpoint, in journal order.
252    pub from_record_index: u64,
253    pub to_record_index: u64,
254
255    /// Merkle root over the canonical JSON of every record in
256    /// `[from_record_index, to_record_index]`.
257    pub merkle_root: String,
258    pub leaf_count: u64,
259
260    pub journal_id: String,
261    pub created_at: String,
262
263    /// Hub identity (e.g. "hub://org-foo"). Required when
264    /// `checkpoint_kind == HubOrg`. Empty/absent for local-journal.
265    #[serde(default, skip_serializing_if = "String::is_empty")]
266    pub hub_id: String,
267
268    /// Hub's signing public key. base64-url no-pad. Required for HubOrg.
269    /// Embedded so a verifier can check the signature without a
270    /// separate trust root lookup; PR 7+ adds a trusted issuer
271    /// registry that pins acceptable hub_public_keys.
272    #[serde(default, skip_serializing_if = "String::is_empty")]
273    pub hub_public_key: String,
274
275    /// base64-url-no-pad Ed25519 signature over the canonical
276    /// signing payload (`canonical_hub_signing_bytes`). Required for
277    /// HubOrg.
278    #[serde(default, skip_serializing_if = "String::is_empty")]
279    pub hub_signature: String,
280
281    /// RFC 3339 timestamp when the Hub signed this checkpoint.
282    /// Distinct from `created_at` (which is the local journal's
283    /// recorded creation time).
284    #[serde(default, skip_serializing_if = "String::is_empty")]
285    pub signed_at: String,
286
287    /// Use IDs the Hub asserts this checkpoint covers. The verifier
288    /// MUST confirm every package use_id is in this list before
289    /// emitting `replay-hub-org` PASS.
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub covered_use_ids: Vec<String>,
292
293    /// Grant IDs covered. Informational; the per-use check is what
294    /// gates the row.
295    #[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    /// True only when EVERY Hub field is populated -- the precondition
313    /// for `replay-hub-org` PASS to be considered. Signature
314    /// verification is a separate step.
315    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    /// Canonical bytes the Hub signs. Stable JSON of every field
324    /// except `hub_signature` and `record_digest` (those depend on
325    /// the signature itself). Sibling helper to `record_digest`'s
326    /// approach in this same module.
327    pub fn canonical_hub_signing_bytes(&self) -> Vec<u8> {
328        // Build a serializable view that omits hub_signature and
329        // record_digest. The previous_record_digest is part of the
330        // chain link and SHOULD be signed -- changing it changes the
331        // checkpoint's identity in the journal.
332        #[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        // v0.10.4 audit lane C: this function feeds Hub-signature
369        // verification. Falling back to `Vec<u8>::default()` on encode
370        // failure would silently produce empty signing bytes — two
371        // failed-to-encode checkpoints would cross-validate against each
372        // other's signatures. Every field above is a primitive, &str,
373        // enum, or slice of String, so serde_json::to_vec is infallible
374        // for this type in the current type system; if a future schema
375        // change introduces a fallible field (f32/f64 NaN, custom
376        // Serialize), we want to fail loud at sign/verify time, not
377        // emit a forgery vector.
378        serde_json::to_vec(&v)
379            .expect("approval_use canonical_hub_signing_bytes encode must not fail; report bug")
380    }
381}
382
383// ---------------------------------------------------------------------------
384// Replay-check metadata for verify output
385// ---------------------------------------------------------------------------
386
387/// Replay-check level surfaced by `verify`. Lets the printer say exactly
388/// what was checked, instead of overclaiming or underclaiming.
389///
390/// The progression is monotonic in trust strength: each level subsumes
391/// the previous. A verifier should report the *strongest* level it
392/// successfully checked, never falling back silently to a weaker one
393/// just because the stronger one was unavailable.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
395#[serde(rename_all = "kebab-case")]
396pub enum ReplayCheckLevel {
397    /// No replay check ran (e.g. no approvals in the package).
398    NotPerformed,
399    /// The package itself was scanned for duplicate uses of the same
400    /// nonce. v0.9.6's behavior. No external state consulted.
401    PackageLocal,
402    /// A local Approval Use Journal was consulted. PR 2's outcome.
403    LocalJournal,
404    /// A signed Hub / org checkpoint was consulted on top of local. PR 6.
405    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/// Result of the replay check that verify ran. Carries the level that
420/// was achieved plus enough context for printers / reports to render
421/// "use 1/1" without re-resolving state.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct ReplayCheck {
424    pub level: ReplayCheckLevel,
425
426    /// Which use of the grant was observed. Some when a journal
427    /// returned the count; None when no journal was consulted.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub use_number: Option<u32>,
430
431    /// Mirror of the grant's `max_actions` at the time of check.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub max_uses: Option<u32>,
434
435    /// True when the check passed. False or absent means a violation
436    /// (duplicate use, journal tampered, etc.). The `details` string
437    /// carries the human-readable reason.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub passed: Option<bool>,
440
441    /// One-line summary shown in verify output and the report.
442    #[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
468// ---------------------------------------------------------------------------
469// Canonical-form helpers
470// ---------------------------------------------------------------------------
471
472/// Compute `record_digest` for an ApprovalUse. The record's own
473/// `record_digest` field is excluded from the hash so the value is
474/// idempotent: digest_of(record_with_digest_cleared) == record.record_digest.
475///
476/// Canonical form is JSON with sorted keys (serde_json's default ordering
477/// is field-declaration order, which is stable for the typed structs in
478/// this module). Both the journal writer and any external auditor must
479/// use this exact function to get matching digests.
480pub 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    // v0.10.4 audit lane C: never fall back to empty bytes here. The
485    // returned digest is used to seal `record_digest` and as the
486    // previous-record link in the journal hash chain; an empty-byte
487    // fallback would let two failed-to-encode records share a digest
488    // and silently cross-validate. ApprovalUse fields are all
489    // primitives, String, Option, or u32 — encode is infallible in the
490    // current type system; panic loud if that ever changes.
491    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    // v0.10.4 audit lane C: see approval_use_record_digest. Same
510    // forgery vector applies — empty-byte fallback would let revocation
511    // records cross-validate against each other in the hash chain.
512    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    // v0.10.4 audit lane C: see approval_use_record_digest. Same
531    // forgery vector applies — empty-byte fallback would let checkpoint
532    // records cross-validate against each other in the hash chain, and
533    // would also let `replay-included-checkpoint` PASS on a tampered
534    // checkpoint that happened to hit an encode failure path.
535    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/// Outcome of `verify_hub_checkpoint_signature`.
550#[derive(Debug, Clone, PartialEq, Eq)]
551pub enum HubCheckpointVerification {
552    /// Signature verified. The checkpoint was genuinely signed by
553    /// `hub_public_key`. Coverage is the caller's job to assert.
554    Valid,
555    /// Checkpoint claims `kind=HubOrg` but is missing one of
556    /// `hub_id`, `hub_public_key`, `hub_signature`, or `signed_at`.
557    /// Verifiers MUST treat this the same as Tampered for the
558    /// purpose of emitting `replay-hub-org`.
559    MissingFields(&'static str),
560    /// Signature did not verify against the embedded public key.
561    /// Tampered or wrong key.
562    Tampered,
563    /// Checkpoint kind is `LocalJournal` -- nothing to verify here.
564    /// Caller should not have called this; surface as a programming
565    /// error.
566    NotHubKind,
567    /// The embedded `hub_public_key` is not in the operator's trust root
568    /// store under kind `Ship`. The signature math may be internally
569    /// consistent, but the issuer is unknown -- self-signed forgeries
570    /// land here. Distinct from `Tampered` so callers can render the
571    /// actionable "configure trust" remediation.
572    UntrustedIssuer,
573}
574
575/// Verify the embedded Hub signature on a `JournalCheckpoint`. Does NOT
576/// check coverage (`covered_use_ids`) -- that's the caller's job, since
577/// it depends on which uses the package contains.
578///
579/// Verification rule: the public key in the checkpoint must successfully
580/// validate the signature against `canonical_hub_signing_bytes()`. If
581/// any required field is empty or the signature decodes wrong, the
582/// result is `Tampered` (or `MissingFields` for upfront validation
583/// failures). Never returns `Valid` on a borderline -- the
584/// release rule "no global single-use claim without verified Hub
585/// checkpoint" is enforced here.
586pub 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    // Trust pin: the embedded hub_public_key MUST be in the operator's
629    // trust root store under kind `HubOrg` before we honor the signature.
630    // Without this an attacker-minted keypair can self-sign a hub-org
631    // checkpoint and promote `replay-local-journal` to `replay-hub-org`.
632    // With it, the operator decides which hubs they trust to vouch for
633    // global single-use claims. (Batch 5: this power used to ride on the
634    // overloaded `Ship` kind, which also granted cert issuance and
635    // revocation; it is now scoped to `HubOrg`.)
636    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
648/// sha256 over a raw approval nonce, prefixed `sha256:`. Used everywhere
649/// the journal needs to reference a grant's nonce without storing it.
650pub 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// ---------------------------------------------------------------------------
665// Tests
666// ---------------------------------------------------------------------------
667
668#[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        // The digest of a record must be the same whether `record_digest`
711        // was empty or already populated -- the function clears it
712        // internally before hashing.
713        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        // Two sample records produce a chain: record N's
725        // previous_record_digest equals record N-1's record_digest.
726        // This pins the property the journal writer must uphold.
727        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        // A different parent breaks the chain check (different digest).
739        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        // The journal stores nonce_digest, never the raw nonce. The
750        // schema enforces this by design (no `nonce` field on
751        // ApprovalUse) -- this test just pins the helper.
752        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    /// v0.10.4 audit lane C regression: the three record-digest helpers
837    /// and `canonical_hub_signing_bytes` used to fall back to
838    /// `Vec<u8>::default()` on serde encode failure. That meant two
839    /// different records that both hit the (rare) failure path would
840    /// produce the SAME digest (sha256 of empty bytes), letting them
841    /// cross-validate against each other in the journal hash chain and
842    /// against each other's Hub signatures. Post-fix, the failure case
843    /// panics rather than silently emitting empty bytes; in the current
844    /// type system the failure case is genuinely unreachable, but this
845    /// test pins the *property* that distinct records produce distinct
846    /// digests and none of them match the sha256 of empty bytes (which
847    /// is what the old fallback would have produced).
848    #[test]
849    fn record_digests_never_match_empty_bytes_sha256() {
850        use sha2::{Digest, Sha256};
851
852        // The fingerprint of the old silent-fallback failure mode:
853        // sha256 of an empty input. If a future regression
854        // re-introduces .unwrap_or_default() here, the digest of
855        // anything would match this value, and the test below would
856        // catch it.
857        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        // And the canonical hub signing bytes must be non-empty.
897        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        // And two distinct records must produce distinct digests --
905        // the property the empty-byte fallback would have broken.
906        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        // Pre-PR-6 records serialized without the field; deserialize
916        // must default to LocalJournal so existing PR 4 packages
917        // verify identically.
918        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    /// Build a one-entry trust store that pins `pk` for kind `HubOrg`.
934    /// Used by every test below now that hub-checkpoint verification
935    /// requires a pin (Batch 5: scoped to HubOrg, was the overloaded Ship).
936    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    /// End-to-end: sign a Hub checkpoint with a real Ed25519 key,
968    /// embed the signature, verify it round-trips. The release rule
969    /// pins on this path: replay-hub-org cannot pass without a real
970    /// signature here AND a configured trust root.
971    #[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    // Batch 5: a deprecated `ship` pin must NOT authorize a hub-org checkpoint
1003    // anymore; only a `hub_org` pin does. This is the security property of the
1004    // trust-split — pinning a hub for one power no longer grants the others.
1005    #[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        // The exact same key, pinned as the deprecated `ship` kind: rejected.
1031        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        // Pinned as `hub_org`: honored.
1037        assert_eq!(
1038            verify_hub_checkpoint_signature(&cp, &pin(TrustRootKind::HubOrg)),
1039            HubCheckpointVerification::Valid,
1040        );
1041        // Cross-power isolation: a `cert_issuer` pin does NOT grant hub-org.
1042        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        // Sanity: signature is good before tamper.
1066        assert_eq!(
1067            verify_hub_checkpoint_signature(&cp, &trust),
1068            HubCheckpointVerification::Valid
1069        );
1070
1071        // Tamper with covered_use_ids -- now the canonical bytes
1072        // change, signature no longer applies.
1073        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]); // different key
1087        let pk_imp = sk_imp.verifying_key();
1088
1089        let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1090        cp.hub_id = "hub://x".into();
1091        // Signature made by sk_real, but public key claims sk_imp.
1092        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        // Pin the impersonator's key so the trust pin doesn't short-circuit
1097        // before we hit the signature mismatch -- this test exercises the
1098        // signature-failure path, not the trust-pin path.
1099        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    /// Trust pin: a checkpoint signed by a key the operator never
1120    /// trusted MUST return `UntrustedIssuer`, even though the
1121    /// signature math is internally consistent. This is the headline
1122    /// case from the v0.10.2 audit -- self-signed forgery was the
1123    /// pre-fix behavior, post-fix it's quarantined.
1124    #[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        // Operator trusts a different hub.
1141        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    /// With no trust configured at all, any hub-org checkpoint is
1151    /// untrusted -- the fail-closed contract.
1152    #[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}