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