gunnar-grpc 1.5.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **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());
    }
}