Skip to main content

polyc_crypto/
grant.rs

1//! Remembered-approval **grant** canonicals and their verification path.
2//!
3//! A grant is the highest-privilege artifact in the system: it clears an egress
4//! gate on an unattended run with no human present. ADR 0001 makes it
5//! unforgeable by the platform — the only signature that mints one comes from
6//! the principal's passkey (a `WebAuthn` assertion whose challenge binds the grant
7//! canonical), verified here against the public key registered at enrollment.
8//! **There is no code path that mints a signed grant from a platform ed25519
9//! key.** [`signed_grant_payload`] assembles the passkey-proof payload;
10//! [`verify_grant`] is the only acceptance path.
11//!
12//! Three canonicals live here, each with a distinct `kind` tag so the audit
13//! trail can never confuse them:
14//!
15//! * **grant** ([`grant_canonical`]) — key `(principal, routine, tool)` plus the
16//!   coverage record; passkey-signed.
17//! * **revocation** ([`revocation_canonical`]) — the principal's own withdrawal;
18//!   a fresh passkey assertion binding the grant's [`grant_ref`].
19//! * **suspension** ([`suspension_canonical`]) — the platform's act; ed25519,
20//!   the existing [`Signer`], binding the same [`grant_ref`], never requiring
21//!   the passkey (a lost passkey cannot strand a live grant).
22//!
23//! [`live_grant`] is the single fold that treats a grant with a matching
24//! verified revocation or suspension as absent — the one place "revoked or
25//! suspended ⇒ absent" lives.
26//!
27//! The canonical style mirrors [`crate::approval`] and [`crate::mandate`]
28//! (canonical JSON, a literal `kind` tag first). A revocation/suspension
29//! references the exact grant by [`grant_ref`] — the sha256 of the grant's full
30//! signed payload — the same chain-link pattern as [`crate::mandate::mandate_hash`].
31
32use serde::Serialize;
33use serde_json::Value;
34use sha2::{Digest, Sha256};
35
36use base64::Engine as _;
37use base64::engine::general_purpose::URL_SAFE_NO_PAD;
38
39use crate::signed::{Envelope, canonical_bytes};
40use crate::{Signer, verify};
41use polyc_passkey::derive::verify_derived_signature;
42use polyc_passkey::{Assertion, WebAuthnProof, challenge_b64, verify_assertion};
43
44/// Signed `kind` tag for a routine-grant canonical.
45pub const GRANT_KIND: &str = "grant.routine.v1";
46/// Signed `kind` tag for a revocation-tombstone canonical.
47pub const REVOCATION_KIND: &str = "grant.revocation.v1";
48/// Signed `kind` tag for a suspension canonical.
49pub const SUSPENSION_KIND: &str = "grant.suspension.v1";
50/// Signed `kind` tag for a routine-grant canonical signed by a persona's
51/// recorded signing credential rather than a per-enrollment `WebAuthn` passkey.
52///
53/// Deliberately distinct from [`GRANT_KIND`] — same kind-per-artifact
54/// discipline the module doc describes — so a persona-signed grant can never
55/// verify as, or be confused with, a `WebAuthn`-signed one in the audit
56/// trail (#1197).
57pub const GRANT_PERSONA_KIND: &str = "grant.routine.persona.v1";
58/// Signed `kind` tag for a persona-signed revocation tombstone.
59///
60/// Mirrors [`GRANT_PERSONA_KIND`]'s distinction for [`REVOCATION_KIND`] (#1197).
61pub const REVOCATION_PERSONA_KIND: &str = "grant.revocation.persona.v1";
62
63/// A persona-derived-key signature over a grant-family canonical (#1197): the
64/// signing key's public bytes plus its plain ed25519 signature.
65///
66/// The persona-signing sibling of [`WebAuthnProof`] — bundled the same way,
67/// so [`signed_grant_payload_persona`]/[`signed_revocation_payload_persona`]
68/// stay at the same argument count their `WebAuthn` siblings are.
69#[derive(Debug, Clone, Copy)]
70pub struct PersonaProof<'a> {
71    /// The persona's currently recorded `PersonaCredential.signing_public_key`.
72    pub signing_public_key: &'a [u8],
73    /// That key's plain ed25519 signature over the canonical directly (no
74    /// `WebAuthn` challenge/assertion wrapping).
75    pub signature: &'a [u8],
76}
77
78/// The coverage record a grant binds: the pinned destination, the slot-schema
79/// hash, and the covered capabilities.
80///
81/// Borrowed so a caller can build it from decoded fields without copying.
82/// `slot_schema_hash` is #618's template-coverage hash; `covered_capabilities`
83/// are stable kebab-case `polyc_capability` names (the consumer resolves them
84/// with `CapabilitySet::from_names`, which is fail-closed on unknown names).
85/// Changing the destination or the slot-schema hash changes the canonical, so a
86/// coverage drift voids the grant — a new approval ceremony is required.
87#[derive(Debug, Clone, Copy)]
88pub struct GrantCoverage<'a> {
89    /// The pinned egress destination the grant covers.
90    ///
91    /// **Canonicalization contract:** the compact JSON of the template's
92    /// destination map serialized from a `BTreeMap` (key-sorted, no
93    /// whitespace). Coverage equality is byte equality of this string, so the
94    /// mint side (the enrollment ceremony) and every verify side (the dispatch
95    /// resolver recomputing current coverage) MUST produce it the same way —
96    /// always `serde_json::to_string` of the `BTreeMap` destination, never a
97    /// hand-built string. A formatting-only difference reads as coverage drift
98    /// and voids the grant.
99    pub destination: &'a str,
100    /// The content-template slot-schema hash the grant covers (#618).
101    pub slot_schema_hash: &'a str,
102    /// The stable kebab-case capability names the grant covers.
103    pub covered_capabilities: &'a [String],
104}
105
106/// The canonical bytes the passkey challenge binds for a routine grant.
107///
108/// Key `(principal, routine, tool)` plus the coverage record, the
109/// `enrollment_id`, and `created_at_ms`. A byte-level change to any field
110/// changes these bytes, hence the [`challenge_b64`] the assertion must carry,
111/// so verification fails — this is the single source the ceremony (#621) and
112/// the gate (#594) both reproduce.
113#[must_use]
114pub fn grant_canonical(
115    principal: &str,
116    routine: &str,
117    tool: &str,
118    coverage: &GrantCoverage<'_>,
119    enrollment_id: &str,
120    created_at_ms: u64,
121) -> Vec<u8> {
122    canonical_bytes(&GrantCanonical {
123        kind: GRANT_KIND,
124        principal,
125        routine,
126        tool,
127        destination: coverage.destination,
128        slot_schema_hash: coverage.slot_schema_hash,
129        covered_capabilities: coverage.covered_capabilities,
130        enrollment_id,
131        created_at_ms,
132    })
133}
134
135/// The signed grant field set, in its frozen order.
136///
137/// Shared by [`grant_canonical`] and [`grant_canonical_persona`] — the two
138/// differ only in the `kind` tag, so one declaration governs both orders and
139/// they cannot drift apart. Declaration order IS the signed contract: it
140/// determines the [`grant_challenge`] a passkey assertion binds, so reordering
141/// a field voids every grant ever minted. See ADR 0009.
142#[derive(Serialize)]
143struct GrantCanonical<'a> {
144    kind: &'static str,
145    principal: &'a str,
146    routine: &'a str,
147    tool: &'a str,
148    destination: &'a str,
149    slot_schema_hash: &'a str,
150    covered_capabilities: &'a [String],
151    enrollment_id: &'a str,
152    created_at_ms: u64,
153}
154
155/// The signed revocation-tombstone field set, in its frozen order.
156///
157/// Shared by [`revocation_canonical`] and [`revocation_canonical_persona`],
158/// which differ only in the `kind` tag. See ADR 0009.
159#[derive(Serialize)]
160struct RevocationCanonical<'a> {
161    kind: &'static str,
162    grant_ref: &'a str,
163    revoked_at_ms: u64,
164}
165
166/// The signed suspension field set, in its frozen order.
167///
168/// The body [`suspension_payload`] seals into an [`Envelope`] — so the
169/// canonical the signature covers and the persisted payload share one
170/// declaration of the field list rather than the two hand-maintained copies
171/// this replaced (`#1845`). See ADR 0009.
172#[derive(Serialize)]
173struct SuspensionCanonical<'a> {
174    kind: &'static str,
175    grant_ref: &'a str,
176    reason: &'a str,
177    suspended_at_ms: u64,
178}
179
180/// The `WebAuthn` challenge a grant-family canonical binds:
181/// `base64url(sha256(canonical))` (url-safe, no pad).
182///
183/// The value the ceremony page passes to `navigator.credentials.get`, and what
184/// every verifier recomputes. A thin alias over [`challenge_b64`] named for the
185/// grant family — grants and revocations both derive their challenge here, so
186/// the derivation cannot drift between the two ceremonies.
187#[must_use]
188pub fn grant_challenge(canonical: &[u8]) -> String {
189    challenge_b64(canonical)
190}
191
192/// The sha256-hex of a grant's **full signed payload** — the reference a
193/// revocation or suspension binds.
194///
195/// Hashing the full payload (canonical fields *and* the assertion) commits to
196/// one exact, already-minted artifact, exactly as
197/// [`crate::mandate::mandate_hash`] does: a grant re-minted with a different
198/// assertion produces a different `grant_ref`, so a stale tombstone cannot void
199/// it. Callers (#594/#623) compute this over the stored payload bytes to match
200/// a tombstone/suspension to its grant.
201#[must_use]
202pub fn grant_ref(signed_grant_payload: &[u8]) -> String {
203    crate::hex::lower(&Sha256::digest(signed_grant_payload))
204}
205
206/// Assemble the full signed grant payload from the canonical fields and a
207/// verified-shape `WebAuthn` proof.
208///
209/// This is the **only** constructor of a signed grant (ADR 0001): the proof is
210/// a passkey assertion, never a platform signature. The ceremony (#621) calls
211/// this after [`verify_grant`] accepts the freshly minted assertion, then
212/// persists the returned bytes as the `routine_grant` event payload. The
213/// assertion fields are carried as url-safe base64.
214#[must_use]
215pub fn signed_grant_payload(
216    principal: &str,
217    routine: &str,
218    tool: &str,
219    coverage: &GrantCoverage<'_>,
220    enrollment_id: &str,
221    created_at_ms: u64,
222    proof: &WebAuthnProof<'_>,
223) -> Vec<u8> {
224    let canonical = grant_canonical(
225        principal,
226        routine,
227        tool,
228        coverage,
229        enrollment_id,
230        created_at_ms,
231    );
232    attach_proof(&canonical, proof)
233}
234
235/// Append a `WebAuthn` proof's four fields to a grant-family canonical,
236/// producing the full signed payload.
237///
238/// Parses `canonical` back into a JSON object and inserts
239/// `credential_id`/`authenticator_data`/`client_data_json`/`signature` (each
240/// url-safe base64, no padding), then reserializes. Both
241/// [`signed_grant_payload`] and [`signed_revocation_payload`] build their
242/// canonical fields once (via [`grant_canonical`] / [`revocation_canonical`])
243/// and delegate the proof-attachment step here, so a payload can never carry
244/// fields that diverge from its own canonical.
245///
246/// Byte-for-byte compatible with the previous single hand-written `json!`
247/// construction regardless of whether `serde_json`'s `preserve_order` feature
248/// is active for this build (this crate does not request it, but cargo
249/// feature unification can switch it on when another workspace member does):
250/// a `BTreeMap`-backed object always resorts keys alphabetically no matter
251/// the insertion order, and an order-preserving map always appends genuinely
252/// new keys at the end — so parse-then-insert reproduces exactly what
253/// building all fields in one macro call would have, under either
254/// configuration.
255///
256/// **That equivalence is per-configuration, not across configurations.** This
257/// function round-trips its input through a [`serde_json::Map`], so the bytes
258/// it returns still track whichever map type the build resolved: sorted keys
259/// under a lean selection, canonical-then-appended under `preserve_order`.
260/// `#1845` made the *canonical* build-graph-independent — which is what
261/// [`grant_challenge`] hashes, so passkey and persona verification are now
262/// independent of the build graph — but the assembled payload is not, and
263/// [`grant_ref`] is the sha256 of that payload. Nothing signs or verifies
264/// against the payload's own bytes, and no deployment has ever minted a grant
265/// (this module is unwired), so nothing is broken today. Because `grant_ref` is
266/// always taken over *stored* bytes, a minted grant keeps a stable ref forever;
267/// the only divergence is two binaries minting the same logical grant, which
268/// cannot happen for a once-minted artifact.
269///
270/// Sealing the proof fields through a declared struct the way
271/// [`suspension_payload`] does closes it, and `#1959` tracks that as a blocker
272/// on the grant ceremony rather than a follow-up to it: today there are zero
273/// durable bytes to preserve, so the fix is free, and the first mint makes it
274/// impossible forever.
275fn attach_proof(canonical: &[u8], proof: &WebAuthnProof<'_>) -> Vec<u8> {
276    // canonical-allow: a grant payload's key order tracks the build's map type,
277    // which is the residual `#1959` tracks. It is annotated rather than fixed
278    // here because nothing has ever minted a grant, so no bytes are at risk;
279    // see the doc comment above for why the fix must precede the first mint.
280    let mut map: serde_json::Map<String, Value> =
281        serde_json::from_slice(canonical).expect("a grant-family canonical is a JSON object");
282    map.insert(
283        "credential_id".to_owned(),
284        Value::String(b64(proof.credential_id)),
285    );
286    map.insert(
287        "authenticator_data".to_owned(),
288        Value::String(b64(proof.authenticator_data)),
289    );
290    map.insert(
291        "client_data_json".to_owned(),
292        Value::String(b64(proof.client_data_json)),
293    );
294    map.insert("signature".to_owned(), Value::String(b64(proof.signature)));
295    Value::Object(map).to_string().into_bytes() // canonical-allow: see `#1959` above
296}
297
298/// Verify the passkey proof carried in a grant-family payload against the
299/// canonical it claims to bind.
300///
301/// The shared verification skeleton of [`verify_grant`] and
302/// [`verify_revocation`]: base64-decode the three assertion fields off the
303/// payload object, derive the expected challenge from `canonical`, and run
304/// [`verify_assertion`]. Each public verifier keeps only its own kind check,
305/// field extraction, and canonical rebuild. Fail-closed: `None` on any
306/// missing field, bad base64, or assertion failure. [`verify_assertion`]'s
307/// `sign_count` is discarded here — this crate enforces no counter policy
308/// today; a future consumer that wants it reads [`verify_assertion`] directly.
309fn verify_passkey_proof(
310    v: &Value,
311    canonical: &[u8],
312    registered_pubkey_sec1: &[u8],
313    rp_id: &str,
314    origin: &str,
315) -> Option<()> {
316    let authenticator_data = b64_decode(v.get("authenticator_data")?.as_str()?)?;
317    let client_data_json = b64_decode(v.get("client_data_json")?.as_str()?)?;
318    let signature = b64_decode(v.get("signature")?.as_str()?)?;
319    let assertion = Assertion {
320        authenticator_data: &authenticator_data,
321        client_data_json: &client_data_json,
322        signature: &signature,
323    };
324    verify_assertion(
325        registered_pubkey_sec1,
326        &assertion,
327        &grant_challenge(canonical),
328        rp_id,
329        origin,
330    )
331    .ok()
332    .map(|_checks| ())
333}
334
335/// Append a [`PersonaProof`]'s two fields to a grant-family canonical,
336/// producing the full signed payload.
337///
338/// The persona-signing sibling of [`attach_proof`]: a persona-derived key
339/// signs the canonical directly (a plain ed25519 signature, no `WebAuthn`
340/// assertion wrapping — the `WebAuthn` ceremony already ran once, at
341/// `PersonaCredential` enrollment, to bind this key), so there is no
342/// `authenticator_data`/`client_data_json` to carry, only the raw signature
343/// and the signing key it claims to be from (#1197). Shares
344/// [`attach_proof`]'s per-configuration byte-equivalence caveat.
345fn attach_persona_proof(canonical: &[u8], proof: &PersonaProof<'_>) -> Vec<u8> {
346    // canonical-allow: the same residual as `attach_proof`, tracked by `#1959`.
347    let mut map: serde_json::Map<String, Value> =
348        serde_json::from_slice(canonical).expect("a grant-family canonical is a JSON object");
349    map.insert(
350        "signing_public_key".to_owned(),
351        Value::String(b64(proof.signing_public_key)),
352    );
353    map.insert("signature".to_owned(), Value::String(b64(proof.signature)));
354    Value::Object(map).to_string().into_bytes() // canonical-allow: see `#1959` above
355}
356
357/// Verify the persona-signed proof carried in a grant-family payload against
358/// the canonical it claims to bind.
359///
360/// The persona-signing sibling of [`verify_passkey_proof`]: decodes
361/// `signing_public_key`/`signature`, checks the payload's claimed signing key
362/// equals `expected_persona_pubkey` (the SAME pinning discipline
363/// [`verify_suspension`]'s `expected_platform_key` uses — a payload's
364/// self-claimed key is never trusted on its own, only a caller-supplied
365/// expected one), then verifies the signature via
366/// [`verify_derived_signature`] — a plain ed25519 check, matching exactly
367/// what a browser's PRF-derived key actually signs with (see
368/// [`verify_derived_signature`]'s own docs on why this must NOT be
369/// `commonware_cryptography`'s namespaced verify). Fail-closed: `None` on any
370/// missing field, bad base64, key mismatch, or signature mismatch.
371fn verify_persona_proof(v: &Value, canonical: &[u8], expected_persona_pubkey: &[u8]) -> Option<()> {
372    let signing_public_key = b64_decode(v.get("signing_public_key")?.as_str()?)?;
373    let signature = b64_decode(v.get("signature")?.as_str()?)?;
374    if signing_public_key != expected_persona_pubkey {
375        return None;
376    }
377    if verify_derived_signature(&signing_public_key, canonical, &signature) {
378        Some(())
379    } else {
380        None
381    }
382}
383
384/// The canonical bytes a persona-signed grant's signature binds.
385///
386/// The persona-signing sibling of [`grant_canonical`], identical fields,
387/// distinguished only by [`GRANT_PERSONA_KIND`] so the two can never verify
388/// as each other (#1197).
389#[must_use]
390pub fn grant_canonical_persona(
391    principal: &str,
392    routine: &str,
393    tool: &str,
394    coverage: &GrantCoverage<'_>,
395    enrollment_id: &str,
396    created_at_ms: u64,
397) -> Vec<u8> {
398    canonical_bytes(&GrantCanonical {
399        kind: GRANT_PERSONA_KIND,
400        principal,
401        routine,
402        tool,
403        destination: coverage.destination,
404        slot_schema_hash: coverage.slot_schema_hash,
405        covered_capabilities: coverage.covered_capabilities,
406        enrollment_id,
407        created_at_ms,
408    })
409}
410
411/// Assemble the full signed grant payload from the canonical fields and a
412/// [`PersonaProof`].
413///
414/// The persona-signing sibling of [`signed_grant_payload`] (#1197):
415/// `proof.signing_public_key` is the persona's currently recorded
416/// `PersonaCredential.signing_public_key`; `proof.signature` is that key's
417/// plain ed25519 signature over [`grant_canonical_persona`]'s bytes directly
418/// (no `WebAuthn` challenge/assertion wrapping).
419#[must_use]
420pub fn signed_grant_payload_persona(
421    principal: &str,
422    routine: &str,
423    tool: &str,
424    coverage: &GrantCoverage<'_>,
425    enrollment_id: &str,
426    created_at_ms: u64,
427    proof: &PersonaProof<'_>,
428) -> Vec<u8> {
429    let canonical = grant_canonical_persona(
430        principal,
431        routine,
432        tool,
433        coverage,
434        enrollment_id,
435        created_at_ms,
436    );
437    attach_persona_proof(&canonical, proof)
438}
439
440/// Verify a persisted persona-signed `routine_grant` payload.
441///
442/// The persona-signing sibling of [`verify_grant`] (#1197). Recomputes
443/// [`grant_canonical_persona`] from the payload's own fields and verifies the
444/// persona proof against `expected_persona_pubkey` — the
445/// persona's CURRENTLY recorded `PersonaCredential.signing_public_key`,
446/// looked up live by the caller (never trusted from the payload itself, and
447/// never snapshotted at grant-mint time) so a later credential revocation is
448/// reflected on every subsequent verification of every grant that credential
449/// ever signed — the cascading-revocation property #1197 intentionally
450/// chose over per-grant-independent revocation. Returns `None` fail-closed
451/// on any malformed field, bad base64, key mismatch, or signature failure.
452#[must_use]
453pub fn verify_persona_signed_grant(
454    payload: &[u8],
455    expected_persona_pubkey: &[u8],
456) -> Option<VerifiedGrant> {
457    let v: Value = serde_json::from_slice(payload).ok()?;
458    if v.get("kind")?.as_str()? != GRANT_PERSONA_KIND {
459        return None;
460    }
461    let principal = v.get("principal")?.as_str()?.to_owned();
462    let routine = v.get("routine")?.as_str()?.to_owned();
463    let tool = v.get("tool")?.as_str()?.to_owned();
464    let destination = v.get("destination")?.as_str()?.to_owned();
465    let slot_schema_hash = v.get("slot_schema_hash")?.as_str()?.to_owned();
466    let covered_capabilities: Vec<String> = v
467        .get("covered_capabilities")?
468        .as_array()?
469        .iter()
470        .map(|c| c.as_str().map(str::to_owned))
471        .collect::<Option<Vec<_>>>()?;
472    let enrollment_id = v.get("enrollment_id")?.as_str()?.to_owned();
473    let created_at_ms = v.get("created_at_ms")?.as_u64()?;
474
475    let coverage = GrantCoverage {
476        destination: &destination,
477        slot_schema_hash: &slot_schema_hash,
478        covered_capabilities: &covered_capabilities,
479    };
480    let canonical = grant_canonical_persona(
481        &principal,
482        &routine,
483        &tool,
484        &coverage,
485        &enrollment_id,
486        created_at_ms,
487    );
488    verify_persona_proof(&v, &canonical, expected_persona_pubkey)?;
489
490    Some(VerifiedGrant {
491        principal,
492        routine,
493        tool,
494        destination,
495        slot_schema_hash,
496        covered_capabilities,
497        enrollment_id,
498        created_at_ms,
499        grant_ref: grant_ref(payload),
500        registered_public_key: expected_persona_pubkey.to_vec(),
501    })
502}
503
504/// A grant whose passkey assertion verified against the registered key.
505#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct VerifiedGrant {
507    /// The principal the grant is scoped to (a persona id — matched against the
508    /// turn's caller for isolation, the same rule as
509    /// [`crate::approval::is_session_grant_for`]).
510    pub principal: String,
511    /// The routine the grant belongs to.
512    pub routine: String,
513    /// The tool the grant covers.
514    pub tool: String,
515    /// The pinned egress destination the grant covers.
516    pub destination: String,
517    /// The content-template slot-schema hash the grant covers.
518    pub slot_schema_hash: String,
519    /// The stable kebab-case capability names the grant covers.
520    pub covered_capabilities: Vec<String>,
521    /// The enrollment this grant was minted in.
522    pub enrollment_id: String,
523    /// Milliseconds since the Unix epoch the grant was created.
524    pub created_at_ms: u64,
525    /// The sha256-hex of the full signed payload — the [`grant_ref`] a
526    /// revocation or suspension binds.
527    pub grant_ref: String,
528    /// The SEC1 registered public key the assertion verified against.
529    pub registered_public_key: Vec<u8>,
530}
531
532/// Verify a persisted `routine_grant` payload against a registered passkey.
533///
534/// Recomputes the grant canonical from the payload's own fields, derives the
535/// expected [`grant_challenge`], and runs [`verify_assertion`] against
536/// `registered_pubkey_sec1` for `rp_id` / `origin`. A byte-level change to ANY
537/// canonical-bound field changes the challenge and fails the assertion; a grant
538/// signed by a different passkey, or verified against a different registered
539/// key, fails. Returns `None` fail-closed on any malformed field, bad base64,
540/// or verification failure.
541#[must_use]
542pub fn verify_grant(
543    payload: &[u8],
544    registered_pubkey_sec1: &[u8],
545    rp_id: &str,
546    origin: &str,
547) -> Option<VerifiedGrant> {
548    let v: Value = serde_json::from_slice(payload).ok()?;
549    if v.get("kind")?.as_str()? != GRANT_KIND {
550        return None;
551    }
552    let principal = v.get("principal")?.as_str()?.to_owned();
553    let routine = v.get("routine")?.as_str()?.to_owned();
554    let tool = v.get("tool")?.as_str()?.to_owned();
555    let destination = v.get("destination")?.as_str()?.to_owned();
556    let slot_schema_hash = v.get("slot_schema_hash")?.as_str()?.to_owned();
557    let covered_capabilities: Vec<String> = v
558        .get("covered_capabilities")?
559        .as_array()?
560        .iter()
561        .map(|c| c.as_str().map(str::to_owned))
562        .collect::<Option<Vec<_>>>()?;
563    let enrollment_id = v.get("enrollment_id")?.as_str()?.to_owned();
564    let created_at_ms = v.get("created_at_ms")?.as_u64()?;
565
566    let coverage = GrantCoverage {
567        destination: &destination,
568        slot_schema_hash: &slot_schema_hash,
569        covered_capabilities: &covered_capabilities,
570    };
571    let canonical = grant_canonical(
572        &principal,
573        &routine,
574        &tool,
575        &coverage,
576        &enrollment_id,
577        created_at_ms,
578    );
579    verify_passkey_proof(&v, &canonical, registered_pubkey_sec1, rp_id, origin)?;
580
581    Some(VerifiedGrant {
582        principal,
583        routine,
584        tool,
585        destination,
586        slot_schema_hash,
587        covered_capabilities,
588        enrollment_id,
589        created_at_ms,
590        grant_ref: grant_ref(payload),
591        registered_public_key: registered_pubkey_sec1.to_vec(),
592    })
593}
594
595/// The canonical bytes a revocation tombstone's passkey challenge binds.
596///
597/// Binds the [`grant_ref`] of the grant being withdrawn and the revocation
598/// time. A distinct `kind` tag ([`REVOCATION_KIND`]) keeps it separable from a
599/// grant in the audit trail.
600#[must_use]
601pub fn revocation_canonical(grant_ref: &str, revoked_at_ms: u64) -> Vec<u8> {
602    canonical_bytes(&RevocationCanonical {
603        kind: REVOCATION_KIND,
604        grant_ref,
605        revoked_at_ms,
606    })
607}
608
609/// Assemble the full signed revocation payload from a `WebAuthn` proof.
610///
611/// The principal's own act (ADR 0001 / CONTEXT.md): a fresh passkey assertion
612/// over the revocation's own [`grant_challenge`]. Symmetric with
613/// [`signed_grant_payload`].
614#[must_use]
615pub fn signed_revocation_payload(
616    grant_ref: &str,
617    revoked_at_ms: u64,
618    proof: &WebAuthnProof<'_>,
619) -> Vec<u8> {
620    let canonical = revocation_canonical(grant_ref, revoked_at_ms);
621    attach_proof(&canonical, proof)
622}
623
624/// A revocation tombstone whose passkey assertion verified.
625#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct VerifiedRevocation {
627    /// The [`grant_ref`] this tombstone voids.
628    pub grant_ref: String,
629    /// Milliseconds since the Unix epoch the grant was revoked.
630    pub revoked_at_ms: u64,
631    /// The SEC1 registered public key the assertion verified against.
632    pub registered_public_key: Vec<u8>,
633}
634
635/// Verify a persisted `grant_revocation` payload against a registered passkey.
636///
637/// Recomputes the revocation canonical, derives the expected challenge, and
638/// runs [`verify_assertion`]. Returns `None` fail-closed on any malformed
639/// field, bad base64, or verification failure — including a tombstone signed by
640/// a passkey other than the one that minted the grant (verified against the
641/// caller-supplied registered key).
642#[must_use]
643pub fn verify_revocation(
644    payload: &[u8],
645    registered_pubkey_sec1: &[u8],
646    rp_id: &str,
647    origin: &str,
648) -> Option<VerifiedRevocation> {
649    let v: Value = serde_json::from_slice(payload).ok()?;
650    if v.get("kind")?.as_str()? != REVOCATION_KIND {
651        return None;
652    }
653    let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
654    let revoked_at_ms = v.get("revoked_at_ms")?.as_u64()?;
655
656    let canonical = revocation_canonical(&grant_ref, revoked_at_ms);
657    verify_passkey_proof(&v, &canonical, registered_pubkey_sec1, rp_id, origin)?;
658
659    Some(VerifiedRevocation {
660        grant_ref,
661        revoked_at_ms,
662        registered_public_key: registered_pubkey_sec1.to_vec(),
663    })
664}
665
666/// The canonical bytes a persona-signed revocation tombstone's signature binds.
667///
668/// The persona-signing sibling of [`revocation_canonical`], identical
669/// fields, distinguished only by [`REVOCATION_PERSONA_KIND`] (#1197).
670#[must_use]
671pub fn revocation_canonical_persona(grant_ref: &str, revoked_at_ms: u64) -> Vec<u8> {
672    canonical_bytes(&RevocationCanonical {
673        kind: REVOCATION_PERSONA_KIND,
674        grant_ref,
675        revoked_at_ms,
676    })
677}
678
679/// Assemble the full signed revocation payload from a [`PersonaProof`].
680///
681/// The persona-signing sibling of [`signed_revocation_payload`] (#1197).
682/// Symmetric with [`signed_grant_payload_persona`].
683#[must_use]
684pub fn signed_revocation_payload_persona(
685    grant_ref: &str,
686    revoked_at_ms: u64,
687    proof: &PersonaProof<'_>,
688) -> Vec<u8> {
689    let canonical = revocation_canonical_persona(grant_ref, revoked_at_ms);
690    attach_persona_proof(&canonical, proof)
691}
692
693/// Verify a persisted persona-signed `grant_revocation` payload.
694///
695/// The persona-signing sibling of [`verify_revocation`] (#1197).
696/// `expected_persona_pubkey` is the persona's CURRENTLY recorded signing
697/// key, looked up live by the caller — same cascading-revocation rationale
698/// as [`verify_persona_signed_grant`]'s docs.
699#[must_use]
700pub fn verify_persona_signed_revocation(
701    payload: &[u8],
702    expected_persona_pubkey: &[u8],
703) -> Option<VerifiedRevocation> {
704    let v: Value = serde_json::from_slice(payload).ok()?;
705    if v.get("kind")?.as_str()? != REVOCATION_PERSONA_KIND {
706        return None;
707    }
708    let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
709    let revoked_at_ms = v.get("revoked_at_ms")?.as_u64()?;
710
711    let canonical = revocation_canonical_persona(&grant_ref, revoked_at_ms);
712    verify_persona_proof(&v, &canonical, expected_persona_pubkey)?;
713
714    Some(VerifiedRevocation {
715        grant_ref,
716        revoked_at_ms,
717        registered_public_key: expected_persona_pubkey.to_vec(),
718    })
719}
720
721/// The canonical (signature-covered) bytes of a suspension.
722///
723/// Binds the [`grant_ref`], the `reason`, and the suspension time under a
724/// distinct `kind` tag ([`SUSPENSION_KIND`]) — so a suspension and a revocation
725/// can never be confused in the audit trail even though both bind a `grant_ref`.
726#[must_use]
727pub fn suspension_canonical(grant_ref: &str, reason: &str, suspended_at_ms: u64) -> Vec<u8> {
728    canonical_bytes(&suspension_fields(grant_ref, reason, suspended_at_ms))
729}
730
731/// The suspension body both [`suspension_canonical`] and
732/// [`suspension_payload`] build, so the signed field list exists once.
733const fn suspension_fields<'a>(
734    grant_ref: &'a str,
735    reason: &'a str,
736    suspended_at_ms: u64,
737) -> SuspensionCanonical<'a> {
738    SuspensionCanonical {
739        kind: SUSPENSION_KIND,
740        grant_ref,
741        reason,
742        suspended_at_ms,
743    }
744}
745
746/// Build a platform-signed (ed25519) suspension payload.
747///
748/// The platform's act, never the principal's (CONTEXT.md): it reuses the
749/// existing [`Signer`] and NEVER requires the passkey, so a lost passkey cannot
750/// strand a live grant. `signed_by` / `signature_hex` are appended after
751/// signing and are not covered. Returns
752/// `(full_payload_bytes, signature_bytes, public_key_bytes)`.
753#[must_use]
754pub fn suspension_payload(
755    grant_ref: &str,
756    reason: &str,
757    suspended_at_ms: u64,
758    signer: &Signer,
759) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
760    Envelope::seal(
761        suspension_fields(grant_ref, reason, suspended_at_ms),
762        signer,
763    )
764}
765
766/// A suspension whose ed25519 signature verified.
767#[derive(Debug, Clone, PartialEq, Eq)]
768pub struct VerifiedSuspension {
769    /// The [`grant_ref`] this suspension voids.
770    pub grant_ref: String,
771    /// The platform's audit reason for the suspension.
772    pub reason: String,
773    /// Milliseconds since the Unix epoch the grant was suspended.
774    pub suspended_at_ms: u64,
775    /// The verified platform signer's public key (encoded).
776    pub signer_public_key: Vec<u8>,
777}
778
779/// Verify a persisted `grant_suspension` payload against the deployment's
780/// pinned platform verification key.
781///
782/// **Trust model:** the caller supplies `expected_platform_key` — the
783/// deployment's platform verification key, the one the operator's [`Signer`]
784/// corresponds to (never taken from the payload). After decoding, this
785/// requires the payload's own `signed_by` key bytes to equal
786/// `expected_platform_key` exactly (byte equality; not constant-time, since a
787/// public key carries no secret worth guarding against a timing side channel)
788/// **before** running the ed25519 verify. A suspension signed by any other
789/// key — including a well-formed, self-consistent one from a disposable
790/// keypair an attacker controls — is dropped fail-closed: without the pin, a
791/// suspension payload carries both its own pubkey and signature, so anyone who
792/// can get a suspension-shaped payload accepted could forge one that verifies
793/// against itself. Pinning is what makes the audit trail's "the platform did
794/// this" claim actually true.
795///
796/// Rebuilds the suspension canonical and checks the embedded signature via the
797/// existing [`verify`]. Returns `None` fail-closed on any malformed field, bad
798/// hex, key mismatch, or signature mismatch. The `kind` tag is checked first so
799/// a revocation or grant payload can never verify as a suspension.
800#[must_use]
801pub fn verify_suspension(
802    payload: &[u8],
803    expected_platform_key: &[u8],
804) -> Option<VerifiedSuspension> {
805    let v: Value = serde_json::from_slice(payload).ok()?;
806    if v.get("kind")?.as_str()? != SUSPENSION_KIND {
807        return None;
808    }
809    let grant_ref = v.get("grant_ref")?.as_str()?.to_owned();
810    let reason = v.get("reason")?.as_str()?.to_owned();
811    let suspended_at_ms = v.get("suspended_at_ms")?.as_u64()?;
812    let pk = crate::hex::decode(v.get("signed_by")?.as_str()?)?;
813    let sig = crate::hex::decode(v.get("signature_hex")?.as_str()?)?;
814    if pk != expected_platform_key {
815        return None;
816    }
817    let canonical = suspension_canonical(&grant_ref, &reason, suspended_at_ms);
818    if verify(&pk, &canonical, &sig) {
819        Some(VerifiedSuspension {
820            grant_ref,
821            reason,
822            suspended_at_ms,
823            signer_public_key: pk,
824        })
825    } else {
826        None
827    }
828}
829
830/// The single fold that decides which grant is live.
831///
832/// Returns the first grant with no matching verified revocation or suspension
833/// (matched by [`grant_ref`]); a grant with either is absent. This is the ONE
834/// place "revoked or suspended ⇒ absent" lives — #594 (the gate) and #623
835/// (re-gating) call it, never reimplement it — so the two paths cannot drift on
836/// what counts as live.
837#[must_use]
838pub fn live_grant<'a>(
839    grants: &'a [VerifiedGrant],
840    revocations: &[VerifiedRevocation],
841    suspensions: &[VerifiedSuspension],
842) -> Option<&'a VerifiedGrant> {
843    grants.iter().find(|g| {
844        !revocations.iter().any(|r| r.grant_ref == g.grant_ref)
845            && !suspensions.iter().any(|s| s.grant_ref == g.grant_ref)
846    })
847}
848
849fn b64(bytes: &[u8]) -> String {
850    URL_SAFE_NO_PAD.encode(bytes)
851}
852
853fn b64_decode(s: &str) -> Option<Vec<u8>> {
854    URL_SAFE_NO_PAD.decode(s).ok()
855}
856
857#[cfg(test)]
858mod tests {
859    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
860
861    use super::*;
862    use polyc_passkey::softkey::SoftPasskey;
863
864    const RP_ID: &str = "enroll.polychrome.test";
865    const ORIGIN: &str = "https://enroll.polychrome.test";
866
867    fn caps() -> Vec<String> {
868        vec![
869            "arbitrary-egress".to_owned(),
870            "fixed-connector-read".to_owned(),
871        ]
872    }
873
874    // Mint a full signed grant with a SoftPasskey over its own challenge — the
875    // exact assemble-then-persist flow the ceremony (#621) runs.
876    fn mint_grant(key: &SoftPasskey) -> Vec<u8> {
877        let covered = caps();
878        let coverage = GrantCoverage {
879            destination: "slack:T1:C42",
880            slot_schema_hash: "sha256:template-abc",
881            covered_capabilities: &covered,
882        };
883        let canonical = grant_canonical(
884            "persona-9",
885            "standup",
886            "post_summary",
887            &coverage,
888            "enr-1",
889            1_700_000_000_000,
890        );
891        let challenge = grant_challenge(&canonical);
892        let signed = key.sign_assertion(&challenge, RP_ID, ORIGIN);
893        let proof = WebAuthnProof {
894            credential_id: &signed.credential_id,
895            authenticator_data: &signed.authenticator_data,
896            client_data_json: &signed.client_data_json,
897            signature: &signed.signature,
898        };
899        signed_grant_payload(
900            "persona-9",
901            "standup",
902            "post_summary",
903            &coverage,
904            "enr-1",
905            1_700_000_000_000,
906            &proof,
907        )
908    }
909
910    #[test]
911    fn grant_round_trips_and_carries_coverage() {
912        let key = SoftPasskey::from_seed(1);
913        let payload = mint_grant(&key);
914        let g = verify_grant(&payload, &key.public_key_sec1(), RP_ID, ORIGIN).expect("verifies");
915        assert_eq!(g.principal, "persona-9");
916        assert_eq!(g.routine, "standup");
917        assert_eq!(g.tool, "post_summary");
918        assert_eq!(g.destination, "slack:T1:C42");
919        assert_eq!(g.slot_schema_hash, "sha256:template-abc");
920        assert_eq!(g.covered_capabilities, caps());
921        assert_eq!(g.enrollment_id, "enr-1");
922        assert_eq!(g.grant_ref, grant_ref(&payload));
923    }
924
925    #[test]
926    fn grant_signed_by_a_different_passkey_is_dropped() {
927        let signer = SoftPasskey::from_seed(1);
928        let attacker = SoftPasskey::from_seed(2);
929        let payload = mint_grant(&signer);
930        // Verified against the attacker's registered key ⇒ fail-closed.
931        assert!(verify_grant(&payload, &attacker.public_key_sec1(), RP_ID, ORIGIN).is_none());
932        // And the genuine key still verifies (control).
933        assert!(verify_grant(&payload, &signer.public_key_sec1(), RP_ID, ORIGIN).is_some());
934    }
935
936    #[test]
937    fn every_canonical_field_including_kind_is_tamper_evident() {
938        let key = SoftPasskey::from_seed(3);
939        let payload = mint_grant(&key);
940        let pk = key.public_key_sec1();
941        // Tampering any canonical-bound field changes the challenge the
942        // assertion must carry, so verification fails. The proof fields are
943        // covered indirectly (a swapped assertion won't match the challenge).
944        // `kind` is the one exception: it fails its own explicit equality
945        // check in verify_grant before the challenge is ever recomputed, so a
946        // grant payload relabeled as a revocation (or any other kind) is
947        // rejected on that check instead — still covered by this table so the
948        // tamper coverage reads as "every canonical field", not "every field
949        // except kind".
950        for (field, val) in [
951            ("kind", serde_json::json!(REVOCATION_KIND)),
952            ("principal", serde_json::json!("persona-EVIL")),
953            ("routine", serde_json::json!("other-routine")),
954            ("tool", serde_json::json!("exfiltrate")),
955            ("destination", serde_json::json!("slack:T1:CEVIL")),
956            ("slot_schema_hash", serde_json::json!("sha256:tampered")),
957            (
958                "covered_capabilities",
959                serde_json::json!(["arbitrary-egress", "mutate-external"]),
960            ),
961            ("enrollment_id", serde_json::json!("enr-2")),
962            ("created_at_ms", serde_json::json!(1_700_000_000_001u64)),
963        ] {
964            let mut v: Value = serde_json::from_slice(&payload).unwrap();
965            v[field] = val;
966            assert!(
967                verify_grant(v.to_string().as_bytes(), &pk, RP_ID, ORIGIN).is_none(),
968                "tampered {field} must fail verification"
969            );
970        }
971        // Untampered still verifies.
972        assert!(verify_grant(&payload, &pk, RP_ID, ORIGIN).is_some());
973    }
974
975    #[test]
976    fn grant_ref_is_sensitive_to_the_full_payload() {
977        let key = SoftPasskey::from_seed(4);
978        let a = mint_grant(&key);
979        assert_eq!(grant_ref(&a), grant_ref(&a));
980        // A different key mints a different assertion ⇒ a different grant_ref,
981        // since it hashes the full signed artifact.
982        let b = mint_grant(&SoftPasskey::from_seed(5));
983        assert_ne!(grant_ref(&a), grant_ref(&b));
984    }
985
986    fn mint_revocation(key: &SoftPasskey, grant_ref_hex: &str, revoked_at_ms: u64) -> Vec<u8> {
987        let canonical = revocation_canonical(grant_ref_hex, revoked_at_ms);
988        let challenge = grant_challenge(&canonical);
989        let signed = key.sign_assertion(&challenge, RP_ID, ORIGIN);
990        let proof = WebAuthnProof {
991            credential_id: &signed.credential_id,
992            authenticator_data: &signed.authenticator_data,
993            client_data_json: &signed.client_data_json,
994            signature: &signed.signature,
995        };
996        signed_revocation_payload(grant_ref_hex, revoked_at_ms, &proof)
997    }
998
999    #[test]
1000    fn revocation_round_trips_and_binds_the_grant_ref() {
1001        let key = SoftPasskey::from_seed(6);
1002        let payload = mint_grant(&key);
1003        let gref = grant_ref(&payload);
1004        let rev = mint_revocation(&key, &gref, 1_700_000_100_000);
1005        let v = verify_revocation(&rev, &key.public_key_sec1(), RP_ID, ORIGIN).expect("verifies");
1006        assert_eq!(v.grant_ref, gref);
1007        assert_eq!(v.revoked_at_ms, 1_700_000_100_000);
1008        // A tombstone signed by a different passkey is rejected.
1009        assert!(
1010            verify_revocation(
1011                &rev,
1012                &SoftPasskey::from_seed(7).public_key_sec1(),
1013                RP_ID,
1014                ORIGIN
1015            )
1016            .is_none()
1017        );
1018    }
1019
1020    #[test]
1021    fn revocation_tampered_grant_ref_fails() {
1022        let key = SoftPasskey::from_seed(6);
1023        let rev = mint_revocation(&key, &"0".repeat(64), 1);
1024        let mut v: Value = serde_json::from_slice(&rev).unwrap();
1025        v["grant_ref"] = serde_json::json!("f".repeat(64));
1026        assert!(
1027            verify_revocation(
1028                v.to_string().as_bytes(),
1029                &key.public_key_sec1(),
1030                RP_ID,
1031                ORIGIN
1032            )
1033            .is_none()
1034        );
1035    }
1036
1037    #[test]
1038    fn suspension_round_trips_and_is_tamper_evident() {
1039        let signer = Signer::from_seed(1);
1040        let platform_key = signer.public_key_bytes();
1041        let (payload, _sig, _pk) = suspension_payload(
1042            "deadbeef",
1043            "misbehaving routine",
1044            1_700_000_200_000,
1045            &signer,
1046        );
1047        let v = verify_suspension(&payload, &platform_key).expect("verifies");
1048        assert_eq!(v.grant_ref, "deadbeef");
1049        assert_eq!(v.reason, "misbehaving routine");
1050        assert_eq!(v.suspended_at_ms, 1_700_000_200_000);
1051        for (field, val) in [
1052            ("grant_ref", serde_json::json!("cafe")),
1053            ("reason", serde_json::json!("something else")),
1054            ("suspended_at_ms", serde_json::json!(0u64)),
1055        ] {
1056            let mut t: Value = serde_json::from_slice(&payload).unwrap();
1057            t[field] = val;
1058            assert!(
1059                verify_suspension(t.to_string().as_bytes(), &platform_key).is_none(),
1060                "tampered {field} must fail"
1061            );
1062        }
1063    }
1064
1065    #[test]
1066    fn suspension_signed_by_a_key_other_than_the_expected_platform_key_is_dropped() {
1067        // A suspension shaped payload carries BOTH its signer's pubkey and
1068        // signature; without pinning, that pubkey+signature are always
1069        // self-consistent no matter which key minted them. Anyone who can get
1070        // a grant_suspension-shaped payload accepted could forge one with a
1071        // disposable keypair. Pinning `expected_platform_key` closes that:
1072        // a suspension signed by any key other than the deployment's platform
1073        // key must be dropped fail-closed, even though it verifies fine
1074        // against its own embedded key.
1075        let forger = Signer::from_seed(99);
1076        let real_platform = Signer::from_seed(1);
1077        let (payload, _sig, _pk) =
1078            suspension_payload("deadbeef", "misbehaving routine", 1, &forger);
1079        assert!(verify_suspension(&payload, &real_platform.public_key_bytes()).is_none());
1080    }
1081
1082    #[test]
1083    fn suspension_signed_by_the_expected_platform_key_round_trips() {
1084        let platform = Signer::from_seed(1);
1085        let (payload, _sig, _pk) =
1086            suspension_payload("deadbeef", "misbehaving routine", 1, &platform);
1087        assert!(verify_suspension(&payload, &platform.public_key_bytes()).is_some());
1088    }
1089
1090    #[test]
1091    fn live_grant_ignores_a_wrong_key_suspension_and_leaves_the_grant_live() {
1092        // Fail-closed cuts the OTHER way here than for grants/revocations: a
1093        // forged suspension (real canonical fields, wrong signer) must be
1094        // IGNORED, not honored, so the grant stays live. Honoring it would let
1095        // an attacker holding a disposable keypair suspend (DoS) any grant it
1096        // can guess a grant_ref for. The pinned check's job isn't to protect
1097        // availability of the grant — it's to keep the audit trail's "the
1098        // platform acted" claim true: a suspension record must only ever
1099        // originate from the platform's actual key, or it doesn't count.
1100        let key = SoftPasskey::from_seed(20);
1101        let payload = mint_grant(&key);
1102        let pk = key.public_key_sec1();
1103        let grant = verify_grant(&payload, &pk, RP_ID, ORIGIN).unwrap();
1104        let grants = vec![grant.clone()];
1105
1106        let forger = Signer::from_seed(99);
1107        let real_platform = Signer::from_seed(1);
1108        let (susp_payload, _s, _p) =
1109            suspension_payload(&grant.grant_ref, "kill switch", 1, &forger);
1110        // The forged suspension does not verify against the real platform key.
1111        assert!(verify_suspension(&susp_payload, &real_platform.public_key_bytes()).is_none());
1112        // So no verified suspension exists to fold in, and the grant is live.
1113        assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1114    }
1115
1116    #[test]
1117    fn revocation_and_suspension_cannot_be_confused() {
1118        // Distinct kinds: a suspension never verifies as a revocation, and a
1119        // grant never verifies as a suspension.
1120        let signer = Signer::from_seed(1);
1121        let (susp, _s, _p) = suspension_payload("deadbeef", "x", 1, &signer);
1122        assert!(
1123            verify_revocation(
1124                &susp,
1125                &SoftPasskey::from_seed(1).public_key_sec1(),
1126                RP_ID,
1127                ORIGIN
1128            )
1129            .is_none()
1130        );
1131        let grant = mint_grant(&SoftPasskey::from_seed(1));
1132        assert!(verify_suspension(&grant, &signer.public_key_bytes()).is_none());
1133    }
1134
1135    #[test]
1136    fn live_grant_folds_revocations_and_suspensions() {
1137        let key = SoftPasskey::from_seed(8);
1138        let payload = mint_grant(&key);
1139        let pk = key.public_key_sec1();
1140        let grant = verify_grant(&payload, &pk, RP_ID, ORIGIN).unwrap();
1141        let grants = vec![grant.clone()];
1142
1143        // No tombstone/suspension ⇒ live.
1144        assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1145
1146        // Matching revocation ⇒ absent.
1147        let rev = verify_revocation(
1148            &mint_revocation(&key, &grant.grant_ref, 1),
1149            &pk,
1150            RP_ID,
1151            ORIGIN,
1152        )
1153        .unwrap();
1154        assert!(live_grant(&grants, std::slice::from_ref(&rev), &[]).is_none());
1155
1156        // Matching suspension ⇒ absent.
1157        let platform = Signer::from_seed(1);
1158        let (susp_payload, _s, _p) =
1159            suspension_payload(&grant.grant_ref, "kill switch", 1, &platform);
1160        let susp = verify_suspension(&susp_payload, &platform.public_key_bytes()).unwrap();
1161        assert!(live_grant(&grants, &[], std::slice::from_ref(&susp)).is_none());
1162
1163        // A revocation for a DIFFERENT grant_ref leaves this grant live.
1164        let other = verify_revocation(
1165            &mint_revocation(&key, &"0".repeat(64), 1),
1166            &pk,
1167            RP_ID,
1168            ORIGIN,
1169        )
1170        .unwrap();
1171        assert_eq!(
1172            live_grant(&grants, std::slice::from_ref(&other), &[]),
1173            Some(&grants[0])
1174        );
1175    }
1176
1177    #[test]
1178    fn garbage_payloads_return_none_not_panic() {
1179        let pk = SoftPasskey::from_seed(1).public_key_sec1();
1180        assert!(verify_grant(b"not json", &pk, RP_ID, ORIGIN).is_none());
1181        assert!(verify_revocation(b"{}", &pk, RP_ID, ORIGIN).is_none());
1182        assert!(verify_suspension(b"[]", &Signer::from_seed(1).public_key_bytes()).is_none());
1183    }
1184
1185    // ── #1197: persona-signed grants (grant-signing credential reuse) ────────
1186
1187    fn persona_keypair(seed: &[u8]) -> polyc_passkey::derive::SigningKeypair {
1188        polyc_passkey::derive::derive_signing_keypair(seed)
1189    }
1190
1191    // Mint a full persona-signed grant — the exact assemble-then-persist flow
1192    // the enrollment ceremony runs when the persona already has a recorded
1193    // signing credential, instead of minting a new per-enrollment passkey.
1194    fn mint_persona_grant(keypair: &polyc_passkey::derive::SigningKeypair) -> Vec<u8> {
1195        let covered = caps();
1196        let coverage = GrantCoverage {
1197            destination: "slack:T1:C42",
1198            slot_schema_hash: "sha256:template-abc",
1199            covered_capabilities: &covered,
1200        };
1201        let canonical = grant_canonical_persona(
1202            "persona-9",
1203            "standup",
1204            "post_summary",
1205            &coverage,
1206            "enr-1",
1207            1_700_000_000_000,
1208        );
1209        let signature = keypair.sign(&canonical);
1210        let public_key = keypair.public_key();
1211        signed_grant_payload_persona(
1212            "persona-9",
1213            "standup",
1214            "post_summary",
1215            &coverage,
1216            "enr-1",
1217            1_700_000_000_000,
1218            &PersonaProof {
1219                signing_public_key: &public_key,
1220                signature: &signature,
1221            },
1222        )
1223    }
1224
1225    #[test]
1226    fn persona_grant_round_trips_and_carries_coverage() {
1227        let keypair = persona_keypair(b"persona-seed-1-------------------");
1228        let payload = mint_persona_grant(&keypair);
1229        let g = verify_persona_signed_grant(&payload, &keypair.public_key()).expect("verifies");
1230        assert_eq!(g.principal, "persona-9");
1231        assert_eq!(g.routine, "standup");
1232        assert_eq!(g.tool, "post_summary");
1233        assert_eq!(g.destination, "slack:T1:C42");
1234        assert_eq!(g.slot_schema_hash, "sha256:template-abc");
1235        assert_eq!(g.covered_capabilities, caps());
1236        assert_eq!(g.enrollment_id, "enr-1");
1237        assert_eq!(g.grant_ref, grant_ref(&payload));
1238        assert_eq!(g.registered_public_key, keypair.public_key());
1239    }
1240
1241    #[test]
1242    fn persona_grant_signed_by_a_different_key_is_dropped() {
1243        let signer = persona_keypair(b"persona-seed-2-------------------");
1244        let attacker = persona_keypair(b"persona-seed-3-------------------");
1245        let payload = mint_persona_grant(&signer);
1246        // Verified against the attacker's key ⇒ fail-closed.
1247        assert!(verify_persona_signed_grant(&payload, &attacker.public_key()).is_none());
1248        // The genuine key still verifies (control).
1249        assert!(verify_persona_signed_grant(&payload, &signer.public_key()).is_some());
1250    }
1251
1252    #[test]
1253    fn persona_grant_every_canonical_field_is_tamper_evident() {
1254        let keypair = persona_keypair(b"persona-seed-4-------------------");
1255        let payload = mint_persona_grant(&keypair);
1256        let pk = keypair.public_key();
1257        for (field, val) in [
1258            ("kind", serde_json::json!(REVOCATION_PERSONA_KIND)),
1259            ("principal", serde_json::json!("persona-EVIL")),
1260            ("routine", serde_json::json!("other-routine")),
1261            ("tool", serde_json::json!("exfiltrate")),
1262            ("destination", serde_json::json!("slack:T1:CEVIL")),
1263            ("slot_schema_hash", serde_json::json!("sha256:tampered")),
1264            (
1265                "covered_capabilities",
1266                serde_json::json!(["arbitrary-egress", "mutate-external"]),
1267            ),
1268            ("enrollment_id", serde_json::json!("enr-2")),
1269            ("created_at_ms", serde_json::json!(1_700_000_000_001u64)),
1270        ] {
1271            let mut v: Value = serde_json::from_slice(&payload).unwrap();
1272            v[field] = val;
1273            assert!(
1274                verify_persona_signed_grant(v.to_string().as_bytes(), &pk).is_none(),
1275                "tampered {field} must fail verification"
1276            );
1277        }
1278        assert!(verify_persona_signed_grant(&payload, &pk).is_some());
1279    }
1280
1281    #[test]
1282    fn persona_grant_and_webauthn_grant_never_verify_as_each_other() {
1283        // A persona-signed grant carries GRANT_PERSONA_KIND, not GRANT_KIND —
1284        // verify_grant (the WebAuthn path) must refuse it outright, and
1285        // verify_persona_signed_grant must refuse a real WebAuthn-signed
1286        // grant right back. Distinct kind tags, never confusable, exactly
1287        // the discipline this module already holds for grant/revocation/
1288        // suspension.
1289        let keypair = persona_keypair(b"persona-seed-5-------------------");
1290        let persona_payload = mint_persona_grant(&keypair);
1291        assert!(
1292            verify_grant(
1293                &persona_payload,
1294                &SoftPasskey::from_seed(1).public_key_sec1(),
1295                RP_ID,
1296                ORIGIN
1297            )
1298            .is_none()
1299        );
1300
1301        let webauthn_payload = mint_grant(&SoftPasskey::from_seed(1));
1302        assert!(verify_persona_signed_grant(&webauthn_payload, &keypair.public_key()).is_none());
1303    }
1304
1305    #[test]
1306    fn persona_revocation_round_trips_and_binds_the_grant_ref() {
1307        let keypair = persona_keypair(b"persona-seed-6-------------------");
1308        let payload = mint_persona_grant(&keypair);
1309        let gref = grant_ref(&payload);
1310        let canonical = revocation_canonical_persona(&gref, 1_700_000_100_000);
1311        let signature = keypair.sign(&canonical);
1312        let public_key = keypair.public_key();
1313        let rev = signed_revocation_payload_persona(
1314            &gref,
1315            1_700_000_100_000,
1316            &PersonaProof {
1317                signing_public_key: &public_key,
1318                signature: &signature,
1319            },
1320        );
1321        let v = verify_persona_signed_revocation(&rev, &keypair.public_key()).expect("verifies");
1322        assert_eq!(v.grant_ref, gref);
1323        assert_eq!(v.revoked_at_ms, 1_700_000_100_000);
1324        // A tombstone signed by a different key is rejected.
1325        let other = persona_keypair(b"persona-seed-7-------------------");
1326        assert!(verify_persona_signed_revocation(&rev, &other.public_key()).is_none());
1327    }
1328
1329    #[test]
1330    fn persona_grant_and_revocation_can_fold_through_live_grant() {
1331        // The generic live_grant fold doesn't care which signing method
1332        // produced a VerifiedGrant/VerifiedRevocation — a persona-signed
1333        // grant with a persona-signed revocation folds exactly like the
1334        // WebAuthn-signed pair does.
1335        let keypair = persona_keypair(b"persona-seed-8-------------------");
1336        let payload = mint_persona_grant(&keypair);
1337        let grant = verify_persona_signed_grant(&payload, &keypair.public_key()).unwrap();
1338        let grants = vec![grant.clone()];
1339        assert_eq!(live_grant(&grants, &[], &[]), Some(&grants[0]));
1340
1341        let canonical = revocation_canonical_persona(&grant.grant_ref, 1);
1342        let signature = keypair.sign(&canonical);
1343        let public_key = keypair.public_key();
1344        let rev_payload = signed_revocation_payload_persona(
1345            &grant.grant_ref,
1346            1,
1347            &PersonaProof {
1348                signing_public_key: &public_key,
1349                signature: &signature,
1350            },
1351        );
1352        let rev = verify_persona_signed_revocation(&rev_payload, &keypair.public_key()).unwrap();
1353        assert!(live_grant(&grants, std::slice::from_ref(&rev), &[]).is_none());
1354    }
1355
1356    #[test]
1357    fn persona_grant_garbage_payloads_return_none_not_panic() {
1358        let pk = persona_keypair(b"persona-seed-9-------------------").public_key();
1359        assert!(verify_persona_signed_grant(b"not json", &pk).is_none());
1360        assert!(verify_persona_signed_revocation(b"{}", &pk).is_none());
1361    }
1362}
1363
1364#[cfg(test)]
1365mod canonical_freeze {
1366    //! Every signed canonical in this module, frozen as literal bytes (`#1845`).
1367    //!
1368    //! These are the bytes a deployment has already signed and has sitting in
1369    //! its log. They are checked in, never regenerated: regenerating one is the
1370    //! defect this module exists to catch, because a canonical whose bytes move
1371    //! invalidates every signature ever minted over the old ones.
1372    //!
1373    //! The reason they can be single literals at all is the conversion `#1845`
1374    //! made, following `#1842`. Before it, each canonical was a
1375    //! [`serde_json::Value`], whose object is a `BTreeMap` (keys sorted) by
1376    //! default and an `IndexMap` (insertion order) whenever anything in the
1377    //! build graph enables `serde_json/preserve_order` — so the same payload
1378    //! signed by two binaries with different dependency sets produced different
1379    //! bytes and different signatures. Every literal below is the
1380    //! insertion-order form, which is what a control-plane binary (where
1381    //! `preserve_order` is unified in) has always signed. Run this module under
1382    //! either selection and every literal holds:
1383    //!
1384    //! ```text
1385    //! cargo nextest run -p polyc-crypto                    # no preserve_order
1386    //! cargo nextest run -p polyc-crypto -p polyc-payments  # preserve_order on
1387    //! ```
1388    //!
1389    //! See ADR 0009 for the decision these literals enforce.
1390    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
1391
1392    use super::*;
1393
1394    /// Assert a canonical's bytes are exactly the frozen literal.
1395    fn frozen(label: &str, got: &[u8], want: &str) {
1396        assert_eq!(
1397            String::from_utf8(got.to_vec()).unwrap(),
1398            want,
1399            "{label}: canonical bytes moved — every signature over the old bytes is now unverifiable"
1400        );
1401    }
1402
1403    const DESTINATION: &str = r#"{"channel":"C1","workspace":"T1"}"#;
1404    const GRANT_REF: &str = "cafe0123";
1405    const CREATED_AT_MS: u64 = 1_700_000_000_000;
1406    const REVOKED_AT_MS: u64 = 1_700_000_100_000;
1407    const SUSPENDED_AT_MS: u64 = 1_700_000_200_000;
1408    const REASON: &str = "policy change";
1409
1410    fn caps() -> Vec<String> {
1411        vec!["arbitrary-egress".to_owned(), "mutate-external".to_owned()]
1412    }
1413
1414    #[test]
1415    fn grant_canonicals_are_frozen() {
1416        let caps = caps();
1417        let coverage = GrantCoverage {
1418            destination: DESTINATION,
1419            slot_schema_hash: "sha256:abc",
1420            covered_capabilities: &caps,
1421        };
1422        frozen(
1423            "grant_canonical",
1424            &grant_canonical(
1425                "persona-1",
1426                "routine-1",
1427                "paid_fetch",
1428                &coverage,
1429                "enr-1",
1430                CREATED_AT_MS,
1431            ),
1432            GRANT_CANONICAL,
1433        );
1434        frozen(
1435            "grant_canonical_persona",
1436            &grant_canonical_persona(
1437                "persona-1",
1438                "routine-1",
1439                "paid_fetch",
1440                &coverage,
1441                "enr-1",
1442                CREATED_AT_MS,
1443            ),
1444            GRANT_PERSONA_CANONICAL,
1445        );
1446    }
1447
1448    #[test]
1449    fn revocation_canonicals_are_frozen() {
1450        frozen(
1451            "revocation_canonical",
1452            &revocation_canonical(GRANT_REF, REVOKED_AT_MS),
1453            REVOCATION_CANONICAL,
1454        );
1455        frozen(
1456            "revocation_canonical_persona",
1457            &revocation_canonical_persona(GRANT_REF, REVOKED_AT_MS),
1458            REVOCATION_PERSONA_CANONICAL,
1459        );
1460    }
1461
1462    #[test]
1463    fn suspension_canonical_and_payload_are_frozen() {
1464        frozen(
1465            "suspension_canonical",
1466            &suspension_canonical(GRANT_REF, REASON, SUSPENDED_AT_MS),
1467            SUSPENSION_CANONICAL,
1468        );
1469        let (full, sig, _) =
1470            suspension_payload(GRANT_REF, REASON, SUSPENDED_AT_MS, &Signer::from_seed(99));
1471        frozen("suspension_payload", &full, SUSPENSION_PAYLOAD);
1472        assert_eq!(crate::hex::lower(&sig), SUSPENSION_SIG);
1473    }
1474
1475    const GRANT_CANONICAL: &str = r#"{"kind":"grant.routine.v1","principal":"persona-1","routine":"routine-1","tool":"paid_fetch","destination":"{\"channel\":\"C1\",\"workspace\":\"T1\"}","slot_schema_hash":"sha256:abc","covered_capabilities":["arbitrary-egress","mutate-external"],"enrollment_id":"enr-1","created_at_ms":1700000000000}"#;
1476    const GRANT_PERSONA_CANONICAL: &str = r#"{"kind":"grant.routine.persona.v1","principal":"persona-1","routine":"routine-1","tool":"paid_fetch","destination":"{\"channel\":\"C1\",\"workspace\":\"T1\"}","slot_schema_hash":"sha256:abc","covered_capabilities":["arbitrary-egress","mutate-external"],"enrollment_id":"enr-1","created_at_ms":1700000000000}"#;
1477    const REVOCATION_CANONICAL: &str =
1478        r#"{"kind":"grant.revocation.v1","grant_ref":"cafe0123","revoked_at_ms":1700000100000}"#;
1479    const REVOCATION_PERSONA_CANONICAL: &str = r#"{"kind":"grant.revocation.persona.v1","grant_ref":"cafe0123","revoked_at_ms":1700000100000}"#;
1480    const SUSPENSION_CANONICAL: &str = r#"{"kind":"grant.suspension.v1","grant_ref":"cafe0123","reason":"policy change","suspended_at_ms":1700000200000}"#;
1481    const SUSPENSION_PAYLOAD: &str = r#"{"kind":"grant.suspension.v1","grant_ref":"cafe0123","reason":"policy change","suspended_at_ms":1700000200000,"signed_by":"35ccaf567ce385fe73a3d0ef44c04c8e4f13cf02ec853ac430a8bfd40cefe008","signature_hex":"dd7c046496ba66d2fc531bbbdbdc926a3f400a45a399a2c7668a2a59253d067eecfd6447de4b2842d21331db5aa8fc6154776094cc694399b507c0684459ea01"}"#;
1482    const SUSPENSION_SIG: &str = "dd7c046496ba66d2fc531bbbdbdc926a3f400a45a399a2c7668a2a59253d067eecfd6447de4b2842d21331db5aa8fc6154776094cc694399b507c0684459ea01";
1483}