Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-auth-webauthn/src/pure.rs

//! The **pure-Rust** WebAuthn backend — no FFI, no C toolchain, no system libssl.
//!
//! The `webauthn-rs`-backed provider that once stood beside it is deleted
//! (A34-32). This one is the right choice for an **airgapped appliance**, and the reason is measurable
//! rather than aesthetic.
//!
//! Measured against holger's real `full` tier (`cargo tree -p holger-server-lib
//! --no-default-features --features full`, 2026-09-17):
//!
//! | backend | crates ADDED | C / FFI | licence |
//! |---|---|---|---|
//! | `webauthn-rs` 0.5 | **41** | `openssl` + `openssl-sys` + `foreign-types` — system libssl at BUILD time | MPL-2.0 |
//! | `p256` + `ciborium` (here) | **24** | **none** | MIT / Apache-2.0 |
//!
//! Note what is NOT the argument: holger's `deny.toml` explicitly allows MPL-2.0,
//! so the licence is not the disqualifier. The disqualifier is `openssl-sys` — a C
//! library that must be present on the build host, in an artifact whose entire
//! purpose is to be built and shipped into a sealed environment. THE LAW permits
//! FFI only when no Rust library exists, and one does.
//!
//! ## What this backend deliberately does NOT do
//!
//! **Attestation is parsed but not verified.** An attestation chain answers "was
//! this credential made by a genuine YubiKey model X", which is a question about
//! the *manufacturer*. In an airgapped appliance the keys are ones an admin
//! enrolled in person, standing at the box; the question nobody is asking is which
//! vendor made them, and answering it would cost the x509 + CA-store closure this
//! module exists to avoid. The same reasoning is written into gunnar's console,
//! which has run this shape in production.
//!
//! **ES256 on P-256 only** (COSE `alg = -7`). It is the one algorithm every
//! authenticator that matters supports, it is mandatory-to-implement for platform
//! authenticators, and every additional curve is more attack surface for a case
//! that does not arise. A credential offering anything else is refused at
//! registration, loudly, rather than at login, mysteriously.
//!
//! ## The ceremony is stateful; the CREDENTIAL is not
//!
//! A WebAuthn login is two round trips, and a nordisk auth seam
//! (`validate_request(config, bearer, client_cert_cn)`) is one stateless credential
//! per request. Those are reconciled by noticing that **the ceremony is not the
//! credential**: the ceremony runs on the server's own HTTP door and, on success,
//! mints a MAC'd [`SessionToken`]. That token is an ordinary bearer, so every
//! request after the login walks the seam that already exists. This module
//! therefore ships both halves — the ceremony crypto and the session token — and
//! the caller decides what a verified assertion grants.

use std::time::{SystemTime, UNIX_EPOCH};

use base64::Engine as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

/// base64url, no padding — the encoding WebAuthn uses for every binary field it
/// puts in JSON.
pub const B64: base64::engine::general_purpose::GeneralPurpose =
    base64::engine::general_purpose::URL_SAFE_NO_PAD;

// ─── Authenticator-data flag bits (WebAuthn §6.1) ────────────────────────────

// User Present (the person touched the authenticator), User Verified (a PIN or
// a biometric, not just a touch: what a step-up decision may rely on), Attested
// credential data (registration responses only). Shared with `ceremony`.
use crate::es256::{FLAG_AT, FLAG_UP, FLAG_UV};

/// Why a ceremony was refused.
///
/// Every variant is a REFUSAL; there is no "probably fine" outcome. The variants
/// are distinguished so a server can log precisely what happened without handing
/// the distinction to the caller — an authenticator that learns *which* check it
/// failed is being told how to pass it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CeremonyError {
    /// The relying-party configuration cannot work in a browser.
    RelyingParty(String),
    /// `clientDataJSON` is absent, not JSON, or the wrong ceremony type.
    ClientData(&'static str),
    /// The response does not carry this ceremony's challenge — a replay, a stale
    /// tab, or a ceremony that was already spent.
    Challenge,
    /// The browser origin is not this relying party's.
    Origin,
    /// `authenticatorData` is malformed, truncated, or for a different `rp_id`.
    AuthData(&'static str),
    /// The attestation object or the COSE public key could not be read.
    Attestation(&'static str),
    /// The credential offers an algorithm this backend refuses (anything but
    /// ES256 on P-256).
    UnsupportedAlgorithm,
    /// A required flag was not set — user presence, or user verification when the
    /// caller demanded it.
    MissingFlag(&'static str),
    /// The assertion signature did not verify against the stored public key.
    BadSignature,
    /// The authenticator's signature counter went backwards or stood still: the
    /// hallmark of a CLONED credential.
    CounterRollback { stored: u32, presented: u32 },
}

impl std::fmt::Display for CeremonyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CeremonyError::RelyingParty(m) => write!(f, "relying party: {m}"),
            CeremonyError::ClientData(m) => write!(f, "clientDataJSON: {m}"),
            CeremonyError::Challenge => write!(f, "the response does not carry this ceremony's challenge"),
            CeremonyError::Origin => write!(f, "the response has the wrong browser origin"),
            CeremonyError::AuthData(m) => write!(f, "authenticatorData: {m}"),
            CeremonyError::Attestation(m) => write!(f, "attestationObject: {m}"),
            CeremonyError::UnsupportedAlgorithm => {
                write!(f, "only ES256 keys on P-256 are accepted")
            }
            CeremonyError::MissingFlag(m) => write!(f, "the {m} flag is missing"),
            CeremonyError::BadSignature => write!(f, "the assertion signature did not verify"),
            CeremonyError::CounterRollback { stored, presented } => write!(
                f,
                "the signature counter went backwards ({presented} after {stored}) — \
                 this credential may have been cloned"
            ),
        }
    }
}

impl std::error::Error for CeremonyError {}

// ─── The relying party ───────────────────────────────────────────────────────

