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/ceremony.rs

//! **The WebAuthn ceremony, server side** (feature `ceremony`, A34 M-3): the
//! challenge store a login door opens ceremonies in, the relying-party and
//! origin rules, `clientDataJSON`, and the assertion and attestation checks over
//! raw fields as a relay hands them on.
//!
//! It is the one copy for every product. Gunnar's own `webauthn.rs` and Holger's
//! challenge map moved here (2026-10-27), and with them two fixes:
//!
//! * **A34-7 / A34-8, the challenge pool.** An open ceremony is held per
//!   [`ClientKey`] as well as server-wide: one client may hold
//!   [`Limits::per_client`] at once, so a flood from one address meets its own
//!   ceiling and leaves every other person's login alone. An IPv6 client is keyed
//!   by its /64 (one host gets a whole /64 from any provider), an IPv4 client by
//!   its address. Holger keyed by `peer.split(':')` (all of IPv6 in one bucket);
//!   Gunnar had one global pool.
//! * **A34-31, the counter.** A credential that counted must present a strictly
//!   larger counter; a drop to 0 is refused (`es256::counter_fits`, shared with
//!   [`crate::pure`]).
//!
//! The crypto is the shared `es256` module, so `pure` and this module cannot
//! disagree about a signature. The attestation reader is a small definite-length
//! CBOR reader of its own (below), so this feature links no `ciborium`: Gunnar's
//! closure gains nothing by moving here.
//!
//! # The encodings, once
//!
//! * A **public key** is P-256, SEC1 uncompressed: `0x04 || X || Y`, 65 bytes.
//! * A **credential id** is the authenticator's raw bytes on the wire, and
//!   base64url without padding as a map key (what a browser shows as `id`).

use std::collections::HashMap;
use std::sync::Mutex;

use base64::Engine as _;

use crate::es256::{counter_fits, verify_es256_sec1, FLAG_AT, FLAG_ED, FLAG_UP, FLAG_UV};

/// base64url, no padding: the WebAuthn wire encoding.
const B64: base64::engine::general_purpose::GeneralPurpose =
    base64::engine::general_purpose::URL_SAFE_NO_PAD;

/// The length of a P-256 public key, SEC1 uncompressed.
pub const PUBLIC_KEY_LEN: usize = 65;

/// The longest credential id WebAuthn allows (§5.8.3, 1023 bytes).
pub const MAX_CREDENTIAL_ID_LEN: usize = 1023;

/// Base64url without padding: a credential id as a key, a challenge as
/// `clientDataJSON` carries it.
pub fn b64url(bytes: &[u8]) -> String {
    B64.encode(bytes)
}

/// The inverse of [`b64url`]; `None` for text that is not base64url.
pub fn from_b64url(text: &str) -> Option<Vec<u8>> {
    B64.decode(text).ok()
}

/// **Is this a credential id a server will store?** Non-empty and within
/// WebAuthn's own bound.
pub fn check_credential_id(id: &[u8]) -> Result<(), String> {
    if id.is_empty() {
        return Err("`credential_id` is required".to_owned());
    }
    if id.len() > MAX_CREDENTIAL_ID_LEN {
        return Err(format!(
            "a credential id is at most {MAX_CREDENTIAL_ID_LEN} bytes and this one is {}",
            id.len()
        ));
    }
    Ok(())
}

/// **Is this a P-256 public key a login could ever be verified with?**
/// SEC1 uncompressed, 65 bytes, and a point on the curve: refused at
/// REGISTRATION rather than at the first login, where a bad key would fail
/// with nothing to say why.
pub fn check_public_key(sec1: &[u8]) -> Result<(), String> {
    if sec1.len() != PUBLIC_KEY_LEN || sec1[0] != 0x04 {
        return Err(format!(
            "`public_key` is P-256, SEC1 uncompressed: 0x04 || X || Y, {PUBLIC_KEY_LEN} bytes \
             (this is {} bytes{})",
            sec1.len(),
            if sec1.first() == Some(&0x04) {
                ""
            } else {
                ", not starting 0x04"
            }
        ));
    }
    p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1)
        .map(|_| ())
        .map_err(|_| "`public_key` is not a point on P-256".to_owned())
}

/// `n` bytes from the operating system's generator.
///
/// # Panics
///
/// When the operating system has no generator to give: a server that cannot
/// make a challenge must not make a guessable one.
pub fn random_bytes(n: usize) -> Vec<u8> {
    let mut bytes = vec![0u8; n];
    getrandom::fill(&mut bytes).expect("the operating system's random generator");
    bytes
}

/// Lowercase hex.
pub fn hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

/// Lowercase hex SHA-256: what an invite's secret is kept as.
pub fn sha256_hex(bytes: &[u8]) -> String {
    use sha2::Digest as _;
    hex(&sha2::Sha256::digest(bytes))
}

// ─── Who opened a ceremony ───────────────────────────────────────────────────

/// The one partition rule (RA5-34): [`crate::client_key`], used by the SCIM bearer's flood
/// guard and the front's per-caller buckets too.
pub use crate::client_key::{ClientKey, MAX_CLIENT_NAME};

// ─── The ceremony store ──────────────────────────────────────────────────────

/// How long a challenge may be answered: five minutes.
pub const CHALLENGE_TTL_MS: i64 = 5 * 60_000;
/// The most challenges outstanding at once, server-wide.
pub const MAX_OUTSTANDING_CHALLENGES: usize = 10_000;
/// How many ceremonies ONE client may hold open. Sixteen: a ceremony is
/// abandoned by anything that interrupts a person (a closed tab, a key that was
/// not there, a reload) and lingers for the TTL; a person retrying never meets
/// it and a loop meets it in milliseconds.
pub const PER_CLIENT_CHALLENGES: usize = 16;
/// The length of a challenge, in bytes.
pub const CHALLENGE_LEN: usize = 32;

/// The store's bounds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
    /// How long a challenge may be answered, in milliseconds.
    pub ttl_ms: i64,
    /// The most ceremonies open at once, server-wide.
    pub max_outstanding: usize,
    /// The most ceremonies one [`ClientKey`] may hold open.
    pub per_client: usize,
}

impl Default for Limits {
    fn default() -> Self {
        Limits {
            ttl_ms: CHALLENGE_TTL_MS,
            max_outstanding: MAX_OUTSTANDING_CHALLENGES,
            per_client: PER_CLIENT_CHALLENGES,
        }
    }
}

/// Why a ceremony was not opened. A full store **refuses the new ceremony
/// rather than evicting an old one**: evicting is how an anonymous flood makes a
/// real person's login fail with no cause.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Full {
    /// The server holds [`Limits::max_outstanding`] ceremonies.
    Server(usize),
    /// This client holds [`Limits::per_client`] ceremonies.
    Client(usize),
}

