1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum KeyKind {
45 SshEd25519,
47 SshRsa,
49 Age,
51 AgePq,
54}
55
56impl KeyKind {
57 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
68pub const PQ_STANZA: &str = "mlkem768x25519";
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum Level {
79 Classic,
83 #[default]
86 QuantumSafe,
87}
88
89impl std::str::FromStr for Level {
90 type Err = String;
91
92 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 pub const NEW: Level = Level::QuantumSafe;
115
116 pub const UNSTATED: Level = Level::Classic;
119
120 pub fn word(self) -> &'static str {
122 match self {
123 Level::Classic => "classic",
124 Level::QuantumSafe => "quantum-safe",
125 }
126 }
127
128 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 pub fn stanza_types(self) -> &'static [&'static str] {
139 match self {
140 Level::Classic => &["X25519"],
141 Level::QuantumSafe => &[PQ_STANZA],
142 }
143 }
144}
145
146const PQ_HRP: &str = "age1pq";
148pub const PQ_PUBLIC_KEY_BYTES: usize = 1216;
150
151const PQ_KEY_CHARS: usize = PQ_HRP.len() + 1 + (PQ_PUBLIC_KEY_BYTES * 8).div_ceil(5) + 6;
154
155pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum KeyRefusal {
198 SecurityKeyOnly(String),
200 Unsupported(String),
202 Malformed,
204 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
231pub 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#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct MemberKey {
259 pub name: String,
261 pub key: String,
263 pub kind: KeyKind,
265}
266
267impl MemberKey {
268 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#[derive(Debug, Clone, PartialEq, Eq)]
285pub enum MembersError {
286 BadLine(usize),
288 BadName(String),
290 Key(usize, KeyRefusal),
292 Duplicate(String),
294 BadLevel(String),
296 SshKeyIsAMethod {
299 name: String,
301 },
302 WrongLevel {
305 name: String,
307 kind: KeyKind,
309 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct UpgradeRefusal {
377 pub missing: Vec<String>,
379 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 pub fn new() -> Members {
404 Members::at(Level::NEW)
405 }
406
407 pub fn at(level: Level) -> Members {
409 Members {
410 level,
411 entries: Vec::new(),
412 }
413 }
414
415 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 pub fn parse(text: &str) -> Result<Members, MembersError> {
455 let mut m = Members::at(Level::UNSTATED);
456 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 pub fn level(&self) -> Level {
491 self.level
492 }
493
494 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 pub fn retain(&mut self, keep: impl FnMut(&MemberKey) -> bool) {
510 self.entries.retain(keep);
511 }
512
513 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 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 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 pub fn entries(&self) -> &[MemberKey] {
576 &self.entries
577 }
578
579 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 pub fn contains(&self, name: &str) -> bool {
592 self.entries.iter().any(|e| e.name == name)
593 }
594
595 pub fn is_empty(&self) -> bool {
597 self.entries.is_empty()
598 }
599}