/// Where the relying party lives, **as the browser sees it** — validated once, at
/// construction, so an unusable configuration is a boot failure and never a
/// mysterious login failure.
///
/// ## `rp_id` is the phishing boundary, so it is CONFIGURATION ONLY
///
/// A credential is scoped by the authenticator to the `rp_id`, and the browser
/// refuses to release it to any other. That makes `rp_id` the one value in this
/// module an attacker would most like to choose — which is exactly why it must
/// never be derived from a request. A server that read it from the `Host` header
/// would let anyone who can set that header (including a merely misconfigured
/// reverse proxy) pick the boundary that is supposed to contain them.
///
/// ## The relocation property, stated rather than worked around
///
/// A credential is bound to `rp_id` **forever**. An appliance that moves from
/// `holger.lab.internal` to `holger.prod.internal` has invalidated every key
/// registered against it, and no amount of code here changes that — it is what
/// binding a credential to a name MEANS. The honest mitigation is at the
/// deployment level: passkeys are one auth method among several, so mTLS and a
/// static token keep a renamed box reachable for re-enrolment. A server that
/// offers passkeys as its ONLY method has built a brick waiting for a rename.
#[derive(Debug, Clone)]
pub struct PureRelyingParty {
    rp_id: String,
    rp_origin: String,
    rp_name: String,
    /// `SHA256(rp_id)`, precomputed: every assertion compares against it, and
    /// hashing a short string per request is a pointless cost in a hot path.
    rp_id_hash: [u8; 32],
}

impl PureRelyingParty {
    /// Validate and bind a relying party.
    ///
    /// # Errors
    ///
    /// Refuses the two configurations that cannot work in a browser, and refuses
    /// them here so the failure is at boot:
    ///
    /// * an **IP-literal `rp_id`** — WebAuthn requires a domain, so an appliance
    ///   reached at `https://10.0.0.7` can never register a key;
    /// * an **origin that does not belong to the `rp_id`** — the origin is what an
    ///   assertion is checked against and `rp_id` is what the credential is scoped
    ///   to, so if the two may disagree, the check that binds a credential to this
    ///   server is checking something else.
    pub fn new(rp_id: &str, rp_origin: &str, rp_name: &str) -> Result<Self, CeremonyError> {
        let rp_id = rp_id.trim();
        let rp_origin = rp_origin.trim().trim_end_matches('/');
        validate_rp_id(rp_id).map_err(CeremonyError::RelyingParty)?;
        validate_origin_for_rp_id(rp_origin, rp_id).map_err(CeremonyError::RelyingParty)?;
        Ok(Self {
            rp_id: rp_id.to_owned(),
            rp_origin: rp_origin.to_owned(),
            rp_name: if rp_name.trim().is_empty() {
                rp_id.to_owned()
            } else {
                rp_name.trim().to_owned()
            },
            rp_id_hash: Sha256::digest(rp_id.as_bytes()).into(),
        })
    }

    /// The registrable domain handed to the authenticator.
    pub fn rp_id(&self) -> &str {
        &self.rp_id
    }
    /// The exact browser origin every assertion is checked against.
    pub fn rp_origin(&self) -> &str {
        &self.rp_origin
    }
    /// The human-facing name the authenticator shows the operator.
    pub fn rp_name(&self) -> &str {
        &self.rp_name
    }
}

// The two relying-party validators used to live here. They moved to
// `nornir_service::passkey_rp`, which is the one crate BOTH this leaf and
// `nornir-auth-policy` already depend on — because a server's BOOT GATE has to
// ask "can this relying party work at all" in a build that pinned no crypto
// backend, and reaching into an optional dependency to ask is how a check ends up
// existing in two versions that disagree. They are pure string work with no
// dependencies, so the seam crate carries them at no cost.
pub use nornir_service::passkey_rp::{validate_origin_for_rp_id, validate_rp_id};

// ─── A registered credential ─────────────────────────────────────────────────

/// One registered passkey, as the server stores it.
///
/// The public key is kept as raw P-256 affine coordinates rather than as a
/// re-encoded SEC1/DER blob so that what is persisted is exactly what the
/// authenticator attested, with no encoder in between to disagree about it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegisteredCredential {
    /// base64url credential id — what the browser echoes back at login.
    pub id: String,
    /// P-256 public key X coordinate, exactly 32 bytes.
    pub x: Vec<u8>,
    /// P-256 public key Y coordinate, exactly 32 bytes.
    pub y: Vec<u8>,
    /// The authenticator's signature counter as of the last accepted assertion.
    /// Monotonic per credential; see [`CeremonyError::CounterRollback`].
    pub sign_count: u32,
}

/// What a verified assertion tells the caller beyond "it was genuine".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifiedAssertion {
    /// The counter the authenticator presented. The caller MUST persist this back
    /// onto the credential, or the clone check degrades to nothing.
    pub sign_count: u32,
    /// Whether the authenticator verified the USER (PIN / biometric), not merely
    /// that somebody touched it. A step-up decision may rely on this; a plain
    /// login need not.
    pub user_verified: bool,
}

// ─── Registration ────────────────────────────────────────────────────────────

