crates/gunnar-ui-server/src/first_admin_login.rs
//! **The first admin's passkey IS the estate's operator, and the console reads
//! as them at once** — driven end to end on a fresh estate: the break-glass
//! token registers a real P-256 passkey through `/auth/passkey/register/*`, the
//! passkey logs in through `/auth/passkey/login/finish`, and the session reads
//! `You → Account` and adds an SSH key.
//!
//! Tests only. Owner, 2026-10-04: "a YubiKey is used to register me as an admin
//! user. I can THEN select to ADD ssh keys to it" — no operator step, no
//! attestation panel — and "The UI shall not DO anything such as create a user —
//! it shall CALL gunnar-server".
//!
//! # The fake is test2 as it stood that morning
//!
//! gunnar's identity table with the console, monetize and the bootstrap admin
//! in it, and a namespace `rickard` that a first push with the installer's admin
//! key had conjured (`rickard/t2-scratch`). Its `Access.AddPrincipal` refuses
//! exactly as the real one did ("cannot create the account namespace
//! \"rickard\" …: namespace already exists"), its `Access.AddKey` keeps gunnar's
//! one-free-key rule, and its `Accounts.ClaimOperator` keeps gunnar's: the FED
//! name wins, an ownerless namespace is adopted, the operator is keyless. The
//! last row is the same estate installed with the fix: the account made at
//! install, the front's credential for it enrolled as its first key and laid as
//! this console's tenant seed.
//!
//! Since the identity core (IDENTITY-HOST, 2026-10-27) the fake serves edda's
//! real `IdentityService` over its own people and passkeys: the break-glass
//! token is the core's FED first-login link onto the operator (the token's
//! SHA-256, recorded by the installer), redeemed through `RedeemStart/Finish`.
//!
//! RED on gunnar-ui 72b2ba0 (what test2 ran): the registration asked
//! `AddPrincipal`, was refused, and the passkey bound to nothing — `/api/account/keys`
//! answered 404 "an operator creates it" and `/api/account/console-key` "gunnar has
//! no principal bound to this session".
use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use base64::Engine as _;
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 RP_ID: &str = "test2.example";
const ORIGIN: &str = "https://test2.example";
const BREAK_GLASS: &str = "break-glass-token-of-the-installer";
const B64: base64::engine::general_purpose::GeneralPurpose =
base64::engine::general_purpose::URL_SAFE_NO_PAD;
// ───────────────────────────────────────────────────────────────────────────
// The fake: test2's gunnar on 2026-10-04 at 08:15
// ───────────────────────────────────────────────────────────────────────────
#[derive(Clone)]
struct Fake {
principals: Arc<Mutex<BTreeMap<String, pb::Principal>>>,
namespaces: Arc<Mutex<BTreeSet<String>>>,
/// (principal, fingerprint, openssh line, comment)
#[allow(clippy::type_complexity)]
keys: Arc<Mutex<Vec<(String, String, String, String)>>>,
/// Every identity call, in order: `"AddPrincipal rickard"`, …
calls: Arc<Mutex<Vec<String>>>,
/// The installer's `operator_name`, as gunnar read it from its feed.
fed_operator: Option<String>,
clock: Arc<AtomicI64>,
/// The passkeys gunnar holds: credential id → (principal, SEC1 key).
#[allow(clippy::type_complexity)]
passkeys: Arc<Mutex<BTreeMap<Vec<u8>, (String, Vec<u8>)>>>,
/// Who holds a server-wide Admin (the operator gunnar claimed).
admins: Arc<Mutex<BTreeSet<String>>>,
/// The identity core's own records at this gunnar.
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<FirstHost>>>>,
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
impl Fake {
fn test2_morning() -> Fake {
let fake = Fake {
principals: Arc::default(),
namespaces: Arc::default(),
keys: Arc::default(),
calls: Arc::default(),
fed_operator: Some("rickard".to_owned()),
clock: Arc::new(AtomicI64::new(1_000)),
passkeys: Arc::default(),
admins: Arc::default(),
sessions: Arc::default(),
invites: Arc::default(),
revocations: Arc::default(),
core: Arc::default(),
};
let core = Arc::new(nornir_identity::Identity::new(FirstHost { fake: fake.clone() }, nornir_identity::Config::default()));
fake.core.set(core).unwrap_or_else(|_| unreachable!("one core"));
for (id, kind) in [
("admin", pb::PrincipalKind::User),
("gunnar-ui", pb::PrincipalKind::Machine),
("monetize", pb::PrincipalKind::Machine),
("gunnar-guest", pb::PrincipalKind::Machine),
] {
fake.make(id, kind);
}
// The namespace `rickard/t2-scratch`'s first push conjured.
fake.namespaces.lock().unwrap().insert("rickard".to_owned());
fake.installer_claims("rickard", true);
fake
}
/// **The installer's word** (Stage L): it claims the operator — a person,
/// gunnar-admin, an ownerless namespace of that name adopted — and, with
/// `invite`, records the break-glass token's hash as the invite for his
/// first passkey. Not the console's call: it is not in `calls`.
fn installer_claims(&self, name: &str, invite: bool) {
if !self.principals.lock().unwrap().contains_key(name) {
self.make(name, pb::PrincipalKind::User);
}
self.namespaces.lock().unwrap().insert(name.to_owned());
self.admins.lock().unwrap().insert(name.to_owned());
if invite {
self.core()
.fed_enrolment(name, &sha256_hex(BREAK_GLASS.as_bytes()), 30 * 86_400, "installer")
.expect("the installer's fed first-login link");
}
}
fn core(&self) -> &Arc<nornir_identity::Identity<FirstHost>> {
self.core.get().expect("the fake's core")
}
/// The operator's first-login link (the token's hash) is still open.
fn first_invite_open(&self) -> bool {
let id = nornir_identity::fed_invite_id(&sha256_hex(BREAK_GLASS.as_bytes()));
self.invites.lock().unwrap().iter().any(|i| i.id == id && i.used_by.is_none() && !i.revoked)
}
/// The principal a call is: by the core session it carried, or by its key.
fn caller<T>(&self, request: &Request<T>) -> Option<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).ok().map(|v| v.user_id);
}
let signer = request
.metadata()
.get(crate::signer::CREDENTIAL_METADATA_KEY)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split_whitespace().nth(1).map(str::to_owned))
.unwrap_or_default();
self.keys.lock().unwrap().iter().find(|k| k.1 == signer).map(|k| k.0.clone())
}
/// **A box no installer fed**: the operator was claimed, and no first
/// invite recorded.
fn no_first_invite() -> Fake {
let fake = Fake::test2_morning();
let id = nornir_identity::fed_invite_id(&sha256_hex(BREAK_GLASS.as_bytes()));
fake.core().withdraw_invite(&id, "test").expect("withdrawn");
fake
}
/// **The same estate installed with the fix**: the primary claimed the
/// operator's account at install (over the conjured `rickard/`) and enrolled
/// the front's offered credential for it as its first key — `credential` is
/// that key's OpenSSH line.
fn installed(credential: &str) -> Fake {
let fake = Fake::test2_morning();
fake.keys.lock().unwrap().push((
"rickard".to_owned(),
fingerprint_of(credential).expect("a key line"),
credential.to_owned(),
"the console's credential for the operator, offered by the front".to_owned(),
));
fake
}
/// **A fresh estate where nothing was pushed first**: no `rickard/`
/// conjured. The old path bound by name here and worked; the claim must
/// work the same, and still be gunnar's word.
fn fresh() -> Fake {
let fake = Fake::test2_morning();
fake.namespaces.lock().unwrap().clear();
fake.principals.lock().unwrap().remove("rickard");
fake.admins.lock().unwrap().clear();
fake.installer_claims("rickard", true);
fake
}
fn make(&self, id: &str, kind: pb::PrincipalKind) -> pb::Principal {
let row = pb::Principal {
id: id.to_owned(),
name: id.to_owned(),
kind: kind as i32,
created_at_unix_ms: self.clock.fetch_add(1_000, Ordering::SeqCst),
};
self.principals.lock().unwrap().insert(id.to_owned(), row.clone());
row
}
fn said(&self, call: String) {
self.calls.lock().unwrap().push(call);
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn keys_of(&self, principal: &str) -> Vec<(String, String, String, String)> {
self.keys
.lock()
.unwrap()
.iter()
.filter(|k| k.0 == principal)
.cloned()
.collect()
}
}
fn fingerprint_of(line: &str) -> Result<String, Status> {
ssh_key::PublicKey::from_openssh(line.trim())
.map(|k| k.fingerprint(ssh_key::HashAlg::Sha256).to_string())
.map_err(|e| Status::invalid_argument(format!("not an OpenSSH key: {e}")))
}
#[tonic::async_trait]
impl pb::access_server::Access for Fake {
async fn list_principals(
&self,
request: Request<pb::ListPrincipalsRequest>,
) -> Result<Response<pb::ListPrincipalsResponse>, Status> {
let name = request.into_inner().name;
Ok(Response::new(pb::ListPrincipalsResponse {
principals: self
.principals
.lock()
.unwrap()
.values()
.filter(|p| name.is_empty() || p.name == name)
.cloned()
.collect(),
next_page_token: String::new(),
..Default::default()
}))
}
/// gunnar's `control_plane::add_principal`: the principal and its account
/// namespace are one claim, and an existing namespace refuses the whole call.
async fn add_principal(
&self,
request: Request<pb::AddPrincipalRequest>,
) -> Result<Response<pb::AddPrincipalResponse>, Status> {
let asked = request.into_inner();
let id = if asked.id.is_empty() { asked.name.clone() } else { asked.id };
self.said(format!("AddPrincipal {id}"));
if self.principals.lock().unwrap().contains_key(&id) {
return Err(Status::already_exists(format!("principal {id}")));
}
if self.namespaces.lock().unwrap().contains(&id) {
return Err(Status::already_exists(format!(
"cannot create the account namespace \"{id}\" for this user: namespace already \
exists: {id}. A user's account is the namespace named after their id and cannot \
be somebody else's; the principal was not created."
)));
}
self.namespaces.lock().unwrap().insert(id.clone());
Ok(Response::new(pb::AddPrincipalResponse {
principal: Some(self.make(&id, pb::PrincipalKind::User)),
}))
}
/// gunnar's one-free-key rule (`key_attestation::Requirement::of`): a
/// principal that holds a key takes another only with an attestation made by
/// one it holds — UNLESS the caller IS that principal (gunnar's self-scope,
/// feat/thin-ui 5456dd9: the call's own signature, by a key the principal
/// holds, is the consent).
async fn add_key(
&self,
request: Request<pb::AddKeyRequest>,
) -> Result<Response<pb::AddKeyResponse>, Status> {
let caller = self.caller(&request);
let asked = request.into_inner();
let fingerprint = fingerprint_of(&asked.openssh_public_key)?;
let held = self.keys_of(&asked.principal_id);
let by_itself = caller.as_deref() == Some(asked.principal_id.as_str());
self.said(format!(
"AddKey {} attested={}",
asked.principal_id,
asked.attestation.is_some()
));
if !held.is_empty() && !by_itself {
let Some(attestation) = &asked.attestation else {
return Err(Status::failed_precondition(format!(
"principal {} already holds {} key(s), so adding another needs their own \
attestation",
asked.principal_id,
held.len()
)));
};
if !held.iter().any(|k| k.1 == attestation.key_fingerprint) {
return Err(Status::invalid_argument("the attestation is not by a key the principal holds"));
}
}
self.keys.lock().unwrap().push((
asked.principal_id.clone(),
fingerprint.clone(),
asked.openssh_public_key.clone(),
asked.comment.clone(),
));
Ok(Response::new(pb::AddKeyResponse {
key: Some(pb::PublicKey {
fingerprint,
algo: "ssh-ed25519".to_owned(),
principal_id: asked.principal_id,
comment: asked.comment,
..Default::default()
}),
}))
}
async fn list_credentials(
&self,
_request: Request<pb::ListCredentialsRequest>,
) -> Result<Response<pb::ListCredentialsResponse>, Status> {
Ok(Response::new(pb::ListCredentialsResponse {
credentials: self
.passkeys
.lock()
.unwrap()
.iter()
.map(|(id, (principal, _))| pb::WebCredential {
credential_id: id.clone(),
principal_id: principal.clone(),
..Default::default()
})
.collect(),
}))
}
async fn who_am_i(
&self,
request: Request<pb::WhoAmIRequest>,
) -> Result<Response<pb::WhoAmIResponse>, Status> {
let principal = self.caller(&request).ok_or_else(|| Status::unauthenticated("no such key"))?;
let admin = self.admins.lock().unwrap().contains(&principal);
Ok(Response::new(pb::WhoAmIResponse {
principal: self.principals.lock().unwrap().get(&principal).cloned(),
server_level: if admin { pb::GrantLevel::Admin as i32 } else { 0 },
..Default::default()
}))
}
async fn list_keys(
&self,
request: Request<pb::ListKeysRequest>,
) -> Result<Response<pb::ListKeysResponse>, Status> {
let principal = request.into_inner().principal_id;
Ok(Response::new(pb::ListKeysResponse {
keys: self
.keys_of(&principal)
.into_iter()
.map(|(principal_id, fingerprint, _, comment)| pb::PublicKey {
fingerprint,
algo: "ssh-ed25519".to_owned(),
principal_id,
comment,
..Default::default()
})
.collect(),
}))
}
}
#[tonic::async_trait]
impl pb::accounts_server::Accounts for Fake {
/// gunnar's `claim_operator_named`: the fed name wins, a person under it is
/// the answer, otherwise it is made — keyless, its namespace adopted when a
/// first push left one with no owner.
async fn claim_operator(
&self,
request: Request<pb::ClaimOperatorRequest>,
) -> Result<Response<pb::ClaimOperatorResponse>, Status> {
let asked = request.into_inner().name;
self.said(format!("ClaimOperator {asked}"));
let (name, named_by) = match &self.fed_operator {
Some(fed) => (fed.clone(), "fed"),
None if !asked.trim().is_empty() => (asked.trim().to_owned(), "asked"),
None => return Err(Status::failed_precondition("no operator is named for this box")),
};
// The operator is gunnar-admin, made or found.
self.admins.lock().unwrap().insert(name.clone());
if let Some(held) = self.principals.lock().unwrap().get(&name).cloned() {
return Ok(Response::new(pb::ClaimOperatorResponse {
principal: Some(held),
created: false,
adopted_namespace: false,
named_by: named_by.to_owned(),
bootstrap_invite: false,
}));
}
let adopted = !self.namespaces.lock().unwrap().insert(name.clone());
Ok(Response::new(pb::ClaimOperatorResponse {
principal: Some(self.make(&name, pb::PrincipalKind::User)),
created: true,
adopted_namespace: adopted,
named_by: named_by.to_owned(),
bootstrap_invite: false,
}))
}
}
// ── The identity core's host over the fake's tables (what `GunnarHost` is) ──
struct FirstHost {
fake: Fake,
}
impl UserStore for FirstHost {
fn user(&self, id: &str) -> Result<Option<User>, StoreError> {
let principal = self.fake.principals.lock().unwrap().get(id).cloned();
let admin = self.fake.admins.lock().unwrap().contains(id);
Ok(principal.map(|p| User {
groups: if admin { vec!["server-admin".to_owned()] } else { Vec::new() },
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> = self.fake.principals.lock().unwrap().keys().cloned().collect();
Ok(ids.iter().filter_map(|id| self.user(id).ok().flatten()).collect())
}
fn put_user(&self, _: &User) -> Result<(), StoreError> {
Ok(())
}
fn credential(&self, id: &[u8]) -> Result<Option<Credential>, StoreError> {
Ok(self.fake.passkeys.lock().unwrap().get(id).map(|(user, key)| Credential { id: id.to_vec(), user: user.clone(), public_key: key.clone(), sign_count: 0, created: 0 }))
}
fn credentials_of(&self, user: &str) -> Result<Vec<Credential>, StoreError> {
Ok(self
.fake
.passkeys
.lock()
.unwrap()
.iter()
.filter(|(_, (u, _))| u == user)
.map(|(id, (u, key))| Credential { id: id.clone(), user: u.clone(), public_key: key.clone(), sign_count: 0, created: 0 })
.collect())
}
/// gunnar's passkey store: a new passkey on its person (said as `Passkey <who>`).
fn put_credential(&self, c: &Credential) -> Result<(), StoreError> {
let mut held = self.fake.passkeys.lock().unwrap();
if !held.contains_key(&c.id) {
self.fake.said(format!("Passkey {}", c.user));
held.insert(c.id.clone(), (c.user.clone(), c.public_key.clone()));
}
Ok(())
}
fn session(&self, id: &str) -> Result<Option<Session>, StoreError> {
Ok(self.fake.sessions.lock().unwrap().iter().find(|s| s.id == id).cloned())
}
fn put_session(&self, session: &Session) -> Result<(), StoreError> {
let mut all = self.fake.sessions.lock().unwrap();
all.retain(|s| s.id != session.id);
all.push(session.clone());
Ok(())
}
fn delete_session(&self, id: &str) -> Result<(), StoreError> {
self.fake.sessions.lock().unwrap().retain(|s| s.id != id);
Ok(())
}
fn invites(&self) -> Result<Vec<Invite>, StoreError> {
Ok(self.fake.invites.lock().unwrap().clone())
}
fn update_invites(&self, change: &mut dyn FnMut(&mut Vec<Invite>)) -> Result<(), StoreError> {
change(&mut self.fake.invites.lock().unwrap());
Ok(())
}
fn revocations(&self) -> Result<Revocations, StoreError> {
Ok(self.fake.revocations.lock().unwrap().clone())
}
fn update_revocations(&self, change: &mut dyn FnMut(&mut Revocations)) -> Result<(), StoreError> {
change(&mut self.fake.revocations.lock().unwrap());
Ok(())
}
}
impl IdentityHost for FirstHost {
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
}
}
// ───────────────────────────────────────────────────────────────────────────
// The console, a passkey in software, and a browser that keeps the cookie
// ───────────────────────────────────────────────────────────────────────────
struct Rig {
router: axum::Router,
webauthn: Arc<crate::auth::Webauthn>,
fake: Fake,
}
fn console_signer() -> crate::signer::Signer {
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[62u8; 32]),
))
.expect("a fixed seed makes a signer")
}
async fn console(fake: Fake) -> Rig {
console_seeded(fake, None).await
}
/// The console as gunnar-front builds it on an installed estate: its tenant
/// seeds in `seeds` (`<data>/console/seeds`, where the front lays the
/// operator's credential the install enrolled).
async fn console_seeded(fake: Fake, seeds: Option<std::path::PathBuf>) -> 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 = nornir_identity::grpc::IdentityGrpc::new(Arc::clone(fake.core())).into_server();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(pb::access_server::AccessServer::new(serving.clone()))
.add_service(pb::accounts_server::AccountsServer::new(serving))
.add_service(identity)
.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 router = crate::serve::router(Arc::new(crate::app::App {
webauthn: Some(webauthn.clone()),
admin_token: Some(BREAK_GLASS.to_owned()),
#[cfg(feature = "metered")]
monetize: None,
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).with_seeds(seeds)),
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(),
}));
Rig {
router,
webauthn,
fake,
}
}
/// The YubiKey: a P-256 key and the credential id it reports.
struct Yubikey {
key: p256::ecdsa::SigningKey,
id: Vec<u8>,
counter: u32,
}
impl Yubikey {
fn new() -> Yubikey {
Yubikey {
key: p256::ecdsa::SigningKey::from_bytes(&[0x37u8; 32].into()).expect("a scalar"),
id: b"the-owners-yubikey".to_vec(),
counter: 0,
}
}
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, the attested
/// credential data with the COSE key.
fn create(&mut self, challenge: &str) -> (String, 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(&self.counter.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 point = self.key.verifying_key().to_sec1_point(false);
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(point.x().expect("x").to_vec())),
((-3).into(), ciborium::value::Value::Bytes(point.y().expect("y").to_vec())),
]);
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");
(
B64.encode(Self::client_json("webauthn.create", challenge)),
B64.encode(attestation),
)
}
/// `navigator.credentials.get`.
fn get(&mut self, challenge: &str) -> (String, String, String) {
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(0x01 | 0x04);
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);
(
B64.encode(&client_data),
B64.encode(&auth_data),
B64.encode(signature.to_der().as_bytes()),
)
}
}
struct Answer {
status: http::StatusCode,
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 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,
cookie,
body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null),
text,
}
}
/// **The owner's first visit**: the installer's break-glass token, his
/// name, his YubiKey — `register/start`, then `register/finish`.
async fn register_with_the_bootstrap_token(&self, user: &str, yubikey: &mut Yubikey) -> Answer {
let options = self
.send("GET", &format!("/auth/passkey/register/start?user={user}"), Some(BREAK_GLASS), None, None)
.await;
if options.status != http::StatusCode::OK {
return options;
}
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",
Some(BREAK_GLASS),
None,
Some(serde_json::json!({
"ceremony_id": ceremony,
"user": user,
"role": "admin",
"label": "YubiKey",
"response": { "clientDataJSON": client_data, "attestationObject": attestation },
})),
)
.await
}
/// **Then he logs in with it** — the session cookie a browser would keep.
async fn login(&self, yubikey: &mut Yubikey) -> String {
let options = self.send("GET", "/auth/passkey/login/start", 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, auth_data, signature) = yubikey.get(&challenge);
let answer = self
.send(
"POST",
"/auth/passkey/login/finish",
None,
None,
Some(serde_json::json!({
"ceremony_id": ceremony,
"id": B64.encode(&yubikey.id),
"response": {
"clientDataJSON": client_data,
"authenticatorData": auth_data,
"signature": signature,
}
})),
)
.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 one passkey: gunnar's, on the principal gunnar answers — and the
/// console's own store holds none (thin layer L).
fn the_passkeys_principal(&self) -> String {
let held = self.fake.passkeys.lock().unwrap().clone();
assert_eq!(held.len(), 1, "one passkey at gunnar: the owner's: {held:?}");
held.into_values().next().expect("one").0
}
}
fn an_ssh_key(seed: u8) -> String {
ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32]))
.public_key()
.to_openssh()
.expect("a public line")
}
// ───────────────────────────────────────────────────────────────────────────
// The rows
// ───────────────────────────────────────────────────────────────────────────
/// ★ **Register the YubiKey with the bootstrap token → the passkey IS the
/// operator's account, registered AT gunnar on it** — on test2's gunnar, where
/// `AddPrincipal rickard` is refused because a first push conjured `rickard/`.
/// The installer claimed the operator and recorded the token's hash as his
/// first invite; the console created nothing and decided nothing: it relayed
/// the ceremony to `Access.WebRegister` with the token as the invite's secret,
/// and gunnar registered the passkey on the operator (first login on Stage L).
/// It holds no key of his — it signs as him with the session key gunnar enrols
/// at his login.
#[tokio::test]
async fn the_first_admin_passkey_is_registered_at_gunnar_on_the_operator() {
let rig = console(Fake::test2_morning()).await;
let mut yubikey = Yubikey::new();
let registered = rig.register_with_the_bootstrap_token("rickard", &mut yubikey).await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
assert_eq!(
rig.the_passkeys_principal(),
"rickard",
"the first admin passkey is not the operator's — test2's 'gunnar has no principal bound \
to this session'. gunnar was asked: {:?}",
rig.fake.calls()
);
let calls = rig.fake.calls();
assert!(calls.contains(&"Passkey rickard".to_owned()), "{calls:?}");
assert!(
!calls.iter().any(|c| c.starts_with("ClaimOperator")),
"the console asks gunnar nothing about who the operator is — the invite says: {calls:?}"
);
assert!(!rig.fake.first_invite_open(), "the first login link was redeemed");
assert!(
!calls.iter().any(|c| c.starts_with("AddPrincipal")),
"the console made a user itself instead of calling gunnar for the operator: {calls:?}"
);
assert!(
!calls.iter().any(|c| c.starts_with("AddKey")),
"the console enrols no key of its own on the operator: {calls:?}"
);
assert!(
!rig.webauthn.admin_token_usable(),
"the first admin passkey exists: the break-glass token is spent"
);
}
/// ★ **Then he logs in and the console reads as him — no panel, no operator
/// step.** `You → Account`: his keys answer (his own account), the console's
/// credential is `enrolled` (his session key, which gunnar enrolled at the
/// login), and the panel draws nothing.
#[tokio::test]
async fn after_the_first_login_the_account_page_is_his_and_the_console_reads_as_him() {
let rig = console(Fake::test2_morning()).await;
let mut yubikey = Yubikey::new();
let registered = rig.register_with_the_bootstrap_token("rickard", &mut yubikey).await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
let session = rig.login(&mut yubikey).await;
let keys = rig.send("GET", "/api/account/keys", None, Some(&session), None).await;
assert_eq!(
keys.status,
http::StatusCode::OK,
"You → Account → SSH keys refused the operator: {}",
keys.text
);
let credential = rig.send("GET", "/api/account/console-key", None, Some(&session), None).await;
assert_eq!(credential.status, http::StatusCode::OK, "{}", credential.text);
assert_eq!(credential.body["state"], "enrolled", "{}", credential.text);
assert_eq!(credential.body["principal_id"], "rickard", "{}", credential.text);
assert!(
credential.body.get("refusal").is_none_or(|r| r.as_str() == Some("")),
"a refusal reached the page: {}",
credential.text
);
let status = rig.send("GET", "/auth/status", None, Some(&session), None).await;
assert_eq!(status.body["role"], "admin", "the operator is gunnar-admin: {}", status.text);
}
/// ★ **"I can THEN select to ADD ssh keys to it"**: You → Account → SSH keys →
/// Add key lands on the operator's account, added AS HIM (his session key, a
/// key on his own principal: gunnar's self-scope) — nobody is asked to run
/// anything.
#[tokio::test]
async fn then_he_adds_his_ssh_keys_and_gunnar_takes_them_with_no_operator_step() {
let rig = console(Fake::test2_morning()).await;
let mut yubikey = Yubikey::new();
let registered = rig.register_with_the_bootstrap_token("rickard", &mut yubikey).await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
let session = rig.login(&mut yubikey).await;
for (n, seed) in [(1, 81u8), (2, 82u8)] {
let added = rig
.send(
"POST",
"/api/account/keys",
None,
Some(&session),
Some(serde_json::json!({ "openssh_public_key": an_ssh_key(seed), "comment": format!("laptop {n}") })),
)
.await;
assert_eq!(added.status, http::StatusCode::OK, "Add key #{n} was refused: {}", added.text);
}
let held = rig.fake.keys_of("rickard");
assert_eq!(held.len(), 2, "his two (a core session holds no key at gunnar): {held:?}");
let listed = rig.send("GET", "/api/account/keys", None, Some(&session), None).await;
assert_eq!(listed.status, http::StatusCode::OK, "{}", listed.text);
assert_eq!(
listed.body["items"].as_array().map(Vec::len),
Some(2),
"his page lists HIS two keys: {}",
listed.text
);
}
/// ★ **On an estate installed with the fix the operator's account is already
/// there — the install claimed it — and the first passkey is registered on it;
/// the console adopts no key**: since passkeys live at gunnar it signs as him
/// with the session key gunnar enrols at his login, and the credential the
/// install laid as the console's seed is not read.
#[tokio::test]
async fn on_an_installed_estate_the_first_passkey_is_the_installed_operators() {
let seed = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[77u8; 32]));
let credential = format!(
"{} gunnar-ui-operator",
seed.public_key().to_openssh().expect("a public line")
);
let seeds = tempfile::tempdir().expect("a temp dir").keep();
std::fs::write(
seeds.join("rickard"),
seed.to_openssh(ssh_key::LineEnding::LF).expect("a private line").as_bytes(),
)
.expect("the seed is laid");
let rig = console_seeded(Fake::installed(&credential), Some(seeds)).await;
let mut yubikey = Yubikey::new();
let registered = rig.register_with_the_bootstrap_token("rickard", &mut yubikey).await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
assert_eq!(rig.the_passkeys_principal(), "rickard");
let calls = rig.fake.calls();
assert!(calls.contains(&"Passkey rickard".to_owned()), "{calls:?}");
assert!(
!calls.iter().any(|c| c.starts_with("AddKey") || c.starts_with("AddPrincipal")),
"the console asks gunnar for no key and no principal: {calls:?}"
);
let session = rig.login(&mut yubikey).await;
let credential_state = rig.send("GET", "/api/account/console-key", None, Some(&session), None).await;
assert_eq!(credential_state.body["state"], "enrolled", "{}", credential_state.text);
assert_ne!(
credential_state.body["fingerprint"],
fingerprint_of(&credential).expect("a key line").as_str(),
"the console signs with his session key, not a key it holds of his: {}",
credential_state.text
);
let added = rig
.send(
"POST",
"/api/account/keys",
None,
Some(&session),
Some(serde_json::json!({ "openssh_public_key": an_ssh_key(91), "comment": "laptop" })),
)
.await;
assert_eq!(added.status, http::StatusCode::OK, "Add key was refused: {}", added.text);
// Added BY HIM: the call is signed with his session key — a key on his own
// principal — so gunnar's self-scope takes it with no attestation, and the
// console vouches for nobody.
assert!(
rig.fake.calls().contains(&"AddKey rickard attested=false".to_owned()),
"his key is added as him, owing no attestation: {:?}",
rig.fake.calls()
);
assert_eq!(
rig.fake.keys_of("rickard").len(),
2,
"the install's credential and his key"
);
}
/// **A fresh estate, nothing conjured** — the same door, the same answer:
/// gunnar names the operator (`ClaimOperator`, never `AddPrincipal`), the
/// passkey is registered on it, and his SSH key goes on as him.
#[tokio::test]
async fn on_a_fresh_estate_with_nothing_conjured_the_first_passkey_is_the_operator_too() {
let rig = console(Fake::fresh()).await;
let mut yubikey = Yubikey::new();
let registered = rig.register_with_the_bootstrap_token("rickard", &mut yubikey).await;
assert_eq!(registered.status, http::StatusCode::OK, "{}", registered.text);
assert_eq!(rig.the_passkeys_principal(), "rickard");
let calls = rig.fake.calls();
assert!(calls.contains(&"Passkey rickard".to_owned()), "{calls:?}");
assert!(!calls.iter().any(|c| c.starts_with("AddPrincipal")), "{calls:?}");
assert!(rig.fake.namespaces.lock().unwrap().contains("rickard"), "the account's namespace was made by the claim");
let session = rig.login(&mut yubikey).await;
let credential = rig.send("GET", "/api/account/console-key", None, Some(&session), None).await;
assert_eq!(credential.body["state"], "enrolled", "{}", credential.text);
let added = rig
.send(
"POST",
"/api/account/keys",
None,
Some(&session),
Some(serde_json::json!({ "openssh_public_key": an_ssh_key(93), "comment": "laptop" })),
)
.await;
assert_eq!(added.status, http::StatusCode::OK, "{}", added.text);
assert_eq!(rig.fake.keys_of("rickard").len(), 1, "his key");
}
/// ★ **A box no installer fed refuses by gunnar's word, and the token still
/// works**: gunnar holds no first invite, so `WebRegister` refuses the token;
/// the console stores nothing, says what fixes it (`gunnar control account
/// claim-operator --bootstrap-file`), and the break-glass door stays open.
#[tokio::test]
async fn a_box_with_no_first_invite_refuses_by_gunnars_word_and_the_token_still_works() {
let rig = console(Fake::no_first_invite()).await;
let mut yubikey = Yubikey::new();
let refused = rig.register_with_the_bootstrap_token("rickard", &mut yubikey).await;
assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
assert!(refused.text.contains("claim-operator"), "the fix is named: {}", refused.text);
assert!(rig.fake.passkeys.lock().unwrap().is_empty(), "nothing registered");
assert!(rig.webauthn.admin_token_usable(), "the break-glass token still works");
}