Skip to main content

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}