/// Verify a **registration** (credential-create) response and extract the
/// credential to store.
///
/// `attestation_object` and `client_data` are the raw bytes the browser returned
/// (the caller has already base64url-decoded them); `challenge_b64` is the
/// challenge this server minted for this ceremony and has not yet spent.
///
/// Attestation is parsed for the credential it carries and is **not** verified as
/// a chain — see the module header for why that is a decision and not an omission.
///
/// # Errors
///
/// Every check in WebAuthn §7.1 that this backend takes a position on: ceremony
/// type, challenge, origin, `rp_id` hash, user presence, and the credential
/// algorithm.
pub fn verify_registration(
    rp: &PureRelyingParty,
    attestation_object: &[u8],
    client_data: &[u8],
    challenge_b64: &str,
    require_user_verification: bool,
) -> Result<RegisteredCredential, CeremonyError> {
    check_client_data(client_data, "webauthn.create", challenge_b64, rp.rp_origin())?;

    let obj: ciborium::value::Value = ciborium::from_reader(attestation_object)
        .map_err(|_| CeremonyError::Attestation("not CBOR"))?;
    let auth_data = cbor_text_key(&obj, "authData")
        .and_then(|v| v.as_bytes())
        .ok_or(CeremonyError::Attestation("no authData"))?
        .clone();

    let (count, flags) = check_auth_data(&auth_data, &rp.rp_id_hash, require_user_verification)?;
    if flags & FLAG_AT == 0 {
        return Err(CeremonyError::Attestation(
            "carries no attested credential data",
        ));
    }

    // attestedCredentialData = aaguid[16] | credIdLen[2] | credId | COSEKey
    // It begins at offset 37, so the length field sits at 53..55.
    if auth_data.len() < 55 {
        return Err(CeremonyError::AuthData("truncated before the credential id"));
    }
    let cred_id_len = u16::from_be_bytes([auth_data[53], auth_data[54]]) as usize;
    let id_start: usize = 55;
    let id_end = id_start
        .checked_add(cred_id_len)
        .ok_or(CeremonyError::AuthData("credential id length overflows"))?;
    if auth_data.len() < id_end {
        return Err(CeremonyError::AuthData("truncates the credential id"));
    }
    let cred_id = &auth_data[id_start..id_end];

    let cose: ciborium::value::Value = ciborium::from_reader(&auth_data[id_end..])
        .map_err(|_| CeremonyError::Attestation("the COSE key is not CBOR"))?;
    // COSE_Key for EC2: kty(1)=2, alg(3)=-7 (ES256), crv(-1)=1 (P-256), x(-2), y(-3).
    let kty = cbor_int_key(&cose, 1).and_then(cbor_as_int);
    let alg = cbor_int_key(&cose, 3).and_then(cbor_as_int);
    let crv = cbor_int_key(&cose, -1).and_then(cbor_as_int);
    if kty != Some(2) || alg != Some(-7) || crv != Some(1) {
        return Err(CeremonyError::UnsupportedAlgorithm);
    }
    let x = cbor_int_key(&cose, -2).and_then(|v| v.as_bytes()).cloned();
    let y = cbor_int_key(&cose, -3).and_then(|v| v.as_bytes()).cloned();
    let (Some(x), Some(y)) = (x, y) else {
        return Err(CeremonyError::Attestation("the COSE key has no x/y"));
    };
    if x.len() != 32 || y.len() != 32 {
        return Err(CeremonyError::Attestation(
            "the COSE key coordinates are not 32 bytes",
        ));
    }
    // Refuse a key the verifier could never load, at REGISTRATION rather than at
    // the first login: a point that is not on the curve stored now is a credential
    // that fails mysteriously later.
    if !public_key_is_loadable(&x, &y) {
        return Err(CeremonyError::Attestation(
            "the COSE key is not a valid P-256 point",
        ));
    }

    Ok(RegisteredCredential {
        id: B64.encode(cred_id),
        x,
        y,
        sign_count: count,
    })
}

// ─── Authentication ──────────────────────────────────────────────────────────

/// Verify an **authentication** (assertion) response against a stored credential.
///
/// `signature_der` is the ECDSA signature exactly as the authenticator produced
/// it: ASN.1 DER, which is what WebAuthn specifies for ES256 and what browsers
/// hand back untouched.
///
/// The signed message is `authenticatorData || SHA256(clientDataJSON)` — note that
/// the client data is hashed but the authenticator data is not, which is a place
/// hand-written verifiers get it wrong in a way that still passes a
/// happy-path test if both sides make the same mistake. The round-trip test
/// against a real recorded assertion is what keeps this honest.
///
/// # Errors
///
/// Ceremony type, challenge, origin, `rp_id` hash, user presence (and user
/// verification when demanded), signature, and counter rollback.
pub fn verify_assertion(
    rp: &PureRelyingParty,
    credential: &RegisteredCredential,
    client_data: &[u8],
    auth_data: &[u8],
    signature_der: &[u8],
    challenge_b64: &str,
    require_user_verification: bool,
) -> Result<VerifiedAssertion, CeremonyError> {
    check_client_data(client_data, "webauthn.get", challenge_b64, rp.rp_origin())?;
    let (count, flags) = check_auth_data(auth_data, &rp.rp_id_hash, require_user_verification)?;

    if !verify_es256(
        &credential.x,
        &credential.y,
        auth_data,
        client_data,
        signature_der,
    ) {
        return Err(CeremonyError::BadSignature);
    }

    // Clone detection (WebAuthn §7.2 step 21, A34-31). Many authenticators — most
    // notably platform passkeys that sync — report a counter of 0 forever, so a
    // credential STORED at 0 accepts any count. One that did count must present
    // a strictly larger number: standing still, going back, or dropping to 0 is
    // a cloned credential (`es256::counter_fits`, shared with `ceremony`).
    if !crate::es256::counter_fits(credential.sign_count, count) {
        return Err(CeremonyError::CounterRollback {
            stored: credential.sign_count,
            presented: count,
        });
    }

    Ok(VerifiedAssertion {
        sign_count: count,
        user_verified: flags & FLAG_UV != 0,
    })
}

// ─── Shared ceremony checks ──────────────────────────────────────────────────

/// Check `clientDataJSON`: the ceremony type, the challenge, and the origin.
///
/// The challenge comparison is an exact match against the base64url string this
/// server minted. Comparing the DECODED bytes would be equivalent only if the
/// encodings agreed, and they do not always: comparing the strings means a
/// response that re-encoded the challenge differently is refused rather than
/// silently accepted.
fn check_client_data(
    client_data: &[u8],
    want_type: &str,
    want_challenge_b64: &str,
    want_origin: &str,
) -> Result<(), CeremonyError> {
    let value: serde_json::Value =
        serde_json::from_slice(client_data).map_err(|_| CeremonyError::ClientData("not JSON"))?;
    if value.get("type").and_then(|v| v.as_str()) != Some(want_type) {
        return Err(CeremonyError::ClientData("wrong ceremony type"));
    }
    if value.get("challenge").and_then(|v| v.as_str()) != Some(want_challenge_b64) {
        return Err(CeremonyError::Challenge);
    }
    if value.get("origin").and_then(|v| v.as_str()) != Some(want_origin) {
        return Err(CeremonyError::Origin);
    }
    Ok(())
}

/// Check `authenticatorData` and return `(signature counter, flags)`.
///
/// Layout: `rpIdHash[32] | flags[1] | signCount[4] | …`.
fn check_auth_data(
    auth_data: &[u8],
    want_rp_id_hash: &[u8; 32],
    require_user_verification: bool,
) -> Result<(u32, u8), CeremonyError> {
    if auth_data.len() < 37 {
        return Err(CeremonyError::AuthData("too short"));
    }
    // Constant time is not required here: the expected value is a hash of PUBLIC
    // configuration, not a secret, so an early exit leaks nothing an attacker
    // does not already have.
    if auth_data[0..32] != want_rp_id_hash[..] {
        return Err(CeremonyError::AuthData("wrong rpIdHash"));
    }
    let flags = auth_data[32];
    if flags & FLAG_UP == 0 {
        return Err(CeremonyError::MissingFlag("user-presence"));
    }
    if require_user_verification && flags & FLAG_UV == 0 {
        return Err(CeremonyError::MissingFlag("user-verification"));
    }
    let count = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]);
    Ok((count, flags))
}

