gunnar-crypt 1.5.1

Encrypted repositories: the content envelope, the per-repo key ring, its members, the server's plaintext check, each person's one encryption key and its unlock methods, and the ring's wrap to members' encryption keys
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! **The user's encryption key**: one key pair per user, the same for every
//! repository, opened by any one of the user's unlock methods.
//!
//! ```text
//! unlock methods (each is one age file on the server, wrapping the SAME user key)
//!   SSH key file ──────────────┐  age ssh-ed25519 / ssh-rsa stanza      (CLI)
//!   security key (PRF secret) ─┤  HKDF-SHA256 → X25519 → age X25519      (browser + CLI)
//!   age1pq1… key file ─────────┤  age mlkem768x25519 stanza              (CLI)
//!   printed recovery code ─────┘  age scrypt stanza                      (CLI + browser)
//!          │
//!          ▼
//! user key (AGE-SECRET-KEY-1…, or AGE-SECRET-KEY-PQ-1… for a quantum-safe user)
//!          │ age X25519 (or mlkem768x25519) stanza: one per member in .gunnar-crypt/keys
//!          ▼
//! repository key ring (id key + epochs)
//! ```
//!
//! * **A repository names only user public keys** (`alice age1…` in
//!   `.gunnar-crypt/members`). Adding or removing an unlock method changes one
//!   wrap of the user key and no repository at all.
//! * **The server keeps the user's public key and the wraps**, which are
//!   ciphertext to it. It never sees a method's secret: an SSH private key, the
//!   security key's PRF output or the recovery code.
//! * **A security key's recipient is fixed per credential**: the PRF output
//!   (WebAuthn `prf`, FIDO2 `hmac-secret` under the CLI) is evaluated on the
//!   fixed [`security_key::PRF_INPUT`], and HKDF-SHA256 with a fixed label and
//!   the credential id as context makes the X25519 secret. The same key and
//!   credential always give the same `age1…` recipient, in the browser and in
//!   the CLI.
//! * **No primitive is ours**: age's X25519, SSH, scrypt and (feature `wrap`)
//!   hybrid stanzas do every wrap; RustCrypto `hkdf` and `sha2` do the
//!   derivation.

use std::io::{Read, Write};

use age::armor::{ArmoredReader, ArmoredWriter, Format};
use age::secrecy::{ExposeSecret as _, SecretString};
use zeroize::Zeroizing;

use crate::members::Level;

/// The first line of a wrapped user key's payload: a type tag, so a wrap that
/// opens to anything else is refused.
pub const PAYLOAD_TAG: &str = "gunnar-crypt user-key v1";

/// The largest wrap anyone parses (a hybrid stanza is ~1.6 KB).
pub const MAX_WRAP: usize = 64 << 10;

/// One user's encryption key (the private half).
pub enum UserKey {
    /// An age X25519 key: the default.
    X25519(age::x25519::Identity),
    /// age's hybrid ML-KEM-768 + X25519 key, for quantum-safe repositories.
    #[cfg(feature = "wrap")]
    Pq(crate::pq::Identity),
}

impl std::fmt::Debug for UserKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "UserKey({})", self.public())
    }
}

impl UserKey {
    /// A new user key for repositories of `level`.
    pub fn generate(level: Level) -> Result<UserKey, UserKeyError> {
        match level {
            Level::Classic => Ok(UserKey::X25519(age::x25519::Identity::generate())),
            #[cfg(feature = "wrap")]
            Level::QuantumSafe => Ok(UserKey::Pq(crate::pq::Identity::generate())),
            #[cfg(not(feature = "wrap"))]
            Level::QuantumSafe => Err(UserKeyError::QuantumSafeNeedsTheCli),
        }
    }

    /// The public half: what a members line and the server hold
    /// (`age1…`, or `age1pq1…`).
    pub fn public(&self) -> String {
        match self {
            UserKey::X25519(id) => id.to_public().to_string(),
            #[cfg(feature = "wrap")]
            UserKey::Pq(id) => id.to_public().to_string(),
        }
    }

    /// The level of repositories this key can be a member of.
    pub fn level(&self) -> Level {
        match self {
            UserKey::X25519(_) => Level::Classic,
            #[cfg(feature = "wrap")]
            UserKey::Pq(_) => Level::QuantumSafe,
        }
    }

    /// As an age identity: what opens a repository's key ring.
    pub fn identity(&self) -> &dyn age::Identity {
        match self {
            UserKey::X25519(id) => id,
            #[cfg(feature = "wrap")]
            UserKey::Pq(id) => id,
        }
    }