impl std::fmt::Display for Full {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Full::Server(n) => write!(
                f,
                "{n} web challenges are outstanding on this server; answer or let some expire first"
            ),
            Full::Client(n) => write!(
                f,
                "this address already has {n} passkey ceremonies open; try again in a moment"
            ),
        }
    }
}

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

/// A ceremony just opened. Its `Debug` redacts the challenge (an outstanding
/// challenge is answerable, so printing one is leaking one).
#[derive(Clone, PartialEq, Eq)]
pub struct Started {
    /// An opaque id for the ceremony, for a door that hands one to its client.
    pub id: String,
    /// The challenge, [`CHALLENGE_LEN`] random bytes.
    pub challenge: Vec<u8>,
    /// When it stops being answerable, Unix milliseconds.
    pub expires_at_ms: i64,
}

impl Started {
    /// The challenge as `clientDataJSON` carries it.
    pub fn challenge_b64(&self) -> String {
        b64url(&self.challenge)
    }
}

impl std::fmt::Debug for Started {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Started")
            .field("id", &self.id)
            .field(
                "challenge",
                &format_args!("<redacted {} bytes>", self.challenge.len()),
            )
            .field("expires_at_ms", &self.expires_at_ms)
            .finish()
    }
}

/// A ceremony taken out of the store: what it was opened for.
#[derive(Clone)]
pub struct Taken<P> {
    /// What the door said the ceremony was for at `start`.
    pub purpose: P,
    /// The challenge it was opened with.
    pub challenge: Vec<u8>,
    /// The client it was held for, when one was named.
    pub client: Option<ClientKey>,
}

impl<P> Taken<P> {
    /// The challenge as `clientDataJSON` carries it.
    pub fn challenge_b64(&self) -> String {
        b64url(&self.challenge)
    }
}

struct Open<P> {
    challenge: Vec<u8>,
    expires_at_ms: i64,
    client: Option<ClientKey>,
    purpose: P,
}

/// **The ceremonies this process has opened and not yet seen answered.** In
/// memory, on purpose: a challenge lives five minutes, and one issued before a
/// restart is one nobody can still be answering. `P` is what the door wants
/// back at `finish` (Holger: login or which registration; Gunnar: nothing).
///
/// Its `Debug` says how many are outstanding and never which.
pub struct CeremonyStore<P> {
    limits: Limits,
    open: Mutex<HashMap<String, Open<P>>>,
}

impl<P> Default for CeremonyStore<P> {
    fn default() -> Self {
        Self::new(Limits::default())
    }
}

impl<P> std::fmt::Debug for CeremonyStore<P> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CeremonyStore")
            .field("outstanding", &self.outstanding())
            .field("limits", &self.limits)
            .finish()
    }
}

impl<P> CeremonyStore<P> {
    /// An empty store with these bounds.
    pub fn new(limits: Limits) -> Self {
        CeremonyStore {
            limits,
            open: Mutex::new(HashMap::new()),
        }
    }

    /// The store's bounds.
    pub fn limits(&self) -> Limits {
        self.limits
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Open<P>>> {
        self.open.lock().unwrap_or_else(|p| p.into_inner())
    }

    /// How many ceremonies are open (expired ones included until the next start).
    pub fn outstanding(&self) -> usize {
        self.lock().len()
    }

    /// **Open a ceremony** for `client`, unless the server or that client
    /// already holds too many. `client: None` is a door that cannot say who
    /// asked (an older relay): only the server-wide bound applies to it.
    pub fn start(
        &self,
        purpose: P,
        client: Option<&ClientKey>,
        now_ms: i64,
    ) -> Result<Started, Full> {
        let mut open = self.lock();
        open.retain(|_, o| o.expires_at_ms > now_ms);
        if open.len() >= self.limits.max_outstanding {
            return Err(Full::Server(self.limits.max_outstanding));
        }
        if let Some(client) = client {
            let held = open
                .values()
                .filter(|o| o.client.as_ref() == Some(client))
                .count();
            if held >= self.limits.per_client {
                return Err(Full::Client(self.limits.per_client));
            }
        }
        let challenge = random_bytes(CHALLENGE_LEN);
        let id = hex(&random_bytes(16));
        let expires_at_ms = now_ms.saturating_add(self.limits.ttl_ms);
        open.insert(
            id.clone(),
            Open {
                challenge: challenge.clone(),
                expires_at_ms,
                client: client.cloned(),
                purpose,
            },
        );
        Ok(Started {
            id,
            challenge,
            expires_at_ms,
        })
    }

    /// **Spend a ceremony by its id**: `Some` when it was opened here and has not
    /// expired. It is gone afterwards whatever the answer, which is what makes a
    /// captured assertion unreplayable.
    pub fn take(&self, id: &str, now_ms: i64) -> Option<Taken<P>> {
        let o = self.lock().remove(id)?;
        (o.expires_at_ms > now_ms).then_some(Taken {
            purpose: o.purpose,
            challenge: o.challenge,
            client: o.client,
        })
    }

    /// **Spend a ceremony by its challenge** (a door that keeps no id: the
    /// challenge `clientDataJSON` carries). As [`CeremonyStore::take`].
    pub fn take_challenge(&self, challenge: &[u8], now_ms: i64) -> Option<Taken<P>> {
        let mut open = self.lock();
        let id = open
            .iter()
            .find(|(_, o)| o.challenge == challenge)
            .map(|(id, _)| id.clone())?;
        let o = open.remove(&id)?;
        (o.expires_at_ms > now_ms).then_some(Taken {
            purpose: o.purpose,
            challenge: o.challenge,
            client: o.client,
        })
    }
}

/// **The challenges of a door that keeps nothing else** (Gunnar's
/// `Access.WebChallenge`): a [`CeremonyStore`] with no purpose, spent by the
/// challenge itself.
pub type Challenges = CeremonyStore<()>;

impl CeremonyStore<()> {
    /// A fresh challenge and when it expires, held for `client`.
    pub fn issue(&self, client: Option<&ClientKey>, now_ms: i64) -> Result<(Vec<u8>, i64), Full> {
        self.start((), client, now_ms)
            .map(|s| (s.challenge, s.expires_at_ms))
    }

    /// **Spend a challenge**: true when it was issued here, has not expired and
    /// was not spent before. It is gone afterwards whatever the answer.
    pub fn spend(&self, challenge: &[u8], now_ms: i64) -> bool {
        self.take_challenge(challenge, now_ms).is_some()
    }
}

// ─── The ceremony ────────────────────────────────────────────────────────────

/// `clientDataJSON`, the three fields a relying party checks. Its `Debug`
/// redacts the challenge.
#[derive(Clone, PartialEq, Eq)]
pub struct ClientData {
    /// `webauthn.get` or `webauthn.create`.
    pub kind: String,
    /// The challenge, decoded from base64url.
    pub challenge: Vec<u8>,
    /// The origin the browser saw.
    pub origin: String,
}

impl std::fmt::Debug for ClientData {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ClientData")
            .field("kind", &self.kind)
            .field(
                "challenge",
                &format_args!("<redacted {} bytes>", self.challenge.len()),
            )
            .field("origin", &self.origin)
            .finish()
    }
}