/// Can this x/y pair be loaded as a P-256 public key at all?
fn public_key_is_loadable(x: &[u8], y: &[u8]) -> bool {
    p256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1_uncompressed(x, y)).is_ok()
}

use crate::es256::sec1_uncompressed;

/// Verify an ES256 signature over `authData || SHA256(clientData)`.
fn verify_es256(x: &[u8], y: &[u8], auth_data: &[u8], client_data: &[u8], der: &[u8]) -> bool {
    crate::es256::verify_es256_sec1(&sec1_uncompressed(x, y), auth_data, client_data, der)
}

// ─── CBOR helpers ────────────────────────────────────────────────────────────

fn cbor_text_key<'a>(
    value: &'a ciborium::value::Value,
    key: &str,
) -> Option<&'a ciborium::value::Value> {
    value.as_map()?.iter().find_map(|(k, v)| match k {
        ciborium::value::Value::Text(t) if t == key => Some(v),
        _ => None,
    })
}

fn cbor_int_key(value: &ciborium::value::Value, key: i128) -> Option<&ciborium::value::Value> {
    value.as_map()?.iter().find_map(|(k, v)| match k {
        ciborium::value::Value::Integer(i) if i128::from(*i) == key => Some(v),
        _ => None,
    })
}

fn cbor_as_int(value: &ciborium::value::Value) -> Option<i128> {
    match value {
        ciborium::value::Value::Integer(i) => Some(i128::from(*i)),
        _ => None,
    }
}

// ─── The session token the ceremony mints ────────────────────────────────────

/// Seconds since the Unix epoch.
pub fn now_secs() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or_default()
}

/// What a verified ceremony mints and a later request presents as a bearer.
///
/// It is **stateless** on purpose: the server holds one MAC key, not a table of
/// live sessions, so a request costs a hash rather than a lookup and a restart
/// does not log everybody out. The two things statelessness normally costs —
/// revocation and expiry — are bought back explicitly:
///
/// * `exp` bounds it in time;
/// * `epoch` is a counter the server bumps to void EVERY outstanding token at
///   once (a breach, or a decommissioned admin);
/// * the caller additionally refuses a token whose subject no longer holds an
///   enabled credential, which is the per-person revocation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionClaims {
    /// The **opaque, enrolment-minted** identity. Never the friendly name: every
    /// role map keys on this string, so it must be as stable and as un-friendly as
    /// an OIDC `sub`.
    pub sub: String,
    /// The group grants this identity carries, so a passkey identity resolves
    /// through the very same group ladder an OIDC one does.
    #[serde(default)]
    pub groups: Vec<String>,
    /// The human name, for the audit line only — it authorizes nothing.
    #[serde(default)]
    pub display: Option<String>,
    /// Expiry, seconds since the Unix epoch.
    pub exp: i64,
    /// When the ceremony that minted this happened — a step-up decision needs to
    /// know how old the assertion is, not how old the token is.
    pub auth_time: i64,
    /// Whether that ceremony verified the USER, not merely their presence.
    pub uv: bool,
    /// The revocation counter this token was minted under.
    pub epoch: u64,
}

/// The key a [`SessionClaims`] is MAC'd with. 32 bytes, persisted by the caller so
/// sessions outlive a restart.
#[derive(Clone)]
pub struct SessionKey([u8; 32]);

impl std::fmt::Debug for SessionKey {
    /// Never renders the key.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("SessionKey(<redacted>)")
    }
}

impl SessionKey {
    /// Adopt 32 bytes of key material.
    pub fn new(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    /// The key's bytes, for the caller to persist. Named to be conspicuous at the
    /// call site: this is the one value that must never be logged or shipped.
    pub fn to_bytes(&self) -> [u8; 32] {
        self.0
    }

    /// Mint a token: `base64url(claims JSON) "." base64url(MAC)`.
    ///
    /// The MAC covers the ENCODED payload rather than the decoded claims, so
    /// verification never has to parse attacker-controlled JSON before it has
    /// established that the bytes are ours.
    pub fn mint(&self, claims: &SessionClaims) -> String {
        let payload = serde_json::to_vec(claims).expect("SessionClaims always serializes");
        let payload_b64 = B64.encode(payload);
        let mac = blake3::keyed_hash(&self.0, payload_b64.as_bytes());
        format!("{payload_b64}.{}", B64.encode(mac.as_bytes()))
    }

    /// Verify a token's MAC and expiry and return its claims.
    ///
    /// `None` for anything that is not a token of ours — a malformed string, a bad
    /// MAC, an expired token, or one minted under a superseded `epoch`. The caller
    /// treats `None` as "not mine" so the credential falls through to the next
    /// provider, exactly as an opaque token handed to a JWT provider does.
    ///
    /// The MAC comparison goes through `blake3::Hash`'s `PartialEq`, which is
    /// constant time. A byte-slice comparison here would leak the MAC one position
    /// at a time to anyone willing to time it.
    pub fn verify(&self, token: &str, current_epoch: u64, now: i64) -> Option<SessionClaims> {
        let (payload_b64, mac_b64) = token.split_once('.')?;
        let presented: [u8; 32] = B64.decode(mac_b64).ok()?.try_into().ok()?;
        let expected = blake3::keyed_hash(&self.0, payload_b64.as_bytes());
        if expected != blake3::Hash::from(presented) {
            return None;
        }
        let claims: SessionClaims = serde_json::from_slice(&B64.decode(payload_b64).ok()?).ok()?;
        if claims.exp <= now || claims.epoch != current_epoch {
            return None;
        }
        Some(claims)
    }
}

// ─── The provider the stateless auth seam dispatches ─────────────────────────

/// The passkey [`AuthProvider`](nornir_service::provider::AuthProvider).
///
/// **It is a BEARER provider, and that is the whole trick.** The two-round-trip
/// ceremony cannot run inside `validate_request(config, bearer, client_cert_cn)`,
/// which sees one stateless credential per request — that is why a passkey method
/// was declared and left undispatched for a year. The resolution is that the
/// ceremony runs on the server's own door and mints a [`SessionClaims`] token;
/// what this provider verifies is that token. So the seam needed no new method,
/// no new signature, and no change to the four providers already in it.
///
/// A credential that is not one of our session tokens is `Ok(None)` — "not mine" —
/// so this provider can never shadow the OIDC or static-token provider sitting
/// beside it in the same config.
#[derive(Clone)]
pub struct PurePasskeyProvider {
    key: SessionKey,
    /// The revocation counter, shared with the server so a bump takes effect on
    /// the next request rather than on the next restart.
    epoch: std::sync::Arc<std::sync::atomic::AtomicU64>,
}

impl std::fmt::Debug for PurePasskeyProvider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PurePasskeyProvider").finish_non_exhaustive()
    }
}