    fn payload(&self) -> Zeroizing<String> {
        let secret = match self {
            UserKey::X25519(id) => id.to_string(),
            #[cfg(feature = "wrap")]
            UserKey::Pq(id) => id.to_secret_string(),
        };
        Zeroizing::new(format!("{PAYLOAD_TAG}\n{}\n", secret.expose_secret()))
    }

    fn from_payload(text: &str) -> Result<UserKey, UserKeyError> {
        let mut lines = text.lines();
        if lines.next() != Some(PAYLOAD_TAG) {
            return Err(UserKeyError::NotAUserKey);
        }
        let secret = lines.next().map(str::trim).unwrap_or_default();
        if lines.any(|l| !l.trim().is_empty()) {
            return Err(UserKeyError::NotAUserKey);
        }
        if secret.starts_with("AGE-SECRET-KEY-PQ-1") {
            #[cfg(feature = "wrap")]
            {
                return crate::pq::parse_identity_file(secret)
                    .map(UserKey::Pq)
                    .map_err(|_| UserKeyError::NotAUserKey);
            }
            #[cfg(not(feature = "wrap"))]
            return Err(UserKeyError::QuantumSafeNeedsTheCli);
        }
        if secret.starts_with("AGE-SECRET-KEY-1") {
            return secret
                .parse::<age::x25519::Identity>()
                .map(UserKey::X25519)
                .map_err(|_| UserKeyError::NotAUserKey);
        }
        Err(UserKeyError::NotAUserKey)
    }

    /// Wrap this key to one unlock method's age recipient: one armored age
    /// file, to be stored on the server as the method's wrap.
    pub fn wrap_to(&self, recipient: &dyn age::Recipient) -> Result<String, UserKeyError> {
        let enc = age::Encryptor::with_recipients(std::iter::once(recipient))
            .map_err(|e| UserKeyError::Age(e.to_string()))?;
        seal(enc, &self.payload())
    }

    /// Wrap this key to a security key's recipient (the `age1…` string
    /// [`security_key::recipient`] gave at enrolment).
    pub fn wrap_to_security_key(&self, recipient: &str) -> Result<String, UserKeyError> {
        let r = recipient
            .trim()
            .parse::<age::x25519::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(recipient.trim().to_string()))?;
        self.wrap_to(&r)
    }

    /// Wrap this key to a printed recovery code: age's scrypt stanza at the
    /// FIXED work factor [`recovery_code::LOG_N`] (scrypt N = 2^18, r = 8,
    /// p = 1: about 256 MiB and a second), on top of the code's 160 random bits.
    pub fn wrap_to_recovery_code(&self, code: &str) -> Result<String, UserKeyError> {
        let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
        let mut r = age::scrypt::Recipient::new(SecretString::from(normal.to_string()));
        r.set_work_factor(recovery_code::LOG_N);
        let enc = age::Encryptor::with_recipients(std::iter::once(&r as &dyn age::Recipient))
            .map_err(|e| UserKeyError::Age(e.to_string()))?;
        seal(enc, &self.payload())
    }

    /// Wrap this key to an SSH public key line (`ssh-ed25519 …`, `ssh-rsa …`).
    #[cfg(feature = "wrap")]
    pub fn wrap_to_ssh(&self, public_key: &str) -> Result<String, UserKeyError> {
        let r = public_key
            .trim()
            .parse::<age::ssh::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
        self.wrap_to(&r)
    }

    /// Wrap this key to an `age1pq1…` key (a key file made by
    /// `gunnar crypt keygen`).
    #[cfg(feature = "wrap")]
    pub fn wrap_to_pq(&self, public_key: &str) -> Result<String, UserKeyError> {
        let r = public_key
            .trim()
            .parse::<crate::pq::Recipient>()
            .map_err(|_| UserKeyError::BadRecipient(public_key.trim().to_string()))?;
        self.wrap_to(&r)
    }
}

fn seal(enc: age::Encryptor, payload: &str) -> Result<String, UserKeyError> {
    let age_err = |e: std::io::Error| UserKeyError::Age(e.to_string());
    let mut out = Vec::new();
    let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor).map_err(age_err)?;
    let mut w = enc.wrap_output(armor).map_err(age_err)?;
    w.write_all(payload.as_bytes()).map_err(age_err)?;
    w.finish().and_then(|a| a.finish()).map_err(age_err)?;
    String::from_utf8(out).map_err(|e| UserKeyError::Age(e.to_string()))
}

