use std::collections::BTreeMap;
use std::sync::Mutex;
use gunnar_proto::{GrantLevel, MachineEnrolment, MachineEnrolmentState};
pub const REQUEST_TTL_MS: i64 = 15 * 60_000;
pub const KEEP_DECIDED_MS: i64 = 15 * 60_000;
pub const MAX_PENDING: usize = 16;
pub const MACHINE_PREFIX: &str = "kit-";
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`)"
))
}
}
const LETTERS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
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])
)
}
#[derive(Debug, Clone)]
pub struct Held {
pub row: MachineEnrolment,
pub public_key: Vec<u8>,
pub sign_count: u32,
}
#[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)
}
}
pub type Expired = Vec<MachineEnrolment>;
impl Requests {
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
}
#[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))
}
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)
}
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)
}
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;
}
}
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())
}
}
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))
)
}
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());
}
}