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