Skip to main content

gunnar_crypt/
userkey.rs

1//! **The user's encryption key**: one key pair per user, the same for every
2//! repository, opened by any one of the user's unlock methods.
3//!
4//! ```text
5//! unlock methods (each is one age file on the server, wrapping the SAME user key)
6//!   SSH key file ──────────────┐  age ssh-ed25519 / ssh-rsa stanza      (CLI)
7//!   security key (PRF secret) ─┤  HKDF-SHA256 → X25519 → age X25519      (browser + CLI)
8//!   age1pq1… key file ─────────┤  age mlkem768x25519 stanza              (CLI)
9//!   printed recovery code ─────┘  age scrypt stanza                      (CLI + browser)
10//!          │
11//!          ▼
12//! user key (AGE-SECRET-KEY-1…, or AGE-SECRET-KEY-PQ-1… for a quantum-safe user)
13//!          │ age X25519 (or mlkem768x25519) stanza: one per member in .gunnar-crypt/keys
14//!          ▼
15//! repository key ring (id key + epochs)
16//! ```
17//!
18//! * **A repository names only user public keys** (`alice age1…` in
19//!   `.gunnar-crypt/members`). Adding or removing an unlock method changes one
20//!   wrap of the user key and no repository at all.
21//! * **The server keeps the user's public key and the wraps**, which are
22//!   ciphertext to it. It never sees a method's secret: an SSH private key, the
23//!   security key's PRF output or the recovery code.
24//! * **A security key's recipient is fixed per credential**: the PRF output
25//!   (WebAuthn `prf`, FIDO2 `hmac-secret` under the CLI) is evaluated on the
26//!   fixed [`security_key::PRF_INPUT`], and HKDF-SHA256 with a fixed label and
27//!   the credential id as context makes the X25519 secret. The same key and
28//!   credential always give the same `age1…` recipient, in the browser and in
29//!   the CLI.
30//! * **No primitive is ours**: age's X25519, SSH, scrypt and (feature `wrap`)
31//!   hybrid stanzas do every wrap; RustCrypto `hkdf` and `sha2` do the
32//!   derivation.
33
34use 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
42/// The first line of a wrapped user key's payload: a type tag, so a wrap that
43/// opens to anything else is refused.
44pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";
45
46/// The largest wrap anyone parses (a hybrid stanza is ~1.6 KB).
47pub const MAX_WRAP: usize = 64 << 10;
48
49/// One user's encryption key (the private half).
50pub enum UserKey {
51    /// An age X25519 key: the default.
52    X25519(age::x25519::Identity),
53    /// age's hybrid ML-KEM-768 + X25519 key, for quantum-safe repositories.
54    #[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    /// A new user key for repositories of `level`.
66    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    /// The public half: what a members line and the server hold
77    /// (`age1…`, or `age1pq1…`).
78    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    /// The level of repositories this key can be a member of.
87    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    /// As an age identity: what opens a repository's key ring.
96    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    /// Wrap this key to one unlock method's age recipient: one armored age
142    /// file, to be stored on the server as the method's wrap.
143    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    /// Wrap this key to a security key's recipient (the `age1…` string
150    /// [`security_key::recipient`] gave at enrolment).
151    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    /// Wrap this key to a printed recovery code: age's scrypt stanza at the
160    /// FIXED work factor [`recovery_code::LOG_N`] (scrypt N = 2^18, r = 8,
161    /// p = 1: about 256 MiB and a second), on top of the code's 160 random bits.
162    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    /// Wrap this key to an SSH public key line (`ssh-ed25519 …`, `ssh-rsa …`).
172    #[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    /// Wrap this key to an `age1pq1…` key (a key file made by
182    /// `gunnar crypt keygen`).
183    #[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/// Why a user key could not be wrapped or opened. Every refusal has a name.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub enum UserKeyError {
206    /// The method's key is not the one this wrap is for: the wrong SSH key,
207    /// another security key, a mistyped recovery code.
208    WrongKey,
209    /// Not a readable age file.
210    Malformed(String),
211    /// It opened, but what is inside is not a user key.
212    NotAUserKey,
213    /// It opened to a user key, but not the one the account publishes: a
214    /// wrap substituted ALONE is refused. A server that replaces both the
215    /// public half and the wrap is caught only where the public half is
216    /// pinned: the CLI's pin (gunnar-cli `crypt_key::pins`, RA4-1).
217    NotThisAccountsKey,
218    /// A recipient string that is not one.
219    BadRecipient(String),
220    /// A recovery code that is not one (wrong length or characters).
221    BadRecoveryCode,
222    /// A quantum-safe user key needs the CLI (the console has no hybrid KEM).
223    QuantumSafeNeedsTheCli,
224    /// age itself failed.
225    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
259/// Open a stored wrap with one method's age identity, and check the key
260/// inside against the account's published public key (`expected_public`).
261pub 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
269/// Open a stored wrap with a printed recovery code.
270pub 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
303/// **A security key as an unlock method**: WebAuthn `prf` in the browser,
304/// FIDO2 `hmac-secret` in the CLI, one secret.
305pub mod security_key {
306    use sha2::{Digest as _, Sha256};
307    use zeroize::Zeroizing;
308
309    /// The PRF input every evaluation uses (WebAuthn `prf.eval.first`). Fixed,
310    /// so a credential always gives the same secret.
311    pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";
312
313    /// The HKDF-SHA256 salt and label of the derivation.
314    pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";
315
316    /// The `hmac-secret` salt a CTAP2 client sends so the authenticator
317    /// answers what a browser's WebAuthn `prf` answers for [`PRF_INPUT`]
318    /// (WebAuthn L3 §10.1.4: `SHA-256("WebAuthn PRF" ‖ 0x00 ‖ input)`).
319    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    /// The X25519 secret of a credential: HKDF-SHA256 (salt [`LABEL`], the
328    /// PRF output as the input key, `LABEL ‖ 0x00 ‖ credential id` as the
329    /// context), as an age identity.
330    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        // age's own key text is the one constructor its API offers.
341        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    /// The credential's recipient (`age1…`): what enrolment uploads.
350    pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
351        identity(prf_output, credential_id).to_public().to_string()
352    }
353}
354
355/// **A printed recovery code** as one more unlock method: 32 characters of
356/// Crockford's base32 (160 random bits), in groups of four.
357pub mod recovery_code {
358    /// The alphabet: no I, L, O or U to misread.
359    pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
360    /// Characters in a code (without the dashes).
361    pub const CHARS: usize = 32;
362    /// The scrypt work factor every recovery wrap uses, and the most an opener
363    /// accepts: N = 2^18 (age's own default class; about 256 MiB).
364    pub const LOG_N: u8 = 18;
365
366    /// A new code from 20 random bytes.
367    pub fn generate() -> zeroize::Zeroizing<String> {
368        from_bytes(&crate::random_bytes::<20>())
369    }
370
371    /// The code of 20 bytes (5 bits per character), grouped `XXXX-XXXX-…`.
372    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    /// The code as typed, normalised: upper case, no spaces or dashes, the
392    /// look-alikes read as Crockford reads them (O → 0, I and L → 1).
393    /// `None` unless it is [`CHARS`] characters of the alphabet.
394    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}