impl PurePasskeyProvider {
    /// Build a provider over the server's persisted session key and its shared
    /// revocation epoch.
    pub fn new(key: SessionKey, epoch: std::sync::Arc<std::sync::atomic::AtomicU64>) -> Self {
        Self { key, epoch }
    }

    /// The epoch handle, so the server can bump it and void every live token.
    pub fn epoch(&self) -> &std::sync::Arc<std::sync::atomic::AtomicU64> {
        &self.epoch
    }
}

#[tonic::async_trait]
impl nornir_service::provider::AuthProvider for PurePasskeyProvider {
    fn name(&self) -> &str {
        "passkey"
    }

    async fn verify_bearer(
        &self,
        token: &str,
    ) -> Result<Option<nornir_service::provider::Principal>, nornir_service::provider::ProviderError>
    {
        let epoch = self.epoch.load(std::sync::atomic::Ordering::Relaxed);
        let Some(claims) = self.key.verify(token, epoch, now_secs()) else {
            // Not one of ours (or expired / revoked). "Not mine" rather than an
            // authoritative reject, so a static token or a JWT presented to the
            // same server still reaches the provider that owns it.
            return Ok(None);
        };
        let mut principal =
            nornir_service::provider::Principal::new(claims.sub, "passkey");
        principal.groups = claims.groups;
        principal.display = claims.display;
        Ok(Some(principal))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rp() -> PureRelyingParty {
        PureRelyingParty::new("holger.lab.internal", "https://holger.lab.internal", "holger")
            .expect("a well-formed relying party builds")
    }

    // ─── A soft authenticator, built here on purpose ─────────────────────────
    //
    // The obvious move is to pull `webauthn-authenticator-rs` as a dev-dependency,
    // and it is the wrong one: it drags the very openssl stack this backend exists
    // to avoid, so a `cargo test` would need the C toolchain that a `cargo build`
    // does not. More importantly, a simulator from the SAME family as the verifier
    // can agree with it about a mistake. This one is built from the spec text: it
    // signs `authData || SHA256(clientDataJSON)` — the client data hashed, the
    // authenticator data NOT — which is exactly the asymmetry a hand-written
    // verifier gets wrong in a way that still passes a happy-path test when both
    // sides make the same mistake.

    use p256::ecdsa::{signature::Signer, Signature, SigningKey};

    /// A deterministic P-256 key, so a failure is reproducible rather than
    /// occasionally interesting.
    fn signing_key() -> SigningKey {
        SigningKey::from_bytes(&[0x42u8; 32].into()).expect("a valid P-256 scalar")
    }

    fn public_xy(key: &SigningKey) -> (Vec<u8>, Vec<u8>) {
        let point = key.verifying_key().to_sec1_point(false);
        (point.x().unwrap().to_vec(), point.y().unwrap().to_vec())
    }

    fn client_data(ceremony: &str, challenge_b64: &str, origin: &str) -> Vec<u8> {
        serde_json::to_vec(&serde_json::json!({
            "type": ceremony,
            "challenge": challenge_b64,
            "origin": origin,
            "crossOrigin": false,
        }))
        .unwrap()
    }

    /// `rpIdHash[32] | flags[1] | signCount[4] | [attestedCredentialData]`.
    fn auth_data(rp_id: &str, flags: u8, count: u32, attested: Option<(&[u8], &SigningKey)>) -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(&Sha256::digest(rp_id.as_bytes()));
        out.push(flags);
        out.extend_from_slice(&count.to_be_bytes());
        if let Some((cred_id, key)) = attested {
            out.extend_from_slice(&[0u8; 16]); // aaguid
            out.extend_from_slice(&(cred_id.len() as u16).to_be_bytes());
            out.extend_from_slice(cred_id);
            let (x, y) = public_xy(key);
            // COSE_Key: kty(1)=2 EC2, alg(3)=-7 ES256, crv(-1)=1 P-256, x(-2), y(-3).
            let cose = ciborium::value::Value::Map(vec![
                (1.into(), 2.into()),
                (3.into(), (-7).into()),
                ((-1).into(), 1.into()),
                ((-2).into(), ciborium::value::Value::Bytes(x)),
                ((-3).into(), ciborium::value::Value::Bytes(y)),
            ]);
            let mut buf = Vec::new();
            ciborium::into_writer(&cose, &mut buf).unwrap();
            out.extend_from_slice(&buf);
        }
        out
    }

    fn attestation_object(auth_data: &[u8]) -> Vec<u8> {
        // "none" attestation: the format an authenticator uses when it is not
        // asserting a manufacturer chain, and the only one this backend reads.
        let obj = ciborium::value::Value::Map(vec![
            (
                ciborium::value::Value::Text("fmt".into()),
                ciborium::value::Value::Text("none".into()),
            ),
            (
                ciborium::value::Value::Text("attStmt".into()),
                ciborium::value::Value::Map(vec![]),
            ),
            (
                ciborium::value::Value::Text("authData".into()),
                ciborium::value::Value::Bytes(auth_data.to_vec()),
            ),
        ]);
        let mut buf = Vec::new();
        ciborium::into_writer(&obj, &mut buf).unwrap();
        buf
    }

    /// Sign the assertion the way an authenticator does.
    fn sign(key: &SigningKey, auth_data: &[u8], client_data: &[u8]) -> Vec<u8> {
        let mut message = auth_data.to_vec();
        message.extend_from_slice(&Sha256::digest(client_data));
        let sig: Signature = key.sign(&message);
        sig.to_der().as_bytes().to_vec()
    }

    const CHALLENGE: &str = "dGhpcy1pcy1hLXRlc3QtY2hhbGxlbmdlLTMy";
    const CRED_ID: &[u8] = b"credential-id-0001";

