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

nordisk/gunnar

crates/gunnar-crypt/src/members.rs

//! `.gunnar-crypt/members`: who the key ring is wrapped to, by each member's
//! USER encryption key (its public half).
//!
//! ```text
//! # gunnar-crypt members v1 — public keys only.
//! alice age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq
//! bob age1…
//! recovery age1…
//! ```
//!
//! A **quantum-safe** repository says so in one line, and then lists only
//! `age1pq1…` user keys (age's hybrid ML-KEM-768 + X25519 recipient). That is
//! the default for a new repository ([`Level::NEW`]); a file without the line
//! is classic, as every file was before it existed:
//!
//! ```text
//! # gunnar-crypt members v1 — public keys only.
//! level quantum-safe
//! alice age1pq1…
//! recovery age1pq1…
//! ```
//!
//! One line per user: each user has ONE encryption key ([`crate::userkey`]),
//! whatever they unlock it with. A repository never names an SSH key or a
//! security key: those are the user's unlock METHODS, and adding or removing
//! one changes the user's own wrap on the server, never a repository. The file
//! is plaintext because every member must be able to re-wrap the ring after an
//! add or a removal without asking anyone; a public key reveals nothing.
//!
//! Which keys can be members:
//!
//! * `age1pq1…` — a quantum-safe user key (the default kind), and the ONLY kind
//!   a quantum-safe repository takes.
//! * `age1…` — a user's classic X25519 encryption key (or an offline recovery
//!   key), for a classic repository.
//! * NOT an SSH key (`ssh-ed25519`, `ssh-rsa`, `sk-…`): it is an unlock
//!   method of its owner's encryption key, refused here by name.

/// The first line `gunnar crypt` writes.
pub const HEADER: &str = "# gunnar-crypt members v1 — public keys only; the key ring in .gunnar-crypt/keys is wrapped to every key below.";

/// What kind of public key a member line carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyKind {
    /// `ssh-ed25519`
    SshEd25519,
    /// `ssh-rsa`
    SshRsa,
    /// `age1…` (X25519), for recovery keys.
    Age,
    /// `age1pq1…`: age's hybrid ML-KEM-768 + X25519 recipient
    /// (`mlkem768x25519`, c2sp.org/age). Quantum-safe.
    AgePq,
}

impl KeyKind {
    /// The age stanza type a key ring wrapped to this key carries.
    pub fn stanza_type(self) -> &'static str {
        match self {
            KeyKind::SshEd25519 => "ssh-ed25519",
            KeyKind::SshRsa => "ssh-rsa",
            KeyKind::Age => "X25519",
            KeyKind::AgePq => PQ_STANZA,
        }
    }
}

/// The age stanza type of the hybrid post-quantum recipient.
pub const PQ_STANZA: &str = "mlkem768x25519";

/// How the key ring is wrapped: chosen per repository, in the members file.
///
/// **A new repository is quantum-safe** ([`Level::NEW`], owner 2026-10-30,
/// CRYPT-PQ-DEFAULT), and so is a new person's key. A members file WITHOUT a
/// `level` line is classic ([`Level::UNSTATED`]): an existing repository keeps
/// its level, and nothing is migrated. `Level::default()` is [`Level::NEW`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Level {
    /// Wrapped to members' X25519 (`age1…`) encryption keys: a ring harvested
    /// today opens once a quantum computer breaks X25519. The visible opt-out
    /// (`gunnar crypt init --level classic`) for members whose one key is classic.
    Classic,
    /// Wrapped ONLY to age's hybrid ML-KEM-768 + X25519 recipients
    /// (`age1pq1…`): no stanza a quantum computer could open. The default.
    #[default]
    QuantumSafe,
}

impl std::str::FromStr for Level {
    type Err = String;

    /// `classic` or `quantum-safe`, the words of the members file and of
    /// `--level`.
    fn from_str(word: &str) -> Result<Level, String> {
        match word.trim() {
            "classic" => Ok(Level::Classic),
            "quantum-safe" => Ok(Level::QuantumSafe),
            other => Err(format!("{other:?} is not a level: classic or quantum-safe")),
        }
    }
}

impl std::fmt::Display for Level {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.word())
    }
}

