crates/gunnar-grpc/src/machine_enrolment.rs
//! **A machine's enrolment requests, waiting for an admin** (2026-10-05).
//!
//! The ops kit used to get its console credential from the owner's copied
//! session cookie — an account-takeover pattern the owner refused ("WHY THIS
//! WAY?? SOLVE IT"). Now the kit ASKS: it makes its own key pair, answers a
//! creation ceremony with it and names the role it wants
//! (`Access.RequestMachineEnrolment`). The request waits here, doing nothing,
//! until a person who is a server admin approves it with a fresh step-up of
//! their own passkey (`Access.ApproveMachineEnrolment`, verified by gunnar),
//! or refuses it, or it expires.
//!
//! In memory, on purpose, like the web challenges: a request lives fifteen
//! minutes, and one made before a restart is one its kit asks again. What an
//! approval CREATES — the machine principal, its grant, its credential — is
//! the identity table's, in one update ([`crate::seam::MachineApproval`]).
use std::collections::BTreeMap;
use std::sync::Mutex;
use gunnar_proto::{GrantLevel, MachineEnrolment, MachineEnrolmentState};
/// How long a request waits for an admin: fifteen minutes.
pub const REQUEST_TTL_MS: i64 = 15 * 60_000;
/// How long a decided (or expired) request is still answered to its
/// machine's poll: fifteen minutes more.
pub const KEEP_DECIDED_MS: i64 = 15 * 60_000;
/// The most requests pending at once. Asking is anonymous at the console (the
/// relay asks on the machine's behalf), so this bounds a flood: a full table
/// refuses the next one, and the admin's list stays readable.
pub const MAX_PENDING: usize = 16;
/// The prefix every machine an enrolment makes carries — reserved for
/// machines (`service::access::is_reserved_principal_name`).
pub const MACHINE_PREFIX: &str = "kit-";
/// **Is `name` a machine name an enrolment may make?** `kit-` and then
/// lowercase letters, digits and `-`, at most 64 characters, not ending in
/// `-`. Pure.
pub fn check_machine_name(name: &str) -> Result<(), String> {
let rest = name.strip_prefix(MACHINE_PREFIX).unwrap_or("");
let ok = !rest.is_empty()
&& name.len() <= 64
&& !name.ends_with('-')
&& rest
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if ok {
Ok(())
} else {
Err(format!(
"{name:?} is not a machine name an enrolment makes: `{MACHINE_PREFIX}` and then \
[a-z0-9-], at most 64 characters (e.g. `kit-dwarf`)"
))
}
}
/// The alphabet of a code's letters: no I or O, which read as 1 and 0.
const LETTERS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
/// **A code for the eye**: three letters, a dash, three digits (`ABC-123`),
/// from `bytes` (at least six). Pure.
pub fn code_of(bytes: &[u8]) -> String {
let letter = |b: u8| LETTERS[usize::from(b) % LETTERS.len()] as char;
let digit = |b: u8| char::from(b'0' + b % 10);
format!(
"{}{}{}-{}{}{}",
letter(bytes[0]),
letter(bytes[1]),
letter(bytes[2]),
digit(bytes[3]),
digit(bytes[4]),
digit(bytes[5])
)
}
/// One request and what only gunnar keeps of it: the public key.
#[derive(Debug, Clone)]
pub struct Held {
pub row: MachineEnrolment,
pub public_key: Vec<u8>,
pub sign_count: u32,
}
/// **The requests this process holds.**
#[derive(Debug)]
pub struct Requests {
rows: Mutex<BTreeMap<String, Held>>,
ttl_ms: i64,
}
impl Default for Requests {
fn default() -> Self {
Requests::with_ttl(REQUEST_TTL_MS)
}
}
/// What a sweep found newly expired (for the audit line), by id.
pub type Expired = Vec<MachineEnrolment>;
impl Requests {
/// A table whose requests wait `ttl_ms` (a test's short life; the
/// listener's is [`REQUEST_TTL_MS`]).
pub fn with_ttl(ttl_ms: i64) -> Requests {
Requests {
rows: Mutex::new(BTreeMap::new()),
ttl_ms,
}
}
fn sweep(rows: &mut BTreeMap<String, Held>, now: i64) -> Expired {
let mut expired = Vec::new();
for held in rows.values_mut() {
if held.row.state == MachineEnrolmentState::Pending as i32
&& held.row.expires_at_unix_ms <= now
{
held.row.state = MachineEnrolmentState::Expired as i32;
held.row.decided_at_unix_ms = held.row.expires_at_unix_ms;
expired.push(held.row.clone());
}
}
rows.retain(|_, held| {
held.row.state == MachineEnrolmentState::Pending as i32
|| held.row.decided_at_unix_ms + KEEP_DECIDED_MS > now
});
expired
}
/// **Hold a new request**, pending for [`REQUEST_TTL_MS`]: an id and a
/// code minted here. Refused when [`MAX_PENDING`] wait already, or when
/// the same credential id waits already. Answers the row and what the
/// sweep found expired.
#[allow(clippy::too_many_arguments)]
pub fn hold(
&self,
machine: &str,
label: &str,
server_level: GrantLevel,
credential_id: &[u8],
public_key: &[u8],
sign_count: u32,
now: i64,
) -> Result<(MachineEnrolment, Expired), tonic::Status> {
let mut rows = self.rows.lock().unwrap_or_else(|p| p.into_inner());
let expired = Self::sweep(&mut rows, now);
let pending: Vec<&Held> = rows
.values()
.filter(|h| h.row.state == MachineEnrolmentState::Pending as i32)
.collect();
if pending.len() >= MAX_PENDING {
return Err(tonic::Status::resource_exhausted(format!(
"{MAX_PENDING} machine enrolment requests are waiting already; approve, refuse or \
let some expire first"
)));
}
if pending.iter().any(|h| h.row.credential_id == credential_id) {
return Err(tonic::Status::already_exists(
"this credential is waiting for approval already",
));
}
let random = crate::webauthn::random_bytes(16 + 6);
let row = MachineEnrolment {
request_id: crate::webauthn::hex(&random[..16]),
machine: machine.to_owned(),
label: label.to_owned(),
server_level: server_level as i32,
code: code_of(&random[16..]),
key_fingerprint: key_fingerprint(public_key),
requested_at_unix_ms: now,
expires_at_unix_ms: now + self.ttl_ms,
state: MachineEnrolmentState::Pending as i32,
decided_by: String::new(),
decided_at_unix_ms: 0,
credential_id: credential_id.to_vec(),
};
rows.insert(
row.request_id.clone(),
Held {
row: row.clone(),
public_key: public_key.to_vec(),
sign_count,
},
);
Ok((row, expired))
}
/// One request, as it stands now (expired when its time is up).
pub fn get(&self, id: &str, now: i64) -> (Option<Held>, Expired) {
let mut rows = self.rows.lock().unwrap_or_else(|p| p.into_inner());
let expired = Self::sweep(&mut rows, now);
(rows.get(id).cloned(), expired)
}
/// Every pending request, oldest first.
pub fn pending(&self, now: i64) -> (Vec<MachineEnrolment>, Expired) {
let mut rows = self.rows.lock().unwrap_or_else(|p| p.into_inner());
let expired = Self::sweep(&mut rows, now);
let mut pending: Vec<MachineEnrolment> = rows
.values()
.filter(|h| h.row.state == MachineEnrolmentState::Pending as i32)
.map(|h| h.row.clone())
.collect();
pending.sort_by(|a, b| {
(a.requested_at_unix_ms, &a.request_id).cmp(&(b.requested_at_unix_ms, &b.request_id))
});
(pending, expired)
}
/// **Put a request back to pending** when the approval it was decided
/// for could not be committed (the plane refused): the admin may try
/// again while it lives.
pub fn reopen(&self, id: &str) {
let mut rows = self.rows.lock().unwrap_or_else(|p| p.into_inner());
if let Some(held) = rows.get_mut(id) {
held.row.state = MachineEnrolmentState::Pending as i32;
held.row.decided_by.clear();
held.row.decided_at_unix_ms = 0;
}
}
/// **Decide a request** that is still pending: approved or refused, by
/// whom. The row as decided; `None` when it is not pending (any more).
pub fn decide(
&self,
id: &str,
state: MachineEnrolmentState,
by: &str,
now: i64,
) -> Option<MachineEnrolment> {
let mut rows = self.rows.lock().unwrap_or_else(|p| p.into_inner());
Self::sweep(&mut rows, now);
let held = rows.get_mut(id)?;
if held.row.state != MachineEnrolmentState::Pending as i32 {
return None;
}
held.row.state = state as i32;
held.row.decided_by = by.to_owned();
held.row.decided_at_unix_ms = now;
Some(held.row.clone())
}
}
/// `SHA256:<base64>` of a SEC1 public key: a name for the key that is not the
/// key, as an SSH fingerprint is written.
pub fn key_fingerprint(public_key: &[u8]) -> String {
use base64ct::Encoding as _;
use sha2::Digest as _;
format!(
"SHA256:{}",
base64ct::Base64Unpadded::encode_string(&sha2::Sha256::digest(public_key))
)
}
/// The state's word, for an audit line or a refusal.
pub fn state_word(state: i32) -> &'static str {
match MachineEnrolmentState::try_from(state) {
Ok(MachineEnrolmentState::Pending) => "pending",
Ok(MachineEnrolmentState::Approved) => "approved",
Ok(MachineEnrolmentState::Refused) => "refused",
Ok(MachineEnrolmentState::Expired) => "expired",
_ => "unknown",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_machine_name_is_kit_and_then_a_plain_slug() {
for good in ["kit-dwarf", "kit-a", "kit-dwarf-2", "kit-0"] {
assert!(check_machine_name(good).is_ok(), "{good}");
}
for bad in [
"dwarf",
"kit-",
"kit-Dwarf",
"kit-dw_arf",
"kit-dwarf-",
"console-x",
"kit- x",
"rickard",
] {
assert!(check_machine_name(bad).is_err(), "{bad}");
}
assert!(check_machine_name(&format!("kit-{}", "a".repeat(61))).is_err());
}
#[test]
fn a_code_is_three_letters_and_three_digits_with_no_i_or_o() {
let code = code_of(&[0, 23, 8, 9, 10, 255]);
assert_eq!(code.len(), 7);
assert_eq!(&code[3..4], "-");
assert!(code[..3].bytes().all(|b| LETTERS.contains(&b)));
assert!(code[4..].bytes().all(|b| b.is_ascii_digit()));
assert_eq!(code, "AZJ-905");
}
#[test]
fn a_request_waits_then_expires_and_is_forgotten_after_its_keeping_time() {
let r = Requests::default();
let (row, _) = r
.hold(
"kit-dwarf",
"kit on dwarf",
GrantLevel::Admin,
b"cred",
&[4u8; 65],
0,
1_000,
)
.expect("held");
assert_eq!(row.state, MachineEnrolmentState::Pending as i32);
assert_eq!(row.request_id.len(), 32);
assert_eq!(r.pending(1_000).0.len(), 1);
let (_, expired) = r.get(&row.request_id, 1_000 + REQUEST_TTL_MS);
assert_eq!(expired.len(), 1, "the sweep names what it expired");
let (held, _) = r.get(&row.request_id, 1_000 + REQUEST_TTL_MS + 1);
assert_eq!(
held.expect("still answered").row.state,
MachineEnrolmentState::Expired as i32
);
assert!(r.pending(1_000 + REQUEST_TTL_MS).0.is_empty());
assert!(
r.decide(
&row.request_id,
MachineEnrolmentState::Approved,
"kim",
1_000 + REQUEST_TTL_MS + 2
)
.is_none(),
"an expired request is decided by nobody"
);
assert!(r
.get(
&row.request_id,
1_000 + REQUEST_TTL_MS + KEEP_DECIDED_MS + 1
)
.0
.is_none());
}
#[test]
fn a_flood_is_refused_and_one_credential_waits_once() {
let r = Requests::default();
for i in 0..MAX_PENDING {
r.hold(
"kit-x",
"x",
GrantLevel::Write,
&[i as u8],
&[4u8; 65],
0,
0,
)
.expect("room");
}
assert!(r
.hold(
"kit-x",
"x",
GrantLevel::Write,
b"one more",
&[4u8; 65],
0,
0
)
.is_err());
let r = Requests::default();
r.hold("kit-x", "x", GrantLevel::Write, b"same", &[4u8; 65], 0, 0)
.expect("first");
assert!(r
.hold("kit-x", "x", GrantLevel::Write, b"same", &[4u8; 65], 0, 0)
.is_err());
}
}