1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum KeyKind {
46 SshEd25519,
48 SshRsa,
50 Age,
52 AgePq,
55}
56
57impl KeyKind {
58 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
69pub const PQ_STANZA: &str = "mlkem768x25519";
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum Level {
75 #[default]
78 Classic,
79 QuantumSafe,
82}
83
84impl Level {
85 pub fn word(self) -> &'static str {
87 match self {
88 Level::Classic => "classic",
89 Level::QuantumSafe => "quantum-safe",
90 }
91 }
92
93 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 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
111const PQ_HRP: &str = "age1pq";
113pub const PQ_PUBLIC_KEY_BYTES: usize = 1216;
115
116const PQ_KEY_CHARS: usize = PQ_HRP.len() + 1 + (PQ_PUBLIC_KEY_BYTES * 8).div_ceil(5) + 6;
119
120pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum KeyRefusal {
163 SecurityKeyOnly(String),
165 Unsupported(String),
167 Malformed,
169 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
196pub 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#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct MemberKey {
224 pub name: String,
226 pub key: String,
228 pub kind: KeyKind,
230}
231
232impl MemberKey {
233 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#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum MembersError {
251 BadLine(usize),
253 BadName(String),
255 Key(usize, KeyRefusal),
257 Duplicate(String),
259 BadLevel(String),
261 WrongLevel {
264 name: String,
266 kind: KeyKind,
268 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
315pub struct Members {
316 level: Level,
317 entries: Vec<MemberKey>,
318}
319
320impl Members {
321 pub fn new() -> Members {
323 Members::default()
324 }
325
326 pub fn parse(text: &str) -> Result<Members, MembersError> {
328 let mut m = Members::new();
329 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 pub fn level(&self) -> Level {
364 self.level
365 }
366
367 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 pub fn retain(&mut self, keep: impl FnMut(&MemberKey) -> bool) {
383 self.entries.retain(keep);
384 }
385
386 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 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 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 pub fn entries(&self) -> &[MemberKey] {
439 &self.entries
440 }
441
442 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 pub fn contains(&self, name: &str) -> bool {
455 self.entries.iter().any(|e| e.name == name)
456 }
457
458 pub fn is_empty(&self) -> bool {
460 self.entries.is_empty()
461 }
462}