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

nordisk/edda

crates/nornir-identity/src/test_host.rs

//! **`TestHost`**: an in-memory [`IdentityHost`], the mailbox and clock every
//! conformance host is handed, and a soft passkey. Dev-only (`test-support`).

use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{Arc, Mutex};

use nornir_auth_webauthn::ceremony;
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;

use crate::{
    Assertion, Ceremony, Challenge, Credential, Event, IdentityHost, Invite, Mail, Mailer,
    Registration, Revocations, Roles, Session, Site, StoreError, User, UserStore,
};

fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
    m.lock().unwrap_or_else(|p| p.into_inner())
}

/// **A clock a test moves.** Starts at a fixed instant.
#[derive(Debug)]
pub struct Clock(AtomicI64);

/// Where a [`Clock`] starts: 2026-10-27, Unix milliseconds.
pub const START_MS: i64 = 1_793_000_000_000;

impl Default for Clock {
    fn default() -> Self {
        Clock(AtomicI64::new(START_MS))
    }
}

impl Clock {
    pub fn now_ms(&self) -> i64 {
        self.0.load(Ordering::SeqCst)
    }
    /// Move on `secs` seconds.
    pub fn advance(&self, secs: i64) {
        self.0.fetch_add(secs * 1000, Ordering::SeqCst);
    }
}

/// **A mailbox**: every mail sent, or (switched) a refusal by name.
#[derive(Debug, Default)]
pub struct MailBox {
    sent: Mutex<Vec<Mail>>,
    refuse: AtomicBool,
}

/// What a refusing [`MailBox`] says.
pub const MAILBOX_REFUSES: &str = "the test mailbox refuses";

impl MailBox {
    /// Refuse from now on (the airgap shape).
    pub fn refuse(&self, refuse: bool) {
        self.refuse.store(refuse, Ordering::SeqCst);
    }
    /// Everything sent so far.
    pub fn sent(&self) -> Vec<Mail> {
        lock(&self.sent).clone()
    }
    /// The last mail sent to `to`.
    pub fn last_to(&self, to: &str) -> Option<Mail> {
        lock(&self.sent).iter().rev().find(|m| m.to == to).cloned()
    }
}

impl Mailer for MailBox {
    fn send(&self, mail: &Mail) -> Result<(), String> {
        if self.refuse.load(Ordering::SeqCst) {
            return Err(MAILBOX_REFUSES.to_owned());
        }
        lock(&self.sent).push(mail.clone());
        Ok(())
    }
}

impl Mailer for Arc<MailBox> {
    fn send(&self, mail: &Mail) -> Result<(), String> {
        (**self).send(mail)
    }
}

/// What a conformance host is handed: the mailbox it must mail through and the
/// clock it must tell time by.
#[derive(Debug, Default, Clone)]
pub struct Kit {
    pub mail: Arc<MailBox>,
    pub clock: Arc<Clock>,
}

#[derive(Debug, Default)]
struct Tables {
    users: Vec<User>,
    credentials: Vec<Credential>,
    sessions: Vec<Session>,
    invites: Vec<Invite>,
    revocations: Revocations,
}

