Skip to main content

agent_bridle_core/
step_up.rs

1//! Human-presence step-up: a third leash outcome between *allow* and *deny*.
2//!
3//! The base leash decision is two-valued: a call is within the granted authority
4//! or it is not (see [`crate::Gate::authorize`]). This module adds a third
5//! outcome — **`attest`** — "authorized, but only with a fresh, non-repudiable
6//! act of human presence" (a passkey / biometric gesture). It is **not a new
7//! authority**: a discharge adds nothing to the grant (`effective` is still
8//! `granted.meet(required)`); it sharpens the *liveness condition* under which
9//! the same Writ is exercised. So it cannot break attenuation.
10//!
11//! The design (`newt-agent/docs/design/human-presence-capabilities.md`, paper
12//! §7.5):
13//!
14//! - The [`Gate`](crate::Gate) stays pure and synchronous: it **verifies a
15//!   proof** ([`Discharge`]) — it never performs the gesture. A host capability
16//!   ([`DischargeProvider`], sibling of [`Sandbox`](crate::Sandbox)) runs the
17//!   ceremony; [`Gate::authorize_step_up`](crate::Gate) orchestrates
18//!   evaluate→obtain→authorize so the host needs a single call.
19//! - The proof is bound to the *exact* action by a content-addressed
20//!   [`Challenge`] — what-you-see-is-what-you-sign — so a gesture cannot be
21//!   harvested and replayed for a different action.
22//! - A verified, recorded gesture becomes a content-addressed [`Attestation`]:
23//!   data that carries its own proof of integrity.
24//!
25//! Content-addressing reuses the mesh's BLAKE3 primitive
26//! ([`agent_mesh_protocol::Fingerprint::of_bytes`]) so the whole stack speaks one
27//! content-address.
28
29use agent_mesh_protocol::Fingerprint;
30use serde::{Deserialize, Serialize};
31
32use crate::{ToolContext, ToolError};
33
34/// Domain-separation tag mixed into every step-up [`Challenge`]. Bumping the
35/// version invalidates every previously issued challenge.
36const CHALLENGE_DOMAIN: &[u8] = b"agent-bridle/step-up/v1";
37
38// ── Presence ────────────────────────────────────────────────────────────────
39
40/// The strength of human gesture an action demands, weakest to strongest.
41///
42/// The ordering is **load-bearing**: a discharge satisfies a requirement iff its
43/// presence is `>=` the required presence (a `Passkey` over-satisfies a
44/// `Prompt`; a `Prompt` never satisfies a `Passkey`). Because attenuation may
45/// only *raise* a required presence, this keeps "you can get more restrictive,
46/// never less" true for the presence axis too.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Presence {
50    /// No human gesture required.
51    #[default]
52    None,
53    /// A soft prompt: any UI affirmation (a typed "yes", a click). Advisory —
54    /// it proves a human *chose*, not *who*. (Charter: Tether.)
55    Prompt,
56    /// A hardware human gesture: a WebAuthn/FIDO2 user-presence (and optional
57    /// user-verification) assertion from an authenticator the human controls.
58    Passkey,
59}
60
61// ── Content-addressed action identity + challenge ────────────────────────────
62
63/// The content address (BLAKE3) of a canonicalized [`CallRequest`] — a stable,
64/// collision-resistant identity for "this exact action."
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ContentId([u8; 32]);
67
68impl ContentId {
69    /// Content-address arbitrary bytes with the mesh's BLAKE3 primitive.
70    #[must_use]
71    pub fn of_bytes(data: &[u8]) -> Self {
72        Self(Fingerprint::of_bytes(data).0)
73    }
74
75    /// The raw 32-byte digest.
76    #[must_use]
77    pub fn as_bytes(&self) -> &[u8; 32] {
78        &self.0
79    }
80
81    /// Lower-hex rendering of the digest.
82    #[must_use]
83    pub fn to_hex(&self) -> String {
84        self.0.iter().map(|b| format!("{b:02x}")).collect()
85    }
86}
87
88/// A what-you-see-is-what-you-sign challenge: the content address of
89/// `DOMAIN ‖ action_id ‖ generation ‖ nonce`. The authenticator signs *this*, so
90/// a verified signature proves the human authorized that exact action, in that
91/// causal generation, for that single-use nonce.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93pub struct Challenge([u8; 32]);
94
95impl Challenge {
96    /// Bind a challenge to an action's [`ContentId`], a causal `generation`
97    /// (never wall-clock), and a single-use `nonce`.
98    #[must_use]
99    pub fn bind(action: &ContentId, generation: u64, nonce: &[u8; 32]) -> Self {
100        let mut buf = Vec::with_capacity(CHALLENGE_DOMAIN.len() + 32 + 8 + 32);
101        buf.extend_from_slice(CHALLENGE_DOMAIN);
102        buf.extend_from_slice(action.as_bytes());
103        buf.extend_from_slice(&generation.to_le_bytes());
104        buf.extend_from_slice(nonce);
105        Self(Fingerprint::of_bytes(&buf).0)
106    }
107
108    /// The raw 32-byte challenge the authenticator signs.
109    #[must_use]
110    pub fn as_bytes(&self) -> &[u8; 32] {
111        &self.0
112    }
113}
114
115/// The action a leash decision is about: a tool name, its arguments, and the
116/// resolved resource the policy keys on.
117///
118/// **Resolve before constructing.** A `resource` (and any path-bearing `args`)
119/// must already be canonicalized (realpath, normalized refspec) so the human
120/// approves the *resolved* effect, not a `..`-bearing alias.
121#[derive(Debug, Clone)]
122pub struct CallRequest {
123    /// The dispatch name of the tool (e.g. `git.push`, `email.send`).
124    pub tool: String,
125    /// The tool arguments (the MCP `arguments` object).
126    pub args: serde_json::Value,
127    /// The resolved, policy-relevant resource (e.g. `github.com/org/repo`,
128    /// a realpath, a recipient set).
129    pub resource: String,
130}
131
132impl CallRequest {
133    /// Construct a request.
134    #[must_use]
135    pub fn new(
136        tool: impl Into<String>,
137        args: serde_json::Value,
138        resource: impl Into<String>,
139    ) -> Self {
140        Self {
141            tool: tool.into(),
142            args,
143            resource: resource.into(),
144        }
145    }
146
147    /// A request with no arguments and no resource — used by the back-compat
148    /// no-step-up path.
149    #[must_use]
150    pub fn unspecified(tool: impl Into<String>) -> Self {
151        Self {
152            tool: tool.into(),
153            args: serde_json::Value::Null,
154            resource: String::new(),
155        }
156    }
157
158    /// The content address of this action, computed over a canonical
159    /// serialization of `(tool, canonical(args), resource)`.
160    ///
161    /// Canonicalization sorts object keys recursively (so argument order cannot
162    /// change the identity) and is robust to whether `serde_json`'s
163    /// `preserve_order` feature is enabled. Full RFC 8785 number/string
164    /// normalization is a follow-up; for now the byte form is deterministic and
165    /// order-independent, which is what the binding requires.
166    #[must_use]
167    pub fn content_id(&self) -> ContentId {
168        let canonical = (
169            self.tool.as_str(),
170            canonical_json(&self.args),
171            self.resource.as_str(),
172        );
173        let bytes = serde_json::to_vec(&canonical)
174            .expect("a (str, Value, str) tuple is always JSON-serializable");
175        ContentId::of_bytes(&bytes)
176    }
177}
178
179/// Recursively rebuild a JSON value with object keys sorted, so the byte form is
180/// deterministic regardless of insertion order or the `preserve_order` feature.
181fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
182    match value {
183        serde_json::Value::Object(map) => {
184            let mut keys: Vec<&String> = map.keys().collect();
185            keys.sort();
186            let mut sorted = serde_json::Map::new();
187            for k in keys {
188                sorted.insert(k.clone(), canonical_json(&map[k]));
189            }
190            serde_json::Value::Object(sorted)
191        }
192        serde_json::Value::Array(items) => {
193            serde_json::Value::Array(items.iter().map(canonical_json).collect())
194        }
195        other => other.clone(),
196    }
197}
198
199// ── Requirement, discharge, attestation ──────────────────────────────────────
200
201/// What step-up an action demands before the gate will mint a context for it.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct AttestRequirement {
204    /// Minimum gesture strength this action demands.
205    pub presence: Presence,
206    /// Whether a verified gesture must be recorded as a provenance
207    /// [`Attestation`] (the `passkey+record` policy decision).
208    pub record: bool,
209    /// Maximum age, in causal generations (never wall-clock), a discharge may
210    /// have: the gate accepts a discharge bound to any generation in
211    /// `[current - freshness_generations, current]`. `0` ⇒ it must be bound to
212    /// the current generation (fresh-per-act, HIGH-consequence); `N` ⇒ a gesture
213    /// may be reused for up to `N` generations (LOW-consequence amortization).
214    /// Enforced in [`Gate::authorize_with_discharge`](crate::Gate) by recomputing
215    /// the bound [`Challenge`] across the window, combined with single-use
216    /// consumption so one gesture authorizes exactly one act (ADR 0007 D4). The
217    /// window is capped defensively (fail-closed) to bound the scan.
218    pub freshness_generations: u64,
219}
220
221impl AttestRequirement {
222    /// The empty requirement: no gesture, no record. The base-case for actions
223    /// with no step-up policy.
224    pub const NONE: Self = Self {
225        presence: Presence::None,
226        record: false,
227        freshness_generations: 0,
228    };
229
230    /// A requirement for the given presence (no recording, current-generation
231    /// freshness).
232    #[must_use]
233    pub fn presence(presence: Presence) -> Self {
234        Self {
235            presence,
236            record: false,
237            freshness_generations: 0,
238        }
239    }
240
241    /// A requirement for a hardware gesture **and** a recorded attestation.
242    #[must_use]
243    pub fn passkey_recorded() -> Self {
244        Self {
245            presence: Presence::Passkey,
246            record: true,
247            freshness_generations: 0,
248        }
249    }
250
251    /// Does this action demand any gesture at all?
252    #[must_use]
253    pub fn demands_gesture(&self) -> bool {
254        self.presence > Presence::None
255    }
256}
257
258/// A human-presence proof presented to the gate. Crypto-format-agnostic: the
259/// `signature` and `credential_id` are opaque bytes a [`DischargeVerifier`]
260/// interprets (e.g. the `Ed25519Verifier` reads them as a raw ed25519 verifying
261/// key + assertion, under the `verifier-ed25519` feature).
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct Discharge {
264    /// The gesture strength actually achieved (e.g. user-presence vs. verified).
265    pub presence: Presence,
266    /// Which authenticator/credential produced the proof (a public-key id).
267    pub credential_id: Vec<u8>,
268    /// The challenge bytes the authenticator signed.
269    pub challenge: [u8; 32],
270    /// The assertion signature. For the raw-Ed25519 path this signs the
271    /// `challenge` directly; for the WebAuthn path it signs
272    /// `authenticator_data ‖ SHA-256(client_data_json)`.
273    pub signature: Vec<u8>,
274    /// WebAuthn `authenticatorData` (binary: `rpIdHash‖flags‖signCount‖…`), set
275    /// only when the proof is a WebAuthn/CTAP2 assertion ([`WebAuthnVerifier`]).
276    /// `None` for the raw-Ed25519 path. Backward-compatible on the wire.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub authenticator_data: Option<Vec<u8>>,
279    /// WebAuthn `clientDataJSON` (the raw UTF-8 bytes the authenticator hashed),
280    /// set only for a WebAuthn assertion; carries the base64url-encoded
281    /// challenge the verifier binds against. `None` for the raw-Ed25519 path.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub client_data_json: Option<Vec<u8>>,
284}
285
286/// A content-addressed, non-repudiable record that a human authorized a specific
287/// action — Provenance that becomes a Scar in the causal log. It carries its own
288/// proof: the credential id + signature prove *which* authenticator, the
289/// challenge proves *which* action, the generation proves *which* flight.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct Attestation {
292    /// Schema tag for forward-compatible parsing.
293    pub schema: String,
294    /// The tool whose invocation was authorized.
295    pub tool: String,
296    /// The resolved resource that was authorized.
297    pub resource: String,
298    /// The bound challenge the human signed.
299    pub challenge: [u8; 32],
300    /// The causal generation this authorization is valid for.
301    pub generation: u64,
302    /// The authenticator/credential id.
303    pub credential_id: Vec<u8>,
304    /// The assertion signature.
305    pub signature: Vec<u8>,
306    /// The gesture strength achieved.
307    pub presence: Presence,
308}
309
310impl Attestation {
311    /// Current attestation schema tag.
312    pub const SCHEMA: &'static str = "agent-bridle/attestation/v1";
313
314    /// Build a provenance record from a verified discharge for `(tool,
315    /// resource)` in `generation`. Called by the gate only *after* a
316    /// [`DischargeVerifier`] has accepted the discharge.
317    #[must_use]
318    pub fn from_verified(
319        tool: &str,
320        resource: &str,
321        discharge: &Discharge,
322        generation: u64,
323    ) -> Self {
324        Self {
325            schema: Self::SCHEMA.to_string(),
326            tool: tool.to_string(),
327            resource: resource.to_string(),
328            challenge: discharge.challenge,
329            generation,
330            credential_id: discharge.credential_id.clone(),
331            signature: discharge.signature.clone(),
332            presence: discharge.presence,
333        }
334    }
335
336    /// The content address of this attestation record.
337    #[must_use]
338    pub fn content_id(&self) -> ContentId {
339        let bytes = serde_json::to_vec(self).expect("Attestation is always JSON-serializable");
340        ContentId::of_bytes(&bytes)
341    }
342}
343
344/// Verifies a [`Discharge`] against the requirement and the gate-recomputed
345/// [`Challenge`]. **Pure**: it performs no ceremony and no IO — that is a host
346/// capability outside the gate. An `Err(reason)` is turned into a leash denial.
347pub trait DischargeVerifier {
348    /// Accept iff the discharge is a valid proof for `expected`, at or above
349    /// `required.presence`. The reason string is safe to surface to the agent.
350    fn verify(
351        &self,
352        discharge: &Discharge,
353        required: &AttestRequirement,
354        expected: &Challenge,
355    ) -> Result<(), String>;
356}
357
358/// A production [`DischargeVerifier`] for **raw Ed25519** assertions — the format
359/// the OpenSSH `ed25519-sk` / software-passkey path produces. It interprets
360/// [`Discharge::credential_id`] as a 32-byte verifying key and
361/// [`Discharge::signature`] as a 64-byte assertion over the gate-recomputed
362/// [`Challenge`], and is **presence-agnostic about *how*** the gesture was
363/// achieved (it trusts the host-reported [`Presence`] only up to the floor the
364/// gate re-checks below).
365///
366/// It checks, in order: the presence floor (`discharge.presence >= required.presence`),
367/// the challenge binding (anti-theater — the signed bytes must equal the bytes
368/// the gate recomputed), then `verify_strict` over the challenge.
369///
370/// **Off by default.** Enable the `verifier-ed25519` cargo feature. This is the
371/// raw-Ed25519 path only; the WebAuthn/CTAP2 assertion path (clientDataJSON +
372/// authenticatorData + UP/UV flag bits) is the sibling [`WebAuthnVerifier`]
373/// (`verifier-webauthn` feature). Attestation-certificate chains / FIDO MDS and
374/// live USB/HID transport remain out of scope for both (ADR 0007).
375#[cfg(feature = "verifier-ed25519")]
376#[derive(Debug, Default, Clone, Copy)]
377pub struct Ed25519Verifier;
378
379#[cfg(feature = "verifier-ed25519")]
380impl DischargeVerifier for Ed25519Verifier {
381    fn verify(
382        &self,
383        discharge: &Discharge,
384        required: &AttestRequirement,
385        expected: &Challenge,
386    ) -> Result<(), String> {
387        use ed25519_dalek::{Signature, VerifyingKey};
388        // Presence floor first — a too-weak gesture is rejected before any crypto
389        // (fail-closed; ADR 0007 D2).
390        if discharge.presence < required.presence {
391            return Err(format!(
392                "presence {:?} is below required {:?}",
393                discharge.presence, required.presence
394            ));
395        }
396        // Anti-theater: the discharge must answer THIS action's challenge.
397        if &discharge.challenge != expected.as_bytes() {
398            return Err("discharge does not answer this action's challenge".into());
399        }
400        let vk_bytes: [u8; 32] = discharge
401            .credential_id
402            .as_slice()
403            .try_into()
404            .map_err(|_| "credential id is not a 32-byte ed25519 key".to_string())?;
405        let vk = VerifyingKey::from_bytes(&vk_bytes).map_err(|e| e.to_string())?;
406        let sig_bytes: [u8; 64] = discharge
407            .signature
408            .as_slice()
409            .try_into()
410            .map_err(|_| "signature is not 64 bytes".to_string())?;
411        let sig = Signature::from_bytes(&sig_bytes);
412        vk.verify_strict(expected.as_bytes(), &sig)
413            .map_err(|e| e.to_string())
414    }
415}
416
417/// base64url **without padding** (RFC 4648 §5), the encoding WebAuthn uses for
418/// `clientDataJSON.challenge`. Encoding-only: we encode the gate-recomputed
419/// challenge and string-compare it to the assertion, so no decoder (and no
420/// malleable-input parsing) is needed.
421#[cfg(any(feature = "verifier-webauthn", feature = "verifier-webauthn-es256"))]
422fn base64url_nopad(bytes: &[u8]) -> String {
423    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
424    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
425    for chunk in bytes.chunks(3) {
426        let b0 = chunk[0];
427        let b1 = chunk.get(1).copied().unwrap_or(0);
428        let b2 = chunk.get(2).copied().unwrap_or(0);
429        let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
430        out.push(T[((n >> 18) & 0x3f) as usize] as char);
431        out.push(T[((n >> 12) & 0x3f) as usize] as char);
432        if chunk.len() > 1 {
433            out.push(T[((n >> 6) & 0x3f) as usize] as char);
434        }
435        if chunk.len() > 2 {
436            out.push(T[(n & 0x3f) as usize] as char);
437        }
438    }
439    out
440}
441
442/// A production [`DischargeVerifier`] for **WebAuthn/CTAP2 assertions** — the
443/// format a hardware passkey (or a `navigator.credentials.get()` ceremony)
444/// produces. The opaque [`Discharge`] fields are read as a WebAuthn assertion:
445/// [`Discharge::credential_id`] is the 32-byte Ed25519 (COSE `EdDSA`/`-8`)
446/// verifying key, [`Discharge::authenticator_data`] and
447/// [`Discharge::client_data_json`] are the assertion's two signed structures, and
448/// [`Discharge::signature`] is the EdDSA signature over
449/// `authenticatorData ‖ SHA-256(clientDataJSON)` (the WebAuthn signing input).
450///
451/// It checks, in order (fail-closed; ADR 0007 D2):
452/// 1. the **presence floor** — `discharge.presence >= required.presence` —
453///    *before any crypto or parsing*;
454/// 2. the **flag bits** in `authenticatorData`: User-Presence (UP) must be set,
455///    and User-Verification (UV) must be set whenever the requirement is
456///    `Presence::Passkey` (the hardware-verified tier);
457/// 3. the **challenge binding** (anti-theater): `clientDataJSON.type` is
458///    `"webauthn.get"` and its `challenge` equals the base64url of the
459///    gate-recomputed [`Challenge`];
460/// 4. the **signature** over `authenticatorData ‖ SHA-256(clientDataJSON)`.
461///
462/// **Off by default.** Enable the `verifier-webauthn` cargo feature. Out of
463/// scope (ADR 0007): attestation-certificate chain validation / FIDO MDS, live
464/// USB/HID transport, and credential registration — this verifies a *presented*
465/// assertion, it does not run the ceremony (that is a [`DischargeProvider`]).
466#[cfg(feature = "verifier-webauthn")]
467#[derive(Debug, Default, Clone, Copy)]
468pub struct WebAuthnVerifier;
469
470/// The shared WebAuthn assertion checks — presence floor, `authenticatorData`
471/// flags (UP required; UV required for Passkey), the `webauthn.get` type, and
472/// the challenge binding — returning the signed message
473/// `authenticatorData ‖ SHA-256(clientDataJSON)`. **Single-sourced** so the two
474/// signature-suite verifiers ([`WebAuthnVerifier`], EdDSA/-8, and
475/// [`WebAuthnEs256Verifier`], ES256/-7) cannot drift on the security-critical
476/// gating — the broken call is made unrepresentable rather than fixed twice.
477#[cfg(any(feature = "verifier-webauthn", feature = "verifier-webauthn-es256"))]
478fn webauthn_verify_common(
479    discharge: &Discharge,
480    required: &AttestRequirement,
481    expected: &Challenge,
482) -> Result<Vec<u8>, String> {
483    use sha2::{Digest, Sha256};
484
485    // 1. Presence floor first — a too-weak gesture is rejected before any
486    // parsing or crypto (fail-closed; ADR 0007 D2).
487    if discharge.presence < required.presence {
488        return Err(format!(
489            "presence {:?} is below required {:?}",
490            discharge.presence, required.presence
491        ));
492    }
493
494    // The WebAuthn proof parts must be present.
495    let auth_data = discharge
496        .authenticator_data
497        .as_deref()
498        .ok_or("WebAuthn assertion is missing authenticatorData")?;
499    let client_data = discharge
500        .client_data_json
501        .as_deref()
502        .ok_or("WebAuthn assertion is missing clientDataJSON")?;
503
504    // 2. authenticatorData = rpIdHash[32] ‖ flags[1] ‖ signCount[4] ‖ …
505    if auth_data.len() < 37 {
506        return Err("authenticatorData is too short (need ≥ 37 bytes)".into());
507    }
508    let flags = auth_data[32];
509    let up = flags & 0x01 != 0; // bit 0: User Present
510    let uv = flags & 0x04 != 0; // bit 2: User Verified
511    if !up {
512        return Err("authenticatorData User-Presence (UP) flag is not set".into());
513    }
514    if required.presence >= Presence::Passkey && !uv {
515        return Err(
516            "authenticatorData User-Verification (UV) flag is required for Passkey but not set"
517                .into(),
518        );
519    }
520
521    // 3. clientDataJSON: must be a `webauthn.get` answering THIS challenge.
522    #[derive(serde::Deserialize)]
523    struct ClientData {
524        #[serde(rename = "type")]
525        typ: String,
526        challenge: String,
527    }
528    let cd: ClientData = serde_json::from_slice(client_data)
529        .map_err(|e| format!("clientDataJSON does not parse: {e}"))?;
530    if cd.typ != "webauthn.get" {
531        return Err(format!(
532            "clientDataJSON.type is {:?}, expected \"webauthn.get\"",
533            cd.typ
534        ));
535    }
536    // Anti-theater: the signed challenge must equal the bytes the gate
537    // recomputed (base64url form).
538    if cd.challenge != base64url_nopad(expected.as_bytes()) {
539        return Err("assertion does not answer this action's challenge".into());
540    }
541    // Defense-in-depth: the discharge's own challenge field must agree too, so a
542    // recorded Attestation (built from these fields) stays consistent.
543    if &discharge.challenge != expected.as_bytes() {
544        return Err("discharge challenge does not match the gate-recomputed challenge".into());
545    }
546
547    // The signed message: authenticatorData ‖ SHA-256(clientDataJSON).
548    let mut signed = Vec::with_capacity(auth_data.len() + 32);
549    signed.extend_from_slice(auth_data);
550    signed.extend_from_slice(&Sha256::digest(client_data));
551    Ok(signed)
552}
553
554#[cfg(feature = "verifier-webauthn")]
555impl DischargeVerifier for WebAuthnVerifier {
556    fn verify(
557        &self,
558        discharge: &Discharge,
559        required: &AttestRequirement,
560        expected: &Challenge,
561    ) -> Result<(), String> {
562        use ed25519_dalek::{Signature, VerifyingKey};
563        // Shared gating + the signed message (single-sourced).
564        let signed = webauthn_verify_common(discharge, required, expected)?;
565        // Verify the EdDSA (-8) signature over that message.
566        let vk_bytes: [u8; 32] = discharge
567            .credential_id
568            .as_slice()
569            .try_into()
570            .map_err(|_| "credential id is not a 32-byte ed25519 key".to_string())?;
571        let vk = VerifyingKey::from_bytes(&vk_bytes).map_err(|e| e.to_string())?;
572        let sig_bytes: [u8; 64] = discharge
573            .signature
574            .as_slice()
575            .try_into()
576            .map_err(|_| "signature is not 64 bytes".to_string())?;
577        let sig = Signature::from_bytes(&sig_bytes);
578        vk.verify_strict(&signed, &sig).map_err(|e| e.to_string())
579    }
580}
581
582/// The **ES256** (COSE alg −7) sibling of [`WebAuthnVerifier`]. Most platform
583/// passkeys (phone / laptop) sign ECDSA-P256, not EdDSA, so this is what lets
584/// real hardware work. Shares [`webauthn_verify_common`] for the presence/flag/
585/// challenge gating and differs only in the signature suite: an ASN.1-DER ECDSA
586/// signature over `authenticatorData ‖ SHA-256(clientDataJSON)`, verified with a
587/// P-256 (SEC1) public key (`p256::ecdsa` hashes with SHA-256 internally).
588/// **Off by default** — enable the `verifier-webauthn-es256` cargo feature
589/// (pulls `p256`). Same out-of-scope as the sibling (ADR 0007): no attestation
590/// chain / MDS / registration.
591#[cfg(feature = "verifier-webauthn-es256")]
592#[derive(Debug, Default, Clone, Copy)]
593pub struct WebAuthnEs256Verifier;
594
595#[cfg(feature = "verifier-webauthn-es256")]
596impl DischargeVerifier for WebAuthnEs256Verifier {
597    fn verify(
598        &self,
599        discharge: &Discharge,
600        required: &AttestRequirement,
601        expected: &Challenge,
602    ) -> Result<(), String> {
603        use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
604        // Shared gating + the signed message (single-sourced).
605        let signed = webauthn_verify_common(discharge, required, expected)?;
606        // The credential is a P-256 public key (SEC1 point); the assertion is an
607        // ASN.1-DER ECDSA signature.
608        let vk = VerifyingKey::from_sec1_bytes(&discharge.credential_id)
609            .map_err(|_| "credential id is not a valid P-256 SEC1 public key".to_string())?;
610        let sig = Signature::from_der(&discharge.signature)
611            .map_err(|_| "signature is not a valid DER ECDSA signature".to_string())?;
612        vk.verify(&signed, &sig).map_err(|e| e.to_string())
613    }
614}
615
616/// Runs the human-presence **ceremony** and returns a [`Discharge`] — the dual
617/// of [`DischargeVerifier`] (a provider *produces* a proof; a verifier *checks*
618/// one).
619///
620/// This is a **host capability**, a sibling of [`Sandbox`](crate::Sandbox): it
621/// performs IO/UI (prompts a passkey, drives an authenticator) and lives in the
622/// host, not the gate. The [`Gate`](crate::Gate) never calls it during
623/// verification — only [`Gate::authorize_step_up`](crate::Gate) calls it, to
624/// orchestrate the evaluate→obtain→authorize sequence on the host's behalf.
625///
626/// **It is not trusted to self-attest presence.** A provider returns whatever
627/// [`Presence`] it claims to have achieved, but the gate (via the
628/// [`DischargeVerifier`]) still re-checks `discharge.presence >= required.presence`
629/// and that the discharge answers the gate-recomputed [`Challenge`]. A lying or
630/// buggy provider can only get its discharge *rejected*, never over-admitted
631/// (ADR 0007 D5).
632pub trait DischargeProvider {
633    /// Run the ceremony for `request` at `required` strength and return a
634    /// [`Discharge`] whose `challenge` answers
635    /// [`Challenge::bind`]`(&request.content_id(), generation, nonce)`.
636    ///
637    /// `generation` and the single-use `nonce` are supplied by the caller (the
638    /// gate) so the produced proof binds to this exact action, generation, and
639    /// nonce — what-you-see-is-what-you-sign. An `Err(reason)` (the human
640    /// declined, no authenticator present, a transport failure) becomes a
641    /// fail-closed leash denial; the reason is safe to surface to the agent.
642    fn obtain(
643        &self,
644        request: &CallRequest,
645        required: &AttestRequirement,
646        generation: u64,
647        nonce: &[u8; 32],
648    ) -> Result<Discharge, String>;
649}
650
651/// A presented step-up proof, bundled: the single-use `nonce` the challenge was
652/// bound with, the [`Discharge`] itself, and the [`DischargeVerifier`] that
653/// checks it. Grouping the "proof" inputs keeps
654/// [`Gate::authorize_with_discharge`](crate::Gate) to a small argument list and
655/// distinct from the "what" (tool, grant, request, policy).
656pub struct DischargeAttempt<'a> {
657    /// The single-use nonce the challenge was bound with.
658    pub nonce: [u8; 32],
659    /// The proof produced by the host after running the ceremony.
660    pub discharge: &'a Discharge,
661    /// The verifier that checks the proof (pure; performs no ceremony).
662    pub verifier: &'a dyn DischargeVerifier,
663}
664
665// ── Decision ─────────────────────────────────────────────────────────────────
666
667/// The gate's verdict for one call under a step-up policy.
668#[derive(Debug)]
669pub enum Decision {
670    /// Authorized with no step-up owed — the minted context, exactly as today.
671    Allow(ToolContext),
672    /// Refused. Fail-closed; this always wins.
673    Deny(ToolError),
674    /// Conditionally authorized: obtain a discharge satisfying the requirement
675    /// and re-present it to [`Gate::authorize_with_discharge`](crate::Gate). No
676    /// context is minted and no budget is charged here.
677    NeedsDischarge(AttestRequirement),
678}
679
680// ── Policy ───────────────────────────────────────────────────────────────────
681
682/// One policy rule mapping an action selector to a required step-up.
683#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684pub struct Rule {
685    /// `"<tool>"` or `"<tool>:<resource-glob>"`. The glob supports a single
686    /// trailing `*` (or `**`) meaning prefix-match.
687    pub selector: String,
688    /// The step-up this rule demands when it matches.
689    pub requirement: AttestRequirement,
690}
691
692impl Rule {
693    fn matches(&self, request: &CallRequest) -> bool {
694        let (tool, resource_glob) = match self.selector.split_once(':') {
695            Some((tool, glob)) => (tool, Some(glob)),
696            None => (self.selector.as_str(), None),
697        };
698        if tool != request.tool {
699            return false;
700        }
701        match resource_glob {
702            None => true,
703            Some(glob) => glob_prefix_match(glob, &request.resource),
704        }
705    }
706}
707
708/// Exact match, or prefix-match when `pattern` ends in `*` / `**`.
709fn glob_prefix_match(pattern: &str, text: &str) -> bool {
710    if let Some(prefix) = pattern.strip_suffix('*') {
711        let prefix = prefix.strip_suffix('*').unwrap_or(prefix);
712        text.starts_with(prefix)
713    } else {
714        pattern == text
715    }
716}
717
718/// The per-action step-up policy: a set of selector rules plus a fall-through
719/// default. Most-specific (longest matching selector) wins. This is the
720/// authoring surface behind the operator menu (*yes once / yes always / yes on
721/// passkey / no*); it composes *on top of* the `Caveats` grant — `Caveats`
722/// decides whether the authority exists, this decides what gesture admits its
723/// use.
724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
725pub struct StepUpPolicy {
726    /// Selector rules, evaluated most-specific-wins.
727    pub rules: Vec<Rule>,
728    /// The requirement for any action no rule matches.
729    pub default: AttestRequirement,
730}
731
732impl StepUpPolicy {
733    /// The empty policy: nothing ever needs a gesture. Used by the back-compat
734    /// no-step-up path so existing behavior is unchanged.
735    pub const EMPTY: Self = Self {
736        rules: Vec::new(),
737        default: AttestRequirement::NONE,
738    };
739
740    /// A policy with the given rules and default.
741    #[must_use]
742    pub fn new(rules: Vec<Rule>, default: AttestRequirement) -> Self {
743        Self { rules, default }
744    }
745
746    /// The strongest requirement matching `request` (longest selector wins), or
747    /// the policy default when none match.
748    #[must_use]
749    pub fn required_for(&self, request: &CallRequest) -> AttestRequirement {
750        let mut best: Option<&Rule> = None;
751        for rule in &self.rules {
752            if rule.matches(request) && best.is_none_or(|b| rule.selector.len() > b.selector.len())
753            {
754                best = Some(rule);
755            }
756        }
757        best.map_or_else(|| self.default.clone(), |r| r.requirement.clone())
758    }
759}
760
761impl Default for StepUpPolicy {
762    fn default() -> Self {
763        Self::EMPTY
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770    use crate::{Caveats, CountBound, Gate, Tool, ToolResult};
771    // `ToolError` is only referenced by the crypto tests below (gated), so gate
772    // its import too — otherwise `--no-default-features` flags it unused.
773    #[cfg(feature = "verifier-ed25519")]
774    use crate::ToolError;
775    #[cfg(any(feature = "verifier-ed25519", feature = "verifier-webauthn"))]
776    use ed25519_dalek::{Signer, SigningKey};
777
778    /// A trivial tool, named so policy selectors can match it.
779    struct NamedTool(&'static str);
780    #[async_trait::async_trait]
781    impl Tool for NamedTool {
782        fn name(&self) -> &str {
783            self.0
784        }
785        fn schema(&self) -> serde_json::Value {
786            serde_json::json!({})
787        }
788        async fn invoke(
789            &self,
790            _args: serde_json::Value,
791            _cx: &ToolContext,
792        ) -> ToolResult<serde_json::Value> {
793            Ok(serde_json::Value::Null)
794        }
795    }
796
797    /// Deterministic test key (a fixed seed — never a real secret). The step-up
798    /// crypto tests sign with this and verify with the production
799    /// [`Ed25519Verifier`], so they require the `verifier-ed25519` feature; the
800    /// lean `--no-default-features` build compiles them out (CI's `--all-features`
801    /// matrix runs them).
802    #[cfg(feature = "verifier-ed25519")]
803    fn test_key() -> SigningKey {
804        SigningKey::from_bytes(&[7u8; 32])
805    }
806
807    /// Build a discharge by signing the challenge for `request` at `generation`.
808    #[cfg(feature = "verifier-ed25519")]
809    fn sign_discharge(
810        key: &SigningKey,
811        request: &CallRequest,
812        generation: u64,
813        nonce: &[u8; 32],
814        presence: Presence,
815    ) -> Discharge {
816        let challenge = Challenge::bind(&request.content_id(), generation, nonce);
817        let sig = key.sign(challenge.as_bytes());
818        Discharge {
819            presence,
820            credential_id: key.verifying_key().to_bytes().to_vec(),
821            challenge: *challenge.as_bytes(),
822            signature: sig.to_bytes().to_vec(),
823            authenticator_data: None,
824            client_data_json: None,
825        }
826    }
827
828    fn push_policy() -> StepUpPolicy {
829        StepUpPolicy::new(
830            vec![Rule {
831                selector: "git.push:github.com/org/*".to_string(),
832                requirement: AttestRequirement::passkey_recorded(),
833            }],
834            AttestRequirement::NONE,
835        )
836    }
837
838    fn push_request() -> CallRequest {
839        CallRequest::new(
840            "git.push",
841            serde_json::json!({"ref": "refs/heads/main"}),
842            "github.com/org/repo",
843        )
844    }
845
846    #[test]
847    fn presence_is_totally_ordered_none_prompt_passkey() {
848        assert!(Presence::None < Presence::Prompt);
849        assert!(Presence::Prompt < Presence::Passkey);
850    }
851
852    #[test]
853    fn content_id_is_deterministic_and_argument_order_independent() {
854        let a = CallRequest::new(
855            "email.send",
856            serde_json::json!({"to": "x", "subj": "y"}),
857            "r",
858        );
859        let b = CallRequest::new(
860            "email.send",
861            serde_json::json!({"subj": "y", "to": "x"}),
862            "r",
863        );
864        assert_eq!(
865            a.content_id(),
866            b.content_id(),
867            "key order must not change identity"
868        );
869        let c = CallRequest::new(
870            "email.send",
871            serde_json::json!({"to": "z", "subj": "y"}),
872            "r",
873        );
874        assert_ne!(a.content_id(), c.content_id(), "different args must differ");
875    }
876
877    /// Regression: a step-up requirement must NOT mint a context or charge a
878    /// call — the gate withholds the leash until a proof is presented.
879    #[test]
880    fn needs_discharge_does_not_mint_or_charge() {
881        let gate = Gate::with_budget(0, CountBound::AtMost(1));
882        let granted = Caveats::top();
883        let tool = NamedTool("git.push");
884        // First, an action the policy gates: must return NeedsDischarge.
885        match gate.evaluate(&tool, &granted, &push_request(), &push_policy()) {
886            Decision::NeedsDischarge(req) => assert_eq!(req.presence, Presence::Passkey),
887            other => panic!("expected NeedsDischarge, got {other:?}"),
888        }
889        // The single budgeted call must still be available — proving the
890        // NeedsDischarge above charged nothing.
891        let free = NamedTool("free.tool");
892        match gate.evaluate(
893            &free,
894            &granted,
895            &CallRequest::unspecified("free.tool"),
896            &StepUpPolicy::EMPTY,
897        ) {
898            Decision::Allow(cx) => assert!(cx.caveats().leq(&granted)),
899            other => panic!("expected Allow, got {other:?}"),
900        }
901    }
902
903    /// A valid discharge over the bound challenge mints the context and records
904    /// the attestation; the context still carries least authority.
905    #[cfg(feature = "verifier-ed25519")]
906    #[test]
907    fn valid_discharge_mints_and_records() {
908        let gate = Gate::new(0);
909        let granted = Caveats::top();
910        let tool = NamedTool("git.push");
911        let req = push_request();
912        let nonce = [9u8; 32];
913        let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
914        let attempt = DischargeAttempt {
915            nonce,
916            discharge: &discharge,
917            verifier: &Ed25519Verifier,
918        };
919
920        let (cx, attestation) = gate
921            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
922            .expect("valid discharge authorizes");
923        assert!(cx.caveats().leq(&granted));
924        let attestation = attestation.expect("passkey+record must produce an attestation");
925        assert_eq!(attestation.tool, "git.push");
926        assert_eq!(attestation.resource, "github.com/org/repo");
927        // The record is content-addressed and stable.
928        assert_eq!(attestation.content_id(), attestation.content_id());
929    }
930
931    /// A test [`DischargeProvider`] standing in for the host ceremony: it signs
932    /// the bound challenge with a fixed key at a chosen presence (the gesture's
933    /// effect, stubbed — no real authenticator).
934    #[cfg(feature = "verifier-ed25519")]
935    struct MockProvider {
936        key: SigningKey,
937        presence: Presence,
938    }
939    #[cfg(feature = "verifier-ed25519")]
940    impl DischargeProvider for MockProvider {
941        fn obtain(
942            &self,
943            request: &CallRequest,
944            _required: &AttestRequirement,
945            generation: u64,
946            nonce: &[u8; 32],
947        ) -> Result<Discharge, String> {
948            Ok(sign_discharge(
949                &self.key,
950                request,
951                generation,
952                nonce,
953                self.presence,
954            ))
955        }
956    }
957
958    /// A provider whose ceremony fails — the human declined, or no authenticator
959    /// is present.
960    #[cfg(feature = "verifier-ed25519")]
961    struct FailingProvider;
962    #[cfg(feature = "verifier-ed25519")]
963    impl DischargeProvider for FailingProvider {
964        fn obtain(
965            &self,
966            _request: &CallRequest,
967            _required: &AttestRequirement,
968            _generation: u64,
969            _nonce: &[u8; 32],
970        ) -> Result<Discharge, String> {
971            Err("ceremony failed: human declined".into())
972        }
973    }
974
975    /// The #61 orchestration helper: a provider that produces a valid passkey
976    /// discharge drives `evaluate→obtain→authorize` to a minted context and a
977    /// recorded attestation in a single call.
978    #[cfg(feature = "verifier-ed25519")]
979    #[test]
980    fn provider_obtain_then_authorize_mints_and_records() {
981        let gate = Gate::new(0);
982        let granted = Caveats::top();
983        let tool = NamedTool("git.push");
984        let provider = MockProvider {
985            key: test_key(),
986            presence: Presence::Passkey,
987        };
988        let (cx, attestation) = gate
989            .authorize_step_up(
990                &tool,
991                &granted,
992                &push_request(),
993                &push_policy(),
994                &provider,
995                &Ed25519Verifier,
996                [11u8; 32],
997            )
998            .expect("a valid provider discharge authorizes");
999        assert!(cx.caveats().leq(&granted));
1000        let attestation = attestation.expect("passkey+record must produce an attestation");
1001        assert_eq!(attestation.tool, "git.push");
1002    }
1003
1004    /// Fail-closed: a provider whose ceremony errors makes the helper deny and
1005    /// mint/charge nothing — the single budgeted call survives (mirrors
1006    /// `needs_discharge_does_not_mint_or_charge`).
1007    #[cfg(feature = "verifier-ed25519")]
1008    #[test]
1009    fn provider_error_fails_closed() {
1010        let gate = Gate::with_budget(0, CountBound::AtMost(1));
1011        let granted = Caveats::top();
1012        let tool = NamedTool("git.push");
1013        let err = gate
1014            .authorize_step_up(
1015                &tool,
1016                &granted,
1017                &push_request(),
1018                &push_policy(),
1019                &FailingProvider,
1020                &Ed25519Verifier,
1021                [12u8; 32],
1022            )
1023            .expect_err("a failed ceremony is fail-closed");
1024        assert!(matches!(err, ToolError::Denied { .. }));
1025        // The single budgeted call is untouched — proving nothing was charged.
1026        let free = NamedTool("free.tool");
1027        match gate.evaluate(
1028            &free,
1029            &granted,
1030            &CallRequest::unspecified("free.tool"),
1031            &StepUpPolicy::EMPTY,
1032        ) {
1033            Decision::Allow(cx) => assert!(cx.caveats().leq(&granted)),
1034            other => panic!("expected Allow, got {other:?}"),
1035        }
1036    }
1037
1038    /// The verifier — not the provider — decides: a provider that only achieves
1039    /// `Prompt` cannot satisfy the policy's `Passkey` requirement, even though it
1040    /// returned `Ok`. The gate re-checks presence regardless of what the
1041    /// provider claimed (ADR 0007 D5).
1042    #[cfg(feature = "verifier-ed25519")]
1043    #[test]
1044    fn provider_below_required_presence_is_denied() {
1045        let gate = Gate::new(0);
1046        let granted = Caveats::top();
1047        let tool = NamedTool("git.push");
1048        let provider = MockProvider {
1049            key: test_key(),
1050            presence: Presence::Prompt,
1051        };
1052        let err = gate
1053            .authorize_step_up(
1054                &tool,
1055                &granted,
1056                &push_request(),
1057                &push_policy(),
1058                &provider,
1059                &Ed25519Verifier,
1060                [13u8; 32],
1061            )
1062            .expect_err("a Prompt provider cannot satisfy a Passkey policy");
1063        assert!(matches!(err, ToolError::Denied { .. }));
1064    }
1065
1066    /// When no step-up is owed, the helper degenerates to an ordinary authorize:
1067    /// it never runs the ceremony and returns no attestation.
1068    #[cfg(feature = "verifier-ed25519")]
1069    #[test]
1070    fn authorize_step_up_without_gesture_degenerates_to_authorize() {
1071        let gate = Gate::new(0);
1072        let granted = Caveats::top();
1073        let free = NamedTool("free.tool");
1074        let provider = MockProvider {
1075            key: test_key(),
1076            presence: Presence::Passkey,
1077        };
1078        let (cx, attestation) = gate
1079            .authorize_step_up(
1080                &free,
1081                &granted,
1082                &CallRequest::unspecified("free.tool"),
1083                &StepUpPolicy::EMPTY,
1084                &provider,
1085                &Ed25519Verifier,
1086                [14u8; 32],
1087            )
1088            .expect("no gesture owed → ordinary authorize");
1089        assert!(cx.caveats().leq(&granted));
1090        assert!(attestation.is_none(), "no step-up → no attestation");
1091    }
1092
1093    /// Anti-theater: a discharge signed for a DIFFERENT action (different nonce)
1094    /// is rejected. This fails only because the challenge is bound to the exact
1095    /// action — a generic gesture would pass.
1096    #[cfg(feature = "verifier-ed25519")]
1097    #[test]
1098    fn wrong_challenge_is_denied() {
1099        let gate = Gate::new(0);
1100        let granted = Caveats::top();
1101        let tool = NamedTool("git.push");
1102        let req = push_request();
1103        // Sign over a different nonce than the one the gate will recompute with.
1104        let discharge = sign_discharge(&test_key(), &req, 0, &[1u8; 32], Presence::Passkey);
1105        let attempt = DischargeAttempt {
1106            nonce: [2u8; 32], // gate's nonce differs → expected challenge differs
1107            discharge: &discharge,
1108            verifier: &Ed25519Verifier,
1109        };
1110        let err = gate
1111            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1112            .expect_err("mismatched challenge must be denied");
1113        assert!(matches!(err, ToolError::Denied { .. }));
1114    }
1115
1116    /// Fail-closed: a too-weak gesture (Prompt) cannot satisfy a Passkey
1117    /// requirement.
1118    #[cfg(feature = "verifier-ed25519")]
1119    #[test]
1120    fn presence_too_weak_fails_closed() {
1121        let gate = Gate::new(0);
1122        let granted = Caveats::top();
1123        let tool = NamedTool("git.push");
1124        let req = push_request();
1125        let nonce = [4u8; 32];
1126        // Correct challenge, but only Prompt strength.
1127        let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Prompt);
1128        let attempt = DischargeAttempt {
1129            nonce,
1130            discharge: &discharge,
1131            verifier: &Ed25519Verifier,
1132        };
1133        let err = gate
1134            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1135            .expect_err("Prompt cannot satisfy Passkey");
1136        assert!(matches!(err, ToolError::Denied { .. }));
1137    }
1138
1139    /// ADR 0007 D2/D3 (design §10 Q3): the **no-authenticator** case. A
1140    /// `Presence::None` discharge — "no hardware gesture was achievable" — over
1141    /// the *correctly bound* challenge still cannot satisfy a `Passkey`
1142    /// requirement. This isolates the presence floor (the challenge matches, so
1143    /// the only reason to deny is presence), and is distinct from
1144    /// `presence_too_weak_fails_closed` (which covers `Prompt`): it proves the
1145    /// gate fails closed and never silently downgrades when *no* presence is
1146    /// achievable, rather than only when a weaker-but-nonzero one is.
1147    #[cfg(feature = "verifier-ed25519")]
1148    #[test]
1149    fn no_authenticator_presence_none_cannot_satisfy_passkey() {
1150        let gate = Gate::new(0);
1151        let granted = Caveats::top();
1152        let tool = NamedTool("git.push");
1153        let req = push_request();
1154        let nonce = [5u8; 32];
1155        // Correctly bound challenge (same nonce the gate recomputes with), but
1156        // the achieved presence is None — the "no authenticator available" case.
1157        let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::None);
1158        let attempt = DischargeAttempt {
1159            nonce,
1160            discharge: &discharge,
1161            verifier: &Ed25519Verifier,
1162        };
1163        let err = gate
1164            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1165            .expect_err("Presence::None cannot satisfy Passkey");
1166        assert!(matches!(err, ToolError::Denied { .. }));
1167    }
1168
1169    /// #62 acceptance: the **production** [`Ed25519Verifier`] accepts a discharge
1170    /// signed over the bound challenge (and mints), rejects one signed over a
1171    /// different nonce (anti-theater), and rejects a `Prompt` gesture against a
1172    /// `Passkey` requirement (fail-closed). This exercises the public, exported
1173    /// type — no test-only verifier exists on this path.
1174    #[cfg(feature = "verifier-ed25519")]
1175    #[test]
1176    fn ed25519_verifier_accepts_valid_and_rejects_wrong_challenge() {
1177        let gate = Gate::new(0);
1178        let granted = Caveats::top();
1179        let tool = NamedTool("git.push");
1180        let req = push_request();
1181
1182        // Accept: a passkey discharge over the gate-recomputed challenge mints.
1183        let nonce = [21u8; 32];
1184        let ok = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1185        let attempt = DischargeAttempt {
1186            nonce,
1187            discharge: &ok,
1188            verifier: &Ed25519Verifier,
1189        };
1190        let (cx, attestation) = gate
1191            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1192            .expect("a valid ed25519 discharge authorizes");
1193        assert!(cx.caveats().leq(&granted));
1194        assert!(
1195            attestation.is_some(),
1196            "passkey+record produces an attestation"
1197        );
1198
1199        // Reject: signed over a different nonce than the gate recomputes.
1200        let bad = sign_discharge(&test_key(), &req, 0, &[99u8; 32], Presence::Passkey);
1201        let bad_attempt = DischargeAttempt {
1202            nonce: [22u8; 32], // gate's nonce differs → expected challenge differs
1203            discharge: &bad,
1204            verifier: &Ed25519Verifier,
1205        };
1206        let err = gate
1207            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &bad_attempt)
1208            .expect_err("wrong challenge is denied");
1209        assert!(matches!(err, ToolError::Denied { .. }));
1210
1211        // Reject: a Prompt gesture cannot satisfy a Passkey requirement.
1212        let weak_nonce = [23u8; 32];
1213        let weak = sign_discharge(&test_key(), &req, 0, &weak_nonce, Presence::Prompt);
1214        let weak_attempt = DischargeAttempt {
1215            nonce: weak_nonce,
1216            discharge: &weak,
1217            verifier: &Ed25519Verifier,
1218        };
1219        let err = gate
1220            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &weak_attempt)
1221            .expect_err("Prompt cannot satisfy Passkey");
1222        assert!(matches!(err, ToolError::Denied { .. }));
1223    }
1224
1225    /// #63 regression: a verified discharge is **single-use** — re-presenting the
1226    /// same valid `DischargeAttempt` is denied as a replay, and the replay charges
1227    /// no budget. This FAILS on the pre-ledger code (which minted twice).
1228    #[cfg(feature = "verifier-ed25519")]
1229    #[test]
1230    fn discharge_is_single_use_replay_is_denied() {
1231        let gate = Gate::with_budget(0, CountBound::AtMost(2));
1232        let granted = Caveats::top();
1233        let tool = NamedTool("git.push");
1234        let req = push_request();
1235        let nonce = [31u8; 32];
1236        let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1237        let attempt = DischargeAttempt {
1238            nonce,
1239            discharge: &discharge,
1240            verifier: &Ed25519Verifier,
1241        };
1242        // First presentation authorizes (spends 1 of 2 budget).
1243        gate.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1244            .expect("first discharge authorizes");
1245        // Re-presenting the SAME discharge is a replay → denied.
1246        let err = gate
1247            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1248            .expect_err("replay of the same discharge is denied");
1249        assert!(matches!(err, ToolError::Denied { .. }));
1250        // The replay charged no budget: a budgeted call still remains.
1251        let free = NamedTool("free.tool");
1252        match gate.evaluate(
1253            &free,
1254            &granted,
1255            &CallRequest::unspecified("free.tool"),
1256            &StepUpPolicy::EMPTY,
1257        ) {
1258            Decision::Allow(_) => {}
1259            other => panic!("expected Allow (budget survived the replay), got {other:?}"),
1260        }
1261    }
1262
1263    /// #63: `freshness_generations: 0` requires the *current* generation — a
1264    /// discharge bound to an earlier generation is denied (the default
1265    /// `passkey_recorded()` requirement is freshness 0).
1266    #[cfg(feature = "verifier-ed25519")]
1267    #[test]
1268    fn freshness_generations_zero_requires_current_generation() {
1269        let gate = Gate::new(1); // current generation is 1
1270        let granted = Caveats::top();
1271        let tool = NamedTool("git.push");
1272        let req = push_request();
1273        let nonce = [32u8; 32];
1274        // Signed for generation 0 — one behind the gate, outside a zero window.
1275        let stale = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1276        let attempt = DischargeAttempt {
1277            nonce,
1278            discharge: &stale,
1279            verifier: &Ed25519Verifier,
1280        };
1281        let err = gate
1282            .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1283            .expect_err("a stale discharge fails freshness_generations: 0");
1284        assert!(matches!(err, ToolError::Denied { .. }));
1285    }
1286
1287    /// #63: with `freshness_generations: 1`, a discharge bound to the previous
1288    /// generation IS accepted — proving the field demonstrably affects behavior
1289    /// (the window includes generation `g-1`).
1290    #[cfg(feature = "verifier-ed25519")]
1291    #[test]
1292    fn freshness_generations_window_accepts_recent() {
1293        let gate = Gate::new(1);
1294        let granted = Caveats::top();
1295        let tool = NamedTool("git.push");
1296        let req = push_request();
1297        let policy = StepUpPolicy::new(
1298            vec![Rule {
1299                selector: "git.push:github.com/org/*".to_string(),
1300                requirement: AttestRequirement {
1301                    presence: Presence::Passkey,
1302                    record: true,
1303                    freshness_generations: 1,
1304                },
1305            }],
1306            AttestRequirement::NONE,
1307        );
1308        let nonce = [33u8; 32];
1309        // Signed for generation 0; gate at 1, window = 1 → in range.
1310        let recent = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1311        let attempt = DischargeAttempt {
1312            nonce,
1313            discharge: &recent,
1314            verifier: &Ed25519Verifier,
1315        };
1316        let (cx, attestation) = gate
1317            .authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
1318            .expect("a one-generation-old discharge is within the window");
1319        assert!(cx.caveats().leq(&granted));
1320        assert!(attestation.is_some());
1321    }
1322
1323    /// #63: single-use is concurrency-safe — two threads presenting the SAME
1324    /// discharge against one shared gate yield exactly one success.
1325    #[cfg(feature = "verifier-ed25519")]
1326    #[test]
1327    fn discharge_single_use_is_concurrency_safe() {
1328        use std::sync::Arc;
1329        use std::thread;
1330
1331        let gate = Arc::new(Gate::with_budget(0, CountBound::AtMost(2)));
1332        let granted = Caveats::top();
1333        let tool = NamedTool("git.push");
1334        let req = push_request();
1335        let policy = push_policy();
1336        let nonce = [34u8; 32];
1337        let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1338
1339        let (r1, r2) = thread::scope(|s| {
1340            let h1 = s.spawn(|| {
1341                let attempt = DischargeAttempt {
1342                    nonce,
1343                    discharge: &discharge,
1344                    verifier: &Ed25519Verifier,
1345                };
1346                gate.authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
1347                    .is_ok()
1348            });
1349            let h2 = s.spawn(|| {
1350                let attempt = DischargeAttempt {
1351                    nonce,
1352                    discharge: &discharge,
1353                    verifier: &Ed25519Verifier,
1354                };
1355                gate.authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
1356                    .is_ok()
1357            });
1358            (h1.join().unwrap(), h2.join().unwrap())
1359        });
1360        assert_eq!(
1361            [r1, r2].iter().filter(|ok| **ok).count(),
1362            1,
1363            "exactly one of two concurrent identical discharges succeeds"
1364        );
1365    }
1366
1367    #[test]
1368    fn policy_most_specific_wins_and_default_applies() {
1369        let policy = StepUpPolicy::new(
1370            vec![
1371                Rule {
1372                    selector: "fs.delete:/tmp/*".to_string(),
1373                    requirement: AttestRequirement::presence(Presence::Prompt),
1374                },
1375                Rule {
1376                    selector: "fs.delete:/tmp/important/*".to_string(),
1377                    requirement: AttestRequirement::presence(Presence::Passkey),
1378                },
1379            ],
1380            AttestRequirement::NONE,
1381        );
1382        // Longer selector wins for the nested path.
1383        let nested = CallRequest::new("fs.delete", serde_json::Value::Null, "/tmp/important/x");
1384        assert_eq!(policy.required_for(&nested).presence, Presence::Passkey);
1385        // Broad path matches only the short rule.
1386        let broad = CallRequest::new("fs.delete", serde_json::Value::Null, "/tmp/scratch");
1387        assert_eq!(policy.required_for(&broad).presence, Presence::Prompt);
1388        // Unmatched tool falls through to the default.
1389        let other = CallRequest::new("email.read", serde_json::Value::Null, "inbox");
1390        assert_eq!(policy.required_for(&other).presence, Presence::None);
1391    }
1392
1393    // ── WebAuthn verifier (#72, verifier-webauthn) ───────────────────────────
1394
1395    /// authenticatorData flag bits.
1396    #[cfg(feature = "verifier-webauthn")]
1397    const UP: u8 = 0x01; // User Present
1398    #[cfg(feature = "verifier-webauthn")]
1399    const UV: u8 = 0x04; // User Verified
1400
1401    /// Build a WebAuthn (EdDSA) assertion discharge over `challenge`: a
1402    /// `webauthn.get` clientDataJSON carrying the base64url challenge, a 37-byte
1403    /// authenticatorData whose flag byte is `flags`, and an Ed25519 signature
1404    /// over `authData ‖ SHA-256(clientDataJSON)`. A fixture builder — no live
1405    /// authenticator (acceptance criteria: canned vectors, no hardware).
1406    #[cfg(feature = "verifier-webauthn")]
1407    fn webauthn_discharge(
1408        key: &SigningKey,
1409        challenge: &Challenge,
1410        presence: Presence,
1411        flags: u8,
1412    ) -> Discharge {
1413        use sha2::{Digest, Sha256};
1414        let client_data = format!(
1415            r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://example.org"}}"#,
1416            base64url_nopad(challenge.as_bytes())
1417        )
1418        .into_bytes();
1419        // rpIdHash[32] ‖ flags[1] ‖ signCount[4]
1420        let mut auth_data = vec![0u8; 37];
1421        auth_data[32] = flags;
1422        let mut signed = auth_data.clone();
1423        signed.extend_from_slice(&Sha256::digest(&client_data));
1424        let sig = key.sign(&signed);
1425        Discharge {
1426            presence,
1427            credential_id: key.verifying_key().to_bytes().to_vec(),
1428            challenge: *challenge.as_bytes(),
1429            signature: sig.to_bytes().to_vec(),
1430            authenticator_data: Some(auth_data),
1431            client_data_json: Some(client_data),
1432        }
1433    }
1434
1435    #[cfg(feature = "verifier-webauthn")]
1436    fn webauthn_challenge(nonce: u8) -> Challenge {
1437        Challenge::bind(&push_request().content_id(), 0, &[nonce; 32])
1438    }
1439
1440    #[cfg(feature = "verifier-webauthn")]
1441    #[test]
1442    fn webauthn_accepts_valid_passkey_assertion() {
1443        let key = SigningKey::from_bytes(&[9u8; 32]);
1444        let challenge = webauthn_challenge(3);
1445        let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP | UV);
1446        assert!(WebAuthnVerifier
1447            .verify(
1448                &d,
1449                &AttestRequirement::presence(Presence::Passkey),
1450                &challenge
1451            )
1452            .is_ok());
1453    }
1454
1455    #[cfg(feature = "verifier-webauthn")]
1456    #[test]
1457    fn webauthn_rejects_tampered_challenge() {
1458        let key = SigningKey::from_bytes(&[9u8; 32]);
1459        // Signed over one challenge; the gate recomputed a different one.
1460        let d = webauthn_discharge(&key, &webauthn_challenge(3), Presence::Passkey, UP | UV);
1461        let err = WebAuthnVerifier
1462            .verify(
1463                &d,
1464                &AttestRequirement::presence(Presence::Passkey),
1465                &webauthn_challenge(4),
1466            )
1467            .unwrap_err();
1468        assert!(err.contains("challenge"), "{err}");
1469    }
1470
1471    #[cfg(feature = "verifier-webauthn")]
1472    #[test]
1473    fn webauthn_rejects_cleared_up_flag() {
1474        let key = SigningKey::from_bytes(&[9u8; 32]);
1475        let challenge = webauthn_challenge(3);
1476        // UV set but UP cleared — UP is mandatory, so reject.
1477        let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UV);
1478        let err = WebAuthnVerifier
1479            .verify(
1480                &d,
1481                &AttestRequirement::presence(Presence::Passkey),
1482                &challenge,
1483            )
1484            .unwrap_err();
1485        assert!(err.contains("User-Presence"), "{err}");
1486    }
1487
1488    #[cfg(feature = "verifier-webauthn")]
1489    #[test]
1490    fn webauthn_passkey_requires_uv() {
1491        let key = SigningKey::from_bytes(&[9u8; 32]);
1492        let challenge = webauthn_challenge(3);
1493        // UP set, UV clear: fine for a Prompt requirement, but a Passkey
1494        // requirement demands the verified gesture.
1495        let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP);
1496        let err = WebAuthnVerifier
1497            .verify(
1498                &d,
1499                &AttestRequirement::presence(Presence::Passkey),
1500                &challenge,
1501            )
1502            .unwrap_err();
1503        assert!(err.contains("User-Verification"), "{err}");
1504    }
1505
1506    /// A `Prompt`-strength discharge can never satisfy a `Passkey` requirement —
1507    /// rejected before any crypto (mirrors `presence_too_weak_fails_closed`).
1508    #[cfg(feature = "verifier-webauthn")]
1509    #[test]
1510    fn webauthn_presence_too_weak_fails_closed() {
1511        let key = SigningKey::from_bytes(&[9u8; 32]);
1512        let challenge = webauthn_challenge(3);
1513        let d = webauthn_discharge(&key, &challenge, Presence::Prompt, UP | UV);
1514        let err = WebAuthnVerifier
1515            .verify(
1516                &d,
1517                &AttestRequirement::presence(Presence::Passkey),
1518                &challenge,
1519            )
1520            .unwrap_err();
1521        assert!(err.contains("presence"), "{err}");
1522    }
1523
1524    #[cfg(feature = "verifier-webauthn")]
1525    #[test]
1526    fn webauthn_rejects_forged_signature() {
1527        let key = SigningKey::from_bytes(&[9u8; 32]);
1528        let challenge = webauthn_challenge(3);
1529        let mut d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP | UV);
1530        d.signature[0] ^= 0xff; // a one-bit-flipped (forged) signature
1531        assert!(WebAuthnVerifier
1532            .verify(
1533                &d,
1534                &AttestRequirement::presence(Presence::Passkey),
1535                &challenge
1536            )
1537            .is_err());
1538    }
1539
1540    // ── ES256 (COSE alg -7) — the real-platform-passkey sibling ──────────────
1541    // Mirrors the EdDSA WebAuthn vectors with a deterministic P-256 signer, so
1542    // the shared gating is proven identical and the ES256 signature suite works.
1543
1544    #[cfg(feature = "verifier-webauthn-es256")]
1545    fn es256_key() -> p256::ecdsa::SigningKey {
1546        // Fixed scalar → deterministic (RFC6979) signatures; no hardware/rng.
1547        p256::ecdsa::SigningKey::from_slice(&[0x42u8; 32]).unwrap()
1548    }
1549
1550    #[cfg(feature = "verifier-webauthn-es256")]
1551    fn es256_challenge(nonce: u8) -> Challenge {
1552        Challenge::bind(
1553            &CallRequest::unspecified("es256.test").content_id(),
1554            0,
1555            &[nonce; 32],
1556        )
1557    }
1558
1559    /// Build a WebAuthn (ES256) assertion over `challenge`: `webauthn.get`
1560    /// clientDataJSON, a 37-byte authenticatorData with flag byte `flags`, and an
1561    /// ASN.1-DER ECDSA-P256 signature over `authData ‖ SHA-256(clientDataJSON)`.
1562    #[cfg(feature = "verifier-webauthn-es256")]
1563    fn webauthn_es256_discharge(
1564        key: &p256::ecdsa::SigningKey,
1565        challenge: &Challenge,
1566        presence: Presence,
1567        flags: u8,
1568    ) -> Discharge {
1569        use p256::ecdsa::{signature::Signer, Signature};
1570        use sha2::{Digest, Sha256};
1571        let client_data = format!(
1572            r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://example.org"}}"#,
1573            base64url_nopad(challenge.as_bytes())
1574        )
1575        .into_bytes();
1576        let mut auth_data = vec![0u8; 37];
1577        auth_data[32] = flags;
1578        let mut signed = auth_data.clone();
1579        signed.extend_from_slice(&Sha256::digest(&client_data));
1580        let sig: Signature = key.sign(&signed);
1581        Discharge {
1582            presence,
1583            credential_id: key
1584                .verifying_key()
1585                .to_encoded_point(false)
1586                .as_bytes()
1587                .to_vec(),
1588            challenge: *challenge.as_bytes(),
1589            signature: sig.to_der().as_bytes().to_vec(),
1590            authenticator_data: Some(auth_data),
1591            client_data_json: Some(client_data),
1592        }
1593    }
1594
1595    #[cfg(feature = "verifier-webauthn-es256")]
1596    #[test]
1597    fn webauthn_es256_accepts_valid_passkey_assertion() {
1598        let key = es256_key();
1599        let challenge = es256_challenge(3);
1600        let d = webauthn_es256_discharge(&key, &challenge, Presence::Passkey, 0x01 | 0x04);
1601        assert!(WebAuthnEs256Verifier
1602            .verify(
1603                &d,
1604                &AttestRequirement::presence(Presence::Passkey),
1605                &challenge
1606            )
1607            .is_ok());
1608    }
1609
1610    #[cfg(feature = "verifier-webauthn-es256")]
1611    #[test]
1612    fn webauthn_es256_rejects_tampered_challenge() {
1613        let key = es256_key();
1614        let d = webauthn_es256_discharge(&key, &es256_challenge(3), Presence::Passkey, 0x05);
1615        let err = WebAuthnEs256Verifier
1616            .verify(
1617                &d,
1618                &AttestRequirement::presence(Presence::Passkey),
1619                &es256_challenge(4),
1620            )
1621            .unwrap_err();
1622        assert!(err.contains("challenge"), "{err}");
1623    }
1624
1625    #[cfg(feature = "verifier-webauthn-es256")]
1626    #[test]
1627    fn webauthn_es256_passkey_requires_uv() {
1628        let key = es256_key();
1629        let challenge = es256_challenge(3);
1630        // UP set, UV clear — a Passkey requirement demands the verified gesture.
1631        let d = webauthn_es256_discharge(&key, &challenge, Presence::Passkey, 0x01);
1632        let err = WebAuthnEs256Verifier
1633            .verify(
1634                &d,
1635                &AttestRequirement::presence(Presence::Passkey),
1636                &challenge,
1637            )
1638            .unwrap_err();
1639        assert!(err.contains("User-Verification"), "{err}");
1640    }
1641
1642    #[cfg(feature = "verifier-webauthn-es256")]
1643    #[test]
1644    fn webauthn_es256_rejects_forged_signature() {
1645        let key = es256_key();
1646        let challenge = es256_challenge(3);
1647        let mut d = webauthn_es256_discharge(&key, &challenge, Presence::Passkey, 0x05);
1648        let n = d.signature.len();
1649        d.signature[n - 1] ^= 0xff; // corrupt the DER signature
1650        assert!(WebAuthnEs256Verifier
1651            .verify(
1652                &d,
1653                &AttestRequirement::presence(Presence::Passkey),
1654                &challenge
1655            )
1656            .is_err());
1657    }
1658
1659    #[cfg(feature = "verifier-webauthn-es256")]
1660    #[test]
1661    fn webauthn_es256_rejects_wrong_key() {
1662        // An assertion by one key must not verify against another's public key.
1663        let challenge = es256_challenge(3);
1664        let mut d = webauthn_es256_discharge(&es256_key(), &challenge, Presence::Passkey, 0x05);
1665        let other = p256::ecdsa::SigningKey::from_slice(&[0x43u8; 32]).unwrap();
1666        d.credential_id = other
1667            .verifying_key()
1668            .to_encoded_point(false)
1669            .as_bytes()
1670            .to_vec();
1671        assert!(WebAuthnEs256Verifier
1672            .verify(
1673                &d,
1674                &AttestRequirement::presence(Presence::Passkey),
1675                &challenge
1676            )
1677            .is_err());
1678    }
1679}