    fn register() -> RegisteredCredential {
        let key = signing_key();
        let ad = auth_data("holger.lab.internal", FLAG_UP | FLAG_UV | FLAG_AT, 1, Some((CRED_ID, &key)));
        verify_registration(
            &rp(),
            &attestation_object(&ad),
            &client_data("webauthn.create", CHALLENGE, "https://holger.lab.internal"),
            CHALLENGE,
            false,
        )
        .expect("a well-formed registration verifies")
    }

    // ─── The ceremony, both directions ───────────────────────────────────────

    /// A full register → authenticate round trip: the credential comes out of the
    /// attestation object with the right id and public key, and an assertion
    /// signed by the matching private key verifies.
    #[test]
    fn a_passkey_registers_and_then_authenticates() {
        let cred = register();
        assert_eq!(cred.id, B64.encode(CRED_ID));
        let (x, y) = public_xy(&signing_key());
        assert_eq!(cred.x, x);
        assert_eq!(cred.y, y);
        assert_eq!(cred.sign_count, 1);

        let key = signing_key();
        let ad = auth_data("holger.lab.internal", FLAG_UP | FLAG_UV, 2, None);
        let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
        let ok = verify_assertion(&rp(), &cred, &cd, &ad, &sign(&key, &ad, &cd), CHALLENGE, false)
            .expect("a genuine assertion verifies");
        assert_eq!(ok.sign_count, 2);
        assert!(ok.user_verified, "the UV flag must be reported for step-up");
    }

    /// A TAMPERED signature is refused. The single most important negative: if
    /// this ever passes, every other check in the file is decoration.
    #[test]
    fn a_tampered_signature_is_refused() {
        let cred = register();
        let key = signing_key();
        let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
        let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
        let mut sig = sign(&key, &ad, &cd);
        let last = sig.len() - 1;
        sig[last] ^= 0x01;
        assert_eq!(
            verify_assertion(&rp(), &cred, &cd, &ad, &sig, CHALLENGE, false),
            Err(CeremonyError::BadSignature)
        );
    }

    /// An assertion signed by a DIFFERENT key does not authenticate as this
    /// credential — the credential's stored public key is what decides, not the
    /// credential id the browser echoed back.
    #[test]
    fn another_key_cannot_assert_this_credential() {
        let cred = register();
        let impostor = SigningKey::from_bytes(&[0x99u8; 32].into()).unwrap();
        let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
        let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
        assert_eq!(
            verify_assertion(&rp(), &cred, &cd, &ad, &sign(&impostor, &ad, &cd), CHALLENGE, false),
            Err(CeremonyError::BadSignature)
        );
    }

    /// A REPLAYED assertion — a perfectly genuine one, presented against a
    /// different ceremony's challenge — is refused. This is the whole point of
    /// minting a fresh challenge per ceremony.
    #[test]
    fn a_replayed_assertion_is_refused() {
        let cred = register();
        let key = signing_key();
        let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
        let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
        let sig = sign(&key, &ad, &cd);
        // Genuine against its own challenge...
        assert!(verify_assertion(&rp(), &cred, &cd, &ad, &sig, CHALLENGE, false).is_ok());
        // ...and refused against the next one.
        assert_eq!(
            verify_assertion(&rp(), &cred, &cd, &ad, &sig, "YS1kaWZmZXJlbnQtY2hhbGxlbmdl", false),
            Err(CeremonyError::Challenge)
        );
    }

    /// An assertion produced for ANOTHER relying party is refused twice over: the
    /// origin in the client data and the rpIdHash in the authenticator data are
    /// both wrong. Checked separately because a verifier that checked only one
    /// would still pass a naive test.
    #[test]
    fn an_assertion_for_another_relying_party_is_refused() {
        let cred = register();
        let key = signing_key();

        // Wrong ORIGIN, right rp_id.
        let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
        let evil_cd = client_data("webauthn.get", CHALLENGE, "https://evil.example");
        assert_eq!(
            verify_assertion(&rp(), &cred, &evil_cd, &ad, &sign(&key, &ad, &evil_cd), CHALLENGE, false),
            Err(CeremonyError::Origin)
        );

        // Right origin, wrong RP ID HASH.
        let evil_ad = auth_data("evil.example", FLAG_UP, 2, None);
        let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
        assert_eq!(
            verify_assertion(&rp(), &cred, &cd, &evil_ad, &sign(&key, &evil_ad, &cd), CHALLENGE, false),
            Err(CeremonyError::AuthData("wrong rpIdHash"))
        );
    }

    /// A registration response cannot finish a login, and vice versa. The ceremony
    /// type is signed over, so swapping them is not merely untidy — it is how a
    /// credential-create is replayed as a credential-get.
    #[test]
    fn a_registration_cannot_finish_a_login() {
        let cred = register();
        let key = signing_key();
        let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
        let create_cd = client_data("webauthn.create", CHALLENGE, "https://holger.lab.internal");
        assert_eq!(
            verify_assertion(&rp(), &cred, &create_cd, &ad, &sign(&key, &ad, &create_cd), CHALLENGE, false),
            Err(CeremonyError::ClientData("wrong ceremony type"))
        );
    }

    /// The user-presence flag is mandatory; user verification is demanded only
    /// when the caller asks, which is how a step-up is expressed.
    #[test]
    fn presence_is_mandatory_and_verification_is_on_demand() {
        let cred = register();
        let key = signing_key();
        let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");

        // No UP at all — refused however the caller feels about UV.
        let no_up = auth_data("holger.lab.internal", 0x00, 2, None);
        assert_eq!(
            verify_assertion(&rp(), &cred, &cd, &no_up, &sign(&key, &no_up, &cd), CHALLENGE, false),
            Err(CeremonyError::MissingFlag("user-presence"))
        );

        // UP but no UV: fine for a login, refused for a step-up.
        let up_only = auth_data("holger.lab.internal", FLAG_UP, 2, None);
        let sig = sign(&key, &up_only, &cd);
        assert!(verify_assertion(&rp(), &cred, &cd, &up_only, &sig, CHALLENGE, false).is_ok());
        assert_eq!(
            verify_assertion(&rp(), &cred, &cd, &up_only, &sig, CHALLENGE, true),
            Err(CeremonyError::MissingFlag("user-verification"))
        );
    }

