Skip to main content

gunnar_crypt/
members.rs

1//! `.gunnar-crypt/members`: who the key ring is wrapped to, by each member's
2//! USER encryption key (its public half).
3//!
4//! ```text
5//! # gunnar-crypt members v1 — public keys only.
6//! alice age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq
7//! bob age1…
8//! recovery age1…
9//! ```
10//!
11//! A **quantum-safe** repository says so in one line, and then lists only
12//! `age1pq1…` user keys (age's hybrid ML-KEM-768 + X25519 recipient):
13//!
14//! ```text
15//! # gunnar-crypt members v1 — public keys only.
16//! level quantum-safe
17//! alice age1pq1…
18//! recovery age1pq1…
19//! ```
20//!
21//! One line per user: each user has ONE encryption key ([`crate::userkey`]),
22//! whatever they unlock it with. A repository never names an SSH key or a
23//! security key: those are the user's unlock METHODS, and adding or removing
24//! one changes the user's own wrap on the server, never a repository. The file
25//! is plaintext because every member must be able to re-wrap the ring after an
26//! add or a removal without asking anyone; a public key reveals nothing.
27//!
28//! Which keys can be members:
29//!
30//! * `age1…` — a user's X25519 encryption key (or an offline recovery key).
31//! * `age1pq1…` — a quantum-safe user key, and the ONLY kind a quantum-safe
32//!   repository takes.
33//! * NOT an SSH key (`ssh-ed25519`, `ssh-rsa`, `sk-…`): it is an unlock
34//!   method of its owner's encryption key, refused here by name.
35
36/// The first line `gunnar crypt` writes.
37pub const HEADER: &str = "# gunnar-crypt members v1 — public keys only; the key ring in .gunnar-crypt/keys is wrapped to every key below.";
38
39/// What kind of public key a member line carries.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum KeyKind {
42    /// `ssh-ed25519`
43    SshEd25519,
44    /// `ssh-rsa`
45    SshRsa,
46    /// `age1…` (X25519), for recovery keys.
47    Age,
48    /// `age1pq1…`: age's hybrid ML-KEM-768 + X25519 recipient
49    /// (`mlkem768x25519`, c2sp.org/age). Quantum-safe.
50    AgePq,
51}
52
53impl KeyKind {
54    /// The age stanza type a key ring wrapped to this key carries.
55    pub fn stanza_type(self) -> &'static str {
56        match self {
57            KeyKind::SshEd25519 => "ssh-ed25519",
58            KeyKind::SshRsa => "ssh-rsa",
59            KeyKind::Age => "X25519",
60            KeyKind::AgePq => PQ_STANZA,
61        }
62    }
63}
64
65/// The age stanza type of the hybrid post-quantum recipient.
66pub const PQ_STANZA: &str = "mlkem768x25519";
67
68/// How the key ring is wrapped: chosen per repository, in the members file.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum Level {
71    /// Wrapped to members' SSH keys (X25519 or RSA-OAEP, and `age1…` X25519):
72    /// a ring harvested today opens once a quantum computer breaks those.
73    #[default]
74    Classic,
75    /// Wrapped ONLY to age's hybrid ML-KEM-768 + X25519 recipients
76    /// (`age1pq1…`): no stanza a quantum computer could open.
77    QuantumSafe,
78}
79
80impl Level {
81    /// The word in the members file (`level quantum-safe`).
82    pub fn word(self) -> &'static str {
83        match self {
84            Level::Classic => "classic",
85            Level::QuantumSafe => "quantum-safe",
86        }
87    }
88
89    /// Does a key of `kind` belong in a ring of this level?
90    pub fn takes(self, kind: KeyKind) -> bool {
91        match self {
92            Level::Classic => kind == KeyKind::Age,
93            Level::QuantumSafe => kind == KeyKind::AgePq,
94        }
95    }
96
97    /// The age stanza types a key file of this level may carry (besides the
98    /// one random `…-grease` stanza age adds to every file).
99    pub fn stanza_types(self) -> &'static [&'static str] {
100        match self {
101            Level::Classic => &["X25519"],
102            Level::QuantumSafe => &[PQ_STANZA],
103        }
104    }
105}
106
107/// The HRP of an `age1pq1…` recipient.
108const PQ_HRP: &str = "age1pq";
109/// The size of an X-Wing (MLKEM768-X25519) encapsulation key.
110pub const PQ_PUBLIC_KEY_BYTES: usize = 1216;
111
112/// The length of an `age1pq1…` recipient: the HRP, the separator, 1216 bytes
113/// in bech32's 5-bit symbols, and the 6-symbol checksum.
114const PQ_KEY_CHARS: usize = PQ_HRP.len() + 1 + (PQ_PUBLIC_KEY_BYTES * 8).div_ceil(5) + 6;
115
116/// Does `key` have the SHAPE of an `age1pq1…` recipient (prefix, length,
117/// bech32's lower-case alphabet)? What a build without `pq-keys` checks.
118pub fn looks_like_pq_key(key: &str) -> bool {
119    const ALPHABET: &str = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
120    let key = key.trim();
121    key.len() == PQ_KEY_CHARS
122        && key
123            .strip_prefix("age1pq1")
124            .is_some_and(|data| data.chars().all(|c| ALPHABET.contains(c)))
125}
126
127fn pq_key_ok(key: &str) -> bool {
128    #[cfg(feature = "pq-keys")]
129    {
130        pq_public_key_bytes(key).is_some()
131    }
132    #[cfg(not(feature = "pq-keys"))]
133    {
134        looks_like_pq_key(key)
135    }
136}
137
138/// The 1216 key bytes of an `age1pq1…` recipient, `None` unless it is one
139/// (bech32 with the right prefix, checksum and length). The format check the
140/// SERVER can do; whether the bytes are a valid ML-KEM key is the wrap's.
141#[cfg(feature = "pq-keys")]
142pub fn pq_public_key_bytes(key: &str) -> Option<Vec<u8>> {
143    use bech32::FromBase32 as _;
144    let key = key.trim();
145    if !key.starts_with("age1pq1") || key.bytes().any(|b| b.is_ascii_uppercase()) {
146        return None;
147    }
148    let (hrp, data, variant) = bech32::decode(key).ok()?;
149    if hrp != PQ_HRP || variant != bech32::Variant::Bech32 {
150        return None;
151    }
152    let bytes = Vec::<u8>::from_base32(&data).ok()?;
153    (bytes.len() == PQ_PUBLIC_KEY_BYTES).then_some(bytes)
154}
155
156/// Why a public key cannot be a recipient.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum KeyRefusal {
159    /// A FIDO security-key SSH key (`sk-…`): it can only sign.
160    SecurityKeyOnly(String),
161    /// A key type age cannot encrypt to (e.g. ECDSA).
162    Unsupported(String),
163    /// Not a public key line at all.
164    Malformed,
165    /// Starts like an `age1pq1…` key but is not one.
166    MalformedPq,
167}
168
169impl std::fmt::Display for KeyRefusal {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        match self {
172            KeyRefusal::SecurityKeyOnly(t) => write!(
173                f,
174                "{t} is a security-key (YubiKey/FIDO) SSH key: it can only sign, so nothing \
175                 can be encrypted to it. Add an ordinary ed25519 key (ssh-keygen -t ed25519) \
176                 for encrypted repositories; keep the security key for login."
177            ),
178            KeyRefusal::Unsupported(t) => write!(
179                f,
180                "{t} keys cannot be encryption recipients; use ssh-ed25519 (or ssh-rsa)"
181            ),
182            KeyRefusal::Malformed => write!(f, "not an SSH or age public key"),
183            KeyRefusal::MalformedPq => write!(
184                f,
185                "not a well-formed age1pq1… key (age's hybrid ML-KEM-768 + X25519 recipient)"
186            ),
187        }
188    }
189}
190impl std::error::Error for KeyRefusal {}
191
192/// Classify one public key (`<type> <base64> [comment]`, or `age1…`).
193pub fn classify(key: &str) -> Result<KeyKind, KeyRefusal> {
194    let key = key.trim();
195    let ty = key.split_ascii_whitespace().next().unwrap_or("");
196    if ty.starts_with("age1pq1") {
197        return match (key.split_ascii_whitespace().count(), pq_key_ok(ty)) {
198            (1, true) => Ok(KeyKind::AgePq),
199            _ => Err(KeyRefusal::MalformedPq),
200        };
201    }
202    if ty.starts_with("age1") && ty.len() > 10 && key.split_ascii_whitespace().count() == 1 {
203        return Ok(KeyKind::Age);
204    }
205    let has_body = key.split_ascii_whitespace().nth(1).is_some();
206    match ty {
207        "ssh-ed25519" if has_body => Ok(KeyKind::SshEd25519),
208        "ssh-rsa" if has_body => Ok(KeyKind::SshRsa),
209        t if t.starts_with("sk-") && has_body => Err(KeyRefusal::SecurityKeyOnly(t.to_string())),
210        t if (t.starts_with("ecdsa-") || t.starts_with("ssh-dss")) && has_body => {
211            Err(KeyRefusal::Unsupported(t.to_string()))
212        }
213        _ => Err(KeyRefusal::Malformed),
214    }
215}
216
217/// One line: a member name and one of their public keys.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct MemberKey {
220    /// The member's gunnar user name (or `recovery`).
221    pub name: String,
222    /// The public key as written (`ssh-ed25519 AAAA… comment`, or `age1…`).
223    pub key: String,
224    /// Its kind.
225    pub kind: KeyKind,
226}
227
228impl MemberKey {
229    /// The key's type and body without the comment: what identifies the key.
230    pub fn key_id(&self) -> &str {
231        key_id(&self.key)
232    }
233}
234
235fn key_id(key: &str) -> &str {
236    let key = key.trim();
237    let mut it = key.match_indices(' ');
238    match (it.next(), it.next()) {
239        (Some(_), Some((second, _))) => &key[..second],
240        _ => key,
241    }
242}
243
244/// Why the members file was refused.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum MembersError {
247    /// A line that is not `<name> <public key>`.
248    BadLine(usize),
249    /// A member name outside `[A-Za-z0-9._@-]`.
250    BadName(String),
251    /// A key that cannot be a recipient.
252    Key(usize, KeyRefusal),
253    /// The same key listed twice.
254    Duplicate(String),
255    /// A `level` line naming no level this version knows.
256    BadLevel(String),
257    /// An SSH key listed as a member: it is an unlock method of its owner's
258    /// encryption key, and a repository is wrapped to that key instead.
259    SshKeyIsAMethod {
260        /// The member.
261        name: String,
262    },
263    /// A key that does not belong at the file's level: an SSH or `age1…`
264    /// key in a quantum-safe repository, or an `age1pq1…` key in a classic one.
265    WrongLevel {
266        /// The member.
267        name: String,
268        /// The key's type.
269        kind: KeyKind,
270        /// The file's level.
271        level: Level,
272    },
273}
274
275impl std::fmt::Display for MembersError {
276    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277        match self {
278            MembersError::BadLine(n) => write!(f, "members line {n} is not `<name> <public key>`"),
279            MembersError::BadName(n) => write!(
280                f,
281                "member name {n:?} may only use letters, digits and . _ @ -"
282            ),
283            MembersError::Key(n, r) => write!(f, "members line {n}: {r}"),
284            MembersError::Duplicate(k) => write!(f, "the key {k} is listed twice"),
285            MembersError::BadLevel(l) => write!(
286                f,
287                "`level {l}` is not a level (classic or quantum-safe); a newer gunnar may know it"
288            ),
289            MembersError::SshKeyIsAMethod { name } => write!(
290                f,
291                "{name}'s SSH key cannot be a member key: a repository is wrapped to {name}'s \
292                 encryption key (age1…), which the SSH key unlocks. Add {name} with \
293                 `gunnar crypt add-member {name} --from-server` (gunnar holds that key's public half)"
294            ),
295            MembersError::WrongLevel { name, kind, level: Level::QuantumSafe } => write!(
296                f,
297                "{name}'s {} key cannot be a recipient: this repository is quantum-safe, and an \
298                 X25519 key exchange is what a quantum computer breaks. {name} needs a \
299                 quantum-safe encryption key (age1pq1…): `gunnar crypt key new --quantum-safe`",
300                kind.stanza_type()
301            ),
302            MembersError::WrongLevel { name, level: Level::Classic, .. } => write!(
303                f,
304                "{name}'s age1pq1… encryption key is quantum-safe and this repository is classic; \
305                 age never mixes the two in one key ring. A quantum-safe repository starts \
306                 quantum-safe (`gunnar crypt init --quantum-safe`)"
307            ),
308        }
309    }
310}
311impl std::error::Error for MembersError {}
312
313fn valid_name(n: &str) -> bool {
314    !n.is_empty()
315        && n.len() <= 64
316        && n.bytes()
317            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'@' | b'-'))
318}
319
320/// The members file.
321#[derive(Debug, Clone, Default, PartialEq, Eq)]
322pub struct Members {
323    level: Level,
324    entries: Vec<MemberKey>,
325}
326
327impl Members {
328    /// An empty list.
329    pub fn new() -> Members {
330        Members::default()
331    }
332
333    /// Parse the file.
334    pub fn parse(text: &str) -> Result<Members, MembersError> {
335        let mut m = Members::new();
336        // The level comes first in meaning, wherever its line is: every key
337        // is checked against it.
338        for line in text.lines().map(str::trim) {
339            let mut words = line.split_ascii_whitespace();
340            if let (Some("level"), Some(word), None) = (words.next(), words.next(), words.next()) {
341                if !word.starts_with("ssh-") && !word.starts_with("age1") {
342                    m.level = match word {
343                        "classic" => Level::Classic,
344                        "quantum-safe" => Level::QuantumSafe,
345                        other => return Err(MembersError::BadLevel(other.to_string())),
346                    };
347                }
348            }
349        }
350        for (i, line) in text.lines().enumerate() {
351            let line = line.trim();
352            if line.is_empty() || line.starts_with('#') {
353                continue;
354            }
355            let Some((name, key)) = line.split_once(char::is_whitespace) else {
356                return Err(MembersError::BadLine(i + 1));
357            };
358            if name == "level" && matches!(key.trim(), "classic" | "quantum-safe") {
359                continue;
360            }
361            m.add(name, key.trim()).map_err(|e| match e {
362                MembersError::Key(_, r) => MembersError::Key(i + 1, r),
363                other => other,
364            })?;
365        }
366        Ok(m)
367    }
368
369    /// The repository's level: how its key ring is wrapped.
370    pub fn level(&self) -> Level {
371        self.level
372    }
373
374    /// Change the level. Refused while a listed key does not belong at the
375    /// new one (an upgrade replaces the keys first: [`Members::retain`]).
376    pub fn set_level(&mut self, level: Level) -> Result<(), MembersError> {
377        if let Some(e) = self.entries.iter().find(|e| !level.takes(e.kind)) {
378            return Err(MembersError::WrongLevel {
379                name: e.name.clone(),
380                kind: e.kind,
381                level,
382            });
383        }
384        self.level = level;
385        Ok(())
386    }
387
388    /// Keep only the keys `keep` says yes to.
389    pub fn retain(&mut self, keep: impl FnMut(&MemberKey) -> bool) {
390        self.entries.retain(keep);
391    }
392
393    /// The text form.
394    pub fn to_text(&self) -> String {
395        let mut s = String::from(HEADER);
396        s.push('\n');
397        if self.level != Level::Classic {
398            s.push_str("level ");
399            s.push_str(self.level.word());
400            s.push('\n');
401        }
402        for e in &self.entries {
403            s.push_str(&e.name);
404            s.push(' ');
405            s.push_str(e.key.trim());
406            s.push('\n');
407        }
408        s
409    }
410
411    /// Add one key for `name`. Refuses keys that cannot be recipients and a
412    /// key already listed.
413    pub fn add(&mut self, name: &str, key: &str) -> Result<(), MembersError> {
414        if !valid_name(name) {
415            return Err(MembersError::BadName(name.to_string()));
416        }
417        let kind = classify(key).map_err(|r| match r {
418            KeyRefusal::SecurityKeyOnly(_) => MembersError::SshKeyIsAMethod { name: name.to_string() },
419            r => MembersError::Key(0, r),
420        })?;
421        if matches!(kind, KeyKind::SshEd25519 | KeyKind::SshRsa) {
422            return Err(MembersError::SshKeyIsAMethod { name: name.to_string() });
423        }
424        if !self.level.takes(kind) {
425            return Err(MembersError::WrongLevel {
426                name: name.to_string(),
427                kind,
428                level: self.level,
429            });
430        }
431        let id = key_id(key);
432        if self.entries.iter().any(|e| e.key_id() == id) {
433            return Err(MembersError::Duplicate(id.to_string()));
434        }
435        self.entries.push(MemberKey {
436            name: name.to_string(),
437            key: key.trim().to_string(),
438            kind,
439        });
440        Ok(())
441    }
442
443    /// Remove every key of `name`; returns how many were removed.
444    pub fn remove(&mut self, name: &str) -> usize {
445        let before = self.entries.len();
446        self.entries.retain(|e| e.name != name);
447        before - self.entries.len()
448    }
449
450    /// Every line.
451    pub fn entries(&self) -> &[MemberKey] {
452        &self.entries
453    }
454
455    /// Member names, in first-seen order, each once.
456    pub fn names(&self) -> Vec<&str> {
457        let mut v: Vec<&str> = Vec::new();
458        for e in &self.entries {
459            if !v.contains(&e.name.as_str()) {
460                v.push(&e.name);
461            }
462        }
463        v
464    }
465
466    /// Is `name` a member?
467    pub fn contains(&self, name: &str) -> bool {
468        self.entries.iter().any(|e| e.name == name)
469    }
470
471    /// No keys at all?
472    pub fn is_empty(&self) -> bool {
473        self.entries.is_empty()
474    }
475}