Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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));
    }
}