crates/gunnar-ui-server/src/api/account.rs
//! **The keys a person may add to their OWN name, and nothing else.**
//!
//! # Why this module exists at all, when `api::access` already adds keys
//!
//! `/api/keys` is on `role_floor`'s `ADMIN_SURFACES` list and belongs there: a
//! POST to it names any `principal_id` on the box, so it hands out SSH access
//! to somebody else's namespace. That floor is right and it is untouched.
//!
//! What it left behind was a hole with a person in it. A tenant who has just
//! registered a passkey through a paid signup is a **viewer**. They can read
//! the console and they can do nothing with git — gunnar authenticates the KEY
//! over SSH and has never heard of them — and the one action that would fix
//! that was behind an admin door. The console's own first-run nudge pointed at
//! a form that would refuse them.
//!
//! So: a second surface, for the one act that is nobody's business but the
//! actor's.
//!
//! # What makes a viewer's floor safe here, and it is not the floor
//!
//! **The target is never a parameter.** Every handler in this file takes
//! [`Me`], which reads the session's own user name out of the extension
//! `require_auth` inserted, and there is no path segment, query parameter or
//! body field that can change it — [`OwnKey`] REFUSES a body that carries
//! `user` or `principal_id` rather than ignoring it, so a client that tries is
//! told, and the test that proves it cannot pass by accident.
//!
//! That is the whole argument. A viewer may write here because there is
//! nothing to escalate TO: the most a caller can do is register a key against
//! the name they already log in as, which is what "add my own key" means.
//!
//! Two consequences follow and both are enforced below:
//!
//! * **A name with no principal is not created.** If gunnar has no principal
//! named like the session, this refuses and says so. Minting one would be
//! `Access.AddPrincipal` — creating an identity and its namespace — from a
//! viewer's floor, which is the escalation this design is built to not have.
//! * **Every call is signed AS THE PERSON** (thin layer, owner 2026-10-04).
//! gunnar's self-scope rule decides: a principal may list, add and revoke
//! its OWN keys as itself, and anything else — somebody else's
//! fingerprint — is refused there. Until then this console checked
//! membership itself and signed with its own server-wide key.
//!
//! # Not to be confused with `api::accounts`
//!
//! `api::accounts` is the ADMIN surface — `Accounts.Gate` locks somebody out
//! of everything and `Accounts.Purge` empties their account. `/api/accounts`
//! is on the admin list; `/api/account` (singular) is this, and it is a
//! viewer's. The two paths are one letter apart, so
//! `the_own_account_floor_does_not_reach_the_admin_accounts_surface` asserts
//! the ladder tells them apart rather than trusting the reader to.
use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;
use crate::api::access::{render_key, Key};
use crate::api::{ApiError, ApiResult, Page};
use crate::app::AppState;
use crate::auth::AUDIT_TARGET;
use crate::grpc::Fail;
use crate::pb;
/// **Whose keys this request is about, decided before a handler exists to
/// forget.**
///
/// An extractor and not a helper, for the reason [`crate::api::Reader`] and
/// `buy_site::GunnarSite::operator` (was `increase::Ordering`) are: a helper is something a handler can forget to
/// call, and forgetting here means a viewer writing to somebody else's
/// identity. A handler either has this name in its signature or it has no name
/// to act for.
///
/// Three callers, three answers:
///
/// * a passkey session — its user name, which is the only interesting case;
/// * the **break-glass admin token** — refused. It is a machine credential
/// held by whoever runs the process, not a person, and there is no principal
/// named after it. An admin with a token that wants to add somebody a key
/// has `/api/keys`, which is what that door is for.
/// * **no session at all** — refused, including in the open-console posture
/// (`trunk serve` on loopback), where every request runs as admin and still
/// has no name. `buy_site::GunnarSite::operator` (was `increase::Ordering`) calls that caller "the open console";
/// here there is no principal by that name either, so the honest answer is
/// the same refusal.
#[derive(Debug, Clone)]
pub struct Me(pub String);
impl axum::extract::FromRequestParts<AppState> for Me {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut http::request::Parts,
_state: &AppState,
) -> Result<Me, ApiError> {
match parts.extensions.get::<crate::auth::middleware::Caller>() {
Some(crate::auth::middleware::Caller::Operator(session)) => {
Ok(Me(session.user.clone()))
}
Some(crate::auth::middleware::Caller::AdminToken) => Err(refuse(
"permissiondenied",
"the break-glass token is not a person and has no keys of its own; add a key \
for somebody through /api/keys",
)),
None => Err(refuse(
"permissiondenied",
"this needs a session that can be named: these are the keys of whoever is \
logged in, and nobody is",
)),
}
}
}
fn refuse(code: &str, message: &str) -> ApiError {
ApiError(Fail {
code: code.to_owned(),
message: message.to_owned(),
})
}
/// One key, as this surface accepts it.
///
/// **`user` and `principal_id` are here to be REFUSED.** They are not ignored:
/// serde drops an unknown field silently, and a client that sent
/// `{"user":"someone-else", …}` would get a 200 and a key on the wrong name
/// with nothing to tell it otherwise. Naming them makes the attempt a 400 that
/// says what happened, and makes
/// `a_body_that_names_somebody_else_is_refused_and_lands_on_nobody` a test
/// that cannot pass by accident.
#[derive(Debug, Clone, Deserialize)]
pub struct OwnKey {
/// One line in OpenSSH `authorized_keys` format. gunnar parses it and
/// derives the fingerprint itself — a fingerprint the client chose is not
/// one the SSH listener could ever match.
pub openssh_public_key: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub principal_id: Option<String>,
#[serde(default)]
pub user: Option<String>,
}
/// Which key to revoke.
///
/// In the QUERY string and not a path segment, deliberately: a fingerprint is
/// `SHA256:` followed by base64, which contains `/`, and axum's `{param}`
/// matches one segment. `api::policy::delete` takes its ref pattern the same
/// way and for the same reason. (`/api/keys/{fingerprint}` predates this and
/// is left alone; it is reached by an admin with a percent-encoded value.)
#[derive(Debug, Clone, Deserialize)]
pub struct Which {
pub fingerprint: String,
}
/// **The principal this console user is**: gunnar's answer when it verified
/// their passkey (the identity core's session names the principal),
/// carried by their session key (thin layer L). Never a name looked up: the
/// session's own key says who they are.
///
/// # Errors
///
/// The session holds no key at gunnar any more (log in again).
pub(crate) async fn principal_of(state: &AppState, user: &str) -> Result<String, ApiError> {
state
.webauthn
.as_ref()
.and_then(|webauthn| webauthn.principal_of(user))
.ok_or_else(|| {
refuse(
"failedprecondition",
"this sign-in holds no key at Gunnar any more, so this console cannot say which \
account it is; log in again",
)
})
}
/// Every key gunnar holds for `principal`.
///
/// Filtered here as well as asked for by id. gunnar narrows `ListKeys` by the
/// `principal_id` in the request, so the second pass can only ever remove rows
/// — the same "one `==` against a deployment mistake" argument
/// `routes::public_repos` makes, and the same reason it is acceptable there:
/// it cannot add a row, and what it defends against is this console asking the
/// wrong question rather than gunnar answering one wrongly.
async fn keys_of(
state: &AppState,
gunnar: &crate::grpc::Gunnar,
user: &str,
principal: &str,
) -> Result<Vec<Key>, ApiError> {
let listed = gunnar
.access()
.list_keys(pb::ListKeysRequest {
principal_id: principal.to_owned(),
})
.await
.map_err(ApiError::from)?
.into_inner();
let consoles = console_key_of(state, user).await;
Ok(listed
.keys
.into_iter()
.filter(|k| k.principal_id == principal)
.filter(|k| !consoles.contains(&k.fingerprint))
.map(render_key)
.collect())
}
/// **The fingerprints of the session keys THIS CONSOLE signs with on `user`'s
/// principal** — gunnar enrolled them at their passkey logins (thin layer L).
///
/// They are registered under their principal, so `Access.ListKeys` returns
/// them and this page would otherwise show them as the person's own — keys
/// they never made, with a delete button beside them that ends the session
/// they are reading with. So they are filtered out of the listing and refused
/// at the revocation; logging out is the lever that retires them.
async fn console_key_of(state: &AppState, user: &str) -> Vec<String> {
// A person's identity-core session holds no key at gunnar (IDENTITY-HOST):
// only a test's planted custody key is the console's on them.
let mut held = Vec::new();
if let Ok(Some(signing)) = crate::api::person_identity(state, user).await {
if !signing.fingerprint.is_empty() {
held.push(signing.fingerprint);
}
}
held
}
/// **The person's own credential** — their session key, which gunnar
/// enrolled on their principal when it verified their passkey. gunnar's
/// self-scope rule lets a principal list, add and revoke its OWN keys as
/// itself — no grant, and no attestation: the request's signature by one of
/// that principal's keys IS the consent — and refuses anything else in its
/// own words.
async fn as_the_person(
state: &AppState,
user: &str,
_principal: &str,
) -> Result<crate::grpc::Gunnar, ApiError> {
match crate::api::person_identity(state, user).await {
Ok(Some(identity)) => Ok(identity.gunnar),
Ok(None) => Err(refuse(
"failedprecondition",
"this sign-in holds no key at Gunnar any more; log in again",
)),
Err(why) => Err(tenant_refusal(why)),
}
}
/// `GET /api/account/keys` — the keys registered against the session's name,
/// asked of gunnar AS THE PERSON.
pub async fn list(Me(user): Me, State(state): State<AppState>) -> ApiResult<Page<Key>> {
let principal = principal_of(&state, &user).await?;
let gunnar = as_the_person(&state, &user, &principal).await?;
let mine = keys_of(&state, &gunnar, &user, &principal).await?;
Ok(Json(Page::of(mine, usize::MAX)))
}
/// `POST /api/account/keys` — register one, against the session's name.
pub async fn add(
Me(user): Me,
State(state): State<AppState>,
Json(new): Json<OwnKey>,
) -> ApiResult<Key> {
if new.principal_id.is_some() || new.user.is_some() {
return Err(refuse(
"invalidargument",
"this route registers a key against the session's own name and takes no other; use \
/api/keys, which needs an admin, to add a key for somebody else",
));
}
let line = new.openssh_public_key.trim();
if line.is_empty() {
return Err(refuse("invalidargument", "no key was pasted"));
}
let principal = principal_of(&state, &user).await?;
// **Signed AS THE PERSON** (thin layer, owner 2026-10-04). gunnar's
// self-scope rule: a principal adding a key to ITSELF needs no grant and
// owes no attestation — the call's own signature, by a key the principal
// already holds, is its word. Until this line the console signed the
// attestation itself with that key and sent the AddKey with its own
// server-wide one, which is a BFF vouching for a person to gunnar.
let gunnar = as_the_person(&state, &user, &principal).await?;
let added = gunnar
.access()
.add_key(pb::AddKeyRequest {
principal_id: principal,
openssh_public_key: line.to_owned(),
comment: new.comment.trim().to_owned(),
// Not offered here. An expiry a person sets on their own key is a
// reminder they cannot lift without an admin, and the console has
// nowhere to renew it from.
expires_at_unix_ms: None,
// Owed by nobody: the caller IS the target.
attestation: None,
})
.await
// **gunnar's own refusal, by name.** A key that already belongs to
// another principal is refused there, with its words, and this console
// is not a second opinion about why.
.map_err(ApiError::from)?
.into_inner();
state.caches.invalidate_access().await;
let key = render_key(added.key.unwrap_or_default());
tracing::info!(
target: AUDIT_TARGET,
event = "key.add",
by = %user,
user = %user,
fingerprint = %key.fingerprint,
"somebody registered a key against their own name"
);
Ok(Json(key))
}
/// `DELETE /api/account/keys?fingerprint=SHA256:…` — remove one of your own.
///
/// **gunnar decides whose key it is** (thin layer, owner 2026-10-04):
/// `Access.RevokeKey` is signed AS THE PERSON, and gunnar's self-scope rule
/// lets a principal revoke only a key it holds — anybody else's fingerprint is
/// refused there, in the same words whether it exists or not. Until this line
/// the console checked membership itself and then revoked with its own
/// server-wide key.
///
/// The one refusal kept here is not a permission: the console's OWN key on
/// this principal is what the person's session reads with, and revoking it
/// would blank their console with nothing saying why.
pub async fn revoke(
Me(user): Me,
State(state): State<AppState>,
Query(which): Query<Which>,
) -> ApiResult<crate::api::repos::Done> {
let principal = principal_of(&state, &user).await?;
if console_key_of(&state, &user).await.contains(&which.fingerprint) {
return Err(refuse(
"permissiondenied",
"that key is this console's own, registered on your principal so your session can \
read as you; revoking it would blank your own console. Revoke the passkey instead \
and this key goes with it",
));
}
let gunnar = as_the_person(&state, &user, &principal).await?;
gunnar
.access()
.revoke_key(pb::RevokeKeyRequest {
fingerprint: which.fingerprint.clone(),
})
.await
.map_err(ApiError::from)?;
state.caches.invalidate_access().await;
tracing::warn!(
target: AUDIT_TARGET,
event = "key.revoke",
by = %user,
user = %user,
fingerprint = %which.fingerprint,
"somebody revoked one of their own keys"
);
Ok(Json(crate::api::repos::Done { ok: true }))
}
// ── the console's own key on this person's principal ─────────────────────────
/// **What the console's credential on this account is doing**, and what the
/// person has to do about it if it is stuck.
///
/// See [`crate::tenant`] for the mechanism. In short: the console's reads are
/// answered with a key of the tenant's own, and gunnar will only accept a
/// SECOND key on a principal if the principal vouches for it. Everyone who
/// added an SSH key before their first console read is in exactly that state,
/// and until 2026-09-18 they had no way out and no sentence telling them so —
/// their reads silently fell back to the grantless guest and showed the public
/// subset of a box they pay for.
#[derive(Debug, Clone, serde::Serialize)]
pub struct ConsoleKey {
/// `enrolled`, `awaiting_attestation`, or `absent`.
///
/// A word and not a boolean because there are three states and the third —
/// no principal behind this session at all — is not a degenerate case of
/// either other one.
pub state: String,
/// The principal this session acts for, when there is one.
pub principal_id: String,
/// `SHA256:…` of the console's key: the enrolled one, or the one waiting.
pub fingerprint: String,
/// The OpenSSH line to attest. Empty unless something is waiting — a
/// public key on screen with nothing to do about it is noise.
pub openssh_public_key: String,
/// **The exact command to run on the appliance**, composed from the two
/// values above so nobody has to assemble it from a log line. Empty unless
/// something is waiting.
pub command: String,
/// **gunnar's own sentence**, when it refused. Verbatim: the appliance
/// knows why and this console does not, and a reworded refusal leaves a
/// reader comparing two explanations with no way to tell which is true.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub refusal: String,
}
/// The attestation, as `gunnar control user attest-key` printed it.
///
/// The CLI prints `gunnar_core::SignedKeyAttestation` as JSON with the inner
/// attestation FLATTENED (`#[serde(flatten)]`), so these five fields are one
/// object. Mirrored here rather than shared, because this crate vendors
/// gunnar's protos and not its core; `the_attestation_shape_is_gunnars` pins
/// the field names against the vendored proto so the mirror cannot drift
/// quietly.
///
/// Unknown fields are ALLOWED, unlike gunnar's own struct: a newer appliance
/// that adds a field would otherwise make every paste a local parse error on a
/// console that is merely relaying it.
#[derive(Debug, Clone, Deserialize)]
pub struct Attestation {
pub principal_id: String,
pub fingerprint: String,
pub at_unix_ms: i64,
pub key_fingerprint: String,
pub signature: String,
}
/// What the browser posts: the CLI's output, pasted whole.
#[derive(Debug, Clone, Deserialize)]
pub struct Attested {
/// The JSON text `gunnar control user attest-key` printed. Taken as TEXT
/// rather than as a parsed object so the person pastes exactly what they
/// were given — a form that asked for five fields separately is a form
/// somebody fills in wrong at 2am.
pub attestation: String,
}
/// `GET /api/account/console-key`
///
/// **The key this console signs with for the person: their session key**,
/// which gunnar enrolled on their principal when it verified their passkey
/// (thin layer L). Always `enrolled` for a live session — nothing waits on the
/// person's word any more, because the verified passkey IS their consent.
pub async fn console_key(Me(user): Me, State(state): State<AppState>) -> ApiResult<ConsoleKey> {
match crate::api::person_identity(&state, &user)
.await
.map_err(tenant_refusal)?
{
Some(identity) => Ok(Json(ConsoleKey {
state: "enrolled".into(),
principal_id: identity.principal_id,
fingerprint: identity.fingerprint,
openssh_public_key: String::new(),
command: String::new(),
refusal: String::new(),
})),
None => Ok(Json(ConsoleKey {
state: "absent".into(),
principal_id: String::new(),
fingerprint: String::new(),
openssh_public_key: String::new(),
command: String::new(),
refusal: "this sign-in holds no key at Gunnar any more; log in again".into(),
})),
}
}
/// `POST /api/account/console-key/attestation`
///
/// **Nothing waits on an attestation any more** (thin layer L): the session's
/// key is enrolled by gunnar when it verifies the person's passkey. Kept as a
/// route so an old page gets a sentence rather than a 404.
pub async fn attest_console_key(
Me(_user): Me,
State(_state): State<AppState>,
Json(_body): Json<Attested>,
) -> ApiResult<ConsoleKey> {
Err(refuse(
"failedprecondition",
"no console key waits on an attestation: Gunnar enrols your session's key when it \
verifies your passkey",
))
}
/// gunnar's refusal, or this console's, in the shape the console renders.
///
/// [`crate::tenant::TenantError`] already carries gunnar's sentence verbatim;
/// this only gives it a code, and never reworks the words.
fn tenant_refusal(why: crate::tenant::TenantError) -> ApiError {
refuse("failedprecondition", &why.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tower::ServiceExt as _;
use crate::pb::access_server::{Access as AccessService, AccessServer};
/// The two people on this fixture's box. `anna` is the tenant who just
/// signed up and holds no key; `olof` is somebody else, and every test that
/// matters here is about anna not being able to touch his.
const ANNA: &str = "p-anna";
const OLOF: &str = "p-olof";
/// Olof's key, and the fingerprint anna can read off `GET /api/keys` — which
/// is exactly why `Access.RevokeKey` taking a fingerprint and nothing else
/// has to be checked on this side.
const OLOFS_KEY: &str = "SHA256:olofolofolofolof";
/// Percent-encode a fingerprint for the query string, the way the console
/// does (`gunnar_ui_web::api::segment`): everything outside RFC 3986's
/// unreserved set. A raw `+` in a query value decodes as a SPACE, and a
/// real base64 fingerprint has them.
fn encoded(value: &str) -> String {
value
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
other => format!("%{other:02X}"),
})
.collect()
}
fn unimplemented<T>(what: &str) -> Result<T, tonic::Status> {
Err(tonic::Status::unimplemented(format!(
"not this fixture's: {what}"
)))
}
/// A gunnar with a principal table and a key table, and a log of the calls
/// it was asked to make.
#[derive(Clone)]
struct FakeGunnar {
keys: Arc<Mutex<Vec<pb::PublicKey>>>,
calls: Arc<Mutex<Vec<String>>>,
/// What `AddKey` refuses with, when it is set. gunnar refuses a key
/// that already belongs to another principal, and the console must pass
/// that sentence on rather than invent one.
refuse_add: Option<String>,
/// **gunnar's attestation rule, as this fixture keeps it**: a SECOND
/// key on a principal needs the principal's own word. Off by default so
/// every older row here is unaffected; on for the rows about the
/// deadlock it caused.
require_attestation: bool,
/// Every attestation `AddKey` was handed, so a test can assert the
/// console relayed one rather than merely stopped failing.
attestations: Arc<Mutex<Vec<pb::SignedKeyAttestation>>>,
/// Every key call, as `<Method> by <who>` — `console`, the principal
/// whose key signed it, or `stranger` (thin layer, 2026-10-04).
by: Arc<Mutex<Vec<String>>>,
}
impl Default for FakeGunnar {
fn default() -> FakeGunnar {
FakeGunnar {
keys: Arc::new(Mutex::new(vec![pb::PublicKey {
fingerprint: OLOFS_KEY.to_owned(),
algo: "ssh-ed25519".to_owned(),
principal_id: OLOF.to_owned(),
comment: "olof's laptop".to_owned(),
added_at_unix_ms: 1,
last_used_unix_ms: None,
expires_at_unix_ms: None,
}])),
calls: Arc::new(Mutex::new(Vec::new())),
refuse_add: None,
require_attestation: false,
attestations: Arc::new(Mutex::new(Vec::new())),
by: Arc::new(Mutex::new(Vec::new())),
}
}
}
/// The console's own signing seed in this fixture (see [`console`]).
const CONSOLE_SEED: [u8; 32] = [21u8; 32];
impl FakeGunnar {
fn record(&self, what: String) {
self.calls.lock().expect("the call log").push(what);
}
fn calls(&self) -> Vec<String> {
self.calls.lock().expect("the call log").clone()
}
/// Whose key signed this call, by gunnar's key table. A key with no
/// row here is this fixture's administrator — the console's own key,
/// or one of the admin clients the tenant-key rows build by seed.
fn who<T>(&self, request: &tonic::Request<T>) -> String {
let fingerprint = 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();
let console = crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&CONSOLE_SEED),
))
.expect("the console's signer")
.fingerprint()
.to_owned();
if fingerprint == console {
return "console".to_owned();
}
self.keys
.lock()
.expect("the key table")
.iter()
.find(|k| k.fingerprint == fingerprint)
.map(|k| k.principal_id.clone())
.unwrap_or_else(|| "console".to_owned())
}
fn signed(&self, method: &str, who: &str) {
self.by.lock().expect("the by log").push(format!("{method} by {who}"));
}
fn by(&self) -> Vec<String> {
self.by.lock().expect("the by log").clone()
}
fn fingerprints_of(&self, principal: &str) -> Vec<String> {
self.keys
.lock()
.expect("the key table")
.iter()
.filter(|k| k.principal_id == principal)
.map(|k| k.fingerprint.clone())
.collect()
}
}
#[tonic::async_trait]
impl AccessService for FakeGunnar {
async fn list_principals(
&self,
_request: tonic::Request<pb::ListPrincipalsRequest>,
) -> Result<tonic::Response<pb::ListPrincipalsResponse>, tonic::Status> {
self.record("Access.ListPrincipals".to_owned());
Ok(tonic::Response::new(pb::ListPrincipalsResponse {
principals: vec![
pb::Principal {
id: ANNA.to_owned(),
name: "anna".to_owned(),
kind: pb::PrincipalKind::User as i32,
created_at_unix_ms: 1,
},
pb::Principal {
id: OLOF.to_owned(),
name: "olof".to_owned(),
kind: pb::PrincipalKind::User as i32,
created_at_unix_ms: 1,
},
],
next_page_token: String::new(),
..Default::default()
}))
}
async fn list_keys(
&self,
request: tonic::Request<pb::ListKeysRequest>,
) -> Result<tonic::Response<pb::ListKeysResponse>, tonic::Status> {
let who = self.who(&request);
let asked = request.into_inner();
self.record(format!("Access.ListKeys {}", asked.principal_id));
self.signed("Access.ListKeys", &who);
// gunnar's rule: the server's admin lists any; a principal its
// OWN (self-scope); anybody else is refused.
if who != "console" && (asked.principal_id.is_empty() || asked.principal_id != who) {
return Err(tonic::Status::permission_denied(format!(
"principal {who} has no admin grant on server"
)));
}
let held = self.keys.lock().expect("the key table");
let keys = held
.iter()
.filter(|k| asked.principal_id.is_empty() || k.principal_id == asked.principal_id)
.cloned()
.collect();
Ok(tonic::Response::new(pb::ListKeysResponse { keys }))
}
async fn add_key(
&self,
request: tonic::Request<pb::AddKeyRequest>,
) -> Result<tonic::Response<pb::AddKeyResponse>, tonic::Status> {
let who = self.who(&request);
let asked = request.into_inner();
self.record(format!("Access.AddKey {}", asked.principal_id));
self.signed("Access.AddKey", &who);
if let Some(refusal) = &self.refuse_add {
return Err(tonic::Status::already_exists(refusal.clone()));
}
// gunnar's self-scope: a principal adding a key to ITSELF needs no
// grant and owes no attestation. Anybody else needs the server's
// Admin (the console's key, here).
let own = who == asked.principal_id;
if !own && who != "console" {
return Err(tonic::Status::permission_denied(format!(
"principal {who} has no admin grant on server"
)));
}
// gunnar's own precondition, in its own words: a principal that
// already holds a signing key has somebody to ask, so a caller who
// is not the target must bring their answer.
if self.require_attestation && !own {
// **gunnar's rule exactly**: a principal holding NO key has
// nobody to ask, so the first enrolment is free. Refusing that
// one too would make the fixture describe a gunnar that cannot
// bootstrap anybody, and the console's whole first-enrolment
// path would look broken in tests and work in production.
let held = self.fingerprints_of(&asked.principal_id).len();
match &asked.attestation {
None if held > 0 => {
return Err(tonic::Status::failed_precondition(format!(
"principal {} already holds {held} key(s), so adding another \
needs their own attestation",
asked.principal_id
)));
}
None => {}
Some(signed) => self
.attestations
.lock()
.expect("the attestation log")
.push(signed.clone()),
}
}
// **gunnar derives the fingerprint from the line, and so does
// this.** It used to be a synthetic function of the string, which
// was enough while nothing compared a fingerprint across the wire
// — and stopped being enough the day `TenantKeys` began checking
// whether gunnar still holds the key it thinks it enrolled: that
// check compares the REAL fingerprint against this table, and a
// stand-in made every credential look forgotten.
//
// A line that is not a key keeps the synthetic form, because
// several rows here paste deliberate rubbish and want gunnar's
// later refusal rather than a parse error at this seam.
let key = pb::PublicKey {
fingerprint: ssh_key::PublicKey::from_openssh(&asked.openssh_public_key)
.map(|k| k.fingerprint(ssh_key::HashAlg::Sha256).to_string())
.unwrap_or_else(|_| {
format!(
"SHA256:{}",
asked
.openssh_public_key
.replace(|c: char| !c.is_ascii_alphanumeric(), "")
)
}),
algo: "ssh-ed25519".to_owned(),
principal_id: asked.principal_id,
comment: asked.comment,
added_at_unix_ms: 2,
last_used_unix_ms: None,
expires_at_unix_ms: None,
};
self.keys.lock().expect("the key table").push(key.clone());
Ok(tonic::Response::new(pb::AddKeyResponse { key: Some(key) }))
}
async fn revoke_key(
&self,
request: tonic::Request<pb::RevokeKeyRequest>,
) -> Result<tonic::Response<pb::RevokeKeyResponse>, tonic::Status> {
let who = self.who(&request);
let asked = request.into_inner();
self.record(format!("Access.RevokeKey {}", asked.fingerprint));
self.signed("Access.RevokeKey", &who);
// gunnar's self-scope: a principal revokes only a key it holds;
// any other fingerprint is the server admin's — the same sentence
// whether it exists or not.
let own = self
.keys
.lock()
.expect("the key table")
.iter()
.any(|k| k.fingerprint == asked.fingerprint && k.principal_id == who);
if who != "console" && !own {
return Err(tonic::Status::permission_denied(format!(
"principal {who} has no admin grant on server"
)));
}
self.keys
.lock()
.expect("the key table")
.retain(|k| k.fingerprint != asked.fingerprint);
Ok(tonic::Response::new(pb::RevokeKeyResponse {}))
}
async fn add_principal(
&self,
_r: tonic::Request<pb::AddPrincipalRequest>,
) -> Result<tonic::Response<pb::AddPrincipalResponse>, tonic::Status> {
// Recorded rather than merely refused: `a_session_with_no_principal
// _is_refused_rather_than_given_one` asserts this was never reached,
// and an `unimplemented!()` would have made the same test pass by
// panicking the server task instead.
self.record("Access.AddPrincipal".to_owned());
unimplemented("AddPrincipal")
}
async fn rename_principal(
&self,
_r: tonic::Request<pb::RenamePrincipalRequest>,
) -> Result<tonic::Response<pb::RenamePrincipalResponse>, tonic::Status> {
unimplemented("RenamePrincipal")
}
async fn remove_principal(
&self,
_r: tonic::Request<pb::RemovePrincipalRequest>,
) -> Result<tonic::Response<pb::RemovePrincipalResponse>, tonic::Status> {
unimplemented("RemovePrincipal")
}
async fn grant(
&self,
_r: tonic::Request<pb::GrantRequest>,
) -> Result<tonic::Response<pb::GrantResponse>, tonic::Status> {
unimplemented("Grant")
}
async fn revoke(
&self,
_r: tonic::Request<pb::RevokeRequest>,
) -> Result<tonic::Response<pb::RevokeResponse>, tonic::Status> {
unimplemented("Revoke")
}
async fn list_grants(
&self,
_r: tonic::Request<pb::ListGrantsRequest>,
) -> Result<tonic::Response<pb::ListGrantsResponse>, tonic::Status> {
unimplemented("ListGrants")
}
async fn add_group_member(
&self,
_r: tonic::Request<pb::AddGroupMemberRequest>,
) -> Result<tonic::Response<pb::AddGroupMemberResponse>, tonic::Status> {
unimplemented("AddGroupMember")
}
async fn remove_group_member(
&self,
_r: tonic::Request<pb::RemoveGroupMemberRequest>,
) -> Result<tonic::Response<pb::RemoveGroupMemberResponse>, tonic::Status> {
unimplemented("RemoveGroupMember")
}
async fn list_group_members(
&self,
_r: tonic::Request<pb::ListGroupMembersRequest>,
) -> Result<tonic::Response<pb::ListGroupMembersResponse>, tonic::Status> {
unimplemented("ListGroupMembers")
}
async fn list_group_membership_events(
&self,
_r: tonic::Request<pb::ListGroupMembershipEventsRequest>,
) -> Result<tonic::Response<pb::ListGroupMembershipEventsResponse>, tonic::Status> {
unimplemented("ListGroupMembershipEvents")
}
}
/// A console with a REAL passkey subsystem, so `require_auth` runs the role
/// ladder rather than the open-on-loopback bypass and sessions can be
/// minted for it. Without that every one of the refusals below would pass
/// vacuously.
async fn console(fake: FakeGunnar) -> (axum::Router, Arc<crate::auth::Webauthn>, FakeGunnar) {
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();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(AccessServer::new(serving))
.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: "console.example".to_owned(),
origin: "https://console.example".to_owned(),
rp_name: "gunnar".to_owned(),
},
store.clone(),
)
.expect("a subsystem"),
);
let signer = Arc::new(
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&CONSOLE_SEED),
))
.expect("a fixed seed makes a signer"),
);
let endpoint = format!("http://{addr}");
let router = crate::serve::router(Arc::new(crate::app::App {
webauthn: Some(webauthn.clone()),
// Set, so `the_admin_token_is_not_a_person` has a token to offer.
admin_token: Some("break-glass".to_owned()),
#[cfg(feature = "metered")]
monetize: None,
events: crate::sse::EventHub::detached(),
gunnar: crate::grpc::Gunnar::connect_lazy(&endpoint, 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)),
forges: Default::default(),
imports: Default::default(),
forge_apps: Default::default(),
ui_dir: None,
caches: crate::api::Caches::default(),
objects: Default::default(),
readmes: Default::default(),
}));
(router, webauthn, fake)
}
/// The same box, plus the two things a RESTART has to be modelled with: the
/// state store (which survives one) and gunnar's endpoint (which a fresh
/// `TenantKeys` is built over). Nothing else can express "the process came
/// back and read its own disk", and that is the case
/// `a_key_gunnar_has_forgotten_is_dropped_and_asked_for_again` is about.
async fn console_and_its_disk(
fake: FakeGunnar,
) -> (Arc<crate::auth::Webauthn>, FakeGunnar, crate::state::StateStore, String) {
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();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(AccessServer::new(serving))
.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: "console.example".to_owned(),
origin: "https://console.example".to_owned(),
rp_name: "gunnar".to_owned(),
},
store.clone(),
)
.expect("a subsystem"),
);
(webauthn, fake, store, format!("http://{addr}"))
}
/// One request, optionally carrying a session cookie or a bearer token.
async fn send(
router: &axum::Router,
method: http::Method,
uri: &str,
cookie: Option<&str>,
token: Option<&str>,
body: &str,
) -> (http::StatusCode, String) {
let mut builder = http::Request::builder()
.method(method)
.uri(uri)
.header(http::header::CONTENT_TYPE, "application/json")
// The CSRF guard reads the origin on a mutation; this is what a
// real browser on this console sends.
.header(http::header::ORIGIN, "https://console.example");
if let Some(cookie) = cookie {
builder = builder.header(
http::header::COOKIE,
format!("{}={cookie}", crate::auth::SESSION_COOKIE),
);
}
if let Some(token) = token {
builder = builder.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
}
let response = router
.clone()
.oneshot(
builder
.body(axum::body::Body::from(body.to_owned()))
.expect("a request"),
)
.await
.expect("the console answers");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.expect("a body");
(status, String::from_utf8_lossy(&bytes).into_owned())
}
/// ★ **A VIEWER adds, lists and removes a key on their own name.**
///
/// The whole point of the surface. Red before it existed: `/api/keys` is
/// admin-floored, so a tenant who had just registered a passkey through a
/// paid signup could read the console and never push to it.
#[tokio::test]
async fn a_viewer_adds_lists_and_removes_a_key_on_their_own_name() {
let (router, webauthn, fake) = console(FakeGunnar::default()).await;
webauthn.plant_bound_credential_for_tests("anna", crate::role::Role::Viewer, ANNA);
let anna = webauthn.make_session("anna", crate::role::Role::Viewer, true);
let (added, body) = send(
&router,
http::Method::POST,
"/api/account/keys",
Some(&anna),
None,
r#"{"openssh_public_key":"ssh-ed25519 AAAAannakey anna@laptop","comment":"laptop"}"#,
)
.await;
assert_eq!(added, http::StatusCode::OK, "a viewer was refused: {body}");
let (listed, mine) = send(
&router,
http::Method::GET,
"/api/account/keys",
Some(&anna),
None,
"",
)
.await;
assert_eq!(listed, http::StatusCode::OK, "{mine}");
assert!(mine.contains(ANNA), "{mine}");
assert!(
!mine.contains(OLOFS_KEY),
"somebody else's key came back from a route that is about your own: {mine}"
);
// Hers is the one the page lists; the console's own key on her
// principal (what her session reads with) is not shown.
let listed: serde_json::Value = serde_json::from_str(&mine).expect("json");
let items = listed["items"].as_array().expect("items");
assert_eq!(items.len(), 1, "{mine}");
let fingerprint = items[0]["fingerprint"].as_str().expect("a fingerprint").to_owned();
let (removed, gone) = send(
&router,
http::Method::DELETE,
&format!("/api/account/keys?fingerprint={}", encoded(&fingerprint)),
Some(&anna),
None,
"",
)
.await;
assert_eq!(removed, http::StatusCode::OK, "{gone}");
assert!(!fake.fingerprints_of(ANNA).contains(&fingerprint));
// ★ Every key call was signed AS anna (thin layer, owner 2026-10-04):
// gunnar's self-scope decided, the console's key only enrolled her
// session's own credential.
let by = fake.by();
for method in ["Access.ListKeys", "Access.RevokeKey"] {
assert!(
by.iter().filter(|c| c.starts_with(method)).all(|c| c.ends_with(&format!("by {ANNA}"))),
"{method} was not signed by anna: {by:?}"
);
}
assert_eq!(
by.iter().filter(|c| c.starts_with("Access.AddKey")).cloned().collect::<Vec<_>>(),
vec!["Access.AddKey by console".to_owned(), format!("Access.AddKey by {ANNA}")],
"the console enrols her session key; her own key is added by her"
);
assert_eq!(
fake.fingerprints_of(OLOF),
vec![OLOFS_KEY.to_owned()],
"removing your own key touched somebody else's"
);
}
/// **And the admin door stays shut to them.** The floor on `/api/keys` is
/// what this route exists to avoid lowering, so it is asserted here rather
/// than assumed: if a later edit widened it, this surface would have been
/// built for nothing and the widening would be the actual change.
#[tokio::test]
async fn a_viewer_cannot_reach_the_admin_key_surface() {
let (router, webauthn, fake) = console(FakeGunnar::default()).await;
let anna = webauthn.make_session("anna", crate::role::Role::Viewer, true);
let (status, body) = send(
&router,
http::Method::POST,
"/api/keys",
Some(&anna),
None,
r#"{"principal_id":"p-olof","openssh_public_key":"ssh-ed25519 AAAAsneak x"}"#,
)
.await;
assert_eq!(status, http::StatusCode::FORBIDDEN, "{body}");
assert!(
body.contains("admin"),
"the refusal must name the role: {body}"
);
assert!(
!fake.calls().iter().any(|c| c.starts_with("Access.AddKey")),
"gunnar was told about a key this console had already refused: {:?}",
fake.calls()
);
}
/// ★ **A body that names somebody else is refused, and lands on nobody.**
///
/// Refused rather than ignored: serde drops an unknown field silently, so a
/// client sending `{"user":"olof", …}` would have got a 200 and a key on the
/// right name with nothing to tell it the field meant nothing. Both halves
/// are asserted — the refusal, and that neither principal grew a key.
#[tokio::test]
async fn a_body_that_names_somebody_else_is_refused_and_lands_on_nobody() {
for body in [
r#"{"openssh_public_key":"ssh-ed25519 AAAAsneak x","user":"olof"}"#,
r#"{"openssh_public_key":"ssh-ed25519 AAAAsneak x","principal_id":"p-olof"}"#,
] {
let (router, webauthn, fake) = console(FakeGunnar::default()).await;
let anna = webauthn.make_session("anna", crate::role::Role::Viewer, true);
let (status, answer) = send(
&router,
http::Method::POST,
"/api/account/keys",
Some(&anna),
None,
body,
)
.await;
assert_eq!(
status,
http::StatusCode::BAD_REQUEST,
"{body} was accepted: {answer}"
);
assert_eq!(
fake.fingerprints_of(OLOF),
vec![OLOFS_KEY.to_owned()],
"the key landed on the name in the BODY: {body}"
);
assert!(
fake.fingerprints_of(ANNA).is_empty(),
"a refused request still registered a key: {body}"
);
assert!(
!fake.calls().iter().any(|c| c.starts_with("Access.AddKey")),
"gunnar heard about a body this console refuses: {:?}",
fake.calls()
);
}
}
/// ★ **Somebody else's key cannot be revoked — gunnar refuses the person's
/// own call.**
///
/// `Access.RevokeKey` takes a fingerprint and nothing else. Since
/// 2026-10-04 it is signed AS THE PERSON and gunnar's self-scope rule
/// scopes it: a principal revokes only a key it holds.
#[tokio::test]
async fn a_key_that_is_not_yours_cannot_be_revoked() {
let (router, webauthn, fake) = console(FakeGunnar::default()).await;
webauthn.plant_bound_credential_for_tests("anna", crate::role::Role::Viewer, ANNA);
let anna = webauthn.make_session("anna", crate::role::Role::Viewer, true);
let (status, body) = send(
&router,
http::Method::DELETE,
&format!("/api/account/keys?fingerprint={}", encoded(OLOFS_KEY)),
Some(&anna),
None,
"",
)
.await;
assert_eq!(status, http::StatusCode::FORBIDDEN, "{body}");
assert!(body.contains("anna"), "{body}");
assert_eq!(
fake.fingerprints_of(OLOF),
vec![OLOFS_KEY.to_owned()],
"a viewer revoked somebody else's key"
);
// ★ The refusal is gunnar's, of anna's OWN call — never a revoke the
// console's server-wide key could have made for her.
assert_eq!(
fake.by().iter().filter(|c| c.starts_with("Access.RevokeKey")).cloned().collect::<Vec<_>>(),
vec![format!("Access.RevokeKey by {ANNA}")],
);
}
/// **A session with no principal is refused, never given one.**
///
/// Minting one is `Access.AddPrincipal`, which creates an identity AND its
/// namespace. Doing that from a viewer's floor is the escalation this whole
/// module is shaped to not have, so the empty call log is the assertion
/// that matters.
#[tokio::test]
async fn a_session_with_no_principal_is_refused_rather_than_given_one() {
let (router, webauthn, fake) = console(FakeGunnar::default()).await;
let stranger = webauthn.make_session("nobody", crate::role::Role::Viewer, true);
let (status, body) = send(
&router,
http::Method::POST,
"/api/account/keys",
Some(&stranger),
None,
r#"{"openssh_public_key":"ssh-ed25519 AAAAnobody x"}"#,
)
.await;
// Since passkeys live at gunnar (thin layer L) the session's principal
// is gunnar's answer at the login; a session carrying none is told to
// log in again, and nothing is created for it.
assert_eq!(status, http::StatusCode::BAD_REQUEST, "{body}");
assert!(body.contains("log in again"), "{body}");
assert!(
!fake.calls().contains(&"Access.AddPrincipal".to_owned()),
"the console created an identity for a viewer: {:?}",
fake.calls()
);
}
/// **The break-glass token is not a person.** It is held by whoever runs
/// the process and there is no principal named after it; an admin adding
/// somebody a key has `/api/keys`.
#[tokio::test]
async fn the_admin_token_is_not_a_person_and_has_no_keys_of_its_own() {
let (router, _, fake) = console(FakeGunnar::default()).await;
let (status, body) = send(
&router,
http::Method::GET,
"/api/account/keys",
None,
Some("break-glass"),
"",
)
.await;
assert_eq!(status, http::StatusCode::FORBIDDEN, "{body}");
assert!(fake.calls().is_empty(), "{:?}", fake.calls());
}
/// **gunnar's refusal, by name.** A key that already belongs to another
/// principal is refused there; this console is not a second opinion about
/// why, and rewording it would leave an operator comparing two sentences
/// with no way to know which to believe.
#[tokio::test]
async fn gunnars_own_refusal_is_passed_through_unreworded() {
let refusal = "that key is already registered to principal p-olof";
let (router, webauthn, _) = console(FakeGunnar {
refuse_add: Some(refusal.to_owned()),
..FakeGunnar::default()
})
.await;
webauthn.plant_bound_credential_for_tests("anna", crate::role::Role::Viewer, ANNA);
let anna = webauthn.make_session("anna", crate::role::Role::Viewer, true);
let (status, body) = send(
&router,
http::Method::POST,
"/api/account/keys",
Some(&anna),
None,
r#"{"openssh_public_key":"ssh-ed25519 AAAAolofkey x"}"#,
)
.await;
assert_ne!(status, http::StatusCode::OK, "{body}");
assert!(body.contains(refusal), "the refusal was reworded: {body}");
}
// ── the console's own key, and the deadlock it used to sit in ────────────
async fn console_key_of(router: &axum::Router, session: &str) -> serde_json::Value {
let (status, body) = send(
router,
http::Method::GET,
"/api/account/console-key",
Some(session),
None,
"",
)
.await;
assert_eq!(status, http::StatusCode::OK, "{body}");
serde_json::from_str(&body).expect("the console key answer is json")
}
/// ★ **The defect that made every paid account unpushable, closed.**
///
/// gunnar allows ONE unattested key per principal. The console spends it on
/// itself — the tenant credential, enrolled the first time a bound person
/// reads anything — so when the customer pasted their OWN key it was the
/// second, and gunnar refused, telling them to run `attest-key` with a key
/// whose private half never leaves this process. `git push` therefore never
/// worked for any paid account.
///
/// This drives the real order through the real router: the console's key
/// enrols first (free, the principal holds none), then the person's own key
/// goes in behind an attestation the console makes with that credential.
/// The assertion is on what reached GUNNAR, because "the request stopped
/// failing" would also pass if the attestation were empty.
#[tokio::test]
async fn a_person_can_add_their_own_key_after_the_console_has_taken_the_free_slot() {
let (router, webauthn, fake) = console(FakeGunnar {
require_attestation: true,
..FakeGunnar::default()
})
.await;
webauthn.plant_bound_credential_for_tests("anna", crate::role::Role::Viewer, ANNA);
let anna = webauthn.make_session("anna", crate::role::Role::Viewer, true);
// The console takes the free slot, exactly as a first read would.
let state = console_key_of(&router, &anna).await;
assert_eq!(state["state"], "enrolled", "the console's own key did not enrol: {state}");
assert_eq!(fake.fingerprints_of(ANNA).len(), 1, "anna holds no console key");
// Now anna's own key — the second on her principal, and the one that
// used to be impossible.
let hers = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[9u8; 32]))
.public_key()
.to_openssh()
.expect("a public key line");
let want = ssh_key::PublicKey::from_openssh(&hers)
.expect("parses")
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string();
let body = serde_json::json!({ "openssh_public_key": hers }).to_string();
let (status, answer) = send(
&router,
http::Method::POST,
"/api/account/keys",
Some(&anna),
None,
&body,
)
.await;
assert_eq!(status, http::StatusCode::OK, "{answer}");
// **What gunnar was actually handed** (thin layer, owner 2026-10-04):
// her AddKey, signed by HER — so gunnar's self-scope rule admits it
// and no attestation is owed. The console vouches for nobody.
assert!(
fake.attestations.lock().expect("the log").is_empty(),
"the console signed a consent on anna's behalf"
);
assert_eq!(
fake.by().last().cloned(),
Some(format!("Access.AddKey by {ANNA}")),
"her key was not added as her"
);
assert!(fake.fingerprints_of(ANNA).contains(&want), "her key did not land");
assert_eq!(fake.fingerprints_of(ANNA).len(), 2, "her key did not land");
}
/// **The admin surface does NOT get this.** `/api/keys` names any principal
/// on the box, and there the console has no business vouching for anybody:
/// gunnar's refusal stands and reaches the caller in gunnar's own words.
/// The whole safety of the row above is that the target is the session's
/// own name, so the distinction is worth a test rather than a comment.
#[tokio::test]
async fn the_admin_key_surface_still_cannot_vouch_for_anybody() {
let (router, webauthn, fake) = console(FakeGunnar {
require_attestation: true,
..FakeGunnar::default()
})
.await;
let boss = webauthn.make_session("boss", crate::role::Role::Admin, true);
// olof already holds a key in this fixture, so an admin adding another
// to HIM is exactly the case gunnar guards.
let body = serde_json::json!({
"principal_id": OLOF,
"openssh_public_key": "ssh-ed25519 AAAAsomebodyelseskey x",
})
.to_string();
let (status, answer) = send(&router, http::Method::POST, "/api/keys", Some(&boss), None, &body).await;
assert_ne!(status, http::StatusCode::OK, "an admin minted a key as another person: {answer}");
assert!(
answer.contains("attestation"),
"gunnar's reason did not reach the caller: {answer}"
);
assert!(
fake.attestations.lock().expect("the log").is_empty(),
"the console vouched on the ADMIN surface"
);
}
/// ★ **A re-imaged appliance does not leave a tenant permanently mute.**
///
/// The two halves of this credential live in different places: the private
/// half in the console's state store, the public half in gunnar's key
/// table. A re-image resets gunnar's and leaves the console's untouched, so
/// a console that trusted its own disk would go on signing with a key the
/// appliance has never heard of — `Unauthenticated` on every read, for
/// ever, from a stored record that looks perfectly healthy on this side.
///
/// Modelled the only way it can be: the same store, a FRESH `TenantKeys`
/// over it, which is what a restart is. A warm credential is still believed
/// without asking — that is the whole point of the split — so a test that
/// reused the old handle would prove nothing.
#[tokio::test]
async fn a_key_gunnar_has_forgotten_is_dropped_and_asked_for_again() {
let (_webauthn, fake, store, endpoint) = console_and_its_disk(FakeGunnar::default()).await;
let signer = Arc::new(
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[21u8; 32]),
))
.expect("a fixed seed makes a signer"),
);
let admin = crate::grpc::Gunnar::connect_lazy(&endpoint, signer).expect("a handle");
let before = crate::tenant::TenantKeys::new(endpoint.clone(), store.clone());
let first = before.ensure("anna", ANNA, &admin).await.expect("a first enrolment");
assert!(
fake.fingerprints_of(ANNA).contains(&first.fingerprint),
"gunnar did not take the first key"
);
// The re-image: gunnar's key table loses everything, the console's disk
// keeps the private half.
fake.keys.lock().expect("the key table").clear();
// The restart.
let after = crate::tenant::TenantKeys::new(endpoint, store);
let second = after.ensure("anna", ANNA, &admin).await.expect("a re-provision");
assert_ne!(
second.fingerprint, first.fingerprint,
"the console went on using a key gunnar has forgotten"
);
assert!(
fake.fingerprints_of(ANNA).contains(&second.fingerprint),
"the credential handed back is not one gunnar holds: {:?}",
fake.fingerprints_of(ANNA)
);
}
/// A key table row as `pair` leaves one.
fn key_row(principal: &str, fingerprint: &str) -> pb::PublicKey {
pb::PublicKey {
fingerprint: fingerprint.to_owned(),
algo: "ssh-ed25519".to_owned(),
principal_id: principal.to_owned(),
comment: String::new(),
added_at_unix_ms: 1,
last_used_unix_ms: None,
expires_at_unix_ms: None,
}
}
fn admin_over(endpoint: &str, seed: u8) -> crate::grpc::Gunnar {
let signer = Arc::new(
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32]),
))
.expect("a fixed seed makes a signer"),
);
crate::grpc::Gunnar::connect_lazy(endpoint, signer).expect("a handle")
}
/// **The ladder's seed is adopted: no key minted, nothing asked of gunnar
/// but "do you hold it".** `cargo xtask estate rebuild` lays
/// `<GUNNAR_UI_TENANT_SEEDS>/<principal>` on this box at `pair`, enrols it
/// second on the operator's principal and has the operator's own key vouch
/// for it. This is the console's half: the first read after the operator's
/// first login is answered with THAT key. Inverted in the same test — the
/// same appliance, no seeds directory — the console mints one, gunnar
/// refuses it (the operator's key spent the free slot), and the paste form
/// is back; so a door that did nothing would fail here.
#[tokio::test]
async fn a_seed_the_ladder_enrolled_is_adopted_without_minting_or_asking() {
let fake = FakeGunnar {
require_attestation: true,
..Default::default()
};
let (_webauthn, fake, store, endpoint) = console_and_its_disk(fake).await;
let admin = admin_over(&endpoint, 23);
let seeds = tempfile::tempdir().expect("a seeds dir");
let seed = crate::reviewer::mint_ed25519().expect("a seed");
std::fs::write(seeds.path().join(ANNA), &seed).expect("the seed laid");
let seed_fp = crate::signer::Signer::from_openssh(&seed)
.expect("the seed parses")
.fingerprint()
.to_owned();
// The appliance as `pair` left it: the operator's key first, the seed second.
fake.keys.lock().expect("the key table").extend([
key_row(ANNA, "SHA256:annasLaptopAnnasLaptopAnnasLaptopAnnasLapt"),
key_row(ANNA, &seed_fp),
]);
let keys = crate::tenant::TenantKeys::new(endpoint.clone(), store.clone())
.with_seeds(Some(seeds.path().to_path_buf()));
let identity = keys.ensure("anna", ANNA, &admin).await.expect("the seed is adopted");
assert_eq!(identity.fingerprint, seed_fp, "the console reads with something other than the seed");
assert_eq!(identity.signer.fingerprint(), seed_fp, "the signer is not the seed's private half");
assert!(
!fake.calls().iter().any(|c| c.starts_with("Access.AddKey")),
"the console asked gunnar for a key it already had: {:?}",
fake.calls()
);
assert_eq!(fake.fingerprints_of(ANNA).len(), 2, "a key was minted beside the seed");
// Inverted: the same appliance, a console with no seeds directory, a
// second console user bound to the same principal. This is the defect.
let blind = crate::tenant::TenantKeys::new(endpoint, store);
let refused = blind.ensure("anna-on-another-browser", ANNA, &admin).await;
let why = refused.err().map(|e| e.to_string()).unwrap_or_default();
assert!(why.contains("already holds"), "without the door gunnar should have refused a third key: {why}");
}
/// **A seed gunnar does not hold is the key ASKED FOR — never a fresh mint
/// beside it.** An appliance re-imaged after the ladder paired it forgets
/// the seed; the console then enrols the seed itself as the principal's
/// first key, so the fingerprint on the box is the one the ladder will find
/// held when it re-pairs, and the one in any refusal is the seed's.
#[tokio::test]
async fn a_seed_gunnar_has_forgotten_is_the_key_asked_for_not_a_fresh_one() {
let (_webauthn, fake, store, endpoint) = console_and_its_disk(FakeGunnar::default()).await;
let admin = admin_over(&endpoint, 24);
let seeds = tempfile::tempdir().expect("a seeds dir");
let seed = crate::reviewer::mint_ed25519().expect("a seed");
std::fs::write(seeds.path().join(ANNA), &seed).expect("the seed laid");
let seed_fp = crate::signer::Signer::from_openssh(&seed)
.expect("the seed parses")
.fingerprint()
.to_owned();
assert!(fake.fingerprints_of(ANNA).is_empty(), "the fixture holds a key for anna already");
let keys = crate::tenant::TenantKeys::new(endpoint, store)
.with_seeds(Some(seeds.path().to_path_buf()));
let identity = keys.ensure("anna", ANNA, &admin).await.expect("enrolled as the first key");
assert_eq!(identity.fingerprint, seed_fp);
assert_eq!(fake.fingerprints_of(ANNA), vec![seed_fp], "gunnar was handed something other than the seed");
assert_eq!(
fake.calls().iter().filter(|c| c.starts_with("Access.AddKey")).count(),
1,
"{:?}",
fake.calls()
);
}
/// **A principal id is a value off the wire, and a seed is a path.** An id
/// that cannot be a plain file name is never joined to the seeds directory;
/// the console mints as before and the file that WOULD have been reached is
/// untouched. And a seed that exists but cannot be read is a refusal, not a
/// quiet fresh mint reporting health.
#[tokio::test]
async fn a_seed_is_reached_by_a_plain_name_only_and_an_unreadable_one_refuses() {
let (_webauthn, fake, store, endpoint) = console_and_its_disk(FakeGunnar::default()).await;
let admin = admin_over(&endpoint, 25);
let seeds = tempfile::tempdir().expect("a seeds dir");
std::fs::create_dir_all(seeds.path().join("a")).expect("a nested dir");
let planted = crate::reviewer::mint_ed25519().expect("a planted key");
std::fs::write(seeds.path().join("a").join("b"), &planted).expect("planted");
let planted_fp = crate::signer::Signer::from_openssh(&planted)
.expect("parses")
.fingerprint()
.to_owned();
let keys = crate::tenant::TenantKeys::new(endpoint.clone(), store.clone())
.with_seeds(Some(seeds.path().to_path_buf()));
let identity = keys.ensure("eve", "a/b", &admin).await.expect("minted as before");
assert_ne!(identity.fingerprint, planted_fp, "a principal id was joined to the seeds directory as a path");
// Unreadable: a directory where the seed should be.
std::fs::create_dir_all(seeds.path().join(ANNA)).expect("a directory in the seed's place");
let refused = keys.ensure("anna", ANNA, &admin).await;
let why = refused.err().map(|e| e.to_string()).unwrap_or_default();
assert!(why.contains("could not be read"), "an unreadable seed did not refuse by name: {why}");
assert!(fake.fingerprints_of(ANNA).is_empty(), "a key was minted for anna past an unreadable seed");
}
/// **A credential discarded because the passkey was re-pinned is a NAMED
/// refusal, once, and the next read provisions.** Until 2026-09-20
/// `existing` answered `Ok(None)` here, which is the same answer as "this
/// user has no credential yet"; a key the estate ladder had enrolled and
/// had the operator vouch for could be given up with only a log line. The
/// refusal names the console user, the principal the record was on, its
/// fingerprint and the pinned principal. It is not fatal: the record is
/// removed BEFORE it is returned, so the second call is `Ok(None)`.
#[tokio::test]
async fn a_credential_discarded_for_a_re_pinned_passkey_is_refused_by_name_once() {
let (_webauthn, _fake, store, endpoint) = console_and_its_disk(FakeGunnar::default()).await;
let admin = admin_over(&endpoint, 26);
let keys = crate::tenant::TenantKeys::new(endpoint, store);
let on_anna = keys.ensure("anna", ANNA, &admin).await.expect("the first enrolment");
let why = keys
.existing("anna", Some("bosse"))
.await
.err()
.map(|e| e.to_string())
.unwrap_or_default();
assert!(why.contains("discarded"), "the drop was not answered by name: {why}");
assert!(why.contains(ANNA) && why.contains("bosse"), "the refusal names neither principal: {why}");
assert!(
why.contains(&on_anna.fingerprint),
"the refusal does not name the fingerprint it gave up: {why}"
);
assert!(!why.contains("PRIVATE KEY"), "the refusal carries key material: {why}");
// Already gone: the same question a second time is the ordinary "no
// credential yet", so nothing is wedged.
assert!(
keys.existing("anna", Some("bosse")).await.expect("no record left").is_none(),
"the discarded record is still being answered"
);
}
/// And the other half of the same split: a credential this process has
/// already used is NOT re-checked, or every tenant read would carry an
/// `Access.ListKeys` in front of it.
#[tokio::test]
async fn a_warm_credential_is_not_re_asked_about_on_every_read() {
let (_webauthn, fake, store, endpoint) = console_and_its_disk(FakeGunnar::default()).await;
let signer = Arc::new(
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[22u8; 32]),
))
.expect("a fixed seed makes a signer"),
);
let admin = crate::grpc::Gunnar::connect_lazy(&endpoint, signer).expect("a handle");
let keys = crate::tenant::TenantKeys::new(endpoint, store);
keys.ensure("anna", ANNA, &admin).await.expect("a first enrolment");
let after_first = fake.calls().len();
for _ in 0..5 {
keys.ensure("anna", ANNA, &admin).await.expect("a warm read");
}
assert_eq!(
fake.calls().len(),
after_first,
"a warm credential cost gunnar a call: {:?}",
fake.calls()
);
}
/// **The paste shape is gunnar's**, and this pins it. The CLI prints
/// `gunnar_core::SignedKeyAttestation`, whose inner attestation is
/// `#[serde(flatten)]` — so five fields in ONE object, not a nested one.
/// Getting that wrong makes every paste a parse error, and the person has
/// no way to tell whether they or the console are at fault.
#[test]
fn the_attestation_shape_is_gunnars() {
let printed = r#"{
"principal_id": "p-anna",
"fingerprint": "SHA256:thenewkey",
"at_unix_ms": 1788000000000,
"key_fingerprint": "SHA256:theoldkey",
"signature": "-----BEGIN SSH SIGNATURE-----\nx\n-----END SSH SIGNATURE-----"
}"#;
let parsed: Attestation = serde_json::from_str(printed).expect("the CLI's own shape");
assert_eq!(parsed.principal_id, "p-anna");
assert_eq!(parsed.fingerprint, "SHA256:thenewkey");
assert_eq!(parsed.key_fingerprint, "SHA256:theoldkey");
assert_eq!(parsed.at_unix_ms, 1_788_000_000_000);
// A newer appliance may print more; relaying must not become a local
// parse error over a field this console never reads.
let with_more = r#"{"principal_id":"p","fingerprint":"f","at_unix_ms":1,
"key_fingerprint":"k","signature":"s","something_new":true}"#;
serde_json::from_str::<Attestation>(with_more).expect("unknown fields are relayed, not refused");
// The proto side, pinned by construction: these are the three fields
// `KeyAttestation` carries and the two `SignedKeyAttestation` adds.
let _ = pb::SignedKeyAttestation {
attestation: Some(pb::KeyAttestation {
principal_id: parsed.principal_id,
fingerprint: parsed.fingerprint,
at_unix_ms: parsed.at_unix_ms,
}),
key_fingerprint: parsed.key_fingerprint,
signature: parsed.signature,
};
}
}