impl Level {
    /// **The level of a new repository and of a new person's key**, unless
    /// asked otherwise: quantum-safe (owner 2026-10-30, CRYPT-PQ-DEFAULT). The
    /// CLI (`gunnar crypt init`, `gunnar crypt key new`) and the console read it
    /// here, so the default lives in one place.
    pub const NEW: Level = Level::QuantumSafe;

    /// The level of a members file without a `level` line: classic, as every
    /// file was before the line existed. An existing repository keeps its level.
    pub const UNSTATED: Level = Level::Classic;

    /// The word in the members file (`level quantum-safe`).
    pub fn word(self) -> &'static str {
        match self {
            Level::Classic => "classic",
            Level::QuantumSafe => "quantum-safe",
        }
    }

    /// Does a key of `kind` belong in a ring of this level?
    pub fn takes(self, kind: KeyKind) -> bool {
        match self {
            Level::Classic => kind == KeyKind::Age,
            Level::QuantumSafe => kind == KeyKind::AgePq,
        }
    }

    /// The age stanza types a key file of this level may carry (besides the
    /// one random `…-grease` stanza age adds to every file).
    pub fn stanza_types(self) -> &'static [&'static str] {
        match self {
            Level::Classic => &["X25519"],
            Level::QuantumSafe => &[PQ_STANZA],
        }
    }
}

/// The HRP of an `age1pq1…` recipient.
const PQ_HRP: &str = "age1pq";
/// The size of an X-Wing (MLKEM768-X25519) encapsulation key.
pub const PQ_PUBLIC_KEY_BYTES: usize = 1216;

/// The length of an `age1pq1…` recipient: the HRP, the separator, 1216 bytes
/// in bech32's 5-bit symbols, and the 6-symbol checksum.
const PQ_KEY_CHARS: usize = PQ_HRP.len() + 1 + (PQ_PUBLIC_KEY_BYTES * 8).div_ceil(5) + 6;

/// Does `key` have the SHAPE of an `age1pq1…` recipient (prefix, length,
/// bech32's lower-case alphabet)? What a build without `pq-keys` checks.
pub fn looks_like_pq_key(key: &str) -> bool {
    const ALPHABET: &str = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
    let key = key.trim();
    key.len() == PQ_KEY_CHARS
        && key
            .strip_prefix("age1pq1")
            .is_some_and(|data| data.chars().all(|c| ALPHABET.contains(c)))
}

fn pq_key_ok(key: &str) -> bool {
    #[cfg(feature = "pq-keys")]
    {
        pq_public_key_bytes(key).is_some()
    }
    #[cfg(not(feature = "pq-keys"))]
    {
        looks_like_pq_key(key)
    }
}

/// The 1216 key bytes of an `age1pq1…` recipient, `None` unless it is one
/// (bech32 with the right prefix, checksum and length). The format check the
/// SERVER can do; whether the bytes are a valid ML-KEM key is the wrap's.
#[cfg(feature = "pq-keys")]
pub fn pq_public_key_bytes(key: &str) -> Option<Vec<u8>> {
    use bech32::FromBase32 as _;
    let key = key.trim();
    if !key.starts_with("age1pq1") || key.bytes().any(|b| b.is_ascii_uppercase()) {
        return None;
    }
    let (hrp, data, variant) = bech32::decode(key).ok()?;
    if hrp != PQ_HRP || variant != bech32::Variant::Bech32 {
        return None;
    }
    let bytes = Vec::<u8>::from_base32(&data).ok()?;
    (bytes.len() == PQ_PUBLIC_KEY_BYTES).then_some(bytes)
}

/// Why a public key cannot be a recipient.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyRefusal {
    /// A FIDO security-key SSH key (`sk-…`): it can only sign.
    SecurityKeyOnly(String),
    /// A key type age cannot encrypt to (e.g. ECDSA).
    Unsupported(String),
    /// Not a public key line at all.
    Malformed,
    /// Starts like an `age1pq1…` key but is not one.
    MalformedPq,
}

