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