Skip to main content

gunnar_crypt/
members.rs

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