1use std::io::{Read, Write};
44
45use age::armor::{ArmoredReader, ArmoredWriter, Format};
46use age::secrecy::{ExposeSecret as _, SecretString};
47use zeroize::Zeroizing;
48
49use crate::members::Level;
50
51pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";
54
55pub const MAX_WRAP: usize = 64 << 10;
57
58pub fn wrap_is_quantum_safe(method_kind: &str, wrap: &str) -> bool {
66 match method_kind {
67 "age-pq" | "recovery-code" => true,
68 "security-key" => crate::skwrap::is_v2(wrap),
69 _ => false,
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum SecurityKeyWrap {
76 V1,
79 V2,
81}
82
83pub fn open_with_security_key(
88 wrap: &[u8],
89 prf_output: &[u8; 32],
90 credential_id: &[u8],
91 expected_public: &str,
92) -> Result<(UserKey, SecurityKeyWrap), UserKeyError> {
93 use chacha20poly1305::aead::{Aead as _, Payload};
94 use chacha20poly1305::KeyInit as _;
95 match std::str::from_utf8(wrap)
96 .ok()
97 .filter(|t| crate::skwrap::is_v2(t))
98 {
99 Some(text) => {
100 let parts = crate::skwrap::parse(text).ok_or_else(|| {
101 UserKeyError::Malformed("not a well-formed security-key wrap v2".into())
102 })?;
103 let key = security_key::wrap_key(prf_output, &parts.salt);
104 let cipher =
105 chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
106 let aad = crate::skwrap::aad(credential_id);
107 let plain = Zeroizing::new(
108 cipher
109 .decrypt(
110 &chacha20poly1305::XNonce::from(parts.nonce),
111 Payload {
112 msg: &parts.sealed,
113 aad: &aad,
114 },
115 )
116 .map_err(|_| UserKeyError::WrongKey)?,
117 );
118 let text = std::str::from_utf8(&plain).map_err(|_| UserKeyError::NotAUserKey)?;
119 let key = UserKey::from_payload(text)?;
120 if key.public() != expected_public.trim() {
121 return Err(UserKeyError::NotThisAccountsKey);
122 }
123 Ok((key, SecurityKeyWrap::V2))
124 }
125 None => open(
126 wrap,
127 &security_key::identity(prf_output, credential_id),
128 expected_public,
129 )
130 .map(|k| (k, SecurityKeyWrap::V1)),
131 }
132}
133
134pub const SSH_WRAP_IS_CLASSICAL: &str =
138 "Your encryption key is quantum-safe, but the SSH key's wrap of \
139 it is not quantum-safe: age wraps it to an ed25519 key with X25519 and to an RSA key with \
140 RSA-OAEP, which a quantum computer breaks, and Gunnar keeps that wrap. Your repositories stay \
141 quantum-safe; this one way to open your key does not. To keep the key quantum-safe at the \
142 server too, add a quantum-safe way to open it (`gunnar crypt keygen`, then `gunnar crypt key \
143 add-pq`; a security key, `gunnar crypt key add-security-key`; or a printed recovery code, \
144 `gunnar crypt key add-recovery-code`), then remove the SSH \
145 one (`gunnar crypt key remove <id>`).";
146
147#[allow(clippy::large_enum_variant)]
151pub enum UserKey {
152 X25519(age::x25519::Identity),
154 #[cfg(feature = "pq")]
157 Pq(crate::pq::Identity),
158}
159
160impl std::fmt::Debug for UserKey {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 write!(f, "UserKey({})", self.public())
163 }
164}
165
166impl UserKey {
167 pub fn generate(level: Level) -> Result<UserKey, UserKeyError> {
170 match level {
171 Level::Classic => Ok(UserKey::X25519(age::x25519::Identity::generate())),
172 #[cfg(feature = "pq")]
173 Level::QuantumSafe => Ok(UserKey::Pq(crate::pq::Identity::generate())),
174 #[cfg(not(feature = "pq"))]
175 Level::QuantumSafe => Err(UserKeyError::QuantumSafeNeedsTheCli),
176 }
177 }
178
179 pub fn public(&self) -> String {
182 match self {
183 UserKey::X25519(id) => id.to_public().to_string(),
184 #[cfg(feature = "pq")]
185 UserKey::Pq(id) => id.to_public().to_string(),
186 }
187 }
188
189 pub fn level(&self) -> Level {
191 match self {
192 UserKey::X25519(_) => Level::Classic,
193 #[cfg(feature = "pq")]
194 UserKey::Pq(_) => Level::QuantumSafe,
195 }
196 }
197
198 pub fn identity(&self) -> &dyn age::Identity {
200 match self {
201 UserKey::X25519(id) => id,
202 #[cfg(feature = "pq")]
203 UserKey::Pq(id) => id,
204 }
205 }
206
207 fn payload(&self) -> Zeroizing<String> {
208 let secret = match self {
209 UserKey::X25519(id) => id.to_string(),
210 #[cfg(feature = "pq")]
211 UserKey::Pq(id) => id.to_secret_string(),
212 };
213 Zeroizing::new(format!("{PAYLOAD_TAG}\n{}\n", secret.expose_secret()))
214 }
215
216 fn from_payload(text: &str) -> Result<UserKey, UserKeyError> {
217 let mut lines = text.lines();
218 if lines.next() != Some(PAYLOAD_TAG) {
219 return Err(UserKeyError::NotAUserKey);
220 }
221 let secret = lines.next().map(str::trim).unwrap_or_default();
222 if lines.any(|l| !l.trim().is_empty()) {
223 return Err(UserKeyError::NotAUserKey);
224 }
225 if secret.starts_with("AGE-SECRET-KEY-PQ-1") {
226 #[cfg(feature = "pq")]
227 {
228 return crate::pq::parse_identity_file(secret)
229 .map(UserKey::Pq)
230 .map_err(|_| UserKeyError::NotAUserKey);
231 }
232 #[cfg(not(feature = "pq"))]
233 return Err(UserKeyError::QuantumSafeNeedsTheCli);
234 }
235 if secret.starts_with("AGE-SECRET-KEY-1") {
236 return secret
237 .parse::<age::x25519::Identity>()
238 .map(UserKey::X25519)
239 .map_err(|_| UserKeyError::NotAUserKey);
240 }
241 Err(UserKeyError::NotAUserKey)
242 }
243
244 pub fn wrap_to(&self, recipient: &dyn age::Recipient) -> Result<String, UserKeyError> {
247 let enc = age::Encryptor::with_recipients(std::iter::once(recipient))
248 .map_err(|e| UserKeyError::Age(e.to_string()))?;
249 seal(enc, &self.payload())
250 }
251
252 pub fn wrap_to_security_key(&self, recipient: &str) -> Result<String, UserKeyError> {
255 let r = recipient
256 .trim()
257 .parse::<age::x25519::Recipient>()
258 .map_err(|_| UserKeyError::BadRecipient(recipient.trim().to_string()))?;
259 self.wrap_to(&r)
260 }
261
262 pub fn wrap_to_security_key_v2(
268 &self,
269 prf_output: &[u8; 32],
270 credential_id: &[u8],
271 ) -> Result<String, UserKeyError> {
272 use chacha20poly1305::aead::{Aead as _, Payload};
273 use chacha20poly1305::KeyInit as _;
274 let salt = crate::random_bytes::<{ crate::skwrap::SALT_BYTES }>();
275 let nonce = crate::random_bytes::<{ crate::skwrap::NONCE_BYTES }>();
276 let key = security_key::wrap_key(prf_output, &salt);
277 let cipher = chacha20poly1305::XChaCha20Poly1305::new(&chacha20poly1305::Key::from(*key));
278 let aad = crate::skwrap::aad(credential_id);
279 let payload = self.payload();
280 let sealed = cipher
281 .encrypt(
282 &chacha20poly1305::XNonce::from(nonce),
283 Payload {
284 msg: payload.as_bytes(),
285 aad: &aad,
286 },
287 )
288 .map_err(|_| UserKeyError::Age("XChaCha20-Poly1305 refused the payload".into()))?;
289 Ok(crate::skwrap::format(&crate::skwrap::Parts {
290 salt,
291 nonce,
292 sealed,
293 }))
294 }
295
296 pub fn wrap_to_recovery_code(&self, code: &str) -> Result<String, UserKeyError> {
300 let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
301 let mut r = age::scrypt::Recipient::new(SecretString::from(normal.to_string()));
302 r.set_work_factor(recovery_code::LOG_N);
303 let enc = age::Encryptor::with_recipients(std::iter::once(&r as &dyn age::Recipient))
304 .map_err(|e| UserKeyError::Age(e.to_string()))?;
305 seal(enc, &self.payload())
306 }
307
308 #[cfg(feature = "wrap")]
312 pub fn wrap_to_ssh(&self, public_key: &str) -> Result<String, UserKeyError> {
313 let r = public_key
314 .trim()
315 .parse::<age::ssh::Recipient>()
316 .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
317 self.wrap_to(&r)
318 }
319
320 #[cfg(feature = "pq")]
323 pub fn wrap_to_pq(&self, public_key: &str) -> Result<String, UserKeyError> {
324 let r = public_key
325 .trim()
326 .parse::<crate::pq::Recipient>()
327 .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
328 self.wrap_to(&r)
329 }
330}
331
332fn seal(enc: age::Encryptor, payload: &str) -> Result<String, UserKeyError> {
333 let age_err = |e: std::io::Error| UserKeyError::Age(e.to_string());
334 let mut out = Vec::new();
335 let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor).map_err(age_err)?;
336 let mut w = enc.wrap_output(armor).map_err(age_err)?;
337 w.write_all(payload.as_bytes()).map_err(age_err)?;
338 w.finish().and_then(|a| a.finish()).map_err(age_err)?;
339 String::from_utf8(out).map_err(|e| UserKeyError::Age(e.to_string()))
340}
341
342#[derive(Debug, Clone, PartialEq, Eq)]
344pub enum UserKeyError {
345 WrongKey,
348 Malformed(String),
350 NotAUserKey,
352 NotThisAccountsKey,
357 BadRecipient(String),
359 BadRecoveryCode,
361 QuantumSafeNeedsTheCli,
363 Age(String),
365}
366
367impl std::fmt::Display for UserKeyError {
368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 match self {
370 UserKeyError::WrongKey => write!(
371 f,
372 "this key does not open your encryption key: it is not one of your unlock methods \
373 (the wrong SSH key, another security key, or a mistyped recovery code)"
374 ),
375 UserKeyError::Malformed(e) => write!(f, "the stored wrap of your encryption key is not readable: {e}"),
376 UserKeyError::NotAUserKey => write!(f, "the stored wrap opened, but it does not hold an encryption key; it is refused"),
377 UserKeyError::NotThisAccountsKey => write!(
378 f,
379 "the stored wrap holds an encryption key that is not your account's: it is refused \
380 (someone replaced it on the server)"
381 ),
382 UserKeyError::BadRecipient(r) => write!(f, "{r:?} is not a key the encryption key can be wrapped to"),
383 UserKeyError::BadRecoveryCode => write!(
384 f,
385 "that is not a recovery code: it is {} characters from 0-9 and A-Z (no I, L, O or U), in groups of four",
386 recovery_code::CHARS
387 ),
388 UserKeyError::QuantumSafeNeedsTheCli => write!(
389 f,
390 "this build cannot open a quantum-safe encryption key (gunnar-crypt without its `pq` \
391 feature); the Gunnar CLI and the console can"
392 ),
393 UserKeyError::Age(e) => write!(f, "age: {e}"),
394 }
395 }
396}
397impl std::error::Error for UserKeyError {}
398
399pub fn open(
402 wrap: &[u8],
403 identity: &dyn age::Identity,
404 expected_public: &str,
405) -> Result<UserKey, UserKeyError> {
406 let key = open_unchecked(wrap, std::iter::once(identity))?;
407 if key.public() != expected_public.trim() {
408 return Err(UserKeyError::NotThisAccountsKey);
409 }
410 Ok(key)
411}
412
413pub fn open_with_recovery_code(
415 wrap: &[u8],
416 code: &str,
417 expected_public: &str,
418) -> Result<UserKey, UserKeyError> {
419 let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
420 let mut id = age::scrypt::Identity::new(SecretString::from(normal.to_string()));
421 id.set_max_work_factor(recovery_code::LOG_N);
422 open(wrap, &id, expected_public)
423}
424
425fn open_unchecked<'a>(
426 wrap: &[u8],
427 identities: impl Iterator<Item = &'a dyn age::Identity>,
428) -> Result<UserKey, UserKeyError> {
429 if wrap.len() > MAX_WRAP {
430 return Err(UserKeyError::Malformed(format!(
431 "larger than {} KiB",
432 MAX_WRAP >> 10
433 )));
434 }
435 let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(wrap))
436 .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
437 let mut reader = match decryptor.decrypt(identities) {
438 Ok(r) => r,
439 Err(age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed) => {
440 return Err(UserKeyError::WrongKey)
441 }
442 Err(e) => return Err(UserKeyError::Malformed(e.to_string())),
443 };
444 let mut buf = Zeroizing::new(Vec::new());
445 reader
446 .by_ref()
447 .take(MAX_WRAP as u64)
448 .read_to_end(&mut buf)
449 .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
450 let text = std::str::from_utf8(&buf).map_err(|_| UserKeyError::NotAUserKey)?;
451 UserKey::from_payload(text)
452}
453
454pub mod security_key {
457 use sha2::{Digest as _, Sha256};
458 use zeroize::Zeroizing;
459
460 pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";
463
464 pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";
466
467 pub fn hmac_secret_salt() -> [u8; 32] {
471 let mut h = Sha256::new();
472 h.update(b"WebAuthn PRF");
473 h.update([0u8]);
474 h.update(PRF_INPUT);
475 h.finalize().into()
476 }
477
478 pub fn identity(prf_output: &[u8; 32], credential_id: &[u8]) -> age::x25519::Identity {
482 use bech32::ToBase32 as _;
483 let mut info = Vec::with_capacity(LABEL.len() + 1 + credential_id.len());
484 info.extend_from_slice(LABEL);
485 info.push(0);
486 info.extend_from_slice(credential_id);
487 let mut secret = Zeroizing::new([0u8; 32]);
488 hkdf::Hkdf::<Sha256>::new(Some(LABEL), prf_output)
489 .expand(&info, secret.as_mut())
490 .expect("32 bytes is a valid HKDF-SHA256 length");
491 let text = Zeroizing::new(
493 bech32::encode(
494 "age-secret-key-",
495 secret.to_base32(),
496 bech32::Variant::Bech32,
497 )
498 .expect("the HRP is valid")
499 .to_uppercase(),
500 );
501 text.parse().expect("a 32-byte age secret key parses")
502 }
503
504 pub fn wrap_key(prf_output: &[u8; 32], salt: &[u8]) -> Zeroizing<[u8; 32]> {
507 let mut key = Zeroizing::new([0u8; 32]);
508 hkdf::Hkdf::<Sha256>::new(Some(salt), prf_output)
509 .expand(crate::skwrap::INFO, key.as_mut())
510 .expect("32 bytes is a valid HKDF-SHA256 length");
511 key
512 }
513
514 pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
516 identity(prf_output, credential_id).to_public().to_string()
517 }
518}
519
520pub mod recovery_code {
523 pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
525 pub const CHARS: usize = 32;
527 pub const LOG_N: u8 = 18;
530
531 pub fn generate() -> zeroize::Zeroizing<String> {
533 from_bytes(&crate::random_bytes::<20>())
534 }
535
536 pub fn from_bytes(bytes: &[u8; 20]) -> zeroize::Zeroizing<String> {
538 let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS + CHARS / 4));
539 let (mut acc, mut bits) = (0u32, 0u32);
540 let mut n = 0;
541 for b in bytes {
542 acc = (acc << 8) | u32::from(*b);
543 bits += 8;
544 while bits >= 5 {
545 bits -= 5;
546 if n > 0 && n % 4 == 0 {
547 out.push('-');
548 }
549 out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
550 n += 1;
551 }
552 }
553 out
554 }
555
556 pub fn normalize(typed: &str) -> Option<zeroize::Zeroizing<String>> {
560 let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS));
561 for c in typed.chars() {
562 let c = match c.to_ascii_uppercase() {
563 ' ' | '-' => continue,
564 'O' => '0',
565 'I' | 'L' => '1',
566 c => c,
567 };
568 if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
569 return None;
570 }
571 out.push(c);
572 }
573 (out.len() == CHARS).then_some(out)
574 }
575}