1use std::io::{Read, Write};
35
36use age::armor::{ArmoredReader, ArmoredWriter, Format};
37use age::secrecy::{ExposeSecret as _, SecretString};
38use zeroize::Zeroizing;
39
40use crate::members::Level;
41
42pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";
45
46pub const MAX_WRAP: usize = 64 << 10;
48
49pub enum UserKey {
51 X25519(age::x25519::Identity),
53 #[cfg(feature = "wrap")]
55 Pq(crate::pq::Identity),
56}
57
58impl std::fmt::Debug for UserKey {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 write!(f, "UserKey({})", self.public())
61 }
62}
63
64impl UserKey {
65 pub fn generate(level: Level) -> Result<UserKey, UserKeyError> {
67 match level {
68 Level::Classic => Ok(UserKey::X25519(age::x25519::Identity::generate())),
69 #[cfg(feature = "wrap")]
70 Level::QuantumSafe => Ok(UserKey::Pq(crate::pq::Identity::generate())),
71 #[cfg(not(feature = "wrap"))]
72 Level::QuantumSafe => Err(UserKeyError::QuantumSafeNeedsTheCli),
73 }
74 }
75
76 pub fn public(&self) -> String {
79 match self {
80 UserKey::X25519(id) => id.to_public().to_string(),
81 #[cfg(feature = "wrap")]
82 UserKey::Pq(id) => id.to_public().to_string(),
83 }
84 }
85
86 pub fn level(&self) -> Level {
88 match self {
89 UserKey::X25519(_) => Level::Classic,
90 #[cfg(feature = "wrap")]
91 UserKey::Pq(_) => Level::QuantumSafe,
92 }
93 }
94
95 pub fn identity(&self) -> &dyn age::Identity {
97 match self {
98 UserKey::X25519(id) => id,
99 #[cfg(feature = "wrap")]
100 UserKey::Pq(id) => id,
101 }
102 }
103
104 fn payload(&self) -> Zeroizing<String> {
105 let secret = match self {
106 UserKey::X25519(id) => id.to_string(),
107 #[cfg(feature = "wrap")]
108 UserKey::Pq(id) => id.to_secret_string(),
109 };
110 Zeroizing::new(format!("{PAYLOAD_TAG}\n{}\n", secret.expose_secret()))
111 }
112
113 fn from_payload(text: &str) -> Result<UserKey, UserKeyError> {
114 let mut lines = text.lines();
115 if lines.next() != Some(PAYLOAD_TAG) {
116 return Err(UserKeyError::NotAUserKey);
117 }
118 let secret = lines.next().map(str::trim).unwrap_or_default();
119 if lines.any(|l| !l.trim().is_empty()) {
120 return Err(UserKeyError::NotAUserKey);
121 }
122 if secret.starts_with("AGE-SECRET-KEY-PQ-1") {
123 #[cfg(feature = "wrap")]
124 {
125 return crate::pq::parse_identity_file(secret)
126 .map(UserKey::Pq)
127 .map_err(|_| UserKeyError::NotAUserKey);
128 }
129 #[cfg(not(feature = "wrap"))]
130 return Err(UserKeyError::QuantumSafeNeedsTheCli);
131 }
132 if secret.starts_with("AGE-SECRET-KEY-1") {
133 return secret
134 .parse::<age::x25519::Identity>()
135 .map(UserKey::X25519)
136 .map_err(|_| UserKeyError::NotAUserKey);
137 }
138 Err(UserKeyError::NotAUserKey)
139 }
140
141 pub fn wrap_to(&self, recipient: &dyn age::Recipient) -> Result<String, UserKeyError> {
144 let enc = age::Encryptor::with_recipients(std::iter::once(recipient))
145 .map_err(|e| UserKeyError::Age(e.to_string()))?;
146 seal(enc, &self.payload())
147 }
148
149 pub fn wrap_to_security_key(&self, recipient: &str) -> Result<String, UserKeyError> {
152 let r = recipient
153 .trim()
154 .parse::<age::x25519::Recipient>()
155 .map_err(|_| UserKeyError::BadRecipient(recipient.trim().to_string()))?;
156 self.wrap_to(&r)
157 }
158
159 pub fn wrap_to_recovery_code(&self, code: &str) -> Result<String, UserKeyError> {
163 let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
164 let mut r = age::scrypt::Recipient::new(SecretString::from(normal.to_string()));
165 r.set_work_factor(recovery_code::LOG_N);
166 let enc = age::Encryptor::with_recipients(std::iter::once(&r as &dyn age::Recipient))
167 .map_err(|e| UserKeyError::Age(e.to_string()))?;
168 seal(enc, &self.payload())
169 }
170
171 #[cfg(feature = "wrap")]
173 pub fn wrap_to_ssh(&self, public_key: &str) -> Result<String, UserKeyError> {
174 let r = public_key
175 .trim()
176 .parse::<age::ssh::Recipient>()
177 .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
178 self.wrap_to(&r)
179 }
180
181 #[cfg(feature = "wrap")]
184 pub fn wrap_to_pq(&self, public_key: &str) -> Result<String, UserKeyError> {
185 let r = public_key
186 .trim()
187 .parse::<crate::pq::Recipient>()
188 .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
189 self.wrap_to(&r)
190 }
191}
192
193fn seal(enc: age::Encryptor, payload: &str) -> Result<String, UserKeyError> {
194 let age_err = |e: std::io::Error| UserKeyError::Age(e.to_string());
195 let mut out = Vec::new();
196 let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor).map_err(age_err)?;
197 let mut w = enc.wrap_output(armor).map_err(age_err)?;
198 w.write_all(payload.as_bytes()).map_err(age_err)?;
199 w.finish().and_then(|a| a.finish()).map_err(age_err)?;
200 String::from_utf8(out).map_err(|e| UserKeyError::Age(e.to_string()))
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum UserKeyError {
206 WrongKey,
209 Malformed(String),
211 NotAUserKey,
213 NotThisAccountsKey,
218 BadRecipient(String),
220 BadRecoveryCode,
222 QuantumSafeNeedsTheCli,
224 Age(String),
226}
227
228impl std::fmt::Display for UserKeyError {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 match self {
231 UserKeyError::WrongKey => write!(
232 f,
233 "this key does not open your encryption key: it is not one of your unlock methods \
234 (the wrong SSH key, another security key, or a mistyped recovery code)"
235 ),
236 UserKeyError::Malformed(e) => write!(f, "the stored wrap of your encryption key is not readable: {e}"),
237 UserKeyError::NotAUserKey => write!(f, "the stored wrap opened, but it does not hold an encryption key; it is refused"),
238 UserKeyError::NotThisAccountsKey => write!(
239 f,
240 "the stored wrap holds an encryption key that is not your account's: it is refused \
241 (someone replaced it on the server)"
242 ),
243 UserKeyError::BadRecipient(r) => write!(f, "{r:?} is not a key the encryption key can be wrapped to"),
244 UserKeyError::BadRecoveryCode => write!(
245 f,
246 "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",
247 recovery_code::CHARS
248 ),
249 UserKeyError::QuantumSafeNeedsTheCli => write!(
250 f,
251 "a quantum-safe encryption key opens in the Gunnar CLI only (`gunnar crypt unlock`)"
252 ),
253 UserKeyError::Age(e) => write!(f, "age: {e}"),
254 }
255 }
256}
257impl std::error::Error for UserKeyError {}
258
259pub fn open(wrap: &[u8], identity: &dyn age::Identity, expected_public: &str) -> Result<UserKey, UserKeyError> {
262 let key = open_unchecked(wrap, std::iter::once(identity))?;
263 if key.public() != expected_public.trim() {
264 return Err(UserKeyError::NotThisAccountsKey);
265 }
266 Ok(key)
267}
268
269pub fn open_with_recovery_code(wrap: &[u8], code: &str, expected_public: &str) -> Result<UserKey, UserKeyError> {
271 let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
272 let mut id = age::scrypt::Identity::new(SecretString::from(normal.to_string()));
273 id.set_max_work_factor(recovery_code::LOG_N);
274 open(wrap, &id, expected_public)
275}
276
277fn open_unchecked<'a>(
278 wrap: &[u8],
279 identities: impl Iterator<Item = &'a dyn age::Identity>,
280) -> Result<UserKey, UserKeyError> {
281 if wrap.len() > MAX_WRAP {
282 return Err(UserKeyError::Malformed(format!("larger than {} KiB", MAX_WRAP >> 10)));
283 }
284 let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(wrap))
285 .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
286 let mut reader = match decryptor.decrypt(identities) {
287 Ok(r) => r,
288 Err(age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed) => {
289 return Err(UserKeyError::WrongKey)
290 }
291 Err(e) => return Err(UserKeyError::Malformed(e.to_string())),
292 };
293 let mut buf = Zeroizing::new(Vec::new());
294 reader
295 .by_ref()
296 .take(MAX_WRAP as u64)
297 .read_to_end(&mut buf)
298 .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
299 let text = std::str::from_utf8(&buf).map_err(|_| UserKeyError::NotAUserKey)?;
300 UserKey::from_payload(text)
301}
302
303pub mod security_key {
306 use sha2::{Digest as _, Sha256};
307 use zeroize::Zeroizing;
308
309 pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";
312
313 pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";
315
316 pub fn hmac_secret_salt() -> [u8; 32] {
320 let mut h = Sha256::new();
321 h.update(b"WebAuthn PRF");
322 h.update([0u8]);
323 h.update(PRF_INPUT);
324 h.finalize().into()
325 }
326
327 pub fn identity(prf_output: &[u8; 32], credential_id: &[u8]) -> age::x25519::Identity {
331 use bech32::ToBase32 as _;
332 let mut info = Vec::with_capacity(LABEL.len() + 1 + credential_id.len());
333 info.extend_from_slice(LABEL);
334 info.push(0);
335 info.extend_from_slice(credential_id);
336 let mut secret = Zeroizing::new([0u8; 32]);
337 hkdf::Hkdf::<Sha256>::new(Some(LABEL), prf_output)
338 .expand(&info, secret.as_mut())
339 .expect("32 bytes is a valid HKDF-SHA256 length");
340 let text = Zeroizing::new(
342 bech32::encode("age-secret-key-", secret.to_base32(), bech32::Variant::Bech32)
343 .expect("the HRP is valid")
344 .to_uppercase(),
345 );
346 text.parse().expect("a 32-byte age secret key parses")
347 }
348
349 pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
351 identity(prf_output, credential_id).to_public().to_string()
352 }
353}
354
355pub mod recovery_code {
358 pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
360 pub const CHARS: usize = 32;
362 pub const LOG_N: u8 = 18;
365
366 pub fn generate() -> zeroize::Zeroizing<String> {
368 from_bytes(&crate::random_bytes::<20>())
369 }
370
371 pub fn from_bytes(bytes: &[u8; 20]) -> zeroize::Zeroizing<String> {
373 let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS + CHARS / 4));
374 let (mut acc, mut bits) = (0u32, 0u32);
375 let mut n = 0;
376 for b in bytes {
377 acc = (acc << 8) | u32::from(*b);
378 bits += 8;
379 while bits >= 5 {
380 bits -= 5;
381 if n > 0 && n % 4 == 0 {
382 out.push('-');
383 }
384 out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
385 n += 1;
386 }
387 }
388 out
389 }
390
391 pub fn normalize(typed: &str) -> Option<zeroize::Zeroizing<String>> {
395 let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS));
396 for c in typed.chars() {
397 let c = match c.to_ascii_uppercase() {
398 ' ' | '-' => continue,
399 'O' => '0',
400 'I' | 'L' => '1',
401 c => c,
402 };
403 if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
404 return None;
405 }
406 out.push(c);
407 }
408 (out.len() == CHARS).then_some(out)
409 }
410}