/// Parse `clientDataJSON`.
pub fn client_data(json: &[u8]) -> Result<ClientData, String> {
    let value: serde_json::Value =
        serde_json::from_slice(json).map_err(|_| "clientDataJSON is not JSON".to_owned())?;
    let field = |name: &str| {
        value
            .get(name)
            .and_then(|v| v.as_str())
            .map(str::to_owned)
            .ok_or_else(|| format!("clientDataJSON has no `{name}`"))
    };
    let challenge = field("challenge")?;
    Ok(ClientData {
        kind: field("type")?,
        challenge: from_b64url(&challenge)
            .ok_or_else(|| "clientDataJSON's challenge is not base64url".to_owned())?,
        origin: field("origin")?,
    })
}

/// **Does `origin` belong to the relying party `rp_id`?** `https://` with a
/// host that is `rp_id` or a subdomain of it, or, for development,
/// `http://localhost` (a browser's secure context) when `rp_id` is
/// `localhost`. A port is allowed; a path is not.
pub fn origin_fits_rp_id(origin: &str, rp_id: &str) -> bool {
    let rp_id = rp_id.trim().to_ascii_lowercase();
    if rp_id.is_empty() || rp_id.contains('/') || rp_id.contains(':') {
        return false;
    }
    let (scheme, rest) = match origin.split_once("://") {
        Some(parts) => parts,
        None => return false,
    };
    if rest.contains('/') || rest.is_empty() {
        return false;
    }
    let host = match rest.rsplit_once(':') {
        Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
        Some(_) => return false,
        None => rest,
    }
    .to_ascii_lowercase();
    let host_fits = host == rp_id || host.ends_with(&format!(".{rp_id}"));
    match scheme {
        "https" => host_fits,
        "http" => host_fits && (host == "localhost" || host.ends_with(".localhost")),
        _ => false,
    }
}

/// **A relying party a server answers for**: its `rp_id` and the exact
/// origins a browser may be at for it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelyingParty {
    /// The WebAuthn relying-party id (`gunnar.rs`).
    pub rp_id: String,
    /// The origins allowed for it, exactly (`https://gunnar.rs`).
    pub origins: Vec<String>,
}

impl std::str::FromStr for RelyingParty {
    type Err = String;
    /// `<rp_id>=<origin>[,<origin>…]`.
    fn from_str(s: &str) -> Result<Self, String> {
        let (rp_id, origins) = s
            .split_once('=')
            .ok_or_else(|| format!("{s:?} is not <rp_id>=<origin>[,<origin>…]"))?;
        let rp_id = rp_id.trim().to_ascii_lowercase();
        let origins: Vec<String> = origins
            .split(',')
            .map(|o| o.trim().to_owned())
            .filter(|o| !o.is_empty())
            .collect();
        if rp_id.is_empty() || origins.is_empty() {
            return Err(format!("{s:?} names no relying party or no origin"));
        }
        if let Some(bad) = origins.iter().find(|o| !origin_fits_rp_id(o, &rp_id)) {
            return Err(format!(
                "the origin {bad:?} does not belong to the relying party {rp_id:?}"
            ));
        }
        Ok(RelyingParty { rp_id, origins })
    }
}

/// **Is `(rp_id, origin)`, as the relay states them, one this server answers
/// for?** An empty list is the relay's word.
pub fn check_relying_party(
    parties: &[RelyingParty],
    rp_id: &str,
    origin: &str,
) -> Result<(), String> {
    if parties.is_empty() {
        return Ok(());
    }
    let rp_id = rp_id.trim();
    let Some(party) = parties.iter().find(|p| p.rp_id.eq_ignore_ascii_case(rp_id)) else {
        return Err(format!(
            "this server is not the relying party {rp_id:?} (--web-relying-party)"
        ));
    };
    if !party.origins.iter().any(|o| o == origin.trim()) {
        return Err(format!(
            "the origin {origin:?} is not one this server answers for as {:?}",
            party.rp_id
        ));
    }
    Ok(())
}

/// Check a ceremony's client data against what the relay says the ceremony
/// was: its type, and an origin that is the one asked and fits the relying
/// party. (The challenge is the caller's to spend.)
pub fn check_ceremony(
    data: &ClientData,
    want_kind: &str,
    origin: &str,
    rp_id: &str,
) -> Result<(), String> {
    if data.kind != want_kind {
        return Err(format!(
            "this is a {:?} ceremony, and {want_kind:?} was asked for",
            data.kind
        ));
    }
    if data.origin != origin {
        return Err(format!(
            "the browser was at {:?}, not at {origin:?}",
            data.origin
        ));
    }
    if !origin_fits_rp_id(origin, rp_id) {
        return Err(format!(
            "the origin {origin:?} does not belong to the relying party {rp_id:?}"
        ));
    }
    Ok(())
}

/// What a verified assertion tells the caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Verified {
    /// The counter the authenticator presented: to be stored back.
    pub sign_count: u32,
    /// Whether the authenticator verified the USER (PIN, biometric).
    pub user_verified: bool,
}

/// **Verify an assertion's authenticator data and signature** against the
/// stored key and counter (the client data was checked by
/// [`check_ceremony`]): rpIdHash == SHA-256(rp_id); user presence (and user
/// verification when required); the ES256 signature over
/// `authenticatorData || SHA-256(clientDataJSON)`; and the counter rule (a
/// credential stored at 0 accepts any count; one that counted must grow, A34-31).
pub fn verify_assertion(
    public_key: &[u8],
    stored_count: u32,
    client_data_json: &[u8],
    authenticator_data: &[u8],
    signature_der: &[u8],
    rp_id: &str,
    require_user_verification: bool,
) -> Result<Verified, String> {
    use sha2::Digest as _;
    if authenticator_data.len() < 37 {
        return Err("authenticatorData is shorter than 37 bytes".to_owned());
    }
    if authenticator_data[..32] != sha2::Sha256::digest(rp_id.as_bytes())[..] {
        return Err(format!(
            "authenticatorData was made for another relying party than {rp_id:?}"
        ));
    }
    let flags = authenticator_data[32];
    if flags & FLAG_UP == 0 {
        return Err("the authenticator reports no user presence".to_owned());
    }
    let user_verified = flags & FLAG_UV != 0;
    if require_user_verification && !user_verified {
        return Err(
            "user verification was required and the authenticator did not verify the user"
                .to_owned(),
        );
    }
    p256::ecdsa::VerifyingKey::from_sec1_bytes(public_key)
        .map_err(|_| "the stored key is not a P-256 point".to_owned())?;
    p256::ecdsa::Signature::from_der(signature_der)
        .map_err(|_| "the signature is not ASN.1 DER ECDSA".to_owned())?;
    if !verify_es256_sec1(
        public_key,
        authenticator_data,
        client_data_json,
        signature_der,
    ) {
        return Err("the signature does not verify with this passkey".to_owned());
    }
    let sign_count = u32::from_be_bytes([
        authenticator_data[33],
        authenticator_data[34],
        authenticator_data[35],
        authenticator_data[36],
    ]);
    if !counter_fits(stored_count, sign_count) {
        return Err(format!(
            "the signature counter went from {stored_count} to {sign_count}: a cloned \
             authenticator, or a replay"
        ));
    }
    Ok(Verified {
        sign_count,
        user_verified,
    })
}

