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:?}"
);
}
}