/// Why a user key could not be wrapped or opened. Every refusal has a name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserKeyError {
    /// The method's key is not the one this wrap is for: the wrong SSH key,
    /// another security key, a mistyped recovery code.
    WrongKey,
    /// Not a readable age file.
    Malformed(String),
    /// It opened, but what is inside is not a user key.
    NotAUserKey,
    /// It opened to a user key, but not the one the account publishes: a
    /// wrap substituted ALONE is refused. A server that replaces both the
    /// public half and the wrap is caught only where the public half is
    /// pinned: the CLI's pin (gunnar-cli `crypt_key::pins`, RA4-1).
    NotThisAccountsKey,
    /// A recipient string that is not one.
    BadRecipient(String),
    /// A recovery code that is not one (wrong length or characters).
    BadRecoveryCode,
    /// A quantum-safe user key needs the CLI (the console has no hybrid KEM).
    QuantumSafeNeedsTheCli,
    /// age itself failed.
    Age(String),
}

impl std::fmt::Display for UserKeyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            UserKeyError::WrongKey => write!(
                f,
                "this key does not open your encryption key: it is not one of your unlock methods \
                 (the wrong SSH key, another security key, or a mistyped recovery code)"
            ),
            UserKeyError::Malformed(e) => write!(f, "the stored wrap of your encryption key is not readable: {e}"),
            UserKeyError::NotAUserKey => write!(f, "the stored wrap opened, but it does not hold an encryption key; it is refused"),
            UserKeyError::NotThisAccountsKey => write!(
                f,
                "the stored wrap holds an encryption key that is not your account's: it is refused \
                 (someone replaced it on the server)"
            ),
            UserKeyError::BadRecipient(r) => write!(f, "{r:?} is not a key the encryption key can be wrapped to"),
            UserKeyError::BadRecoveryCode => write!(
                f,
                "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",
                recovery_code::CHARS
            ),
            UserKeyError::QuantumSafeNeedsTheCli => write!(
                f,
                "a quantum-safe encryption key opens in the Gunnar CLI only (`gunnar crypt unlock`)"
            ),
            UserKeyError::Age(e) => write!(f, "age: {e}"),
        }
    }
}
impl std::error::Error for UserKeyError {}

/// Open a stored wrap with one method's age identity, and check the key
/// inside against the account's published public key (`expected_public`).
pub fn open(wrap: &[u8], identity: &dyn age::Identity, expected_public: &str) -> Result<UserKey, UserKeyError> {
    let key = open_unchecked(wrap, std::iter::once(identity))?;
    if key.public() != expected_public.trim() {
        return Err(UserKeyError::NotThisAccountsKey);
    }
    Ok(key)
}

/// Open a stored wrap with a printed recovery code.
pub fn open_with_recovery_code(wrap: &[u8], code: &str, expected_public: &str) -> Result<UserKey, UserKeyError> {
    let normal = recovery_code::normalize(code).ok_or(UserKeyError::BadRecoveryCode)?;
    let mut id = age::scrypt::Identity::new(SecretString::from(normal.to_string()));
    id.set_max_work_factor(recovery_code::LOG_N);
    open(wrap, &id, expected_public)
}

fn open_unchecked<'a>(
    wrap: &[u8],
    identities: impl Iterator<Item = &'a dyn age::Identity>,
) -> Result<UserKey, UserKeyError> {
    if wrap.len() > MAX_WRAP {
        return Err(UserKeyError::Malformed(format!("larger than {} KiB", MAX_WRAP >> 10)));
    }
    let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(wrap))
        .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
    let mut reader = match decryptor.decrypt(identities) {
        Ok(r) => r,
        Err(age::DecryptError::NoMatchingKeys | age::DecryptError::DecryptionFailed) => {
            return Err(UserKeyError::WrongKey)
        }
        Err(e) => return Err(UserKeyError::Malformed(e.to_string())),
    };
    let mut buf = Zeroizing::new(Vec::new());
    reader
        .by_ref()
        .take(MAX_WRAP as u64)
        .read_to_end(&mut buf)
        .map_err(|e| UserKeyError::Malformed(e.to_string()))?;
    let text = std::str::from_utf8(&buf).map_err(|_| UserKeyError::NotAUserKey)?;
    UserKey::from_payload(text)
}

/// **A security key as an unlock method**: WebAuthn `prf` in the browser,
/// FIDO2 `hmac-secret` in the CLI, one secret.
pub mod security_key {
    use sha2::{Digest as _, Sha256};
    use zeroize::Zeroizing;

