crates/gunnar-ui-server/src/tenant.rs
//! **The credential a logged-in person's READS are answered with — theirs, not
//! this console's and not the grantless guest's.**
//!
//! # The hole this closes, and the one it does not reopen
//!
//! `api::Reader` used to hand `App::gunnar` — the console's server-wide Admin
//! key — to every request that carried a session, whatever its role. A viewer
//! is the tenant role: the lowest rung, minted by payment, and it read every
//! private repository on the box, because gunnar was answering the credential
//! and the credential was the console's (GUI-002).
//!
//! Narrowing a viewer to `App::guest` stopped that, and took something with it
//! that the console is paid to provide: a tenant's own private repositories.
//! The guest principal holds no grants, so a paying customer signed in and saw
//! the public subset — the same thing a stranger sees.
//!
//! Neither credential is the right one, because neither one is *theirs*. So
//! this module mints a third kind: one ed25519 key per console user, enrolled
//! on that person's own gunnar principal. Their reads are signed with it,
//! gunnar applies **their** grants, and the answer is narrowed on gunnar's side
//! of the wire — where a mistake in this crate cannot reach it. The console's
//! admin key goes back to what it is for: administration.
//!
//! # Why this is [`crate::reviewer`]'s shape and not [`crate::reviewer`]
//!
//! The two look alike and answer different questions, and merging them would
//! lose the difference:
//!
//! * a REVIEWER key exists so a forge approval **names a human**. It is
//! enrolled on a principal this console creates for the purpose
//! (`console-<user>`) and is granted server-wide Write, because signing a
//! review is a write a console operator could already make.
//! * a TENANT key exists so a read is **narrowed to what that person may see**.
//! It is enrolled on the principal that person already is, and is granted
//! **nothing at all** — the grants that principal holds are the whole point,
//! and a grant handed out here would be the console deciding what a tenant
//! may read, which is the confused deputy again with extra steps.
//!
//! They share the minting and the sealing, and that much IS shared:
//! [`crate::reviewer::mint_ed25519`] and the same [`StateStore`] the session
//! key lives in, at mode 0600 (see [`crate::state`]). No second crypto scheme
//! and no second file.
//!
//! # What custody means here, stated rather than hidden
//!
//! The console holds the private half. A compromised console can therefore read
//! as any tenant — but a compromised console already holds a server-wide Admin
//! key, so this adds no reach it did not have. What it removes is the reach an
//! **uncompromised** console hands out by accident, which is what every finding
//! in this file's history has been about.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::grpc::Gunnar;
use crate::pb;
use crate::reviewer::mint_ed25519;
use crate::signer::Signer;
use crate::state::{StateError, StateStore};
/// A console user's gunnar identity: the principal their reads are answered as,
/// and a client that signs with the key enrolled on it.
pub struct TenantIdentity {
/// The identity-table principal this session acts for.
pub principal_id: String,
/// The `SHA256:…` fingerprint of the key this console enrolled for them.
///
/// Read by [`crate::api::account`], which must not offer a person a button
/// that revokes the credential their own console session is reading with.
pub fingerprint: String,
/// A client that acts AS the tenant on the control plane.
pub gunnar: Gunnar,
/// **The key itself**, for the one call that needs a signature rather than
/// a connection: `Access.AddKey`'s attestation, where a person's own key
/// has to vouch for the next one
/// (`crate::signer::Signer::sign_key_attestation`). Every other use of this
/// credential goes through [`TenantIdentity::gunnar`], which signs a
/// per-call credential itself — this is the exception, and it is here
/// rather than behind a method because the caller needs the key and not a
/// client.
pub signer: Arc<Signer>,
}
/// **A minted key waiting on its owner's word**, as the console's own account
/// surface shows it.
///
/// No private half: this crosses into [`crate::api::account`] and from there
/// into a browser, and the private half never leaves this process.
pub struct PendingKey {
/// The principal it was minted for, so a record left over from a rebinding
/// is not offered as this session's business.
pub principal_id: String,
/// The OpenSSH one-liner to attest — what `--key` takes.
pub public_key: String,
/// `SHA256:…`, the same fingerprint gunnar's refusal names.
pub fingerprint: String,
}
/// What gunnar said about a key this console holds.
///
/// Three answers and not two, because the difference between "gone" and "I
/// could not ask" is the difference between dropping a private key and keeping
/// it through an outage.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Enrolment {
/// gunnar's key table holds it.
Held,
/// gunnar answered, and it is not there.
Gone,
/// gunnar did not answer.
Unknown,
}
/// Something went wrong establishing a tenant's credential.
#[derive(Debug)]
pub enum TenantError {
/// The state store could not be read or written.
State(StateError),
/// A generated or stored key was not usable.
Signer(crate::signer::SignerError),
/// gunnar refused a provisioning call.
Gunnar(String),
/// A per-user control-plane client could not be built.
Client(String),
/// A seed the ladder laid for this principal could not be used: it exists
/// and cannot be read, or gunnar could not be asked whether it holds it.
/// Never "there is no seed" — that is the ordinary path, not an error.
Seed(String),
/// A stored credential named a different principal than the passkey is
/// pinned to. The record is discarded first and this is returned second, so
/// the next call re-provisions; the refusal exists because a discard that
/// answered `None` was indistinguishable from having no credential yet, and
/// a seed the ladder enrolled would have been given up without a word.
Rebound(String),
}
impl std::fmt::Display for TenantError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TenantError::State(e) => write!(f, "the tenant key store: {e}"),
TenantError::Signer(e) => write!(f, "the tenant's signing key: {e}"),
TenantError::Gunnar(m) => write!(f, "Gunnar refused to enrol the tenant's key: {m}"),
TenantError::Client(m) => write!(f, "the tenant's control-plane client: {m}"),
TenantError::Seed(m) => write!(f, "the ladder's seed for this principal: {m}"),
TenantError::Rebound(m) => write!(f, "the stored credential was discarded: {m}"),
}
}
}
impl std::error::Error for TenantError {}
/// Mints, holds and retires per-tenant credentials.
///
/// One per console, shared behind an [`Arc`]: the store is a handle and the
/// endpoint is a string.
pub struct TenantKeys {
/// gunnar's control-plane endpoint, to build a per-user client from.
endpoint: String,
/// Where the private keys and the pinned principal ids live, in the same
/// 0600 store as the session key.
store: StateStore,
/// Serialises provisioning, so two requests racing a tenant's first read
/// enrol exactly one key rather than two.
provisioning: Mutex<()>,
/// So a warm tenant does not re-read the store and rebuild a client on
/// every request. Keyed by console user name.
warm: Mutex<HashMap<String, Arc<Held>>>,
/// **ONE channel to gunnar for every person this console signs as**
/// (`Gunnar::over`), made at the first use (a lazy channel wants the
/// runtime). Until 2026-10-06 each person's client dialled its own: a TCP
/// connection, kept alive while idle, per session key — MEASURED on the KVM
/// mock: ~900 logins after vm1 booted, the front ran out of file
/// descriptors and its web, its forge door and monetize stopped answering.
channel: std::sync::OnceLock<Result<tonic::transport::Channel, String>>,
/// **Where the ladder lays a tenant key before anybody has logged in.**
///
/// `GUNNAR_UI_TENANT_SEEDS`. `cargo xtask estate rebuild` (private-gunnar-ops,
/// `rebuild::tenant_seed`) mints one ed25519 key per operator principal at
/// `<dir>/<principal_id>` on this box at its `pair` rung, enrols the public
/// half on that principal, and has the operator's OWN key vouch for it. Until
/// 2026-09-20 this console minted its own key at the first read, gunnar
/// refused it (the operator's key had spent the one free slot), and the
/// operator was asked to paste an attestation — on every fresh estate. With a
/// seed here, the key already exists and is already held: `ensure` adopts it
/// and asks gunnar for nothing. `None` is the console as it was.
seeds: Option<PathBuf>,
}
/// What [`TenantKeys::sweep`] did: keys revoked at gunnar and forgotten, and
/// keys kept for the next sweep.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Swept {
pub revoked: usize,
pub kept: usize,
}
struct Held {
principal_id: String,
fingerprint: String,
signer: Arc<Signer>,
}
/// The state-store key a tenant's credential is filed under.
///
/// Its own namespace, distinct from `reviewer-identity:` — the two keys belong
/// to the same human and to different principals, and a console that confused
/// them would sign a tenant's read with a server-wide Write credential.
fn store_key(user: &str) -> String {
format!("tenant-identity:{user}")
}
/// **The key this console minted and gunnar has not accepted yet.**
///
/// Its own namespace beside `tenant-identity:`, and the difference between the
/// two is the whole point: a record under THIS key is a private half the
/// console holds and cannot yet sign with, and a reader that confused it for an
/// enrolled credential would build a client whose every call gunnar refuses.
///
/// # Why a pending record exists at all
///
/// It is the other half of gunnar's attestation rule. `Access.AddKey` is
/// refused with `FAILED_PRECONDITION` when the target principal already holds a
/// signing key and the caller is not the target — correctly: that rule is what
/// stops this console's server-wide admin grant from minting itself into
/// somebody's identity. The refusal names the fingerprint to attest.
///
/// Until 2026-09-18 the console minted a key, was refused, and dropped it. The
/// next request minted a DIFFERENT one and was refused naming a different
/// fingerprint, so the operator was asked to attest a key that no longer
/// existed by the time they read the line. MEASURED on the front the same day:
/// five distinct fingerprints in two page loads, and no way out of it for
/// anybody — attesting needs a key the principal holds, and the key needing
/// attestation was gone.
///
/// So the mint is persisted BEFORE gunnar is asked. The fingerprint then stops
/// moving, which is what makes it a thing a human can sign over; see
/// [`TenantKeys::pending`] for what is shown to them and
/// [`TenantKeys::attest`] for the way back in.
fn pending_key(user: &str) -> String {
format!("tenant-pending:{user}")
}
impl TenantKeys {
/// A client signed as `signer`, over the console's one channel to gunnar.
fn client(&self, signer: Arc<Signer>) -> Result<Gunnar, TenantError> {
let channel = self
.channel
.get_or_init(|| Gunnar::lazy_channel(&self.endpoint))
.clone()
.map_err(TenantError::Client)?;
Ok(Gunnar::over(channel, signer))
}
/// Build over gunnar's endpoint and the console's state store.
pub fn new(endpoint: impl Into<String>, store: StateStore) -> TenantKeys {
TenantKeys {
endpoint: endpoint.into(),
store,
provisioning: Mutex::new(()),
warm: Mutex::new(HashMap::new()),
channel: std::sync::OnceLock::new(),
seeds: None,
}
}
/// Read seeds from `dir` — see [`TenantKeys::seeds`]. `None` leaves the
/// console minting its own keys, as before.
pub fn with_seeds(mut self, dir: Option<PathBuf>) -> TenantKeys {
self.seeds = dir;
self
}
/// The credential this user's reads are answered with, minting one on first
/// use.
///
/// **`principal_id` is required, and there is no lookup here.** It is the
/// id the passkey was bound to (`auth::StoredCred::principal_id`), pinned
/// at registration or at the first login after this console learned how —
/// the moments when the name is somebody's to give. A read path that
/// resolved a name would be GUI-001 living on in a quieter place, and it
/// would also put an `Access.ListPrincipals` on the console's ADMIN key in
/// front of every GET a viewer makes, which is measurable: the fingerprint
/// log for one viewer's read showed the admin key interleaved with every
/// call.
///
/// # Errors
///
/// The store could not be read or written, a key could not be minted, or
/// gunnar refused the enrolment. Every one of them is a refusal to hand
/// back a credential — never a fallback to the console's own.
pub async fn ensure(
&self,
user: &str,
principal_id: &str,
admin: &Gunnar,
) -> Result<TenantIdentity, TenantError> {
// A credential this process has already used is taken at its word: it
// was checked against gunnar when it came off the disk (below), and
// re-asking on every read would put an `Access.ListKeys` in front of
// every request a tenant makes.
if let Some(identity) = self.warm(user, principal_id).await? {
return Ok(identity);
}
let _guard = self.provisioning.lock().await;
// Re-check under the lock: another request may have provisioned this
// tenant while we waited for it.
if let Some(identity) = self.warm(user, principal_id).await? {
return Ok(identity);
}
// **A stored key gunnar has never heard of is not a credential.**
//
// The two sides keep their halves in different places: the private half
// lives in this console's state store, the public half in gunnar's key
// table. A RE-IMAGE resets gunnar's and leaves this one untouched — and
// then the console signs every call with a key the appliance does not
// know, which comes back as `Unauthenticated` on every read, for ever,
// with a stored record that looks perfectly healthy from here.
//
// So the record is believed only once per process: when it is read off
// the disk it is checked against gunnar's table, and a key that is not
// there is DROPPED rather than used. What follows is the ordinary
// first-run path — mint, ask, and if the principal has since grown a
// key of its own, the attestation panel.
if let Some(identity) = self.existing(user, Some(principal_id)).await? {
match self.still_enrolled(&identity, admin).await {
Enrolment::Held => return Ok(identity),
// Not a verdict. Keep the key and let the read that follows be
// answered — or refused — by gunnar itself.
Enrolment::Unknown => return Ok(identity),
Enrolment::Gone => {}
}
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.key_unknown_to_gunnar",
user = %user,
principal = %principal_id,
fingerprint = %identity.fingerprint,
"the key this console holds for a tenant is not in gunnar's table — a \
re-imaged or restored appliance — so it is dropped and a new one asked for"
);
self.warm.lock().await.remove(user);
self.store
.remove(&store_key(user))
.map_err(TenantError::State)?;
}
let principal_id = principal_id.to_owned();
// **A seed the ladder laid** — see [`TenantKeys::seeds`]. Three answers
// and not two, the way `still_enrolled` keeps them apart: gunnar HOLDS
// it, so it is adopted and nothing is asked for; gunnar says it is GONE
// (an appliance re-imaged since the ladder paired it), so it is the key
// this console asks for — as the first enrolment, or into the pending
// slot where the panel names ITS fingerprint — never a fresh mint
// beside it; gunnar could not be asked, so this refuses, because a
// credential handed out on no evidence is the confused deputy again.
let seeded = self.seeded_private(&principal_id)?;
if let Some(seed) = &seeded {
let signer = Signer::from_openssh(seed).map_err(TenantError::Signer)?;
let fingerprint = signer.fingerprint().to_owned();
match self.held_at_gunnar(&principal_id, &fingerprint, admin).await {
Enrolment::Held => {
self.store
.put(&store_key(user), format!("{principal_id}\n{seed}").as_bytes())
.map_err(TenantError::State)?;
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.seed_adopted",
user = %user,
principal = %principal_id,
fingerprint = %fingerprint,
"the ladder's seed on this principal is held by gunnar and is now this \
console's credential for them; no key was minted and nothing was asked for"
);
let held = Arc::new(Held {
principal_id: principal_id.clone(),
fingerprint,
signer: Arc::new(signer),
});
self.warm.lock().await.insert(user.to_owned(), held.clone());
return self.identity_from(&held);
}
Enrolment::Gone => {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.seed_not_enrolled",
user = %user,
principal = %principal_id,
fingerprint = %fingerprint,
"a seed lies here for this principal and gunnar does not hold it — an \
appliance re-imaged since the ladder paired it; it is the key asked for now"
);
}
Enrolment::Unknown => {
return Err(TenantError::Seed(format!(
"Gunnar could not be asked whether it holds {fingerprint} on {principal_id}, \
so the seed is neither adopted nor replaced"
)));
}
}
}
// **The same key every time, until gunnar takes it.** A mint here would
// be a new fingerprint on every request, and gunnar's refusal names the
// fingerprint the principal is asked to sign over — so a moving one is
// an instruction nobody can carry out. See [`pending_key`].
let private = match self.pending_private(user, &principal_id)? {
Some(private) => private,
None => {
let private = match seeded {
Some(seed) => seed,
None => mint_ed25519().map_err(TenantError::Signer)?,
};
// Written BEFORE gunnar is asked. A crash between here and the
// call leaves a key nothing has accepted, which is exactly what
// the next `ensure` is built to pick up.
self.store
.put(
&pending_key(user),
format!("{principal_id}\n{private}").as_bytes(),
)
.map_err(TenantError::State)?;
private
}
};
self.enrol(user, &principal_id, &private, None, admin).await
}
/// **Enrol a minted key on its principal, with or without the owner's
/// word**, and promote it on acceptance.
///
/// The one place `Access.AddKey` is called for a tenant, so the two ways in
/// — the unattested first enrolment and the attested retry — cannot drift
/// on what they store or what they log.
async fn enrol(
&self,
user: &str,
principal_id: &str,
private: &str,
attestation: Option<pb::SignedKeyAttestation>,
admin: &Gunnar,
) -> Result<TenantIdentity, TenantError> {
let signer = Signer::from_openssh(private).map_err(TenantError::Signer)?;
let fingerprint = signer.fingerprint().to_owned();
let attested = attestation.is_some();
// **`Access.AddKey` and nothing else.** No `AddPrincipal`: the
// principal is whose it already is. No `Grant`: what this credential
// may read is what that principal was already allowed to read, and a
// grant issued here would be this console deciding that instead of
// gunnar.
let added = admin
.access()
.add_key(pb::AddKeyRequest {
principal_id: principal_id.to_owned(),
openssh_public_key: signer.public_key_openssh(),
comment: format!("gunnar-ui console session for {user}"),
// Absent on a first enrolment, because the console holds no key
// of the tenant's to attest with and a principal with no
// signing key has nobody to ask. Present on the retry, where it
// is the tenant's own sshsig, pasted by them — see
// [`TenantKeys::attest`] and `api::access::add_key`.
attestation,
// No expiry. The console cannot renew one unattended, and an
// expired key would take a tenant's whole console dark with no
// way back in; revocation is the lever, and it is
// [`TenantKeys::forget`].
expires_at_unix_ms: None,
})
.await;
match added {
Ok(_) => {}
// **gunnar already holds this very key on this principal** — its
// documented idempotent answer ("no new key, so no attestation is
// owed — ALREADY_EXISTS, which callers tolerate"): a restored main,
// a retried call, an enrolment this console lost track of. That IS
// an enrolment. Believed only after gunnar lists the fingerprint on
// THIS principal: an `AlreadyExists` for a key filed under anybody
// else stays a refusal, because signing with it would be signing as
// them. Until 2026-10-04 every `AlreadyExists` was a failure, the
// key never left the pending slot, and the person's reads were the
// guest's for ever.
Err(status) if status.code() == tonic::Code::AlreadyExists => {
match self.held_at_gunnar(principal_id, &fingerprint, admin).await {
Enrolment::Held => tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.key_already_held",
user = %user,
principal = %principal_id,
fingerprint = %fingerprint,
"gunnar already held this console's key on the tenant's principal; it is \
their credential"
),
Enrolment::Gone | Enrolment::Unknown => {
return Err(TenantError::Gunnar(status.to_string()))
}
}
}
Err(status) => return Err(TenantError::Gunnar(status.to_string())),
}
// Promoted only after gunnar accepted it, so a half-enrolled tenant is
// retried rather than remembered. `principal_id + '\n' + the multi-line
// OpenSSH private key`, read back by splitting on the first newline —
// the same record shape `reviewer` uses.
let record = format!("{principal_id}\n{private}");
self.store
.put(&store_key(user), record.as_bytes())
.map_err(TenantError::State)?;
// The pending copy goes only now, and its removal is not allowed to
// fail the call: the credential is live at gunnar and in the store, and
// a leftover pending record is read back as "already enrolled" by
// [`TenantKeys::pending`] rather than as an instruction.
if let Err(why) = self.store.remove(&pending_key(user)) {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.pending_not_cleared",
user = %user,
reason = %why,
"the enrolled key could not be cleared from the pending slot; it is \
superseded, not live"
);
}
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.key_enrolled",
user = %user,
principal = %principal_id,
fingerprint = %fingerprint,
attested = attested,
"this console enrolled a key of its own on a tenant's principal, so \
their reads are answered with their grants"
);
let held = Arc::new(Held {
principal_id: principal_id.to_owned(),
fingerprint,
signer: Arc::new(signer),
});
self.warm.lock().await.insert(user.to_owned(), held.clone());
self.identity_from(&held)
}
/// The private half waiting in the pending slot for THIS principal, if any.
///
/// A record naming a different principal is dropped rather than reused: the
/// pin is somebody's decision about whose account this session is, and a
/// key minted before it must not be enrolled onto the new one. Same rule as
/// [`TenantKeys::existing`], same reason.
fn pending_private(
&self,
user: &str,
principal_id: &str,
) -> Result<Option<String>, TenantError> {
let Some(bytes) = self
.store
.get(&pending_key(user))
.map_err(TenantError::State)?
else {
return Ok(None);
};
let text = String::from_utf8_lossy(&bytes).into_owned();
let Some((held, private)) = text.split_once('\n') else {
self.store
.remove(&pending_key(user))
.map_err(TenantError::State)?;
return Ok(None);
};
if held != principal_id {
self.store
.remove(&pending_key(user))
.map_err(TenantError::State)?;
return Ok(None);
}
Ok(Some(private.to_owned()))
}
/// **What this person is being asked to vouch for**, or `None` when nothing
/// is waiting.
///
/// `None` is also the answer when the credential is already enrolled, even
/// if a pending record survived: an enrolled tenant has nothing to do, and
/// showing them a key to attest would be an instruction with no effect.
pub async fn pending(
&self,
user: &str,
principal_id: &str,
) -> Result<Option<PendingKey>, TenantError> {
if self.existing(user, Some(principal_id)).await?.is_some() {
return Ok(None);
}
let Some(private) = self.pending_private(user, principal_id)? else {
return Ok(None);
};
let signer = Signer::from_openssh(&private).map_err(TenantError::Signer)?;
Ok(Some(PendingKey {
principal_id: principal_id.to_owned(),
public_key: signer.public_key_openssh(),
fingerprint: signer.fingerprint().to_owned(),
}))
}
/// **The tenant's own word, carried to gunnar.**
///
/// The attestation is an sshsig the person made with a key they already
/// hold, over the fingerprint of the key in the pending slot. This console
/// composes none of it and verifies none of it: gunnar checks the signature
/// against its own key table, and a wrong one comes back as
/// `INVALID_ARGUMENT` in gunnar's words. That is deliberate — a console
/// that judged the signature would be a second opinion about the thing the
/// appliance is the authority on.
///
/// # Errors
///
/// Nothing is pending (there is nothing to attest), the stored key is
/// unusable, or gunnar refused. A refusal leaves the pending record where
/// it is, so a corrected paste can be tried against the same fingerprint.
pub async fn attest(
&self,
user: &str,
principal_id: &str,
attestation: pb::SignedKeyAttestation,
admin: &Gunnar,
) -> Result<TenantIdentity, TenantError> {
let _guard = self.provisioning.lock().await;
if let Some(identity) = self.existing(user, Some(principal_id)).await? {
return Ok(identity);
}
let Some(private) = self.pending_private(user, principal_id)? else {
return Err(TenantError::Gunnar(
"no key is waiting to be attested for this account".into(),
));
};
self.enrol(user, principal_id, &private, Some(attestation), admin)
.await
}
/// The credential already in this process's memory, or `None`.
///
/// Split out of [`TenantKeys::existing`] so that `ensure` can tell a
/// credential it has already used from one it has just read off the disk:
/// only the second needs checking against gunnar, and putting that check on
/// the first would be an `Access.ListKeys` in front of every tenant read.
async fn warm(&self, user: &str, pinned: &str) -> Result<Option<TenantIdentity>, TenantError> {
let held = self.warm.lock().await.get(user).cloned();
match held {
Some(held) if held.principal_id == pinned => Ok(Some(self.identity_from(&held)?)),
_ => Ok(None),
}
}
/// **Does gunnar still hold this key?** — and the third answer matters.
///
/// A stored credential is dropped only when gunnar POSITIVELY says the key
/// is not in its table. "I could not ask" is not that answer, and treating
/// it as one was a real mistake caught by
/// `a_viewers_read_is_not_signed_with_the_consoles_admin_key`: a momentary
/// refusal from the control plane would discard a perfectly good private
/// key and then try to re-provision INTO THE SAME OUTAGE, turning a blink
/// into a tenant with no credential at all and a fresh one waiting to be
/// attested by hand.
///
/// Leaving the record alone costs nothing that is not already lost: the
/// read that follows is signed and gunnar answers it, or does not, in its
/// own words. The only state this check exists to end is the DURABLE one —
/// a key gunnar has genuinely forgotten, which it will go on genuinely
/// forgetting on the next request, and the next.
async fn still_enrolled(&self, identity: &TenantIdentity, admin: &Gunnar) -> Enrolment {
self.held_at_gunnar(&identity.principal_id, &identity.fingerprint, admin)
.await
}
/// The one `Access.ListKeys` behind both [`TenantKeys::still_enrolled`]
/// and the seed's adoption, so the two cannot read one table two ways.
async fn held_at_gunnar(&self, principal_id: &str, fingerprint: &str, admin: &Gunnar) -> Enrolment {
match admin
.access()
.list_keys(pb::ListKeysRequest {
principal_id: principal_id.to_owned(),
})
.await
{
Ok(answer) => {
let held = answer
.into_inner()
.keys
.iter()
.any(|k| k.fingerprint == fingerprint && k.principal_id == principal_id);
if held {
Enrolment::Held
} else {
Enrolment::Gone
}
}
Err(_) => Enrolment::Unknown,
}
}
/// The credential this user already has, or `None`.
///
/// A stored record whose principal disagrees with `pinned` does not outrank
/// the pin: the pin is the binding somebody with authority made. The record
/// is dropped so the next call re-provisions, and the drop is answered as
/// [`TenantError::Rebound`] — NOT as `Ok(None)`. Until 2026-09-20 it was
/// `Ok(None)`, which reads the same as "this user has no credential yet",
/// so a credential the estate ladder had enrolled and vouched for could be
/// thrown away with nothing but a log line to say it happened. The refusal
/// is not fatal: the record is already gone when it is returned, so the
/// caller's next read provisions for the pinned principal.
pub async fn existing(
&self,
user: &str,
pinned: Option<&str>,
) -> Result<Option<TenantIdentity>, TenantError> {
if let Some(held) = self.warm.lock().await.get(user).cloned() {
if pinned.is_none_or(|want| want == held.principal_id) {
return Ok(Some(self.identity_from(&held)?));
}
}
let Some(bytes) = self
.store
.get(&store_key(user))
.map_err(TenantError::State)?
else {
return Ok(None);
};
let held = Arc::new(self.decode(&bytes)?);
if pinned.is_some_and(|want| want != held.principal_id) {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.rebound",
user = %user,
held = %held.principal_id,
pinned = %pinned.unwrap_or_default(),
"a stored tenant credential names a different principal than the \
passkey is bound to; it is discarded rather than trusted"
);
self.warm.lock().await.remove(user);
self.store
.remove(&store_key(user))
.map_err(TenantError::State)?;
// Named, not `None`. The record is already gone, so the next call
// provisions from the ladder's seed or mints; this answer exists so
// the discard is READ somewhere instead of only logged.
return Err(TenantError::Rebound(format!(
"console user `{user}` held a credential on principal `{}` ({}) and the passkey is \
pinned to `{}`; the credential is discarded and the next read provisions for the \
pinned principal",
held.principal_id,
held.fingerprint,
pinned.unwrap_or_default()
)));
}
self.warm.lock().await.insert(user.to_owned(), held.clone());
Ok(Some(self.identity_from(&held)?))
}
/// **Retire this user's credential: revoke the key at gunnar, then forget
/// it here.**
///
/// Called when the last passkey behind a console user is revoked. In that
/// order on purpose: a console that forgot the key first would leave a live
/// credential in gunnar's key table with nothing left in this process that
/// knows its fingerprint, which is a key nobody can find to revoke.
///
/// A gunnar that refuses is reported and the local halves are dropped
/// anyway — the console must not go on holding a private key for somebody
/// it has just revoked, and an operator can see the fingerprint in the
/// audit line and revoke it by hand.
///
/// # Errors
///
/// The store could not be read or written. gunnar's refusal is returned
/// too, after the local state is dropped.
pub async fn forget(&self, user: &str, admin: &Gunnar) -> Result<Option<String>, TenantError> {
let Some(bytes) = self
.store
.get(&store_key(user))
.map_err(TenantError::State)?
else {
return Ok(None);
};
let held = self.decode(&bytes)?;
let refusal = admin
.access()
.revoke_key(pb::RevokeKeyRequest {
fingerprint: held.fingerprint.clone(),
})
.await
.err();
self.warm.lock().await.remove(user);
self.store
.remove(&store_key(user))
.map_err(TenantError::State)?;
match refusal {
None => {
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.key_revoked",
user = %user,
principal = %held.principal_id,
fingerprint = %held.fingerprint,
"the console's key on a tenant's principal was revoked with their last passkey"
);
Ok(Some(held.fingerprint))
}
Some(status) => {
tracing::error!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.key_revoke_failed",
user = %user,
principal = %held.principal_id,
fingerprint = %held.fingerprint,
message = %status.message(),
"gunnar would not revoke the console's key on a tenant's principal; \
it is still live there and must be revoked by hand"
);
Err(TenantError::Gunnar(status.to_string()))
}
}
}
/// **RA5-13: retire every key this console no longer signs with.** Since
/// the identity core (IDENTITY-HOST) a session holds no key at gunnar and
/// nothing in production signs as a tenant key, so each one still in the
/// store — `tenant-identity:*`, `tenant-pending:*` — and each seed the
/// ladder laid ([`TenantKeys::seeds`]) is a private key enrolled on its
/// person and used by nobody: a copy of the store would sign as each of
/// them. Each is revoked at gunnar (the console's admin key), then
/// forgotten here; a key gunnar no longer holds (`NotFound`) is forgotten
/// too. One gunnar would not revoke now is KEPT, and said, so the next
/// sweep tries again: forgetting it first would leave a live key nobody
/// can find to revoke. Nothing else in the store is touched.
pub async fn sweep(&self, admin: &Gunnar) -> Swept {
let mut swept = Swept::default();
let records = match self.store.auth_keys() {
Ok(keys) => keys,
Err(e) => {
tracing::error!(target: crate::auth::AUDIT_TARGET, event = "tenant.sweep_failed", error = %e, "the console's store could not be listed: no tenant key was retired");
swept.kept += 1;
return swept;
}
};
for record in records.into_iter().filter(|k| k.starts_with("tenant-identity:") || k.starts_with("tenant-pending:")) {
let private = match self.store.get(&record) {
Ok(Some(bytes)) => String::from_utf8_lossy(&bytes).split_once('\n').map(|(_, key)| key.to_owned()),
Ok(None) => continue,
Err(e) => {
tracing::error!(target: crate::auth::AUDIT_TARGET, event = "tenant.sweep_failed", record = %record, error = %e, "a stored tenant key could not be read: kept");
swept.kept += 1;
continue;
}
};
let fingerprint = private.as_deref().and_then(|p| Signer::from_openssh(p).ok()).map(|s| s.fingerprint().to_owned());
let gone = match &fingerprint {
Some(fp) => self.revoked_at_gunnar(admin, fp, &record).await,
// No key in it: nothing to revoke, and nothing to keep.
None => true,
};
if gone && self.store.remove(&record).is_ok() {
self.warm.lock().await.remove(record.split_once(':').map(|(_, user)| user).unwrap_or_default());
swept.revoked += 1;
} else {
swept.kept += 1;
}
}
if let Some(dir) = &self.seeds {
let files = match std::fs::read_dir(dir) {
Ok(files) => files.flatten().collect::<Vec<_>>(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
tracing::error!(target: crate::auth::AUDIT_TARGET, event = "tenant.sweep_failed", dir = %dir.display(), error = %e, "the tenant seeds could not be listed: kept");
swept.kept += 1;
Vec::new()
}
};
for file in files {
let path = file.path();
let name = file.file_name().to_string_lossy().into_owned();
if !plain_file_name(&name) || !path.is_file() {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
swept.kept += 1;
continue;
};
let Ok(signer) = Signer::from_openssh(&text) else { continue };
if self.revoked_at_gunnar(admin, signer.fingerprint(), &format!("seed {name}")).await && std::fs::remove_file(&path).is_ok() {
swept.revoked += 1;
} else {
swept.kept += 1;
}
}
}
tracing::warn!(target: crate::auth::AUDIT_TARGET, event = "tenant.swept", revoked = swept.revoked, kept = swept.kept, "the per-person keys this console no longer signs with: revoked at gunnar and forgotten");
swept
}
/// Revoke `fingerprint` at gunnar: `true` when gunnar holds it no more.
async fn revoked_at_gunnar(&self, admin: &Gunnar, fingerprint: &str, what: &str) -> bool {
match admin.access().revoke_key(pb::RevokeKeyRequest { fingerprint: fingerprint.to_owned() }).await {
Ok(_) => {
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "tenant.key_revoked", record = %what, fingerprint = %fingerprint, "a tenant key this console no longer signs with was revoked at gunnar");
true
}
Err(status) if status.code() == tonic::Code::NotFound => true,
Err(status) => {
tracing::error!(target: crate::auth::AUDIT_TARGET, event = "tenant.key_revoke_failed", record = %what, fingerprint = %fingerprint, message = %status.message(), "gunnar would not revoke a tenant key this console no longer signs with: kept, the next sweep tries again");
false
}
}
}
/// **The private half the ladder laid for `principal_id`**, or `None`.
///
/// `None` when no directory is configured, when nothing lies there for this
/// principal, or when the id could not be a plain file name — a `/`, a
/// leading `.`, anything outside ASCII letters, digits, `-`, `_` and `.`:
/// a principal id is a value off the wire and this is a path, so it is
/// refused as a name before it is joined to one. A file that EXISTS and
/// cannot be read is an error and not `None`: a seed this console cannot
/// open is the defect this door ends, wearing a permission bit, and a
/// quiet fresh mint would report it as health.
fn seeded_private(&self, principal_id: &str) -> Result<Option<String>, TenantError> {
let Some(dir) = &self.seeds else {
return Ok(None);
};
if !plain_file_name(principal_id) {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "tenant.seed_name_refused",
principal = %principal_id,
"a principal id that cannot be a plain file name is not looked up as a seed"
);
return Ok(None);
}
let path: PathBuf = Path::new(dir).join(principal_id);
match std::fs::read_to_string(&path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(TenantError::Seed(format!(
"{} exists but could not be read ({e})",
path.display()
))),
}
}
fn decode(&self, bytes: &[u8]) -> Result<Held, TenantError> {
let text = String::from_utf8_lossy(bytes);
let (principal_id, private) = text
.split_once('\n')
.ok_or_else(|| TenantError::Gunnar("a stored tenant record has no key".into()))?;
let signer = Signer::from_openssh(private).map_err(TenantError::Signer)?;
Ok(Held {
principal_id: principal_id.to_owned(),
fingerprint: signer.fingerprint().to_owned(),
signer: Arc::new(signer),
})
}
fn identity_from(&self, held: &Held) -> Result<TenantIdentity, TenantError> {
let gunnar = self.client(held.signer.clone())?;
Ok(TenantIdentity {
principal_id: held.principal_id.clone(),
fingerprint: held.fingerprint.clone(),
gunnar,
signer: held.signer.clone(),
})
}
}
/// A principal id that is safe to join to a directory: ASCII letters, digits,
/// `-`, `_`, `.`; not empty; not beginning with `.`. The same rule the ladder
/// applies before it lays a seed (`rebuild::tenant_seed::checked_name`), so a
/// name the ladder would refuse is a name this console never looks up.
fn plain_file_name(principal_id: &str) -> bool {
!principal_id.is_empty()
&& !principal_id.starts_with('.')
&& principal_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
}
#[cfg(test)]
mod tests {
use super::plain_file_name;
use crate::pb;
use std::sync::{Arc, Mutex};
/// A gunnar that ALREADY holds whatever key it is asked to enrol on `kim`
/// — what a restored main, a retried call or an earlier enrolment the
/// console lost track of all look like — and answers `AlreadyExists`, its
/// documented idempotent answer ("callers tolerate"). `foreign` makes it
/// claim the key belongs to somebody else instead.
#[derive(Clone, Default)]
struct Holds {
asked: Arc<Mutex<Vec<String>>>,
foreign: bool,
}
#[tonic::async_trait]
impl pb::access_server::Access for Holds {
async fn add_key(
&self,
request: tonic::Request<pb::AddKeyRequest>,
) -> Result<tonic::Response<pb::AddKeyResponse>, tonic::Status> {
let asked = request.into_inner();
let parsed = ssh_key::PublicKey::from_openssh(&asked.openssh_public_key)
.map_err(|e| tonic::Status::invalid_argument(e.to_string()))?;
let fingerprint = parsed.fingerprint(ssh_key::HashAlg::Sha256).to_string();
self.asked.lock().expect("asked").push(fingerprint.clone());
Err(tonic::Status::already_exists(format!(
"principal {} already holds key {fingerprint}",
asked.principal_id
)))
}
async fn list_keys(
&self,
request: tonic::Request<pb::ListKeysRequest>,
) -> Result<tonic::Response<pb::ListKeysResponse>, tonic::Status> {
let principal = request.into_inner().principal_id;
let keys = self
.asked
.lock()
.expect("asked")
.iter()
.map(|fingerprint| pb::PublicKey {
fingerprint: fingerprint.clone(),
algo: "ssh-ed25519".to_owned(),
principal_id: if self.foreign { "somebody-else".to_owned() } else { principal.clone() },
..Default::default()
})
.filter(|k| k.principal_id == principal)
.collect();
Ok(tonic::Response::new(pb::ListKeysResponse { keys }))
}
}
async fn serve(holds: Holds) -> 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");
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(pb::access_server::AccessServer::new(holds))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
format!("http://{addr}")
}
fn console(endpoint: &str) -> crate::grpc::Gunnar {
crate::grpc::Gunnar::connect_lazy(
endpoint,
Arc::new(
crate::signer::Signer::new(ssh_key::PrivateKey::from(
ssh_key::private::Ed25519Keypair::from_seed(&[71u8; 32]),
))
.expect("a signer"),
),
)
.expect("a handle")
}
/// ★ **ONE connection to gunnar for every person this console signs as.**
///
/// MEASURED on the KVM mock, 2026-10-06 (private-gunnar-ops `bench load`):
/// after about 900 logins since vm1 booted, the front answered nothing —
/// `web: accept on :443 error=No file descriptors available (os error 24)`,
/// the forge forward refused `:2222` with the same words, monetize could
/// not read its own config, and the console told every person "tcp open
/// error". Every login mints a session key, and [`super::TenantKeys::session`]
/// built a client — a tonic channel, one HTTP/2 connection kept alive
/// while idle — per session key and kept it: a file descriptor on vm1 per
/// login, for ever. gunnar-front runs the console in its own process, so
/// the web, the forge's door and monetize went down with it.
///
/// A listener that answers nothing and counts the connections made to it:
/// five people, five sessions, one call each — one connection.
#[tokio::test]
async fn every_persons_session_rides_the_one_connection() {
use std::sync::atomic::{AtomicUsize, Ordering};
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 accepted = Arc::new(AtomicUsize::new(0));
let counted = accepted.clone();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((socket, _)) = listener.accept().await {
counted.fetch_add(1, Ordering::SeqCst);
held.push(socket);
}
});
// Every person's client is the console's one channel carrying THEIR
// identity-core session (IDENTITY-HOST): five sessions, one connection.
let console = crate::grpc::Gunnar::connect_lazy(
&format!("http://{addr}"),
Arc::new(crate::signer::Signer::new(ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[1; 32]))).expect("a signer")),
)
.expect("a lazy channel");
for n in 0..5u8 {
let person = console.as_session(&format!("nis1.person-{n}"));
let mut access = person.access();
// The listener never answers: the call is given up, the connection stays.
let _ = tokio::time::timeout(std::time::Duration::from_millis(300), access.list_keys(pb::ListKeysRequest::default())).await;
}
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
assert_eq!(
accepted.load(Ordering::SeqCst),
1,
"a connection per session is a file descriptor on vm1 per login, kept for ever"
);
}
/// ★ **gunnar already holding the very key the console asks it to enrol is
/// an enrolment, not a refusal.** Red before this row: `AlreadyExists` was
/// a failure, the key never left the pending slot, and every read the
/// person made was answered by the grantless guest for ever — the uitest
/// rig's viewer `kim` (the console's own log: "principal kim already holds
/// key SHA256:…"), whose own repositories 404'd and whose room said
/// "principal guest has no read grant on namespace:kim".
#[tokio::test]
async fn a_key_gunnar_already_holds_on_the_principal_is_enrolled() {
let holds = Holds::default();
let endpoint = serve(holds.clone()).await;
let dir = tempfile::tempdir().expect("a temp dir");
let store = crate::state::StateStore::open(dir.path().join("state.redb")).expect("a store");
let keys = super::TenantKeys::new(endpoint.clone(), store);
let identity = keys
.ensure("kim", "kim", &console(&endpoint))
.await
.expect("a key gunnar holds on kim is kim's credential");
assert_eq!(
holds.asked.lock().expect("asked").clone(),
vec![identity.fingerprint.clone()],
"the credential handed back is the key gunnar said it holds"
);
// …and it is remembered: the next read asks gunnar nothing.
let again = keys.ensure("kim", "kim", &console(&endpoint)).await.expect("warm");
assert_eq!(again.fingerprint, identity.fingerprint);
assert_eq!(holds.asked.lock().expect("asked").len(), 1);
}
/// The other half: `AlreadyExists` for a key gunnar does NOT list on this
/// principal is still a refusal — the console never signs as a principal
/// with a key gunnar files under somebody else.
#[tokio::test]
async fn already_exists_for_a_key_the_principal_does_not_hold_is_still_refused() {
let holds = Holds {
foreign: true,
..Holds::default()
};
let endpoint = serve(holds.clone()).await;
let dir = tempfile::tempdir().expect("a temp dir");
let store = crate::state::StateStore::open(dir.path().join("state.redb")).expect("a store");
let keys = super::TenantKeys::new(endpoint.clone(), store);
assert!(keys.ensure("kim", "kim", &console(&endpoint)).await.is_err());
}
/// A gunnar that revokes whatever key it is asked to, except `refuse`, and
/// says which it was asked for.
#[derive(Clone, Default)]
struct Revokes {
asked: Arc<Mutex<Vec<String>>>,
refuse: Option<String>,
}
#[tonic::async_trait]
impl pb::access_server::Access for Revokes {
async fn revoke_key(
&self,
request: tonic::Request<pb::RevokeKeyRequest>,
) -> Result<tonic::Response<pb::RevokeKeyResponse>, tonic::Status> {
let fingerprint = request.into_inner().fingerprint;
self.asked.lock().expect("asked").push(fingerprint.clone());
if self.refuse.as_deref() == Some(fingerprint.as_str()) {
return Err(tonic::Status::unavailable("gunnar is busy"));
}
if fingerprint == "SHA256:gone" {
return Err(tonic::Status::not_found("no such key"));
}
Ok(tonic::Response::new(pb::RevokeKeyResponse {}))
}
}
fn fingerprint_of(private: &str) -> String {
crate::signer::Signer::from_openssh(private).expect("a key").fingerprint().to_owned()
}
/// ★ **RA5-13: the per-person keys this console no longer signs with are
/// revoked at gunnar and removed here.** Since the identity core, no
/// session holds a key at gunnar; every tenant key minted before (and every
/// seed the ladder laid) stays in the store and enrolled on its person,
/// unused — a copy of the store signs as each of them. The sweep revokes
/// each at gunnar, then forgets it (the record, the pending record, the
/// seed file); a key gunnar no longer holds is forgotten too; one gunnar
/// would not revoke now is KEPT, so the next sweep tries again.
#[tokio::test]
async fn the_sweep_revokes_every_stored_tenant_key_at_gunnar_and_forgets_it() {
let (anna, bob, kim, seed) = (
crate::reviewer::mint_ed25519().unwrap(),
crate::reviewer::mint_ed25519().unwrap(),
crate::reviewer::mint_ed25519().unwrap(),
crate::reviewer::mint_ed25519().unwrap(),
);
let revokes = Revokes { refuse: Some(fingerprint_of(&kim)), ..Revokes::default() };
let endpoint = {
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 served = revokes.clone();
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(pb::access_server::AccessServer::new(served))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
format!("http://{addr}")
};
let dir = tempfile::tempdir().expect("a temp dir");
let store = crate::state::StateStore::open(dir.path().join("state.redb")).expect("a store");
store.put("tenant-identity:anna", format!("anna\n{anna}").as_bytes()).unwrap();
store.put("tenant-pending:bob", format!("bob\n{bob}").as_bytes()).unwrap();
store.put("tenant-identity:kim", format!("kim\n{kim}").as_bytes()).unwrap();
store.put("reviewer-identity:anna", b"not a tenant key: untouched").unwrap();
let seeds = dir.path().join("seeds");
std::fs::create_dir_all(&seeds).unwrap();
std::fs::write(seeds.join("rickard"), &seed).unwrap();
let keys = super::TenantKeys::new(endpoint.clone(), store.clone()).with_seeds(Some(seeds.clone()));
let swept = keys.sweep(&console(&endpoint)).await;
let asked = revokes.asked.lock().unwrap().clone();
for (who, key) in [("anna", &anna), ("bob", &bob), ("kim", &kim), ("the seed", &seed)] {
assert!(asked.contains(&fingerprint_of(key)), "{who}'s key was revoked at gunnar: {asked:?}");
}
assert_eq!(store.get("tenant-identity:anna").unwrap(), None, "forgotten once revoked");
assert_eq!(store.get("tenant-pending:bob").unwrap(), None);
assert!(!seeds.join("rickard").exists(), "the seed's private half is gone");
assert!(store.get("tenant-identity:kim").unwrap().is_some(), "kept: gunnar did not revoke it yet");
assert_eq!(store.get("reviewer-identity:anna").unwrap().as_deref(), Some(&b"not a tenant key: untouched"[..]));
assert_eq!((swept.revoked, swept.kept), (3, 1), "{swept:?}");
}
/// A path, a hidden file, a shell word: none of them is a seed's name.
#[test]
fn a_seed_is_looked_up_by_a_plain_name_and_nothing_else() {
for fine in ["rickard", "p-anna", "a.b_c-9"] {
assert!(plain_file_name(fine), "{fine}");
}
for bad in ["", ".", "..", "../console_key", "a/b", ".hidden", "rick'; rm -rf / #", "a b", "räv"] {
assert!(!plain_file_name(bad), "{bad:?} would be joined to the seeds directory");
}
}
}