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")] type_: &'a str,
335 checkpoint_id: &'a str,
336 checkpoint_kind: CheckpointKind,
337 from_record_index: u64,
338 to_record_index: u64,
339 merkle_root: &'a str,
340 leaf_count: u64,
341 journal_id: &'a str,
342 created_at: &'a str,
343 hub_id: &'a str,
344 hub_public_key: &'a str,
345 signed_at: &'a str,
346 covered_use_ids: &'a [String],
347 covered_grant_ids: &'a [String],
348 previous_record_digest: &'a str,
349 }
350 let v = Signing {
351 type_: &self.type_,
352 checkpoint_id: &self.checkpoint_id,
353 checkpoint_kind: self.checkpoint_kind,
354 from_record_index: self.from_record_index,
355 to_record_index: self.to_record_index,
356 merkle_root: &self.merkle_root,
357 leaf_count: self.leaf_count,
358 journal_id: &self.journal_id,
359 created_at: &self.created_at,
360 hub_id: &self.hub_id,
361 hub_public_key: &self.hub_public_key,
362 signed_at: &self.signed_at,
363 covered_use_ids: &self.covered_use_ids,
364 covered_grant_ids: &self.covered_grant_ids,
365 previous_record_digest: &self.previous_record_digest,
366 };
367 // v0.10.4 audit lane C: this function feeds Hub-signature
368 // verification. Falling back to `Vec<u8>::default()` on encode
369 // failure would silently produce empty signing bytes — two
370 // failed-to-encode checkpoints would cross-validate against each
371 // other's signatures. Every field above is a primitive, &str,
372 // enum, or slice of String, so serde_json::to_vec is infallible
373 // for this type in the current type system; if a future schema
374 // change introduces a fallible field (f32/f64 NaN, custom
375 // Serialize), we want to fail loud at sign/verify time, not
376 // emit a forgery vector.
377 serde_json::to_vec(&v)
378 .expect("approval_use canonical_hub_signing_bytes encode must not fail; report bug")
379 }
380}
381
382// ---------------------------------------------------------------------------
383// Replay-check metadata for verify output
384// ---------------------------------------------------------------------------
385
386/// Replay-check level surfaced by `verify`. Lets the printer say exactly
387/// what was checked, instead of overclaiming or underclaiming.
388///
389/// The progression is monotonic in trust strength: each level subsumes
390/// the previous. A verifier should report the *strongest* level it
391/// successfully checked, never falling back silently to a weaker one
392/// just because the stronger one was unavailable.
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "kebab-case")]
395pub enum ReplayCheckLevel {
396 /// No replay check ran (e.g. no approvals in the package).
397 NotPerformed,
398 /// The package itself was scanned for duplicate uses of the same
399 /// nonce. v0.9.6's behavior. No external state consulted.
400 PackageLocal,
401 /// A local Approval Use Journal was consulted. PR 2's outcome.
402 LocalJournal,
403 /// A signed Hub / org checkpoint was consulted on top of local. PR 6.
404 HubOrg,
405}
406
407impl ReplayCheckLevel {
408 pub fn label(self) -> &'static str {
409 match self {
410 Self::NotPerformed => "not performed",
411 Self::PackageLocal => "package-local",
412 Self::LocalJournal => "local-journal",
413 Self::HubOrg => "hub-org",
414 }
415 }
416}
417
418/// Result of the replay check that verify ran. Carries the level that
419/// was achieved plus enough context for printers / reports to render
420/// "use 1/1" without re-resolving state.
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct ReplayCheck {
423 pub level: ReplayCheckLevel,
424
425 /// Which use of the grant was observed. Some when a journal
426 /// returned the count; None when no journal was consulted.
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub use_number: Option<u32>,
429
430 /// Mirror of the grant's `max_actions` at the time of check.
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub max_uses: Option<u32>,
433
434 /// True when the check passed. False or absent means a violation
435 /// (duplicate use, journal tampered, etc.). The `details` string
436 /// carries the human-readable reason.
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub passed: Option<bool>,
439
440 /// One-line summary shown in verify output and the report.
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub details: Option<String>,
443}
444
445impl ReplayCheck {
446 pub fn not_performed() -> Self {
447 Self { level: ReplayCheckLevel::NotPerformed, use_number: None, max_uses: None, passed: None, details: None }
448 }
449
450 pub fn package_local(passed: bool, details: impl Into<String>) -> Self {
451 Self {
452 level: ReplayCheckLevel::PackageLocal,
453 use_number: None,
454 max_uses: None,
455 passed: Some(passed),
456 details: Some(details.into()),
457 }
458 }
459}
460
461// ---------------------------------------------------------------------------
462// Canonical-form helpers
463// ---------------------------------------------------------------------------
464
465/// Compute `record_digest` for an ApprovalUse. The record's own
466/// `record_digest` field is excluded from the hash so the value is
467/// idempotent: digest_of(record_with_digest_cleared) == record.record_digest.
468///
469/// Canonical form is JSON with sorted keys (serde_json's default ordering
470/// is field-declaration order, which is stable for the typed structs in
471/// this module). Both the journal writer and any external auditor must
472/// use this exact function to get matching digests.
473pub fn approval_use_record_digest(rec: &ApprovalUse) -> String {
474 use sha2::{Digest, Sha256};
475 let mut canon = rec.clone();
476 canon.record_digest = String::new();
477 // v0.10.4 audit lane C: never fall back to empty bytes here. The
478 // returned digest is used to seal `record_digest` and as the
479 // previous-record link in the journal hash chain; an empty-byte
480 // fallback would let two failed-to-encode records share a digest
481 // and silently cross-validate. ApprovalUse fields are all
482 // primitives, String, Option, or u32 — encode is infallible in the
483 // current type system; panic loud if that ever changes.
484 let bytes = serde_json::to_vec(&canon)
485 .expect("approval_use_record_digest encode must not fail; report bug");
486 let mut hasher = Sha256::new();
487 hasher.update(&bytes);
488 let digest = hasher.finalize();
489 let mut hex = String::with_capacity(64 + 7);
490 hex.push_str("sha256:");
491 for b in digest.as_slice() {
492 use std::fmt::Write;
493 let _ = write!(hex, "{b:02x}");
494 }
495 hex
496}
497
498pub fn approval_revocation_record_digest(rec: &ApprovalRevocation) -> String {
499 use sha2::{Digest, Sha256};
500 let mut canon = rec.clone();
501 canon.record_digest = String::new();
502 // v0.10.4 audit lane C: see approval_use_record_digest. Same
503 // forgery vector applies — empty-byte fallback would let revocation
504 // records cross-validate against each other in the hash chain.
505 let bytes = serde_json::to_vec(&canon)
506 .expect("approval_revocation_record_digest encode must not fail; report bug");
507 let mut hasher = Sha256::new();
508 hasher.update(&bytes);
509 let digest = hasher.finalize();
510 let mut hex = String::with_capacity(64 + 7);
511 hex.push_str("sha256:");
512 for b in digest.as_slice() {
513 use std::fmt::Write;
514 let _ = write!(hex, "{b:02x}");
515 }
516 hex
517}
518
519pub fn journal_checkpoint_record_digest(rec: &JournalCheckpoint) -> String {
520 use sha2::{Digest, Sha256};
521 let mut canon = rec.clone();
522 canon.record_digest = String::new();
523 // v0.10.4 audit lane C: see approval_use_record_digest. Same
524 // forgery vector applies — empty-byte fallback would let checkpoint
525 // records cross-validate against each other in the hash chain, and
526 // would also let `replay-included-checkpoint` PASS on a tampered
527 // checkpoint that happened to hit an encode failure path.
528 let bytes = serde_json::to_vec(&canon)
529 .expect("journal_checkpoint_record_digest encode must not fail; report bug");
530 let mut hasher = Sha256::new();
531 hasher.update(&bytes);
532 let digest = hasher.finalize();
533 let mut hex = String::with_capacity(64 + 7);
534 hex.push_str("sha256:");
535 for b in digest.as_slice() {
536 use std::fmt::Write;
537 let _ = write!(hex, "{b:02x}");
538 }
539 hex
540}
541
542/// Outcome of `verify_hub_checkpoint_signature`.
543#[derive(Debug, Clone, PartialEq, Eq)]
544pub enum HubCheckpointVerification {
545 /// Signature verified. The checkpoint was genuinely signed by
546 /// `hub_public_key`. Coverage is the caller's job to assert.
547 Valid,
548 /// Checkpoint claims `kind=HubOrg` but is missing one of
549 /// `hub_id`, `hub_public_key`, `hub_signature`, or `signed_at`.
550 /// Verifiers MUST treat this the same as Tampered for the
551 /// purpose of emitting `replay-hub-org`.
552 MissingFields(&'static str),
553 /// Signature did not verify against the embedded public key.
554 /// Tampered or wrong key.
555 Tampered,
556 /// Checkpoint kind is `LocalJournal` -- nothing to verify here.
557 /// Caller should not have called this; surface as a programming
558 /// error.
559 NotHubKind,
560 /// The embedded `hub_public_key` is not in the operator's trust root
561 /// store under kind `Ship`. The signature math may be internally
562 /// consistent, but the issuer is unknown -- self-signed forgeries
563 /// land here. Distinct from `Tampered` so callers can render the
564 /// actionable "configure trust" remediation.
565 UntrustedIssuer,
566}
567
568/// Verify the embedded Hub signature on a `JournalCheckpoint`. Does NOT
569/// check coverage (`covered_use_ids`) -- that's the caller's job, since
570/// it depends on which uses the package contains.
571///
572/// Verification rule: the public key in the checkpoint must successfully
573/// validate the signature against `canonical_hub_signing_bytes()`. If
574/// any required field is empty or the signature decodes wrong, the
575/// result is `Tampered` (or `MissingFields` for upfront validation
576/// failures). Never returns `Valid` on a borderline -- the
577/// release rule "no global single-use claim without verified Hub
578/// checkpoint" is enforced here.
579pub fn verify_hub_checkpoint_signature(
580 cp: &JournalCheckpoint,
581 trust: &crate::trust::TrustRootStore,
582) -> HubCheckpointVerification {
583 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
584 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
585 use crate::trust::TrustRootKind;
586
587 if cp.checkpoint_kind != CheckpointKind::HubOrg {
588 return HubCheckpointVerification::NotHubKind;
589 }
590 if cp.hub_id.is_empty() { return HubCheckpointVerification::MissingFields("hub_id"); }
591 if cp.hub_public_key.is_empty() { return HubCheckpointVerification::MissingFields("hub_public_key"); }
592 if cp.hub_signature.is_empty() { return HubCheckpointVerification::MissingFields("hub_signature"); }
593 if cp.signed_at.is_empty() { return HubCheckpointVerification::MissingFields("signed_at"); }
594
595 let pk_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_public_key.as_bytes()) {
596 Ok(b) if b.len() == 32 => b,
597 _ => return HubCheckpointVerification::Tampered,
598 };
599 let sig_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_signature.as_bytes()) {
600 Ok(b) if b.len() == 64 => b,
601 _ => return HubCheckpointVerification::Tampered,
602 };
603 let mut pk_arr = [0u8; 32];
604 pk_arr.copy_from_slice(&pk_bytes);
605 let mut sig_arr = [0u8; 64];
606 sig_arr.copy_from_slice(&sig_bytes);
607
608 let vk = match VerifyingKey::from_bytes(&pk_arr) {
609 Ok(k) => k,
610 Err(_) => return HubCheckpointVerification::Tampered,
611 };
612
613 // Trust pin: the embedded hub_public_key MUST be in the operator's
614 // trust root store under kind `Ship` before we honor the signature.
615 // Without this an attacker-minted keypair can self-sign a hub-org
616 // checkpoint and promote `replay-local-journal` to
617 // `replay-hub-org`. With it, the operator decides which hubs they
618 // trust to vouch for global single-use claims.
619 if !trust.contains(&vk, TrustRootKind::Ship) {
620 return HubCheckpointVerification::UntrustedIssuer;
621 }
622
623 let sig = Signature::from_bytes(&sig_arr);
624 let payload = cp.canonical_hub_signing_bytes();
625 match vk.verify(&payload, &sig) {
626 Ok(()) => HubCheckpointVerification::Valid,
627 Err(_) => HubCheckpointVerification::Tampered,
628 }
629}
630
631/// sha256 over a raw approval nonce, prefixed `sha256:`. Used everywhere
632/// the journal needs to reference a grant's nonce without storing it.
633pub fn nonce_digest(raw_nonce: &str) -> String {
634 use sha2::{Digest, Sha256};
635 let mut hasher = Sha256::new();
636 hasher.update(raw_nonce.as_bytes());
637 let digest = hasher.finalize();
638 let mut hex = String::with_capacity(64 + 7);
639 hex.push_str("sha256:");
640 for b in digest.as_slice() {
641 use std::fmt::Write;
642 let _ = write!(hex, "{b:02x}");
643 }
644 hex
645}
646
647// ---------------------------------------------------------------------------
648// Tests
649// ---------------------------------------------------------------------------
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 fn sample_use() -> ApprovalUse {
656 ApprovalUse {
657 type_: TYPE_APPROVAL_USE.into(),
658 use_id: "use_abc".into(),
659 grant_id: "art_grant_1".into(),
660 grant_digest: "sha256:00".into(),
661 nonce_digest: "sha256:11".into(),
662 actor: "agent://deployer".into(),
663 action: "deploy.production".into(),
664 subject: "env://production".into(),
665 session_id: Some("ssn_xyz".into()),
666 action_artifact_id: None,
667 receipt_digest: None,
668 use_number: 1,
669 max_uses: Some(1),
670 idempotency_key: None,
671 created_at: "2026-04-30T06:00:00Z".into(),
672 expires_at: None,
673 previous_record_digest: String::new(),
674 record_digest: String::new(),
675 signature: None,
676 signature_alg: None,
677 signing_key_id: None,
678 }
679 }
680
681 #[test]
682 fn approval_use_serialization_round_trips() {
683 let u = sample_use();
684 let bytes = serde_json::to_vec(&u).unwrap();
685 let back: ApprovalUse = serde_json::from_slice(&bytes).unwrap();
686 assert_eq!(back.use_id, u.use_id);
687 assert_eq!(back.grant_id, u.grant_id);
688 assert_eq!(back.use_number, 1);
689 }
690
691 #[test]
692 fn record_digest_is_stable_and_excludes_itself() {
693 // The digest of a record must be the same whether `record_digest`
694 // was empty or already populated -- the function clears it
695 // internally before hashing.
696 let u1 = sample_use();
697 let mut u2 = u1.clone();
698 u2.record_digest = "sha256:cafe".into();
699 assert_eq!(approval_use_record_digest(&u1), approval_use_record_digest(&u2));
700 }
701
702 #[test]
703 fn previous_record_digest_chains() {
704 // Two sample records produce a chain: record N's
705 // previous_record_digest equals record N-1's record_digest.
706 // This pins the property the journal writer must uphold.
707 let mut a = sample_use();
708 a.use_number = 1;
709 a.record_digest = approval_use_record_digest(&a);
710
711 let mut b = sample_use();
712 b.use_number = 2;
713 b.use_id = "use_def".into();
714 b.previous_record_digest = a.record_digest.clone();
715 b.record_digest = approval_use_record_digest(&b);
716
717 assert_eq!(b.previous_record_digest, a.record_digest);
718 // A different parent breaks the chain check (different digest).
719 let mut c = sample_use();
720 c.use_id = "use_ghi".into();
721 c.use_number = 2;
722 c.previous_record_digest = "sha256:wrong".into();
723 c.record_digest = approval_use_record_digest(&c);
724 assert_ne!(b.record_digest, c.record_digest);
725 }
726
727 #[test]
728 fn nonce_digest_does_not_leak_raw_nonce() {
729 // The journal stores nonce_digest, never the raw nonce. The
730 // schema enforces this by design (no `nonce` field on
731 // ApprovalUse) -- this test just pins the helper.
732 let raw = "n_abcdef0123";
733 let d = nonce_digest(raw);
734 assert!(d.starts_with("sha256:"));
735 assert!(!d.contains(raw), "digest must not contain the raw nonce");
736 }
737
738 #[test]
739 fn replay_check_level_labels() {
740 assert_eq!(ReplayCheckLevel::NotPerformed.label(), "not performed");
741 assert_eq!(ReplayCheckLevel::PackageLocal.label(), "package-local");
742 assert_eq!(ReplayCheckLevel::LocalJournal.label(), "local-journal");
743 assert_eq!(ReplayCheckLevel::HubOrg.label(), "hub-org");
744 }
745
746 #[test]
747 fn replay_check_serialization_uses_kebab_case() {
748 let r = ReplayCheck {
749 level: ReplayCheckLevel::LocalJournal,
750 use_number: Some(1),
751 max_uses: Some(1),
752 passed: Some(true),
753 details: Some("local Approval Use Journal passed".into()),
754 };
755 let v = serde_json::to_value(&r).unwrap();
756 assert_eq!(v["level"], "local-journal");
757 assert_eq!(v["use_number"], 1);
758 assert_eq!(v["max_uses"], 1);
759 assert_eq!(v["passed"], true);
760 }
761
762 #[test]
763 fn revocation_record_digest_stable() {
764 let rev = ApprovalRevocation {
765 type_: TYPE_APPROVAL_REVOCATION.into(),
766 revocation_id: "rev_1".into(),
767 grant_id: "art_grant_1".into(),
768 grant_digest: "sha256:00".into(),
769 revoker: "human://alice".into(),
770 reason: Some("rotated key".into()),
771 created_at: "2026-04-30T06:01:00Z".into(),
772 previous_record_digest: "sha256:00".into(),
773 record_digest: String::new(),
774 signature: None,
775 signature_alg: None,
776 signing_key_id: None,
777 };
778 let d1 = approval_revocation_record_digest(&rev);
779 let d2 = approval_revocation_record_digest(&rev);
780 assert_eq!(d1, d2);
781 }
782
783 fn sample_checkpoint(kind: CheckpointKind) -> JournalCheckpoint {
784 JournalCheckpoint {
785 type_: TYPE_JOURNAL_CHECKPOINT.into(),
786 checkpoint_id: "cp_1".into(),
787 checkpoint_kind: kind,
788 from_record_index: 1,
789 to_record_index: 10,
790 merkle_root: "sha256:abcd".into(),
791 leaf_count: 10,
792 journal_id: "journal_1".into(),
793 created_at: "2026-04-30T06:02:00Z".into(),
794 hub_id: String::new(),
795 hub_public_key: String::new(),
796 hub_signature: String::new(),
797 signed_at: String::new(),
798 covered_use_ids: Vec::new(),
799 covered_grant_ids: Vec::new(),
800 previous_record_digest: "sha256:00".into(),
801 record_digest: String::new(),
802 signature: None,
803 signature_alg: None,
804 signing_key_id: None,
805 }
806 }
807
808 #[test]
809 fn checkpoint_record_digest_stable() {
810 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
811 let d1 = journal_checkpoint_record_digest(&cp);
812 let d2 = journal_checkpoint_record_digest(&cp);
813 assert_eq!(d1, d2);
814 }
815
816 /// v0.10.4 audit lane C regression: the three record-digest helpers
817 /// and `canonical_hub_signing_bytes` used to fall back to
818 /// `Vec<u8>::default()` on serde encode failure. That meant two
819 /// different records that both hit the (rare) failure path would
820 /// produce the SAME digest (sha256 of empty bytes), letting them
821 /// cross-validate against each other in the journal hash chain and
822 /// against each other's Hub signatures. Post-fix, the failure case
823 /// panics rather than silently emitting empty bytes; in the current
824 /// type system the failure case is genuinely unreachable, but this
825 /// test pins the *property* that distinct records produce distinct
826 /// digests and none of them match the sha256 of empty bytes (which
827 /// is what the old fallback would have produced).
828 #[test]
829 fn record_digests_never_match_empty_bytes_sha256() {
830 use sha2::{Digest, Sha256};
831
832 // The fingerprint of the old silent-fallback failure mode:
833 // sha256 of an empty input. If a future regression
834 // re-introduces .unwrap_or_default() here, the digest of
835 // anything would match this value, and the test below would
836 // catch it.
837 let mut hasher = Sha256::new();
838 let empty: &[u8] = &[];
839 hasher.update(empty);
840 let empty_digest = hasher.finalize();
841 let mut empty_hex = String::with_capacity(64 + 7);
842 empty_hex.push_str("sha256:");
843 for b in empty_digest.as_slice() {
844 use std::fmt::Write;
845 let _ = write!(empty_hex, "{b:02x}");
846 }
847
848 let u = sample_use();
849 let use_digest = approval_use_record_digest(&u);
850 assert_ne!(
851 use_digest, empty_hex,
852 "approval_use_record_digest must never match sha256-of-empty (audit lane C)",
853 );
854
855 let rev = ApprovalRevocation {
856 type_: TYPE_APPROVAL_REVOCATION.into(),
857 revocation_id: "rev_x".into(),
858 grant_id: "art_grant_x".into(),
859 grant_digest: "sha256:00".into(),
860 revoker: "human://x".into(),
861 reason: None,
862 created_at: "2026-04-30T06:01:00Z".into(),
863 previous_record_digest: "sha256:00".into(),
864 record_digest: String::new(),
865 signature: None,
866 signature_alg: None,
867 signing_key_id: None,
868 };
869 let rev_digest = approval_revocation_record_digest(&rev);
870 assert_ne!(rev_digest, empty_hex);
871
872 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
873 let cp_digest = journal_checkpoint_record_digest(&cp);
874 assert_ne!(cp_digest, empty_hex);
875
876 // And the canonical hub signing bytes must be non-empty.
877 let cp_hub = sample_checkpoint(CheckpointKind::HubOrg);
878 let signing_bytes = cp_hub.canonical_hub_signing_bytes();
879 assert!(
880 !signing_bytes.is_empty(),
881 "canonical_hub_signing_bytes must never be empty (would let two checkpoints cross-validate)",
882 );
883
884 // And two distinct records must produce distinct digests --
885 // the property the empty-byte fallback would have broken.
886 let mut u2 = sample_use();
887 u2.use_id = "use_zzz".into();
888 u2.use_number = 99;
889 let use_digest_2 = approval_use_record_digest(&u2);
890 assert_ne!(use_digest, use_digest_2);
891 }
892
893 #[test]
894 fn checkpoint_kind_defaults_to_local_journal() {
895 // Pre-PR-6 records serialized without the field; deserialize
896 // must default to LocalJournal so existing PR 4 packages
897 // verify identically.
898 let json = r#"{"type":"treeship/journal-checkpoint/v1","checkpoint_id":"cp_legacy",
899 "from_record_index":1,"to_record_index":10,"merkle_root":"sha256:00",
900 "leaf_count":10,"journal_id":"j","created_at":"2026-04-30T00:00:00Z"}"#;
901 let cp: JournalCheckpoint = serde_json::from_str(json).unwrap();
902 assert_eq!(cp.checkpoint_kind, CheckpointKind::LocalJournal);
903 assert!(!cp.is_hub_signed());
904 }
905
906 #[test]
907 fn checkpoint_kind_serializes_kebab_case() {
908 let cp = sample_checkpoint(CheckpointKind::HubOrg);
909 let v = serde_json::to_value(&cp).unwrap();
910 assert_eq!(v["checkpoint_kind"], "hub-org");
911 }
912
913 /// Build a one-entry trust store that pins `pk` for kind `Ship`.
914 /// Used by every test below now that hub-checkpoint verification
915 /// requires a pin.
916 fn trust_with(pk: &ed25519_dalek::VerifyingKey) -> crate::trust::TrustRootStore {
917 use crate::trust::{encode_ed25519_pubkey, TrustRoot, TrustRootKind, TrustRootStore};
918 TrustRootStore::with_roots(vec![TrustRoot {
919 key_id: "test_hub".into(),
920 public_key: encode_ed25519_pubkey(pk),
921 kind: TrustRootKind::Ship,
922 label: "test pin".into(),
923 added_at: "2026-05-15T00:00:00Z".into(),
924 }])
925 }
926
927 #[test]
928 fn local_journal_checkpoint_is_not_hub_signed() {
929 let cp = sample_checkpoint(CheckpointKind::LocalJournal);
930 assert!(!cp.is_hub_signed());
931 assert_eq!(
932 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
933 HubCheckpointVerification::NotHubKind,
934 );
935 }
936
937 #[test]
938 fn hub_kind_without_fields_is_missing() {
939 let cp = sample_checkpoint(CheckpointKind::HubOrg);
940 assert!(!cp.is_hub_signed());
941 assert!(matches!(
942 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
943 HubCheckpointVerification::MissingFields(_),
944 ));
945 }
946
947 /// End-to-end: sign a Hub checkpoint with a real Ed25519 key,
948 /// embed the signature, verify it round-trips. The release rule
949 /// pins on this path: replay-hub-org cannot pass without a real
950 /// signature here AND a configured trust root.
951 #[test]
952 fn hub_checkpoint_signature_round_trip() {
953 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
954 use ed25519_dalek::{Signer, SigningKey};
955
956 let mut sk_bytes = [0u8; 32];
957 for (i, b) in sk_bytes.iter_mut().enumerate() {
958 *b = i as u8 + 7;
959 }
960 let sk = SigningKey::from_bytes(&sk_bytes);
961 let pk = sk.verifying_key();
962 let pk_b64 = URL_SAFE_NO_PAD.encode(pk.to_bytes());
963
964 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
965 cp.hub_id = "hub://zerker-org".into();
966 cp.hub_public_key = pk_b64.clone();
967 cp.signed_at = "2026-04-30T07:00:00Z".into();
968 cp.covered_use_ids = vec!["use_alpha".into(), "use_beta".into()];
969
970 let payload = cp.canonical_hub_signing_bytes();
971 let sig = sk.sign(&payload);
972 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
973
974 assert!(cp.is_hub_signed());
975 let trust = trust_with(&pk);
976 assert_eq!(
977 verify_hub_checkpoint_signature(&cp, &trust),
978 HubCheckpointVerification::Valid,
979 );
980 }
981
982 #[test]
983 fn tampered_hub_checkpoint_fails_verification() {
984 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
985 use ed25519_dalek::{Signer, SigningKey};
986
987 let sk = SigningKey::from_bytes(&[1u8; 32]);
988 let pk = sk.verifying_key();
989
990 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
991 cp.hub_id = "hub://x".into();
992 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
993 cp.signed_at = "2026-04-30T07:00:00Z".into();
994 cp.covered_use_ids = vec!["use_alpha".into()];
995
996 let sig = sk.sign(&cp.canonical_hub_signing_bytes());
997 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
998 let trust = trust_with(&pk);
999 // Sanity: signature is good before tamper.
1000 assert_eq!(verify_hub_checkpoint_signature(&cp, &trust), HubCheckpointVerification::Valid);
1001
1002 // Tamper with covered_use_ids -- now the canonical bytes
1003 // change, signature no longer applies.
1004 cp.covered_use_ids.push("use_smuggled".into());
1005 assert_eq!(
1006 verify_hub_checkpoint_signature(&cp, &trust),
1007 HubCheckpointVerification::Tampered,
1008 );
1009 }
1010
1011 #[test]
1012 fn wrong_key_fails_verification() {
1013 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1014 use ed25519_dalek::{Signer, SigningKey};
1015
1016 let sk_real = SigningKey::from_bytes(&[2u8; 32]);
1017 let sk_imp = SigningKey::from_bytes(&[3u8; 32]); // different key
1018 let pk_imp = sk_imp.verifying_key();
1019
1020 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1021 cp.hub_id = "hub://x".into();
1022 // Signature made by sk_real, but public key claims sk_imp.
1023 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk_imp.to_bytes());
1024 cp.signed_at = "2026-04-30T07:00:00Z".into();
1025 let sig = sk_real.sign(&cp.canonical_hub_signing_bytes());
1026 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1027 // Pin the impersonator's key so the trust pin doesn't short-circuit
1028 // before we hit the signature mismatch -- this test exercises the
1029 // signature-failure path, not the trust-pin path.
1030 let trust = trust_with(&pk_imp);
1031 assert_eq!(
1032 verify_hub_checkpoint_signature(&cp, &trust),
1033 HubCheckpointVerification::Tampered,
1034 );
1035 }
1036
1037 #[test]
1038 fn malformed_pubkey_or_signature_fails() {
1039 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1040 cp.hub_id = "hub://x".into();
1041 cp.hub_public_key = "not-base64!!".into();
1042 cp.hub_signature = "also-not-base64".into();
1043 cp.signed_at = "2026-04-30T07:00:00Z".into();
1044 assert_eq!(
1045 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
1046 HubCheckpointVerification::Tampered,
1047 );
1048 }
1049
1050 /// Trust pin: a checkpoint signed by a key the operator never
1051 /// trusted MUST return `UntrustedIssuer`, even though the
1052 /// signature math is internally consistent. This is the headline
1053 /// case from the v0.10.2 audit -- self-signed forgery was the
1054 /// pre-fix behavior, post-fix it's quarantined.
1055 #[test]
1056 fn hub_checkpoint_rejects_untrusted_issuer() {
1057 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1058 use ed25519_dalek::{Signer, SigningKey};
1059
1060 let attacker = SigningKey::from_bytes(&[42u8; 32]);
1061 let pk = attacker.verifying_key();
1062
1063 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1064 cp.hub_id = "hub://attacker-claims-zerker".into();
1065 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1066 cp.signed_at = "2026-04-30T07:00:00Z".into();
1067 cp.covered_use_ids = vec!["use_alpha".into()];
1068 let sig = attacker.sign(&cp.canonical_hub_signing_bytes());
1069 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1070
1071 // Operator trusts a different hub.
1072 let honest = SigningKey::from_bytes(&[7u8; 32]);
1073 let trust = trust_with(&honest.verifying_key());
1074
1075 assert_eq!(
1076 verify_hub_checkpoint_signature(&cp, &trust),
1077 HubCheckpointVerification::UntrustedIssuer,
1078 );
1079 }
1080
1081 /// With no trust configured at all, any hub-org checkpoint is
1082 /// untrusted -- the fail-closed contract.
1083 #[test]
1084 fn hub_checkpoint_rejects_with_no_trust_configured() {
1085 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
1086 use ed25519_dalek::{Signer, SigningKey};
1087
1088 let sk = SigningKey::from_bytes(&[9u8; 32]);
1089 let pk = sk.verifying_key();
1090 let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
1091 cp.hub_id = "hub://anything".into();
1092 cp.hub_public_key = URL_SAFE_NO_PAD.encode(pk.to_bytes());
1093 cp.signed_at = "2026-04-30T07:00:00Z".into();
1094 let sig = sk.sign(&cp.canonical_hub_signing_bytes());
1095 cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
1096
1097 assert_eq!(
1098 verify_hub_checkpoint_signature(&cp, &crate::trust::TrustRootStore::empty()),
1099 HubCheckpointVerification::UntrustedIssuer,
1100 );
1101 }
1102}