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/// Build a deterministic, unique page nonce from a page number and the
170/// monotonic write counter. `(page, counter)` never repeats, so neither does
171/// the nonce — the central safety property of the encryption scheme.
172pub fn page_nonce(page_no: u64, write_counter: u64) -> [u8; NONCE_LEN] {
173 let mut n = [0u8; NONCE_LEN];
174 n[0..4].copy_from_slice(&(page_no as u32).to_le_bytes());
175 n[4..12].copy_from_slice(&write_counter.to_le_bytes());
176 n
177}
178
179/// Wrap (encrypt) the DEK with the KEK. The wrap uses **empty AAD** in every
180/// version, so v5 wrapped-DEK bytes on disk round-trip unchanged through a v6
181/// build — only page encryption changed in v6.
182pub fn wrap_dek(
183 kek: &[u8; KEY_LEN],
184 nonce: &[u8; NONCE_LEN],
185 dek: &[u8; KEY_LEN],
186) -> Result<Vec<u8>> {
187 aead_encrypt(kek, nonce, dek, &[])
188}
189
190/// Unwrap (decrypt) the DEK with the KEK. A wrong KEK (wrong passphrase)
191/// fails authentication and is reported as [`MnemoError::WrongPassphrase`].
192pub fn unwrap_dek(
193 kek: &[u8; KEY_LEN],
194 nonce: &[u8; NONCE_LEN],
195 wrapped: &[u8],
196) -> Result<Zeroizing<[u8; KEY_LEN]>> {
197 let plain = cipher(kek)
198 .decrypt(
199 Nonce::from_slice(nonce),
200 Payload {
201 msg: wrapped,
202 aad: &[],
203 },
204 )
205 .map_err(|_| MnemoError::WrongPassphrase)?;
206 if plain.len() != KEY_LEN {
207 return Err(MnemoError::WrongPassphrase);
208 }
209 let mut dek = Zeroizing::new([0u8; KEY_LEN]);
210 dek.copy_from_slice(&plain);
211 Ok(dek)
212}