crates/nornir-auth-webauthn/src/pure.rs
//! The **pure-Rust** WebAuthn backend — no FFI, no C toolchain, no system libssl.
//!
//! The `webauthn-rs`-backed provider that once stood beside it is deleted
//! (A34-32). This one is the right choice for an **airgapped appliance**, and the reason is measurable
//! rather than aesthetic.
//!
//! Measured against holger's real `full` tier (`cargo tree -p holger-server-lib
//! --no-default-features --features full`, 2026-09-17):
//!
//! | backend | crates ADDED | C / FFI | licence |
//! |---|---|---|---|
//! | `webauthn-rs` 0.5 | **41** | `openssl` + `openssl-sys` + `foreign-types` — system libssl at BUILD time | MPL-2.0 |
//! | `p256` + `ciborium` (here) | **24** | **none** | MIT / Apache-2.0 |
//!
//! Note what is NOT the argument: holger's `deny.toml` explicitly allows MPL-2.0,
//! so the licence is not the disqualifier. The disqualifier is `openssl-sys` — a C
//! library that must be present on the build host, in an artifact whose entire
//! purpose is to be built and shipped into a sealed environment. THE LAW permits
//! FFI only when no Rust library exists, and one does.
//!
//! ## What this backend deliberately does NOT do
//!
//! **Attestation is parsed but not verified.** An attestation chain answers "was
//! this credential made by a genuine YubiKey model X", which is a question about
//! the *manufacturer*. In an airgapped appliance the keys are ones an admin
//! enrolled in person, standing at the box; the question nobody is asking is which
//! vendor made them, and answering it would cost the x509 + CA-store closure this
//! module exists to avoid. The same reasoning is written into gunnar's console,
//! which has run this shape in production.
//!
//! **ES256 on P-256 only** (COSE `alg = -7`). It is the one algorithm every
//! authenticator that matters supports, it is mandatory-to-implement for platform
//! authenticators, and every additional curve is more attack surface for a case
//! that does not arise. A credential offering anything else is refused at
//! registration, loudly, rather than at login, mysteriously.
//!
//! ## The ceremony is stateful; the CREDENTIAL is not
//!
//! A WebAuthn login is two round trips, and a nordisk auth seam
//! (`validate_request(config, bearer, client_cert_cn)`) is one stateless credential
//! per request. Those are reconciled by noticing that **the ceremony is not the
//! credential**: the ceremony runs on the server's own HTTP door and, on success,
//! mints a MAC'd [`SessionToken`]. That token is an ordinary bearer, so every
//! request after the login walks the seam that already exists. This module
//! therefore ships both halves — the ceremony crypto and the session token — and
//! the caller decides what a verified assertion grants.
use std::time::{SystemTime, UNIX_EPOCH};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// base64url, no padding — the encoding WebAuthn uses for every binary field it
/// puts in JSON.
pub const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
// ─── Authenticator-data flag bits (WebAuthn §6.1) ────────────────────────────
// User Present (the person touched the authenticator), User Verified (a PIN or
// a biometric, not just a touch: what a step-up decision may rely on), Attested
// credential data (registration responses only). Shared with `ceremony`.
use crate::es256::{FLAG_AT, FLAG_UP, FLAG_UV};
/// Why a ceremony was refused.
///
/// Every variant is a REFUSAL; there is no "probably fine" outcome. The variants
/// are distinguished so a server can log precisely what happened without handing
/// the distinction to the caller — an authenticator that learns *which* check it
/// failed is being told how to pass it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CeremonyError {
/// The relying-party configuration cannot work in a browser.
RelyingParty(String),
/// `clientDataJSON` is absent, not JSON, or the wrong ceremony type.
ClientData(&'static str),
/// The response does not carry this ceremony's challenge — a replay, a stale
/// tab, or a ceremony that was already spent.
Challenge,
/// The browser origin is not this relying party's.
Origin,
/// `authenticatorData` is malformed, truncated, or for a different `rp_id`.
AuthData(&'static str),
/// The attestation object or the COSE public key could not be read.
Attestation(&'static str),
/// The credential offers an algorithm this backend refuses (anything but
/// ES256 on P-256).
UnsupportedAlgorithm,
/// A required flag was not set — user presence, or user verification when the
/// caller demanded it.
MissingFlag(&'static str),
/// The assertion signature did not verify against the stored public key.
BadSignature,
/// The authenticator's signature counter went backwards or stood still: the
/// hallmark of a CLONED credential.
CounterRollback { stored: u32, presented: u32 },
}
impl std::fmt::Display for CeremonyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CeremonyError::RelyingParty(m) => write!(f, "relying party: {m}"),
CeremonyError::ClientData(m) => write!(f, "clientDataJSON: {m}"),
CeremonyError::Challenge => write!(f, "the response does not carry this ceremony's challenge"),
CeremonyError::Origin => write!(f, "the response has the wrong browser origin"),
CeremonyError::AuthData(m) => write!(f, "authenticatorData: {m}"),
CeremonyError::Attestation(m) => write!(f, "attestationObject: {m}"),
CeremonyError::UnsupportedAlgorithm => {
write!(f, "only ES256 keys on P-256 are accepted")
}
CeremonyError::MissingFlag(m) => write!(f, "the {m} flag is missing"),
CeremonyError::BadSignature => write!(f, "the assertion signature did not verify"),
CeremonyError::CounterRollback { stored, presented } => write!(
f,
"the signature counter went backwards ({presented} after {stored}) — \
this credential may have been cloned"
),
}
}
}
impl std::error::Error for CeremonyError {}
// ─── The relying party ───────────────────────────────────────────────────────
/// Where the relying party lives, **as the browser sees it** — validated once, at
/// construction, so an unusable configuration is a boot failure and never a
/// mysterious login failure.
///
/// ## `rp_id` is the phishing boundary, so it is CONFIGURATION ONLY
///
/// A credential is scoped by the authenticator to the `rp_id`, and the browser
/// refuses to release it to any other. That makes `rp_id` the one value in this
/// module an attacker would most like to choose — which is exactly why it must
/// never be derived from a request. A server that read it from the `Host` header
/// would let anyone who can set that header (including a merely misconfigured
/// reverse proxy) pick the boundary that is supposed to contain them.
///
/// ## The relocation property, stated rather than worked around
///
/// A credential is bound to `rp_id` **forever**. An appliance that moves from
/// `holger.lab.internal` to `holger.prod.internal` has invalidated every key
/// registered against it, and no amount of code here changes that — it is what
/// binding a credential to a name MEANS. The honest mitigation is at the
/// deployment level: passkeys are one auth method among several, so mTLS and a
/// static token keep a renamed box reachable for re-enrolment. A server that
/// offers passkeys as its ONLY method has built a brick waiting for a rename.
#[derive(Debug, Clone)]
pub struct PureRelyingParty {
rp_id: String,
rp_origin: String,
rp_name: String,
/// `SHA256(rp_id)`, precomputed: every assertion compares against it, and
/// hashing a short string per request is a pointless cost in a hot path.
rp_id_hash: [u8; 32],
}
impl PureRelyingParty {
/// Validate and bind a relying party.
///
/// # Errors
///
/// Refuses the two configurations that cannot work in a browser, and refuses
/// them here so the failure is at boot:
///
/// * an **IP-literal `rp_id`** — WebAuthn requires a domain, so an appliance
/// reached at `https://10.0.0.7` can never register a key;
/// * an **origin that does not belong to the `rp_id`** — the origin is what an
/// assertion is checked against and `rp_id` is what the credential is scoped
/// to, so if the two may disagree, the check that binds a credential to this
/// server is checking something else.
pub fn new(rp_id: &str, rp_origin: &str, rp_name: &str) -> Result<Self, CeremonyError> {
let rp_id = rp_id.trim();
let rp_origin = rp_origin.trim().trim_end_matches('/');
validate_rp_id(rp_id).map_err(CeremonyError::RelyingParty)?;
validate_origin_for_rp_id(rp_origin, rp_id).map_err(CeremonyError::RelyingParty)?;
Ok(Self {
rp_id: rp_id.to_owned(),
rp_origin: rp_origin.to_owned(),
rp_name: if rp_name.trim().is_empty() {
rp_id.to_owned()
} else {
rp_name.trim().to_owned()
},
rp_id_hash: Sha256::digest(rp_id.as_bytes()).into(),
})
}
/// The registrable domain handed to the authenticator.
pub fn rp_id(&self) -> &str {
&self.rp_id
}
/// The exact browser origin every assertion is checked against.
pub fn rp_origin(&self) -> &str {
&self.rp_origin
}
/// The human-facing name the authenticator shows the operator.
pub fn rp_name(&self) -> &str {
&self.rp_name
}
}
// The two relying-party validators used to live here. They moved to
// `nornir_service::passkey_rp`, which is the one crate BOTH this leaf and
// `nornir-auth-policy` already depend on — because a server's BOOT GATE has to
// ask "can this relying party work at all" in a build that pinned no crypto
// backend, and reaching into an optional dependency to ask is how a check ends up
// existing in two versions that disagree. They are pure string work with no
// dependencies, so the seam crate carries them at no cost.
pub use nornir_service::passkey_rp::{validate_origin_for_rp_id, validate_rp_id};
// ─── A registered credential ─────────────────────────────────────────────────
/// One registered passkey, as the server stores it.
///
/// The public key is kept as raw P-256 affine coordinates rather than as a
/// re-encoded SEC1/DER blob so that what is persisted is exactly what the
/// authenticator attested, with no encoder in between to disagree about it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegisteredCredential {
/// base64url credential id — what the browser echoes back at login.
pub id: String,
/// P-256 public key X coordinate, exactly 32 bytes.
pub x: Vec<u8>,
/// P-256 public key Y coordinate, exactly 32 bytes.
pub y: Vec<u8>,
/// The authenticator's signature counter as of the last accepted assertion.
/// Monotonic per credential; see [`CeremonyError::CounterRollback`].
pub sign_count: u32,
}
/// What a verified assertion tells the caller beyond "it was genuine".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifiedAssertion {
/// The counter the authenticator presented. The caller MUST persist this back
/// onto the credential, or the clone check degrades to nothing.
pub sign_count: u32,
/// Whether the authenticator verified the USER (PIN / biometric), not merely
/// that somebody touched it. A step-up decision may rely on this; a plain
/// login need not.
pub user_verified: bool,
}
// ─── Registration ────────────────────────────────────────────────────────────
/// Verify a **registration** (credential-create) response and extract the
/// credential to store.
///
/// `attestation_object` and `client_data` are the raw bytes the browser returned
/// (the caller has already base64url-decoded them); `challenge_b64` is the
/// challenge this server minted for this ceremony and has not yet spent.
///
/// Attestation is parsed for the credential it carries and is **not** verified as
/// a chain — see the module header for why that is a decision and not an omission.
///
/// # Errors
///
/// Every check in WebAuthn §7.1 that this backend takes a position on: ceremony
/// type, challenge, origin, `rp_id` hash, user presence, and the credential
/// algorithm.
pub fn verify_registration(
rp: &PureRelyingParty,
attestation_object: &[u8],
client_data: &[u8],
challenge_b64: &str,
require_user_verification: bool,
) -> Result<RegisteredCredential, CeremonyError> {
check_client_data(client_data, "webauthn.create", challenge_b64, rp.rp_origin())?;
let obj: ciborium::value::Value = ciborium::from_reader(attestation_object)
.map_err(|_| CeremonyError::Attestation("not CBOR"))?;
let auth_data = cbor_text_key(&obj, "authData")
.and_then(|v| v.as_bytes())
.ok_or(CeremonyError::Attestation("no authData"))?
.clone();
let (count, flags) = check_auth_data(&auth_data, &rp.rp_id_hash, require_user_verification)?;
if flags & FLAG_AT == 0 {
return Err(CeremonyError::Attestation(
"carries no attested credential data",
));
}
// attestedCredentialData = aaguid[16] | credIdLen[2] | credId | COSEKey
// It begins at offset 37, so the length field sits at 53..55.
if auth_data.len() < 55 {
return Err(CeremonyError::AuthData("truncated before the credential id"));
}
let cred_id_len = u16::from_be_bytes([auth_data[53], auth_data[54]]) as usize;
let id_start: usize = 55;
let id_end = id_start
.checked_add(cred_id_len)
.ok_or(CeremonyError::AuthData("credential id length overflows"))?;
if auth_data.len() < id_end {
return Err(CeremonyError::AuthData("truncates the credential id"));
}
let cred_id = &auth_data[id_start..id_end];
let cose: ciborium::value::Value = ciborium::from_reader(&auth_data[id_end..])
.map_err(|_| CeremonyError::Attestation("the COSE key is not CBOR"))?;
// COSE_Key for EC2: kty(1)=2, alg(3)=-7 (ES256), crv(-1)=1 (P-256), x(-2), y(-3).
let kty = cbor_int_key(&cose, 1).and_then(cbor_as_int);
let alg = cbor_int_key(&cose, 3).and_then(cbor_as_int);
let crv = cbor_int_key(&cose, -1).and_then(cbor_as_int);
if kty != Some(2) || alg != Some(-7) || crv != Some(1) {
return Err(CeremonyError::UnsupportedAlgorithm);
}
let x = cbor_int_key(&cose, -2).and_then(|v| v.as_bytes()).cloned();
let y = cbor_int_key(&cose, -3).and_then(|v| v.as_bytes()).cloned();
let (Some(x), Some(y)) = (x, y) else {
return Err(CeremonyError::Attestation("the COSE key has no x/y"));
};
if x.len() != 32 || y.len() != 32 {
return Err(CeremonyError::Attestation(
"the COSE key coordinates are not 32 bytes",
));
}
// Refuse a key the verifier could never load, at REGISTRATION rather than at
// the first login: a point that is not on the curve stored now is a credential
// that fails mysteriously later.
if !public_key_is_loadable(&x, &y) {
return Err(CeremonyError::Attestation(
"the COSE key is not a valid P-256 point",
));
}
Ok(RegisteredCredential {
id: B64.encode(cred_id),
x,
y,
sign_count: count,
})
}
// ─── Authentication ──────────────────────────────────────────────────────────
/// Verify an **authentication** (assertion) response against a stored credential.
///
/// `signature_der` is the ECDSA signature exactly as the authenticator produced
/// it: ASN.1 DER, which is what WebAuthn specifies for ES256 and what browsers
/// hand back untouched.
///
/// The signed message is `authenticatorData || SHA256(clientDataJSON)` — note that
/// the client data is hashed but the authenticator data is not, which is a place
/// hand-written verifiers get it wrong in a way that still passes a
/// happy-path test if both sides make the same mistake. The round-trip test
/// against a real recorded assertion is what keeps this honest.
///
/// # Errors
///
/// Ceremony type, challenge, origin, `rp_id` hash, user presence (and user
/// verification when demanded), signature, and counter rollback.
pub fn verify_assertion(
rp: &PureRelyingParty,
credential: &RegisteredCredential,
client_data: &[u8],
auth_data: &[u8],
signature_der: &[u8],
challenge_b64: &str,
require_user_verification: bool,
) -> Result<VerifiedAssertion, CeremonyError> {
check_client_data(client_data, "webauthn.get", challenge_b64, rp.rp_origin())?;
let (count, flags) = check_auth_data(auth_data, &rp.rp_id_hash, require_user_verification)?;
if !verify_es256(
&credential.x,
&credential.y,
auth_data,
client_data,
signature_der,
) {
return Err(CeremonyError::BadSignature);
}
// Clone detection (WebAuthn §7.2 step 21, A34-31). Many authenticators — most
// notably platform passkeys that sync — report a counter of 0 forever, so a
// credential STORED at 0 accepts any count. One that did count must present
// a strictly larger number: standing still, going back, or dropping to 0 is
// a cloned credential (`es256::counter_fits`, shared with `ceremony`).
if !crate::es256::counter_fits(credential.sign_count, count) {
return Err(CeremonyError::CounterRollback {
stored: credential.sign_count,
presented: count,
});
}
Ok(VerifiedAssertion {
sign_count: count,
user_verified: flags & FLAG_UV != 0,
})
}
// ─── Shared ceremony checks ──────────────────────────────────────────────────
/// Check `clientDataJSON`: the ceremony type, the challenge, and the origin.
///
/// The challenge comparison is an exact match against the base64url string this
/// server minted. Comparing the DECODED bytes would be equivalent only if the
/// encodings agreed, and they do not always: comparing the strings means a
/// response that re-encoded the challenge differently is refused rather than
/// silently accepted.
fn check_client_data(
client_data: &[u8],
want_type: &str,
want_challenge_b64: &str,
want_origin: &str,
) -> Result<(), CeremonyError> {
let value: serde_json::Value =
serde_json::from_slice(client_data).map_err(|_| CeremonyError::ClientData("not JSON"))?;
if value.get("type").and_then(|v| v.as_str()) != Some(want_type) {
return Err(CeremonyError::ClientData("wrong ceremony type"));
}
if value.get("challenge").and_then(|v| v.as_str()) != Some(want_challenge_b64) {
return Err(CeremonyError::Challenge);
}
if value.get("origin").and_then(|v| v.as_str()) != Some(want_origin) {
return Err(CeremonyError::Origin);
}
Ok(())
}
/// Check `authenticatorData` and return `(signature counter, flags)`.
///
/// Layout: `rpIdHash[32] | flags[1] | signCount[4] | …`.
fn check_auth_data(
auth_data: &[u8],
want_rp_id_hash: &[u8; 32],
require_user_verification: bool,
) -> Result<(u32, u8), CeremonyError> {
if auth_data.len() < 37 {
return Err(CeremonyError::AuthData("too short"));
}
// Constant time is not required here: the expected value is a hash of PUBLIC
// configuration, not a secret, so an early exit leaks nothing an attacker
// does not already have.
if auth_data[0..32] != want_rp_id_hash[..] {
return Err(CeremonyError::AuthData("wrong rpIdHash"));
}
let flags = auth_data[32];
if flags & FLAG_UP == 0 {
return Err(CeremonyError::MissingFlag("user-presence"));
}
if require_user_verification && flags & FLAG_UV == 0 {
return Err(CeremonyError::MissingFlag("user-verification"));
}
let count = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]);
Ok((count, flags))
}
/// Can this x/y pair be loaded as a P-256 public key at all?
fn public_key_is_loadable(x: &[u8], y: &[u8]) -> bool {
p256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1_uncompressed(x, y)).is_ok()
}
use crate::es256::sec1_uncompressed;
/// Verify an ES256 signature over `authData || SHA256(clientData)`.
fn verify_es256(x: &[u8], y: &[u8], auth_data: &[u8], client_data: &[u8], der: &[u8]) -> bool {
crate::es256::verify_es256_sec1(&sec1_uncompressed(x, y), auth_data, client_data, der)
}
// ─── CBOR helpers ────────────────────────────────────────────────────────────
fn cbor_text_key<'a>(
value: &'a ciborium::value::Value,
key: &str,
) -> Option<&'a ciborium::value::Value> {
value.as_map()?.iter().find_map(|(k, v)| match k {
ciborium::value::Value::Text(t) if t == key => Some(v),
_ => None,
})
}
fn cbor_int_key(value: &ciborium::value::Value, key: i128) -> Option<&ciborium::value::Value> {
value.as_map()?.iter().find_map(|(k, v)| match k {
ciborium::value::Value::Integer(i) if i128::from(*i) == key => Some(v),
_ => None,
})
}
fn cbor_as_int(value: &ciborium::value::Value) -> Option<i128> {
match value {
ciborium::value::Value::Integer(i) => Some(i128::from(*i)),
_ => None,
}
}
// ─── The session token the ceremony mints ────────────────────────────────────
/// Seconds since the Unix epoch.
pub fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default()
}
/// What a verified ceremony mints and a later request presents as a bearer.
///
/// It is **stateless** on purpose: the server holds one MAC key, not a table of
/// live sessions, so a request costs a hash rather than a lookup and a restart
/// does not log everybody out. The two things statelessness normally costs —
/// revocation and expiry — are bought back explicitly:
///
/// * `exp` bounds it in time;
/// * `epoch` is a counter the server bumps to void EVERY outstanding token at
/// once (a breach, or a decommissioned admin);
/// * the caller additionally refuses a token whose subject no longer holds an
/// enabled credential, which is the per-person revocation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionClaims {
/// The **opaque, enrolment-minted** identity. Never the friendly name: every
/// role map keys on this string, so it must be as stable and as un-friendly as
/// an OIDC `sub`.
pub sub: String,
/// The group grants this identity carries, so a passkey identity resolves
/// through the very same group ladder an OIDC one does.
#[serde(default)]
pub groups: Vec<String>,
/// The human name, for the audit line only — it authorizes nothing.
#[serde(default)]
pub display: Option<String>,
/// Expiry, seconds since the Unix epoch.
pub exp: i64,
/// When the ceremony that minted this happened — a step-up decision needs to
/// know how old the assertion is, not how old the token is.
pub auth_time: i64,
/// Whether that ceremony verified the USER, not merely their presence.
pub uv: bool,
/// The revocation counter this token was minted under.
pub epoch: u64,
}
/// The key a [`SessionClaims`] is MAC'd with. 32 bytes, persisted by the caller so
/// sessions outlive a restart.
#[derive(Clone)]
pub struct SessionKey([u8; 32]);
impl std::fmt::Debug for SessionKey {
/// Never renders the key.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SessionKey(<redacted>)")
}
}
impl SessionKey {
/// Adopt 32 bytes of key material.
pub fn new(bytes: [u8; 32]) -> Self {
Self(bytes)
}
/// The key's bytes, for the caller to persist. Named to be conspicuous at the
/// call site: this is the one value that must never be logged or shipped.
pub fn to_bytes(&self) -> [u8; 32] {
self.0
}
/// Mint a token: `base64url(claims JSON) "." base64url(MAC)`.
///
/// The MAC covers the ENCODED payload rather than the decoded claims, so
/// verification never has to parse attacker-controlled JSON before it has
/// established that the bytes are ours.
pub fn mint(&self, claims: &SessionClaims) -> String {
let payload = serde_json::to_vec(claims).expect("SessionClaims always serializes");
let payload_b64 = B64.encode(payload);
let mac = blake3::keyed_hash(&self.0, payload_b64.as_bytes());
format!("{payload_b64}.{}", B64.encode(mac.as_bytes()))
}
/// Verify a token's MAC and expiry and return its claims.
///
/// `None` for anything that is not a token of ours — a malformed string, a bad
/// MAC, an expired token, or one minted under a superseded `epoch`. The caller
/// treats `None` as "not mine" so the credential falls through to the next
/// provider, exactly as an opaque token handed to a JWT provider does.
///
/// The MAC comparison goes through `blake3::Hash`'s `PartialEq`, which is
/// constant time. A byte-slice comparison here would leak the MAC one position
/// at a time to anyone willing to time it.
pub fn verify(&self, token: &str, current_epoch: u64, now: i64) -> Option<SessionClaims> {
let (payload_b64, mac_b64) = token.split_once('.')?;
let presented: [u8; 32] = B64.decode(mac_b64).ok()?.try_into().ok()?;
let expected = blake3::keyed_hash(&self.0, payload_b64.as_bytes());
if expected != blake3::Hash::from(presented) {
return None;
}
let claims: SessionClaims = serde_json::from_slice(&B64.decode(payload_b64).ok()?).ok()?;
if claims.exp <= now || claims.epoch != current_epoch {
return None;
}
Some(claims)
}
}
// ─── The provider the stateless auth seam dispatches ─────────────────────────
/// The passkey [`AuthProvider`](nornir_service::provider::AuthProvider).
///
/// **It is a BEARER provider, and that is the whole trick.** The two-round-trip
/// ceremony cannot run inside `validate_request(config, bearer, client_cert_cn)`,
/// which sees one stateless credential per request — that is why a passkey method
/// was declared and left undispatched for a year. The resolution is that the
/// ceremony runs on the server's own door and mints a [`SessionClaims`] token;
/// what this provider verifies is that token. So the seam needed no new method,
/// no new signature, and no change to the four providers already in it.
///
/// A credential that is not one of our session tokens is `Ok(None)` — "not mine" —
/// so this provider can never shadow the OIDC or static-token provider sitting
/// beside it in the same config.
#[derive(Clone)]
pub struct PurePasskeyProvider {
key: SessionKey,
/// The revocation counter, shared with the server so a bump takes effect on
/// the next request rather than on the next restart.
epoch: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
impl std::fmt::Debug for PurePasskeyProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PurePasskeyProvider").finish_non_exhaustive()
}
}
impl PurePasskeyProvider {
/// Build a provider over the server's persisted session key and its shared
/// revocation epoch.
pub fn new(key: SessionKey, epoch: std::sync::Arc<std::sync::atomic::AtomicU64>) -> Self {
Self { key, epoch }
}
/// The epoch handle, so the server can bump it and void every live token.
pub fn epoch(&self) -> &std::sync::Arc<std::sync::atomic::AtomicU64> {
&self.epoch
}
}
#[tonic::async_trait]
impl nornir_service::provider::AuthProvider for PurePasskeyProvider {
fn name(&self) -> &str {
"passkey"
}
async fn verify_bearer(
&self,
token: &str,
) -> Result<Option<nornir_service::provider::Principal>, nornir_service::provider::ProviderError>
{
let epoch = self.epoch.load(std::sync::atomic::Ordering::Relaxed);
let Some(claims) = self.key.verify(token, epoch, now_secs()) else {
// Not one of ours (or expired / revoked). "Not mine" rather than an
// authoritative reject, so a static token or a JWT presented to the
// same server still reaches the provider that owns it.
return Ok(None);
};
let mut principal =
nornir_service::provider::Principal::new(claims.sub, "passkey");
principal.groups = claims.groups;
principal.display = claims.display;
Ok(Some(principal))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rp() -> PureRelyingParty {
PureRelyingParty::new("holger.lab.internal", "https://holger.lab.internal", "holger")
.expect("a well-formed relying party builds")
}
// ─── A soft authenticator, built here on purpose ─────────────────────────
//
// The obvious move is to pull `webauthn-authenticator-rs` as a dev-dependency,
// and it is the wrong one: it drags the very openssl stack this backend exists
// to avoid, so a `cargo test` would need the C toolchain that a `cargo build`
// does not. More importantly, a simulator from the SAME family as the verifier
// can agree with it about a mistake. This one is built from the spec text: it
// signs `authData || SHA256(clientDataJSON)` — the client data hashed, the
// authenticator data NOT — which is exactly the asymmetry a hand-written
// verifier gets wrong in a way that still passes a happy-path test when both
// sides make the same mistake.
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
/// A deterministic P-256 key, so a failure is reproducible rather than
/// occasionally interesting.
fn signing_key() -> SigningKey {
SigningKey::from_bytes(&[0x42u8; 32].into()).expect("a valid P-256 scalar")
}
fn public_xy(key: &SigningKey) -> (Vec<u8>, Vec<u8>) {
let point = key.verifying_key().to_sec1_point(false);
(point.x().unwrap().to_vec(), point.y().unwrap().to_vec())
}
fn client_data(ceremony: &str, challenge_b64: &str, origin: &str) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"type": ceremony,
"challenge": challenge_b64,
"origin": origin,
"crossOrigin": false,
}))
.unwrap()
}
/// `rpIdHash[32] | flags[1] | signCount[4] | [attestedCredentialData]`.
fn auth_data(rp_id: &str, flags: u8, count: u32, attested: Option<(&[u8], &SigningKey)>) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&Sha256::digest(rp_id.as_bytes()));
out.push(flags);
out.extend_from_slice(&count.to_be_bytes());
if let Some((cred_id, key)) = attested {
out.extend_from_slice(&[0u8; 16]); // aaguid
out.extend_from_slice(&(cred_id.len() as u16).to_be_bytes());
out.extend_from_slice(cred_id);
let (x, y) = public_xy(key);
// COSE_Key: kty(1)=2 EC2, alg(3)=-7 ES256, crv(-1)=1 P-256, x(-2), y(-3).
let cose = ciborium::value::Value::Map(vec![
(1.into(), 2.into()),
(3.into(), (-7).into()),
((-1).into(), 1.into()),
((-2).into(), ciborium::value::Value::Bytes(x)),
((-3).into(), ciborium::value::Value::Bytes(y)),
]);
let mut buf = Vec::new();
ciborium::into_writer(&cose, &mut buf).unwrap();
out.extend_from_slice(&buf);
}
out
}
fn attestation_object(auth_data: &[u8]) -> Vec<u8> {
// "none" attestation: the format an authenticator uses when it is not
// asserting a manufacturer chain, and the only one this backend reads.
let obj = ciborium::value::Value::Map(vec![
(
ciborium::value::Value::Text("fmt".into()),
ciborium::value::Value::Text("none".into()),
),
(
ciborium::value::Value::Text("attStmt".into()),
ciborium::value::Value::Map(vec![]),
),
(
ciborium::value::Value::Text("authData".into()),
ciborium::value::Value::Bytes(auth_data.to_vec()),
),
]);
let mut buf = Vec::new();
ciborium::into_writer(&obj, &mut buf).unwrap();
buf
}
/// Sign the assertion the way an authenticator does.
fn sign(key: &SigningKey, auth_data: &[u8], client_data: &[u8]) -> Vec<u8> {
let mut message = auth_data.to_vec();
message.extend_from_slice(&Sha256::digest(client_data));
let sig: Signature = key.sign(&message);
sig.to_der().as_bytes().to_vec()
}
const CHALLENGE: &str = "dGhpcy1pcy1hLXRlc3QtY2hhbGxlbmdlLTMy";
const CRED_ID: &[u8] = b"credential-id-0001";
fn register() -> RegisteredCredential {
let key = signing_key();
let ad = auth_data("holger.lab.internal", FLAG_UP | FLAG_UV | FLAG_AT, 1, Some((CRED_ID, &key)));
verify_registration(
&rp(),
&attestation_object(&ad),
&client_data("webauthn.create", CHALLENGE, "https://holger.lab.internal"),
CHALLENGE,
false,
)
.expect("a well-formed registration verifies")
}
// ─── The ceremony, both directions ───────────────────────────────────────
/// A full register → authenticate round trip: the credential comes out of the
/// attestation object with the right id and public key, and an assertion
/// signed by the matching private key verifies.
#[test]
fn a_passkey_registers_and_then_authenticates() {
let cred = register();
assert_eq!(cred.id, B64.encode(CRED_ID));
let (x, y) = public_xy(&signing_key());
assert_eq!(cred.x, x);
assert_eq!(cred.y, y);
assert_eq!(cred.sign_count, 1);
let key = signing_key();
let ad = auth_data("holger.lab.internal", FLAG_UP | FLAG_UV, 2, None);
let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
let ok = verify_assertion(&rp(), &cred, &cd, &ad, &sign(&key, &ad, &cd), CHALLENGE, false)
.expect("a genuine assertion verifies");
assert_eq!(ok.sign_count, 2);
assert!(ok.user_verified, "the UV flag must be reported for step-up");
}
/// A TAMPERED signature is refused. The single most important negative: if
/// this ever passes, every other check in the file is decoration.
#[test]
fn a_tampered_signature_is_refused() {
let cred = register();
let key = signing_key();
let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
let mut sig = sign(&key, &ad, &cd);
let last = sig.len() - 1;
sig[last] ^= 0x01;
assert_eq!(
verify_assertion(&rp(), &cred, &cd, &ad, &sig, CHALLENGE, false),
Err(CeremonyError::BadSignature)
);
}
/// An assertion signed by a DIFFERENT key does not authenticate as this
/// credential — the credential's stored public key is what decides, not the
/// credential id the browser echoed back.
#[test]
fn another_key_cannot_assert_this_credential() {
let cred = register();
let impostor = SigningKey::from_bytes(&[0x99u8; 32].into()).unwrap();
let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
assert_eq!(
verify_assertion(&rp(), &cred, &cd, &ad, &sign(&impostor, &ad, &cd), CHALLENGE, false),
Err(CeremonyError::BadSignature)
);
}
/// A REPLAYED assertion — a perfectly genuine one, presented against a
/// different ceremony's challenge — is refused. This is the whole point of
/// minting a fresh challenge per ceremony.
#[test]
fn a_replayed_assertion_is_refused() {
let cred = register();
let key = signing_key();
let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
let sig = sign(&key, &ad, &cd);
// Genuine against its own challenge...
assert!(verify_assertion(&rp(), &cred, &cd, &ad, &sig, CHALLENGE, false).is_ok());
// ...and refused against the next one.
assert_eq!(
verify_assertion(&rp(), &cred, &cd, &ad, &sig, "YS1kaWZmZXJlbnQtY2hhbGxlbmdl", false),
Err(CeremonyError::Challenge)
);
}
/// An assertion produced for ANOTHER relying party is refused twice over: the
/// origin in the client data and the rpIdHash in the authenticator data are
/// both wrong. Checked separately because a verifier that checked only one
/// would still pass a naive test.
#[test]
fn an_assertion_for_another_relying_party_is_refused() {
let cred = register();
let key = signing_key();
// Wrong ORIGIN, right rp_id.
let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
let evil_cd = client_data("webauthn.get", CHALLENGE, "https://evil.example");
assert_eq!(
verify_assertion(&rp(), &cred, &evil_cd, &ad, &sign(&key, &ad, &evil_cd), CHALLENGE, false),
Err(CeremonyError::Origin)
);
// Right origin, wrong RP ID HASH.
let evil_ad = auth_data("evil.example", FLAG_UP, 2, None);
let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
assert_eq!(
verify_assertion(&rp(), &cred, &cd, &evil_ad, &sign(&key, &evil_ad, &cd), CHALLENGE, false),
Err(CeremonyError::AuthData("wrong rpIdHash"))
);
}
/// A registration response cannot finish a login, and vice versa. The ceremony
/// type is signed over, so swapping them is not merely untidy — it is how a
/// credential-create is replayed as a credential-get.
#[test]
fn a_registration_cannot_finish_a_login() {
let cred = register();
let key = signing_key();
let ad = auth_data("holger.lab.internal", FLAG_UP, 2, None);
let create_cd = client_data("webauthn.create", CHALLENGE, "https://holger.lab.internal");
assert_eq!(
verify_assertion(&rp(), &cred, &create_cd, &ad, &sign(&key, &ad, &create_cd), CHALLENGE, false),
Err(CeremonyError::ClientData("wrong ceremony type"))
);
}
/// The user-presence flag is mandatory; user verification is demanded only
/// when the caller asks, which is how a step-up is expressed.
#[test]
fn presence_is_mandatory_and_verification_is_on_demand() {
let cred = register();
let key = signing_key();
let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
// No UP at all — refused however the caller feels about UV.
let no_up = auth_data("holger.lab.internal", 0x00, 2, None);
assert_eq!(
verify_assertion(&rp(), &cred, &cd, &no_up, &sign(&key, &no_up, &cd), CHALLENGE, false),
Err(CeremonyError::MissingFlag("user-presence"))
);
// UP but no UV: fine for a login, refused for a step-up.
let up_only = auth_data("holger.lab.internal", FLAG_UP, 2, None);
let sig = sign(&key, &up_only, &cd);
assert!(verify_assertion(&rp(), &cred, &cd, &up_only, &sig, CHALLENGE, false).is_ok());
assert_eq!(
verify_assertion(&rp(), &cred, &cd, &up_only, &sig, CHALLENGE, true),
Err(CeremonyError::MissingFlag("user-verification"))
);
}
/// A counter that goes backwards or stands still is a CLONED credential —
/// but only when the authenticator uses the counter at all. A synced platform
/// passkey reports 0 forever, and locking those out on their second use would
/// be a bug that looks exactly like security.
#[test]
fn a_rolled_back_counter_is_refused_but_a_zero_counter_is_not() {
let mut cred = register();
cred.sign_count = 10;
let key = signing_key();
let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
// A34-31: a counter that drops to 0 after a real one (stored 10) is a
// clone that stopped counting, not a synced passkey.
for replayed in [9u32, 10, 0] {
let ad = auth_data("holger.lab.internal", FLAG_UP, replayed, None);
assert_eq!(
verify_assertion(&rp(), &cred, &cd, &ad, &sign(&key, &ad, &cd), CHALLENGE, false),
Err(CeremonyError::CounterRollback { stored: 10, presented: replayed }),
"counter {replayed} after 10 must be refused"
);
}
let ad = auth_data("holger.lab.internal", FLAG_UP, 11, None);
assert!(verify_assertion(&rp(), &cred, &cd, &ad, &sign(&key, &ad, &cd), CHALLENGE, false).is_ok());
// The synced-passkey shape: both sides zero, accepted.
let mut synced = register();
synced.sign_count = 0;
let ad0 = auth_data("holger.lab.internal", FLAG_UP, 0, None);
assert!(
verify_assertion(&rp(), &synced, &cd, &ad0, &sign(&key, &ad0, &cd), CHALLENGE, false).is_ok(),
"an authenticator that does not use the counter must keep working"
);
}
/// A credential offering anything but ES256/P-256 is refused at REGISTRATION,
/// where the message can name the cause — not at the first login, where it
/// could not.
#[test]
fn a_non_es256_credential_is_refused_at_registration() {
let key = signing_key();
let mut ad = auth_data("holger.lab.internal", FLAG_UP | FLAG_AT, 1, Some((CRED_ID, &key)));
// Rewrite the COSE alg from -7 (ES256) to -257 (RS256) by rebuilding the
// key: simplest is to assert the guard fires on a doctored map.
let (x, y) = public_xy(&key);
let cose = ciborium::value::Value::Map(vec![
(1.into(), 3.into()), // kty = RSA
(3.into(), (-257).into()), // alg = RS256
((-1).into(), ciborium::value::Value::Bytes(x)),
((-2).into(), ciborium::value::Value::Bytes(y)),
]);
let mut buf = Vec::new();
ciborium::into_writer(&cose, &mut buf).unwrap();
ad.truncate(55 + CRED_ID.len());
ad.extend_from_slice(&buf);
assert_eq!(
verify_registration(
&rp(),
&attestation_object(&ad),
&client_data("webauthn.create", CHALLENGE, "https://holger.lab.internal"),
CHALLENGE,
false,
),
Err(CeremonyError::UnsupportedAlgorithm)
);
}
/// Truncated and non-CBOR input is refused rather than panicking. Every one of
/// these is attacker-supplied, so an index out of bounds here is a denial of
/// service on the login door.
#[test]
fn malformed_input_is_refused_and_never_panics() {
let cred = register();
let cd = client_data("webauthn.get", CHALLENGE, "https://holger.lab.internal");
assert!(verify_registration(&rp(), b"not cbor", &cd, CHALLENGE, false).is_err());
assert!(verify_registration(&rp(), &[], &cd, CHALLENGE, false).is_err());
for short in [0usize, 1, 20, 36] {
let ad = vec![0u8; short];
assert!(
verify_assertion(&rp(), &cred, &cd, &ad, &[0u8; 8], CHALLENGE, false).is_err(),
"authData of {short} bytes must be refused"
);
}
assert!(verify_assertion(&rp(), &cred, b"not json", &[0u8; 40], &[0u8; 8], CHALLENGE, false).is_err());
// An attestation whose credential-id length runs past the buffer.
let key = signing_key();
let mut ad = auth_data("holger.lab.internal", FLAG_UP | FLAG_AT, 1, Some((CRED_ID, &key)));
ad[53] = 0xFF;
ad[54] = 0xFF;
assert!(verify_registration(
&rp(),
&attestation_object(&ad),
&client_data("webauthn.create", CHALLENGE, "https://holger.lab.internal"),
CHALLENGE,
false,
)
.is_err());
}
// ─── rp_id: the airgap question ──────────────────────────────────────────
/// An IP-literal rp_id is refused, v4 and v6, bracketed or bare. A browser
/// rejects an address as an RP ID, so such a deployment could never enrol a
/// key — the failure must be at boot, not at the first registration.
#[test]
fn an_ip_literal_rp_id_is_refused() {
for bad in ["10.0.0.7", "127.0.0.1", "fd00::1", "[fd00::1]", "::1"] {
let err = validate_rp_id(bad).expect_err("an address is not a relying-party id");
assert!(
err.contains("DOMAIN NAME"),
"the refusal must say what to do instead, got: {err}"
);
}
assert!(PureRelyingParty::new("10.0.0.7", "https://10.0.0.7", "h").is_err());
}
/// A name is accepted, and so is the localhost development shape.
#[test]
fn a_name_is_accepted() {
assert!(validate_rp_id("holger.lab.internal").is_ok());
assert!(validate_rp_id("localhost").is_ok());
assert!(PureRelyingParty::new("localhost", "http://localhost:8090", "h").is_ok());
}
/// An rp_id carrying a scheme, a port or a path is a configuration mistake
/// with an obvious fix, so it is named rather than silently hashed.
#[test]
fn an_rp_id_that_is_really_a_url_is_refused() {
assert!(validate_rp_id("https://holger.lab.internal").is_err());
assert!(validate_rp_id("holger.lab.internal:8443").is_err());
assert!(validate_rp_id("").is_err());
}
/// The origin must belong to the rp_id: equal, or a subdomain of it. This is
/// the check that stops the credential's scope and the assertion's check from
/// disagreeing.
#[test]
fn an_origin_must_belong_to_the_rp_id() {
assert!(validate_origin_for_rp_id("https://holger.lab.internal", "lab.internal").is_ok());
assert!(
validate_origin_for_rp_id("https://holger.lab.internal", "holger.lab.internal").is_ok()
);
assert!(validate_origin_for_rp_id("https://holger.lab.internal:8443", "lab.internal").is_ok());
// A different registrable domain — the phishing case the boundary exists for.
assert!(validate_origin_for_rp_id("https://evil.example", "lab.internal").is_err());
// A SUFFIX that is not a subdomain: "evil-lab.internal" ends with
// "lab.internal" as a string but is a different domain entirely.
assert!(validate_origin_for_rp_id("https://evil-lab.internal", "lab.internal").is_err());
// Insecure context.
assert!(validate_origin_for_rp_id("http://holger.lab.internal", "lab.internal").is_err());
}
// ─── Session tokens ──────────────────────────────────────────────────────
fn claims() -> SessionClaims {
SessionClaims {
sub: "passkey:9c1f7e40-2b11-4a6e-8f77-000000000002".to_owned(),
groups: vec!["platform-admins".to_owned()],
display: Some("bo".to_owned()),
exp: now_secs() + 3600,
auth_time: now_secs(),
uv: true,
epoch: 0,
}
}
#[test]
fn a_minted_token_verifies_and_carries_its_claims() {
let key = SessionKey::new([7u8; 32]);
let token = key.mint(&claims());
let got = key.verify(&token, 0, now_secs()).expect("our own token verifies");
assert_eq!(got.sub, claims().sub);
assert_eq!(got.groups, vec!["platform-admins".to_owned()]);
assert!(got.uv);
}
/// A tampered payload is refused: the MAC covers the encoded payload, so
/// rewriting the subject to an admin's invalidates it.
#[test]
fn a_tampered_payload_is_refused() {
let key = SessionKey::new([7u8; 32]);
let token = key.mint(&claims());
let (_, mac) = token.split_once('.').unwrap();
let mut forged = claims();
forged.sub = "passkey:00000000-0000-0000-0000-00000000admin".to_owned();
let forged_payload = B64.encode(serde_json::to_vec(&forged).unwrap());
assert!(
key.verify(&format!("{forged_payload}.{mac}"), 0, now_secs()).is_none(),
"a re-subjected payload must not verify under the old MAC"
);
}
/// A token minted under another key is not ours — "not mine", so the
/// dispatcher goes on to the next provider rather than refusing outright.
#[test]
fn a_token_from_another_key_is_not_ours() {
let token = SessionKey::new([7u8; 32]).mint(&claims());
assert!(SessionKey::new([9u8; 32]).verify(&token, 0, now_secs()).is_none());
}
/// Expiry and the revocation epoch both void a token that is otherwise
/// perfectly well-formed and correctly MAC'd.
#[test]
fn expiry_and_the_epoch_both_void_a_token() {
let key = SessionKey::new([7u8; 32]);
let mut c = claims();
c.exp = now_secs() - 1;
assert!(key.verify(&key.mint(&c), 0, now_secs()).is_none(), "expired");
let token = key.mint(&claims());
assert!(key.verify(&token, 0, now_secs()).is_some(), "good under epoch 0");
assert!(
key.verify(&token, 1, now_secs()).is_none(),
"bumping the epoch must void every outstanding token at once"
);
}
#[test]
fn garbage_is_not_a_token() {
let key = SessionKey::new([7u8; 32]);
for bad in ["", ".", "no-dot", "a.b", "....."] {
assert!(key.verify(bad, 0, now_secs()).is_none(), "{bad:?}");
}
}
/// The key never renders, however it is formatted. A MAC key in a log line is
/// every session forged.
#[test]
fn the_session_key_never_renders() {
let key = SessionKey::new([0xAB; 32]);
let shown = format!("{key:?}");
assert!(shown.contains("redacted"));
assert!(!shown.contains("ab") && !shown.contains("171"));
}
}
/// Known-answer test for ES256 verification (2026-10-02, W3 crypto bump).
///
/// Authenticators do NOT normalise ECDSA `s`: a real fleet sends high-S and
/// low-S signatures alike. `testdata/es256-kat/` holds one assertion message,
/// its public key and the SAME signature in both forms (`s` and `n − s`), all
/// accepted by the PRE-bump stack (p256 0.13 / ecdsa 0.16). A later stack must
/// accept both — a verifier that started rejecting high-S would lock out every
/// passkey whose authenticator emits it. Regenerate ONLY on purpose:
/// `WEBAUTHN_ES256_KAT_REGEN=1 cargo test -p nornir-auth-webauthn es256_kat`.
#[cfg(test)]
mod es256_kat {
use super::*;
const AUTH_DATA: &[u8] = b"webauthn kat authData (rpIdHash||flags||signCount)";
const CLIENT_DATA: &[u8] = br#"{"type":"webauthn.get","challenge":"a2F0","origin":"https://kat.example"}"#;
#[test]
fn es256_kat_old_signatures_both_s_forms_verify() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/es256-kat");
if std::env::var_os("WEBAUTHN_ES256_KAT_REGEN").is_some() {
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
let sk = SigningKey::from_bytes(&[0x11u8; 32].into()).unwrap();
let point = sk.verifying_key().to_sec1_point(false);
let mut msg = AUTH_DATA.to_vec();
msg.extend_from_slice(&Sha256::digest(CLIENT_DATA));
let sig: Signature = sk.sign(&msg);
let (r, s) = sig.split_scalars();
let flipped = Signature::from_scalars(r.to_bytes(), (-*s).to_bytes()).unwrap();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("key.sec1"), point.as_bytes()).unwrap();
std::fs::write(dir.join("sig-a.der"), sig.to_der().as_bytes()).unwrap();
std::fs::write(dir.join("sig-b.der"), flipped.to_der().as_bytes()).unwrap();
}
let read = |n: &str| std::fs::read(dir.join(n)).unwrap_or_else(|e| panic!("fixture {n}: {e}"));
let key = read("key.sec1");
let (x, y) = (&key[1..33], &key[33..65]);
for s in ["sig-a.der", "sig-b.der"] {
assert!(verify_es256(x, y, AUTH_DATA, CLIENT_DATA, &read(s)), "{s} no longer verifies");
}
assert!(!verify_es256(x, y, b"tampered", CLIENT_DATA, &read("sig-a.der")));
}
}