impl std::fmt::Display for KeyRefusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            KeyRefusal::SecurityKeyOnly(t) => write!(
                f,
                "{t} is a security-key (YubiKey/FIDO) SSH key: it can only sign, so nothing \
                 can be encrypted to it. Add an ordinary ed25519 key (ssh-keygen -t ed25519) \
                 for encrypted repositories; keep the security key for login."
            ),
            KeyRefusal::Unsupported(t) => write!(
                f,
                "{t} keys cannot be encryption recipients; use ssh-ed25519 (or ssh-rsa)"
            ),
            KeyRefusal::Malformed => write!(f, "not an SSH or age public key"),
            KeyRefusal::MalformedPq => write!(
                f,
                "not a well-formed age1pq1… key (age's hybrid ML-KEM-768 + X25519 recipient)"
            ),
        }
    }
}
impl std::error::Error for KeyRefusal {}

/// Classify one public key (`<type> <base64> [comment]`, or `age1…`).
pub fn classify(key: &str) -> Result<KeyKind, KeyRefusal> {
    let key = key.trim();
    let ty = key.split_ascii_whitespace().next().unwrap_or("");
    if ty.starts_with("age1pq1") {
        return match (key.split_ascii_whitespace().count(), pq_key_ok(ty)) {
            (1, true) => Ok(KeyKind::AgePq),
            _ => Err(KeyRefusal::MalformedPq),
        };
    }
    if ty.starts_with("age1") && ty.len() > 10 && key.split_ascii_whitespace().count() == 1 {
        return Ok(KeyKind::Age);
    }
    let has_body = key.split_ascii_whitespace().nth(1).is_some();
    match ty {
        "ssh-ed25519" if has_body => Ok(KeyKind::SshEd25519),
        "ssh-rsa" if has_body => Ok(KeyKind::SshRsa),
        t if t.starts_with("sk-") && has_body => Err(KeyRefusal::SecurityKeyOnly(t.to_string())),
        t if (t.starts_with("ecdsa-") || t.starts_with("ssh-dss")) && has_body => {
            Err(KeyRefusal::Unsupported(t.to_string()))
        }
        _ => Err(KeyRefusal::Malformed),
    }
}

/// One line: a member name and one of their public keys.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberKey {
    /// The member's gunnar user name (or `recovery`).
    pub name: String,
    /// The public key as written (`ssh-ed25519 AAAA… comment`, or `age1…`).
    pub key: String,
    /// Its kind.
    pub kind: KeyKind,
}

impl MemberKey {
    /// The key's type and body without the comment: what identifies the key.
    pub fn key_id(&self) -> &str {
        key_id(&self.key)
    }
}

fn key_id(key: &str) -> &str {
    let key = key.trim();
    let mut it = key.match_indices(' ');
    match (it.next(), it.next()) {
        (Some(_), Some((second, _))) => &key[..second],
        _ => key,
    }
}

/// Why the members file was refused.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MembersError {
    /// A line that is not `<name> <public key>`.
    BadLine(usize),
    /// A member name outside `[A-Za-z0-9._@-]`.
    BadName(String),
    /// A key that cannot be a recipient.
    Key(usize, KeyRefusal),
    /// The same key listed twice.
    Duplicate(String),
    /// A `level` line naming no level this version knows.
    BadLevel(String),
    /// An SSH key listed as a member: it is an unlock method of its owner's
    /// encryption key, and a repository is wrapped to that key instead.
    SshKeyIsAMethod {
        /// The member.
        name: String,
    },
    /// A key that does not belong at the file's level: an SSH or `age1…`
    /// key in a quantum-safe repository, or an `age1pq1…` key in a classic one.
    WrongLevel {
        /// The member.
        name: String,
        /// The key's type.
        kind: KeyKind,
        /// The file's level.
        level: Level,
    },
}

