server/lib/src/passkey.rs
//! **The passkey door** — Holger's HTTP face of edda's identity core (IDENTITY-HOST).
//!
//! Sign-up, login, the passkey ceremony, invites, sessions and step-up are ONE implementation,
//! `nornir_identity::Identity` over Holger's host ([`crate::identity_host`]). This module is
//! the door a browser and the CLI speak (`/-/auth/passkey/*` on the HTTP gateway): it reads
//! their JSON, asks the core, and answers in the shape the console reads. What stays here is
//! Holger's own and only that:
//!
//! * **the policy's words**: a role or a duty becomes the group the declarative `AuthConfig`
//! maps to it ([`group_for_role`], [`group_for_duty`], [`first_admin_groups`]), and the names
//! a machine acts under are no person's ([`is_reserved_name`]);
//! * **where a person comes from**: the shop's fed first-login invite (its SHA-256, taken in
//! once, [`PasskeyDoor::seed_first_admin`]), a paid sign-up's repository (`signup`), the
//! break-glass bearer that enrols the FIRST admin and then retires, the first-key door, the
//! resend sign-in link; each lands as an invite in the core's store and is redeemed by the
//! core;
//! * **the sold record**: a redeemed sign-up binds its repository to the person, the shop's
//! buyer owns what the order made.
//!
//! The ceremony, the session token (opaque, kept as a hash), the floors, the single-use rule and
//! the step-up window are the core's. The JSON shapes are the ones the console has always read
//! (`publicKey` options in, the browser's response out), so the page changed nothing.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use base64::Engine as _;
use nornir_identity::{
Assertion, ClientKey, Code, Event, IdentityHost as _, Invite, InviteAsk, Refused, Registration,
Relay, UserState,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::auth::{AuthConfig, AuthIdentity};
use crate::identity_host::{identity_of, SharedIdentity};
use crate::lock::LockOrRecover as _;
const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
/// A refusal: the HTTP status and the words.
pub type Refusal = (u16, String);
fn refuse(status: u16, msg: impl Into<String>) -> Refusal {
(status, msg.into())
}
/// Seconds since the epoch by the ESTATE's clock (`crate::clock`).
fn now_secs() -> i64 {
i64::try_from(crate::clock::now_unix_secs()).unwrap_or(i64::MAX)
}
fn str_field<'a>(v: &'a Value, key: &str) -> Option<&'a str> {
v.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
}
fn b64_field(v: &Value, key: &str) -> Result<Vec<u8>, Refusal> {
let raw = v.get(key).and_then(Value::as_str).unwrap_or_default();
B64.decode(raw)
.map_err(|_| refuse(400, format!("`{key}` is not base64url")))
}
fn groups_field(v: &Value) -> Vec<String> {
v.get("groups")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
/// How long a ceremony stays open, as the browser is told (the core's own bound decides).
const CEREMONY_TIMEOUT_MS: i64 = 300_000;
/// The step-up window and its machine word (edda `nornir-identity-session`).
pub use nornir_identity_session::step_up::{STEP_UP_CODE, STEP_UP_WINDOW_SECS};
/// The words of the refusal a session that is not stepped up earns; a console recognises it by
/// its [`STEP_UP_CODE`], never by these words.
pub const STEP_UP_REFUSAL: &str =
"log in again with your key first: this needs a user-verified session from the last 10 minutes";
/// **The body of a passkey door's refusal**: `{"error": words}`, and `"code": "stepup"` when the
/// refusal is [`STEP_UP_REFUSAL`].
pub fn refusal_json(words: &str) -> Value {
if words == STEP_UP_REFUSAL {
json!({ "error": words, "code": STEP_UP_CODE })
} else {
json!({ "error": words })
}
}
/// A core refusal in the door's words and status.
fn said(r: Refused) -> Refusal {
let status = match r.code {
Code::SignUpClosed | Code::InviteWouldBeAdmin | Code::NotAdmin => 403,
Code::InviteNotOpen | Code::NoSession => 401,
// A deactivated person's key logs in no more: the plain 401 a stranger's gets.
Code::NotActive => 401,
// A ceremony that is gone (spent, expired, never opened) is not there.
Code::Ceremony if r.detail.contains("was not opened here") => 404,
Code::Bootstrapped => return refuse(409, FIRST_KEY_ENROLLED),
Code::NeedsStepUp => return refuse(403, STEP_UP_REFUSAL),
Code::Busy => 429,
Code::MailRefused => 503,
Code::Store => 500,
_ => 400,
};
let words = match r.code {
Code::InviteNotOpen => "this invite is not valid any more; ask for a new one".to_owned(),
Code::InviteWouldBeAdmin => "an invite cannot grant admin: it is a link that travels out of band. The first admin is enrolled once; every other person is invited".to_owned(),
Code::NotAdmin => "this action requires an admin identity".to_owned(),
_ => r.detail,
};
(status, words)
}
// ─── The policy's words ──────────────────────────────────────────────────────
/// The role words a console shows for holger's roles.
pub(crate) fn role_word(role: crate::auth::Role) -> &'static str {
match role {
crate::auth::Role::Denied => "denied",
crate::auth::Role::Reader => "reader",
crate::auth::Role::Writer => "writer",
crate::auth::Role::Admin => "admin",
}
}
/// **The group an invite or an enrolment by ROLE puts a person in**: the group this server's
/// `group_roles` maps to exactly that role (the first by name). The console's words are
/// accepted beside holger's (`viewer` = Reader, `operator` = Writer).
pub fn group_for_role(auth: &AuthConfig, role: &str) -> Result<String, Refusal> {
let want = match role.trim().to_ascii_lowercase().as_str() {
"reader" | "viewer" => crate::auth::Role::Reader,
"writer" | "operator" => crate::auth::Role::Writer,
"admin" => crate::auth::Role::Admin,
other => {
return Err(refuse(
400,
format!("`{other}` is not a role (reader, writer or admin)"),
))
}
};
let mut groups: Vec<&String> = auth
.group_roles
.iter()
.filter(|(_, r)| **r == want)
.map(|(g, _)| g)
.collect();
groups.sort();
groups.first().map(|g| (*g).clone()).ok_or_else(|| {
refuse(
409,
format!(
"this server's `group_roles` names no group with the role {want:?}, so nobody can be given it by role; add one (for example \"holger-{}s\": {want:?}) to the auth block",
format!("{want:?}").to_ascii_lowercase()
),
)
})
}
/// **The group an invite by DUTY puts a person in** (lane W7): a group `group_duties` grants
/// that duty through, never an admin group, the one with the fewest duties first.
pub fn group_for_duty(auth: &AuthConfig, word: &str) -> Result<String, Refusal> {
let duty = crate::auth::duty_from_word(word)
.ok_or_else(|| refuse(400, format!("`{word}` is not a duty (importer, security_approver, auditor, developer or repository_admin)")))?;
let admin = |g: &str| {
auth.role_for_identity(&groups_identity(&[g.to_owned()]))
.can_admin()
};
let mut fits: Vec<(usize, &String)> = auth
.group_duties
.iter()
.filter(|(g, d)| {
d.contains(&duty) && (duty == crate::auth::Duty::RepositoryAdmin || !admin(g))
})
.map(|(g, d)| (d.len(), g))
.collect();
fits.sort();
fits.first().map(|(_, g)| (*g).clone()).ok_or_else(|| {
refuse(
409,
format!(
"this server's `group_duties` names no group with the duty {}, so nobody can be given it by an invite; add one (for example \"holger-{}s\": [{}]) to the auth block",
duty.name(),
duty.name().to_ascii_lowercase(),
duty.name()
),
)
})
}
fn groups_identity(groups: &[String]) -> AuthIdentity {
AuthIdentity {
subject: String::new(),
method: "passkey".to_owned(),
groups: groups.to_vec(),
display: None,
}
}
/// **The names a MACHINE holds here, which no person may take**: every static-token subject
/// the policy declares, the console's own names, `anonymous`, and anything shaped like a
/// subject.
pub fn is_reserved_name(auth: &AuthConfig, name: &str) -> bool {
let n = name.trim().to_ascii_lowercase();
if n.is_empty()
|| n.starts_with("passkey:")
|| (n.starts_with("u-") && n.len() == 18)
|| ["anonymous", "holger-ui", "admin-token"].contains(&n.as_str())
{
return true;
}
auth.methods.iter().any(|m| matches!(m, crate::auth::AuthMethodConfig::StaticToken { subject, .. } if subject.trim().eq_ignore_ascii_case(&n)))
|| auth.roles.keys().any(|k| k.trim().eq_ignore_ascii_case(&n))
}
fn refuse_reserved(auth: &AuthConfig, name: &str) -> Result<(), Refusal> {
if is_reserved_name(auth, name) {
return Err(refuse(400, format!("{name:?} is a name a machine of this server acts under (or one shaped like an identity), so no person may take it; choose another")));
}
Ok(())
}
/// **An estate owner's invitee is of the owner's estate** (lane P1): an invite minted by one of
/// the estate's owners puts its invitee in [`crate::auth::ESTATE_OWNERS`] too.
pub fn with_owners_of(body: &Value, inviter: Option<&AuthIdentity>) -> Value {
let owners = crate::auth::ESTATE_OWNERS;
if !inviter.is_some_and(|i| i.groups.iter().any(|g| g == owners)) {
return body.clone();
}
let mut b = body.clone();
let mut groups = groups_field(&b);
if !groups.iter().any(|g| g == owners) {
groups.push(owners.to_string());
}
b["groups"] = json!(groups);
b
}
/// **The groups the shop's buyer is enrolled in** (lane P1, W7's duties): the admin group, the
/// auditors' when that holds no Auditor, and the estate's owners; RepositoryAdmin,
/// SecurityApprover and Auditor, never Importer.
pub fn first_admin_groups(auth: &AuthConfig) -> Result<Vec<String>, Refusal> {
use crate::auth::Duty;
if !auth.duties_enabled() {
return Err(refuse(409, "this server grants no duties, so the shop's first admin cannot be given RepositoryAdmin, SecurityApprover and Auditor"));
}
let duties = |groups: &[String]| auth.duties_for_identity(&groups_identity(groups));
let mut groups = vec![group_for_role(auth, "admin")?];
if !duties(&groups).contains(&Duty::Auditor) {
groups.push(group_for_duty(auth, "auditor")?);
}
groups.push(crate::auth::ESTATE_OWNERS.to_string());
let held = duties(&groups);
let missing: Vec<&str> = [Duty::RepositoryAdmin, Duty::SecurityApprover, Duty::Auditor]
.into_iter()
.filter(|d| !held.contains(d))
.map(|d| d.name())
.collect();
if !missing.is_empty() {
return Err(refuse(
409,
format!(
"the shop's first admin would not hold {} under this server's duties ({})",
missing.join(", "),
groups.join(", ")
),
));
}
if held.contains(&Duty::Importer) {
return Err(refuse(409, format!("the shop's first admin would hold Importer under this server's duties ({}); an admin judges uploads and never makes them", groups.join(", "))));
}
Ok(groups)
}
// ─── Where a person comes from ───────────────────────────────────────────────
/// The shop's fed first-login invite, as the installer fed it (lane P1): the SHA-256 of the
/// secret the buyer's mail carries, how long it lives and the name it is pinned to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirstAdminInvite {
pub sha256_hex: String,
pub ttl_secs: i64,
pub display: String,
}
/// The one answer to a sign-up that cannot be redeemed.
pub const SIGNUP_REFUSED: &str =
"this sign-up cannot be redeemed here (it is already someone's, or it was not made here)";
/// Who a fed first-login invite was "minted" by.
pub const FIRST_ADMIN_BY: &str = "the shop's first-login invite";
/// Who a first-key invite was minted by (`holger.doors.v1.FirstKeyService`).
pub const FIRST_KEY_BY: &str = "the first-key door";
/// The file a plain start writes its first sign-in link into, under the data root (lane
/// HOL-FIELD-TEST P3): `<rp_origin>/login?invite=<secret>`, 0600.
pub const FIRST_SIGN_IN_FILE: &str = "holger-passkeys/first-sign-in.url";
/// The one answer once the first admin key exists.
pub const FIRST_KEY_ENROLLED: &str = "this server already has an admin key: the first-key door is shut; an admin enrols further keys with their own session";
/// Who a resend sign-in link was minted by (A16, monetize's signed order).
pub const SIGN_IN_AGAIN_BY: &str = "monetize:sign_in_again";
/// Who a paid sign-up's invite is minted by: `signup:<repository>`.
const SIGNUP_BY: &str = "signup:";
/// The longest a fed first-login invite may live.
const FIRST_ADMIN_TTL_MAX: i64 = 30 * 24 * 3600;
/// The record that the fed invite was taken in, once ([`crate::auth::FIRST_ADMIN_MARKER_REL`]).
const FIRST_ADMIN_FILE: &str = "first-admin.json";
/// What [`FIRST_ADMIN_FILE`] says.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct FirstAdminSeeded {
sha256: String,
at: i64,
/// `false`: the box already had an admin, so the fed invite was not kept.
seeded: bool,
#[serde(default)]
display: String,
#[serde(default)]
ttl_secs: i64,
/// The buyer, once registered.
#[serde(default, skip_serializing_if = "Option::is_none")]
used_by: Option<String>,
}
fn sha256_hex(secret: &str) -> String {
use sha2::Digest as _;
hex::encode(sha2::Sha256::digest(secret.trim().as_bytes()))
}
/// What one open enrolment is for, by its ceremony (the core holds the ceremony; this is the
/// door's own note of where the person comes from).
#[derive(Debug, Clone, Default)]
struct Note {
/// The repository a paid sign-up binds to the person.
bind: Option<String>,
/// The shop's buyer (a fed first-login invite or a resend link).
buyer: bool,
/// An admin enrolled a key for somebody else: no session for the admin's browser.
for_another: bool,
/// The invite the ceremony redeems (its hash), for the first-key rule at the finish.
invite: Option<String>,
label: String,
/// When the ceremony opened (Unix seconds): a note lives as long as its ceremony may be
/// answered, and no longer.
opened: i64,
}
/// Open notes kept at most. Past it a new enrolment is refused by name (RA5-15); an open one
/// is never dropped to make room.
const MAX_NOTES: usize = 1024;
/// Refused when [`MAX_NOTES`] enrolments are open at once.
pub const ENROLMENTS_FULL: &str =
"too many enrolments are open on this server right now; try again in a few minutes";
/// Refused when a finish names a ceremony whose note this door does not hold.
pub const ENROLMENT_UNKNOWN: &str = "this enrolment is not open here any more (it expired, or the server restarted): start it again";
/// **The passkey door of one box**: its identity core, its data root, its relying party.
pub struct PasskeyDoor {
core: SharedIdentity,
data_root: PathBuf,
rp_id: String,
/// The origins a ceremony may come from (WWW-CANON): the site's canonical one first
/// (`https://www.<rp_id>`), then the kept `rp_origin` ([`crate::identity_host::origins_of`]).
origins: Vec<String>,
rp_name: String,
bootstrap_until_first_admin: bool,
notes: Mutex<HashMap<String, Note>>,
marker_lock: Mutex<()>,
gate: OnceLock<Arc<crate::auth::ReadGate>>,
}
/// The door, shared by the HTTP gateway, the gRPC doors and the auth seam.
pub type SharedPasskeyDoor = Arc<PasskeyDoor>;
/// The notes whose ceremony can no longer be answered, dropped.
fn prune_notes(notes: &mut HashMap<String, Note>, now: i64) {
notes.retain(|_, n| now - n.opened <= CEREMONY_TIMEOUT_MS / 1000 + 60);
}
impl std::fmt::Debug for PasskeyDoor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PasskeyDoor")
.field("rp_id", &self.rp_id)
.field("data_root", &self.data_root)
.finish()
}
}
fn live_doors() -> &'static Mutex<Vec<(String, SharedPasskeyDoor)>> {
static LIVE: OnceLock<Mutex<Vec<(String, SharedPasskeyDoor)>>> = OnceLock::new();
LIVE.get_or_init(Default::default)
}
fn live_key(auth: &AuthConfig) -> Option<String> {
auth.methods.iter().find_map(|m| match m {
crate::auth::AuthMethodConfig::Passkey {
session_key_file: Some(f),
..
} if !f.trim().is_empty() => Some(f.trim().to_owned()),
crate::auth::AuthMethodConfig::Passkey { rp_id, .. } => Some(rp_id.trim().to_owned()),
_ => None,
})
}
/// Make `door` the one [`door_for`] answers under `auth`'s passkey method, and its core the
/// one every door's auth seam asks (`identity_host::session_identity`).
pub fn register_live(auth: &AuthConfig, door: &SharedPasskeyDoor) {
crate::identity_host::register_live(auth, &door.core);
let Some(key) = live_key(auth) else { return };
let mut live = live_doors().lock_or_recover();
live.retain(|(k, _)| k != &key);
live.push((key, Arc::clone(door)));
}
/// The door this process serves under `auth`'s passkey method, if any.
pub fn door_for(auth: &AuthConfig) -> Option<SharedPasskeyDoor> {
let key = live_key(auth)?;
live_doors()
.lock_or_recover()
.iter()
.find(|(k, _)| *k == key)
.map(|(_, d)| Arc::clone(d))
}
impl PasskeyDoor {
/// **The door of the box at `data_root`** under `auth` (its passkey method resolved), its
/// events on `audit`. `None`: no passkey method, no door.
pub fn open(
data_root: &Path,
auth: &AuthConfig,
audit: Arc<dyn crate::audit::AuditLog>,
) -> Result<Option<SharedPasskeyDoor>, String> {
Self::open_at(data_root, auth, audit, None)
}
/// [`Self::open`] at the site the ONE box fed (`identity_host::open_at`).
pub fn open_at(
data_root: &Path,
auth: &AuthConfig,
audit: Arc<dyn crate::audit::AuditLog>,
site: Option<nornir_identity::Site>,
) -> Result<Option<SharedPasskeyDoor>, String> {
let Some((rp_id, rp_origin, rp_name, bootstrap)) =
auth.methods.iter().find_map(|m| match m {
crate::auth::AuthMethodConfig::Passkey {
rp_id,
rp_origin,
rp_name,
bootstrap_until_first_admin,
..
} => Some((
rp_id.trim().to_owned(),
rp_origin.trim().trim_end_matches('/').to_owned(),
rp_name.trim().to_owned(),
*bootstrap_until_first_admin,
)),
_ => None,
})
else {
return Ok(None);
};
let Some(core) = crate::identity_host::open_at(data_root, auth, audit, site)? else {
return Ok(None);
};
Ok(Some(Arc::new(PasskeyDoor {
core,
data_root: data_root.to_path_buf(),
rp_name: if rp_name.is_empty() {
"holger".to_owned()
} else {
rp_name
},
origins: crate::identity_host::origins_of(&rp_id, &rp_origin),
rp_id,
bootstrap_until_first_admin: bootstrap,
notes: Mutex::new(HashMap::new()),
marker_lock: Mutex::new(()),
gate: OnceLock::new(),
})))
}
/// The core this door asks.
pub fn core(&self) -> &SharedIdentity {
&self.core
}
/// The directory the people live in (`<data_root>/holger-passkeys`), which the twin follows.
pub fn root(&self) -> PathBuf {
self.data_root.join(crate::identity_host::STORE_DIR)
}
/// The relying party's id.
pub fn rp_id(&self) -> &str {
&self.rp_id
}
/// **The read gate every door holds** (lane P1): attached once, at the start.
pub fn attach_gate(&self, gate: Arc<crate::auth::ReadGate>) {
let _ = self.gate.set(gate);
}
fn relay(&self, client: &str) -> Relay {
let client = client.trim();
Relay {
rp_id: self.rp_id.clone(),
origin: self.origin().to_owned(),
client: (!client.is_empty()).then(|| ClientKey::of_peer(client)),
}
}
/// **The site's origin** (WWW-CANON): `https://www.<rp_id>` for a site named by its bare
/// name, else the kept `rp_origin`.
pub fn origin(&self) -> &str {
&self.origins[0]
}
/// **The origin a ceremony's answer is checked against**: the one the browser signed
/// (`clientDataJSON.origin`) when it is one of this door's ([`Self::origins`]), else the
/// site's own, so a foreign origin is refused by the core's check, by name.
fn origin_of(&self, client_data_json: &[u8]) -> String {
nornir_auth_webauthn::ceremony::client_data(client_data_json)
.ok()
.map(|d| d.origin)
.filter(|o| self.origins.iter().any(|ours| ours == o))
.unwrap_or_else(|| self.origin().to_owned())
}
/// The origins a ceremony may come from: the site's first.
pub fn origins(&self) -> &[String] {
&self.origins
}
/// The door's public facts (`GET /-/auth/passkey/config`).
pub fn config_json(&self) -> Value {
json!({ "enabled": true, "rp_id": self.rp_id, "rp_origin": self.origin(), "rp_origins": self.origins, "rp_name": self.rp_name })
}
/// **Does an active admin exist?** (the break-glass retirement and the first-key rule).
/// **The first sign-in link of a plain start** (lane HOL-FIELD-TEST P3, Gunnar's first-login
/// path): while no admin holds a passkey, a single-use first-key invite whose link is written
/// to [`FIRST_SIGN_IN_FILE`] (0600, the box's user alone). The passkey it enrols is the admin:
/// no bearer, no attestation step. An open first-key invite with its file kept is left as it
/// is, so a restart never revokes a link already handed out. A box whose RON names an Admin
/// subject gets none: that admin mints the invite. `Some(file)` when one was written.
pub fn offer_first_sign_in(&self, auth: &AuthConfig) -> Result<Option<PathBuf>, Refusal> {
// An admin that is not a person (a bootstrap bearer, an mTLS subject) mints the first-key
// invite itself (`holger-cli first-key`); and a box with an admin passkey has its admin.
if self.has_admin_passkey(auth) {
// A link a crash left behind after its enrolment: single use on disk too.
self.forget_first_sign_in();
return Ok(None);
}
if auth.roles.values().any(|r| *r == crate::auth::Role::Admin) {
return Ok(None);
}
let Some(origin) = auth.methods.iter().find_map(|m| match m {
crate::auth::AuthMethodConfig::Passkey { rp_origin, .. } => {
Some(rp_origin.trim().trim_end_matches('/').to_owned())
}
_ => None,
}) else {
return Ok(None);
};
let file = self.data_root.join(FIRST_SIGN_IN_FILE);
let now = now_secs();
let mut open = false;
self.core
.host()
.store()
.update_invites(&mut |all| {
open = all
.iter()
.any(|i| i.created_by.starts_with(FIRST_KEY_BY) && i.open(now));
})
.map_err(|e| refuse(500, e.0))?;
if open && file.exists() {
return Ok(None);
}
let (secret, _) = self.mint_first_key_invite("", "holger start", auth)?;
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir).map_err(|e| refuse(500, e.to_string()))?;
}
crate::keyfile::write_private(
&file,
format!("{origin}/login?invite={secret}\n").as_bytes(),
)
.map_err(|e| refuse(500, format!("{}: {e}", file.display())))?;
Ok(Some(file))
}
/// **The first sign-in link is single use on disk too** (HOL-DOCFIND f): once a first-key
/// invite enrolled its passkey (or the box has its admin), [`FIRST_SIGN_IN_FILE`] goes, so
/// no copy of the data root (a backup, a disk image) holds a credential-shaped file. A file
/// that cannot be removed is emptied.
fn forget_first_sign_in(&self) {
let file = self.data_root.join(FIRST_SIGN_IN_FILE);
match std::fs::remove_file(&file) {
Ok(()) => log::warn!(
"passkey: the first sign-in link was used; {} is removed",
file.display()
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
let emptied = std::fs::write(&file, b"").is_ok();
log::error!(
"passkey: the used first sign-in link {} could not be removed ({e}); {}",
file.display(),
if emptied {
"it was emptied"
} else {
"it could not be emptied either"
}
);
}
}
}
pub fn has_admin_passkey(&self, _auth: &AuthConfig) -> bool {
let host = self.core.host();
host.store()
.users()
.unwrap_or_default()
.iter()
.any(|u| u.state == UserState::Active && host.roles(u).admin)
}
/// The person `subject` as this server knows them now; `None` when not active.
pub fn person(&self, subject: &str) -> Option<AuthIdentity> {
let user = self.core.host().store().user(subject).ok()??;
(user.state == UserState::Active).then(|| identity_of(&user))
}
/// **The identity a core session acts as now**, or `None`.
pub fn live_identity(&self, token: &str) -> Option<AuthIdentity> {
let view = self.core.session(token).ok()?;
self.person(&view.user_id)
}
// ── Login ────────────────────────────────────────────────────────────────
/// `POST /-/auth/passkey/login/start` (`{"uv": "required"}` asks a user-verified touch).
pub fn open_login(&self, body: &Value, client: &str) -> Result<Value, Refusal> {
let c = self.core.login_start(&self.relay(client)).map_err(said)?;
let uv = if str_field(body, "uv") == Some("required") || c.user_verification_required {
"required"
} else {
"preferred"
};
Ok(json!({
"ceremony_id": c.id,
"publicKey": { "challenge": c.challenge, "rpId": c.rp_id, "timeout": CEREMONY_TIMEOUT_MS, "userVerification": uv, "allowCredentials": [] },
}))
}
fn assertion(&self, body: &Value) -> Result<Assertion, Refusal> {
let id = str_field(body, "id").ok_or_else(|| refuse(400, "`id` is required"))?;
let response = body
.get("response")
.ok_or_else(|| refuse(400, "`response` is required"))?;
let client_data_json = b64_field(response, "clientDataJSON")?;
Ok(Assertion {
ceremony_id: str_field(body, "ceremony_id")
.unwrap_or_default()
.to_owned(),
credential_id: B64
.decode(id)
.map_err(|_| refuse(400, "`id` is not base64url"))?,
origin: self.origin_of(&client_data_json),
client_data_json,
authenticator_data: b64_field(response, "authenticatorData")?,
signature: b64_field(response, "signature")?,
rp_id: self.rp_id.clone(),
})
}
/// `POST /-/auth/passkey/login/finish`: the core's session.
pub fn close_login(&self, body: &Value) -> Result<Value, Refusal> {
str_field(body, "ceremony_id").ok_or_else(|| refuse(400, "`ceremony_id` is required"))?;
let done = self
.core
.login_finish(self.assertion(body)?)
.map_err(said)?;
log::info!("passkey: {} logged in", done.session.display);
Ok(json!({
"token": done.token,
"subject": done.session.user_id,
"expires_at": done.session.expires_at,
"display": done.session.display,
// Rendering only, in holger's role names (the server decides on every request).
"role": role_name(done.session.roles.first().map(String::as_str).unwrap_or_default()),
"stepped_up": done.session.stepped_up,
}))
}
// ── Step-up ──────────────────────────────────────────────────────────────
/// `POST /-/auth/passkey/step-up/start`: a fresh challenge for the session's own passkeys.
pub fn open_step_up(
&self,
identity: Option<&AuthIdentity>,
bearer: Option<&str>,
client: &str,
) -> Result<Value, Refusal> {
let (token, person) = step_up_person(identity, bearer)?;
let c = self
.core
.step_up_start(token, Some(&ClientKey::of_peer(client)))
.map_err(said)?;
let allow: Vec<Value> = self
.core
.host()
.store()
.credentials_of(person)
.unwrap_or_default()
.iter()
.map(|c| json!({ "type": "public-key", "id": B64.encode(&c.id) }))
.collect();
Ok(json!({
"ceremony_id": "step-up",
"publicKey": { "challenge": c.challenge, "rpId": self.rp_id, "timeout": CEREMONY_TIMEOUT_MS, "userVerification": "required", "allowCredentials": allow },
}))
}
/// `POST /-/auth/passkey/step-up/finish`: the session is stepped up in place.
pub fn close_step_up(
&self,
body: &Value,
identity: Option<&AuthIdentity>,
bearer: Option<&str>,
) -> Result<Value, Refusal> {
let (token, _) = step_up_person(identity, bearer)?;
let mut a = self.assertion(body)?;
a.ceremony_id = String::new();
// A touch that is not the session's own key is a refusal of the person, not of the wire.
let view = self.core.step_up_finish(token, a).map_err(|r| {
if r.code == Code::Ceremony {
(403, r.detail)
} else {
said(r)
}
})?;
Ok(
json!({ "signed_in": true, "subject": view.user_id, "display": view.display, "role": view.roles.first().cloned().unwrap_or_default(), "stepped_up": view.stepped_up, "expires_at": view.expires_at }),
)
}
/// Is `bearer` a PERSON's core session that is stepped up? A machine credential is not a
/// person, so it never is (the stricter cousin of [`PasskeyDoor::caller_stepped_up`]).
pub fn person_stepped_up(&self, bearer: Option<&str>) -> bool {
bearer
.filter(|b| crate::identity_host::is_session_token(b))
.and_then(|b| self.core.session(b).ok())
.is_some_and(|v| v.stepped_up)
}
/// Is this caller stepped up? A core session must be; a machine credential is exempt.
pub fn caller_stepped_up(&self, identity: &AuthIdentity, bearer: Option<&str>) -> bool {
if !bearer.is_some_and(crate::identity_host::is_session_token) {
return identity.method != "passkey";
}
bearer
.and_then(|b| self.core.session(b).ok())
.is_some_and(|v| v.stepped_up)
}
// ── Enrolment ────────────────────────────────────────────────────────────
/// `POST /-/auth/passkey/register/start`: an invite (the core's, a first-key one, or the
/// shop's fed one), a paid sign-up, or an admin's word (the break-glass bearer for the FIRST
/// admin; an admin's session for everybody else, by invite) — redeemed by the core.
pub fn open_enrolment(
&self,
body: &Value,
identity: Option<&AuthIdentity>,
bearer: Option<&str>,
auth: &AuthConfig,
client: &str,
) -> Result<Value, Refusal> {
let label = str_field(body, "label").unwrap_or("passkey").to_owned();
let display = str_field(body, "display").unwrap_or_default().to_owned();
if !display.is_empty() {
refuse_reserved(auth, &display)?;
}
let mut note = Note {
label,
opened: now_secs(),
..Note::default()
};
// The room is checked before anything is minted: a full door mints nothing.
{
let mut notes = self.notes.lock_or_recover();
prune_notes(&mut notes, now_secs());
if notes.len() >= MAX_NOTES {
return Err(refuse(503, ENROLMENTS_FULL));
}
}
let secret = match (str_field(body, "invite"), str_field(body, "signup")) {
(Some(secret), _) => {
self.take_in_fed(secret, auth)?;
let invite = self.open_invite(secret).ok_or_else(|| {
refuse(401, "this invite is not valid any more; ask for a new one")
})?;
// THE INVITEE'S CHOICE OF NAME is theirs to choose, never to take.
if invite.display.is_empty() && !display.is_empty() && self.name_taken(&display) {
return Err(refuse(409, format!("{display:?} is already somebody's name here, so an invite cannot be redeemed under it; choose another, or ask for an invite pinned to it if it is yours")));
}
if invite.created_by.starts_with(FIRST_KEY_BY) && self.has_admin_passkey(auth) {
self.withdraw_invite(&invite.id).ok();
return Err(refuse(409, FIRST_KEY_ENROLLED));
}
note.buyer =
invite.created_by == FIRST_ADMIN_BY || invite.created_by == SIGN_IN_AGAIN_BY;
note.invite = Some(invite.id);
secret.to_owned()
}
(None, Some(signup)) => {
let repo = self
.unredeemed_signup(signup)
.ok_or_else(|| refuse(401, SIGNUP_REFUSED))?;
if display.is_empty() {
return Err(refuse(
400,
"a sign-up lets you choose your name: `display` is required",
));
}
if self.name_taken(&display) {
return Err(refuse(
409,
format!("{display:?} is already somebody's name here; choose another"),
));
}
// No group: what this person has here is the repository the sign-up paid for.
let (secret, invite) = self.seed(
&display,
Vec::new(),
&format!("{SIGNUP_BY}{repo}"),
nornir_identity_session::invites::INVITE_TTL_SECS,
None,
)?;
note.bind = Some(repo);
note.invite = Some(invite.id);
secret
}
(None, None) => {
let id = identity
.ok_or_else(|| refuse(401, "enrolling a passkey requires authority"))?;
if !auth.role_for_identity(id).can_admin() {
return Err(refuse(
403,
"enrolling a passkey requires an admin identity",
));
}
if display.is_empty() {
return Err(refuse(400, "`display` is required"));
}
let mut groups = groups_field(body);
if let Some(role) = str_field(body, "role") {
groups.push(group_for_role(auth, role)?);
}
if let Some(duty) = str_field(body, "duty") {
groups.push(group_for_duty(auth, duty)?);
}
let session = bearer.filter(|b| crate::identity_host::is_session_token(b));
let secret = match session {
// An admin's session: an invite of the core's (never an admin), redeemed here.
Some(token) => {
let ask = InviteAsk {
display: display.clone(),
email: None,
groups,
ttl_secs: None,
person: None,
};
self.core.invite(token, ask).map_err(said)?.secret
}
// A machine credential (the break-glass bearer, an mTLS cert): the FIRST admin
// only, and it retires the moment one exists.
None => {
if self.has_admin_passkey(auth)
&& (self.bootstrap_until_first_admin || id.method != "passkey")
{
return Err(refuse(403, "this server already has an admin passkey, so the bootstrap credential no longer enrols passkeys — sign in with an admin passkey, or use an invite"));
}
let invited = self
.core
.first_admin_invite(groups, &id.audit_name())
.map_err(said)?;
self.pin_display(&invited.invite_id, &display)?;
invited.secret
}
};
note.for_another = session.is_some();
note.invite = Some(nornir_identity_session::invites::hash_secret(&secret));
secret
}
};
let c = self
.core
.redeem_start(&secret, &display, &self.relay(client))
.map_err(said)?;
{
let mut notes = self.notes.lock_or_recover();
prune_notes(&mut notes, now_secs());
if notes.len() >= MAX_NOTES {
return Err(refuse(503, ENROLMENTS_FULL));
}
notes.insert(c.id.clone(), note);
}
Ok(json!({
"ceremony_id": c.id,
"publicKey": {
"challenge": c.challenge,
"rp": { "id": c.rp_id, "name": self.rp_name },
"user": { "id": B64.encode(c.user_id.as_bytes()), "name": c.user_name, "displayName": c.display },
"pubKeyCredParams": [ { "type": "public-key", "alg": -7 } ],
"timeout": CEREMONY_TIMEOUT_MS,
"authenticatorSelection": { "userVerification": "preferred", "residentKey": "preferred" },
"attestation": "none",
"excludeCredentials": [],
}
}))
}
/// `POST /-/auth/passkey/register/finish`: the core redeems; a sign-up's repository is bound,
/// the shop's buyer owns the order's repositories.
pub fn close_enrolment(&self, body: &Value, auth: &AuthConfig) -> Result<Value, Refusal> {
let ceremony_id = str_field(body, "ceremony_id")
.ok_or_else(|| refuse(400, "`ceremony_id` is required"))?;
// A finish whose note is gone is refused by name, never finished under a default note (a
// paid sign-up would register with its repository unbound, an admin's enrolment for
// somebody else would hand the admin that person's session).
let mut note = self
.notes
.lock_or_recover()
.remove(ceremony_id)
.ok_or_else(|| refuse(409, ENROLMENT_UNKNOWN))?;
// What the invite itself says wins over the note: a sign-up's repository is in its
// invite (`signup:<repo>`), the shop's buyer is its invite's maker.
if let Some(invite) = note.invite.as_ref().and_then(|id| self.invite_by_id(id)) {
if let Some(repo) = invite
.created_by
.strip_prefix(SIGNUP_BY)
.filter(|r| !r.is_empty())
{
note.bind = Some(repo.to_owned());
}
note.buyer |=
invite.created_by == FIRST_ADMIN_BY || invite.created_by == SIGN_IN_AGAIN_BY;
}
let response = body
.get("response")
.ok_or_else(|| refuse(400, "`response` is required"))?;
let client_data = b64_field(response, "clientDataJSON")?;
let attestation = b64_field(response, "attestationObject")?;
let origin = self.origin_of(&client_data);
let (credential_id, public_key) =
attested_key(&self.rp_id, &origin, &attestation, &client_data)?;
let first_key = note.invite.as_ref().is_some_and(|id| {
self.invite_by_id(id)
.is_some_and(|i| i.created_by.starts_with(FIRST_KEY_BY))
});
if first_key && self.has_admin_passkey(auth) {
if let Some(id) = ¬e.invite {
self.withdraw_invite(id).ok();
}
self.forget_first_sign_in();
return Err(refuse(409, FIRST_KEY_ENROLLED));
}
let reg = Registration {
ceremony_id: ceremony_id.to_owned(),
credential_id: credential_id.clone(),
public_key,
client_data_json: client_data,
attestation_object: attestation,
rp_id: self.rp_id.clone(),
origin,
};
let done = self.core.redeem_finish(reg).map_err(said)?;
if first_key {
self.forget_first_sign_in();
}
let subject = done.session.user_id.clone();
if let Some(repo) = ¬e.bind {
if let Err(e) = self.bind_signup(repo, &subject) {
let _ = self.core.deactivate(&subject, "the sign-up's binding");
return Err(e);
}
log::warn!(
"passkey: {} ({subject}) redeemed the sign-up of '{repo}' and owns it",
done.session.display
);
}
if note.buyer {
self.buyer_registered(&subject);
}
log::info!(
"passkey: enrolled a passkey for {} ({subject})",
done.session.display
);
let mut out = json!({ "subject": subject, "display": done.session.display, "credential_id": B64.encode(&credential_id), "label": note.label });
if note.for_another {
// An admin enrolling a key for somebody else gets no session for them.
let _ = self.core.logout(&done.token, false);
} else {
out["token"] = json!(done.token);
out["expires_at"] = json!(done.session.expires_at);
}
Ok(out)
}
// ── Invites ──────────────────────────────────────────────────────────────
/// Does an active person here go by `display`?
fn name_taken(&self, display: &str) -> bool {
self.core
.host()
.store()
.users()
.unwrap_or_default()
.iter()
.any(|u| u.state != UserState::Deactivated && u.display == display)
}
fn open_invite(&self, secret: &str) -> Option<Invite> {
let id = nornir_identity_session::invites::hash_secret(secret.trim());
let now = now_secs();
self.core
.host()
.store()
.invites()
.ok()?
.into_iter()
.find(|i| i.id == id && i.open(now))
}
fn invite_by_id(&self, id: &str) -> Option<Invite> {
self.core
.host()
.store()
.invites()
.ok()?
.into_iter()
.find(|i| i.id == id)
}
fn pin_display(&self, id: &str, display: &str) -> Result<(), Refusal> {
let display = display.to_owned();
self.core
.host()
.store()
.update_invites(&mut |all| {
if let Some(i) = all.iter_mut().find(|i| i.id == id) {
i.display = display.clone();
}
})
.map_err(|e| refuse(500, e.0))
}
/// **An invite of this box's own making** (where a person comes from: a sign-up, the
/// shop's buyer, the first key): minted by the shared rule (`nornir_identity_session`, never
/// an admin unless `admin_ok`), kept in the core's store, redeemed by the core. `secret`:
/// the secret the invite answers to when it is a fed one (its holder's), else a fresh one.
fn seed(
&self,
display: &str,
groups: Vec<String>,
by: &str,
ttl_secs: i64,
secret: Option<&str>,
) -> Result<(String, Invite), Refusal> {
let now = now_secs();
let ask = nornir_identity_session::invites::Ask {
display: display.to_owned(),
groups,
by: by.to_owned(),
ttl_secs,
};
let (fresh, mut invite) = nornir_identity_session::invites::mint(ask, now, |_| false)
.map_err(|e| refuse(400, e.to_string()))?;
let secret = match secret {
Some(s) => {
invite.id = nornir_identity_session::invites::hash_secret(s.trim());
s.trim().to_owned()
}
None => fresh,
};
let kept = invite.clone();
self.core
.host()
.store()
.update_invites(&mut |all| {
all.retain(|i| i.id != kept.id);
all.push(kept.clone());
nornir_identity_session::invites::trim(
all,
nornir_identity_session::invites::MAX_INVITES,
now,
);
})
.map_err(|e| refuse(500, e.0))?;
self.core.host().audit(Event::InviteMinted {
invite: invite.id.clone(),
by: by.to_owned(),
mailed: false,
});
Ok((secret, invite))
}
/// `POST /-/auth/passkey/invites`: an admin's invite (never an admin). A person's session is
/// the core's invite; a machine admin's (the console, the CLI) is the same rule, here.
pub fn mint_invite_as(
&self,
body: &Value,
by: &str,
bearer: Option<&str>,
auth: &AuthConfig,
) -> Result<Value, Refusal> {
let display = str_field(body, "display").unwrap_or_default().to_owned();
if !display.is_empty() {
refuse_reserved(auth, &display)?;
}
let mut groups = groups_field(body);
if let Some(role) = str_field(body, "role") {
groups.push(group_for_role(auth, role)?);
}
if let Some(duty) = str_field(body, "duty") {
groups.push(group_for_duty(auth, duty)?);
}
let would_be = groups_identity(&groups);
if auth.role_for_identity(&would_be).can_admin() {
return Err(said(Refused::new(Code::InviteWouldBeAdmin, "")));
}
// HOL-NCSC (NCSC P1): an approver is invited by a PERSON whose session is stepped up,
// never by a machine credential (the console's token, the CLI's bearer, an mTLS
// cert): a stolen machine token must not be able to mint the second pair of eyes.
if auth
.duties_for_identity(&would_be)
.contains(&crate::auth::Duty::SecurityApprover)
&& !self.person_stepped_up(bearer)
{
return Err(refuse(
403,
holger_errcode::AUTH_INVITE_NEEDS_PERSON.tag(
"an invite that makes a Security Approver is minted by a person's stepped-up passkey session, \
never by a machine token: sign in with an admin passkey, step up, and invite again",
),
));
}
let email = str_field(body, "email").map(str::to_owned);
let (secret, id, expires, mailed) =
match bearer.filter(|b| crate::identity_host::is_session_token(b)) {
Some(token) => {
let i = self
.core
.invite(
token,
InviteAsk {
display: display.clone(),
email,
groups: groups.clone(),
ttl_secs: None,
person: None,
},
)
.map_err(said)?;
(i.secret, i.invite_id, i.expires_at, i.mailed)
}
None => {
let (secret, i) = self.seed(
&display,
groups.clone(),
by,
nornir_identity_session::invites::INVITE_TTL_SECS,
None,
)?;
(secret, i.id, i.expires, false)
}
};
let role = auth.role_for_identity(&would_be);
log::info!(
"passkey: {by} minted an invite for {} ({})",
if display.is_empty() {
"(their choice of name)"
} else {
display.as_str()
},
role_word(role)
);
Ok(json!({
"invite": secret,
"id": id,
"expires": expires,
"display": display,
"groups": groups,
"role": role_word(role),
"duties": auth.duties_for_identity(&would_be).into_iter().map(|d| d.name()).collect::<Vec<_>>(),
"mailed": mailed,
}))
}
/// `POST /-/auth/passkey/invites/list`: every invite, each with the role its groups resolve
/// to and whether it is still redeemable. Secrets are not part of the record.
pub fn invites_json(&self, auth: &AuthConfig) -> Value {
let now = now_secs();
let rows: Vec<Value> = self
.core
.host()
.store()
.invites()
.unwrap_or_default()
.into_iter()
.map(|i| {
let role = auth.role_for_identity(&groups_identity(&i.groups));
let mut v = serde_json::to_value(&i).unwrap_or(Value::Null);
v["role"] = json!(role_word(role));
v["open"] = json!(i.open(now));
v
})
.collect();
json!({ "invites": rows })
}
/// `POST /-/auth/passkey/invites/revoke`: an invite withdrawn by its id.
pub fn withdraw_invite(&self, id: &str) -> Result<Value, Refusal> {
let mut found = false;
self.core
.host()
.store()
.update_invites(&mut |all| {
if let Some(i) = all.iter_mut().find(|i| i.id == id) {
i.revoked = true;
found = true;
}
})
.map_err(|e| refuse(500, e.0))?;
if found {
Ok(json!({ "revoked": id }))
} else {
Err(refuse(404, "no such invite"))
}
}
// ── Sessions ─────────────────────────────────────────────────────────────
/// `POST /-/auth/passkey/session`: who the caller is, for a console to render.
pub fn session_json(
&self,
identity: &AuthIdentity,
bearer: Option<&str>,
auth: &AuthConfig,
) -> Value {
let view = bearer
.filter(|b| crate::identity_host::is_session_token(b))
.and_then(|b| self.core.session(b).ok());
json!({
"subject": identity.subject,
"display": identity.display.clone().unwrap_or_else(|| identity.subject.clone()),
"method": identity.method,
"groups": identity.groups,
"role": role_word(auth.role_for_identity(identity)),
"uv": view.as_ref().is_some_and(|v| v.stepped_up),
"expires_at": view.as_ref().map(|v| v.expires_at),
// A machine credential is the operator's own possession and has no session to step up.
"stepped_up": view.as_ref().map_or(identity.method != "passkey", |v| v.stepped_up),
})
}
/// `POST /-/auth/passkey/logout`: every session of the caller's person ends.
pub fn logout(&self, bearer: &str) -> Result<Value, Refusal> {
self.core.logout(bearer, true).map_err(said)?;
Ok(json!({ "ok": true }))
}
/// `POST /-/auth/passkey/sessions/revoke-all`: every session on this server ends.
pub fn revoke_all_sessions(&self) -> Result<Value, Refusal> {
let now = now_secs();
self.core
.host()
.store()
.update_revocations(&mut |r| r.revoke_all(now))
.map_err(|e| refuse(500, e.0))?;
Ok(json!({ "ok": true, "floor": now }))
}
// ── People ───────────────────────────────────────────────────────────────
/// `POST /-/auth/passkey/credentials/list`: every passkey, with its person and their role.
pub fn credentials_json(&self, auth: &AuthConfig) -> Value {
let store = self.core.host().store();
let users = store.users().unwrap_or_default();
let mut rows = Vec::new();
let mut admins = 0;
for u in &users {
let role = auth.role_for_identity(&identity_of(u));
if u.state == UserState::Active && role.can_admin() {
admins += 1;
}
for c in store.credentials_of(&u.id).unwrap_or_default() {
rows.push(json!({
"id": B64.encode(&c.id),
"subject": u.id,
"display": u.display,
"groups": u.groups,
"role": role_word(role),
"created": c.created,
"disabled": u.state != UserState::Active,
}));
}
}
json!({ "credentials": rows, "enabled_admins": admins })
}
/// `POST /-/auth/passkey/credentials/revoke`: the passkey's person is deactivated (every
/// session ends) — never the last admin.
pub fn revoke_credential(
&self,
id: &str,
by: &str,
auth: &AuthConfig,
) -> Result<Value, Refusal> {
let raw = B64
.decode(id)
.map_err(|_| refuse(404, "no such credential"))?;
let store = self.core.host().store();
let c = store
.credential(&raw)
.map_err(|e| refuse(500, e.0))?
.ok_or_else(|| refuse(404, "no such credential"))?;
let user = store
.user(&c.user)
.map_err(|e| refuse(500, e.0))?
.ok_or_else(|| refuse(404, "no such credential"))?;
if user.state != UserState::Active {
return Err(refuse(409, "this credential is already revoked"));
}
let admin = |u: &nornir_identity::User| {
u.state == UserState::Active && auth.role_for_identity(&identity_of(u)).can_admin()
};
if admin(&user)
&& store
.users()
.unwrap_or_default()
.iter()
.filter(|u| admin(u))
.count()
<= 1
{
return Err(refuse(
409,
"this is the last admin passkey; register another before revoking it",
));
}
self.core.deactivate(&user.id, by).map_err(said)?;
log::warn!(
"passkey: {} ({}) deactivated by {by}, and every session of theirs ended",
user.display,
user.id
);
Ok(json!({ "ok": true, "subject": user.id, "display": user.display }))
}
// ── The shop's buyer, the first key, a paid sign-up ─────────────────────
fn marker(&self) -> PathBuf {
self.root().join(FIRST_ADMIN_FILE)
}
fn read_marker(&self) -> Option<FirstAdminSeeded> {
std::fs::read(self.marker())
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
}
fn write_marker(&self, m: &FirstAdminSeeded) -> Result<(), Refusal> {
let marker = self.marker();
if let Some(dir) = marker.parent() {
std::fs::create_dir_all(dir).map_err(|e| {
refuse(
500,
format!("cannot make the first-login record's directory: {e}"),
)
})?;
}
let bytes = serde_json::to_vec_pretty(m)
.map_err(|e| refuse(500, format!("cannot serialize the first-login record: {e}")))?;
let tmp = marker.with_extension("tmp");
std::fs::write(&tmp, bytes)
.and_then(|()| std::fs::rename(&tmp, &marker))
.map_err(|e| refuse(500, format!("cannot write the first-login record: {e}")))
}
/// **The shop's first-login invite, taken in once** (lane P1): the fed SHA-256 of the
/// buyer's secret, pinned to the buyer, single use, its life clamped; not kept when an admin
/// exists already. The box never sees the secret: the buyer's link brings it, and the first
/// time it does it becomes an invite of the core's ([`Self::open_enrolment`]).
pub fn seed_first_admin(
&self,
fed: &FirstAdminInvite,
auth: &AuthConfig,
) -> Result<String, Refusal> {
let hex = fed.sha256_hex.trim().to_ascii_lowercase();
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(refuse(
400,
"the fed first-login invite is not a SHA-256 (64 hex characters)",
));
}
let display = fed.display.trim().to_owned();
if display.is_empty() {
return Err(refuse(
400,
"the fed first-login invite names no first owner (`operator_name`)",
));
}
refuse_reserved(auth, &display)?;
let groups = first_admin_groups(auth)?;
let _held = self.marker_lock.lock_or_recover();
let ttl = fed.ttl_secs.clamp(60, FIRST_ADMIN_TTL_MAX);
if self.marker().exists() {
// HOL-FIRST-ADMIN: a record taken in before the identity move names nobody (the
// account lived in the old store's invites, which the move did not carry over), so
// the shop's `sign_in_again` for its first admin was refused. The shop's feed names
// them: the record takes that name (and the life it was fed), nothing else of it
// changes. A record that names somebody is never renamed.
if let Some(mut m) = self.read_marker().filter(|m| m.display.trim().is_empty()) {
m.display = display.clone();
if m.ttl_secs <= 0 {
m.ttl_secs = ttl;
}
self.write_marker(&m)?;
return Ok(format!("taken in before: not opened again; the record names the shop's first admin {display} now"));
}
return Ok("taken in before: not opened again".into());
}
let mut m = FirstAdminSeeded {
sha256: hex,
at: now_secs(),
seeded: false,
display: display.clone(),
ttl_secs: ttl,
used_by: None,
};
if self.has_admin_passkey(auth) {
self.write_marker(&m)?;
return Ok(
"this server already has an admin passkey: no first-login invite is kept".into(),
);
}
m.seeded = true;
self.write_marker(&m)?;
Ok(format!(
"kept for {display} for {} h, single use: it enrols the estate's first admin ({})",
ttl / 3600,
groups.join(", ")
))
}
/// The fed first-login invite becomes the core's the first time its secret is presented.
fn take_in_fed(&self, secret: &str, auth: &AuthConfig) -> Result<(), Refusal> {
let _held = self.marker_lock.lock_or_recover();
let Some(m) = self.read_marker().filter(|m| m.seeded) else {
return Ok(());
};
if m.sha256 != sha256_hex(secret)
|| self.open_invite(secret).is_some()
|| self
.invite_by_id(&nornir_identity_session::invites::hash_secret(
secret.trim(),
))
.is_some()
{
return Ok(());
}
let left = (m.at + m.ttl_secs) - now_secs();
if left <= 0 {
return Ok(());
}
self.seed(
&m.display,
first_admin_groups(auth)?,
FIRST_ADMIN_BY,
left,
Some(secret),
)
.map(|_| ())
}
/// **The invite that enrols the box's FIRST admin key** (`holger.doors.v1.FirstKeyService`):
/// the core's first-admin invite, refused (409) once an admin exists, one open at a time.
pub fn mint_first_key_invite(
&self,
display: &str,
by: &str,
auth: &AuthConfig,
) -> Result<(String, Invite), Refusal> {
let display = display.trim().to_owned();
if !display.is_empty() {
refuse_reserved(auth, &display)?;
}
if self.has_admin_passkey(auth) {
return Err(refuse(409, FIRST_KEY_ENROLLED));
}
let groups = if auth.duties_enabled() {
first_admin_groups(auth)?
.into_iter()
.filter(|g| g != crate::auth::ESTATE_OWNERS)
.collect()
} else {
vec![group_for_role(auth, "admin")?]
};
let now = now_secs();
self.core
.host()
.store()
.update_invites(&mut |all| {
for open in all
.iter_mut()
.filter(|i| i.created_by.starts_with(FIRST_KEY_BY) && i.open(now))
{
open.revoked = true;
}
})
.map_err(|e| refuse(500, e.0))?;
let invited = self
.core
.first_admin_invite(groups, &format!("{FIRST_KEY_BY} ({by})"))
.map_err(said)?;
if !display.is_empty() {
self.pin_display(&invited.invite_id, &display)?;
}
let invite = self
.invite_by_id(&invited.invite_id)
.ok_or_else(|| refuse(500, "the first-key invite was not kept"))?;
log::info!(
"passkey: {by} minted the first-key invite ({})",
invite.groups.join(", ")
);
Ok((invited.secret, invite))
}
/// **A resend sign-in link for the estate's buyer** (A16): a fresh single-use invite pinned
/// to `account` with the first admin's groups, only for the account the shop named (404) and
/// only while it holds no passkey (409). `(secret, id)`.
pub fn mint_sign_in_again(
&self,
account: &str,
ttl_secs: u64,
auth: &AuthConfig,
) -> Result<(String, String), Refusal> {
let account = account.trim();
if !self.read_marker().is_some_and(|m| m.display == account) {
return Err(refuse(
404,
format!(
"{account} is no account the shop named on this box: no sign-in link minted"
),
));
}
let held = self
.core
.host()
.store()
.users()
.unwrap_or_default()
.into_iter()
.filter(|u| u.state == UserState::Active && u.display == account)
.count();
if held > 0 {
return Err(refuse(409, format!("{account} already signs in with a passkey ({held} held): a sign-in link only enrols the first one — sign in, or add a passkey from a signed-in device")));
}
let ttl = i64::try_from(ttl_secs)
.unwrap_or(FIRST_ADMIN_TTL_MAX)
.clamp(60, FIRST_ADMIN_TTL_MAX);
let (secret, invite) = self.seed(
account,
first_admin_groups(auth)?,
SIGN_IN_AGAIN_BY,
ttl,
None,
)?;
log::warn!("passkey: a resend sign-in link was minted for {account} (monetize's sign_in_again), {ttl} s, single use");
Ok((secret, invite.id))
}
/// **The repository a paid sign-up made that nobody has redeemed yet** (lane P1).
fn unredeemed_signup(&self, signup: &str) -> Option<String> {
let signup = signup.trim();
if signup.len() < 8 {
return None;
}
crate::sold::read(&self.data_root)
.into_iter()
.find(|(_, s)| {
!s.signup.is_empty()
&& s.owner.is_empty()
&& (s.signup == signup || s.signup.ends_with(&format!(":{signup}")))
})
.map(|(name, _)| name)
}
/// **Bind `repo` to `subject`** in the sold record and on the gate.
fn bind_signup(&self, repo: &str, subject: &str) -> Result<(), Refusal> {
let root = self.data_root.clone();
let entry = crate::sold::update(&root, |made| {
let s = made
.get_mut(repo)
.filter(|s| s.owner.is_empty())
.ok_or_else(|| SIGNUP_REFUSED.to_owned())?;
s.owner = subject.to_owned();
Ok(crate::sold::gate_entry(&root, s))
})
.map_err(|e| {
if e == SIGNUP_REFUSED {
refuse(401, SIGNUP_REFUSED)
} else {
refuse(500, e)
}
})?;
if let Some(g) = self.gate.get() {
g.own_entry(repo, entry);
}
Ok(())
}
/// **The shop's buyer registered** (lane P1): the first-login record names them, and every
/// repository the paid order made for the estate's owners is theirs.
fn buyer_registered(&self, subject: &str) {
{
let _held = self.marker_lock.lock_or_recover();
let mut m = self.read_marker().unwrap_or_default();
m.used_by = Some(subject.to_owned());
if let Err((_, e)) = self.write_marker(&m) {
log::error!("passkey: the first owner {subject} registered, and the first-login record could not say so: {e}");
}
}
match crate::sold::bind_buyer(&self.data_root, subject) {
Ok(bound) if bound.is_empty() => {}
Ok(bound) => {
let made = crate::sold::read(&self.data_root);
if let Some(g) = self.gate.get() {
for name in &bound {
if let Some(s) = made.get(name) {
g.own_entry(name, crate::sold::gate_entry(&self.data_root, s));
}
}
}
log::warn!("passkey: the first owner {subject} registered and owns {}", bound.join(", "))
}
Err(e) => log::error!("passkey: the first owner {subject} registered, and the sign-up record could not name them its owner: {e}"),
}
}
}
/// holger's role name for a console's role word (`reader` → `Reader`), as the login answer
/// always said it.
fn role_name(word: &str) -> String {
let mut c = word.chars();
c.next()
.map(|f| f.to_ascii_uppercase().to_string() + c.as_str())
.unwrap_or_default()
}
/// The session token and person of a step-up: a person signed in with the core's session.
fn step_up_person<'a>(
identity: Option<&'a AuthIdentity>,
bearer: Option<&'a str>,
) -> Result<(&'a str, &'a str), Refusal> {
match (identity, bearer) {
(Some(id), Some(b))
if id.method == "passkey" && crate::identity_host::is_session_token(b) =>
{
Ok((b, id.subject.as_str()))
}
(Some(_), _) => Err(refuse(
400,
"a step-up is a signed-in person's touch; this credential has none to refresh",
)),
(None, _) => Err(refuse(401, "log in first")),
}
}
/// The credential id and the SEC1 public key a registration's attestation carries (read by
/// nornir-auth-webauthn's parser; the core verifies the whole answer again).
fn attested_key(
rp_id: &str,
origin: &str,
attestation: &[u8],
client_data: &[u8],
) -> Result<(Vec<u8>, Vec<u8>), Refusal> {
let bad = || refuse(400, "that authenticator's response could not be verified");
let challenge = nornir_auth_webauthn::ceremony::client_data(client_data)
.map_err(|_| bad())?
.challenge;
let rp = nornir_auth_webauthn::pure::PureRelyingParty::new(rp_id, origin, "holger")
.map_err(|_| bad())?;
let cred = nornir_auth_webauthn::pure::verify_registration(
&rp,
attestation,
client_data,
&B64.encode(&challenge),
false,
)
.map_err(|e| {
log::warn!("passkey: a registration was refused: {e}");
bad()
})?;
let id = B64.decode(&cred.id).map_err(|_| bad())?;
let mut key = Vec::with_capacity(65);
key.push(0x04);
key.extend_from_slice(&cred.x);
key.extend_from_slice(&cred.y);
Ok((id, key))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::MemoryAuditLog;
use nornir_auth_webauthn::softkey::SoftKey;
const RP: &str = "holger.lab.internal";
const ORIGIN: &str = "https://holger.lab.internal";
fn auth() -> AuthConfig {
let mut a = AuthConfig {
methods: vec![
crate::auth::AuthMethodConfig::StaticToken {
subject: "console".into(),
token: Some("c".repeat(40)),
token_env: None,
token_file: None,
},
crate::auth::AuthMethodConfig::Passkey {
rp_id: RP.into(),
rp_origin: ORIGIN.into(),
rp_name: "holger".into(),
session_key_file: None,
bootstrap_until_first_admin: true,
},
],
roles: HashMap::from([("console".to_string(), crate::auth::Role::Admin)]),
group_roles: HashMap::from([
("holger-admins".to_string(), crate::auth::Role::Admin),
("holger-writers".to_string(), crate::auth::Role::Writer),
]),
..Default::default()
};
assert!(!crate::auth::estate_duties(&mut a).is_empty());
a
}
fn door() -> (SharedPasskeyDoor, AuthConfig, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let a = auth();
let d = PasskeyDoor::open(dir.path(), &a, Arc::new(MemoryAuditLog::new()))
.unwrap()
.expect("a passkey method opens a door");
(d, a, dir)
}
fn console() -> AuthIdentity {
AuthIdentity {
subject: "console".into(),
method: "static_token".into(),
groups: vec![],
display: None,
}
}
/// One enrolment through the door's JSON: start, the soft key, finish.
fn enrol(
d: &PasskeyDoor,
a: &AuthConfig,
key: &mut SoftKey,
ask: Value,
id: Option<&AuthIdentity>,
bearer: Option<&str>,
) -> Result<Value, Refusal> {
let start = d.open_enrolment(&ask, id, bearer, a, "203.0.113.1")?;
let challenge = start["publicKey"]["challenge"].as_str().unwrap().to_owned();
let (cd, att) = key.register(RP, ORIGIN, &challenge, true);
d.close_enrolment(&json!({ "ceremony_id": start["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "attestationObject": att } }), a)
}
fn login(d: &PasskeyDoor, key: &mut SoftKey, uv: bool) -> Result<Value, Refusal> {
let start = d.open_login(&json!({}), "203.0.113.1")?;
let challenge = start["publicKey"]["challenge"].as_str().unwrap().to_owned();
let (cd, ad, sig) = key.authenticate(RP, ORIGIN, &challenge, uv);
d.close_login(&json!({ "ceremony_id": start["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "authenticatorData": ad, "signature": sig } }))
}
/// ★ The break-glass bearer enrols the FIRST admin through the core, and then retires; the
/// admin's own session invites everybody else (never an admin); a login is the core's session.
#[test]
fn the_break_glass_enrols_the_first_admin_once_and_the_admin_invites_the_rest() {
let (d, a, _dir) = door();
let mut owner = SoftKey::new(1, &[1u8; 16]);
let done = enrol(
&d,
&a,
&mut owner,
json!({ "display": "rickard", "role": "admin" }),
Some(&console()),
Some(&"c".repeat(40)),
)
.expect("the first admin");
assert!(
done["token"]
.as_str()
.is_some_and(crate::identity_host::is_session_token),
"the core's session: {done}"
);
assert!(d.has_admin_passkey(&a));
let mut late = SoftKey::new(2, &[2u8; 16]);
let again = enrol(
&d,
&a,
&mut late,
json!({ "display": "eve", "role": "admin" }),
Some(&console()),
Some(&"c".repeat(40)),
)
.unwrap_err();
assert_eq!(again.0, 403, "the break-glass retired: {again:?}");
let session = login(&d, &mut owner, true).expect("the admin logs in")["token"]
.as_str()
.unwrap()
.to_owned();
let me = d.live_identity(&session).expect("a live session");
assert!(a.role_for_identity(&me).can_admin());
let admin_invite = d
.mint_invite_as(&json!({ "role": "admin" }), "rickard", Some(&session), &a)
.unwrap_err();
assert_eq!(admin_invite.0, 403, "an invite never makes an admin");
let invite = d
.mint_invite_as(&json!({ "role": "writer" }), "rickard", Some(&session), &a)
.unwrap();
let mut vera = SoftKey::new(3, &[3u8; 16]);
let v = enrol(
&d,
&a,
&mut vera,
json!({ "invite": invite["invite"], "display": "vera" }),
None,
None,
)
.expect("vera redeems");
assert_eq!(v["display"], "vera");
let reused = enrol(
&d,
&a,
&mut SoftKey::new(4, &[4u8; 16]),
json!({ "invite": invite["invite"], "display": "eve" }),
None,
None,
)
.unwrap_err();
assert_eq!(reused.0, 401, "an invite works once");
d.logout(v["token"].as_str().unwrap()).unwrap();
assert!(
d.live_identity(v["token"].as_str().unwrap()).is_none(),
"logout ends the session"
);
}
/// ★ HOL-NCSC (NCSC P1): **an invite that makes an approver needs a person's stepped-up
/// session.** The console's machine token still invites readers, writers and importers; an
/// approver invite from it is refused by name (`holger-auth-6`), and so is one from a person
/// whose session is not stepped up. An admin is never invited, by anyone.
#[test]
fn an_approver_invite_needs_a_stepped_up_person_never_the_machine_token() {
let (d, a, _dir) = door();
let machine = "c".repeat(40);
let approver_asks = [
json!({ "duty": "security_approver" }),
json!({ "groups": [crate::auth::ESTATE_APPROVERS] }),
];
for ask in &approver_asks {
let e = d
.mint_invite_as(ask, "console", Some(&machine), &a)
.unwrap_err();
assert_eq!(e.0, 403, "{ask}: {e:?}");
assert!(e.1.contains("[holger-auth-6]"), "{ask}: {e:?}");
let e = d.mint_invite_as(ask, "console", None, &a).unwrap_err();
assert!(
e.1.contains("[holger-auth-6]"),
"no bearer is no person: {e:?}"
);
}
d.mint_invite_as(&json!({ "role": "writer" }), "console", Some(&machine), &a)
.expect("the machine token invites a writer");
d.mint_invite_as(
&json!({ "duty": "importer" }),
"console",
Some(&machine),
&a,
)
.expect("and an importer");
// A person: first a plain touch (no step-up), then a stepped-up session.
let mut owner = SoftKey::new(0x61, &[0x61u8; 16]);
enrol(
&d,
&a,
&mut owner,
json!({ "display": "rickard", "role": "admin" }),
Some(&console()),
Some(&machine),
)
.unwrap();
let plain = login(&d, &mut owner, false).unwrap()["token"]
.as_str()
.unwrap()
.to_owned();
let e = d
.mint_invite_as(&approver_asks[0], "rickard", Some(&plain), &a)
.unwrap_err();
assert!(
e.1.contains("[holger-auth-6]"),
"a session that is not stepped up: {e:?}"
);
let me = d.live_identity(&plain).unwrap();
let start = d
.open_step_up(Some(&me), Some(&plain), "203.0.113.1")
.unwrap();
let challenge = start["publicKey"]["challenge"].as_str().unwrap().to_owned();
let (cd, ad, sig) = owner.authenticate(RP, ORIGIN, &challenge, true);
d.close_step_up(&json!({ "id": owner.credential_id_b64(), "response": { "clientDataJSON": cd, "authenticatorData": ad, "signature": sig } }), Some(&me), Some(&plain)).unwrap();
for ask in &approver_asks {
let ok = d
.mint_invite_as(ask, "rickard", Some(&plain), &a)
.expect("a stepped-up person invites an approver");
assert!(
ok["duties"]
.as_array()
.unwrap()
.iter()
.any(|d| d == "SecurityApprover"),
"{ok}"
);
}
assert_eq!(
d.mint_invite_as(&json!({ "role": "admin" }), "rickard", Some(&plain), &a)
.unwrap_err()
.0,
403,
"never an admin"
);
}
/// ★ The shop's fed first-login invite: the buyer's secret (its SHA-256 was fed) enrols the
/// first admin once; taken in again it is never reopened.
#[test]
fn the_shops_first_login_invite_enrols_the_buyer_once() {
let (d, a, _dir) = door();
let secret = "5".repeat(64);
let fed = FirstAdminInvite {
sha256_hex: sha256_hex(&secret),
ttl_secs: 3600,
display: "acme".into(),
};
assert!(d
.seed_first_admin(&fed, &a)
.unwrap()
.contains("kept for acme"));
assert!(d
.seed_first_admin(&fed, &a)
.unwrap()
.contains("not opened again"));
let wrong = enrol(
&d,
&a,
&mut SoftKey::new(5, &[5u8; 16]),
json!({ "invite": "6".repeat(64) }),
None,
None,
)
.unwrap_err();
assert_eq!(wrong.0, 401);
let done = enrol(
&d,
&a,
&mut SoftKey::new(6, &[6u8; 16]),
json!({ "invite": secret }),
None,
None,
)
.expect("the buyer enrols");
assert_eq!(done["display"], "acme", "pinned to the buyer");
assert!(d.has_admin_passkey(&a));
assert_eq!(
crate::sold::buyer_of(&d.data_root),
done["subject"].as_str().unwrap()
);
assert_eq!(
enrol(
&d,
&a,
&mut SoftKey::new(7, &[7u8; 16]),
json!({ "invite": secret }),
None,
None
)
.unwrap_err()
.0,
401,
"once"
);
assert_eq!(
d.mint_sign_in_again("acme", 86_400, &a).unwrap_err().0,
409,
"the buyer holds a passkey already"
);
assert_eq!(
d.mint_sign_in_again("bob", 86_400, &a).unwrap_err().0,
404,
"no account the shop named"
);
}
/// A door for the relying party `rp` at the configured origin `origin` (what a paired main
/// keeps: `rp_origin = https://<rp>`).
fn door_at(rp: &str, origin: &str) -> (SharedPasskeyDoor, AuthConfig, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mut a = auth();
for m in a.methods.iter_mut() {
if let crate::auth::AuthMethodConfig::Passkey {
rp_id, rp_origin, ..
} = m
{
*rp_id = rp.into();
*rp_origin = origin.into();
}
}
let d = PasskeyDoor::open(dir.path(), &a, Arc::new(MemoryAuditLog::new()))
.unwrap()
.expect("a passkey method opens a door");
(d, a, dir)
}
fn enrol_at(
d: &PasskeyDoor,
a: &AuthConfig,
key: &mut SoftKey,
(rp, origin): (&str, &str),
ask: Value,
id: Option<&AuthIdentity>,
bearer: Option<&str>,
) -> Result<Value, Refusal> {
let start = d.open_enrolment(&ask, id, bearer, a, "203.0.113.1")?;
let challenge = start["publicKey"]["challenge"].as_str().unwrap().to_owned();
let (cd, att) = key.register(rp, origin, &challenge, true);
d.close_enrolment(&json!({ "ceremony_id": start["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "attestationObject": att } }), a)
}
fn login_at(
d: &PasskeyDoor,
key: &mut SoftKey,
rp: &str,
origin: &str,
) -> Result<Value, Refusal> {
let start = d.open_login(&json!({}), "203.0.113.1")?;
let challenge = start["publicKey"]["challenge"].as_str().unwrap().to_owned();
let (cd, ad, sig) = key.authenticate(rp, origin, &challenge, true);
d.close_login(&json!({ "ceremony_id": start["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "authenticatorData": ad, "signature": sig } }))
}
/// ★ WWW-CANON (owner 2026-10-30: the site is www.holger.rs): **a passkey registered with RP
/// ID holger.rs on the bare host signs in at https://www.holger.rs**, on a main whose kept
/// config still names the bare origin (no re-pair); a new passkey can be made on www; a
/// foreign origin is still refused, at login and at enrolment; the door names www as the
/// site's origin.
#[test]
fn a_passkey_of_the_bare_site_signs_in_at_www_and_a_foreign_origin_is_refused() {
let (d, a, _dir) = door_at("holger.rs", "https://holger.rs");
let mut owner = SoftKey::new(0x31, &[0x31u8; 16]);
enrol_at(
&d,
&a,
&mut owner,
("holger.rs", "https://holger.rs"),
json!({ "display": "rickard", "role": "admin" }),
Some(&console()),
Some(&"c".repeat(40)),
)
.expect("the owner's passkey, made on the bare host");
let at_www = login_at(&d, &mut owner, "holger.rs", "https://www.holger.rs")
.expect("the bare site's passkey signs in at www");
assert!(
at_www["token"]
.as_str()
.is_some_and(crate::identity_host::is_session_token),
"{at_www}"
);
login_at(&d, &mut owner, "holger.rs", "https://holger.rs")
.expect("the bare origin is kept during the change");
let evil = login_at(&d, &mut owner, "holger.rs", "https://evil.example").unwrap_err();
assert!(
matches!(evil.0, 400 | 401),
"a foreign origin is refused: {evil:?}"
);
let sub = login_at(
&d,
&mut owner,
"holger.rs",
"https://holger.rs.evil.example",
)
.unwrap_err();
assert!(
matches!(sub.0, 400 | 401),
"a look-alike origin is refused: {sub:?}"
);
let session = login_at(&d, &mut owner, "holger.rs", "https://www.holger.rs").unwrap()
["token"]
.as_str()
.unwrap()
.to_owned();
let invite = d
.mint_invite_as(&json!({ "role": "writer" }), "rickard", Some(&session), &a)
.unwrap();
let mut vera = SoftKey::new(0x32, &[0x32u8; 16]);
let bad = enrol_at(
&d,
&a,
&mut vera,
("holger.rs", "https://evil.example"),
json!({ "invite": invite["invite"], "display": "vera" }),
None,
None,
)
.unwrap_err();
assert!(
bad.0 == 401 || bad.0 == 400,
"an enrolment at a foreign origin is refused: {bad:?}"
);
let invite = d
.mint_invite_as(&json!({ "role": "writer" }), "rickard", Some(&session), &a)
.unwrap();
enrol_at(
&d,
&a,
&mut vera,
("holger.rs", "https://www.holger.rs"),
json!({ "invite": invite["invite"], "display": "vera" }),
None,
None,
)
.expect("a passkey made on www");
login_at(&d, &mut vera, "holger.rs", "https://www.holger.rs")
.expect("and it signs in there");
assert_eq!(
d.config_json()["rp_origin"],
"https://www.holger.rs",
"the site's origin is www"
);
assert_eq!(
d.config_json()["rp_id"],
"holger.rs",
"the relying party stays the bare site"
);
}
/// ★ RA5-15: **a paid sign-up's finish never runs without its note.** The door's ceremony
/// notes (the repository to bind, the buyer, an admin enrolling for another) lived in a map
/// cleared WHOLE at 1024 open starts, and the finish took `unwrap_or_default()`: the buyer
/// registered and the paid repository stayed nobody's, with no word. Here the note is lost
/// (the same core, a door that holds no note of the ceremony): the finish is refused by name,
/// nobody is registered, and the repository waits for its buyer.
#[test]
fn a_paid_sign_ups_finish_without_its_note_is_refused_by_name() {
let (d, a, _dir) = door();
crate::sold::update(&d.data_root, |m| {
m.insert(
"bolt".into(),
crate::sold::SoldRepository {
template: "raw".into(),
signup: "stripe:cs_test_bolt00000001".into(),
..Default::default()
},
);
Ok(())
})
.unwrap();
let mut key = SoftKey::new(0x21, &[0x21u8; 16]);
let start = d
.open_enrolment(
&json!({ "signup": "cs_test_bolt00000001", "display": "bolt" }),
None,
None,
&a,
"203.0.113.1",
)
.expect("the buyer's start");
let forgot = PasskeyDoor {
core: Arc::clone(&d.core),
data_root: d.data_root.clone(),
rp_id: d.rp_id.clone(),
origins: d.origins.clone(),
rp_name: d.rp_name.clone(),
bootstrap_until_first_admin: d.bootstrap_until_first_admin,
notes: Mutex::new(HashMap::new()),
marker_lock: Mutex::new(()),
gate: OnceLock::new(),
};
let challenge = start["publicKey"]["challenge"].as_str().unwrap().to_owned();
let (cd, att) = key.register(RP, ORIGIN, &challenge, true);
let finish = json!({ "ceremony_id": start["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": cd, "attestationObject": att } });
let r = forgot.close_enrolment(&finish, &a);
assert!(
matches!(&r, Err((409, _))),
"a finish without its note is refused by name, never finished unbound: {r:?}"
);
assert_eq!(
crate::sold::read(&d.data_root)
.get("bolt")
.map(|s| s.owner.clone()),
Some(String::new()),
"the repository waits for its buyer"
);
assert!(
d.core
.host()
.store()
.users()
.unwrap()
.iter()
.all(|u| u.display != "bolt" || u.state != UserState::Active),
"nobody was registered"
);
let done = d
.close_enrolment(&finish, &a)
.expect("the door that holds the note finishes it");
assert_eq!(
crate::sold::read(&d.data_root)["bolt"].owner,
done["subject"].as_str().unwrap(),
"bound to the buyer"
);
}
/// ★ RA5-15: a full door refuses a new enrolment by name and drops no open one; notes
/// whose ceremony expired make room.
#[test]
fn a_full_door_refuses_a_new_enrolment_and_expired_notes_make_room() {
let (d, a, _dir) = door();
let now = now_secs();
{
let mut notes = d.notes.lock().unwrap();
for i in 0..MAX_NOTES {
notes.insert(
format!("open-{i}"),
Note {
opened: now,
..Note::default()
},
);
}
}
let ask = json!({ "display": "rickard", "role": "admin" });
assert_eq!(
d.open_enrolment(
&ask,
Some(&console()),
Some(&"c".repeat(40)),
&a,
"203.0.113.1"
)
.unwrap_err(),
(503, ENROLMENTS_FULL.to_owned())
);
assert_eq!(
d.notes.lock().unwrap().len(),
MAX_NOTES,
"no open enrolment was dropped"
);
for n in d.notes.lock().unwrap().values_mut() {
n.opened = now - 3600;
}
assert!(
d.open_enrolment(
&ask,
Some(&console()),
Some(&"c".repeat(40)),
&a,
"203.0.113.1"
)
.is_ok(),
"expired notes make room"
);
assert_eq!(d.notes.lock().unwrap().len(), 1);
}
/// ★ The first-key door's invite enrols the first admin only: refused once an admin exists.
#[test]
fn a_first_key_invite_is_refused_once_an_admin_exists() {
let (d, a, _dir) = door();
let (secret, invite) = d.mint_first_key_invite("", "console", &a).unwrap();
assert!(invite.created_by.starts_with(FIRST_KEY_BY));
enrol(
&d,
&a,
&mut SoftKey::new(8, &[8u8; 16]),
json!({ "display": "rickard", "role": "admin" }),
Some(&console()),
Some(&"c".repeat(40)),
)
.unwrap();
assert_eq!(
enrol(
&d,
&a,
&mut SoftKey::new(9, &[9u8; 16]),
json!({ "invite": secret, "display": "x" }),
None,
None
)
.unwrap_err(),
(409, FIRST_KEY_ENROLLED.to_owned())
);
assert_eq!(
d.mint_first_key_invite("", "console", &a).unwrap_err(),
(409, FIRST_KEY_ENROLLED.to_owned())
);
}
/// ★ **The first sign-in link is single use on disk too** (HOL-DOCFIND f): once its invite
/// enrolled the admin, `first-sign-in.url` is gone; a copy left by a crash between the
/// enrolment and the removal is removed at the next start, which writes no new link.
#[test]
fn a_used_first_sign_in_link_is_removed_from_disk() {
let (d, mut a, _dir) = door();
a.roles.retain(|_, r| *r != crate::auth::Role::Admin);
let file = d
.offer_first_sign_in(&a)
.unwrap()
.expect("a box with no admin writes its first sign-in link");
let link = std::fs::read_to_string(&file).unwrap();
let secret = link.trim().split("invite=").nth(1).unwrap().to_owned();
enrol(
&d,
&a,
&mut SoftKey::new(11, &[11u8; 16]),
json!({ "invite": secret, "display": "owner" }),
None,
None,
)
.unwrap();
assert!(d.has_admin_passkey(&a), "the link's passkey is the admin");
assert!(!file.exists(), "a used link is removed: {}", file.display());
// A copy a crash left behind: the next start removes it and writes none.
std::fs::write(&file, &link).unwrap();
assert_eq!(d.offer_first_sign_in(&a).unwrap(), None);
assert!(!file.exists(), "a stale link is removed at the start");
}
/// ★ A step-up is a fresh touch of one's own key through the core; the session says so.
#[test]
fn a_step_up_is_a_fresh_touch_of_ones_own_key() {
let (d, a, _dir) = door();
let mut owner = SoftKey::new(10, &[10u8; 16]);
enrol(
&d,
&a,
&mut owner,
json!({ "display": "rickard", "role": "admin" }),
Some(&console()),
Some(&"c".repeat(40)),
)
.unwrap();
let token = login(&d, &mut owner, false).unwrap()["token"]
.as_str()
.unwrap()
.to_owned();
let me = d.live_identity(&token).unwrap();
assert!(
!d.caller_stepped_up(&me, Some(&token)),
"a plain touch is no step-up"
);
let start = d
.open_step_up(Some(&me), Some(&token), "203.0.113.1")
.unwrap();
let challenge = start["publicKey"]["challenge"].as_str().unwrap().to_owned();
let (cd, ad, sig) = owner.authenticate(RP, ORIGIN, &challenge, true);
let up = d.close_step_up(&json!({ "id": owner.credential_id_b64(), "response": { "clientDataJSON": cd, "authenticatorData": ad, "signature": sig } }), Some(&me), Some(&token)).unwrap();
assert_eq!(
(up["signed_in"].as_bool(), up["stepped_up"].as_bool()),
(Some(true), Some(true))
);
assert!(d.caller_stepped_up(&me, Some(&token)));
assert!(
d.caller_stepped_up(&console(), None),
"a machine credential has no touch to refresh"
);
assert_eq!(refusal_json(STEP_UP_REFUSAL)["code"], STEP_UP_CODE);
}
/// ★ Revoking a passkey deactivates its person, never the last admin.
#[test]
fn revoking_a_passkey_ends_its_person_never_the_last_admin() {
let (d, a, _dir) = door();
let mut owner = SoftKey::new(11, &[11u8; 16]);
let o = enrol(
&d,
&a,
&mut owner,
json!({ "display": "rickard", "role": "admin" }),
Some(&console()),
Some(&"c".repeat(40)),
)
.unwrap();
assert_eq!(
d.revoke_credential(o["credential_id"].as_str().unwrap(), "console", &a)
.unwrap_err()
.0,
409
);
let list = d.credentials_json(&a);
assert_eq!(list["enabled_admins"], 1);
assert!(
is_reserved_name(&a, "console")
&& is_reserved_name(&a, "passkey:x")
&& !is_reserved_name(&a, "anna")
);
}
}