1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum KeyKind {
42 SshEd25519,
44 SshRsa,
46 Age,
48 AgePq,
51}
52
53impl KeyKind {
54 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
65pub const PQ_STANZA: &str = "mlkem768x25519";
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum Level {
71 #[default]
74 Classic,
75 QuantumSafe,
78}
79
80impl Level {
81 pub fn word(self) -> &'static str {
83 match self {
84 Level::Classic => "classic",
85 Level::QuantumSafe => "quantum-safe",
86 }
87 }
88
89 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 pub fn stanza_types(self) -> &'static [&'static str] {
100 match self {
101 Level::Classic => &["X25519"],
102 Level::QuantumSafe => &[PQ_STANZA],
103 }
104 }
105}
106
107const PQ_HRP: &str = "age1pq";
109pub const PQ_PUBLIC_KEY_BYTES: usize = 1216;
111
112const PQ_KEY_CHARS: usize = PQ_HRP.len() + 1 + (PQ_PUBLIC_KEY_BYTES * 8).div_ceil(5) + 6;
115
116pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum KeyRefusal {
159 SecurityKeyOnly(String),
161 Unsupported(String),
163 Malformed,
165 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
192pub 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#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct MemberKey {
220 pub name: String,
222 pub key: String,
224 pub kind: KeyKind,
226}
227
228impl MemberKey {
229 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#[derive(Debug, Clone, PartialEq, Eq)]
246pub enum MembersError {
247 BadLine(usize),
249 BadName(String),
251 Key(usize, KeyRefusal),
253 Duplicate(String),
255 BadLevel(String),
257 SshKeyIsAMethod {
260 name: String,
262 },
263 WrongLevel {
266 name: String,
268 kind: KeyKind,
270 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
322pub struct Members {
323 level: Level,
324 entries: Vec<MemberKey>,
325}
326
327impl Members {
328 pub fn new() -> Members {
330 Members::default()
331 }
332
333 pub fn parse(text: &str) -> Result<Members, MembersError> {
335 let mut m = Members::new();
336 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 pub fn level(&self) -> Level {
371 self.level
372 }
373
374 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 pub fn retain(&mut self, keep: impl FnMut(&MemberKey) -> bool) {
390 self.entries.retain(keep);
391 }
392
393 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 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 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 pub fn entries(&self) -> &[MemberKey] {
452 &self.entries
453 }
454
455 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 pub fn contains(&self, name: &str) -> bool {
468 self.entries.iter().any(|e| e.name == name)
469 }
470
471 pub fn is_empty(&self) -> bool {
473 self.entries.is_empty()
474 }
475}