impl std::fmt::Display for MembersError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MembersError::BadLine(n) => write!(f, "members line {n} is not `<name> <public key>`"),
            MembersError::BadName(n) => write!(
                f,
                "member name {n:?} may only use letters, digits and . _ @ -"
            ),
            MembersError::Key(n, r) => write!(f, "members line {n}: {r}"),
            MembersError::Duplicate(k) => write!(f, "the key {k} is listed twice"),
            MembersError::BadLevel(l) => write!(
                f,
                "`level {l}` is not a level (classic or quantum-safe); a newer gunnar may know it"
            ),
            MembersError::SshKeyIsAMethod { name } => write!(
                f,
                "{name}'s SSH key cannot be a member key: a repository is wrapped to {name}'s \
                 encryption key (age1…), which the SSH key unlocks. Add {name} with \
                 `gunnar crypt add-member {name} --from-server` (gunnar holds that key's public half)"
            ),
            MembersError::WrongLevel { name, kind, level: Level::QuantumSafe } => write!(
                f,
                "{name}'s {} key cannot be a recipient: this repository is quantum-safe, and an \
                 X25519 key exchange is what a quantum computer breaks. {name} needs a \
                 quantum-safe encryption key (age1pq1…), which `gunnar crypt key new` makes. A \
                 person whose one key is classic is a member of classic repositories only \
                 (`gunnar crypt init --level classic`)",
                kind.stanza_type()
            ),
            MembersError::WrongLevel { name, level: Level::Classic, .. } => write!(
                f,
                "{name}'s age1pq1… encryption key is quantum-safe and this repository is classic; \
                 age never mixes the two in one key ring. `gunnar crypt level quantum-safe` makes \
                 this repository quantum-safe once every member has a quantum-safe key"
            ),
        }
    }
}
impl std::error::Error for MembersError {}

fn valid_name(n: &str) -> bool {
    !n.is_empty()
        && n.len() <= 64
        && n.bytes()
            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'@' | b'-'))
}

/// The members file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Members {
    level: Level,
    entries: Vec<MemberKey>,
}

impl Default for Members {
    fn default() -> Members {
        Members::new()
    }
}

/// Why `gunnar crypt level quantum-safe` cannot re-wrap the repository yet.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpgradeRefusal {
    /// The members with no quantum-safe (`age1pq1…`) key, in the file's order.
    pub missing: Vec<String>,
    /// A key that was given but cannot be a member (listed twice, malformed).
    pub error: Option<MembersError>,
}

impl std::fmt::Display for UpgradeRefusal {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(e) = &self.error {
            return write!(f, "{e}");
        }
        write!(
            f,
            "not quantum-safe yet: {} {} no quantum-safe encryption key (age1pq1…). A quantum-safe \
             repository is wrapped only to such keys, and age never mixes them with classic ones, so \
             EVERY member needs one: give it (`--key NAME=age1pq1…`), or remove the member first \
             (`gunnar crypt remove-member NAME`). Nothing was changed",
            self.missing.join(", "),
            if self.missing.len() == 1 { "has" } else { "have" }
        )
    }
}
impl std::error::Error for UpgradeRefusal {}

impl Members {
    /// An empty list for a NEW repository: at [`Level::NEW`], quantum-safe.
    pub fn new() -> Members {
        Members::at(Level::NEW)
    }

    /// An empty list at `level` (`Members::at(Level::Classic)`: the opt-out).
    pub fn at(level: Level) -> Members {
        Members {
            level,
            entries: Vec::new(),
        }
    }

    /// **The same members, quantum-safe** (`gunnar crypt level quantum-safe`):
    /// every member's key replaced by the `age1pq1…` key `keys` gives for their
    /// name. Refused, naming them, while any member has none (a classic key is
    /// none). A quantum-safe file comes back as it is. The caller rotates the
    /// ring and wraps it to the result.
    pub fn to_quantum_safe(
        &self,
        keys: &std::collections::BTreeMap<String, String>,
    ) -> Result<Members, UpgradeRefusal> {
        if self.level == Level::QuantumSafe {
            return Ok(self.clone());
        }
        let names = self.names();
        let missing: Vec<String> = names
            .iter()
            .filter(|n| {
                !keys
                    .get(**n)
                    .is_some_and(|k| classify(k) == Ok(KeyKind::AgePq))
            })
            .map(|n| n.to_string())
            .collect();
        if !missing.is_empty() {
            return Err(UpgradeRefusal {
                missing,
                error: None,
            });
        }
        let mut up = Members::at(Level::QuantumSafe);
        for n in names {
            up.add(n, &keys[n]).map_err(|e| UpgradeRefusal {
                missing: Vec::new(),
                error: Some(e),
            })?;
        }
        Ok(up)
    }

