crates/nornir-identity-session/src/invites.rs
//! **Single-use enrolment invites** (moved from Holger's `passkey.rs`, A34 M-5).
//!
//! The secret is **never stored**: an invite's `id` is `blake3(secret)`,
//! base64url, so a stolen store yields no usable invite. The secret exists
//! exactly once, in the answer that minted it. An invite is a link that travels
//! out of band, so **an invite never grants an admin** ([`mint`] asks the
//! product's policy). Expired, used, revoked and never-minted invites are refused
//! with ONE answer ([`Refused::NotOpen`]), so the refusal is no oracle for which
//! invites existed.
use base64::Engine as _;
use serde::{Deserialize, Serialize};
const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
/// How long an invite stays redeemable when the product says nothing: a day. It
/// is carried out of band (read aloud, a QR, printed), so it must outlive a walk
/// down a corridor and not a weekend.
pub const INVITE_TTL_SECS: i64 = 24 * 3600;
/// The most invites a store keeps; the oldest closed ones go first.
pub const MAX_INVITES: usize = 256;
/// A single-use enrolment grant, as a product stores it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Invite {
/// `blake3(secret)`, base64url. The lookup key and the audit id.
pub id: String,
/// The name the admin pinned, or EMPTY: the invitee chooses one.
pub display: String,
/// The groups the invitee joins.
pub groups: Vec<String>,
/// Minted at, Unix seconds.
pub created: i64,
/// Redeemable until, Unix seconds (exclusive).
pub expires: i64,
/// Who minted it.
pub created_by: String,
/// Who redeemed it.
pub used_by: Option<String>,
/// When it was redeemed.
pub used_at: Option<i64>,
/// Revoked by an admin.
pub revoked: bool,
}
impl Invite {
/// Is this invite still redeemable?
pub fn open(&self, now: i64) -> bool {
!self.revoked && self.used_by.is_none() && now < self.expires
}
}
/// Why an invite was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refused {
/// The invite would make an admin.
WouldBeAdmin,
/// Unknown, expired, used or revoked: one answer for all four.
NotOpen,
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refused::WouldBeAdmin => f.write_str(
"an invite cannot grant admin: it is a link that travels out of band. Enrol another \
admin with your own session instead",
),
Refused::NotOpen => f.write_str("this invite is not valid (it may have expired or been used)"),
}
}
}
impl std::error::Error for Refused {}
/// The id an invite's secret is stored under.
pub fn hash_secret(secret: &str) -> String {
B64.encode(blake3::hash(secret.as_bytes()).as_bytes())
}
/// What a new invite is for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ask {
/// The pinned name, or empty.
pub display: String,
/// The groups.
pub groups: Vec<String>,
/// Who mints it.
pub by: String,
/// How long it lives, seconds.
pub ttl_secs: i64,
}
/// **Mint an invite**: refused when `grants_admin(groups)` (the product's
/// policy) says the groups make an admin. Returns the secret (shown once) and
/// the record to store.
///
/// # Panics
///
/// When the operating system has no random generator.
pub fn mint(ask: Ask, now: i64, grants_admin: impl FnOnce(&[String]) -> bool) -> Result<(String, Invite), Refused> {
if grants_admin(&ask.groups) {
return Err(Refused::WouldBeAdmin);
}
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("the operating system's random generator");
let secret = B64.encode(bytes);
let invite = Invite {
id: hash_secret(&secret),
display: ask.display,
groups: ask.groups,
created: now,
expires: now.saturating_add(ask.ttl_secs),
created_by: ask.by,
used_by: None,
used_at: None,
revoked: false,
};
Ok((secret, invite))
}
/// Keep at most `max` invites, dropping closed ones first (then the oldest).
pub fn trim(invites: &mut Vec<Invite>, max: usize, now: i64) {
while invites.len() > max {
let victim = invites.iter().position(|i| !i.open(now)).unwrap_or(0);
invites.remove(victim);
}
}
/// The open invite `secret` names, or [`Refused::NotOpen`].
pub fn find_open<'a>(invites: &'a [Invite], secret: &str, now: i64) -> Result<&'a Invite, Refused> {
let id = hash_secret(secret.trim());
invites.iter().find(|i| i.id == id && i.open(now)).ok_or(Refused::NotOpen)
}
/// **Spend the invite `secret` names** for `by`: single use. The caller holds
/// its store's lock across the read, this call and the write, so two racing
/// redemptions cannot both pass.
pub fn redeem(invites: &mut [Invite], secret: &str, by: &str, now: i64) -> Result<Invite, Refused> {
let id = hash_secret(secret.trim());
let invite = invites.iter_mut().find(|i| i.id == id && i.open(now)).ok_or(Refused::NotOpen)?;
invite.used_by = Some(by.to_owned());
invite.used_at = Some(now);
Ok(invite.clone())
}
#[cfg(test)]
mod tests {
use super::*;
fn ask(groups: &[&str]) -> Ask {
Ask {
display: "bo".into(),
groups: groups.iter().map(|g| (*g).to_owned()).collect(),
by: "admin".into(),
ttl_secs: INVITE_TTL_SECS,
}
}
#[test]
fn an_invite_is_stored_by_its_hash_and_spent_once() {
let (secret, invite) = mint(ask(&["readers"]), 1_000, |_| false).expect("minted");
assert_ne!(invite.id, secret);
assert!(!serde_json::to_string(&invite).unwrap().contains(&secret), "the secret is never stored");
assert_eq!(invite.id, hash_secret(&secret));
let mut all = vec![invite];
assert_eq!(find_open(&all, &secret, 1_001).map(|i| i.display.as_str()), Ok("bo"));
let spent = redeem(&mut all, &secret, "bo", 1_002).expect("redeemed");
assert_eq!((spent.used_by.as_deref(), spent.used_at), (Some("bo"), Some(1_002)));
assert_eq!(redeem(&mut all, &secret, "eve", 1_003), Err(Refused::NotOpen), "single use");
}
#[test]
fn expired_revoked_and_unknown_invites_are_one_refusal() {
let (secret, invite) = mint(ask(&[]), 0, |_| false).unwrap();
let mut all = vec![invite];
assert_eq!(find_open(&all, &secret, INVITE_TTL_SECS).err(), Some(Refused::NotOpen), "expired");
assert_eq!(find_open(&all, "not-an-invite", 1).err(), Some(Refused::NotOpen), "never minted");
all[0].revoked = true;
assert_eq!(find_open(&all, &secret, 1).err(), Some(Refused::NotOpen), "revoked");
}
#[test]
fn an_invite_never_makes_an_admin() {
let r = mint(ask(&["admins"]), 0, |groups| groups.iter().any(|g| g == "admins"));
assert_eq!(r.err(), Some(Refused::WouldBeAdmin));
assert!(Refused::WouldBeAdmin.to_string().contains("cannot grant admin"));
}
#[test]
fn a_full_store_drops_closed_invites_first() {
let mut all: Vec<Invite> = (0..4).map(|_| mint(ask(&[]), 0, |_| false).unwrap().1).collect();
all[2].revoked = true;
let closed = all[2].id.clone();
trim(&mut all, 3, 1);
assert_eq!(all.len(), 3);
assert!(all.iter().all(|i| i.id != closed));
}
}