    /// A counter that goes backwards or stands still is a CLONED credential —
    /// but only when the authenticator uses the counter at all. A synced platform
    /// passkey reports 0 forever, and locking those out on their second use would
    /// be a bug that looks exactly like security.
    #[test]
    fn a_rolled_back_counter_is_refused_but_a_zero_counter_is_not() {
        let mut cred = register();
        cred.sign_count = 10;
        let key = signing_key();
        let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");

        // A34-31: a counter that drops to 0 after a real one (stored 10) is a
        // clone that stopped counting, not a synced passkey.
        for replayed in [9u32, 10, 0] {
            let ad = auth_data("holger.lab.internal", FLAG_UP, replayed, None);
            assert_eq!(
                verify_assertion(&rp(), &cred, &cd, &ad, &sign(&key, &ad, &cd), CHALLENGE, false),
                Err(CeremonyError::CounterRollback { stored: 10, presented: replayed }),
                "counter {replayed} after 10 must be refused"
            );
        }

        let ad = auth_data("holger.lab.internal", FLAG_UP, 11, None);
        assert!(verify_assertion(&rp(), &cred, &cd, &ad, &sign(&key, &ad, &cd), CHALLENGE, false).is_ok());

        // The synced-passkey shape: both sides zero, accepted.
        let mut synced = register();
        synced.sign_count = 0;
        let ad0 = auth_data("holger.lab.internal", FLAG_UP, 0, None);
        assert!(
            verify_assertion(&rp(), &synced, &cd, &ad0, &sign(&key, &ad0, &cd), CHALLENGE, false).is_ok(),
            "an authenticator that does not use the counter must keep working"
        );
    }

    /// A credential offering anything but ES256/P-256 is refused at REGISTRATION,
    /// where the message can name the cause — not at the first login, where it
    /// could not.
    #[test]
    fn a_non_es256_credential_is_refused_at_registration() {
        let key = signing_key();
        let mut ad = auth_data("holger.lab.internal", FLAG_UP | FLAG_AT, 1, Some((CRED_ID, &key)));
        // Rewrite the COSE alg from -7 (ES256) to -257 (RS256) by rebuilding the
        // key: simplest is to assert the guard fires on a doctored map.
        let (x, y) = public_xy(&key);
        let cose = ciborium::value::Value::Map(vec![
            (1.into(), 3.into()), // kty = RSA
            (3.into(), (-257).into()), // alg = RS256
            ((-1).into(), ciborium::value::Value::Bytes(x)),
            ((-2).into(), ciborium::value::Value::Bytes(y)),
        ]);
        let mut buf = Vec::new();
        ciborium::into_writer(&cose, &mut buf).unwrap();
        ad.truncate(55 + CRED_ID.len());
        ad.extend_from_slice(&buf);

        assert_eq!(
            verify_registration(
                &rp(),
                &attestation_object(&ad),
                &client_data("webauthn.create", CHALLENGE, "https://holger.lab.internal"),
                CHALLENGE,
                false,
            ),
            Err(CeremonyError::UnsupportedAlgorithm)
        );
    }

    /// Truncated and non-CBOR input is refused rather than panicking. Every one of
    /// these is attacker-supplied, so an index out of bounds here is a denial of
    /// service on the login door.
    #[test]
    fn malformed_input_is_refused_and_never_panics() {
        let cred = register();
        let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");

        assert!(verify_registration(&rp(), b"not cbor", &cd, CHALLENGE, false).is_err());
        assert!(verify_registration(&rp(), &[], &cd, CHALLENGE, false).is_err());

        for short in [0usize, 1, 20, 36] {
            let ad = vec![0u8; short];
            assert!(
                verify_assertion(&rp(), &cred, &cd, &ad, &[0u8; 8], CHALLENGE, false).is_err(),
                "authData of {short} bytes must be refused"
            );
        }
        assert!(verify_assertion(&rp(), &cred, b"not json", &[0u8; 40], &[0u8; 8], CHALLENGE, false).is_err());

        // An attestation whose credential-id length runs past the buffer.
        let key = signing_key();
        let mut ad = auth_data("holger.lab.internal", FLAG_UP | FLAG_AT, 1, Some((CRED_ID, &key)));
        ad[53] = 0xFF;
        ad[54] = 0xFF;
        assert!(verify_registration(
            &rp(),
            &attestation_object(&ad),
            &client_data("webauthn.create", CHALLENGE, "https://holger.lab.internal"),
            CHALLENGE,
            false,
        )
        .is_err());
    }

    // ─── rp_id: the airgap question ──────────────────────────────────────────

    /// An IP-literal rp_id is refused, v4 and v6, bracketed or bare. A browser
    /// rejects an address as an RP ID, so such a deployment could never enrol a
    /// key — the failure must be at boot, not at the first registration.
    #[test]
    fn an_ip_literal_rp_id_is_refused() {
        for bad in ["10.0.0.7", "127.0.0.1", "fd00::1", "[fd00::1]", "::1"] {
            let err = validate_rp_id(bad).expect_err("an address is not a relying-party id");
            assert!(
                err.contains("DOMAIN NAME"),
                "the refusal must say what to do instead, got: {err}"
            );
        }
        assert!(PureRelyingParty::new("10.0.0.7", "https://10.0.0.7", "h").is_err());
    }

    /// A name is accepted, and so is the localhost development shape.
    #[test]
    fn a_name_is_accepted() {
        assert!(validate_rp_id("holger.lab.internal").is_ok());
        assert!(validate_rp_id("localhost").is_ok());
        assert!(PureRelyingParty::new("localhost", "http://localhost:8090", "h").is_ok());
    }

    /// An rp_id carrying a scheme, a port or a path is a configuration mistake
    /// with an obvious fix, so it is named rather than silently hashed.
    #[test]
    fn an_rp_id_that_is_really_a_url_is_refused() {
        assert!(validate_rp_id("https://holger.lab.internal").is_err());
        assert!(validate_rp_id("holger.lab.internal:8443").is_err());
        assert!(validate_rp_id("").is_err());
    }

    /// The origin must belong to the rp_id: equal, or a subdomain of it. This is
    /// the check that stops the credential's scope and the assertion's check from
    /// disagreeing.
    #[test]
    fn an_origin_must_belong_to_the_rp_id() {
        assert!(validate_origin_for_rp_id("https://holger.lab.internal", "lab.internal").is_ok());
        assert!(
            validate_origin_for_rp_id("https://holger.lab.internal", "holger.lab.internal").is_ok()
        );
        assert!(validate_origin_for_rp_id("https://holger.lab.internal:8443", "lab.internal").is_ok());

        // A different registrable domain — the phishing case the boundary exists for.
        assert!(validate_origin_for_rp_id("https://evil.example", "lab.internal").is_err());
        // A SUFFIX that is not a subdomain: "evil-lab.internal" ends with
        // "lab.internal" as a string but is a different domain entirely.
        assert!(validate_origin_for_rp_id("https://evil-lab.internal", "lab.internal").is_err());
        // Insecure context.
        assert!(validate_origin_for_rp_id("http://holger.lab.internal", "lab.internal").is_err());
    }

