Skip to main content

lumen/
crypto.rs

1//! Authenticated encryption: the `AUTH` chunk, the AEAD units, Argon2id
2//! password wrapping and X25519 machine binding ([`spec/15-encryption.md`]).
3//!
4//! This module owns every cryptographic parameter the specification fixes. Two
5//! things are deliberately *not* here: the corpus' fixed nonces and salts (a
6//! real encoder draws them from the OS CSPRNG), and the per-chunk framing, which
7//! belongs to whoever knows the chunk type and unit index.
8
9use crate::check::Check;
10use crate::container::ChunkType;
11use crate::error::{Error, Result};
12use crate::io::{Reader, Writer};
13
14use aes_gcm::aead::generic_array::GenericArray;
15use aes_gcm::aead::{Aead, KeyInit, Payload};
16use aes_gcm::Aes256Gcm;
17use aes_kw::KekAes256;
18use argon2::{Algorithm, Argon2, Params, Version};
19use chacha20poly1305::ChaCha20Poly1305;
20use hkdf::Hkdf;
21use rand::rngs::OsRng;
22use rand::RngCore;
23use sha2::{Digest, Sha256};
24use x25519_dalek::{PublicKey, StaticSecret};
25
26/// `auth_version` this specification defines.
27const AUTH_VERSION: u32 = 1;
28/// Fixed `AUTH` header: cipher id, version, mode and the two section lengths.
29const AUTH_HEADER_LEN: usize = 20;
30
31/// `cipher_id` for AES-256-GCM.
32pub const CIPHER_A256: [u8; 4] = *b"A256";
33/// `cipher_id` for ChaCha20-Poly1305.
34pub const CIPHER_C20P: [u8; 4] = *b"C20P";
35
36/// `AUTH.mode` bit 0: a password section is present.
37pub const MODE_PASSWORD: u32 = 1;
38/// `AUTH.mode` bit 1: a machine-binding section is present.
39pub const MODE_MACHINE: u32 = 1 << 1;
40
41/// Fixed size of the password section.
42pub const PASSWORD_SECTION_LEN: usize = 65;
43/// Fixed size of one machine recipient entry.
44pub const RECIPIENT_ENTRY_LEN: usize = 104;
45/// Per-unit AEAD framing: 12-byte nonce, 16-byte tag.
46pub const UNIT_OVERHEAD: usize = 28;
47
48/// Recommended Argon2id ceilings from section 11.4.
49pub const ARGON2_MAX_ITERATIONS: u32 = 10;
50/// Recommended Argon2id memory ceiling in KiB.
51pub const ARGON2_MAX_MEMORY_KIB: u32 = 4_194_304;
52/// Recommended Argon2id lane ceiling.
53pub const ARGON2_MAX_PARALLELISM: u32 = 16;
54
55/// The HKDF `info` prefix for machine binding, including its NUL.
56pub const MACHINE_BINDING_LABEL: &[u8] = b"LUMEN machine-binding v1\0";
57
58/// The two AEAD ciphers the specification defines.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Cipher {
61    /// AES-256-GCM, 12-byte nonce and 16-byte tag.
62    Aes256Gcm,
63    /// ChaCha20-Poly1305, 12-byte nonce and 16-byte tag.
64    ChaCha20Poly1305,
65}
66
67impl Cipher {
68    /// The four-byte AUTH identifier.
69    pub fn id(self) -> [u8; 4] {
70        match self {
71            Cipher::Aes256Gcm => CIPHER_A256,
72            Cipher::ChaCha20Poly1305 => CIPHER_C20P,
73        }
74    }
75
76    /// Parse an AUTH identifier.
77    pub fn from_id(id: [u8; 4]) -> Result<Cipher> {
78        match id {
79            CIPHER_A256 => Ok(Cipher::Aes256Gcm),
80            CIPHER_C20P => Ok(Cipher::ChaCha20Poly1305),
81            _ => Err(Error::new(
82                Check::AuthCipherKnown,
83                format!("unknown cipher_id {}", ChunkType(id).tag()),
84            )),
85        }
86    }
87
88    /// Nonce length in bytes.
89    pub const fn nonce_len(self) -> usize {
90        12
91    }
92
93    /// Tag length in bytes.
94    pub const fn tag_len(self) -> usize {
95        16
96    }
97}
98
99/// A 256-bit session key, one per file.
100#[derive(Clone, Copy, PartialEq, Eq)]
101pub struct SessionKey([u8; 32]);
102
103impl SessionKey {
104    /// Wrap raw bytes.
105    pub fn from_bytes(bytes: [u8; 32]) -> SessionKey {
106        SessionKey(bytes)
107    }
108
109    /// The raw bytes.
110    pub fn as_bytes(&self) -> &[u8; 32] {
111        &self.0
112    }
113
114    /// Draw a fresh session key from the OS CSPRNG.
115    pub fn generate() -> SessionKey {
116        let mut bytes = [0u8; 32];
117        let mut rng = OsRng;
118        rng.fill_bytes(&mut bytes);
119        SessionKey(bytes)
120    }
121
122    /// Derive the file's session key for `chunk_type` from a password.
123    ///
124    /// Convenience over [`unwrap_password`] plus [`parse_auth`].
125    pub fn from_password(auth: &Auth, password: &str) -> Result<SessionKey> {
126        unwrap_password(auth, password)
127    }
128}
129
130impl core::fmt::Debug for SessionKey {
131    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
132        f.write_str("SessionKey(<redacted>)")
133    }
134}
135
136/// The fixed 65-byte password section.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub struct PasswordSection {
139    /// Argon2id salt.
140    pub salt: [u8; 16],
141    /// Argon2id time cost.
142    pub iterations: u32,
143    /// Argon2id memory cost in KiB.
144    pub memory_kib: u32,
145    /// Argon2id lanes.
146    pub parallelism: u8,
147    /// The session key wrapped with the derived KEK.
148    pub wrapped_key: [u8; 40],
149}
150
151/// One machine recipient entry.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct RecipientEntry {
154    /// SHA-256 fingerprint of the recipient's public key.
155    pub machine_fp: [u8; 32],
156    /// The sender's ephemeral X25519 public key.
157    pub ephemeral_pk: [u8; 32],
158    /// The session key wrapped with the ECDH-derived KEK.
159    pub wrapped_key: [u8; 40],
160}
161
162/// The `AUTH` chunk.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct Auth {
165    /// The recognized cipher.
166    pub cipher: Cipher,
167    /// Layout version.
168    pub auth_version: u32,
169    /// Bitfield: bit 0 password, bit 1 machine binding.
170    pub mode: u32,
171    /// The password section, when `mode` bit 0 is set.
172    pub password: Option<PasswordSection>,
173    /// The recipient entries, when `mode` bit 1 is set.
174    pub recipients: Vec<RecipientEntry>,
175}
176
177impl Auth {
178    /// The raw `cipher_id` bytes, as they appear on disk.
179    pub fn cipher_id(&self) -> [u8; 4] {
180        self.cipher.id()
181    }
182
183    /// Whether a password section is declared.
184    pub fn has_password(&self) -> bool {
185        self.mode & MODE_PASSWORD != 0
186    }
187
188    /// Whether a machine-binding section is declared.
189    pub fn has_machine(&self) -> bool {
190        self.mode & MODE_MACHINE != 0
191    }
192}
193
194/// Parse an `AUTH` payload.
195///
196/// A non-empty `mode` with no matching section length is rejected here; the
197/// Argon2id budget is checked by [`check_argon2_budget`], which a reader must
198/// call before deriving anything.
199pub fn parse_auth(payload: &[u8]) -> Result<Auth> {
200    if payload.len() < AUTH_HEADER_LEN {
201        return Err(Error::new(
202            Check::AuthFrame,
203            format!(
204                "AUTH payload is {} bytes, shorter than the {AUTH_HEADER_LEN}-byte header",
205                payload.len()
206            ),
207        ));
208    }
209
210    let mut r = Reader::checked(payload, Check::AuthFrame);
211    let cipher = Cipher::from_id(r.array::<4>()?)?;
212    let auth_version = r.u32()?;
213    if auth_version != AUTH_VERSION {
214        return Err(Error::new(
215            Check::AuthVersion,
216            format!("unsupported AUTH.auth_version {auth_version}, expected {AUTH_VERSION}"),
217        ));
218    }
219    let mode = r.u32()?;
220    let password_section_len = u64::from(r.u32()?);
221    let machine_section_len = u64::from(r.u32()?);
222
223    // Both declared lengths are attacker-controlled, so the bound is computed
224    // before either is used as an offset or a capacity.
225    if AUTH_HEADER_LEN as u64 + password_section_len + machine_section_len > payload.len() as u64 {
226        return Err(Error::new(
227            Check::AuthFrame,
228            format!(
229                "AUTH declares {password_section_len} password and {machine_section_len} machine bytes but carries only {} bytes after the header",
230                payload.len() - AUTH_HEADER_LEN
231            ),
232        ));
233    }
234    if mode == 0 {
235        return Err(Error::new(
236            Check::CryptModeEmpty,
237            "AUTH.mode is 0: neither a password nor a machine binding is declared",
238        ));
239    }
240
241    let password = if mode & MODE_PASSWORD != 0 {
242        if password_section_len < PASSWORD_SECTION_LEN as u64 {
243            return Err(Error::new(
244                Check::CryptPasswordSectionLen,
245                format!(
246                    "AUTH declares a {password_section_len}-byte password section, shorter than the {PASSWORD_SECTION_LEN}-byte fixed section"
247                ),
248            ));
249        }
250        let section = r.bytes(password_section_len as usize)?;
251        Some(read_password_section(section)?)
252    } else {
253        None
254    };
255
256    let recipients = if mode & MODE_MACHINE != 0 {
257        if machine_section_len < RECIPIENT_ENTRY_LEN as u64
258            || machine_section_len % RECIPIENT_ENTRY_LEN as u64 != 0
259        {
260            return Err(Error::new(
261                Check::CryptMachineSectionLen,
262                format!(
263                    "AUTH declares a {machine_section_len}-byte machine section, which is not a positive multiple of the {RECIPIENT_ENTRY_LEN}-byte entry size"
264                ),
265            ));
266        }
267        let section = r.bytes(machine_section_len as usize)?;
268        let mut recipients = Vec::with_capacity(section.len() / RECIPIENT_ENTRY_LEN);
269        for entry in section.chunks_exact(RECIPIENT_ENTRY_LEN) {
270            recipients.push(read_recipient_entry(entry)?);
271        }
272        recipients
273    } else {
274        Vec::new()
275    };
276
277    Ok(Auth {
278        cipher,
279        auth_version,
280        mode,
281        password,
282        recipients,
283    })
284}
285
286/// Serialize an `AUTH` payload.
287pub fn encode_auth(auth: &Auth) -> Vec<u8> {
288    let password_section_len = if auth.password.is_some() {
289        PASSWORD_SECTION_LEN
290    } else {
291        0
292    };
293    let machine_section_len = auth.recipients.len().saturating_mul(RECIPIENT_ENTRY_LEN);
294    let capacity = AUTH_HEADER_LEN
295        .saturating_add(password_section_len)
296        .saturating_add(machine_section_len);
297
298    let mut w = Writer::with_capacity(capacity);
299    w.bytes(&auth.cipher.id());
300    w.u32(auth.auth_version);
301    w.u32(auth.mode);
302    w.u32(password_section_len.min(u32::MAX as usize) as u32);
303    w.u32(machine_section_len.min(u32::MAX as usize) as u32);
304    if let Some(section) = auth.password {
305        w.bytes(&section.salt);
306        w.u32(section.iterations);
307        w.u32(section.memory_kib);
308        w.u8(section.parallelism);
309        w.bytes(&section.wrapped_key);
310    }
311    for entry in &auth.recipients {
312        w.bytes(&entry.machine_fp);
313        w.bytes(&entry.ephemeral_pk);
314        w.bytes(&entry.wrapped_key);
315    }
316    w.into_vec()
317}
318
319/// Reject Argon2id parameters past the recommended ceilings.
320pub fn check_argon2_budget(section: &PasswordSection) -> Result<()> {
321    if section.iterations > ARGON2_MAX_ITERATIONS {
322        return Err(Error::new(
323            Check::CryptArgon2Budget,
324            format!(
325                "AUTH declares {} Argon2id iterations, above the ceiling of {ARGON2_MAX_ITERATIONS}",
326                section.iterations
327            ),
328        ));
329    }
330    if section.memory_kib > ARGON2_MAX_MEMORY_KIB {
331        return Err(Error::new(
332            Check::CryptArgon2Budget,
333            format!(
334                "AUTH declares {} KiB of Argon2id memory, above the ceiling of {ARGON2_MAX_MEMORY_KIB}",
335                section.memory_kib
336            ),
337        ));
338    }
339    if u32::from(section.parallelism) > ARGON2_MAX_PARALLELISM {
340        return Err(Error::new(
341            Check::CryptArgon2Budget,
342            format!(
343                "AUTH declares {} Argon2id lanes, above the ceiling of {ARGON2_MAX_PARALLELISM}",
344                section.parallelism
345            ),
346        ));
347    }
348    Ok(())
349}
350
351/// The associated data binding a unit to its identity: `chunk_type || 0x00 || unit_index`.
352///
353/// `unit_index` is the slot the unit occupies within its chunk type: `0` for a
354/// chunk that is one unit, and the chunk's directory index for a `LAYR` frame,
355/// which is one unit per chunk and would otherwise be swappable between two
356/// `LAYR` chunks (`crypt.unit_index_binding`, section 9.3). The caller supplies
357/// it; [`crate::chunkio`] is where the choice is made.
358pub fn associated_data(chunk_type: ChunkType, unit_index: u32) -> [u8; 9] {
359    let mut aad = [0u8; 9];
360    aad[..4].copy_from_slice(&chunk_type.0);
361    aad[4] = 0;
362    aad[5..].copy_from_slice(&unit_index.to_le_bytes());
363    aad
364}
365
366/// Seal one unit: random nonce, ciphertext, tag.
367///
368/// The nonce is drawn from the OS CSPRNG and must never repeat for a key.
369pub fn seal(
370    cipher: Cipher,
371    key: &SessionKey,
372    chunk_type: ChunkType,
373    unit_index: u32,
374    plaintext: &[u8],
375) -> Result<Vec<u8>> {
376    let mut nonce = [0u8; 12];
377    let mut rng = OsRng;
378    rng.fill_bytes(&mut nonce);
379
380    let aad = associated_data(chunk_type, unit_index);
381    let body = aead_encrypt(cipher, key.as_bytes(), &nonce, plaintext, &aad)?;
382
383    let mut out = Vec::with_capacity(nonce.len() + body.len());
384    out.extend_from_slice(&nonce);
385    out.extend_from_slice(&body);
386    Ok(out)
387}
388
389/// Open one sealed unit, verifying its tag before returning the plaintext.
390pub fn open(
391    cipher: Cipher,
392    key: &SessionKey,
393    chunk_type: ChunkType,
394    unit_index: u32,
395    sealed: &[u8],
396) -> Result<Vec<u8>> {
397    if sealed.len() < UNIT_OVERHEAD {
398        return Err(Error::new(
399            Check::CryptTagVerify,
400            format!(
401                "sealed unit is {} bytes, shorter than the {UNIT_OVERHEAD}-byte nonce and tag framing",
402                sealed.len()
403            ),
404        ));
405    }
406
407    // The guard above makes this split total for both ciphers, whose nonces are
408    // 12 bytes.
409    let (nonce, body) = sealed.split_at(cipher.nonce_len());
410    let aad = associated_data(chunk_type, unit_index);
411    aead_decrypt(cipher, key.as_bytes(), nonce, body, &aad)
412}
413
414/// Compute a machine fingerprint: the SHA-256 of the X25519 public key.
415pub fn machine_fingerprint(public_key: &[u8; 32]) -> [u8; 32] {
416    let digest = Sha256::digest(public_key);
417    let mut out = [0u8; 32];
418    out.copy_from_slice(&digest);
419    out
420}
421
422/// Recover the session key from the password section.
423pub fn unwrap_password(auth: &Auth, password: &str) -> Result<SessionKey> {
424    let Some(section) = auth.password.as_ref() else {
425        return Err(Error::new(
426            Check::CryptNoKey,
427            "AUTH carries no password section",
428        ));
429    };
430    check_argon2_budget(section)?;
431    let kek = password_kek(
432        password,
433        &section.salt,
434        section.iterations,
435        section.memory_kib,
436        section.parallelism,
437    )?;
438    Ok(SessionKey::from_bytes(aes_kw_unwrap(
439        &kek,
440        &section.wrapped_key,
441    )?))
442}
443
444/// Recover the session key from the recipient entry matching `private_key`.
445///
446/// Entries that do not match are skipped; a matching entry whose X25519 result
447/// is the all-zero value is rejected rather than unwrapped.
448pub fn unwrap_machine(auth: &Auth, private_key: &[u8; 32]) -> Result<SessionKey> {
449    let secret = StaticSecret::from(*private_key);
450    let fingerprint = machine_fingerprint(PublicKey::from(&secret).as_bytes());
451
452    let mut matched = false;
453    for entry in &auth.recipients {
454        if entry.machine_fp != fingerprint {
455            continue;
456        }
457        matched = true;
458
459        let shared = secret.diffie_hellman(&PublicKey::from(entry.ephemeral_pk));
460        let ss = shared.as_bytes();
461        if ss.iter().all(|byte| *byte == 0) {
462            // The low-order-point result: reject this entry and keep looking,
463            // because another entry may still hold the session key.
464            continue;
465        }
466
467        // A matching entry that fails to unwrap is a hard error: the fingerprint
468        // says this key was meant for us, so the file does not bind to it.
469        let kek = machine_kek(ss, &entry.ephemeral_pk, &entry.machine_fp)?;
470        return Ok(SessionKey::from_bytes(aes_kw_unwrap(
471            &kek,
472            &entry.wrapped_key,
473        )?));
474    }
475
476    if matched {
477        Err(Error::new(
478            Check::CryptLowOrderPoint,
479            "every recipient entry matching this machine key yields the all-zero X25519 shared secret",
480        ))
481    } else {
482        Err(Error::new(
483            Check::CryptNoKey,
484            "no recipient entry carries the fingerprint of this machine key",
485        ))
486    }
487}
488
489/// Wrap `session` for a password, deriving the KEK with Argon2id.
490pub fn wrap_password(
491    session: &SessionKey,
492    password: &str,
493    iterations: u32,
494    memory_kib: u32,
495    parallelism: u8,
496) -> Result<PasswordSection> {
497    let mut salt = [0u8; 16];
498    let mut rng = OsRng;
499    rng.fill_bytes(&mut salt);
500
501    let kek = password_kek(password, &salt, iterations, memory_kib, parallelism)?;
502    Ok(PasswordSection {
503        salt,
504        iterations,
505        memory_kib,
506        parallelism,
507        wrapped_key: aes_kw_wrap(&kek, session.as_bytes())?,
508    })
509}
510
511/// Wrap `session` for one recipient public key.
512pub fn wrap_machine(session: &SessionKey, recipient_public: &[u8; 32]) -> Result<RecipientEntry> {
513    let mut ephemeral = [0u8; 32];
514    let mut rng = OsRng;
515    rng.fill_bytes(&mut ephemeral);
516
517    let secret = StaticSecret::from(ephemeral);
518    let ephemeral_public = PublicKey::from(&secret);
519    let shared = secret.diffie_hellman(&PublicKey::from(*recipient_public));
520
521    let fingerprint = machine_fingerprint(recipient_public);
522    let kek = machine_kek(shared.as_bytes(), ephemeral_public.as_bytes(), &fingerprint)?;
523    Ok(RecipientEntry {
524        machine_fp: fingerprint,
525        ephemeral_pk: *ephemeral_public.as_bytes(),
526        wrapped_key: aes_kw_wrap(&kek, session.as_bytes())?,
527    })
528}
529
530/// Read the fixed password section from the start of `section`.
531///
532/// The caller has already checked that `section` is at least
533/// [`PASSWORD_SECTION_LEN`] bytes, so any surplus is ignored.
534fn read_password_section(section: &[u8]) -> Result<PasswordSection> {
535    let mut r = Reader::checked(section, Check::CryptPasswordSectionLen);
536    Ok(PasswordSection {
537        salt: r.array::<16>()?,
538        iterations: r.u32()?,
539        memory_kib: r.u32()?,
540        parallelism: r.u8()?,
541        wrapped_key: r.array::<40>()?,
542    })
543}
544
545/// Read one fixed [`RECIPIENT_ENTRY_LEN`]-byte machine recipient entry.
546fn read_recipient_entry(entry: &[u8]) -> Result<RecipientEntry> {
547    let mut r = Reader::checked(entry, Check::CryptRecipientEntry);
548    Ok(RecipientEntry {
549        machine_fp: r.array::<32>()?,
550        ephemeral_pk: r.array::<32>()?,
551        wrapped_key: r.array::<40>()?,
552    })
553}
554
555/// Derive the 32-byte AES-256-KW key that wraps a session key under `password`.
556fn password_kek(
557    password: &str,
558    salt: &[u8; 16],
559    iterations: u32,
560    memory_kib: u32,
561    parallelism: u8,
562) -> Result<[u8; 32]> {
563    let params =
564        Params::new(memory_kib, iterations, u32::from(parallelism), Some(32)).map_err(|e| {
565            Error::new(
566                Check::CryptArgon2Budget,
567                format!("unsupported Argon2id parameters: {e}"),
568            )
569        })?;
570    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
571
572    let mut kek = [0u8; 32];
573    argon2
574        .hash_password_into(password.as_bytes(), salt, &mut kek)
575        .map_err(|e| {
576            Error::new(
577                Check::CryptArgon2Budget,
578                format!("Argon2id derivation failed: {e}"),
579            )
580        })?;
581    Ok(kek)
582}
583
584/// Derive the 32-byte AES-256-KW key that binds machine `machine_fp` to the
585/// ephemeral key `ephemeral_pk` through the shared secret `ss`.
586fn machine_kek(ss: &[u8; 32], ephemeral_pk: &[u8; 32], machine_fp: &[u8; 32]) -> Result<[u8; 32]> {
587    let mut info = [0u8; MACHINE_BINDING_LABEL.len() + 64];
588    info[..MACHINE_BINDING_LABEL.len()].copy_from_slice(MACHINE_BINDING_LABEL);
589    info[MACHINE_BINDING_LABEL.len()..MACHINE_BINDING_LABEL.len() + 32]
590        .copy_from_slice(ephemeral_pk);
591    info[MACHINE_BINDING_LABEL.len() + 32..].copy_from_slice(machine_fp);
592
593    let hkdf = Hkdf::<Sha256>::new(Some(machine_fp.as_slice()), ss);
594    let mut kek = [0u8; 32];
595    hkdf.expand(&info, &mut kek).map_err(|_| {
596        Error::new(
597            Check::CryptKeyUnwrap,
598            "HKDF-SHA-256 expand failed for the machine-binding KEK",
599        )
600    })?;
601    Ok(kek)
602}
603
604/// AES-256-KW wrap (RFC 3394) of a 32-byte session key.
605fn aes_kw_wrap(kek: &[u8; 32], key: &[u8; 32]) -> Result<[u8; 40]> {
606    let mut wrapped = [0u8; 40];
607    KekAes256::from(*kek).wrap(key, &mut wrapped).map_err(|e| {
608        Error::new(
609            Check::CryptKeyUnwrap,
610            format!("AES-256-KW wrap failed: {e}"),
611        )
612    })?;
613    Ok(wrapped)
614}
615
616/// AES-256-KW unwrap (RFC 3394) of a 40-byte wrapped session key.
617fn aes_kw_unwrap(kek: &[u8; 32], wrapped: &[u8; 40]) -> Result<[u8; 32]> {
618    let mut key = [0u8; 32];
619    KekAes256::from(*kek)
620        .unwrap(wrapped, &mut key)
621        .map_err(|e| {
622            Error::new(
623                Check::CryptKeyUnwrap,
624                format!("AES-256-KW unwrap failed: {e}"),
625            )
626        })?;
627    Ok(key)
628}
629
630/// AEAD-seal `msg` under `key`, returning `ciphertext || tag`.
631fn aead_encrypt(
632    cipher: Cipher,
633    key: &[u8; 32],
634    nonce: &[u8; 12],
635    msg: &[u8],
636    aad: &[u8],
637) -> Result<Vec<u8>> {
638    let payload = Payload { msg, aad };
639    let nonce = GenericArray::from_slice(nonce);
640    let sealed = match cipher {
641        Cipher::Aes256Gcm => Aes256Gcm::new(GenericArray::from_slice(key)).encrypt(nonce, payload),
642        Cipher::ChaCha20Poly1305 => {
643            ChaCha20Poly1305::new(GenericArray::from_slice(key)).encrypt(nonce, payload)
644        }
645    };
646    sealed.map_err(|_| {
647        Error::new(
648            Check::CryptTagVerify,
649            format!("{cipher:?} encryption failed"),
650        )
651    })
652}
653
654/// AEAD-open `msg` under `key`, verifying the tag before returning the plaintext.
655fn aead_decrypt(
656    cipher: Cipher,
657    key: &[u8; 32],
658    nonce: &[u8],
659    msg: &[u8],
660    aad: &[u8],
661) -> Result<Vec<u8>> {
662    let payload = Payload { msg, aad };
663    let nonce = GenericArray::from_slice(nonce);
664    let opened = match cipher {
665        Cipher::Aes256Gcm => Aes256Gcm::new(GenericArray::from_slice(key)).decrypt(nonce, payload),
666        Cipher::ChaCha20Poly1305 => {
667            ChaCha20Poly1305::new(GenericArray::from_slice(key)).decrypt(nonce, payload)
668        }
669    };
670    opened.map_err(|_| {
671        Error::new(
672            Check::CryptTagVerify,
673            format!("{cipher:?} AEAD tag did not verify"),
674        )
675    })
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    /// Argon2id parameters small enough to keep the suite fast.
683    const TINY: (u32, u32, u8) = (1, 8, 1);
684    const PASSWORD: &str = "lumen-crypto-test";
685
686    fn session_key(byte: u8) -> SessionKey {
687        SessionKey::from_bytes([byte; 32])
688    }
689
690    /// A deterministic X25519 keypair: `(private, public)`.
691    fn keypair(seed: u8) -> ([u8; 32], [u8; 32]) {
692        let secret = StaticSecret::from([seed; 32]);
693        let public = PublicKey::from(&secret);
694        ([seed; 32], *public.as_bytes())
695    }
696
697    fn machine_auth(recipients: Vec<RecipientEntry>) -> Auth {
698        Auth {
699            cipher: Cipher::Aes256Gcm,
700            auth_version: AUTH_VERSION,
701            mode: MODE_MACHINE,
702            password: None,
703            recipients,
704        }
705    }
706
707    /// A hand-built `AUTH` payload, for the frames `encode_auth` cannot produce.
708    fn raw_auth(
709        cipher_id: [u8; 4],
710        version: u32,
711        mode: u32,
712        password: &[u8],
713        machine: &[u8],
714    ) -> Vec<u8> {
715        let mut w = Writer::new();
716        w.bytes(&cipher_id);
717        w.u32(version);
718        w.u32(mode);
719        w.u32(password.len() as u32);
720        w.u32(machine.len() as u32);
721        w.bytes(password);
722        w.bytes(machine);
723        w.into_vec()
724    }
725
726    /// An entry carrying `fingerprint` whose ephemeral key is the low-order
727    /// point, wrapping `wrapped_session`: X25519 against it is all zeros, so a
728    /// reader that does not reject it recovers the wrong session key.
729    fn low_order_entry(fingerprint: &[u8; 32], wrapped_session: &SessionKey) -> RecipientEntry {
730        let zero = [0u8; 32];
731        let kek = machine_kek(&zero, &zero, fingerprint).unwrap();
732        RecipientEntry {
733            machine_fp: *fingerprint,
734            ephemeral_pk: zero,
735            wrapped_key: aes_kw_wrap(&kek, wrapped_session.as_bytes()).unwrap(),
736        }
737    }
738
739    #[test]
740    fn generated_session_keys_are_random() {
741        let a = SessionKey::generate();
742        let b = SessionKey::generate();
743        assert_eq!(a.as_bytes().len(), 32);
744        assert_ne!(a, b);
745        assert_ne!(a.as_bytes(), &[0u8; 32]);
746    }
747
748    #[test]
749    fn associated_data_binds_chunk_type_and_unit_index() {
750        let aad = associated_data(ChunkType::LAYR, 0x0102_0304);
751        assert_eq!(&aad[..4], b"LAYR");
752        assert_eq!(aad[4], 0);
753        assert_eq!(&aad[5..], &[0x04, 0x03, 0x02, 0x01]);
754    }
755
756    #[test]
757    fn seal_open_round_trip_both_ciphers() {
758        let key = session_key(0x11);
759        let plaintext = b"LUMEN sealed unit payload";
760        for cipher in [Cipher::Aes256Gcm, Cipher::ChaCha20Poly1305] {
761            let sealed = seal(cipher, &key, ChunkType::META, 0, plaintext).unwrap();
762            assert_eq!(sealed.len(), plaintext.len() + UNIT_OVERHEAD);
763            assert_eq!(
764                open(cipher, &key, ChunkType::META, 0, &sealed).unwrap(),
765                plaintext.to_vec()
766            );
767
768            // An empty payload is still a legal unit: nonce and tag only.
769            let empty = seal(cipher, &key, ChunkType::META, 0, b"").unwrap();
770            assert_eq!(empty.len(), UNIT_OVERHEAD);
771            assert!(open(cipher, &key, ChunkType::META, 0, &empty)
772                .unwrap()
773                .is_empty());
774
775            // Every unit gets a fresh nonce.
776            let again = seal(cipher, &key, ChunkType::META, 0, plaintext).unwrap();
777            assert_ne!(&sealed[..12], &again[..12]);
778        }
779    }
780
781    #[test]
782    fn open_rejects_tampered_or_misbound_units() {
783        let key = session_key(0x22);
784        let other_key = session_key(0x23);
785        for cipher in [Cipher::Aes256Gcm, Cipher::ChaCha20Poly1305] {
786            let sealed = seal(cipher, &key, ChunkType::LAYR, 3, b"block payload").unwrap();
787
788            let mut flipped_ciphertext = sealed.clone();
789            flipped_ciphertext[12] ^= 0x01;
790            assert_eq!(
791                open(cipher, &key, ChunkType::LAYR, 3, &flipped_ciphertext)
792                    .unwrap_err()
793                    .check(),
794                Check::CryptTagVerify
795            );
796
797            let mut flipped_tag = sealed.clone();
798            let last = flipped_tag.len() - 1;
799            flipped_tag[last] ^= 0x80;
800            assert_eq!(
801                open(cipher, &key, ChunkType::LAYR, 3, &flipped_tag)
802                    .unwrap_err()
803                    .check(),
804                Check::CryptTagVerify
805            );
806
807            // Frames too short to hold a nonce and a tag are rejected outright.
808            assert_eq!(
809                open(
810                    cipher,
811                    &key,
812                    ChunkType::LAYR,
813                    3,
814                    &sealed[..UNIT_OVERHEAD - 1]
815                )
816                .unwrap_err()
817                .check(),
818                Check::CryptTagVerify
819            );
820            assert_eq!(
821                open(cipher, &key, ChunkType::LAYR, 3, &sealed[..12])
822                    .unwrap_err()
823                    .check(),
824                Check::CryptTagVerify
825            );
826
827            // The AAD binds the unit to its chunk type and index.
828            assert_eq!(
829                open(cipher, &key, ChunkType::LAYR, 4, &sealed)
830                    .unwrap_err()
831                    .check(),
832                Check::CryptTagVerify
833            );
834            assert_eq!(
835                open(cipher, &key, ChunkType::META, 3, &sealed)
836                    .unwrap_err()
837                    .check(),
838                Check::CryptTagVerify
839            );
840
841            // A different session key cannot open it.
842            assert_eq!(
843                open(cipher, &other_key, ChunkType::LAYR, 3, &sealed)
844                    .unwrap_err()
845                    .check(),
846                Check::CryptTagVerify
847            );
848        }
849    }
850
851    #[test]
852    fn password_round_trip_recovers_the_session_key() {
853        let session = session_key(0x2a);
854        let (iterations, memory_kib, parallelism) = TINY;
855        let section =
856            wrap_password(&session, PASSWORD, iterations, memory_kib, parallelism).unwrap();
857        assert_eq!(section.iterations, 1);
858        assert_eq!(section.memory_kib, 8);
859        assert_eq!(section.parallelism, 1);
860        assert_ne!(section.salt, [0u8; 16]);
861        assert_eq!(check_argon2_budget(&section), Ok(()));
862
863        let auth = Auth {
864            cipher: Cipher::Aes256Gcm,
865            auth_version: AUTH_VERSION,
866            mode: MODE_PASSWORD,
867            password: Some(section),
868            recipients: Vec::new(),
869        };
870        assert_eq!(
871            unwrap_password(&auth, PASSWORD).unwrap().as_bytes(),
872            session.as_bytes()
873        );
874        assert_eq!(
875            SessionKey::from_password(&auth, PASSWORD)
876                .unwrap()
877                .as_bytes(),
878            session.as_bytes()
879        );
880
881        // The salt is fresh per wrap, so two sections never match.
882        let again = wrap_password(&session, PASSWORD, iterations, memory_kib, parallelism).unwrap();
883        assert_ne!(again.salt, section.salt);
884
885        // A wrong password derives a KEK whose unwrap fails the RFC 3394 check.
886        assert_eq!(
887            unwrap_password(&auth, "lumen-crypto-tes")
888                .unwrap_err()
889                .check(),
890            Check::CryptKeyUnwrap
891        );
892
893        // A machine-only AUTH carries no password section at all.
894        assert_eq!(
895            unwrap_password(&machine_auth(Vec::new()), PASSWORD)
896                .unwrap_err()
897                .check(),
898            Check::CryptNoKey
899        );
900    }
901
902    #[test]
903    fn argon2_budget_rejects_cost_above_the_ceiling() {
904        let base = PasswordSection {
905            salt: [0x5a; 16],
906            iterations: 1,
907            memory_kib: 8,
908            parallelism: 1,
909            wrapped_key: [0; 40],
910        };
911        assert_eq!(check_argon2_budget(&base), Ok(()));
912
913        let at_ceiling = PasswordSection {
914            iterations: ARGON2_MAX_ITERATIONS,
915            memory_kib: ARGON2_MAX_MEMORY_KIB,
916            parallelism: ARGON2_MAX_PARALLELISM as u8,
917            ..base
918        };
919        assert_eq!(check_argon2_budget(&at_ceiling), Ok(()));
920
921        let too_many_iterations = PasswordSection {
922            iterations: 99,
923            ..base
924        };
925        assert_eq!(
926            check_argon2_budget(&too_many_iterations)
927                .unwrap_err()
928                .check(),
929            Check::CryptArgon2Budget
930        );
931
932        let too_much_memory = PasswordSection {
933            memory_kib: ARGON2_MAX_MEMORY_KIB + 1,
934            ..base
935        };
936        assert_eq!(
937            check_argon2_budget(&too_much_memory).unwrap_err().check(),
938            Check::CryptArgon2Budget
939        );
940
941        let too_many_lanes = PasswordSection {
942            parallelism: (ARGON2_MAX_PARALLELISM + 1) as u8,
943            ..base
944        };
945        assert_eq!(
946            check_argon2_budget(&too_many_lanes).unwrap_err().check(),
947            Check::CryptArgon2Budget
948        );
949    }
950
951    #[test]
952    fn auth_round_trips_both_modes() {
953        let session = session_key(0x5c);
954        let (recipient_private, recipient_public) = keypair(0x21);
955        let password = wrap_password(&session, PASSWORD, 1, 8, 1).unwrap();
956        let entry = wrap_machine(&session, &recipient_public).unwrap();
957        let auth = Auth {
958            cipher: Cipher::ChaCha20Poly1305,
959            auth_version: AUTH_VERSION,
960            mode: MODE_PASSWORD | MODE_MACHINE,
961            password: Some(password),
962            recipients: vec![entry],
963        };
964
965        let encoded = encode_auth(&auth);
966        assert_eq!(&encoded[..4], b"C20P");
967        assert_eq!(
968            u32::from_le_bytes(encoded[4..8].try_into().unwrap()),
969            AUTH_VERSION
970        );
971        assert_eq!(
972            u32::from_le_bytes(encoded[8..12].try_into().unwrap()),
973            MODE_PASSWORD | MODE_MACHINE
974        );
975        assert_eq!(
976            u32::from_le_bytes(encoded[12..16].try_into().unwrap()),
977            PASSWORD_SECTION_LEN as u32
978        );
979        assert_eq!(
980            u32::from_le_bytes(encoded[16..20].try_into().unwrap()),
981            RECIPIENT_ENTRY_LEN as u32
982        );
983        assert_eq!(
984            encoded.len(),
985            AUTH_HEADER_LEN + PASSWORD_SECTION_LEN + RECIPIENT_ENTRY_LEN
986        );
987        assert_eq!(
988            &encoded[AUTH_HEADER_LEN..AUTH_HEADER_LEN + 16],
989            &password.salt
990        );
991        assert_eq!(
992            &encoded[AUTH_HEADER_LEN + 25..AUTH_HEADER_LEN + 65],
993            &password.wrapped_key
994        );
995        assert_eq!(
996            encoded[AUTH_HEADER_LEN + PASSWORD_SECTION_LEN + 64..],
997            entry.wrapped_key
998        );
999
1000        let parsed = parse_auth(&encoded).unwrap();
1001        assert_eq!(parsed, auth);
1002        assert_eq!(parsed.password.unwrap().wrapped_key, password.wrapped_key);
1003        assert_eq!(parsed.recipients, vec![entry]);
1004        assert_eq!(
1005            unwrap_password(&parsed, PASSWORD).unwrap().as_bytes(),
1006            session.as_bytes()
1007        );
1008        assert_eq!(
1009            unwrap_machine(&parsed, &recipient_private)
1010                .unwrap()
1011                .as_bytes(),
1012            session.as_bytes()
1013        );
1014    }
1015
1016    #[test]
1017    fn auth_round_trips_single_mode_layouts() {
1018        let session = session_key(0x60);
1019
1020        let password = wrap_password(&session, PASSWORD, 1, 8, 1).unwrap();
1021        let password_only = Auth {
1022            cipher: Cipher::Aes256Gcm,
1023            auth_version: AUTH_VERSION,
1024            mode: MODE_PASSWORD,
1025            password: Some(password),
1026            recipients: Vec::new(),
1027        };
1028        let encoded = encode_auth(&password_only);
1029        assert_eq!(encoded.len(), AUTH_HEADER_LEN + PASSWORD_SECTION_LEN);
1030        assert_eq!(parse_auth(&encoded).unwrap(), password_only);
1031
1032        let first = wrap_machine(&session, &keypair(0x31).1).unwrap();
1033        let second = wrap_machine(&session, &keypair(0x32).1).unwrap();
1034        let machine_only = machine_auth(vec![first, second]);
1035        let encoded = encode_auth(&machine_only);
1036        assert_eq!(encoded.len(), AUTH_HEADER_LEN + 2 * RECIPIENT_ENTRY_LEN);
1037        assert_eq!(parse_auth(&encoded).unwrap(), machine_only);
1038    }
1039
1040    #[test]
1041    fn parse_auth_ignores_surplus_password_section_bytes() {
1042        let session = session_key(0x77);
1043        let section = wrap_password(&session, PASSWORD, 1, 8, 1).unwrap();
1044        let auth = Auth {
1045            cipher: Cipher::Aes256Gcm,
1046            auth_version: AUTH_VERSION,
1047            mode: MODE_PASSWORD,
1048            password: Some(section),
1049            recipients: Vec::new(),
1050        };
1051
1052        let encoded = encode_auth(&auth);
1053        let mut padded = encoded[AUTH_HEADER_LEN..].to_vec();
1054        padded.extend_from_slice(&[0u8; 5]);
1055
1056        let payload = raw_auth(CIPHER_A256, AUTH_VERSION, MODE_PASSWORD, &padded, &[]);
1057        assert_eq!(
1058            u32::from_le_bytes(payload[12..16].try_into().unwrap()),
1059            (PASSWORD_SECTION_LEN + 5) as u32
1060        );
1061
1062        let parsed = parse_auth(&payload).unwrap();
1063        assert_eq!(parsed.password, Some(section));
1064        assert_eq!(
1065            unwrap_password(&parsed, PASSWORD).unwrap().as_bytes(),
1066            session.as_bytes()
1067        );
1068    }
1069
1070    #[test]
1071    fn parse_auth_rejects_malformed_frames() {
1072        let password = [0u8; PASSWORD_SECTION_LEN];
1073
1074        // Shorter than the fixed header.
1075        assert_eq!(
1076            parse_auth(&[0u8; AUTH_HEADER_LEN - 1]).unwrap_err().check(),
1077            Check::AuthFrame
1078        );
1079        assert_eq!(parse_auth(&[]).unwrap_err().check(), Check::AuthFrame);
1080
1081        // Declared sections that do not fit in the payload.
1082        let mut overrun = raw_auth(CIPHER_A256, AUTH_VERSION, MODE_MACHINE, &[], &[]);
1083        overrun[16..20].copy_from_slice(&(RECIPIENT_ENTRY_LEN as u32).to_le_bytes());
1084        assert_eq!(parse_auth(&overrun).unwrap_err().check(), Check::AuthFrame);
1085
1086        // An unrecognized cipher or layout version.
1087        assert_eq!(
1088            parse_auth(&raw_auth(
1089                *b"XXXX",
1090                AUTH_VERSION,
1091                MODE_PASSWORD,
1092                &password,
1093                &[]
1094            ))
1095            .unwrap_err()
1096            .check(),
1097            Check::AuthCipherKnown
1098        );
1099        assert_eq!(
1100            parse_auth(&raw_auth(CIPHER_A256, 2, MODE_PASSWORD, &password, &[]))
1101                .unwrap_err()
1102                .check(),
1103            Check::AuthVersion
1104        );
1105
1106        // Neither mode declared.
1107        assert_eq!(
1108            parse_auth(&raw_auth(CIPHER_A256, AUTH_VERSION, 0, &[], &[]))
1109                .unwrap_err()
1110                .check(),
1111            Check::CryptModeEmpty
1112        );
1113
1114        // A password section one byte short of the fixed size.
1115        assert_eq!(
1116            parse_auth(&raw_auth(
1117                CIPHER_A256,
1118                AUTH_VERSION,
1119                MODE_PASSWORD,
1120                &password[..PASSWORD_SECTION_LEN - 1],
1121                &[]
1122            ))
1123            .unwrap_err()
1124            .check(),
1125            Check::CryptPasswordSectionLen
1126        );
1127
1128        // A machine section with no complete recipient entry.
1129        assert_eq!(
1130            parse_auth(&raw_auth(CIPHER_C20P, AUTH_VERSION, MODE_MACHINE, &[], &[]))
1131                .unwrap_err()
1132                .check(),
1133            Check::CryptMachineSectionLen
1134        );
1135        assert_eq!(
1136            parse_auth(&raw_auth(
1137                CIPHER_C20P,
1138                AUTH_VERSION,
1139                MODE_MACHINE,
1140                &[],
1141                &[0u8; RECIPIENT_ENTRY_LEN + 1]
1142            ))
1143            .unwrap_err()
1144            .check(),
1145            Check::CryptMachineSectionLen
1146        );
1147    }
1148
1149    #[test]
1150    fn machine_round_trip_recovers_the_session_key() {
1151        let session = session_key(0x33);
1152        let (private, public) = keypair(0x07);
1153        let entry = wrap_machine(&session, &public).unwrap();
1154        assert_eq!(entry.machine_fp, machine_fingerprint(&public));
1155        assert_ne!(entry.ephemeral_pk, [0u8; 32]);
1156
1157        assert_eq!(
1158            unwrap_machine(&machine_auth(vec![entry]), &private)
1159                .unwrap()
1160                .as_bytes(),
1161            session.as_bytes()
1162        );
1163
1164        // Each wrap uses a fresh ephemeral keypair.
1165        let other = wrap_machine(&session, &public).unwrap();
1166        assert_eq!(other.machine_fp, entry.machine_fp);
1167        assert_ne!(other.ephemeral_pk, entry.ephemeral_pk);
1168        assert_ne!(other.wrapped_key, entry.wrapped_key);
1169    }
1170
1171    #[test]
1172    fn machine_unwrap_skips_recipients_for_other_machines() {
1173        let session = session_key(0x44);
1174        let (stranger_private, stranger_public) = keypair(0x08);
1175        let (our_private, our_public) = keypair(0x09);
1176        let auth = machine_auth(vec![
1177            wrap_machine(&session, &stranger_public).unwrap(),
1178            wrap_machine(&session, &our_public).unwrap(),
1179        ]);
1180
1181        // The matching entry sits after one that is not ours.
1182        assert_eq!(
1183            unwrap_machine(&auth, &our_private).unwrap().as_bytes(),
1184            session.as_bytes()
1185        );
1186        // The stranger still recovers it from their own entry.
1187        assert_eq!(
1188            unwrap_machine(&auth, &stranger_private).unwrap().as_bytes(),
1189            session.as_bytes()
1190        );
1191
1192        // A machine key in no entry at all is CryptNoKey.
1193        let (absent_private, _) = keypair(0x0a);
1194        assert_eq!(
1195            unwrap_machine(&auth, &absent_private).unwrap_err().check(),
1196            Check::CryptNoKey
1197        );
1198        assert_eq!(
1199            unwrap_machine(&machine_auth(Vec::new()), &our_private)
1200                .unwrap_err()
1201                .check(),
1202            Check::CryptNoKey
1203        );
1204    }
1205
1206    #[test]
1207    fn machine_unwrap_skips_low_order_entry_and_keeps_searching() {
1208        let session = session_key(0x55);
1209        let decoy_session = session_key(0x56);
1210        let (our_private, our_public) = keypair(0x0b);
1211        let fingerprint = machine_fingerprint(&our_public);
1212
1213        let decoy = low_order_entry(&fingerprint, &decoy_session);
1214        // The decoy is coherent: a reader that unwraps without checking the
1215        // shared secret recovers the wrong session key from it.
1216        let zero = [0u8; 32];
1217        let decoy_kek = machine_kek(&zero, &zero, &fingerprint).unwrap();
1218        assert_eq!(
1219            aes_kw_unwrap(&decoy_kek, &decoy.wrapped_key).unwrap(),
1220            *decoy_session.as_bytes()
1221        );
1222
1223        let auth = machine_auth(vec![decoy, wrap_machine(&session, &our_public).unwrap()]);
1224        let recovered = unwrap_machine(&auth, &our_private).unwrap();
1225        assert_eq!(recovered.as_bytes(), session.as_bytes());
1226        assert_ne!(recovered.as_bytes(), decoy_session.as_bytes());
1227    }
1228
1229    #[test]
1230    fn machine_unwrap_reports_low_order_when_every_match_is_rejected() {
1231        let decoy_session = session_key(0x56);
1232        let (our_private, our_public) = keypair(0x0b);
1233        let fingerprint = machine_fingerprint(&our_public);
1234
1235        let auth = machine_auth(vec![low_order_entry(&fingerprint, &decoy_session)]);
1236        assert_eq!(
1237            unwrap_machine(&auth, &our_private).unwrap_err().check(),
1238            Check::CryptLowOrderPoint
1239        );
1240    }
1241
1242    #[test]
1243    fn machine_unwrap_rejects_a_matching_entry_that_does_not_unwrap() {
1244        let session = session_key(0x66);
1245        let (our_private, our_public) = keypair(0x0c);
1246        let mut entry = wrap_machine(&session, &our_public).unwrap();
1247        entry.wrapped_key[0] ^= 0x01;
1248
1249        assert_eq!(
1250            unwrap_machine(&machine_auth(vec![entry]), &our_private)
1251                .unwrap_err()
1252                .check(),
1253            Check::CryptKeyUnwrap
1254        );
1255    }
1256}