/// What a creation ceremony's `attestationObject` says, once
/// [`verify_attestation`] has bound it to the request's fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attested {
    /// The counter in its authenticatorData — the one stored.
    pub sign_count: u32,
    /// Whether the authenticator verified the USER (PIN, biometric).
    pub user_verified: bool,
    /// The attestation format (`none`, `packed`, …).
    pub format: String,
    /// The attestation statement's signature was verified with the
    /// credential's own key (a `packed` self-attestation).
    pub self_signed: bool,
}

/// **Bind a registration to what the authenticator attested** (stinky row 13,
/// 2026-10-10). The relay used to hand gunnar a bare `public_key`; now it
/// relays the browser's `attestationObject` too, and the server reads the
/// authenticatorData in it itself:
///
/// * `{fmt, attStmt, authData}`, well-formed CBOR, nothing after it;
/// * rpIdHash == SHA-256(`rp_id`), user presence set, attested credential
///   data present;
/// * its credential id == `credential_id`, its COSE key an ES256 P-256 EC2 key
///   whose point == `public_key` — the relay cannot name another key than the
///   authenticator made;
/// * `packed` with no certificate (self-attestation): the ES256 signature over
///   `authData || SHA-256(clientDataJSON)` verifies with that same key — the
///   authenticator holds it, for THIS ceremony;
/// * `none`: an empty statement.
///
/// What it cannot do: the server holds no attestation trust anchors, so a `none`
/// object (the browsers' default, and all a synced passkey sends) or a
/// certificate-signed format is checked for agreement only — whoever can
/// forge a whole creation ceremony can still forge its attestation object.
/// What it does close is the relay that swaps one field.
pub fn verify_attestation(
    attestation_object: &[u8],
    client_data_json: &[u8],
    rp_id: &str,
    credential_id: &[u8],
    public_key: &[u8],
) -> Result<Attested, String> {
    use sha2::Digest as _;
    if attestation_object.is_empty() {
        return Err("no attestation object: relay the browser's `attestationObject`".to_owned());
    }
    if attestation_object.len() > MAX_ATTESTATION_LEN {
        return Err(format!(
            "the attestation object is over {MAX_ATTESTATION_LEN} bytes"
        ));
    }
    let mut at = 0;
    let top = cbor::read(attestation_object, &mut at, 0)?;
    if at != attestation_object.len() {
        return Err("bytes after the attestation object".to_owned());
    }
    let top = top
        .map()
        .ok_or("the attestation object is not a CBOR map")?;
    let format = cbor::field(top, "fmt")
        .and_then(cbor::Item::text)
        .ok_or("the attestation object has no `fmt`")?
        .to_owned();
    let statement = cbor::field(top, "attStmt")
        .and_then(cbor::Item::map)
        .ok_or("the attestation object has no `attStmt` map")?;
    let auth_data = cbor::field(top, "authData")
        .and_then(cbor::Item::bytes)
        .ok_or("the attestation object has no `authData`")?;

    if auth_data.len() < 37 {
        return Err("authenticatorData is shorter than 37 bytes".to_owned());
    }
    if auth_data[..32] != sha2::Sha256::digest(rp_id.as_bytes())[..] {
        return Err(format!(
            "the authenticator made this credential for another relying party than {rp_id:?}"
        ));
    }
    let flags = auth_data[32];
    if flags & FLAG_UP == 0 {
        return Err("the authenticator reports no user presence".to_owned());
    }
    if flags & FLAG_AT == 0 || auth_data.len() < 55 {
        return Err("authenticatorData carries no attested credential data".to_owned());
    }
    let sign_count =
        u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]);
    let id_len = usize::from(u16::from_be_bytes([auth_data[53], auth_data[54]]));
    let attested_id = auth_data
        .get(55..55 + id_len)
        .ok_or("authenticatorData is cut short inside its credential id")?;
    if attested_id != credential_id {
        return Err("the attested credential id is not the relayed `credential_id`".to_owned());
    }
    let mut at = 55 + id_len;
    let cose = cbor::read(auth_data, &mut at, 0)?;
    if flags & FLAG_ED == 0 && at != auth_data.len() {
        return Err("bytes after the credential key, and no extensions flagged".to_owned());
    }
    let attested_key = cose_es256_point(&cose)?;
    if attested_key != public_key {
        return Err("the attested key is not the relayed `public_key`".to_owned());
    }

    let self_signed = match format.as_str() {
        "none" => {
            if !statement.is_empty() {
                return Err("a `none` attestation with a statement".to_owned());
            }
            false
        }
        "packed" if cbor::field(statement, "x5c").is_none() => {
            if cbor::field(statement, "alg").and_then(cbor::Item::int) != Some(-7) {
                return Err("a packed self-attestation that is not ES256".to_owned());
            }
            let sig = cbor::field(statement, "sig")
                .and_then(cbor::Item::bytes)
                .ok_or("a packed attestation with no `sig`")?;
            p256::ecdsa::Signature::from_der(sig)
                .map_err(|_| "the attestation signature is not ASN.1 DER ECDSA".to_owned())?;
            if !verify_es256_sec1(&attested_key, auth_data, client_data_json, sig) {
                return Err("the self-attestation does not verify with the attested key, over this ceremony".to_owned());
            }
            true
        }
        _ => false,
    };
    Ok(Attested {
        sign_count,
        user_verified: flags & FLAG_UV != 0,
        format,
        self_signed,
    })
}

/// An attestation object is a few hundred bytes; a certificate chain a few KiB.
pub const MAX_ATTESTATION_LEN: usize = 16 * 1024;