    /// The PRF input every evaluation uses (WebAuthn `prf.eval.first`). Fixed,
    /// so a credential always gives the same secret.
    pub const PRF_INPUT: &[u8] = b"gunnar-crypt user-key unlock v1";

    /// The HKDF-SHA256 salt and label of the derivation.
    pub const LABEL: &[u8] = b"gunnar-crypt security-key x25519 v1";

    /// The `hmac-secret` salt a CTAP2 client sends so the authenticator
    /// answers what a browser's WebAuthn `prf` answers for [`PRF_INPUT`]
    /// (WebAuthn L3 §10.1.4: `SHA-256("WebAuthn PRF" ‖ 0x00 ‖ input)`).
    pub fn hmac_secret_salt() -> [u8; 32] {
        let mut h = Sha256::new();
        h.update(b"WebAuthn PRF");
        h.update([0u8]);
        h.update(PRF_INPUT);
        h.finalize().into()
    }

    /// The X25519 secret of a credential: HKDF-SHA256 (salt [`LABEL`], the
    /// PRF output as the input key, `LABEL ‖ 0x00 ‖ credential id` as the
    /// context), as an age identity.
    pub fn identity(prf_output: &[u8; 32], credential_id: &[u8]) -> age::x25519::Identity {
        use bech32::ToBase32 as _;
        let mut info = Vec::with_capacity(LABEL.len() + 1 + credential_id.len());
        info.extend_from_slice(LABEL);
        info.push(0);
        info.extend_from_slice(credential_id);
        let mut secret = Zeroizing::new([0u8; 32]);
        hkdf::Hkdf::<Sha256>::new(Some(LABEL), prf_output)
            .expand(&info, secret.as_mut())
            .expect("32 bytes is a valid HKDF-SHA256 length");
        // age's own key text is the one constructor its API offers.
        let text = Zeroizing::new(
            bech32::encode("age-secret-key-", secret.to_base32(), bech32::Variant::Bech32)
                .expect("the HRP is valid")
                .to_uppercase(),
        );
        text.parse().expect("a 32-byte age secret key parses")
    }

    /// The credential's recipient (`age1…`): what enrolment uploads.
    pub fn recipient(prf_output: &[u8; 32], credential_id: &[u8]) -> String {
        identity(prf_output, credential_id).to_public().to_string()
    }
}

/// **A printed recovery code** as one more unlock method: 32 characters of
/// Crockford's base32 (160 random bits), in groups of four.
pub mod recovery_code {
    /// The alphabet: no I, L, O or U to misread.
    pub const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
    /// Characters in a code (without the dashes).
    pub const CHARS: usize = 32;
    /// The scrypt work factor every recovery wrap uses, and the most an opener
    /// accepts: N = 2^18 (age's own default class; about 256 MiB).
    pub const LOG_N: u8 = 18;

    /// A new code from 20 random bytes.
    pub fn generate() -> zeroize::Zeroizing<String> {
        from_bytes(&crate::random_bytes::<20>())
    }

    /// The code of 20 bytes (5 bits per character), grouped `XXXX-XXXX-…`.
    pub fn from_bytes(bytes: &[u8; 20]) -> zeroize::Zeroizing<String> {
        let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS + CHARS / 4));
        let (mut acc, mut bits) = (0u32, 0u32);
        let mut n = 0;
        for b in bytes {
            acc = (acc << 8) | u32::from(*b);
            bits += 8;
            while bits >= 5 {
                bits -= 5;
                if n > 0 && n % 4 == 0 {
                    out.push('-');
                }
                out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
                n += 1;
            }
        }
        out
    }

    /// The code as typed, normalised: upper case, no spaces or dashes, the
    /// look-alikes read as Crockford reads them (O → 0, I and L → 1).
    /// `None` unless it is [`CHARS`] characters of the alphabet.
    pub fn normalize(typed: &str) -> Option<zeroize::Zeroizing<String>> {
        let mut out = zeroize::Zeroizing::new(String::with_capacity(CHARS));
        for c in typed.chars() {
            let c = match c.to_ascii_uppercase() {
                ' ' | '-' => continue,
                'O' => '0',
                'I' | 'L' => '1',
                c => c,
            };
            if !c.is_ascii() || !ALPHABET.contains(&(c as u8)) {
                return None;
            }
            out.push(c);
        }
        (out.len() == CHARS).then_some(out)
    }
}