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")]                    type_: &'a str,
335            checkpoint_id:           &'a str,
336            checkpoint_kind:         CheckpointKind,
337            from_record_index:       u64,
338            to_record_index:         u64,
339            merkle_root:             &'a str,
340            leaf_count:              u64,
341            journal_id:              &'a str,
342            created_at:              &'a str,
343            hub_id:                  &'a str,
344            hub_public_key:          &'a str,
345            signed_at:               &'a str,
346            covered_use_ids:         &'a [String],
347            covered_grant_ids:       &'a [String],
348            previous_record_digest:  &'a str,
349        }
350        let v = Signing {
351            type_:                   &self.type_,
352            checkpoint_id:           &self.checkpoint_id,
353            checkpoint_kind:         self.checkpoint_kind,
354            from_record_index:       self.from_record_index,
355            to_record_index:         self.to_record_index,
356            merkle_root:             &self.merkle_root,
357            leaf_count:              self.leaf_count,
358            journal_id:              &self.journal_id,
359            created_at:              &self.created_at,
360            hub_id:                  &self.hub_id,
361            hub_public_key:          &self.hub_public_key,
362            signed_at:               &self.signed_at,
363            covered_use_ids:         &self.covered_use_ids,
364            covered_grant_ids:       &self.covered_grant_ids,
365            previous_record_digest:  &self.previous_record_digest,
366        };
367        serde_json::to_vec(&v).unwrap_or_default()
368    }
369}
370
371// ---------------------------------------------------------------------------
372// Replay-check metadata for verify output
373// ---------------------------------------------------------------------------
374
375/// Replay-check level surfaced by `verify`. Lets the printer say exactly
376/// what was checked, instead of overclaiming or underclaiming.
377///
378/// The progression is monotonic in trust strength: each level subsumes
379/// the previous. A verifier should report the *strongest* level it
380/// successfully checked, never falling back silently to a weaker one
381/// just because the stronger one was unavailable.
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "kebab-case")]
384pub enum ReplayCheckLevel {
385    /// No replay check ran (e.g. no approvals in the package).
386    NotPerformed,
387    /// The package itself was scanned for duplicate uses of the same
388    /// nonce. v0.9.6's behavior. No external state consulted.
389    PackageLocal,
390    /// A local Approval Use Journal was consulted. PR 2's outcome.
391    LocalJournal,
392    /// A signed Hub / org checkpoint was consulted on top of local. PR 6.
393    HubOrg,
394}
395
396impl ReplayCheckLevel {
397    pub fn label(self) -> &'static str {
398        match self {
399            Self::NotPerformed => "not performed",
400            Self::PackageLocal => "package-local",
401            Self::LocalJournal => "local-journal",
402            Self::HubOrg       => "hub-org",
403        }
404    }
405}
406
407/// Result of the replay check that verify ran. Carries the level that
408/// was achieved plus enough context for printers / reports to render
409/// "use 1/1" without re-resolving state.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct ReplayCheck {
412    pub level: ReplayCheckLevel,
413
414    /// Which use of the grant was observed. Some when a journal
415    /// returned the count; None when no journal was consulted.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub use_number: Option<u32>,
418
419    /// Mirror of the grant's `max_actions` at the time of check.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub max_uses: Option<u32>,
422
423    /// True when the check passed. False or absent means a violation
424    /// (duplicate use, journal tampered, etc.). The `details` string
425    /// carries the human-readable reason.
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub passed: Option<bool>,
428
429    /// One-line summary shown in verify output and the report.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub details: Option<String>,
432}
433
434impl ReplayCheck {
435    pub fn not_performed() -> Self {
436        Self { level: ReplayCheckLevel::NotPerformed, use_number: None, max_uses: None, passed: None, details: None }
437    }
438
439    pub fn package_local(passed: bool, details: impl Into<String>) -> Self {
440        Self {
441            level:      ReplayCheckLevel::PackageLocal,
442            use_number: None,
443            max_uses:   None,
444            passed:     Some(passed),
445            details:    Some(details.into()),
446        }
447    }
448}
449
450// ---------------------------------------------------------------------------
451// Canonical-form helpers
452// ---------------------------------------------------------------------------
453
454/// Compute `record_digest` for an ApprovalUse. The record's own
455/// `record_digest` field is excluded from the hash so the value is
456/// idempotent: digest_of(record_with_digest_cleared) == record.record_digest.
457///
458/// Canonical form is JSON with sorted keys (serde_json's default ordering
459/// is field-declaration order, which is stable for the typed structs in
460/// this module). Both the journal writer and any external auditor must
461/// use this exact function to get matching digests.
462pub fn approval_use_record_digest(rec: &ApprovalUse) -> String {
463    use sha2::{Digest, Sha256};
464    let mut canon = rec.clone();
465    canon.record_digest = String::new();
466    let bytes = serde_json::to_vec(&canon).unwrap_or_default();
467    let mut hasher = Sha256::new();
468    hasher.update(&bytes);
469    let digest = hasher.finalize();
470    let mut hex = String::with_capacity(64 + 7);
471    hex.push_str("sha256:");
472    for b in digest.as_slice() {
473        use std::fmt::Write;
474        let _ = write!(hex, "{b:02x}");
475    }
476    hex
477}
478
479pub fn approval_revocation_record_digest(rec: &ApprovalRevocation) -> String {
480    use sha2::{Digest, Sha256};
481    let mut canon = rec.clone();
482    canon.record_digest = String::new();
483    let bytes = serde_json::to_vec(&canon).unwrap_or_default();
484    let mut hasher = Sha256::new();
485    hasher.update(&bytes);
486    let digest = hasher.finalize();
487    let mut hex = String::with_capacity(64 + 7);
488    hex.push_str("sha256:");
489    for b in digest.as_slice() {
490        use std::fmt::Write;
491        let _ = write!(hex, "{b:02x}");
492    }
493    hex
494}
495
496pub fn journal_checkpoint_record_digest(rec: &JournalCheckpoint) -> String {
497    use sha2::{Digest, Sha256};
498    let mut canon = rec.clone();
499    canon.record_digest = String::new();
500    let bytes = serde_json::to_vec(&canon).unwrap_or_default();
501    let mut hasher = Sha256::new();
502    hasher.update(&bytes);
503    let digest = hasher.finalize();
504    let mut hex = String::with_capacity(64 + 7);
505    hex.push_str("sha256:");
506    for b in digest.as_slice() {
507        use std::fmt::Write;
508        let _ = write!(hex, "{b:02x}");
509    }
510    hex
511}
512
513/// Outcome of `verify_hub_checkpoint_signature`.
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub enum HubCheckpointVerification {
516    /// Signature verified. The checkpoint was genuinely signed by
517    /// `hub_public_key`. Coverage is the caller's job to assert.
518    Valid,
519    /// Checkpoint claims `kind=HubOrg` but is missing one of
520    /// `hub_id`, `hub_public_key`, `hub_signature`, or `signed_at`.
521    /// Verifiers MUST treat this the same as Tampered for the
522    /// purpose of emitting `replay-hub-org`.
523    MissingFields(&'static str),
524    /// Signature did not verify against the embedded public key.
525    /// Tampered or wrong key.
526    Tampered,
527    /// Checkpoint kind is `LocalJournal` -- nothing to verify here.
528    /// Caller should not have called this; surface as a programming
529    /// error.
530    NotHubKind,
531}
532
533/// Verify the embedded Hub signature on a `JournalCheckpoint`. Does NOT
534/// check coverage (`covered_use_ids`) -- that's the caller's job, since
535/// it depends on which uses the package contains.
536///
537/// Verification rule: the public key in the checkpoint must successfully
538/// validate the signature against `canonical_hub_signing_bytes()`. If
539/// any required field is empty or the signature decodes wrong, the
540/// result is `Tampered` (or `MissingFields` for upfront validation
541/// failures). Never returns `Valid` on a borderline -- the
542/// release rule "no global single-use claim without verified Hub
543/// checkpoint" is enforced here.
544pub fn verify_hub_checkpoint_signature(
545    cp: &JournalCheckpoint,
546) -> HubCheckpointVerification {
547    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
548    use ed25519_dalek::{Signature, Verifier, VerifyingKey};
549
550    if cp.checkpoint_kind != CheckpointKind::HubOrg {
551        return HubCheckpointVerification::NotHubKind;
552    }
553    if cp.hub_id.is_empty()         { return HubCheckpointVerification::MissingFields("hub_id"); }
554    if cp.hub_public_key.is_empty() { return HubCheckpointVerification::MissingFields("hub_public_key"); }
555    if cp.hub_signature.is_empty()  { return HubCheckpointVerification::MissingFields("hub_signature"); }
556    if cp.signed_at.is_empty()      { return HubCheckpointVerification::MissingFields("signed_at"); }
557
558    let pk_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_public_key.as_bytes()) {
559        Ok(b) if b.len() == 32 => b,
560        _ => return HubCheckpointVerification::Tampered,
561    };
562    let sig_bytes = match URL_SAFE_NO_PAD.decode(cp.hub_signature.as_bytes()) {
563        Ok(b) if b.len() == 64 => b,
564        _ => return HubCheckpointVerification::Tampered,
565    };
566    let mut pk_arr = [0u8; 32];
567    pk_arr.copy_from_slice(&pk_bytes);
568    let mut sig_arr = [0u8; 64];
569    sig_arr.copy_from_slice(&sig_bytes);
570
571    let vk = match VerifyingKey::from_bytes(&pk_arr) {
572        Ok(k)  => k,
573        Err(_) => return HubCheckpointVerification::Tampered,
574    };
575    let sig = Signature::from_bytes(&sig_arr);
576    let payload = cp.canonical_hub_signing_bytes();
577    match vk.verify(&payload, &sig) {
578        Ok(())  => HubCheckpointVerification::Valid,
579        Err(_)  => HubCheckpointVerification::Tampered,
580    }
581}
582
583/// sha256 over a raw approval nonce, prefixed `sha256:`. Used everywhere
584/// the journal needs to reference a grant's nonce without storing it.
585pub fn nonce_digest(raw_nonce: &str) -> String {
586    use sha2::{Digest, Sha256};
587    let mut hasher = Sha256::new();
588    hasher.update(raw_nonce.as_bytes());
589    let digest = hasher.finalize();
590    let mut hex = String::with_capacity(64 + 7);
591    hex.push_str("sha256:");
592    for b in digest.as_slice() {
593        use std::fmt::Write;
594        let _ = write!(hex, "{b:02x}");
595    }
596    hex
597}
598
599// ---------------------------------------------------------------------------
600// Tests
601// ---------------------------------------------------------------------------
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    fn sample_use() -> ApprovalUse {
608        ApprovalUse {
609            type_:                  TYPE_APPROVAL_USE.into(),
610            use_id:                 "use_abc".into(),
611            grant_id:               "art_grant_1".into(),
612            grant_digest:           "sha256:00".into(),
613            nonce_digest:           "sha256:11".into(),
614            actor:                  "agent://deployer".into(),
615            action:                 "deploy.production".into(),
616            subject:                "env://production".into(),
617            session_id:             Some("ssn_xyz".into()),
618            action_artifact_id:     None,
619            receipt_digest:         None,
620            use_number:             1,
621            max_uses:               Some(1),
622            idempotency_key:        None,
623            created_at:             "2026-04-30T06:00:00Z".into(),
624            expires_at:             None,
625            previous_record_digest: String::new(),
626            record_digest:          String::new(),
627            signature:              None,
628            signature_alg:          None,
629            signing_key_id:         None,
630        }
631    }
632
633    #[test]
634    fn approval_use_serialization_round_trips() {
635        let u = sample_use();
636        let bytes = serde_json::to_vec(&u).unwrap();
637        let back: ApprovalUse = serde_json::from_slice(&bytes).unwrap();
638        assert_eq!(back.use_id, u.use_id);
639        assert_eq!(back.grant_id, u.grant_id);
640        assert_eq!(back.use_number, 1);
641    }
642
643    #[test]
644    fn record_digest_is_stable_and_excludes_itself() {
645        // The digest of a record must be the same whether `record_digest`
646        // was empty or already populated -- the function clears it
647        // internally before hashing.
648        let u1 = sample_use();
649        let mut u2 = u1.clone();
650        u2.record_digest = "sha256:cafe".into();
651        assert_eq!(approval_use_record_digest(&u1), approval_use_record_digest(&u2));
652    }
653
654    #[test]
655    fn previous_record_digest_chains() {
656        // Two sample records produce a chain: record N's
657        // previous_record_digest equals record N-1's record_digest.
658        // This pins the property the journal writer must uphold.
659        let mut a = sample_use();
660        a.use_number = 1;
661        a.record_digest = approval_use_record_digest(&a);
662
663        let mut b = sample_use();
664        b.use_number = 2;
665        b.use_id = "use_def".into();
666        b.previous_record_digest = a.record_digest.clone();
667        b.record_digest = approval_use_record_digest(&b);
668
669        assert_eq!(b.previous_record_digest, a.record_digest);
670        // A different parent breaks the chain check (different digest).
671        let mut c = sample_use();
672        c.use_id = "use_ghi".into();
673        c.use_number = 2;
674        c.previous_record_digest = "sha256:wrong".into();
675        c.record_digest = approval_use_record_digest(&c);
676        assert_ne!(b.record_digest, c.record_digest);
677    }
678
679    #[test]
680    fn nonce_digest_does_not_leak_raw_nonce() {
681        // The journal stores nonce_digest, never the raw nonce. The
682        // schema enforces this by design (no `nonce` field on
683        // ApprovalUse) -- this test just pins the helper.
684        let raw = "n_abcdef0123";
685        let d = nonce_digest(raw);
686        assert!(d.starts_with("sha256:"));
687        assert!(!d.contains(raw), "digest must not contain the raw nonce");
688    }
689
690    #[test]
691    fn replay_check_level_labels() {
692        assert_eq!(ReplayCheckLevel::NotPerformed.label(), "not performed");
693        assert_eq!(ReplayCheckLevel::PackageLocal.label(), "package-local");
694        assert_eq!(ReplayCheckLevel::LocalJournal.label(), "local-journal");
695        assert_eq!(ReplayCheckLevel::HubOrg.label(),       "hub-org");
696    }
697
698    #[test]
699    fn replay_check_serialization_uses_kebab_case() {
700        let r = ReplayCheck {
701            level:      ReplayCheckLevel::LocalJournal,
702            use_number: Some(1),
703            max_uses:   Some(1),
704            passed:     Some(true),
705            details:    Some("local Approval Use Journal passed".into()),
706        };
707        let v = serde_json::to_value(&r).unwrap();
708        assert_eq!(v["level"], "local-journal");
709        assert_eq!(v["use_number"], 1);
710        assert_eq!(v["max_uses"], 1);
711        assert_eq!(v["passed"], true);
712    }
713
714    #[test]
715    fn revocation_record_digest_stable() {
716        let rev = ApprovalRevocation {
717            type_:                  TYPE_APPROVAL_REVOCATION.into(),
718            revocation_id:          "rev_1".into(),
719            grant_id:               "art_grant_1".into(),
720            grant_digest:           "sha256:00".into(),
721            revoker:                "human://alice".into(),
722            reason:                 Some("rotated key".into()),
723            created_at:             "2026-04-30T06:01:00Z".into(),
724            previous_record_digest: "sha256:00".into(),
725            record_digest:          String::new(),
726            signature:              None,
727            signature_alg:          None,
728            signing_key_id:         None,
729        };
730        let d1 = approval_revocation_record_digest(&rev);
731        let d2 = approval_revocation_record_digest(&rev);
732        assert_eq!(d1, d2);
733    }
734
735    fn sample_checkpoint(kind: CheckpointKind) -> JournalCheckpoint {
736        JournalCheckpoint {
737            type_:                  TYPE_JOURNAL_CHECKPOINT.into(),
738            checkpoint_id:          "cp_1".into(),
739            checkpoint_kind:        kind,
740            from_record_index:      1,
741            to_record_index:        10,
742            merkle_root:            "sha256:abcd".into(),
743            leaf_count:             10,
744            journal_id:             "journal_1".into(),
745            created_at:             "2026-04-30T06:02:00Z".into(),
746            hub_id:                 String::new(),
747            hub_public_key:         String::new(),
748            hub_signature:          String::new(),
749            signed_at:              String::new(),
750            covered_use_ids:        Vec::new(),
751            covered_grant_ids:      Vec::new(),
752            previous_record_digest: "sha256:00".into(),
753            record_digest:          String::new(),
754            signature:              None,
755            signature_alg:          None,
756            signing_key_id:         None,
757        }
758    }
759
760    #[test]
761    fn checkpoint_record_digest_stable() {
762        let cp = sample_checkpoint(CheckpointKind::LocalJournal);
763        let d1 = journal_checkpoint_record_digest(&cp);
764        let d2 = journal_checkpoint_record_digest(&cp);
765        assert_eq!(d1, d2);
766    }
767
768    #[test]
769    fn checkpoint_kind_defaults_to_local_journal() {
770        // Pre-PR-6 records serialized without the field; deserialize
771        // must default to LocalJournal so existing PR 4 packages
772        // verify identically.
773        let json = r#"{"type":"treeship/journal-checkpoint/v1","checkpoint_id":"cp_legacy",
774            "from_record_index":1,"to_record_index":10,"merkle_root":"sha256:00",
775            "leaf_count":10,"journal_id":"j","created_at":"2026-04-30T00:00:00Z"}"#;
776        let cp: JournalCheckpoint = serde_json::from_str(json).unwrap();
777        assert_eq!(cp.checkpoint_kind, CheckpointKind::LocalJournal);
778        assert!(!cp.is_hub_signed());
779    }
780
781    #[test]
782    fn checkpoint_kind_serializes_kebab_case() {
783        let cp = sample_checkpoint(CheckpointKind::HubOrg);
784        let v = serde_json::to_value(&cp).unwrap();
785        assert_eq!(v["checkpoint_kind"], "hub-org");
786    }
787
788    #[test]
789    fn local_journal_checkpoint_is_not_hub_signed() {
790        let cp = sample_checkpoint(CheckpointKind::LocalJournal);
791        assert!(!cp.is_hub_signed());
792        assert_eq!(
793            verify_hub_checkpoint_signature(&cp),
794            HubCheckpointVerification::NotHubKind,
795        );
796    }
797
798    #[test]
799    fn hub_kind_without_fields_is_missing() {
800        let cp = sample_checkpoint(CheckpointKind::HubOrg);
801        assert!(!cp.is_hub_signed());
802        assert!(matches!(
803            verify_hub_checkpoint_signature(&cp),
804            HubCheckpointVerification::MissingFields(_),
805        ));
806    }
807
808    /// End-to-end: sign a Hub checkpoint with a real Ed25519 key,
809    /// embed the signature, verify it round-trips. The release rule
810    /// pins on this path: replay-hub-org cannot pass without a real
811    /// signature here.
812    #[test]
813    fn hub_checkpoint_signature_round_trip() {
814        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
815        use ed25519_dalek::{Signer, SigningKey};
816
817        let mut sk_bytes = [0u8; 32];
818        for (i, b) in sk_bytes.iter_mut().enumerate() {
819            *b = i as u8 + 7;
820        }
821        let sk = SigningKey::from_bytes(&sk_bytes);
822        let pk = sk.verifying_key();
823        let pk_b64 = URL_SAFE_NO_PAD.encode(pk.to_bytes());
824
825        let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
826        cp.hub_id          = "hub://zerker-org".into();
827        cp.hub_public_key  = pk_b64.clone();
828        cp.signed_at       = "2026-04-30T07:00:00Z".into();
829        cp.covered_use_ids = vec!["use_alpha".into(), "use_beta".into()];
830
831        let payload = cp.canonical_hub_signing_bytes();
832        let sig     = sk.sign(&payload);
833        cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
834
835        assert!(cp.is_hub_signed());
836        assert_eq!(
837            verify_hub_checkpoint_signature(&cp),
838            HubCheckpointVerification::Valid,
839        );
840    }
841
842    #[test]
843    fn tampered_hub_checkpoint_fails_verification() {
844        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
845        use ed25519_dalek::{Signer, SigningKey};
846
847        let sk = SigningKey::from_bytes(&[1u8; 32]);
848        let pk = sk.verifying_key();
849
850        let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
851        cp.hub_id          = "hub://x".into();
852        cp.hub_public_key  = URL_SAFE_NO_PAD.encode(pk.to_bytes());
853        cp.signed_at       = "2026-04-30T07:00:00Z".into();
854        cp.covered_use_ids = vec!["use_alpha".into()];
855
856        let sig = sk.sign(&cp.canonical_hub_signing_bytes());
857        cp.hub_signature = URL_SAFE_NO_PAD.encode(sig.to_bytes());
858        // Sanity: signature is good before tamper.
859        assert_eq!(verify_hub_checkpoint_signature(&cp), HubCheckpointVerification::Valid);
860
861        // Tamper with covered_use_ids -- now the canonical bytes
862        // change, signature no longer applies.
863        cp.covered_use_ids.push("use_smuggled".into());
864        assert_eq!(
865            verify_hub_checkpoint_signature(&cp),
866            HubCheckpointVerification::Tampered,
867        );
868    }
869
870    #[test]
871    fn wrong_key_fails_verification() {
872        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
873        use ed25519_dalek::{Signer, SigningKey};
874
875        let sk_real = SigningKey::from_bytes(&[2u8; 32]);
876        let sk_imp  = SigningKey::from_bytes(&[3u8; 32]); // different key
877        let pk_imp  = sk_imp.verifying_key();
878
879        let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
880        cp.hub_id          = "hub://x".into();
881        // Signature made by sk_real, but public key claims sk_imp.
882        cp.hub_public_key  = URL_SAFE_NO_PAD.encode(pk_imp.to_bytes());
883        cp.signed_at       = "2026-04-30T07:00:00Z".into();
884        let sig = sk_real.sign(&cp.canonical_hub_signing_bytes());
885        cp.hub_signature   = URL_SAFE_NO_PAD.encode(sig.to_bytes());
886        assert_eq!(
887            verify_hub_checkpoint_signature(&cp),
888            HubCheckpointVerification::Tampered,
889        );
890    }
891
892    #[test]
893    fn malformed_pubkey_or_signature_fails() {
894        let mut cp = sample_checkpoint(CheckpointKind::HubOrg);
895        cp.hub_id          = "hub://x".into();
896        cp.hub_public_key  = "not-base64!!".into();
897        cp.hub_signature   = "also-not-base64".into();
898        cp.signed_at       = "2026-04-30T07:00:00Z".into();
899        assert_eq!(
900            verify_hub_checkpoint_signature(&cp),
901            HubCheckpointVerification::Tampered,
902        );
903    }
904}