crates/nornir-auth-webauthn/src/softkey.rs
//! A **soft authenticator** — a passkey in software, for tests only.
//!
//! Gated behind the `test-support` feature and never in a shipped closure, the
//! same way `nornir-auth-oidc` ships its throwaway-keypair JWT minter.
//!
//! It exists for two reasons, and the second is the important one.
//!
//! **It is written from the spec, not from the verifier.** The obvious move is to
//! pull `webauthn-authenticator-rs`, and it is the wrong one twice over: that
//! crate drags the very openssl stack the pure backend exists to avoid, so a
//! `cargo test` would need a C toolchain a `cargo build` does not — and a
//! simulator from the same family as the verifier can agree with it about a
//! mistake. This one 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 it.
//!
//! **It lets a CONSUMER prove the whole door.** holger's end-to-end test boots a
//! real server and drives the four ceremony routes with this, which is how you
//! find out that a login actually completes rather than that a function returns
//! `Ok`. Without it, every consumer writes its own simulator and each one gets to
//! be wrong differently.
use base64::Engine as _;
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
use sha2::{Digest, Sha256};
use crate::pure::B64;
/// A software authenticator holding one credential.
pub struct SoftKey {
key: SigningKey,
/// The credential id this authenticator reports.
pub credential_id: Vec<u8>,
/// The signature counter, incremented on every assertion the way a real
/// security key's is (a platform passkey that syncs would leave it at 0; the
/// verifier tolerates both and [`SoftKey::stuck_counter`] covers that shape).
counter: u32,
/// When true the counter never advances — the synced-passkey shape.
stuck: bool,
}
impl SoftKey {
/// A deterministic authenticator, so a failure is reproducible rather than
/// occasionally interesting. `seed` distinguishes two keys in one test.
pub fn new(seed: u8, credential_id: &[u8]) -> Self {
Self {
key: SigningKey::from_bytes(&[seed; 32].into())
.expect("every non-zero 32-byte seed is a valid P-256 scalar"),
credential_id: credential_id.to_vec(),
counter: 0,
stuck: false,
}
}
/// The synced-platform-passkey shape: a counter that never moves. The
/// verifier must keep accepting it, which is a real bug class — a rollback
/// check applied unconditionally locks out every synced passkey on its second
/// use, and looks exactly like security while doing it.
pub fn stuck_counter(mut self) -> Self {
self.stuck = true;
self
}
/// The credential id as the browser reports it.
pub fn credential_id_b64(&self) -> String {
B64.encode(&self.credential_id)
}
fn next_counter(&mut self) -> u32 {
if self.stuck {
return 0;
}
self.counter += 1;
self.counter
}
/// `clientDataJSON` for one ceremony leg.
pub fn client_data(&self, 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,
}))
.expect("the client data always serializes")
}
/// `authenticatorData`: `rpIdHash[32] | flags[1] | signCount[4]`, plus
/// attested credential data when registering.
pub fn authenticator_data(&self, rp_id: &str, flags: u8, count: u32, attested: bool) -> 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 attested {
out.extend_from_slice(&[0u8; 16]); // aaguid — "none" attestation
out.extend_from_slice(&(self.credential_id.len() as u16).to_be_bytes());
out.extend_from_slice(&self.credential_id);
out.extend_from_slice(&self.cose_key());
}
out
}
/// The COSE_Key for this credential's public key: EC2 / ES256 / P-256.
fn cose_key(&self) -> Vec<u8> {
let point = self.key.verifying_key().to_sec1_point(false);
let cose = ciborium::value::Value::Map(vec![
(1.into(), 2.into()), // kty = EC2
(3.into(), (-7).into()), // alg = ES256
((-1).into(), 1.into()), // crv = P-256
((-2).into(), ciborium::value::Value::Bytes(point.x().unwrap().to_vec())),
((-3).into(), ciborium::value::Value::Bytes(point.y().unwrap().to_vec())),
]);
let mut buf = Vec::new();
ciborium::into_writer(&cose, &mut buf).expect("the COSE key always encodes");
buf
}
/// A `none`-format attestation object wrapping `auth_data`.
pub fn attestation_object(&self, auth_data: &[u8]) -> Vec<u8> {
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).expect("the attestation object always encodes");
buf
}
/// Run a **registration** ceremony: what `navigator.credentials.create`
/// returns, base64url-encoded as the browser sends it.
///
/// Returns `(clientDataJSON, attestationObject)`.
pub fn register(&mut self, rp_id: &str, origin: &str, challenge_b64: &str, uv: bool) -> (String, String) {
let flags = 0x01 | 0x40 | if uv { 0x04 } else { 0 }; // UP | AT | UV?
let count = self.next_counter();
let auth_data = self.authenticator_data(rp_id, flags, count, true);
let client_data = self.client_data("webauthn.create", challenge_b64, origin);
(B64.encode(&client_data), B64.encode(self.attestation_object(&auth_data)))
}
/// Run an **authentication** ceremony: what `navigator.credentials.get`
/// returns, base64url-encoded.
///
/// Returns `(clientDataJSON, authenticatorData, signature)`.
pub fn authenticate(
&mut self,
rp_id: &str,
origin: &str,
challenge_b64: &str,
uv: bool,
) -> (String, String, String) {
let flags = 0x01 | if uv { 0x04 } else { 0 }; // UP | UV?
let count = self.next_counter();
let auth_data = self.authenticator_data(rp_id, flags, count, false);
let client_data = self.client_data("webauthn.get", challenge_b64, origin);
let signature = self.sign(&auth_data, &client_data);
(
B64.encode(&client_data),
B64.encode(&auth_data),
B64.encode(&signature),
)
}
/// Sign `authData || SHA256(clientData)` — the asymmetry that matters.
pub fn sign(&self, 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 = self.key.sign(&message);
sig.to_der().as_bytes().to_vec()
}
}