/// A COSE_Key (RFC 9053) for ES256 on P-256: kty 2 (EC2), alg -7, crv 1, and
/// 32-byte x and y — as `0x04 || x || y`.
fn cose_es256_point(cose: &cbor::Item<'_>) -> Result<Vec<u8>, String> {
    let map = cose.map().ok_or("the credential key is not a COSE map")?;
    let get = |label: i64| {
        map.iter()
            .find(|(k, _)| k.int() == Some(label))
            .map(|(_, v)| v)
    };
    if get(1).and_then(cbor::Item::int) != Some(2) {
        return Err("the credential key is not an EC2 key".to_owned());
    }
    if get(3).and_then(cbor::Item::int) != Some(-7) {
        return Err("the credential key is not ES256".to_owned());
    }
    if get(-1).and_then(cbor::Item::int) != Some(1) {
        return Err("the credential key is not on P-256".to_owned());
    }
    let (Some(x), Some(y)) = (
        get(-2).and_then(cbor::Item::bytes),
        get(-3).and_then(cbor::Item::bytes),
    ) else {
        return Err("the credential key has no x or y".to_owned());
    };
    if x.len() != 32 || y.len() != 32 {
        return Err("the credential key's x and y are not 32 bytes each".to_owned());
    }
    let mut point = Vec::with_capacity(PUBLIC_KEY_LEN);
    point.push(0x04);
    point.extend_from_slice(x);
    point.extend_from_slice(y);
    Ok(point)
}

/// **A `none` attestation object** — what a browser answers a creation
/// ceremony with by default: `{fmt: "none", attStmt: {}, authData}`, the
/// authData carrying user presence, the counter, and `credential_id` with its
/// P-256 key (`public_key`, SEC1 uncompressed) as a COSE EC2 key. For a test's
/// authenticator and for tools that drive the ceremony without a browser;
/// a server only ever reads one.
pub fn attestation_none(
    rp_id: &str,
    credential_id: &[u8],
    public_key: &[u8],
    sign_count: u32,
) -> Vec<u8> {
    let auth_data = attested_auth_data(
        rp_id,
        FLAG_UP | FLAG_AT,
        sign_count,
        credential_id,
        public_key,
    );
    attestation_object("none", &[], &auth_data)
}

/// `rpIdHash | flags | signCount | aaguid(0) | credIdLen | credId | COSE key`.
#[doc(hidden)]
pub fn attested_auth_data(
    rp_id: &str,
    flags: u8,
    sign_count: u32,
    credential_id: &[u8],
    public_key: &[u8],
) -> Vec<u8> {
    use sha2::Digest as _;
    let mut out = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
    out.push(flags);
    out.extend_from_slice(&sign_count.to_be_bytes());
    out.extend_from_slice(&[0u8; 16]);
    out.extend_from_slice(
        &u16::try_from(credential_id.len())
            .unwrap_or(u16::MAX)
            .to_be_bytes(),
    );
    out.extend_from_slice(credential_id);
    let (x, y) = if public_key.len() == PUBLIC_KEY_LEN {
        (&public_key[1..33], &public_key[33..])
    } else {
        (&[][..], &[][..])
    };
    cbor::map_head(&mut out, 5);
    cbor::int(&mut out, 1);
    cbor::int(&mut out, 2);
    cbor::int(&mut out, 3);
    cbor::int(&mut out, -7);
    cbor::int(&mut out, -1);
    cbor::int(&mut out, 1);
    cbor::int(&mut out, -2);
    cbor::bytes(&mut out, x);
    cbor::int(&mut out, -3);
    cbor::bytes(&mut out, y);
    out
}

