crates/gunnar-ui-server/src/passkeys_at_gunnar.rs
//! **Passkeys, invites and sessions are the identity core's, over gunnar's
//! people** (IDENTITY-HOST, 2026-10-27; thin layer L before it, owner
//! 2026-10-04: "make ui thin, you're moving code to server").
//!
//! Tests only. The console RELAYS: the ceremonies are
//! `nornir.identity.v1.IdentityService`'s (LoginStart/Finish,
//! RedeemStart/Finish, StepUpStart/Finish), served by gunnar over its own
//! principals and passkeys; the session is the core's opaque token, which the
//! cookie carries and every call the console makes for the person carries
//! (`nornir-identity-session`). Invites are the core's, minted AS the person
//! who invites; the passkey list and its revocation are gunnar's `Access`.
//!
//! # The fake is gunnar with the REAL core
//!
//! The fake gunnar here serves edda's real `IdentityGrpc` over a host whose
//! people and passkeys ARE the fake's principals and web credentials — what
//! `GunnarHost` is over gunnar's control plane — so every ceremony is checked
//! by the code gunnar runs. The fake records every call as `<Method> by
//! <who>`, `who` being the person whose core session the call carried
//! (`console` for the console's own key); the identity calls as
//! `Identity.<Method>`.
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use base64::Engine as _;
use nornir_identity::grpc::pb as ipb;
use nornir_identity::{Credential, Event, IdentityHost, Invite, Mailer, NoMail, Revocations, Roles, Session, Site, StoreError, User, UserState, UserStore};
use p256::ecdsa::signature::Signer as _;
use sha2::{Digest as _, Sha256};
use tonic::{Request, Response, Status};
use tower::ServiceExt as _;
use crate::api::mirror_gate::Imports;
use crate::pb;
const CONSOLE_SEED: [u8; 32] = [71u8; 32];
const RP_ID: &str = "console.example";
const ORIGIN: &str = "https://console.example";
const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
fn console_signer() -> crate::signer::Signer {
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&CONSOLE_SEED),
))
.expect("a fixed seed makes a signer")
}
fn fingerprint_of<T>(request: &Request<T>) -> String {
request
.metadata()
.get(crate::signer::CREDENTIAL_METADATA_KEY)
.and_then(|value| value.to_str().ok())
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("(unsigned)")
.to_owned()
}
fn openssh_fingerprint(line: &str) -> Result<String, Status> {
ssh_key::PublicKey::from_openssh(line.trim())
.map(|key| key.fingerprint(ssh_key::HashAlg::Sha256).to_string())
.map_err(|e| Status::invalid_argument(format!("not an OpenSSH key: {e}")))
}
/// What real gunnar checks of a relayed registration since 2026-10-10 (stinky
/// gunnar row 13), as far as a fake needs: the browser's `attestationObject`
/// came along, and it carries the relayed key's x and y.
pub(crate) fn attestation_carries(attestation_object: &[u8], public_key: &[u8]) -> Result<(), Status> {
let carries = |part: &[u8]| attestation_object.windows(part.len()).any(|w| w == part);
if public_key.len() != 65 || !carries(&public_key[1..33]) || !carries(&public_key[33..]) {
return Err(Status::permission_denied(
"refused: no attestation object, or one that names another key than `public_key`",
));
}
Ok(())
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|p| p.into_inner())
}
/// A passkey the core registered at the fake, as the fake heard it.
#[derive(Debug, Clone)]
pub(crate) struct Registered {
pub(crate) principal_id: String,
pub(crate) credential_id: Vec<u8>,
pub(crate) public_key: Vec<u8>,
pub(crate) attestation_object: Vec<u8>,
pub(crate) rp_id: String,
pub(crate) origin: String,
}
// ───────────────────────────────────────────────────────────────────────────
// The fake: gunnar's identity table, and the core over it
// ───────────────────────────────────────────────────────────────────────────
#[derive(Clone, Default)]
pub(crate) struct Fake {
/// `<Method> by <who>`.
heard: Arc<Mutex<Vec<String>>>,
/// key fingerprint → principal.
keys: Arc<Mutex<BTreeMap<String, String>>>,
principals: Arc<Mutex<BTreeMap<String, pb::Principal>>>,
/// principal → its server-wide grant level.
server: Arc<Mutex<BTreeMap<String, i32>>>,
/// The passkeys gunnar stores, with their SEC1 public keys.
#[allow(clippy::type_complexity)]
credentials: Arc<Mutex<Vec<(pb::WebCredential, Vec<u8>)>>>,
/// The core's own records at this gunnar: sessions, invites, floors.
sessions: Arc<Mutex<Vec<Session>>>,
invites: Arc<Mutex<Vec<Invite>>>,
revocations: Arc<Mutex<Revocations>>,
/// The identity core this gunnar serves (over the tables above).
core: Arc<OnceLock<Arc<nornir_identity::Identity<FakeHost>>>>,
/// Challenges `Access.MachineChallenge` issued and nothing consumed yet.
challenges: Arc<Mutex<Vec<Vec<u8>>>>,
issued: Arc<AtomicU32>,
/// Every `LoginStart` and `RedeemStart` the console asked (the client it named).
starts: Arc<Mutex<Vec<String>>>,
/// Every `LoginFinish` as the console relayed it.
logins: Arc<Mutex<Vec<ipb::LoginFinishRequest>>>,
/// Every passkey the core registered.
pub(crate) registrations: Arc<Mutex<Vec<Registered>>>,
/// The people an invite was made for (`EnrollEmployee{invite}` and
/// `Access.EnrolmentLink`): the principal ids, in order.
invite_requests: Arc<Mutex<Vec<String>>>,
revoked_keys: Arc<Mutex<Vec<String>>>,
/// Keys added (`Access.AddKey`): (principal, OpenSSH line, expiry).
#[allow(clippy::type_complexity)]
added_keys: Arc<Mutex<Vec<(String, String, Option<i64>)>>>,
/// principal → (its encryption key's public half, its unlock methods).
#[allow(clippy::type_complexity)]
encryption: Arc<Mutex<BTreeMap<String, (String, Vec<pb::UnlockMethod>)>>>,
revoked_credentials: Arc<Mutex<Vec<Vec<u8>>>>,
/// (account admin, employee): who enrolled whom.
employees: Arc<Mutex<Vec<(String, String)>>>,
/// Answer every LoginFinish as a refused ceremony.
refuse_logins: Arc<AtomicBool>,
/// Answer UNAVAILABLE: gunnar not reachable yet (a front just rebooted,
/// its WireGuard tunnel to main not back).
unreachable: Arc<AtomicBool>,
/// Answer LoginFinish with the core's STORE refusal, as gunnar does while
/// its data set is grown: the transaction log is released for the remount.
growing: Arc<AtomicBool>,
/// Answer LoginFinish with exactly this status (code, message) — what
/// reaches the console when the call itself fails (an http2 error).
login_status: Arc<Mutex<Option<(tonic::Code, String)>>>,
/// Machine enrolment requests gunnar holds (2026-10-05), with the key.
#[allow(clippy::type_complexity)]
enrolments: Arc<Mutex<Vec<(pb::MachineEnrolment, Vec<u8>)>>>,
/// Every request as the console relayed it.
enrol_requests: Arc<Mutex<Vec<pb::RequestMachineEnrolmentRequest>>>,
/// Every approval as the console relayed it, by whom, and whether their
/// session had stepped up.
approvals: Arc<Mutex<Vec<(String, bool, pb::ApproveMachineEnrolmentRequest)>>>,
/// How long RedeemFinish takes between hearing a passkey and storing it.
register_delay: Arc<Mutex<Option<std::time::Duration>>>,
/// The origin a real browser on loopback answers from (`first_touch`);
/// the core takes the relay's word for the relying party.
pub(crate) origin: Arc<Mutex<Option<String>>>,
/// How long the next `LoginStart`s / `RedeemStart`s take, one entry each
/// (a slow gunnar right after paying).
pub(crate) challenge_delays: Arc<Mutex<std::collections::VecDeque<std::time::Duration>>>,
/// RA5-2: the core's own step-up window has closed for every session (its clock, not the
/// console's): `CreateInvite` is the core's `needs_step_up` until a person steps up again.
stale_at_core: Arc<AtomicBool>,
/// GHUNT-19: every `Forge.Append` gunnar took — by whom, whether their
/// session had stepped up, and the event exactly as the console sent it.
forge_events: Arc<Mutex<Vec<(String, bool, pb::SignedForgeEvent)>>>,
}
impl Fake {
pub(crate) fn new() -> Fake {
let fake = Fake::default();
let host = FakeHost { fake: fake.clone() };
let core = Arc::new(nornir_identity::Identity::new(host, nornir_identity::Config::default()));
fake.core.set(core).unwrap_or_else(|_| unreachable!("one core"));
fake.person("anna", None);
fake.person("boss", Some(pb::GrantLevel::Admin));
fake.person("acme", None);
fake
}
fn core(&self) -> &Arc<nornir_identity::Identity<FakeHost>> {
self.core.get().expect("the fake's core")
}
pub(crate) fn person(&self, id: &str, server: Option<pb::GrantLevel>) {
lock(&self.principals).insert(
id.to_owned(),
pb::Principal {
id: id.to_owned(),
name: id.to_owned(),
kind: pb::PrincipalKind::User as i32,
created_at_unix_ms: 1_000,
},
);
if let Some(level) = server {
lock(&self.server).insert(id.to_owned(), level as i32);
}
}
/// A passkey gunnar already stores on `principal`.
fn holds_passkey(&self, principal: &str, yubikey: &Yubikey) {
lock(&self.credentials).push((
pb::WebCredential {
credential_id: yubikey.id.clone(),
principal_id: principal.to_owned(),
label: format!("{principal}'s key"),
created_at_unix_ms: 2_000,
last_used_unix_ms: None,
sign_count: 0,
},
yubikey.sec1(),
));
}
fn said(&self, call: String) {
lock(&self.heard).push(call);
}
fn heard(&self) -> Vec<String> {
lock(&self.heard).clone()
}
/// Who a call is: the person whose core session it carried, the console
/// by its key, or a stranger.
fn who<T>(&self, request: &Request<T>) -> String {
if let Some(token) = request.metadata().get(crate::grpc::SESSION_METADATA_KEY).and_then(|v| v.to_str().ok()) {
return self.core().session(token).map(|v| v.user_id).unwrap_or_else(|_| "stranger".to_owned());
}
let fingerprint = fingerprint_of(request);
if fingerprint == console_signer().fingerprint() {
return "console".to_owned();
}
lock(&self.keys).get(&fingerprint).cloned().unwrap_or_else(|| "stranger".to_owned())
}
/// Whether the call's core session stepped up (the core's word).
fn stepped_up<T>(&self, request: &Request<T>) -> bool {
request
.metadata()
.get(crate::grpc::SESSION_METADATA_KEY)
.and_then(|v| v.to_str().ok())
.and_then(|token| self.core().session(token).ok())
.is_some_and(|v| v.stepped_up)
}
fn admin(&self, who: &str) -> bool {
who == "console" || lock(&self.server).get(who).copied() == Some(pb::GrantLevel::Admin as i32)
}
/// Note the call; refuse a stranger.
fn hear<T>(&self, request: &Request<T>, method: &str) -> Result<String, Status> {
let who = self.who(request);
self.said(format!("{method} by {who}"));
if who == "stranger" {
return Err(Status::unauthenticated("no such key"));
}
Ok(who)
}
/// Note the call; refuse anybody but a server admin (the relay's RPCs).
fn hear_admin<T>(&self, request: &Request<T>, method: &str) -> Result<String, Status> {
let who = self.hear(request, method)?;
if !self.admin(&who) {
return Err(Status::permission_denied(format!("{method} is a server admin's")));
}
Ok(who)
}
/// The challenge a clientDataJSON carries, consumed — or a refusal.
fn consume(&self, client_data: &[u8], kind: &str) -> Result<(), Status> {
let data: serde_json::Value = serde_json::from_slice(client_data)
.map_err(|_| Status::invalid_argument("clientDataJSON is not JSON"))?;
let origin = lock(&self.origin).clone().unwrap_or_else(|| ORIGIN.to_owned());
if data["type"] != kind || data["origin"] != origin.as_str() {
return Err(Status::unauthenticated("wrong ceremony"));
}
let challenge = B64
.decode(data["challenge"].as_str().unwrap_or_default())
.map_err(|_| Status::invalid_argument("challenge"))?;
let mut issued = lock(&self.challenges);
let at = issued
.iter()
.position(|c| *c == challenge)
.ok_or_else(|| Status::unauthenticated("the challenge was not issued here"))?;
issued.remove(at);
Ok(())
}
fn store_credential(&self, principal: &str, id: &[u8], public: &[u8], label: &str) -> pb::WebCredential {
let credential = pb::WebCredential {
credential_id: id.to_vec(),
principal_id: principal.to_owned(),
label: label.to_owned(),
created_at_unix_ms: 3_000,
last_used_unix_ms: None,
sign_count: 0,
};
lock(&self.credentials).push((credential.clone(), public.to_vec()));
credential
}
/// The identity calls, faults injected the way a real gunnar fails them.
fn fault(&self) -> Option<Status> {
self.unreachable.load(Ordering::SeqCst).then(|| Status::unavailable("tcp connect error"))
}
async fn delay(&self) {
let delay = lock(&self.challenge_delays).pop_front();
if let Some(d) = delay {
tokio::time::sleep(d).await;
}
}
}
// ── The core's host over the fake's tables (what `GunnarHost` is) ──────────
/// gunnar's group for a server-wide Admin grant, as `GunnarStore` reads it.
const SERVER_ADMIN: &str = "server-admin";
const SERVER_WRITE: &str = "server-write";
pub(crate) struct FakeHost {
fake: Fake,
}
impl FakeHost {
fn groups(&self, id: &str) -> Vec<String> {
match lock(&self.fake.server).get(id).copied() {
Some(l) if l == pb::GrantLevel::Admin as i32 => vec![SERVER_ADMIN.to_owned()],
Some(l) if l == pb::GrantLevel::Write as i32 => vec![SERVER_WRITE.to_owned()],
_ => Vec::new(),
}
}
}
impl UserStore for FakeHost {
fn user(&self, id: &str) -> Result<Option<User>, StoreError> {
let principal = lock(&self.fake.principals).get(id).cloned();
Ok(principal.map(|p| User {
groups: self.groups(&p.id),
display: p.name,
email: None,
state: UserState::Active,
created: 0,
id: p.id,
}))
}
fn user_by_email(&self, _: &str) -> Result<Option<User>, StoreError> {
Ok(None)
}
fn users(&self) -> Result<Vec<User>, StoreError> {
let ids: Vec<String> = lock(&self.fake.principals).keys().cloned().collect();
Ok(ids.iter().filter_map(|id| self.user(id).ok().flatten()).collect())
}
fn put_user(&self, user: &User) -> Result<(), StoreError> {
let exists = lock(&self.fake.principals).contains_key(&user.id);
if !exists {
lock(&self.fake.principals).insert(
user.id.clone(),
pb::Principal { id: user.id.clone(), name: user.display.clone(), kind: pb::PrincipalKind::User as i32, created_at_unix_ms: 1_000 },
);
}
if user.groups.iter().any(|g| g == SERVER_ADMIN) {
lock(&self.fake.server).insert(user.id.clone(), pb::GrantLevel::Admin as i32);
} else if user.groups.iter().any(|g| g == SERVER_WRITE) {
lock(&self.fake.server).entry(user.id.clone()).or_insert(pb::GrantLevel::Write as i32);
}
Ok(())
}
fn credential(&self, id: &[u8]) -> Result<Option<Credential>, StoreError> {
Ok(lock(&self.fake.credentials).iter().find(|(c, _)| c.credential_id == id).map(|(c, key)| Credential {
id: c.credential_id.clone(),
user: c.principal_id.clone(),
public_key: key.clone(),
sign_count: c.sign_count,
created: 0,
}))
}
fn credentials_of(&self, user: &str) -> Result<Vec<Credential>, StoreError> {
Ok(lock(&self.fake.credentials)
.iter()
.filter(|(c, _)| c.principal_id == user)
.map(|(c, key)| Credential { id: c.credential_id.clone(), user: c.principal_id.clone(), public_key: key.clone(), sign_count: c.sign_count, created: 0 })
.collect())
}
fn put_credential(&self, credential: &Credential) -> Result<(), StoreError> {
let mut held = lock(&self.fake.credentials);
match held.iter_mut().find(|(c, _)| c.credential_id == credential.id) {
Some((c, _)) => c.sign_count = credential.sign_count,
None => {
drop(held);
self.fake.store_credential(&credential.user, &credential.id, &credential.public_key, "passkey");
}
}
Ok(())
}
fn session(&self, id: &str) -> Result<Option<Session>, StoreError> {
Ok(lock(&self.fake.sessions).iter().find(|s| s.id == id).cloned())
}
fn put_session(&self, session: &Session) -> Result<(), StoreError> {
let mut all = lock(&self.fake.sessions);
all.retain(|s| s.id != session.id);
all.push(session.clone());
Ok(())
}
fn delete_session(&self, id: &str) -> Result<(), StoreError> {
lock(&self.fake.sessions).retain(|s| s.id != id);
Ok(())
}
fn invites(&self) -> Result<Vec<Invite>, StoreError> {
Ok(lock(&self.fake.invites).clone())
}
fn update_invites(&self, change: &mut dyn FnMut(&mut Vec<Invite>)) -> Result<(), StoreError> {
change(&mut lock(&self.fake.invites));
Ok(())
}
fn revocations(&self) -> Result<Revocations, StoreError> {
Ok(lock(&self.fake.revocations).clone())
}
fn update_revocations(&self, change: &mut dyn FnMut(&mut Revocations)) -> Result<(), StoreError> {
change(&mut lock(&self.fake.revocations));
Ok(())
}
}
impl IdentityHost for FakeHost {
fn site(&self) -> Site {
Site::Private
}
fn store(&self) -> &dyn UserStore {
self
}
fn roles(&self, user: &User) -> Roles {
Roles { admin: user.groups.iter().any(|g| g == SERVER_ADMIN), names: user.groups.clone() }
}
fn audit(&self, _: Event) {}
fn mailer(&self) -> &dyn Mailer {
&NoMail
}
/// An account admin enrols the account's own people (gunnar's rule).
fn may_enrol(&self, by: &User, person: &User) -> bool {
lock(&self.fake.employees).iter().any(|(admin, employee)| *admin == by.id && *employee == person.id)
}
}
// ── The IdentityService gunnar serves: the real adapter, heard and faulted ──
// (`fixtures/`: the adapter's own method names, which no product code may
// define, are a test fixture's to implement.)
mod fixtures;
use fixtures::Heard;
#[tonic::async_trait]
impl pb::access_server::Access for Fake {
async fn who_am_i(
&self,
request: Request<pb::WhoAmIRequest>,
) -> Result<Response<pb::WhoAmIResponse>, Status> {
if self.unreachable.load(Ordering::SeqCst) {
return Err(Status::unavailable("tcp connect error"));
}
let who = self.hear(&request, "Access.WhoAmI")?;
let principal = lock(&self.principals).get(&who).cloned();
let level = lock(&self.server).get(&who).copied().unwrap_or(0);
Ok(Response::new(pb::WhoAmIResponse {
principal,
server_level: level,
..Default::default()
}))
}
async fn list_principals(
&self,
request: Request<pb::ListPrincipalsRequest>,
) -> Result<Response<pb::ListPrincipalsResponse>, Status> {
self.hear(&request, "Access.ListPrincipals")?;
let name = request.into_inner().name;
Ok(Response::new(pb::ListPrincipalsResponse {
principals: lock(&self.principals).values().filter(|p| name.is_empty() || p.name == name).cloned().collect(),
..Default::default()
}))
}
async fn add_principal(
&self,
request: Request<pb::AddPrincipalRequest>,
) -> Result<Response<pb::AddPrincipalResponse>, Status> {
self.hear_admin(&request, "Access.AddPrincipal")?;
let name = request.into_inner().name;
if lock(&self.principals).contains_key(&name) {
return Err(Status::already_exists(format!("{name} exists")));
}
self.person(&name, None);
let principal = lock(&self.principals).get(&name).cloned();
Ok(Response::new(pb::AddPrincipalResponse { principal }))
}
async fn remove_principal(
&self,
request: Request<pb::RemovePrincipalRequest>,
) -> Result<Response<pb::RemovePrincipalResponse>, Status> {
self.hear_admin(&request, "Access.RemovePrincipal")?;
lock(&self.principals).remove(&request.into_inner().principal_id);
Ok(Response::new(pb::RemovePrincipalResponse::default()))
}
async fn grant(
&self,
request: Request<pb::GrantRequest>,
) -> Result<Response<pb::GrantResponse>, Status> {
self.hear_admin(&request, "Access.Grant")?;
let grant = request.into_inner();
if grant.namespace.is_empty() && grant.repo.is_empty() {
lock(&self.server).insert(grant.principal_id, grant.level);
}
Ok(Response::new(pb::GrantResponse::default()))
}
async fn list_grants(
&self,
request: Request<pb::ListGrantsRequest>,
) -> Result<Response<pb::ListGrantsResponse>, Status> {
self.hear_admin(&request, "Access.ListGrants")?;
Ok(Response::new(pb::ListGrantsResponse {
grants: lock(&self.server)
.iter()
.map(|(principal, level)| pb::Grant { principal_id: principal.clone(), level: *level, ..Default::default() })
.collect(),
}))
}
async fn add_key(
&self,
request: Request<pb::AddKeyRequest>,
) -> Result<Response<pb::AddKeyResponse>, Status> {
let who = self.hear(&request, "Access.AddKey")?;
let add = request.into_inner();
if add.principal_id != who && !self.admin(&who) {
return Err(Status::permission_denied("not your principal"));
}
let fingerprint = openssh_fingerprint(&add.openssh_public_key)?;
lock(&self.keys).insert(fingerprint.clone(), add.principal_id.clone());
lock(&self.added_keys).push((add.principal_id.clone(), add.openssh_public_key.clone(), add.expires_at_unix_ms));
Ok(Response::new(pb::AddKeyResponse {
key: Some(pb::PublicKey {
fingerprint,
principal_id: add.principal_id,
comment: add.comment,
expires_at_unix_ms: add.expires_at_unix_ms,
..Default::default()
}),
}))
}
async fn list_keys(
&self,
request: Request<pb::ListKeysRequest>,
) -> Result<Response<pb::ListKeysResponse>, Status> {
let who = self.hear(&request, "Access.ListKeys")?;
let principal = request.into_inner().principal_id;
if principal != who && !self.admin(&who) {
return Err(Status::permission_denied("not your keys"));
}
Ok(Response::new(pb::ListKeysResponse {
keys: lock(&self.keys)
.iter()
.filter(|(_, p)| **p == principal)
.map(|(fingerprint, p)| pb::PublicKey { fingerprint: fingerprint.clone(), principal_id: p.clone(), ..Default::default() })
.collect(),
}))
}
async fn get_encryption_key(
&self,
request: Request<pb::GetEncryptionKeyRequest>,
) -> Result<Response<pb::GetEncryptionKeyResponse>, Status> {
let who = self.hear(&request, "Access.GetEncryptionKey")?;
let held = lock(&self.encryption).get(&who).cloned();
Ok(Response::new(match held {
Some((public_key, methods)) => pb::GetEncryptionKeyResponse { exists: true, public_key, methods },
None => pb::GetEncryptionKeyResponse::default(),
}))
}
async fn create_encryption_key(
&self,
request: Request<pb::CreateEncryptionKeyRequest>,
) -> Result<Response<pb::CreateEncryptionKeyResponse>, Status> {
let who = self.hear(&request, "Access.CreateEncryptionKey")?;
let r = request.into_inner();
let mut all = lock(&self.encryption);
if all.contains_key(&who) {
return Err(Status::already_exists("you have an encryption key already; add a method to it instead"));
}
let mut first = r.first.ok_or_else(|| Status::invalid_argument("no first method"))?;
first.id = format!("{:016x}", first.wrap.len() as u64 * 7919 + 1);
all.insert(who, (r.public_key.clone(), vec![first.clone()]));
Ok(Response::new(pb::CreateEncryptionKeyResponse { public_key: r.public_key, methods: vec![first] }))
}
async fn add_unlock_method(
&self,
request: Request<pb::AddUnlockMethodRequest>,
) -> Result<Response<pb::AddUnlockMethodResponse>, Status> {
let who = self.hear(&request, "Access.AddUnlockMethod")?;
let mut m = request.into_inner().method.ok_or_else(|| Status::invalid_argument("no method"))?;
let mut all = lock(&self.encryption);
let (_, methods) = all.get_mut(&who).ok_or_else(|| Status::failed_precondition("you have no encryption key yet"))?;
m.id = format!("{:016x}", m.wrap.len() as u64 * 7919 + methods.len() as u64 + 2);
methods.push(m);
Ok(Response::new(pb::AddUnlockMethodResponse { methods: methods.clone() }))
}
async fn remove_unlock_method(
&self,
request: Request<pb::RemoveUnlockMethodRequest>,
) -> Result<Response<pb::RemoveUnlockMethodResponse>, Status> {
let who = self.hear(&request, "Access.RemoveUnlockMethod")?;
let id = request.into_inner().id;
let mut all = lock(&self.encryption);
let (_, methods) = all.get_mut(&who).ok_or_else(|| Status::not_found("no such method"))?;
if !methods.iter().any(|m| m.id == id) {
return Err(Status::not_found("no such method"));
}
if methods.len() == 1 {
return Err(Status::failed_precondition("that is your last unlock method: add another first, or nothing opens your encryption key again"));
}
methods.retain(|m| m.id != id);
Ok(Response::new(pb::RemoveUnlockMethodResponse { methods: methods.clone() }))
}
async fn revoke_key(
&self,
request: Request<pb::RevokeKeyRequest>,
) -> Result<Response<pb::RevokeKeyResponse>, Status> {
let who = self.hear(&request, "Access.RevokeKey")?;
let fingerprint = request.into_inner().fingerprint;
let owner = lock(&self.keys).get(&fingerprint).cloned();
if owner.as_deref() != Some(who.as_str()) && !self.admin(&who) {
return Err(Status::permission_denied("not your key"));
}
lock(&self.keys).remove(&fingerprint);
lock(&self.revoked_keys).push(fingerprint);
Ok(Response::new(pb::RevokeKeyResponse::default()))
}
/// The console's first-login link for an existing person (a server admin's
/// door): minted by the core, first only when asked.
async fn enrolment_link(
&self,
request: Request<pb::EnrolmentLinkRequest>,
) -> Result<Response<pb::EnrolmentLinkResponse>, Status> {
self.hear_admin(&request, "Access.EnrolmentLink")?;
let ask = request.into_inner();
if ask.first_only && lock(&self.credentials).iter().any(|(c, _)| c.principal_id == ask.principal_id) {
return Err(Status::failed_precondition(format!("{} already holds a passkey", ask.principal_id)));
}
let link = self.core().enrolment(&ask.principal_id, "console", Some(900)).map_err(nornir_identity::grpc::status)?;
lock(&self.invite_requests).push(ask.principal_id);
Ok(Response::new(pb::EnrolmentLinkResponse { secret: link.secret, invite_id: link.invite_id, expires_at_unix_ms: link.expires_at * 1000 }))
}
async fn machine_challenge(
&self,
request: Request<pb::MachineChallengeRequest>,
) -> Result<Response<pb::MachineChallengeResponse>, Status> {
if self.unreachable.load(Ordering::SeqCst) {
return Err(Status::unavailable("tcp connect error"));
}
self.hear_admin(&request, "Access.MachineChallenge")?;
let n = self.issued.fetch_add(1, Ordering::SeqCst) as u8;
let challenge = vec![0xC0 ^ n; 32];
lock(&self.challenges).push(challenge.clone());
Ok(Response::new(pb::MachineChallengeResponse { challenge, expires_at_unix_ms: crate::auth::now_secs() * 1000 + 300_000 }))
}
async fn list_credentials(
&self,
request: Request<pb::ListCredentialsRequest>,
) -> Result<Response<pb::ListCredentialsResponse>, Status> {
if self.unreachable.load(Ordering::SeqCst) {
return Err(Status::unavailable("tcp connect error"));
}
let who = self.hear(&request, "Access.ListCredentials")?;
let principal = request.into_inner().principal_id;
if principal != who && !self.admin(&who) {
return Err(Status::permission_denied("not your passkeys"));
}
Ok(Response::new(pb::ListCredentialsResponse {
credentials: lock(&self.credentials)
.iter()
.filter(|(c, _)| principal.is_empty() || c.principal_id == principal)
.map(|(c, _)| c.clone())
.collect(),
}))
}
/// A revoked passkey ends its person's core sessions (as gunnar does).
async fn revoke_credential(
&self,
request: Request<pb::RevokeCredentialRequest>,
) -> Result<Response<pb::RevokeCredentialResponse>, Status> {
let who = self.hear(&request, "Access.RevokeCredential")?;
let id = request.into_inner().credential_id;
let mut held = lock(&self.credentials);
let at = held
.iter()
.position(|(c, _)| c.credential_id == id)
.ok_or_else(|| Status::not_found("no such web credential"))?;
if held[at].0.principal_id != who && !self.admin(&who) {
return Err(Status::permission_denied("not your passkey"));
}
let (gone, _) = held.remove(at);
drop(held);
self.core().end_sessions(&gone.principal_id, &who).map_err(nornir_identity::grpc::status)?;
lock(&self.revoked_credentials).push(id);
Ok(Response::new(pb::RevokeCredentialResponse::default()))
}
/// Relabelled as gunnar does: one's own passkey, or anybody's for a server admin.
async fn rename_credential(
&self,
request: Request<pb::RenameCredentialRequest>,
) -> Result<Response<pb::RenameCredentialResponse>, Status> {
let who = self.hear(&request, "Access.RenameCredential")?;
let ask = request.into_inner();
let label = ask.label.trim();
if label.is_empty() || label.chars().count() > 128 {
return Err(Status::invalid_argument("`label`: not empty, at most 128 characters"));
}
let mut held = lock(&self.credentials);
let (c, _) = held
.iter_mut()
.find(|(c, _)| c.credential_id == ask.credential_id)
.ok_or_else(|| Status::not_found("no such web credential"))?;
if c.principal_id != who && !self.admin(&who) {
return Err(Status::permission_denied("not your passkey"));
}
c.label = label.to_owned();
Ok(Response::new(pb::RenameCredentialResponse { credential: Some(c.clone()) }))
}
// ── a machine asks, a person approves (2026-10-05) ──────────────────────
async fn request_machine_enrolment(
&self,
request: Request<pb::RequestMachineEnrolmentRequest>,
) -> Result<Response<pb::RequestMachineEnrolmentResponse>, Status> {
self.hear_admin(&request, "Access.RequestMachineEnrolment")?;
let asked = request.into_inner();
lock(&self.enrol_requests).push(asked.clone());
self.consume(&asked.client_data_json, "webauthn.create")?;
attestation_carries(&asked.attestation_object, &asked.public_key)?;
let mut held = lock(&self.enrolments);
let row = pb::MachineEnrolment {
request_id: format!("req-{}", held.len()),
machine: asked.machine,
label: asked.label,
server_level: asked.server_level,
code: format!("ABC-12{}", held.len()),
key_fingerprint: "SHA256:kitkey".to_owned(),
requested_at_unix_ms: 1_000,
expires_at_unix_ms: 9_000_000_000_000,
state: pb::MachineEnrolmentState::Pending as i32,
credential_id: asked.credential_id,
..Default::default()
};
held.push((row.clone(), asked.public_key));
Ok(Response::new(pb::RequestMachineEnrolmentResponse { request: Some(row) }))
}
async fn machine_enrolment_status(
&self,
request: Request<pb::MachineEnrolmentStatusRequest>,
) -> Result<Response<pb::MachineEnrolmentStatusResponse>, Status> {
self.hear_admin(&request, "Access.MachineEnrolmentStatus")?;
let id = request.into_inner().request_id;
let row = lock(&self.enrolments)
.iter()
.find(|(r, _)| r.request_id == id)
.map(|(r, _)| r.clone())
.ok_or_else(|| Status::not_found("no such request"))?;
Ok(Response::new(pb::MachineEnrolmentStatusResponse { request: Some(row) }))
}
async fn list_machine_enrolments(
&self,
request: Request<pb::ListMachineEnrolmentsRequest>,
) -> Result<Response<pb::ListMachineEnrolmentsResponse>, Status> {
self.hear_admin(&request, "Access.ListMachineEnrolments")?;
Ok(Response::new(pb::ListMachineEnrolmentsResponse {
requests: lock(&self.enrolments)
.iter()
.filter(|(r, _)| r.state == pb::MachineEnrolmentState::Pending as i32)
.map(|(r, _)| r.clone())
.collect(),
}))
}
/// gunnar's rule: the approver's own core session, stepped up.
async fn approve_machine_enrolment(
&self,
request: Request<pb::ApproveMachineEnrolmentRequest>,
) -> Result<Response<pb::ApproveMachineEnrolmentResponse>, Status> {
let who = self.hear_admin(&request, "Access.ApproveMachineEnrolment")?;
let stepped_up = self.stepped_up(&request);
let approve = request.into_inner();
lock(&self.approvals).push((who.clone(), stepped_up, approve.clone()));
if !stepped_up {
return Err(Status::permission_denied("[stepup] Access.ApproveMachineEnrolment needs a fresh touch of your own passkey"));
}
let mut held = lock(&self.enrolments);
let (row, public) = held
.iter_mut()
.find(|(r, _)| r.request_id == approve.request_id && r.code == approve.code)
.ok_or_else(|| Status::failed_precondition("no such pending request with that code"))?;
row.state = pb::MachineEnrolmentState::Approved as i32;
row.decided_by = who;
let (row, public) = (row.clone(), public.clone());
drop(held);
lock(&self.principals).insert(
row.machine.clone(),
pb::Principal { id: row.machine.clone(), name: row.machine.clone(), kind: pb::PrincipalKind::Machine as i32, created_at_unix_ms: 1_000 },
);
lock(&self.server).insert(row.machine.clone(), row.server_level);
let credential = self.store_credential(&row.machine, &row.credential_id, &public, &row.label);
let principal = lock(&self.principals).get(&row.machine).cloned();
Ok(Response::new(pb::ApproveMachineEnrolmentResponse { request: Some(row), principal, credential: Some(credential) }))
}
async fn refuse_machine_enrolment(
&self,
request: Request<pb::RefuseMachineEnrolmentRequest>,
) -> Result<Response<pb::RefuseMachineEnrolmentResponse>, Status> {
let who = self.hear_admin(&request, "Access.RefuseMachineEnrolment")?;
let id = request.into_inner().request_id;
let mut held = lock(&self.enrolments);
let (row, _) = held.iter_mut().find(|(r, _)| r.request_id == id).ok_or_else(|| Status::not_found("no such request"))?;
row.state = pb::MachineEnrolmentState::Refused as i32;
row.decided_by = who;
Ok(Response::new(pb::RefuseMachineEnrolmentResponse { request: Some(row.clone()) }))
}
}
/// **gunnar's `Forge.Append` rule for a person's session** (GHUNT-19): an
/// UNSIGNED event from the session's own person is attested when the session
/// stepped up and refused `[stepup]` when it did not; a session's event signed
/// with the console's key is somebody else's word in her name, refused as gunnar
/// refuses it.
#[tonic::async_trait]
impl pb::forge_server::Forge for Fake {
async fn append(&self, request: Request<pb::AppendEventRequest>) -> Result<Response<pb::AppendEventResponse>, Status> {
let who = self.hear(&request, "Forge.Append")?;
let session = request.metadata().get(crate::grpc::SESSION_METADATA_KEY).is_some();
let stepped_up = self.stepped_up(&request);
let signed = request.into_inner().event.unwrap_or_default();
let actor = signed.event.as_ref().map(|e| e.actor.clone()).unwrap_or_default();
if actor != who {
return Err(Status::permission_denied(format!("this event is attributed to {actor:?} and the caller is {who:?}")));
}
if session && !signed.key_fingerprint.is_empty() {
return Err(Status::invalid_argument(format!(
"this event is attributed to {actor:?} and is signed by {}, which is not {actor:?}'s key",
signed.key_fingerprint
)));
}
if session && !stepped_up {
return Err(Status::permission_denied("[stepup] Forge.Append needs a fresh touch of your own passkey (user-verified, within the last 10 minutes)"));
}
let mut events = lock(&self.forge_events);
events.push((who, stepped_up, signed));
Ok(Response::new(pb::AppendEventResponse { index: events.len() as u64 - 1 }))
}
async fn get_review(&self, _: Request<pb::GetReviewRequest>) -> Result<Response<pb::GetReviewResponse>, Status> {
Ok(Response::new(pb::GetReviewResponse::default()))
}
async fn list_reviews(&self, _: Request<pb::ListReviewsRequest>) -> Result<Response<pb::ListReviewsResponse>, Status> {
Ok(Response::new(pb::ListReviewsResponse::default()))
}
async fn get_issue(&self, _: Request<pb::GetIssueRequest>) -> Result<Response<pb::GetIssueResponse>, Status> {
Err(Status::not_found("no issue"))
}
async fn list_issues(&self, _: Request<pb::ListIssuesRequest>) -> Result<Response<pb::ListIssuesResponse>, Status> {
Ok(Response::new(pb::ListIssuesResponse::default()))
}
}
#[tonic::async_trait]
impl pb::accounts_server::Accounts for Fake {
async fn enroll_employee(
&self,
request: Request<pb::EnrollEmployeeRequest>,
) -> Result<Response<pb::EnrollEmployeeResponse>, Status> {
let who = self.hear(&request, "Accounts.EnrollEmployee")?;
let enroll = request.into_inner();
if enroll.account != who {
return Err(Status::permission_denied("not your account"));
}
self.person(&enroll.id, None);
lock(&self.employees).push((who, enroll.id.clone()));
let principal = lock(&self.principals).get(&enroll.id).cloned();
// The new named user's first-login link, made in the same call (Add
// users, 2026-10-04): the core's.
let (invite_id, invite_secret, expires) = if enroll.invite {
let link = self.core().enrolment(&enroll.id, "acme", None).map_err(nornir_identity::grpc::status)?;
lock(&self.invite_requests).push(enroll.id.clone());
(link.invite_id, link.secret, link.expires_at * 1000)
} else {
(String::new(), String::new(), 0)
};
Ok(Response::new(pb::EnrollEmployeeResponse {
principal,
invite_id,
invite_secret,
invite_expires_at_unix_ms: expires,
..Default::default()
}))
}
}
// ───────────────────────────────────────────────────────────────────────────
// The console, and a person's authenticator
// ───────────────────────────────────────────────────────────────────────────
pub(crate) struct Rig {
pub(crate) router: axum::Router,
pub(crate) webauthn: Arc<crate::auth::Webauthn>,
store: crate::state::StateStore,
fake: Fake,
}
async fn console(fake: Fake) -> Rig {
console_selling(fake, None).await
}
/// The console, and — for a `metered` build's paid signup — monetize's
/// `Signup` service on the same loopback port.
#[cfg_attr(not(feature = "metered"), allow(unused_variables))]
async fn console_selling(
fake: Fake,
#[cfg(feature = "metered")] monetize: Option<crate::auth::signup::tests::FakeMonetize>,
#[cfg(not(feature = "metered"))] monetize: Option<()>,
) -> Rig {
console_selling_at(fake, monetize, RP_ID, ORIGIN).await
}
/// [`console_selling`] for the relying party `rp_id` at `origin` (a real browser on loopback).
#[cfg_attr(not(feature = "metered"), allow(unused_variables))]
pub(crate) async fn console_selling_at(
fake: Fake,
#[cfg(feature = "metered")] monetize: Option<crate::auth::signup::tests::FakeMonetize>,
#[cfg(not(feature = "metered"))] monetize: Option<()>,
rp_id: &str,
origin: &str,
) -> Rig {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
let serving = fake.clone();
let identity = Heard { fake: fake.clone(), inner: nornir_identity::grpc::IdentityGrpc::new(Arc::clone(fake.core())) };
#[cfg(feature = "metered")]
let selling = monetize.is_some();
tokio::spawn(async move {
let router = tonic::transport::Server::builder()
.add_service(pb::access_server::AccessServer::new(serving.clone()))
.add_service(pb::accounts_server::AccountsServer::new(serving.clone()))
.add_service(pb::forge_server::ForgeServer::new(serving))
.add_service(ipb::identity_service_server::IdentityServiceServer::new(identity));
#[cfg(feature = "metered")]
let router = router.add_optional_service(
monetize.map(crate::mpb::signup_server::SignupServer::new),
);
router
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
let dir = tempfile::tempdir().expect("a temp dir").keep();
let store = crate::state::StateStore::open(dir.join("state.redb")).expect("a store");
let webauthn = Arc::new(
crate::auth::Webauthn::new(
crate::auth::RelyingParty {
rp_id: rp_id.to_owned(),
origin: origin.to_owned(),
rp_name: "gunnar".to_owned(),
},
store.clone(),
)
.expect("a subsystem"),
);
let endpoint = format!("http://{addr}");
let state: crate::app::AppState = Arc::new(crate::app::App {
webauthn: Some(webauthn.clone()),
admin_token: Some("break-glass".to_owned()),
#[cfg(feature = "metered")]
monetize: selling.then(|| {
crate::monetize::Monetize::connect_lazy(
&format!("http://{addr}"),
crate::auth::signup::tests::BEARER,
"gunnar",
)
.expect("a lazy channel")
}),
events: crate::sse::EventHub::detached(),
gunnar: crate::grpc::Gunnar::connect_lazy(&endpoint, Arc::new(console_signer()))
.expect("a handle"),
twin: None,
guest: None,
reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(
endpoint.clone(),
store.clone(),
)),
tenants: Arc::new(crate::tenant::TenantKeys::new(endpoint, store.clone())),
forges: Default::default(),
imports: Arc::new(Imports::default()),
forge_apps: Arc::default(),
ui_dir: None,
caches: crate::api::Caches::default(),
objects: Default::default(),
readmes: Default::default(),
});
let router = crate::serve::router(state);
Rig {
router,
webauthn,
store,
fake,
}
}
/// A P-256 authenticator and the credential id it reports.
pub(crate) struct Yubikey {
key: p256::ecdsa::SigningKey,
id: Vec<u8>,
counter: u32,
/// Whether a `get` reports a verified user (PIN or biometric); a bare
/// touch when false.
uv: bool,
}
impl Yubikey {
fn new(seed: u8, id: &str) -> Yubikey {
Yubikey {
key: p256::ecdsa::SigningKey::from_bytes(&[seed; 32].into()).expect("a scalar"),
id: id.as_bytes().to_vec(),
counter: 0,
uv: true,
}
}
/// The public key as gunnar stores it: SEC1 uncompressed, 65 bytes.
fn sec1(&self) -> Vec<u8> {
self.key.verifying_key().to_sec1_point(false).as_bytes().to_vec()
}
fn xy(&self) -> (Vec<u8>, Vec<u8>) {
let point = self.key.verifying_key().to_sec1_point(false);
(point.x().expect("x").to_vec(), point.y().expect("y").to_vec())
}
fn client_json(kind: &str, challenge: &str) -> Vec<u8> {
serde_json::json!({ "type": kind, "challenge": challenge, "origin": ORIGIN })
.to_string()
.into_bytes()
}
/// `navigator.credentials.create`: `none` attestation with the COSE key.
fn create(&mut self, challenge: &str) -> (Vec<u8>, String) {
self.counter += 1;
let mut auth_data = Sha256::digest(RP_ID.as_bytes()).to_vec();
auth_data.push(0x01 | 0x04 | 0x40); // UP | UV | AT
auth_data.extend_from_slice(&0u32.to_be_bytes());
auth_data.extend_from_slice(&[0u8; 16]); // aaguid
auth_data.extend_from_slice(&(self.id.len() as u16).to_be_bytes());
auth_data.extend_from_slice(&self.id);
let (x, y) = self.xy();
let cose = ciborium::value::Value::Map(vec![
(1.into(), 2.into()),
(3.into(), (-7).into()),
((-1).into(), 1.into()),
((-2).into(), ciborium::value::Value::Bytes(x)),
((-3).into(), ciborium::value::Value::Bytes(y)),
]);
ciborium::into_writer(&cose, &mut auth_data).expect("cbor");
let object = ciborium::value::Value::Map(vec![
("fmt".into(), "none".into()),
("attStmt".into(), ciborium::value::Value::Map(vec![])),
("authData".into(), ciborium::value::Value::Bytes(auth_data)),
]);
let mut attestation = Vec::new();
ciborium::into_writer(&object, &mut attestation).expect("cbor");
(Self::client_json("webauthn.create", challenge), B64.encode(attestation))
}
/// `navigator.credentials.get`: (clientDataJSON, authenticatorData, DER signature).
fn get(&mut self, challenge: &str) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
self.counter += 1;
let client_data = Self::client_json("webauthn.get", challenge);
let mut auth_data = Sha256::digest(RP_ID.as_bytes()).to_vec();
auth_data.push(if self.uv { 0x01 | 0x04 } else { 0x01 });
auth_data.extend_from_slice(&self.counter.to_be_bytes());
let mut message = auth_data.clone();
message.extend_from_slice(&Sha256::digest(&client_data));
let signature: p256::ecdsa::Signature = self.key.sign(&message);
(client_data, auth_data, signature.to_der().as_bytes().to_vec())
}
/// An assertion as the login page posts it, for `options`.
fn answer(&mut self, options: &serde_json::Value) -> serde_json::Value {
let challenge = options["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
let (client_data, auth_data, signature) = self.get(&challenge);
serde_json::json!({
"ceremony_id": options["ceremony_id"],
"id": B64.encode(&self.id),
"response": {
"clientDataJSON": B64.encode(&client_data),
"authenticatorData": B64.encode(&auth_data),
"signature": B64.encode(&signature),
}
})
}
}
pub(crate) struct Answer {
status: http::StatusCode,
/// `Retry-After`, when the answer names one.
retry_after: Option<String>,
cookie: Option<String>,
body: serde_json::Value,
text: String,
}
impl Rig {
async fn send(
&self,
method: &str,
uri: &str,
bearer: Option<&str>,
cookie: Option<&str>,
body: Option<serde_json::Value>,
) -> Answer {
let mut request = http::Request::builder()
.method(method)
.uri(uri)
.header(http::header::ORIGIN, ORIGIN)
.header(http::header::ACCEPT, "application/json");
if let Some(token) = bearer {
request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
}
if let Some(value) = cookie {
request = request.header(http::header::COOKIE, format!("{}={value}", crate::auth::SESSION_COOKIE));
}
let request = match body {
Some(json) => request
.header(http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(json.to_string())),
None => request.body(axum::body::Body::empty()),
}
.expect("a request");
let response = self.router.clone().oneshot(request).await.expect("the console answers");
let status = response.status();
let retry_after = response.headers().get(http::header::RETRY_AFTER).and_then(|v| v.to_str().ok()).map(str::to_owned);
let cookie = response
.headers()
.get_all(http::header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.filter(|v| !v.contains("Max-Age=0"))
.find_map(|v| {
v.strip_prefix(&format!("{}=", crate::auth::SESSION_COOKIE))
.map(|rest| rest.split(';').next().unwrap_or_default().to_owned())
});
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.expect("a body");
let text = String::from_utf8_lossy(&bytes).into_owned();
Answer { status, retry_after, cookie, body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null), text }
}
/// The login page's `login()`: start, the authenticator, finish.
async fn try_login(&self, yubikey: &mut Yubikey) -> Answer {
let options = self.send("GET", "/auth/passkey/login/start", None, None, None).await;
if options.status != http::StatusCode::OK {
return options;
}
let body = yubikey.answer(&options.body);
self.send("POST", "/auth/passkey/login/finish", None, None, Some(body)).await
}
async fn login(&self, yubikey: &mut Yubikey) -> String {
let answer = self.try_login(yubikey).await;
assert_eq!(answer.status, http::StatusCode::OK, "the passkey did not log in: {}", answer.text);
answer.cookie.expect("a login that worked hands out a session")
}
/// The login page's `register()` with an invite link's secret.
async fn register_with_invite(&self, secret: &str, user: &str, yubikey: &mut Yubikey) -> Answer {
let options = self
.send("GET", &format!("/auth/passkey/register/start?invite={secret}&user={user}"), None, None, None)
.await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
let (client_data, attestation) = yubikey.create(&challenge);
self.send(
"POST",
"/auth/passkey/register/finish",
None,
None,
Some(serde_json::json!({
"ceremony_id": ceremony,
"invite": secret,
"user": user,
"label": "kim's key",
"response": {
"clientDataJSON": B64.encode(&client_data),
"attestationObject": attestation,
},
})),
)
.await
}
/// Nothing about passkeys, sessions or invites lives in the console's own store.
fn console_keeps_no_logins(&self) {
let keys = self.store.auth_keys().expect("the store reads");
for kept in &keys {
assert!(
!(kept == "passkeys" || kept == "session_key" || kept == "session_epoch" || kept.starts_with("session_floor:") || kept.starts_with("web-session:") || kept.starts_with("tenant-identity:") || kept == "invites" || kept == "signups"),
"the console keeps {kept} of its own: {keys:?}"
);
}
}
}
/// The invite secret a minted link carries.
fn secret_of(url: &str) -> String {
url.split("invite=").nth(1).and_then(|r| r.split('&').next()).expect("a secret").to_owned()
}
// ───────────────────────────────────────────────────────────────────────────
// The rows
// ───────────────────────────────────────────────────────────────────────────
/// ★ **A login is the core's verdict, and the session acts as the person.**
/// The ceremony is the core's, the assertion goes to `LoginFinish` byte for
/// byte at the console's relying party, the cookie carries the core's session,
/// and what the person then reads is asked with that session — never a key
/// the console minted, never one it enrolled with its own Admin.
#[tokio::test]
async fn a_login_is_verified_by_the_core_and_the_session_acts_as_the_person() {
let fake = Fake::new();
let mut yubikey = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &yubikey);
let rig = console(fake).await;
let options = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
assert!(rig.fake.heard().contains(&"Identity.LoginStart".to_owned()), "{:?}", rig.fake.heard());
let body = yubikey.answer(&options.body);
let answer = rig.send("POST", "/auth/passkey/login/finish", None, None, Some(body.clone())).await;
assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
assert_eq!(answer.body["user"], "anna", "{}", answer.text);
let cookie = answer.cookie.expect("a session");
assert!(cookie.starts_with(nornir_identity::TOKEN_PREFIX), "the cookie carries the core's token");
let login = lock(&rig.fake.logins)[0].clone();
assert_eq!(login.credential_id, yubikey.id);
assert_eq!(B64.encode(&login.client_data_json), body["response"]["clientDataJSON"].as_str().unwrap(), "the browser's bytes, unchanged");
assert_eq!((login.rp_id.as_str(), login.origin.as_str()), (RP_ID, ORIGIN));
let keys = rig.send("GET", "/api/account/keys", None, Some(&cookie), None).await;
assert_eq!(keys.status, http::StatusCode::OK, "{}", keys.text);
assert!(
rig.fake.heard().contains(&"Access.ListKeys by anna".to_owned()),
"the person's own keys are asked AS anna, with her session: {:?}",
rig.fake.heard()
);
assert!(!rig.fake.heard().iter().any(|h| h.starts_with("Access.AddKey")), "the console enrols no key of its own on anybody: {:?}", rig.fake.heard());
assert!(rig.store.get("tenant-identity:anna").expect("reads").is_none(), "no custody key");
rig.console_keeps_no_logins();
}
/// The credential ids a `login/start` answer offers, as the browser reads them.
fn offered(answer: &Answer) -> Vec<String> {
answer.body["publicKey"]["allowCredentials"]
.as_array()
.unwrap_or_else(|| panic!("no allowCredentials: {}", answer.text))
.iter()
.map(|c| c["id"].as_str().unwrap_or_default().to_owned())
.collect()
}
/// ★ **An anonymous `login/start` offers NOBODY's passkey** (the load-test
/// helper, 2026-10-06): the discoverable-credential ceremony, an empty
/// `allowCredentials`, and gunnar is not even asked for a list.
#[tokio::test]
async fn an_anonymous_login_start_offers_no_ones_passkey_and_lists_nothing() {
let fake = Fake::new();
let mut anna = Yubikey::new(0x41, "annas-yubikey");
for (i, who) in ["anna", "bob", "cleo"].into_iter().enumerate() {
fake.person(who, None);
fake.holds_passkey(who, &Yubikey::new(0x50 + i as u8, &format!("{who}s-key")));
}
fake.holds_passkey("anna", &anna);
let rig = console(fake).await;
let options = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
assert!(offered(&options).is_empty(), "an anonymous login/start offers credential ids: {}", options.text);
assert!(!rig.fake.heard().iter().any(|h| h.starts_with("Access.ListCredentials")), "{:?}", rig.fake.heard());
let answer = rig.try_login(&mut anna).await;
assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
assert_eq!(answer.body["user"], "anna", "{}", answer.text);
}
/// ★ **A34-7: every ceremony names the browser it is for**, so the core holds
/// one browser's open ceremonies apart from everyone else's.
#[tokio::test]
async fn every_ceremony_this_console_asks_names_the_browser() {
let fake = Fake::new();
let mut anna = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &anna);
let rig = console(fake).await;
let answer = rig.try_login(&mut anna).await;
assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
let clients = lock(&rig.fake.starts).clone();
assert!(!clients.is_empty(), "a login asked the core for a ceremony");
assert!(clients.iter().all(|c| !c.trim().is_empty()), "a ceremony named no browser: {clients:?}");
}
/// ★ **The login offer does not grow with the people on the estate.**
#[tokio::test]
async fn the_login_offer_is_the_same_size_for_one_person_and_for_sixty_one() {
let fake = Fake::new();
fake.holds_passkey("anna", &Yubikey::new(0x41, "annas-yubikey"));
let rig = console(fake).await;
let size = |a: &Answer| (offered(a).len(), a.body["publicKey"]["allowCredentials"].to_string().len());
let anonymous_one = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
let named_one = rig.send("GET", "/auth/passkey/login/start?user=anna", None, None, None).await;
for i in 0..60u8 {
rig.fake.holds_passkey(&format!("person{i}"), &Yubikey::new(i.wrapping_add(0x60), &format!("key-of-person-{i:02}")));
}
let anonymous_many = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
let named_many = rig.send("GET", "/auth/passkey/login/start?user=anna", None, None, None).await;
for a in [&anonymous_one, &named_one, &anonymous_many, &named_many] {
assert_eq!(a.status, http::StatusCode::OK, "{}", a.text);
}
assert_eq!(size(&anonymous_one), size(&anonymous_many));
assert_eq!(size(&named_one), size(&named_many));
assert_eq!(offered(&named_many).len(), crate::auth::LOGIN_OFFER, "{}", named_many.text);
}
/// ★ **A named login offers that account's passkeys, and an unknown name the
/// same shape** — the estate kit's path.
#[tokio::test]
async fn a_named_login_offers_that_accounts_passkeys_and_an_unknown_name_the_same_shape() {
let fake = Fake::new();
let mut anna = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &anna);
fake.person("bob", None);
fake.holds_passkey("bob", &Yubikey::new(0x42, "bobs-yubikey"));
let rig = console(fake).await;
let (annas, bobs) = (B64.encode(b"annas-yubikey"), B64.encode(b"bobs-yubikey"));
let named = rig.send("GET", "/auth/passkey/login/start?user=anna", None, None, None).await;
assert_eq!(named.status, http::StatusCode::OK, "{}", named.text);
let ids = offered(&named);
assert!(ids.contains(&annas), "{}", named.text);
assert!(!ids.contains(&bobs), "{}", named.text);
assert_eq!(ids.len(), crate::auth::LOGIN_OFFER, "{}", named.text);
assert!(rig.fake.heard().contains(&"Access.ListCredentials by console".to_owned()), "{:?}", rig.fake.heard());
let unknown = rig.send("GET", "/auth/passkey/login/start?user=nosuch", None, None, None).await;
assert_eq!(unknown.status, http::StatusCode::OK, "{}", unknown.text);
let decoys = offered(&unknown);
assert_eq!(decoys.len(), crate::auth::LOGIN_OFFER, "{}", unknown.text);
assert!(!decoys.contains(&annas) && !decoys.contains(&bobs), "{}", unknown.text);
let again = rig.send("GET", "/auth/passkey/login/start?user=nosuch", None, None, None).await;
assert_eq!(offered(&again), decoys, "an unknown name's offer is as stable as a person's");
let bytes: Vec<Vec<u8>> = ids.iter().map(|id| B64.decode(id).expect("base64url")).collect();
let mut sorted = bytes.clone();
sorted.sort();
assert_eq!(bytes, sorted, "the offer is in one order whatever is real in it");
let body = anna.answer(&named.body);
let answer = rig.send("POST", "/auth/passkey/login/finish", None, None, Some(body)).await;
assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
assert_eq!(answer.body["user"], "anna", "{}", answer.text);
}
/// A login the core refuses hands out nothing.
#[tokio::test]
async fn a_login_the_core_refuses_hands_out_no_session() {
let fake = Fake::new();
let mut yubikey = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &yubikey);
fake.refuse_logins.store(true, Ordering::SeqCst);
let rig = console(fake).await;
let answer = rig.try_login(&mut yubikey).await;
assert_eq!(answer.status, http::StatusCode::UNAUTHORIZED, "{}", answer.text);
assert!(answer.cookie.is_none(), "no session for a refused login");
assert!(rig.fake.heard().contains(&"Identity.LoginFinish".to_owned()));
}
/// ★ **A login while gunnar's data set is grown is 503, never 401**
/// (grow-login-503): the core's STORE refusal is gunnar not finishing the
/// call, never a verdict on the passkey; the same passkey logs in once the set
/// is back.
#[tokio::test]
async fn a_login_while_the_data_set_is_grown_is_unavailable_and_succeeds_after() {
let fake = Fake::new();
let mut yubikey = Yubikey::new(0x46, "annas-yubikey");
fake.holds_passkey("anna", &yubikey);
fake.growing.store(true, Ordering::SeqCst);
let rig = console(fake).await;
let answer = rig.try_login(&mut yubikey).await;
assert_eq!(answer.status, http::StatusCode::SERVICE_UNAVAILABLE, "{}", answer.text);
assert_eq!(answer.text, crate::auth::routes::LOGIN_UNAVAILABLE_WORDS, "the console's words, never gunnar's");
assert!(answer.cookie.is_none());
assert!(answer.retry_after.is_some(), "a 503 says when to ask again");
rig.fake.growing.store(false, Ordering::SeqCst);
let answer = rig.try_login(&mut yubikey).await;
assert_eq!(answer.status, http::StatusCode::OK, "once the set is back: {}", answer.text);
}
/// ★ **Only "these credentials are wrong" is a 401** (grow-login-503, widened
/// after cov-B's UpCloud mockup): every answer that is NOT a refusal of the
/// passkey — a transport failure, gunnar unreachable, anything it could not
/// finish — is a 503 with a Retry-After.
#[tokio::test]
async fn only_a_credential_refusal_is_401_every_other_failure_on_the_login_path_is_503() {
use tonic::Code;
let fake = Fake::new();
let mut yubikey = Yubikey::new(0x47, "annas-yubikey");
fake.holds_passkey("anna", &yubikey);
let rig = console(fake).await;
for (code, message) in [
(Code::Unknown, "h2 protocol error: http2 error"),
(Code::Internal, "h2 protocol error: http2 error"),
(Code::Unavailable, "tcp connect error: Connection refused (os error 111)"),
(Code::DeadlineExceeded, "Timeout expired"),
(Code::Cancelled, "operation was canceled"),
(Code::Aborted, "the login raced another write; ask again"),
(Code::ResourceExhausted, "too many calls"),
] {
*lock(&rig.fake.login_status) = Some((code, message.to_owned()));
let answer = rig.try_login(&mut yubikey).await;
assert_eq!(answer.status, http::StatusCode::SERVICE_UNAVAILABLE, "{code:?} {message}: {}", answer.text);
assert!(answer.retry_after.is_some(), "{code:?}");
assert!(answer.cookie.is_none(), "{code:?}");
}
for (code, message) in [(Code::Unauthenticated, "the signature does not verify"), (Code::PermissionDenied, "the counter went back"), (Code::InvalidArgument, "the assertion is malformed")] {
*lock(&rig.fake.login_status) = Some((code, message.to_owned()));
let answer = rig.try_login(&mut yubikey).await;
assert_eq!(answer.status, http::StatusCode::UNAUTHORIZED, "{code:?}: {}", answer.text);
}
*lock(&rig.fake.login_status) = None;
let answer = rig.try_login(&mut yubikey).await;
assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
}
/// ★ **gunnar not reachable is not "no passkey"** (round 3): a 503, and the
/// same login works once gunnar answers.
#[tokio::test]
async fn a_login_while_gunnar_is_unreachable_is_unavailable_not_no_passkey() {
let fake = Fake::new();
let mut yubikey = Yubikey::new(0x42, "annas-yubikey");
fake.holds_passkey("anna", &yubikey);
fake.unreachable.store(true, Ordering::SeqCst);
let rig = console(fake).await;
let answer = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
assert_eq!(answer.status, http::StatusCode::SERVICE_UNAVAILABLE, "{}", answer.text);
rig.fake.unreachable.store(false, Ordering::SeqCst);
let answer = rig.try_login(&mut yubikey).await;
assert_eq!(answer.status, http::StatusCode::OK, "once gunnar answers: {}", answer.text);
}
/// ★ **A role gunnar cannot tell yet is unavailable, not a viewer**, and not
/// remembered.
#[tokio::test]
async fn a_role_gunnar_cannot_tell_yet_is_unavailable_and_not_remembered() {
let fake = Fake::new();
let mut yubikey = Yubikey::new(0x44, "boss-yubikey");
fake.holds_passkey("boss", &yubikey);
let rig = console(fake).await;
let cookie = rig.login(&mut yubikey).await;
rig.webauthn.forget_role("boss");
// The core still says who the session is; gunnar's Access does not answer.
rig.fake.unreachable.store(true, Ordering::SeqCst);
let answer = rig.send("GET", "/api/dataset", None, Some(&cookie), None).await;
assert!(
answer.status == http::StatusCode::SERVICE_UNAVAILABLE || answer.status == http::StatusCode::UNAUTHORIZED,
"never a viewer's 403: {} {}",
answer.status,
answer.text
);
assert!(!answer.text.contains("your session is viewer"), "{}", answer.text);
rig.fake.unreachable.store(false, Ordering::SeqCst);
let answer = rig.send("GET", "/api/dataset", None, Some(&cookie), None).await;
assert_ne!(answer.status, http::StatusCode::FORBIDDEN, "{}", answer.text);
assert_ne!(answer.status, http::StatusCode::SERVICE_UNAVAILABLE, "{}", answer.text);
}
/// **Logging out ends the person's sessions at the core**, everywhere, and
/// the cookie stops working.
#[tokio::test]
async fn logout_ends_the_persons_sessions_at_the_core() {
let fake = Fake::new();
let mut yubikey = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &yubikey);
let rig = console(fake).await;
let cookie = rig.login(&mut yubikey).await;
let other = rig.login(&mut yubikey).await;
let out = rig.send("POST", "/auth/logout", None, Some(&cookie), None).await;
assert_eq!(out.status, http::StatusCode::OK, "{}", out.text);
assert!(rig.fake.heard().contains(&"Identity.Logout by anna everywhere=true".to_owned()), "{:?}", rig.fake.heard());
for gone in [&cookie, &other] {
let after = rig.send("GET", "/api/account/keys", None, Some(gone), None).await;
assert_eq!(after.status, http::StatusCode::UNAUTHORIZED, "{}", after.text);
}
}
/// ★ **An invite is the core's, minted AS the administrator who invites, and
/// redeemed through the core.** The person is made AS the administrator
/// (gunnar decides), the operator role is their server grant, the link
/// carries the core's secret, and the registration is the core's.
#[tokio::test]
async fn an_invite_is_minted_by_the_administrator_and_redeemed_through_the_core() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
let boss = rig.login(&mut boss_key).await;
let minted = rig
.send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "kim", "role": "operator" })))
.await;
assert_eq!(minted.status, http::StatusCode::OK, "{}", minted.text);
let heard = rig.fake.heard();
assert!(heard.contains(&"Access.AddPrincipal by boss".to_owned()), "{heard:?}");
assert!(heard.contains(&"Access.Grant by boss".to_owned()), "{heard:?}");
assert!(heard.contains(&"Identity.CreateInvite by boss".to_owned()), "{heard:?}");
assert_eq!(lock(&rig.fake.invite_requests).clone(), vec!["kim".to_owned()]);
assert_eq!(lock(&rig.fake.server).get("kim").copied(), Some(pb::GrantLevel::Write as i32), "an operator is server Write");
let url = minted.body["url"].as_str().expect("a link").to_owned();
let secret = secret_of(&url);
let listed = rig.send("GET", "/auth/passkey/invites", None, Some(&boss), None).await;
assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
assert!(rig.fake.heard().contains(&"Identity.ListInvites by boss".to_owned()));
assert_eq!(listed.body["invites"][0]["id"], minted.body["id"], "{}", listed.text);
assert_eq!(listed.body["invites"][0]["user"], "kim");
assert_eq!(listed.body["invites"][0]["role"], "operator");
let mut kim_key = Yubikey::new(0x43, "kims-yubikey");
let registered = rig.register_with_invite(&secret, "kim", &mut kim_key).await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
let register = lock(&rig.fake.registrations)[0].clone();
assert_eq!(register.principal_id, "kim");
assert_eq!(register.credential_id, kim_key.id);
assert_eq!(register.public_key, kim_key.sec1(), "SEC1 uncompressed, 0x04 || X || Y");
assert!(!register.attestation_object.is_empty());
assert_eq!((register.rp_id.as_str(), register.origin.as_str()), (RP_ID, ORIGIN));
let kim = rig.login(&mut kim_key).await;
let status = rig.send("GET", "/auth/status", None, Some(&kim), None).await;
assert_eq!(status.body["user"], "kim", "{}", status.text);
rig.console_keeps_no_logins();
}
/// ★ **GHUNT-16 (lane OX-GUI16, 2026-10-28): a passkey keeps the name its person gave it.**
/// Since IDENTITY-HOST the core stores the passkey (`RedeemFinish`, which carries no label) and
/// gunnar files it as "passkey", so the name typed on the login page was lost on the way
/// (gunnar-ui uitest `the-session-lists-the-credential-it-logged-in-with`: "passkey" for the
/// "virtual" one). The label is gunnar's, not the core's: the console names the new passkey at
/// gunnar (`Access.RenameCredential`) AS the person, with the session the core just minted.
#[tokio::test]
async fn a_registered_passkey_keeps_the_name_its_person_gave_it() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
let boss = rig.login(&mut boss_key).await;
let minted = rig
.send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "kim", "role": "operator" })))
.await;
assert_eq!(minted.status, http::StatusCode::OK, "{}", minted.text);
let secret = secret_of(minted.body["url"].as_str().expect("a link"));
let mut kim_key = Yubikey::new(0x43, "kims-yubikey");
let registered = rig.register_with_invite(&secret, "kim", &mut kim_key).await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
let kim = registered.cookie.clone().expect("the registration hands out the person's session");
assert!(rig.fake.heard().contains(&"Access.RenameCredential by kim".to_owned()), "named AS kim: {:?}", rig.fake.heard());
let listed = rig.send("GET", "/auth/passkey/credentials", None, Some(&kim), None).await;
assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
let own = listed.body["credentials"]
.as_array()
.expect("credentials")
.iter()
.find(|c| c["id"] == B64.encode(&kim_key.id))
.unwrap_or_else(|| panic!("kim's passkey is not listed: {}", listed.text))
.clone();
assert_eq!(own["label"], "kim's key", "{}", listed.text);
}
/// A registration that names no passkey keeps gunnar's own word, and asks gunnar nothing more.
#[tokio::test]
async fn a_registered_passkey_with_no_name_is_not_renamed() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
let boss = rig.login(&mut boss_key).await;
let minted = rig
.send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "kim", "role": "operator" })))
.await;
let secret = secret_of(minted.body["url"].as_str().expect("a link"));
let mut kim_key = Yubikey::new(0x43, "kims-yubikey");
let options = rig.send("GET", &format!("/auth/passkey/register/start?invite={secret}&user=kim"), None, None, None).await;
let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
let (client_data, attestation) = kim_key.create(&challenge);
let registered = rig
.send(
"POST",
"/auth/passkey/register/finish",
None,
None,
Some(serde_json::json!({
"ceremony_id": ceremony,
"invite": secret,
"user": "kim",
"response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
})),
)
.await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
assert!(!rig.fake.heard().iter().any(|h| h.starts_with("Access.RenameCredential")), "{:?}", rig.fake.heard());
let held = lock(&rig.fake.credentials).iter().find(|(c, _)| c.credential_id == kim_key.id).map(|(c, _)| c.label.clone());
assert_eq!(held.as_deref(), Some("passkey"));
}
/// ★ **GHUNT-11 (lane OX-GUNHUNT, 2026-10-28): an invite never makes an admin.** Before
/// IDENTITY-HOST the role travelled as the invite's server level and gunnar refused ADMIN
/// (`invite_would_be_admin`). Since 7a0c3a8 the console grants the asked role itself
/// (`Access.Grant`, as the inviter) BEFORE the core mints the link, so a link for
/// `role: admin` made its holder a server admin, and the break-glass token minted such links
/// too (gunnar-ui uitest `an-invite-cannot-grant-admin`: 200 where it was 400). Refused by
/// name, before anything is made or granted.
#[tokio::test]
async fn an_invite_for_the_admin_role_is_refused_and_grants_nothing() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
let boss = rig.login(&mut boss_key).await;
let refused = rig
.send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "zed", "role": "admin" })))
.await;
assert_eq!(refused.status, http::StatusCode::BAD_REQUEST, "{}", refused.text);
assert!(refused.text.contains("never makes an admin"), "{}", refused.text);
let heard = rig.fake.heard();
for asked in ["Access.Grant", "Access.AddPrincipal", "Identity.CreateInvite", "Access.EnrolmentLink"] {
assert!(!heard.iter().any(|h| h.starts_with(asked)), "{asked} was asked for an admin invite: {heard:?}");
}
assert_eq!(lock(&rig.fake.server).get("zed").copied(), None, "no server grant for zed");
rig.console_keeps_no_logins();
}
/// ★ **A login asks for a verified user** (owner 2026-10-11, usernameless
/// passkeys): the options require it.
#[tokio::test]
async fn a_login_asks_for_a_verified_user() {
let rig = console(Fake::new()).await;
let options = rig.send("GET", "/auth/passkey/login/start", None, None, None).await;
assert_eq!(options.body["publicKey"]["userVerification"], "required", "{}", options.text);
}
/// ★ **A passkey an admin registers in person is their OWN** (owner
/// 2026-10-11): neither a `user` nor a `role` in the body is read; the core
/// mints a link onto the stepped-up caller and redeems it.
#[tokio::test]
async fn a_passkey_an_admin_registers_in_person_is_their_own() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
let boss = rig.login(&mut boss_key).await;
let options = rig.send("GET", "/auth/passkey/register/start", None, Some(&boss), None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
assert_eq!(options.body["publicKey"]["user"]["name"], "boss", "the key shows whose it is: {}", options.text);
let excluded: Vec<&str> = options.body["publicKey"]["excludeCredentials"].as_array().expect("a list").iter().filter_map(|c| c["id"].as_str()).collect();
assert_eq!(excluded, vec![B64.encode(&boss_key.id)], "the passkey boss holds is excluded: {}", options.text);
let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
let mut spare = Yubikey::new(0x44, "boss-spare");
let (client_data, attestation) = spare.create(&challenge);
let answer = rig
.send(
"POST",
"/auth/passkey/register/finish",
None,
Some(&boss),
Some(serde_json::json!({
"ceremony_id": ceremony,
"user": "kim",
"role": "operator",
"label": "spare",
"response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
})),
)
.await;
assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
let heard = rig.fake.heard();
assert!(!heard.iter().any(|h| h.starts_with("Access.AddPrincipal")), "a name in the body made an account: {heard:?}");
assert!(!heard.iter().any(|h| h.starts_with("Access.Grant")), "a role in the body was granted: {heard:?}");
assert_eq!(lock(&rig.fake.invite_requests).clone(), vec!["boss".to_owned()], "the key is the caller's own");
let spare_in = rig.login(&mut spare).await;
let status = rig.send("GET", "/auth/status", None, Some(&spare_in), None).await;
assert_eq!(status.body["user"], "boss", "{}", status.text);
}
/// Withdrawing an invite, and listing and revoking passkeys, are asked AS the
/// administrator; a revoked passkey ends its person's sessions (gunnar's).
#[tokio::test]
async fn invites_and_passkeys_are_withdrawn_as_the_administrator() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let mut annas = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &annas);
let rig = console(fake).await;
let boss = rig.login(&mut boss_key).await;
let anna = rig.login(&mut annas).await;
let minted = rig
.send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "anna", "role": "viewer" })))
.await;
assert_eq!(minted.status, http::StatusCode::OK, "{}", minted.text);
assert!(!rig.fake.heard().contains(&"Access.AddPrincipal by boss".to_owned()), "anna exists: no principal is made");
let withdrawn = rig
.send("POST", "/auth/passkey/invites/revoke", None, Some(&boss), Some(serde_json::json!({ "id": minted.body["id"] })))
.await;
assert_eq!(withdrawn.status, http::StatusCode::OK, "{}", withdrawn.text);
assert!(rig.fake.heard().contains(&"Identity.RevokeInvite by boss".to_owned()));
let listed = rig.send("GET", "/auth/passkey/invites", None, Some(&boss), None).await;
assert_eq!(listed.body["invites"], serde_json::json!([]), "{}", listed.text);
let listed = rig.send("GET", "/auth/passkey/credentials", None, Some(&boss), None).await;
assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
assert!(rig.fake.heard().contains(&"Access.ListCredentials by boss".to_owned()));
let annas_id = B64.encode(&annas.id);
assert!(listed.text.contains(&annas_id), "{}", listed.text);
let revoked = rig
.send("POST", "/auth/passkey/credentials/revoke", None, Some(&boss), Some(serde_json::json!({ "id": annas_id })))
.await;
assert_eq!(revoked.status, http::StatusCode::OK, "{}", revoked.text);
assert!(rig.fake.heard().contains(&"Access.RevokeCredential by boss".to_owned()));
assert_eq!(*lock(&rig.fake.revoked_credentials), vec![annas.id.clone()]);
let after = rig.send("GET", "/api/account/keys", None, Some(&anna), None).await;
assert_eq!(after.status, http::StatusCode::UNAUTHORIZED, "anna's session outlived her passkey: {}", after.text);
}
/// ★ **An account admin's employee invite is gunnar's**, minted in the SAME
/// call (`EnrollEmployee{invite}`) — the core's link onto the employee.
#[tokio::test]
async fn an_employee_invite_is_created_by_the_account_admin_at_gunnar() {
let fake = Fake::new();
let mut acme_key = Yubikey::new(0x44, "acme-yubikey");
fake.holds_passkey("acme", &acme_key);
let rig = console(fake).await;
let acme = rig.login(&mut acme_key).await;
let enrolled = rig
.send("POST", "/api/account/employees", None, Some(&acme), Some(serde_json::json!({ "account": "acme", "name": "olle" })))
.await;
assert_eq!(enrolled.status, http::StatusCode::OK, "{}", enrolled.text);
let heard = rig.fake.heard();
assert!(heard.contains(&"Accounts.EnrollEmployee by acme".to_owned()), "{heard:?}");
assert!(!heard.iter().any(|h| h.starts_with("Identity.CreateInvite")), "a second call for the invite: {heard:?}");
assert_eq!(lock(&rig.fake.invite_requests).clone(), vec!["olle".to_owned()]);
let url = enrolled.body["url"].as_str().unwrap_or_default().to_owned();
let mut olle = Yubikey::new(0x48, "olles-yubikey");
let registered = rig.register_with_invite(&secret_of(&url), "olle", &mut olle).await;
assert_eq!(registered.status, http::StatusCode::OK, "the core's link registers olle: {}", registered.text);
rig.console_keeps_no_logins();
}
/// ★ **A paid signup's passkey is registered through the core on the account
/// monetize says the checkout paid for**: gunnar mints the account's first
/// login link (`Access.EnrolmentLink`, first only), the core redeems it, and a
/// second registration through the same link is refused (the account holds a
/// passkey now).
#[cfg(feature = "metered")]
#[tokio::test]
async fn a_paid_signups_passkey_is_registered_on_the_account_monetize_names() {
use crate::auth::signup::tests::{FakeMonetize, SESSION};
let fake = Fake::new();
fake.person("bob", None);
let rig = console_selling(fake, Some(FakeMonetize::created("bob"))).await;
let mut bobs = Yubikey::new(0x45, "bobs-yubikey");
let options = rig.send("GET", &format!("/auth/passkey/register/start?signup={SESSION}&user=bob"), None, None, None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
assert!(rig.fake.heard().contains(&"Access.EnrolmentLink by console".to_owned()), "{:?}", rig.fake.heard());
let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
let (client_data, attestation) = bobs.create(&challenge);
let registered = rig
.send(
"POST",
"/auth/passkey/register/finish",
None,
None,
Some(serde_json::json!({
"ceremony_id": ceremony,
"signup": SESSION,
"user": "bob",
"label": "bob's key",
"response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
})),
)
.await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
assert_eq!(lock(&rig.fake.registrations)[0].principal_id, "bob", "the account monetize named");
rig.console_keeps_no_logins();
let issued = rig.fake.issued.load(Ordering::SeqCst);
let again = rig.send("GET", &format!("/auth/passkey/register/start?signup={SESSION}&user=bob"), None, None, None).await;
assert_eq!(again.status, http::StatusCode::CONFLICT, "{}", again.text);
assert_eq!(rig.fake.issued.load(Ordering::SeqCst), issued, "no ceremony for a door that is shut");
assert_eq!(lock(&rig.fake.registrations).len(), 1);
}
/// A registration's start is a door for anonymous visitors only with a token
/// that could open it; anything else is refused before the core is asked.
#[tokio::test]
async fn an_invite_that_cannot_be_one_opens_no_ceremony() {
let rig = console(Fake::new()).await;
let too_long = "a".repeat(300);
for invite in ["", "%20", "%3Cscript%3E", "not%20a%20secret", too_long.as_str()] {
let options = rig.send("GET", &format!("/auth/passkey/register/start?invite={invite}&user=bob"), None, None, None).await;
assert_eq!(options.status, http::StatusCode::BAD_REQUEST, "invite {invite:?}: {}", options.text);
}
assert_eq!(rig.fake.issued.load(Ordering::SeqCst), 0, "no ceremony was asked of the core");
}
/// ★ **An invite registers ONE passkey** (gunnar.rs 2026-10-19): after the
/// first registration the invite is refused at the START — before the browser
/// makes anything — and at a finish, in plain words (410); a fresh invite for
/// the same person EXCLUDES the passkey they hold; and a login with a passkey
/// gunnar does not hold is said machine-readably.
#[tokio::test]
async fn a_spent_invite_is_refused_before_any_passkey_is_made() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
let boss = rig.login(&mut boss_key).await;
let mint = || async {
let minted = rig
.send("POST", "/auth/passkey/invites", None, Some(&boss), Some(serde_json::json!({ "user": "henrik", "role": "operator" })))
.await;
assert_eq!(minted.status, http::StatusCode::OK, "{}", minted.text);
secret_of(minted.body["url"].as_str().expect("a link"))
};
let secret = mint().await;
let mut henrik_key = Yubikey::new(0x44, "henriks-yubikey");
// A ceremony opened before the first registration finished (a second tab).
let early = rig.send("GET", &format!("/auth/passkey/register/start?invite={secret}&user=henrik"), None, None, None).await;
assert_eq!(early.status, http::StatusCode::OK, "{}", early.text);
let first = rig.register_with_invite(&secret, "henrik", &mut henrik_key).await;
assert_eq!(first.status, http::StatusCode::OK, "{}", first.text);
let again = rig.send("GET", &format!("/auth/passkey/register/start?invite={secret}&user=henrik"), None, None, None).await;
assert_eq!(again.status, http::StatusCode::GONE, "{}", again.text);
assert_eq!(again.text, crate::auth::INVITE_SPENT_WORDS);
// The early ceremony's finish is refused too: the core spent the invite.
let mut second_key = Yubikey::new(0x45, "henriks-second");
let challenge = early.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
let (client_data, attestation) = second_key.create(&challenge);
let late = rig
.send(
"POST",
"/auth/passkey/register/finish",
None,
None,
Some(serde_json::json!({
"ceremony_id": early.body["ceremony_id"],
"invite": secret,
"response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
})),
)
.await;
assert_eq!(late.status, http::StatusCode::GONE, "{}", late.text);
assert_eq!(lock(&rig.fake.registrations).len(), 1, "one passkey for one invite");
// An invite nobody made: refused at the start.
let unknown = rig.send("GET", "/auth/passkey/register/start?invite=unknown-invite-secret-0", None, None, None).await;
assert_eq!(unknown.status, http::StatusCode::GONE, "{}", unknown.text);
// A fresh invite for henrik excludes the passkey he holds.
let fresh = mint().await;
let options = rig.send("GET", &format!("/auth/passkey/register/start?invite={fresh}&user=somebody"), None, None, None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
let excluded: Vec<&str> = options.body["publicKey"]["excludeCredentials"].as_array().expect("a list").iter().filter_map(|c| c["id"].as_str()).collect();
assert_eq!(excluded, vec![B64.encode(&henrik_key.id)], "{}", options.text);
assert_eq!(options.body["publicKey"]["user"]["name"], "henrik", "the key shows gunnar's person: {}", options.text);
// A passkey gunnar does not hold: said machine-readably.
let answer = rig.try_login(&mut second_key).await;
assert_eq!(answer.status, http::StatusCode::UNAUTHORIZED, "{}", answer.text);
assert_eq!(answer.body["code"], "unknowncredential", "{}", answer.text);
assert!(answer.cookie.is_none());
}
/// A paid signup's start is asked of monetize first: a session it does not
/// know, or one whose account holds a passkey already, opens no ceremony.
#[cfg(feature = "metered")]
#[tokio::test]
async fn a_signup_that_cannot_register_opens_no_ceremony() {
use crate::auth::signup::tests::{FakeMonetize, SESSION};
let rig = console_selling(Fake::new(), Some(FakeMonetize::unknown())).await;
for signup in ["", "garbage", SESSION] {
let options = rig.send("GET", &format!("/auth/passkey/register/start?signup={signup}&user=bob"), None, None, None).await;
assert!(options.status.is_client_error(), "signup {signup:?}: {} {}", options.status, options.text);
}
assert_eq!(rig.fake.issued.load(Ordering::SeqCst), 0);
let fake = Fake::new();
fake.person("bob", None);
fake.holds_passkey("bob", &Yubikey::new(0x45, "bobs-yubikey"));
let rig = console_selling(fake, Some(FakeMonetize::created("bob"))).await;
let options = rig.send("GET", &format!("/auth/passkey/register/start?signup={SESSION}&user=bob"), None, None, None).await;
assert_eq!(options.status, http::StatusCode::CONFLICT, "{}", options.text);
assert_eq!(rig.fake.issued.load(Ordering::SeqCst), 0);
}
/// ★ **One paid signup registers one passkey, however many ask at once**
/// (stinky 2026-10-10 #3): the second finish is a 409, and the core hears one.
#[cfg(feature = "metered")]
#[tokio::test]
async fn two_finishes_of_one_paid_signup_at_once_register_one_passkey() {
use crate::auth::signup::tests::{FakeMonetize, SESSION};
let fake = Fake::new();
fake.person("bob", None);
*lock(&fake.register_delay) = Some(std::time::Duration::from_millis(300));
let rig = console_selling(fake, Some(FakeMonetize::created("bob"))).await;
let mut finishes = Vec::new();
for (seed, label) in [(0x45, "one"), (0x46, "two")] {
let mut key = Yubikey::new(seed, label);
let options = rig.send("GET", &format!("/auth/passkey/register/start?signup={SESSION}&user=bob"), None, None, None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
let (client_data, attestation) = key.create(&challenge);
finishes.push(serde_json::json!({
"ceremony_id": ceremony,
"signup": SESSION,
"user": "bob",
"label": label,
"response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
}));
}
let (a, b) = tokio::join!(
rig.send("POST", "/auth/passkey/register/finish", None, None, Some(finishes[0].clone())),
rig.send("POST", "/auth/passkey/register/finish", None, None, Some(finishes[1].clone())),
);
let mut statuses = [a.status, b.status];
statuses.sort();
assert_eq!(statuses, [http::StatusCode::OK, http::StatusCode::CONFLICT], "{} / {}", a.text, b.text);
assert_eq!(lock(&rig.fake.registrations).len(), 1, "the core registered one passkey");
}
/// ★ **A paid signup's registration spends the poll's budget** (stinky
/// 2026-10-10 #3).
#[cfg(feature = "metered")]
#[tokio::test]
async fn a_paid_signups_registration_spends_the_polls_budget() {
use crate::auth::signup::tests::FakeMonetize;
let monetize = FakeMonetize::created("bob");
let asked = monetize.calls.clone();
let rig = console_selling(Fake::new(), Some(monetize)).await;
rig.webauthn.freeze_signup_clock();
let mut refused = 0;
for n in 0..40 {
let session = format!("cs_test_{n:024}");
let finish = rig
.send("POST", "/auth/passkey/register/finish", None, None, Some(serde_json::json!({ "signup": session, "ceremony_id": "nothing", "response": {} })))
.await;
if finish.status == http::StatusCode::TOO_MANY_REQUESTS {
refused += 1;
}
}
assert_eq!(refused, 10, "{refused} of 40 new sessions from one address were refused");
let statuses = asked.lock().expect("the call log").iter().filter(|c| c.starts_with("Signup.Status")).count();
assert!(statuses <= 35, "monetize was asked {statuses} times");
}
// ───────────────────────────────────────────────────────────────────────────
// A machine asks; a person approves; gunnar decides (2026-10-05)
// ───────────────────────────────────────────────────────────────────────────
impl Rig {
/// The kit's request: the creation ceremony, anonymously, as the kit sends it.
async fn kit_asks(&self, kit: &mut Yubikey, machine: &str, role: &str) -> Answer {
let options = self.send("GET", &format!("/auth/machine/enrol/start?machine={machine}"), None, None, None).await;
assert_eq!(options.status, http::StatusCode::OK, "the kit opens its ceremony anonymously: {}", options.text);
let ceremony = options.body["ceremony_id"].as_str().expect("a ceremony").to_owned();
let challenge = options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned();
let (client_data, attestation) = kit.create(&challenge);
self.send(
"POST",
"/auth/machine/enrol",
None,
None,
Some(serde_json::json!({
"ceremony_id": ceremony,
"machine": machine,
"label": format!("installer kit on {}", machine.trim_start_matches("kit-")),
"role": role,
"response": { "clientDataJSON": B64.encode(&client_data), "attestationObject": attestation },
})),
)
.await
}
/// The Approve button: the core's step-up options, the approver's key,
/// the approval — as the Identities page sends them.
async fn approve_with_step_up(&self, cookie: &str, approver: &mut Yubikey, id: &str, code: &str) -> Answer {
let options = self.send("GET", "/auth/machine/step-up", None, Some(cookie), None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
assert_eq!(options.body["publicKey"]["userVerification"], "required", "{}", options.text);
let mut body = approver.answer(&options.body);
body["code"] = code.into();
self.send("POST", &format!("/auth/machine/requests/{id}/approve"), None, Some(cookie), Some(body)).await
}
}
/// ★ **A machine asks anonymously and the console only relays**: gunnar's
/// challenge (`Access.MachineChallenge`, the console's key), the request with
/// the console's key, gunnar's row, the kit's anonymous poll.
#[tokio::test]
async fn a_machine_asks_anonymously_and_the_console_only_relays_to_gunnar() {
let rig = console(Fake::new()).await;
let mut kit = Yubikey::new(0x51, "kit-dwarf-key");
let asked = rig.kit_asks(&mut kit, "kit-dwarf", "admin").await;
assert_eq!(asked.status, http::StatusCode::OK, "{}", asked.text);
assert_eq!((asked.body["state"].as_str(), asked.body["code"].as_str(), asked.body["machine"].as_str()), (Some("pending"), Some("ABC-120"), Some("kit-dwarf")), "{}", asked.text);
let heard = rig.fake.heard();
assert!(heard.contains(&"Access.MachineChallenge by console".to_owned()), "{heard:?}");
assert!(heard.contains(&"Access.RequestMachineEnrolment by console".to_owned()), "{heard:?}");
let relayed = lock(&rig.fake.enrol_requests)[0].clone();
assert_eq!(relayed.credential_id, kit.id);
assert_eq!(relayed.public_key, kit.sec1());
assert!(!relayed.attestation_object.is_empty());
assert_eq!(relayed.server_level, pb::GrantLevel::Admin as i32);
assert_eq!((relayed.rp_id.as_str(), relayed.origin.as_str()), (RP_ID, ORIGIN));
assert_eq!(relayed.label, "installer kit on dwarf");
let id = asked.body["id"].as_str().expect("an id");
let polled = rig.send("GET", &format!("/auth/machine/enrol/{id}"), None, None, None).await;
assert_eq!(polled.status, http::StatusCode::OK, "{}", polled.text);
assert_eq!(polled.body["state"], "pending");
let mut ops = Yubikey::new(0x52, "kit-ops-key");
let asked = rig.kit_asks(&mut ops, "kit-ops", "operator").await;
assert_eq!(asked.status, http::StatusCode::OK, "{}", asked.text);
assert_eq!(lock(&rig.fake.enrol_requests)[1].server_level, pb::GrantLevel::Write as i32);
rig.console_keeps_no_logins();
}
/// ★ **The admin lists, approves from her own stepped-up session, and
/// refuses — each AS HERSELF, each decided by gunnar**; the kit then logs in
/// through the core as the machine gunnar made.
#[tokio::test]
async fn the_admin_approves_with_her_own_step_up_and_the_kit_logs_in_as_its_machine() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let mut anna_key = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &anna_key);
let rig = console(fake).await;
let mut kit = Yubikey::new(0x51, "kit-dwarf-key");
let asked = rig.kit_asks(&mut kit, "kit-dwarf", "admin").await;
let (id, code) = (asked.body["id"].as_str().expect("id").to_owned(), asked.body["code"].as_str().expect("code").to_owned());
boss_key.uv = false;
let boss = rig.login(&mut boss_key).await;
boss_key.uv = true;
let listed = rig.send("GET", "/auth/machine/requests", None, Some(&boss), None).await;
assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
assert_eq!(listed.body["requests"][0]["code"], code.as_str(), "{}", listed.text);
assert!(rig.fake.heard().contains(&"Access.ListMachineEnrolments by boss".to_owned()), "{:?}", rig.fake.heard());
let options = rig.send("GET", "/auth/machine/step-up", None, Some(&boss), None).await;
let allowed: Vec<String> = options.body["publicKey"]["allowCredentials"].as_array().expect("allowed").iter().map(|c| c["id"].as_str().unwrap_or_default().to_owned()).collect();
assert_eq!(allowed, vec![B64.encode(&boss_key.id)], "only boss's own passkey steps boss up: {}", options.text);
assert!(rig.fake.heard().contains(&"Identity.StepUpStart".to_owned()));
let approved = rig.approve_with_step_up(&boss, &mut boss_key, &id, &code).await;
assert_eq!(approved.status, http::StatusCode::OK, "{}", approved.text);
assert_eq!(approved.body["machine"], "kit-dwarf", "{}", approved.text);
assert!(rig.fake.heard().contains(&"Identity.StepUpFinish".to_owned()));
let (by, stepped_up, relayed) = lock(&rig.fake.approvals).last().cloned().expect("an approval");
assert_eq!(by, "boss", "the approval is AS boss, never the console");
assert!(stepped_up, "boss's own session stepped up for it");
assert_eq!(relayed.code, code);
let answer = rig.try_login(&mut kit).await;
assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
assert_eq!((answer.body["user"].as_str(), answer.body["role"].as_str()), (Some("kit-dwarf"), Some("admin")), "{}", answer.text);
let mut other = Yubikey::new(0x53, "kit-other-key");
let second = rig.kit_asks(&mut other, "kit-other", "admin").await;
let refused = rig.send("POST", &format!("/auth/machine/requests/{}/refuse", second.body["id"].as_str().expect("id")), None, Some(&boss), None).await;
assert_eq!(refused.status, http::StatusCode::OK, "{}", refused.text);
assert_eq!(refused.body["request"]["state"], "refused");
let anna = rig.login(&mut anna_key).await;
let listed = rig.send("GET", "/auth/machine/requests", None, Some(&anna), None).await;
assert_eq!(listed.status, http::StatusCode::FORBIDDEN, "{}", listed.text);
assert!(rig.fake.heard().contains(&"Access.ListMachineEnrolments by anna".to_owned()), "the refusal is gunnar's: {:?}", rig.fake.heard());
rig.console_keeps_no_logins();
}
/// An approval from a session that did not step up is gunnar's `[stepup]`,
/// answered as the console's in-place step-up refusal.
#[tokio::test]
async fn an_approval_without_a_step_up_is_the_in_place_step_up_refusal() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
let mut kit = Yubikey::new(0x51, "kit-dwarf-key");
let asked = rig.kit_asks(&mut kit, "kit-dwarf", "admin").await;
boss_key.uv = false;
let boss = rig.login(&mut boss_key).await;
let refused = rig
.send("POST", &format!("/auth/machine/requests/{}/approve", asked.body["id"].as_str().unwrap()), None, Some(&boss), Some(serde_json::json!({ "code": asked.body["code"] })))
.await;
assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
assert_eq!(refused.body["code"], crate::auth::STEP_UP_CODE, "{}", refused.text);
}
impl Rig {
/// The web console's answer to `"code":"stepup"`: the core's options, the
/// person's touch, the finish — with the session it already has.
async fn step_up(&self, cookie: &str, yubikey: &mut Yubikey) -> Answer {
let options = self.send("GET", "/auth/passkey/step-up", None, Some(cookie), None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
assert_eq!(options.body["publicKey"]["userVerification"], "required");
let body = yubikey.answer(&options.body);
self.send("POST", "/auth/passkey/step-up", None, Some(cookie), Some(body)).await
}
}
/// ★ **A stale touch is answered in place** (owner 2026-10-19): `"code":
/// "stepup"`, a touch of the person's OWN key at `/auth/passkey/step-up`
/// steps the SAME session up at the core, and the refused call then goes
/// through. Another person's key steps nobody up; nobody signed in is told to
/// log in.
#[tokio::test]
async fn a_stale_touch_is_stepped_up_in_place_and_the_call_goes_through() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let mut anna_key = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &anna_key);
let rig = console(fake).await;
boss_key.uv = false;
let stale = rig.login(&mut boss_key).await;
let invite = || Some(serde_json::json!({ "user": "kim", "role": "operator" }));
let refused = rig.send("POST", "/auth/passkey/invites", None, Some(&stale), invite()).await;
assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
assert_eq!(refused.body["code"], crate::auth::STEP_UP_CODE, "machine-readable: {}", refused.text);
assert!(!refused.text.to_ascii_lowercase().contains("role"), "{}", refused.text);
let wrong = rig.step_up(&stale, &mut anna_key).await;
assert!(!wrong.status.is_success(), "another person's key steps boss up: {}", wrong.text);
let nobody = rig.send("GET", "/auth/passkey/step-up", None, None, None).await;
assert_eq!(nobody.status, http::StatusCode::UNAUTHORIZED, "{}", nobody.text);
boss_key.uv = true;
let up = rig.step_up(&stale, &mut boss_key).await;
assert_eq!(up.status, http::StatusCode::OK, "{}", up.text);
assert_eq!(up.body["user"], "boss", "{}", up.text);
let minted = rig.send("POST", "/auth/passkey/invites", None, Some(&stale), invite()).await;
assert_eq!(minted.status, http::StatusCode::OK, "the same session, stepped up: {}", minted.text);
rig.console_keeps_no_logins();
}
/// ★ **RA5-2 (nornir-identity 0.2.2): every invite needs a fresh step-up at the core.** The
/// core refuses `CreateInvite` with `needs_step_up` when the session's touch is no longer
/// fresh by ITS clock, even where the console's own check let the call through. The console
/// answers it as the in-place step-up (`"code":"stepup"`, which the web answers with a touch
/// and the same call once more), and a refused invite leaves nothing behind: no account made
/// for it, and no server grant for the person it named. After a touch of the inviter's own
/// key the same call mints the invite and grants the role.
#[tokio::test]
async fn an_invite_the_core_wants_a_fresh_step_up_for_asks_for_one_grants_nothing_then_works() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
let boss = rig.login(&mut boss_key).await;
rig.fake.stale_at_core.store(true, Ordering::SeqCst);
let for_anna = || Some(serde_json::json!({ "user": "anna", "role": "operator" }));
let refused = rig.send("POST", "/auth/passkey/invites", None, Some(&boss), for_anna()).await;
assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
assert_eq!(refused.body["code"], crate::auth::STEP_UP_CODE, "the in-place step-up: {}", refused.text);
assert!(rig.fake.heard().contains(&"Identity.CreateInvite by boss".to_owned()), "the core was asked: {:?}", rig.fake.heard());
assert_eq!(lock(&rig.fake.server).get("anna").copied(), None, "a refused invite grants anna nothing");
let for_kim = || Some(serde_json::json!({ "user": "kim", "role": "viewer" }));
let refused = rig.send("POST", "/auth/passkey/invites", None, Some(&boss), for_kim()).await;
assert_eq!(refused.body["code"], crate::auth::STEP_UP_CODE, "{}", refused.text);
assert!(!lock(&rig.fake.principals).contains_key("kim"), "a refused invite leaves no account behind");
let up = rig.step_up(&boss, &mut boss_key).await;
assert_eq!(up.status, http::StatusCode::OK, "{}", up.text);
let minted = rig.send("POST", "/auth/passkey/invites", None, Some(&boss), for_anna()).await;
assert_eq!(minted.status, http::StatusCode::OK, "after the touch: {}", minted.text);
assert_eq!(lock(&rig.fake.invite_requests).clone(), vec!["anna".to_owned()]);
assert_eq!(lock(&rig.fake.server).get("anna").copied(), Some(pb::GrantLevel::Write as i32), "the operator role, now the invite exists");
rig.console_keeps_no_logins();
}
/// ★ **The break-glass bearer approves nothing**: it is the OWNER's first
/// login link. The console turns it away, relays nothing, and the bearer stays
/// the owner's.
#[tokio::test]
async fn the_break_glass_bearer_approves_nothing_it_is_the_owners_first_invite() {
let rig = console(Fake::new()).await;
let mut kit = Yubikey::new(0x51, "kit-fresh-key");
let asked = rig.kit_asks(&mut kit, "kit-fresh", "admin").await;
let (id, code) = (asked.body["id"].as_str().expect("id").to_owned(), asked.body["code"].as_str().expect("code").to_owned());
let refused = rig
.send("POST", &format!("/auth/machine/requests/{id}/approve"), Some("break-glass"), None, Some(serde_json::json!({ "code": code })))
.await;
assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
assert!(refused.text.contains("first invite"), "{}", refused.text);
assert!(!rig.fake.heard().iter().any(|h| h.starts_with("Access.ApproveMachineEnrolment")), "{:?}", rig.fake.heard());
let status = rig.send("GET", "/auth/status", None, None, None).await;
assert_eq!(status.body["bootstrap_spent"], false, "{}", status.text);
}
/// ★ **A session bound to no principal lists nobody's passkeys** (stinky
/// 2026-10-10 #10).
#[tokio::test]
async fn a_session_bound_to_no_principal_lists_nobodys_passkeys() {
let fake = Fake::new();
let annas = Yubikey::new(0x46, "annas-yubikey");
fake.holds_passkey("anna", &annas);
let rig = console(fake).await;
let olle = rig.webauthn.make_session("olle", crate::role::Role::Operator, true);
let listed = rig.send("GET", "/auth/passkey/credentials", None, Some(&olle), None).await;
assert!(!listed.status.is_success(), "{}", listed.text);
assert!(!listed.text.contains("anna"), "{}", listed.text);
assert!(!rig.fake.heard().iter().any(|c| c.starts_with("Access.ListCredentials")), "{:?}", rig.fake.heard());
}
// ───────────────────────────────────────────────────────────────────────────
// `gunnar login`: the CLI's browser device flow (5.4 §3b)
// ───────────────────────────────────────────────────────────────────────────
/// A key the CLI minted for itself: (private OpenSSH, public line).
fn cli_key() -> (String, String) {
let private = crate::reviewer::mint_ed25519().expect("a key");
let public = crate::signer::Signer::from_openssh(&private).expect("a signer").public_key_openssh();
(private, public)
}
impl Rig {
async fn device_start(&self, public: &str, ttl_secs: Option<u64>) -> (String, String) {
let mut body = serde_json::json!({ "public_key": public, "label": "anna's laptop" });
if let Some(ttl) = ttl_secs {
body["ttl_secs"] = ttl.into();
}
let started = self.send("POST", "/auth/device/start", None, None, Some(body)).await;
assert_eq!(started.status, http::StatusCode::OK, "{}", started.text);
assert_eq!(started.body["verification_uri"], format!("{ORIGIN}/login/device"), "{}", started.text);
let device = started.body["device_code"].as_str().expect("a device code").to_owned();
let user = started.body["user_code"].as_str().expect("a user code").to_owned();
assert!(device.len() >= 32, "{device}");
assert_ne!(device, user);
(device, user)
}
async fn device_poll(&self, device: &str) -> Answer {
self.send("POST", "/auth/device/poll", None, None, Some(serde_json::json!({ "device_code": device }))).await
}
/// The `/login/device` page's `approve()`.
async fn device_approve(&self, typed: &str, yubikey: &mut Yubikey) -> Answer {
let options = self.send("GET", &format!("/auth/device/approve/start?code={typed}"), None, None, None).await;
if options.status != http::StatusCode::OK {
return options;
}
assert_eq!(options.body["publicKey"]["userVerification"], "required", "{}", options.text);
assert_eq!(options.body["publicKey"]["allowCredentials"], serde_json::json!([]), "{}", options.text);
let mut body = yubikey.answer(&options.body);
body["code"] = typed.into();
self.send("POST", "/auth/device/approve", None, None, Some(body)).await
}
}
/// ★ **`gunnar login` is a passkey touch that adds the CLI's OWN key.** The
/// core verifies the person's touch; AS the person (their fresh session) the
/// console adds the CLI's public key to their principal with an expiry; that
/// session is ended; the CLI's poll is answered ONCE; the browser is not
/// logged in by it.
#[tokio::test]
async fn gunnar_login_is_a_passkey_touch_that_adds_the_clis_own_key() {
let fake = Fake::new();
let mut annas = Yubikey::new(0x61, "annas-yubikey");
fake.holds_passkey("anna", &annas);
let rig = console(fake).await;
let (_private, public) = cli_key();
let (device, user_code) = rig.device_start(&public, None).await;
let pending = rig.device_poll(&device).await;
assert_eq!(pending.body["state"], "pending", "{}", pending.text);
let looked = rig.send("GET", &format!("/auth/device/lookup?code={}", user_code.to_lowercase().replace('-', "")), None, None, None).await;
assert_eq!(looked.status, http::StatusCode::OK, "{}", looked.text);
let fingerprint = openssh_fingerprint(&public).expect("a fingerprint");
assert_eq!(looked.body["fingerprint"], fingerprint, "{}", looked.text);
let approved = rig.device_approve(&user_code, &mut annas).await;
assert_eq!(approved.status, http::StatusCode::OK, "{}", approved.text);
assert_eq!(approved.body["user"], "anna", "{}", approved.text);
assert!(approved.cookie.is_none(), "approving a CLI login does not log the browser in");
assert!(rig.fake.heard().contains(&"Access.AddKey by anna".to_owned()), "AS anna: {:?}", rig.fake.heard());
assert!(rig.fake.heard().contains(&"Identity.Logout by anna everywhere=false".to_owned()), "the approval's session is ended: {:?}", rig.fake.heard());
let (principal, line, expires) = lock(&rig.fake.added_keys).last().cloned().expect("a key added");
assert_eq!((principal.as_str(), line.as_str()), ("anna", public.as_str()), "the CLI's key, not one the console minted");
let ttl_ms = expires.expect("an expiry") - crate::auth::now_secs() * 1000;
assert!(ttl_ms > 3_600_000 && ttl_ms <= 24 * 3_600_000 + 5_000, "{ttl_ms} ms");
let collected = rig.device_poll(&device).await;
assert_eq!(collected.body["state"], "approved", "{}", collected.text);
assert_eq!(collected.body["principal"], "anna", "{}", collected.text);
assert_eq!(collected.body["fingerprint"], fingerprint, "{}", collected.text);
let again = rig.device_poll(&device).await;
assert_ne!(again.body["state"], "approved", "an approval is collected once: {}", again.text);
let reused = rig.device_approve(&user_code, &mut annas).await;
assert!(!reused.status.is_success(), "a code is approved once: {}", reused.text);
assert_eq!(lock(&rig.fake.added_keys).len(), 1, "the key was added once");
}
/// **The CLI cannot ask for a key that outlives a day, or for no key at all.**
#[tokio::test]
async fn a_device_login_asks_for_a_usable_key_and_at_most_a_day() {
let fake = Fake::new();
let mut annas = Yubikey::new(0x62, "annas-yubikey");
fake.holds_passkey("anna", &annas);
let rig = console(fake).await;
for junk in ["", "ssh-ed25519 AAAA", "not a key", "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC7 rsa"] {
let refused = rig.send("POST", "/auth/device/start", None, None, Some(serde_json::json!({ "public_key": junk }))).await;
assert_eq!(refused.status, http::StatusCode::BAD_REQUEST, "{junk:?}: {}", refused.text);
}
let (_private, public) = cli_key();
let (device, user_code) = rig.device_start(&public, Some(30 * 86_400)).await;
let approved = rig.device_approve(&user_code, &mut annas).await;
assert_eq!(approved.status, http::StatusCode::OK, "{}", approved.text);
let (_, _, expires) = lock(&rig.fake.added_keys).last().cloned().expect("a key");
let ttl_ms = expires.expect("an expiry") - crate::auth::now_secs() * 1000;
assert!(ttl_ms <= 24 * 3_600_000 + 5_000, "thirty days asked, a day at most: {ttl_ms} ms");
assert_eq!(rig.device_poll(&device).await.body["state"], "approved");
}
/// **A denied or mistyped code adds nothing.**
#[tokio::test]
async fn a_denied_or_mistyped_device_code_adds_nothing() {
let fake = Fake::new();
let mut annas = Yubikey::new(0x63, "annas-yubikey");
fake.holds_passkey("anna", &annas);
let rig = console(fake).await;
let (_private, public) = cli_key();
let (device, user_code) = rig.device_start(&public, None).await;
let wrong = rig.device_approve("BCDF-GHJK", &mut annas).await;
assert_eq!(wrong.status, http::StatusCode::NOT_FOUND, "{}", wrong.text);
let denied = rig.send("POST", "/auth/device/deny", None, None, Some(serde_json::json!({ "code": user_code }))).await;
assert_eq!(denied.status, http::StatusCode::OK, "{}", denied.text);
assert_eq!(rig.device_poll(&device).await.body["state"], "denied");
let late = rig.device_approve(&user_code, &mut annas).await;
assert!(!late.status.is_success(), "{}", late.text);
assert!(lock(&rig.fake.added_keys).is_empty(), "gunnar added nothing");
let unknown = rig.device_poll("00".repeat(32).as_str()).await;
assert_eq!(unknown.body["state"], "expired", "{}", unknown.text);
let mut limited = false;
for n in 0..80 {
let guess = rig.send("GET", &format!("/auth/device/lookup?code=ZZZZ{n:04}"), None, None, None).await;
if guess.status == http::StatusCode::TOO_MANY_REQUESTS {
limited = true;
break;
}
assert_eq!(guess.status, http::StatusCode::NOT_FOUND, "{}", guess.text);
}
assert!(limited, "guessing codes from one address runs out of budget");
}
/// **The device flow's doors are public, and the page carries its own script
/// under the console's CSP.**
#[tokio::test]
async fn the_device_flow_is_public_and_its_page_is_served() {
for path in [
"/login/device",
"/auth/device/start",
"/auth/device/poll",
"/auth/device/lookup",
"/auth/device/approve/start",
"/auth/device/approve",
"/auth/device/deny",
] {
assert!(crate::auth::middleware::is_public(path), "{path} must be reachable without a session");
}
let rig = console(Fake::new()).await;
let page = rig.send("GET", "/login/device", None, None, None).await;
assert_eq!(page.status, http::StatusCode::OK, "{}", page.text);
assert!(page.text.contains("/auth/device/approve"));
let html = crate::auth::device::device_html();
assert_eq!(page.text, html);
assert!(!crate::security::inline_script_hashes(html).is_empty());
}
/// ★ The first touch after paying, in a real browser (A29-20).
#[cfg(feature = "metered")]
pub(crate) mod first_touch;
/// ★ **The person's encryption key is relayed AS THE PERSON** (CRYPT-YUBI):
/// gunnar hears every call carried by anna's session, the first method
/// makes the key, more methods join it, the last is refused by gunnar's own
/// words, and a body that names somebody else is refused before anything is
/// signed. The console keeps nothing.
#[tokio::test]
async fn the_encryption_key_and_its_methods_are_relayed_as_the_person() {
let fake = Fake::new();
let mut anna = Yubikey::new(0x41, "annas-yubikey");
fake.holds_passkey("anna", &anna);
let rig = console(fake).await;
let cookie = rig.login(&mut anna).await;
let wrap = "-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n-----END AGE ENCRYPTED FILE-----\n";
let sk = serde_json::json!({ "kind": "security-key", "label": "YubiKey", "method_ref": "AQID", "rp_id": "console.example", "wrap": wrap });
let none = rig.send("GET", "/api/account/encryption-key", None, Some(&cookie), None).await;
assert_eq!(none.status, http::StatusCode::OK, "{}", none.text);
assert_eq!(none.body["exists"], false, "{}", none.text);
let made = rig
.send("POST", "/api/account/encryption-key", None, Some(&cookie), Some(serde_json::json!({ "public_key": "age1example", "first": sk })))
.await;
assert_eq!(made.status, http::StatusCode::OK, "{}", made.text);
assert_eq!(made.body["methods"].as_array().map(Vec::len), Some(1), "{}", made.text);
assert!(rig.fake.heard().contains(&"Access.CreateEncryptionKey by anna".to_owned()), "{:?}", rig.fake.heard());
let again = rig
.send("POST", "/api/account/encryption-key", None, Some(&cookie), Some(serde_json::json!({ "public_key": "age1other", "first": sk })))
.await;
assert_eq!(again.status, http::StatusCode::CONFLICT, "gunnar's refusal of a second key: {}", again.text);
let code = serde_json::json!({ "kind": "recovery-code", "label": "printed", "wrap": wrap.replace("YWdl", "c2NyeXB0") });
let two = rig.send("POST", "/api/account/encryption-key/methods", None, Some(&cookie), Some(code)).await;
assert_eq!(two.status, http::StatusCode::OK, "{}", two.text);
let ids: Vec<String> = two.body["methods"].as_array().unwrap().iter().map(|m| m["id"].as_str().unwrap().to_owned()).collect();
assert_eq!(ids.len(), 2, "{}", two.text);
assert!(rig.fake.heard().contains(&"Access.AddUnlockMethod by anna".to_owned()));
let one = rig.send("DELETE", &format!("/api/account/encryption-key/methods/{}", ids[0]), None, Some(&cookie), None).await;
assert_eq!(one.status, http::StatusCode::OK, "{}", one.text);
assert_eq!(one.body["methods"].as_array().map(Vec::len), Some(1));
let last = rig.send("DELETE", &format!("/api/account/encryption-key/methods/{}", ids[1]), None, Some(&cookie), None).await;
assert!(last.status.is_client_error(), "{}", last.text);
assert!(last.text.contains("last unlock method"), "gunnar's own words: {}", last.text);
let heard_before = rig.fake.heard().len();
let named = serde_json::json!({ "kind": "recovery-code", "wrap": wrap, "user": "boss" });
let refused = rig.send("POST", "/api/account/encryption-key/methods", None, Some(&cookie), Some(named)).await;
assert_eq!(refused.status, http::StatusCode::BAD_REQUEST, "{}", refused.text);
assert!(
!rig.fake.heard()[heard_before..].iter().any(|h| h.starts_with("Access.AddUnlockMethod")),
"a body naming somebody else reached gunnar: {:?}",
rig.fake.heard()
);
let anonymous = rig.send("GET", "/api/account/encryption-key", None, None, None).await;
assert!(anonymous.status.is_client_error(), "{}", anonymous.text);
}
/// ★ **GHUNT-19: a person approves in the browser; gunnar attests it for her
/// stepped-up session.** The console sends the approval UNSIGNED, as HER, from
/// her own core session — never with its own key in her name. With only a plain
/// touch gunnar answers `[stepup]`, which the console says as `"code":"stepup"`
/// (the web then asks her key for a fresh touch in place); after the touch the
/// same approval is taken, hers, from a stepped-up session.
///
/// The fake serves no `PullRequests`, so the read-back after the approval is not
/// this test's: what gunnar HEARD is (the uitest drives the whole page).
#[tokio::test]
async fn a_persons_web_approval_is_unsigned_from_her_session_and_a_plain_touch_is_the_step_up() {
let fake = Fake::new();
let mut boss_key = Yubikey::new(0x42, "boss-yubikey");
fake.holds_passkey("boss", &boss_key);
let rig = console(fake).await;
boss_key.uv = false;
let boss = rig.login(&mut boss_key).await;
boss_key.uv = true;
let approve = serde_json::json!({ "commit": "c0ffee" });
let refused = rig.send("POST", "/api/repos/acme/app/pulls/1/approve", None, Some(&boss), Some(approve.clone())).await;
assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
assert_eq!(refused.body["code"], "stepup", "a plain touch is answered with the step-up: {}", refused.text);
assert!(lock(&rig.fake.forge_events).is_empty(), "refused and taken anyway");
let options = rig.send("GET", "/auth/passkey/step-up", None, Some(&boss), None).await;
assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
let touch = boss_key.answer(&options.body);
let stepped = rig.send("POST", "/auth/passkey/step-up", None, Some(&boss), Some(touch)).await;
assert_eq!(stepped.status, http::StatusCode::OK, "{}", stepped.text);
let _ = rig.send("POST", "/api/repos/acme/app/pulls/1/approve", None, Some(&boss), Some(approve)).await;
let taken = lock(&rig.fake.forge_events).clone();
assert_eq!(taken.len(), 1, "the stepped-up approval reaches gunnar once: {:?}", rig.fake.heard());
let (who, stepped_up, signed) = &taken[0];
assert_eq!(who, "boss", "sent AS her, from her own session");
assert!(*stepped_up, "from her stepped-up session");
assert!(
signed.key_fingerprint.is_empty() && signed.signature.is_empty(),
"the console must not sign her approval with its own key: {}",
signed.key_fingerprint
);
let event = signed.event.as_ref().expect("an event");
assert_eq!((event.actor.as_str(), event.pull), ("boss", 1));
assert!(matches!(&event.kind, Some(pb::forge_event::Kind::Approved(a)) if a.commit == "c0ffee"));
}