Skip to main content

mnemo/
crypto.rs

1//! Cryptography layer for Mnemo (Phase 4 of the build plan).
2//!
3//! Mnemo uses a **two-tier key hierarchy**:
4//!
5//! ```text
6//!   passphrase ──Argon2id──▶ KEK (key-encryption-key)
7//!                              │  AES-256-GCM
8//!                              ▼
9//!                            DEK (data-encryption-key, random 256-bit)
10//!                              │  AES-256-GCM, per-page
11//!                              ▼
12//!                            encrypted pages on disk
13//! ```
14//!
15//! The DEK encrypts every page. The KEK only ever encrypts ("wraps") the DEK.
16//! This is what makes `rekey` cheap: changing the passphrase re-derives the KEK
17//! and re-wraps the DEK — the pages themselves never need re-encryption.
18//!
19//! Every page gets a unique nonce derived from `(page_number, write_counter)`,
20//! where `write_counter` is a monotonic counter persisted in the file header.
21//! Because the counter never repeats, an AES-GCM nonce is never reused.
22
23use aes_gcm::aead::{Aead, KeyInit, Payload};
24use aes_gcm::{Aes256Gcm, Key, Nonce};
25use argon2::{Algorithm, Argon2, Params, Version};
26use zeroize::Zeroizing;
27
28use crate::error::{MnemoError, Result};
29
30/// Length of a symmetric key in bytes (256-bit).
31pub const KEY_LEN: usize = 32;
32/// Length of an AES-GCM nonce in bytes (96-bit).
33pub const NONCE_LEN: usize = 12;
34/// Length of an AES-GCM authentication tag in bytes.
35pub const TAG_LEN: usize = 16;
36/// Length of the Argon2 salt in bytes.
37pub const SALT_LEN: usize = 16;
38
39/// Argon2id cost parameters. Stored (unencrypted) in the file header so the
40/// KEK can be re-derived on open.
41#[derive(Clone, Copy, Debug)]
42pub struct KdfParams {
43    /// Memory cost in KiB.
44    pub m_cost: u32,
45    /// Time cost (number of iterations).
46    pub t_cost: u32,
47    /// Parallelism (lanes).
48    pub p_cost: u32,
49}
50
51impl KdfParams {
52    /// Sensible interactive-use defaults (~19 MiB, 2 passes).
53    pub fn secure() -> Self {
54        Self {
55            m_cost: 19_456,
56            t_cost: 2,
57            p_cost: 1,
58        }
59    }
60
61    /// Deliberately weak parameters — **only** for fast unit tests.
62    #[doc(hidden)]
63    pub fn fast() -> Self {
64        Self {
65            m_cost: 512,
66            t_cost: 1,
67            p_cost: 1,
68        }
69    }
70}
71
72impl Default for KdfParams {
73    fn default() -> Self {
74        Self::secure()
75    }
76}
77
78/// Derive the key-encryption-key (KEK) from a passphrase using Argon2id.
79pub fn derive_kek(
80    passphrase: &[u8],
81    salt: &[u8],
82    params: KdfParams,
83) -> Result<Zeroizing<[u8; KEY_LEN]>> {
84    let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(KEY_LEN))
85        .map_err(|e| MnemoError::Kdf(e.to_string()))?;
86    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, p);
87    let mut key = Zeroizing::new([0u8; KEY_LEN]);
88    argon
89        .hash_password_into(passphrase, salt, &mut key[..])
90        .map_err(|e| MnemoError::Kdf(e.to_string()))?;
91    Ok(key)
92}
93
94fn cipher(key: &[u8; KEY_LEN]) -> Aes256Gcm {
95    Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key))
96}
97
98/// AES-256-GCM encrypt with **Associated Authenticated Data**. The AAD is not
99/// part of the ciphertext but is bound into the authentication tag; decryption
100/// fails unless the caller supplies the same AAD. Output is
101/// `ciphertext || 16-byte tag`.
102///
103/// Pass `&[]` to encrypt without AAD — this matches the format before v6, used
104/// for wrapping the DEK and for v4/v5 page encryption. From v6 onwards, page
105/// encryption passes `page_no.to_le_bytes()` as AAD so an attacker can't
106/// transplant a valid encrypted page to a different page slot — the
107/// authentication tag binds the page image to its home page number.
108pub fn aead_encrypt(
109    key: &[u8; KEY_LEN],
110    nonce: &[u8; NONCE_LEN],
111    plaintext: &[u8],
112    aad: &[u8],
113) -> Result<Vec<u8>> {
114    cipher(key)
115        .encrypt(
116            Nonce::from_slice(nonce),
117            Payload {
118                msg: plaintext,
119                aad,
120            },
121        )
122        .map_err(|e| MnemoError::Crypto(e.to_string()))
123}
124
125/// AES-256-GCM decrypt with AAD. Input must be `ciphertext || 16-byte tag`.
126/// Returns an error if authentication fails — including when the supplied
127/// AAD doesn't match what was used during encryption.
128pub fn aead_decrypt(
129    key: &[u8; KEY_LEN],
130    nonce: &[u8; NONCE_LEN],
131    ciphertext: &[u8],
132    aad: &[u8],
133) -> Result<Vec<u8>> {
134    cipher(key)
135        .decrypt(
136            Nonce::from_slice(nonce),
137            Payload {
138                msg: ciphertext,
139                aad,
140            },
141        )
142        .map_err(|_| MnemoError::Crypto("authentication failed".into()))
143}
144
145/// Generate a fresh random 256-bit data-encryption-key.
146pub fn random_dek() -> Zeroizing<[u8; KEY_LEN]> {
147    use rand::RngCore;
148    let mut dek = Zeroizing::new([0u8; KEY_LEN]);
149    rand::thread_rng().fill_bytes(&mut dek[..]);
150    dek
151}
152
153/// Generate a random Argon2 salt.
154pub fn random_salt() -> [u8; SALT_LEN] {
155    use rand::RngCore;
156    let mut salt = [0u8; SALT_LEN];
157    rand::thread_rng().fill_bytes(&mut salt);
158    salt
159}
160
161/// Generate a random nonce (used for wrapping the DEK).
162pub fn random_nonce() -> [u8; NONCE_LEN] {
163    use rand::RngCore;
164    let mut n = [0u8; NONCE_LEN];
165    rand::thread_rng().fill_bytes(&mut n);
166    n
167}
168
169/// Generate a random `u64`, used by the pager to pick a per-session
170/// nonce prefix. See [`session_page_nonce`] for the full construction.
171pub fn random_u64() -> u64 {
172    use rand::RngCore;
173    rand::thread_rng().next_u64()
174}
175
176/// Build a per-session page nonce (PR SEC-1, v0.5.2).
177///
178/// Structure, following NIST SP 800-38D §8.2.1 (fixed field + invocation
179/// field) for AES-GCM:
180///
181/// - **Bytes 0..8** — `session_random`: a 64-bit random value drawn
182///   fresh on every writable [`crate::Mnemo::open`] / [`crate::Mnemo::create`]
183///   (and regenerated on counter wrap). This is the "fixed field".
184/// - **Bytes 8..12** — `session_counter`: a 32-bit per-session monotonic
185///   counter that advances once per encrypted page write. This is the
186///   "invocation field".
187///
188/// `page_no` is **not** encoded in the nonce. Pages were bound to their
189/// home slot through AAD since v6 (`page_no.to_le_bytes()`), so an
190/// attacker who swapped a valid page to a different slot already failed
191/// authentication — the AAD binding stays in place and does the job the
192/// `page_no` component of the pre-SEC-1 nonce was helping with.
193///
194/// # Why not deterministic any more
195///
196/// Before v0.5.2 the nonce was `page_no as u32 || write_counter as u64`,
197/// which was unique **within a file** but not **across copies of a file**.
198/// If you copied a `.mnemo` file to another host and both the original and
199/// the copy got written to (two laptops syncing a Dropbox file; a
200/// restored older backup resuming operation), each copy's writer produced
201/// pages whose nonces collided with the other's at the same `(page_no,
202/// write_counter)` coordinates, under the same DEK (`random_dek()` only
203/// at create; `rekey` keeps it). That breaks AES-GCM's confidentiality
204/// and tamper-evidence guarantee: whoever holds both ciphertexts can
205/// recover the XOR of the plaintexts and forge pages with a valid tag.
206///
207/// A fresh random 64-bit `session_random` per writable open closes that:
208/// cross-copy collision now requires two sessions to independently draw
209/// the same 64-bit value (birthday bound ~2^32 sessions, i.e.
210/// astronomically unlikely for a human-scale user). The 32-bit
211/// `session_counter` guarantees uniqueness *within* a session; the pager
212/// regenerates `session_random` and resets the counter if it would wrap.
213///
214/// # Decrypt is unaffected
215///
216/// Each page already stores its own nonce in bytes `[0..12]` of the
217/// on-disk image (see `mnemo/src/pager.rs::read_page`). Decrypt reads the
218/// stored nonce; it never recomputes one. **No format bump — a v0.5.1
219/// file opens fine on v0.5.2 and vice versa.**
220pub fn session_page_nonce(session_random: u64, session_counter: u32) -> [u8; NONCE_LEN] {
221    let mut n = [0u8; NONCE_LEN];
222    n[0..8].copy_from_slice(&session_random.to_le_bytes());
223    n[8..12].copy_from_slice(&session_counter.to_le_bytes());
224    n
225}
226
227/// Wrap (encrypt) the DEK with the KEK. The wrap uses **empty AAD** in every
228/// version, so v5 wrapped-DEK bytes on disk round-trip unchanged through a v6
229/// build — only page encryption changed in v6.
230pub fn wrap_dek(
231    kek: &[u8; KEY_LEN],
232    nonce: &[u8; NONCE_LEN],
233    dek: &[u8; KEY_LEN],
234) -> Result<Vec<u8>> {
235    aead_encrypt(kek, nonce, dek, &[])
236}
237
238/// Unwrap (decrypt) the DEK with the KEK. A wrong KEK (wrong passphrase)
239/// fails authentication and is reported as [`MnemoError::WrongPassphrase`].
240pub fn unwrap_dek(
241    kek: &[u8; KEY_LEN],
242    nonce: &[u8; NONCE_LEN],
243    wrapped: &[u8],
244) -> Result<Zeroizing<[u8; KEY_LEN]>> {
245    let plain = cipher(kek)
246        .decrypt(
247            Nonce::from_slice(nonce),
248            Payload {
249                msg: wrapped,
250                aad: &[],
251            },
252        )
253        .map_err(|_| MnemoError::WrongPassphrase)?;
254    if plain.len() != KEY_LEN {
255        return Err(MnemoError::WrongPassphrase);
256    }
257    let mut dek = Zeroizing::new([0u8; KEY_LEN]);
258    dek.copy_from_slice(&plain);
259    Ok(dek)
260}