/// **An in-memory [`UserStore`].** A test can make one kind of write fail
/// ([`MemStore::fail`]), to prove a flow leaves nothing half done.
#[derive(Debug, Default)]
pub struct MemStore(Mutex<Tables>, Mutex<Vec<&'static str>>);

/// What a failing [`MemStore`] write says.
pub const STORE_FAILS: &str = "the test store refuses this write";

impl MemStore {
    /// Make every `write` (`"put_user"`, `"put_credential"`, `"put_session"`,
    /// `"update_invites"`) fail from now on (`on`), or work again.
    pub fn fail(&self, write: &'static str, on: bool) {
        let mut f = lock(&self.1);
        f.retain(|w| *w != write);
        if on {
            f.push(write);
        }
    }
    fn check(&self, write: &'static str) -> Result<(), StoreError> {
        if lock(&self.1).contains(&write) {
            return Err(StoreError(STORE_FAILS.to_owned()));
        }
        Ok(())
    }
}

impl UserStore for MemStore {
    fn user(&self, id: &str) -> Result<Option<User>, StoreError> {
        Ok(lock(&self.0).users.iter().find(|u| u.id == id).cloned())
    }
    fn user_by_email(&self, email: &str) -> Result<Option<User>, StoreError> {
        Ok(lock(&self.0)
            .users
            .iter()
            .find(|u| u.email.as_deref() == Some(email))
            .cloned())
    }
    fn users(&self) -> Result<Vec<User>, StoreError> {
        Ok(lock(&self.0).users.clone())
    }
    fn put_user(&self, user: &User) -> Result<(), StoreError> {
        self.check("put_user")?;
        let mut t = lock(&self.0);
        t.users.retain(|u| u.id != user.id);
        t.users.push(user.clone());
        Ok(())
    }
    fn credential(&self, id: &[u8]) -> Result<Option<Credential>, StoreError> {
        Ok(lock(&self.0)
            .credentials
            .iter()
            .find(|c| c.id == id)
            .cloned())
    }
    fn credentials_of(&self, user: &str) -> Result<Vec<Credential>, StoreError> {
        Ok(lock(&self.0)
            .credentials
            .iter()
            .filter(|c| c.user == user)
            .cloned()
            .collect())
    }
    fn put_credential(&self, credential: &Credential) -> Result<(), StoreError> {
        self.check("put_credential")?;
        let mut t = lock(&self.0);
        t.credentials.retain(|c| c.id != credential.id);
        t.credentials.push(credential.clone());
        Ok(())
    }
    fn session(&self, id: &str) -> Result<Option<Session>, StoreError> {
        Ok(lock(&self.0).sessions.iter().find(|s| s.id == id).cloned())
    }
    fn put_session(&self, session: &Session) -> Result<(), StoreError> {
        self.check("put_session")?;
        let mut t = lock(&self.0);
        t.sessions.retain(|s| s.id != session.id);
        t.sessions.push(session.clone());
        Ok(())
    }
    fn delete_session(&self, id: &str) -> Result<(), StoreError> {
        lock(&self.0).sessions.retain(|s| s.id != id);
        Ok(())
    }
    fn invites(&self) -> Result<Vec<Invite>, StoreError> {
        Ok(lock(&self.0).invites.clone())
    }
    fn update_invites(&self, change: &mut dyn FnMut(&mut Vec<Invite>)) -> Result<(), StoreError> {
        self.check("update_invites")?;
        change(&mut lock(&self.0).invites);
        Ok(())
    }
    fn revocations(&self) -> Result<Revocations, StoreError> {
        Ok(lock(&self.0).revocations.clone())
    }
    fn update_revocations(
        &self,
        change: &mut dyn FnMut(&mut Revocations),
    ) -> Result<(), StoreError> {
        change(&mut lock(&self.0).revocations);
        Ok(())
    }
}

/// The group a [`TestHost`] makes an admin of.
pub const TEST_ADMINS: &str = "admins";

/// **The test host**: a [`MemStore`], the kit's mailbox and clock, and the
/// events it was told. `admins` is its admin group.
#[derive(Debug)]
pub struct TestHost {
    pub site: Site,
    pub store: MemStore,
    pub kit: Kit,
    pub events: Mutex<Vec<Event>>,
}

impl TestHost {
    pub fn new(site: Site, kit: &Kit) -> Self {
        TestHost {
            site,
            store: MemStore::default(),
            kit: kit.clone(),
            events: Mutex::new(Vec::new()),
        }
    }
    /// The events told so far.
    pub fn events(&self) -> Vec<Event> {
        lock(&self.events).clone()
    }
}

impl IdentityHost for TestHost {
    fn site(&self) -> Site {
        self.site
    }
    fn store(&self) -> &dyn UserStore {
        &self.store
    }
    fn roles(&self, user: &User) -> Roles {
        let admin = user.groups.iter().any(|g| g == TEST_ADMINS);
        Roles {
            admin,
            names: user.groups.clone(),
        }
    }
    fn audit(&self, event: Event) {
        lock(&self.events).push(event);
    }
    fn mailer(&self) -> &dyn Mailer {
        &self.kit.mail
    }
    fn now_ms(&self) -> i64 {
        self.kit.clock.now_ms()
    }
}

// ─── The soft passkey ───────────────────────────────────────────────────────

/// **A passkey in software**, P-256, with a counter that grows.
pub struct SoftPasskey {
    key: p256::ecdsa::SigningKey,
    /// Its credential id.
    pub id: Vec<u8>,
    count: u32,
}

const UP: u8 = 0x01;
const UV: u8 = 0x04;

impl SoftPasskey {
    /// A deterministic key; `seed` tells two apart.
    pub fn new(seed: u8) -> Self {
        let key =
            p256::ecdsa::SigningKey::from_slice(&[seed.max(1); 32]).expect("a non-zero scalar");
        SoftPasskey {
            key,
            id: format!("soft-passkey-{seed}").into_bytes(),
            count: 0,
        }
    }

    /// The public key, SEC1 uncompressed.
    pub fn public_key(&self) -> Vec<u8> {
        self.key
            .verifying_key()
            .to_sec1_point(false)
            .as_bytes()
            .to_vec()
    }

    fn client_data(kind: &str, challenge_b64: &str, origin: &str) -> Vec<u8> {
        format!(r#"{{"type":"{kind}","challenge":"{challenge_b64}","origin":"{origin}","crossOrigin":false}}"#).into_bytes()
    }

    /// Answer a creation ceremony at `origin`.
    pub fn register(&mut self, c: &Ceremony, origin: &str) -> Registration {
        Registration {
            ceremony_id: c.id.clone(),
            credential_id: self.id.clone(),
            public_key: self.public_key(),
            client_data_json: Self::client_data("webauthn.create", &c.challenge, origin),
            attestation_object: ceremony::attestation_none(
                &c.rp_id,
                &self.id,
                &self.public_key(),
                0,
            ),
            rp_id: c.rp_id.clone(),
            origin: origin.to_owned(),
        }
    }

    fn assert(
        &mut self,
        ceremony_id: &str,
        challenge: &str,
        rp_id: &str,
        origin: &str,
        uv: bool,
    ) -> Assertion {
        self.count += 1;
        let cdj = Self::client_data("webauthn.get", challenge, origin);
        let mut ad = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
        ad.push(if uv { UP | UV } else { UP });
        ad.extend_from_slice(&self.count.to_be_bytes());
        let mut message = ad.clone();
        message.extend_from_slice(&sha2::Sha256::digest(&cdj));
        let sig: p256::ecdsa::Signature = self.key.sign(&message);
        Assertion {
            ceremony_id: ceremony_id.to_owned(),
            credential_id: self.id.clone(),
            client_data_json: cdj,
            authenticator_data: ad,
            signature: sig.to_der().as_bytes().to_vec(),
            rp_id: rp_id.to_owned(),
            origin: origin.to_owned(),
        }
    }

    /// Answer a login ceremony at `origin`, user-verified or not.
    pub fn login(&mut self, c: &Ceremony, origin: &str, uv: bool) -> Assertion {
        self.assert(&c.id, &c.challenge, &c.rp_id, origin, uv)
    }

    /// Answer a step-up challenge.
    pub fn step_up(&mut self, c: &Challenge, rp_id: &str, origin: &str, uv: bool) -> Assertion {
        self.assert("", &c.challenge, rp_id, origin, uv)
    }
}