nodedb_lite/storage/encrypted/crypto.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Encryption-at-rest wrapper for `StorageEngine`.
4//!
5//! Wraps any `StorageEngine` with AES-256-GCM encryption. All values are
6//! encrypted before writing and decrypted after reading. Keys (namespace + key)
7//! are NOT encrypted — they are needed for range scans and prefix lookups.
8//!
9//! Key derivation: Argon2id KDF from a user-provided passphrase + random salt.
10//! The salt is stored in plaintext in the Meta namespace (it's not secret —
11//! only the passphrase is). The derived key is zeroized on drop.
12//!
13//! Nonce: deterministic 12-byte nonce derived from namespace + key via HMAC.
14//! This makes encryption deterministic (same key+value = same ciphertext),
15//! which is acceptable for a KV store where the key already uniquely identifies
16//! the entry. The benefit is idempotent writes without nonce tracking.
17
18use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
19use zeroize::Zeroize;
20
21use crate::error::LiteError;
22use crate::storage::engine::StorageEngine;
23use nodedb_types::Namespace;
24
25/// Salt size for Argon2id key derivation.
26pub(super) const SALT_SIZE: usize = 16;
27
28/// Meta key for the stored salt.
29pub(super) const SALT_KEY: &[u8] = b"encryption:salt";
30
31/// Encrypted storage wrapper.
32///
33/// Encrypts all values with AES-256-GCM. Keys are plaintext (needed for scans).
34/// The encryption key is derived from a passphrase via Argon2id and held in
35/// memory until the `EncryptedStorage` is dropped, at which point it's zeroized.
36pub struct EncryptedStorage<S: StorageEngine> {
37 pub(super) inner: S,
38 cipher: Aes256Gcm,
39 /// Derived key material — zeroized on drop.
40 _key_material: ZeroizeKey,
41}
42
43/// Wrapper to zeroize the derived key on drop.
44struct ZeroizeKey {
45 bytes: [u8; 32],
46}
47
48impl Drop for ZeroizeKey {
49 fn drop(&mut self) {
50 self.bytes.zeroize();
51 }
52}
53
54impl<S: StorageEngine> EncryptedStorage<S> {
55 /// Create an encrypted storage wrapper.
56 ///
57 /// On first use (no salt stored), generates a random salt and stores it.
58 /// On subsequent opens, reads the existing salt for key derivation.
59 ///
60 /// `m_cost`, `t_cost`, and `p_cost` are the Argon2id parameters (memory in
61 /// KiB, iteration count, and parallelism lanes respectively). Callers should
62 /// obtain these from [`crate::config::LiteConfig`].
63 ///
64 /// # Errors
65 /// Returns `LiteError` if salt generation fails or the storage is inaccessible.
66 pub async fn open(
67 inner: S,
68 passphrase: &str,
69 m_cost: u32,
70 t_cost: u32,
71 p_cost: u32,
72 ) -> Result<Self, LiteError> {
73 // Read or generate salt.
74 let salt = match inner.get(Namespace::Meta, SALT_KEY).await? {
75 Some(existing_salt) => {
76 if existing_salt.len() != SALT_SIZE {
77 return Err(LiteError::Storage {
78 detail: format!(
79 "encryption salt has wrong size: expected {SALT_SIZE}, got {}",
80 existing_salt.len()
81 ),
82 });
83 }
84 let mut salt = [0u8; SALT_SIZE];
85 salt.copy_from_slice(&existing_salt);
86 salt
87 }
88 None => {
89 // First use — generate random salt.
90 let mut salt = [0u8; SALT_SIZE];
91 getrandom::fill(&mut salt).map_err(|e| LiteError::Storage {
92 detail: format!("getrandom failed for encryption salt: {e}"),
93 })?;
94 inner.put(Namespace::Meta, SALT_KEY, &salt).await?;
95 salt
96 }
97 };
98
99 // Derive key via Argon2id.
100 let mut key_bytes = [0u8; 32];
101 let argon2 = argon2::Argon2::new(
102 argon2::Algorithm::Argon2id,
103 argon2::Version::V0x13,
104 argon2::Params::new(m_cost, t_cost, p_cost, Some(32)).map_err(|e| {
105 LiteError::Storage {
106 detail: format!("argon2 params: {e}"),
107 }
108 })?,
109 );
110 argon2
111 .hash_password_into(passphrase.as_bytes(), &salt, &mut key_bytes)
112 .map_err(|e| LiteError::Storage {
113 detail: format!("argon2 key derivation failed: {e}"),
114 })?;
115
116 let cipher = Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| LiteError::Storage {
117 detail: format!("AES-256-GCM init failed: {e}"),
118 })?;
119
120 Ok(Self {
121 inner,
122 cipher,
123 _key_material: ZeroizeKey { bytes: key_bytes },
124 })
125 }
126
127 /// Derive a deterministic 12-byte nonce from namespace + key.
128 ///
129 /// Uses the first 12 bytes of CRC32C(namespace || key), extended with
130 /// the namespace byte and key length for uniqueness. Not cryptographically
131 /// ideal (nonce reuse for same key), but acceptable because:
132 /// 1. Each (namespace, key) pair maps to exactly one value at a time
133 /// 2. Rewriting the same key with different data is an update, not a new message
134 fn derive_nonce(ns: Namespace, key: &[u8]) -> [u8; 12] {
135 let mut nonce_input = Vec::with_capacity(1 + key.len());
136 nonce_input.push(ns as u8);
137 nonce_input.extend_from_slice(key);
138
139 let crc = crc32c::crc32c(&nonce_input);
140 let crc_bytes = crc.to_le_bytes();
141
142 let mut nonce = [0u8; 12];
143 // Fill: [crc32(4)] [ns(1)] [key_len_le(2)] [key_prefix(5)]
144 nonce[0..4].copy_from_slice(&crc_bytes);
145 nonce[4] = ns as u8;
146 nonce[5..7].copy_from_slice(&(key.len() as u16).to_le_bytes());
147 let prefix_len = key.len().min(5);
148 nonce[7..7 + prefix_len].copy_from_slice(&key[..prefix_len]);
149 nonce
150 }
151
152 pub(super) fn encrypt(
153 &self,
154 ns: Namespace,
155 key: &[u8],
156 plaintext: &[u8],
157 ) -> Result<Vec<u8>, LiteError> {
158 let nonce = Self::derive_nonce(ns, key);
159 self.cipher
160 .encrypt(&Nonce::from(nonce), plaintext)
161 .map_err(|e| LiteError::Storage {
162 detail: format!("AES-GCM encrypt failed: {e}"),
163 })
164 }
165
166 pub(super) fn decrypt(
167 &self,
168 ns: Namespace,
169 key: &[u8],
170 ciphertext: &[u8],
171 ) -> Result<Vec<u8>, LiteError> {
172 let nonce = Self::derive_nonce(ns, key);
173 self.cipher
174 .decrypt(&Nonce::from(nonce), ciphertext)
175 .map_err(|e| LiteError::Storage {
176 detail: format!("AES-GCM decrypt failed (wrong passphrase or corrupted data): {e}"),
177 })
178 }
179}