    /// Parse the file.
    pub fn parse(text: &str) -> Result<Members, MembersError> {
        let mut m = Members::at(Level::UNSTATED);
        // The level comes first in meaning, wherever its line is: every key
        // is checked against it.
        for line in text.lines().map(str::trim) {
            let mut words = line.split_ascii_whitespace();
            if let (Some("level"), Some(word), None) = (words.next(), words.next(), words.next()) {
                if !word.starts_with("ssh-") && !word.starts_with("age1") {
                    m.level = match word {
                        "classic" => Level::Classic,
                        "quantum-safe" => Level::QuantumSafe,
                        other => return Err(MembersError::BadLevel(other.to_string())),
                    };
                }
            }
        }
        for (i, line) in text.lines().enumerate() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let Some((name, key)) = line.split_once(char::is_whitespace) else {
                return Err(MembersError::BadLine(i + 1));
            };
            if name == "level" && matches!(key.trim(), "classic" | "quantum-safe") {
                continue;
            }
            m.add(name, key.trim()).map_err(|e| match e {
                MembersError::Key(_, r) => MembersError::Key(i + 1, r),
                other => other,
            })?;
        }
        Ok(m)
    }

    /// The repository's level: how its key ring is wrapped.
    pub fn level(&self) -> Level {
        self.level
    }

    /// Change the level. Refused while a listed key does not belong at the
    /// new one (an upgrade replaces the keys: [`Members::to_quantum_safe`]).
    pub fn set_level(&mut self, level: Level) -> Result<(), MembersError> {
        if let Some(e) = self.entries.iter().find(|e| !level.takes(e.kind)) {
            return Err(MembersError::WrongLevel {
                name: e.name.clone(),
                kind: e.kind,
                level,
            });
        }
        self.level = level;
        Ok(())
    }

    /// Keep only the keys `keep` says yes to.
    pub fn retain(&mut self, keep: impl FnMut(&MemberKey) -> bool) {
        self.entries.retain(keep);
    }

    /// The text form.
    pub fn to_text(&self) -> String {
        let mut s = String::from(HEADER);
        s.push('\n');
        if self.level != Level::Classic {
            s.push_str("level ");
            s.push_str(self.level.word());
            s.push('\n');
        }
        for e in &self.entries {
            s.push_str(&e.name);
            s.push(' ');
            s.push_str(e.key.trim());
            s.push('\n');
        }
        s
    }

    /// Add one key for `name`. Refuses keys that cannot be recipients and a
    /// key already listed.
    pub fn add(&mut self, name: &str, key: &str) -> Result<(), MembersError> {
        if !valid_name(name) {
            return Err(MembersError::BadName(name.to_string()));
        }
        let kind = classify(key).map_err(|r| match r {
            KeyRefusal::SecurityKeyOnly(_) => MembersError::SshKeyIsAMethod {
                name: name.to_string(),
            },
            r => MembersError::Key(0, r),
        })?;
        if matches!(kind, KeyKind::SshEd25519 | KeyKind::SshRsa) {
            return Err(MembersError::SshKeyIsAMethod {
                name: name.to_string(),
            });
        }
        if !self.level.takes(kind) {
            return Err(MembersError::WrongLevel {
                name: name.to_string(),
                kind,
                level: self.level,
            });
        }
        let id = key_id(key);
        if self.entries.iter().any(|e| e.key_id() == id) {
            return Err(MembersError::Duplicate(id.to_string()));
        }
        self.entries.push(MemberKey {
            name: name.to_string(),
            key: key.trim().to_string(),
            kind,
        });
        Ok(())
    }

    /// Remove every key of `name`; returns how many were removed.
    pub fn remove(&mut self, name: &str) -> usize {
        let before = self.entries.len();
        self.entries.retain(|e| e.name != name);
        before - self.entries.len()
    }

    /// Every line.
    pub fn entries(&self) -> &[MemberKey] {
        &self.entries
    }

    /// Member names, in first-seen order, each once.
    pub fn names(&self) -> Vec<&str> {
        let mut v: Vec<&str> = Vec::new();
        for e in &self.entries {
            if !v.contains(&e.name.as_str()) {
                v.push(&e.name);
            }
        }
        v
    }

    /// Is `name` a member?
    pub fn contains(&self, name: &str) -> bool {
        self.entries.iter().any(|e| e.name == name)
    }

    /// No keys at all?
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}