    // ─── Session tokens ──────────────────────────────────────────────────────

    fn claims() -> SessionClaims {
        SessionClaims {
            sub: "passkey:9c1f7e40-2b11-4a6e-8f77-000000000002".to_owned(),
            groups: vec!["platform-admins".to_owned()],
            display: Some("bo".to_owned()),
            exp: now_secs() + 3600,
            auth_time: now_secs(),
            uv: true,
            epoch: 0,
        }
    }

    #[test]
    fn a_minted_token_verifies_and_carries_its_claims() {
        let key = SessionKey::new([7u8; 32]);
        let token = key.mint(&claims());
        let got = key.verify(&token, 0, now_secs()).expect("our own token verifies");
        assert_eq!(got.sub, claims().sub);
        assert_eq!(got.groups, vec!["platform-admins".to_owned()]);
        assert!(got.uv);
    }

    /// A tampered payload is refused: the MAC covers the encoded payload, so
    /// rewriting the subject to an admin's invalidates it.
    #[test]
    fn a_tampered_payload_is_refused() {
        let key = SessionKey::new([7u8; 32]);
        let token = key.mint(&claims());
        let (_, mac) = token.split_once('.').unwrap();

        let mut forged = claims();
        forged.sub = "passkey:00000000-0000-0000-0000-00000000admin".to_owned();
        let forged_payload = B64.encode(serde_json::to_vec(&forged).unwrap());
        assert!(
            key.verify(&format!("{forged_payload}.{mac}"), 0, now_secs()).is_none(),
            "a re-subjected payload must not verify under the old MAC"
        );
    }

    /// A token minted under another key is not ours — "not mine", so the
    /// dispatcher goes on to the next provider rather than refusing outright.
    #[test]
    fn a_token_from_another_key_is_not_ours() {
        let token = SessionKey::new([7u8; 32]).mint(&claims());
        assert!(SessionKey::new([9u8; 32]).verify(&token, 0, now_secs()).is_none());
    }

    /// Expiry and the revocation epoch both void a token that is otherwise
    /// perfectly well-formed and correctly MAC'd.
    #[test]
    fn expiry_and_the_epoch_both_void_a_token() {
        let key = SessionKey::new([7u8; 32]);
        let mut c = claims();
        c.exp = now_secs() - 1;
        assert!(key.verify(&key.mint(&c), 0, now_secs()).is_none(), "expired");

        let token = key.mint(&claims());
        assert!(key.verify(&token, 0, now_secs()).is_some(), "good under epoch 0");
        assert!(
            key.verify(&token, 1, now_secs()).is_none(),
            "bumping the epoch must void every outstanding token at once"
        );
    }

    #[test]
    fn garbage_is_not_a_token() {
        let key = SessionKey::new([7u8; 32]);
        for bad in ["", ".", "no-dot", "a.b", "....."] {
            assert!(key.verify(bad, 0, now_secs()).is_none(), "{bad:?}");
        }
    }

    /// The key never renders, however it is formatted. A MAC key in a log line is
    /// every session forged.
    #[test]
    fn the_session_key_never_renders() {
        let key = SessionKey::new([0xAB; 32]);
        let shown = format!("{key:?}");
        assert!(shown.contains("redacted"));
        assert!(!shown.contains("ab") && !shown.contains("171"));
    }
}

/// Known-answer test for ES256 verification (2026-10-02, W3 crypto bump).
///
/// Authenticators do NOT normalise ECDSA `s`: a real fleet sends high-S and
/// low-S signatures alike. `testdata/es256-kat/` holds one assertion message,
/// its public key and the SAME signature in both forms (`s` and `n − s`), all
/// accepted by the PRE-bump stack (p256 0.13 / ecdsa 0.16). A later stack must
/// accept both — a verifier that started rejecting high-S would lock out every
/// passkey whose authenticator emits it. Regenerate ONLY on purpose:
/// `WEBAUTHN_ES256_KAT_REGEN=1 cargo test -p nornir-auth-webauthn es256_kat`.
#[cfg(test)]
mod es256_kat {
    use super::*;

    const AUTH_DATA: &[u8] = b"webauthn kat authData (rpIdHash||flags||signCount)";
    const CLIENT_DATA: &[u8] = br#"{"type":"webauthn.get","challenge":"a2F0","origin":"https://kat.example"}"#;

    #[test]
    fn es256_kat_old_signatures_both_s_forms_verify() {
        let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/es256-kat");
        if std::env::var_os("WEBAUTHN_ES256_KAT_REGEN").is_some() {
            use p256::ecdsa::{signature::Signer, Signature, SigningKey};
            let sk = SigningKey::from_bytes(&[0x11u8; 32].into()).unwrap();
            let point = sk.verifying_key().to_sec1_point(false);
            let mut msg = AUTH_DATA.to_vec();
            msg.extend_from_slice(&Sha256::digest(CLIENT_DATA));
            let sig: Signature = sk.sign(&msg);
            let (r, s) = sig.split_scalars();
            let flipped = Signature::from_scalars(r.to_bytes(), (-*s).to_bytes()).unwrap();
            std::fs::create_dir_all(&dir).unwrap();
            std::fs::write(dir.join("key.sec1"), point.as_bytes()).unwrap();
            std::fs::write(dir.join("sig-a.der"), sig.to_der().as_bytes()).unwrap();
            std::fs::write(dir.join("sig-b.der"), flipped.to_der().as_bytes()).unwrap();
        }
        let read = |n: &str| std::fs::read(dir.join(n)).unwrap_or_else(|e| panic!("fixture {n}: {e}"));
        let key = read("key.sec1");
        let (x, y) = (&key[1..33], &key[33..65]);
        for s in ["sig-a.der", "sig-b.der"] {
            assert!(verify_es256(x, y, AUTH_DATA, CLIENT_DATA, &read(s)), "{s} no longer verifies");
        }
        assert!(!verify_es256(x, y, b"tampered", CLIENT_DATA, &read("sig-a.der")));
    }
}