gunnar-crypt 1.5.1

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, each person's one encryption key and its unlock methods, and the ring's wrap to members' encryption keys
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
//! `.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):
//!
//! ```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:
//!
//! * `age1…` — a user's X25519 encryption key (or an offline recovery key).
//! * `age1pq1…` — a quantum-safe user key, and the ONLY kind a quantum-safe
//!   repository takes.
//! * 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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Level {
    /// Wrapped to members' SSH keys (X25519 or RSA-OAEP, and `age1…` X25519):
    /// a ring harvested today opens once a quantum computer breaks those.
    #[default]
    Classic,
    /// Wrapped ONLY to age's hybrid ML-KEM-768 + X25519 recipients
    /// (`age1pq1…`): no stanza a quantum computer could open.
    QuantumSafe,
}

impl Level {
    /// 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…): `gunnar crypt key new --quantum-safe`",
                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. A quantum-safe repository starts \
                 quantum-safe (`gunnar crypt init --quantum-safe`)"
            ),
        }
    }
}
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, Default, PartialEq, Eq)]
pub struct Members {
    level: Level,
    entries: Vec<MemberKey>,
}

impl Members {
    /// An empty list.
    pub fn new() -> Members {
        Members::default()
    }

    /// Parse the file.
    pub fn parse(text: &str) -> Result<Members, MembersError> {
        let mut m = Members::new();
        // 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 first: [`Members::retain`]).
    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()
    }
}