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)
}
}