/// `{fmt, attStmt, authData}` with `att_stmt` as `(text key, value)` pairs —
/// `alg` an integer, anything else bytes.
#[doc(hidden)]
pub fn attestation_object(
    fmt: &str,
    att_stmt: &[(&str, AttStmtValue<'_>)],
    auth_data: &[u8],
) -> Vec<u8> {
    let mut out = Vec::new();
    cbor::map_head(&mut out, 3);
    cbor::text(&mut out, "fmt");
    cbor::text(&mut out, fmt);
    cbor::text(&mut out, "attStmt");
    cbor::map_head(&mut out, att_stmt.len() as u64);
    for (k, v) in att_stmt {
        cbor::text(&mut out, k);
        match v {
            AttStmtValue::Int(i) => cbor::int(&mut out, *i),
            AttStmtValue::Bytes(b) => cbor::bytes(&mut out, b),
            AttStmtValue::Certs(certs) => {
                cbor::array_head(&mut out, certs.len() as u64);
                for c in *certs {
                    cbor::bytes(&mut out, c);
                }
            }
        }
    }
    cbor::text(&mut out, "authData");
    cbor::bytes(&mut out, auth_data);
    out
}

/// One value in an attestation statement, for [`attestation_object`].
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
pub enum AttStmtValue<'a> {
    Int(i64),
    Bytes(&'a [u8]),
    Certs(&'a [&'a [u8]]),
}

/// The few CBOR items WebAuthn's attestation object is made of (RFC 8949):
/// unsigned and negative integers, byte and text strings, arrays, maps, and the
/// simple values. Definite lengths only — CTAP2's canonical form — and a
/// nesting limit, because the bytes come from a relay.
mod cbor {
    const MAX_DEPTH: usize = 8;

    /// One decoded item, borrowing its strings from the input.
    #[derive(Debug, Clone, PartialEq)]
    pub(super) enum Item<'a> {
        Int(i128),
        Bytes(&'a [u8]),
        Text(&'a str),
        Array(Vec<Item<'a>>),
        Map(Vec<(Item<'a>, Item<'a>)>),
        Simple(u8),
    }

    impl<'a> Item<'a> {
        pub(super) fn int(&self) -> Option<i64> {
            match self {
                Item::Int(i) => i64::try_from(*i).ok(),
                _ => None,
            }
        }
        pub(super) fn bytes(&self) -> Option<&'a [u8]> {
            match self {
                Item::Bytes(b) => Some(b),
                _ => None,
            }
        }
        pub(super) fn text(&self) -> Option<&'a str> {
            match self {
                Item::Text(t) => Some(t),
                _ => None,
            }
        }
        pub(super) fn map(&self) -> Option<&[(Item<'a>, Item<'a>)]> {
            match self {
                Item::Map(m) => Some(m),
                _ => None,
            }
        }
    }

    /// The value under a text key.
    pub(super) fn field<'m, 'a>(
        map: &'m [(Item<'a>, Item<'a>)],
        key: &str,
    ) -> Option<&'m Item<'a>> {
        map.iter()
            .find(|(k, _)| k.text() == Some(key))
            .map(|(_, v)| v)
    }

    fn take<'a>(buf: &'a [u8], at: &mut usize, n: usize) -> Result<&'a [u8], String> {
        let end = at
            .checked_add(n)
            .filter(|end| *end <= buf.len())
            .ok_or("CBOR cut short")?;
        let out = &buf[*at..end];
        *at = end;
        Ok(out)
    }

    /// One item at `*at`, advancing past it.
    pub(super) fn read<'a>(
        buf: &'a [u8],
        at: &mut usize,
        depth: usize,
    ) -> Result<Item<'a>, String> {
        if depth > MAX_DEPTH {
            return Err("CBOR nested too deep".to_owned());
        }
        let first = take(buf, at, 1)?[0];
        let (major, info) = (first >> 5, first & 0x1f);
        let n: u64 = match info {
            0..=23 => u64::from(info),
            24 => u64::from(take(buf, at, 1)?[0]),
            25 => u64::from(u16::from_be_bytes(take(buf, at, 2)?.try_into().expect("2"))),
            26 => u64::from(u32::from_be_bytes(take(buf, at, 4)?.try_into().expect("4"))),
            27 => u64::from_be_bytes(take(buf, at, 8)?.try_into().expect("8")),
            _ => return Err("indefinite-length or reserved CBOR".to_owned()),
        };
        // Every element is at least one byte: a count past what is left is a lie.
        let left = buf.len() - *at;
        let count = |n: u64| {
            usize::try_from(n)
                .ok()
                .filter(|n| *n <= left)
                .ok_or_else(|| "CBOR cut short".to_owned())
        };
        Ok(match major {
            0 => Item::Int(i128::from(n)),
            1 => Item::Int(-1 - i128::from(n)),
            2 => Item::Bytes(take(buf, at, count(n)?)?),
            3 => Item::Text(
                std::str::from_utf8(take(buf, at, count(n)?)?)
                    .map_err(|_| "CBOR text is not UTF-8".to_owned())?,
            ),
            4 => {
                let n = count(n)?;
                let mut items = Vec::with_capacity(n);
                for _ in 0..n {
                    items.push(read(buf, at, depth + 1)?);
                }
                Item::Array(items)
            }
            5 => {
                let n = count(n)?;
                let mut pairs = Vec::with_capacity(n);
                for _ in 0..n {
                    let k = read(buf, at, depth + 1)?;
                    let v = read(buf, at, depth + 1)?;
                    if pairs.iter().any(|(seen, _)| *seen == k) {
                        return Err("a CBOR map names one key twice".to_owned());
                    }
                    pairs.push((k, v));
                }
                Item::Map(pairs)
            }
            6 => return Err("tagged CBOR".to_owned()),
            _ if info < 24 => Item::Simple(info),
            _ => return Err("CBOR floats have no place in an attestation".to_owned()),
        })
    }

    pub(super) fn head(out: &mut Vec<u8>, major: u8, n: u64) {
        let m = major << 5;
        match n {
            0..=23 => out.push(m | n as u8),
            24..=0xff => out.extend_from_slice(&[m | 24, n as u8]),
            0x100..=0xffff => {
                out.push(m | 25);
                out.extend_from_slice(&(n as u16).to_be_bytes());
            }
            0x1_0000..=0xffff_ffff => {
                out.push(m | 26);
                out.extend_from_slice(&(n as u32).to_be_bytes());
            }
            _ => {
                out.push(m | 27);
                out.extend_from_slice(&n.to_be_bytes());
            }
        }
    }
    pub(super) fn int(out: &mut Vec<u8>, v: i64) {
        if v >= 0 {
            head(out, 0, v as u64);
        } else {
            head(out, 1, (-1 - v) as u64);
        }
    }
    pub(super) fn bytes(out: &mut Vec<u8>, b: &[u8]) {
        head(out, 2, b.len() as u64);
        out.extend_from_slice(b);
    }
    pub(super) fn text(out: &mut Vec<u8>, t: &str) {
        head(out, 3, t.len() as u64);
        out.extend_from_slice(t.as_bytes());
    }
    pub(super) fn array_head(out: &mut Vec<u8>, n: u64) {
        head(out, 4, n);
    }
    pub(super) fn map_head(out: &mut Vec<u8>, n: u64) {
        head(out, 5, n);
    }
}

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

    fn key(seed: u8) -> Vec<u8> {
        p256::ecdsa::SigningKey::from_slice(&[seed; 32])
            .expect("a scalar")
            .verifying_key()
            .to_sec1_point(false)
            .as_bytes()
            .to_vec()
    }

    #[test]
    fn a_point_on_the_curve_is_a_key_and_nothing_else_is() {
        assert!(check_public_key(&key(1)).is_ok());
        let mut off = key(1);
        off[64] ^= 1;
        assert!(check_public_key(&off).is_err(), "off the curve");
        let compressed = p256::ecdsa::SigningKey::from_slice(&[1; 32])
            .expect("a scalar")
            .verifying_key()
            .to_sec1_point(true)
            .as_bytes()
            .to_vec();
        assert!(
            check_public_key(&compressed).is_err(),
            "compressed is not the stored form"
        );
        assert!(check_public_key(&[0u8; 65]).is_err());
    }

    #[test]
    fn an_origin_belongs_to_its_relying_party_and_no_other() {
        for (origin, rp, fits) in [
            ("https://gunnar.rs", "gunnar.rs", true),
            ("https://console.gunnar.rs", "gunnar.rs", true),
            ("https://gunnar.rs:8443", "gunnar.rs", true),
            ("https://evilgunnar.rs", "gunnar.rs", false),
            ("http://gunnar.rs", "gunnar.rs", false),
            ("http://localhost:3210", "localhost", true),
            ("https://gunnar.rs/path", "gunnar.rs", false),
            ("gunnar.rs", "gunnar.rs", false),
        ] {
            assert_eq!(origin_fits_rp_id(origin, rp), fits, "{origin} / {rp}");
        }
    }

    #[test]
    fn a_challenge_is_spent_once_and_not_after_it_expires() {
        let c = Challenges::default();
        let (one, expires) = c.issue(None, 1_000).expect("issued");
        assert_eq!(one.len(), CHALLENGE_LEN);
        assert_eq!(expires, 1_000 + CHALLENGE_TTL_MS);
        assert!(c.spend(&one, 2_000));
        assert!(!c.spend(&one, 2_000), "spent");
        let (two, expires) = c.issue(None, 1_000).expect("issued");
        assert!(!c.spend(&two, expires), "expired");
        assert!(!c.spend(&[0; 32], 1_000), "never issued");
    }

    #[test]
    fn a_credential_id_round_trips_through_its_key() {
        let raw = b"\x00\xffcredential";
        assert_eq!(from_b64url(&b64url(raw)).as_deref(), Some(&raw[..]));
        assert!(check_credential_id(&[]).is_err());
        assert!(check_credential_id(&[1; 1024]).is_err());
        assert!(check_credential_id(&[1; 1023]).is_ok());
    }

    /// Stinky row 23 (2026-10-10): `#[derive(Debug)]` on the challenges this
    /// process holds and on a parsed `clientDataJSON` — one `{:?}` in a log
    /// line wrote live, answerable challenges into it.
    #[test]
    fn a_debug_print_never_carries_a_live_challenge() {
        let c = Challenges::default();
        let (one, _) = c.issue(None, 1_000).expect("issued");
        let shown = format!("{c:?}");
        for spelled in [format!("{one:?}"), format!("{:?}", &one[..4]), b64url(&one)] {
            assert!(
                !shown.contains(spelled.trim_end_matches(']')),
                "a live challenge is in {shown}"
            );
        }
        assert!(
            shown.contains('1'),
            "how many are outstanding is still said: {shown}"
        );

        let json = format!(
            r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://gunnar.rs"}}"#,
            b64url(&one)
        );
        let data = client_data(json.as_bytes()).expect("parsed");
        let shown = format!("{data:?}");
        for spelled in [
            format!("{:?}", data.challenge),
            format!("{:?}", &data.challenge[..4]),
            b64url(&one),
        ] {
            assert!(
                !shown.contains(spelled.trim_end_matches(']')),
                "the challenge is in {shown}"
            );
        }
        assert!(
            shown.contains("webauthn.get") && shown.contains("https://gunnar.rs"),
            "the rest is still said: {shown}"
        );
    }

    /// Stinky row 13 (2026-10-10): `rp_id` and `origin` came from the request
    /// alone, so the whole ceremony trusted the relay: a relay could run it for
    /// any relying party it named. A server told its relying parties refuses
    /// any other.
    #[test]
    fn a_server_told_its_relying_parties_refuses_any_other() {
        let parties: Vec<RelyingParty> = vec!["gunnar.rs=https://gunnar.rs,https://app.gunnar.rs"
            .parse()
            .unwrap()];
        assert!(check_relying_party(&parties, "gunnar.rs", "https://gunnar.rs").is_ok());
        assert!(check_relying_party(&parties, "GUNNAR.rs", "https://app.gunnar.rs").is_ok());
        assert!(
            check_relying_party(&parties, "evil.example", "https://evil.example").is_err(),
            "another relying party"
        );
        assert!(
            check_relying_party(&parties, "gunnar.rs", "https://evil.gunnar.rs").is_err(),
            "an origin not listed, though it fits the rp_id"
        );
        assert!(
            check_relying_party(&[], "evil.example", "https://evil.example").is_ok(),
            "none told: the relay's word, as before"
        );
        assert!("gunnar.rs=https://evil.example"
            .parse::<RelyingParty>()
            .is_err());
        assert!("gunnar.rs".parse::<RelyingParty>().is_err());
    }

    fn signer(seed: u8) -> p256::ecdsa::SigningKey {
        p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar")
    }

    fn packed_self(
        seed: u8,
        rp_id: &str,
        id: &[u8],
        client_data_json: &[u8],
        sign_with: u8,
    ) -> Vec<u8> {
        use p256::ecdsa::signature::Signer as _;
        use sha2::Digest as _;
        let auth_data = attested_auth_data(rp_id, FLAG_UP | FLAG_UV | FLAG_AT, 3, id, &key(seed));
        let mut message = auth_data.clone();
        message.extend_from_slice(&sha2::Sha256::digest(client_data_json));
        let sig: p256::ecdsa::Signature = signer(sign_with).sign(&message);
        let der = sig.to_der();
        attestation_object(
            "packed",
            &[
                ("alg", AttStmtValue::Int(-7)),
                ("sig", AttStmtValue::Bytes(der.as_bytes())),
            ],
            &auth_data,
        )
    }

    /// Stinky row 13, second half (2026-10-10): a registration took a raw
    /// `public_key` on the relay's word. Now the authenticator's own
    /// attestation object must name the same credential, the same key and the
    /// same relying party, with user presence — and a `packed`
    /// self-attestation's signature must verify with that key.
    #[test]
    fn a_registration_is_bound_to_what_its_authenticator_attested() {
        const CD: &[u8] =
            br#"{"type":"webauthn.create","challenge":"AAAA","origin":"https://gunnar.rs"}"#;
        let none = attestation_none("gunnar.rs", b"phone", &key(5), 0);
        let ok = verify_attestation(&none, CD, "gunnar.rs", b"phone", &key(5))
            .expect("a none attestation that agrees");
        assert_eq!(
            (ok.format.as_str(), ok.self_signed, ok.sign_count),
            ("none", false, 0)
        );

        let refused = |obj: &[u8], rp: &str, id: &[u8], pk: &[u8], what: &str| {
            let r = verify_attestation(obj, CD, rp, id, pk);
            assert!(r.is_err(), "{what}: {r:?}");
        };
        refused(
            &none,
            "gunnar.rs",
            b"phone",
            &key(6),
            "another key than the authenticator's",
        );
        refused(
            &none,
            "gunnar.rs",
            b"laptop",
            &key(5),
            "another credential than the authenticator's",
        );
        refused(
            &none,
            "evil.example",
            b"phone",
            &key(5),
            "made for another relying party",
        );
        refused(&[], "gunnar.rs", b"phone", &key(5), "no attestation at all");
        refused(
            &none[..none.len() - 3],
            "gunnar.rs",
            b"phone",
            &key(5),
            "cut short",
        );
        refused(&[0xa0], "gunnar.rs", b"phone", &key(5), "an empty map");
        refused(
            &[0xbb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
            "gunnar.rs",
            b"phone",
            &key(5),
            "a map that claims 2^64 entries",
        );
        refused(
            &[[0x81u8; 64].as_slice(), &[0]].concat(),
            "gunnar.rs",
            b"phone",
            &key(5),
            "nested past the limit",
        );
        refused(
            &[none.as_slice(), &[0]].concat(),
            "gunnar.rs",
            b"phone",
            &key(5),
            "bytes after the object",
        );
        let absent = attestation_object(
            "none",
            &[],
            &attested_auth_data("gunnar.rs", FLAG_AT, 0, b"phone", &key(5)),
        );
        refused(&absent, "gunnar.rs", b"phone", &key(5), "no user presence");
        let bare = attestation_object(
            "none",
            &[],
            &attested_auth_data("gunnar.rs", FLAG_UP, 0, b"phone", &key(5))[..37],
        );
        refused(
            &bare,
            "gunnar.rs",
            b"phone",
            &key(5),
            "no attested credential data",
        );

        let packed = packed_self(5, "gunnar.rs", b"phone", CD, 5);
        let ok = verify_attestation(&packed, CD, "gunnar.rs", b"phone", &key(5))
            .expect("a packed self-attestation");
        assert_eq!(
            (
                ok.format.as_str(),
                ok.self_signed,
                ok.sign_count,
                ok.user_verified
            ),
            ("packed", true, 3, true)
        );
        refused(
            &packed_self(5, "gunnar.rs", b"phone", CD, 6),
            "gunnar.rs",
            b"phone",
            &key(5),
            "signed by another key",
        );
        let other_cd =
            br#"{"type":"webauthn.create","challenge":"BBBB","origin":"https://gunnar.rs"}"#;
        assert!(
            verify_attestation(
                &packed_self(5, "gunnar.rs", b"phone", other_cd, 5),
                CD,
                "gunnar.rs",
                b"phone",
                &key(5)
            )
            .is_err(),
            "signed over another ceremony's client data"
        );
    }

    // ── Moved with Holger's partition and fixed (A34-7, A34-8, A34-31) ───────

    #[test]
    fn an_ipv6_client_is_keyed_by_its_64_and_an_ipv4_one_by_its_address() {
        let a = ClientKey::of_peer("[2001:db8:0:1::1]:443");
        let b = ClientKey::of_peer("[2001:db8:0:1:ffff::2]:51000");
        let other = ClientKey::of_peer("[2001:db8:0:2::1]:443");
        assert_eq!(a, b, "one /64 is one client");
        assert_ne!(a, other, "another /64 is another client");
        assert_eq!(a.as_str(), "2001:db8:0:1::/64");
        assert_ne!(
            ClientKey::of_peer("[2001:db8::1]:443"),
            ClientKey::of_peer("[2001:db9::1]:443"),
            "A34-8: `split(':')` made every 2001:* address one client"
        );
        assert_eq!(ClientKey::of_peer("192.0.2.7:5555").as_str(), "192.0.2.7");
        assert_ne!(
            ClientKey::of_peer("192.0.2.7:1"),
            ClientKey::of_peer("192.0.2.8:1")
        );
        assert_eq!(
            ClientKey::of_peer("[::ffff:192.0.2.7]:80"),
            ClientKey::of_peer("192.0.2.7:80"),
            "mapped v4"
        );
        assert_eq!(ClientKey::of_peer("2001:db8:0:1::9"), a, "a bare address");
        assert_eq!(ClientKey::of_peer("via admin: x").as_str(), "via admin: x");
        assert_eq!(
            ClientKey::named(&"é".repeat(200)).as_str().len(),
            MAX_CLIENT_NAME
        );
    }

    #[test]
    fn a_flood_from_one_client_meets_its_own_ceiling_and_leaves_every_other_login_alone() {
        let store: Challenges = CeremonyStore::new(Limits {
            ttl_ms: 1_000,
            max_outstanding: 64,
            per_client: 4,
        });
        let flooder = ClientKey::of_peer("[2001:db8:bad::1]:1");
        let flooder_next_door = ClientKey::of_peer("[2001:db8:bad:0:1::7]:2");
        for _ in 0..4 {
            store.issue(Some(&flooder), 0).expect("within its ceiling");
        }
        assert_eq!(
            store.issue(Some(&flooder_next_door), 0),
            Err(Full::Client(4)),
            "the same /64"
        );
        let person = ClientKey::of_peer("198.51.100.4:443");
        let (c, _) = store
            .issue(Some(&person), 0)
            .expect("A34-7: a real person still gets a challenge");
        assert!(store.spend(&c, 10));
        // expired ceremonies free the client again
        store
            .issue(Some(&flooder), 1_000)
            .expect("its old ones expired");
        // the server-wide bound still holds for clients that cannot be named
        let wide: Challenges = CeremonyStore::new(Limits {
            ttl_ms: 1_000,
            max_outstanding: 2,
            per_client: 1,
        });
        wide.issue(None, 0).unwrap();
        wide.issue(None, 0).unwrap();
        assert_eq!(wide.issue(None, 0), Err(Full::Server(2)));
        assert!(Full::Client(4).to_string().contains("this address"));
    }

    #[test]
    fn a_ceremony_is_spent_once_by_its_id_and_carries_its_purpose() {
        let store: CeremonyStore<&'static str> = CeremonyStore::default();
        let me = ClientKey::of_peer("192.0.2.1:9");
        let s = store.start("login", Some(&me), 0).expect("opened");
        assert_eq!(s.challenge.len(), CHALLENGE_LEN);
        assert!(!format!("{s:?}").contains(&s.challenge_b64()), "redacted");
        assert!(!format!("{store:?}").contains(&s.challenge_b64()));
        let t = store.take(&s.id, 1).expect("taken");
        assert_eq!(
            (t.purpose, t.client.as_ref(), t.challenge_b64()),
            ("login", Some(&me), s.challenge_b64())
        );
        assert!(store.take(&s.id, 1).is_none(), "single use");
        let late = store.start("register", None, 0).unwrap();
        assert!(store.take(&late.id, CHALLENGE_TTL_MS).is_none(), "expired");
        assert_eq!(
            store.outstanding(),
            0,
            "an expired ceremony is gone once taken"
        );
        let by_challenge = store.start("register", None, 0).unwrap();
        assert_eq!(
            store
                .take_challenge(&by_challenge.challenge, 1)
                .map(|t| t.purpose),
            Some("register")
        );
    }

    fn assertion(seed: u8, rp_id: &str, flags: u8, count: u32, cdj: &[u8]) -> (Vec<u8>, Vec<u8>) {
        use p256::ecdsa::signature::Signer as _;
        use sha2::Digest as _;
        let mut ad = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
        ad.push(flags);
        ad.extend_from_slice(&count.to_be_bytes());
        let mut message = ad.clone();
        message.extend_from_slice(&sha2::Sha256::digest(cdj));
        let sig: p256::ecdsa::Signature = signer(seed).sign(&message);
        (ad, sig.to_der().as_bytes().to_vec())
    }

    #[test]
    fn an_assertion_verifies_with_its_key_and_a_counter_that_counted_must_grow() {
        const CD: &[u8] =
            br#"{"type":"webauthn.get","challenge":"AAAA","origin":"https://gunnar.rs"}"#;
        let (ad, sig) = assertion(5, "gunnar.rs", FLAG_UP | FLAG_UV, 7, CD);
        let ok = verify_assertion(&key(5), 6, CD, &ad, &sig, "gunnar.rs", true).expect("genuine");
        assert_eq!(
            ok,
            Verified {
                sign_count: 7,
                user_verified: true
            }
        );
        assert!(
            verify_assertion(&key(6), 6, CD, &ad, &sig, "gunnar.rs", false).is_err(),
            "another key"
        );
        assert!(
            verify_assertion(&key(5), 6, CD, &ad, &sig, "evil.example", false).is_err(),
            "another rp"
        );
        assert!(
            verify_assertion(&key(5), 7, CD, &ad, &sig, "gunnar.rs", false).is_err(),
            "stood still"
        );
        let (up, upsig) = assertion(5, "gunnar.rs", FLAG_UP, 0, CD);
        assert!(
            verify_assertion(&key(5), 0, CD, &up, &upsig, "gunnar.rs", false).is_ok(),
            "a synced passkey, 0 for ever"
        );
        assert!(
            verify_assertion(&key(5), 0, CD, &up, &upsig, "gunnar.rs", true).is_err(),
            "UV demanded"
        );
        let r = verify_assertion(&key(5), 5, CD, &up, &upsig, "gunnar.rs", false);
        assert!(
            r.as_ref().is_err_and(|e| e.contains("from 5 to 0")),
            "A34-31: a drop to 0 after 5: {r:?}"
        );
    }
}