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

nordisk/edda

crates/nornir-auth-webauthn/src/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()
    }
}