Skip to main content

holographic_memory/core/
security.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4#[cfg(feature = "security")]
5use anyhow::{anyhow, Result};
6#[cfg(feature = "security")]
7use std::path::Path;
8
9#[cfg(feature = "security")]
10use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
11#[cfg(feature = "security")]
12use zeroize::Zeroize;
13
14#[cfg(feature = "security")]
15use aes_gcm::{
16    aead::{Aead, KeyInit},
17    Aes256Gcm, Nonce,
18};
19
20#[cfg(feature = "security")]
21pub struct SigningManager {
22    signing_key: SigningKey,
23    #[allow(dead_code)]
24    verifying_key: VerifyingKey,
25}
26
27#[cfg(feature = "security")]
28impl SigningManager {
29    /// Load or generate an Ed25519 keypair.
30    /// If `key_path` exists, loads from it. Otherwise generates a new keypair
31    /// and saves it to `key_path`.
32    pub fn new(key_path: &Path) -> Result<Self> {
33        if key_path.exists() {
34            let mut key_bytes = std::fs::read(key_path)?;
35            if key_bytes.len() != 32 {
36                key_bytes.zeroize();
37                return Err(anyhow!(
38                    "Invalid signing key file: expected 32 bytes, got {}",
39                    key_bytes.len()
40                ));
41            }
42            let mut bytes: [u8; 32] = key_bytes[..32].try_into().expect("length checked above");
43            key_bytes.zeroize();
44            let signing_key = SigningKey::from_bytes(&bytes);
45            bytes.zeroize();
46            let verifying_key = signing_key.verifying_key();
47            Ok(Self {
48                signing_key,
49                verifying_key,
50            })
51        } else {
52            let signing_key = SigningKey::from_bytes(&rand::random());
53            let verifying_key = signing_key.verifying_key();
54            if let Some(parent) = key_path.parent() {
55                std::fs::create_dir_all(parent)?;
56            }
57            write_private_key(key_path, &signing_key)?;
58            Ok(Self {
59                signing_key,
60                verifying_key,
61            })
62        }
63    }
64
65    /// Sign arbitrary data, returning a 64-byte Ed25519 signature.
66    pub fn sign(&self, data: &[u8]) -> [u8; 64] {
67        self.signing_key.sign(data).to_bytes()
68    }
69
70    /// Verify a signature against data.
71    #[allow(dead_code)]
72    pub fn verify(&self, data: &[u8], signature: &[u8; 64]) -> Result<()> {
73        let sig = ed25519_dalek::Signature::from_bytes(signature);
74        self.verifying_key
75            .verify(data, &sig)
76            .map_err(|e| anyhow!("Signature verification failed: {}", e))
77    }
78
79    /// Export the public verifying key (32 bytes).
80    #[allow(dead_code)]
81    pub fn verifying_key_bytes(&self) -> [u8; 32] {
82        self.verifying_key.to_bytes()
83    }
84
85    /// Move the signing key out for use by other managers. Only consumed by the
86    /// provenance layer today, so it is dead under `security`-without-`provenance`.
87    #[allow(dead_code)]
88    pub fn into_signing_key(self) -> SigningKey {
89        self.signing_key
90    }
91}
92
93/// Write an Ed25519 private key to disk. On unix the file is created with
94/// owner-only permissions (0600) so it is not world-readable on shared hosts;
95/// the serialized key bytes are zeroized immediately after writing.
96#[cfg(feature = "security")]
97fn write_private_key(key_path: &Path, signing_key: &SigningKey) -> Result<()> {
98    use std::io::Write;
99
100    let mut opts = std::fs::OpenOptions::new();
101    opts.write(true).create(true).truncate(true);
102    #[cfg(unix)]
103    {
104        use std::os::unix::fs::OpenOptionsExt;
105        opts.mode(0o600);
106    }
107    let mut file = opts.open(key_path)?;
108    let mut bytes = signing_key.to_bytes();
109    let result = file.write_all(&bytes);
110    bytes.zeroize();
111    result.map_err(|e| anyhow!("failed to write signing key: {e}"))
112}
113
114/// The AES-256-GCM cipher behind one heap allocation: moving the manager moves a pointer, not
115/// the key schedule (whose first two round keys are the key), and the `aes-gcm/zeroize`
116/// feature zeroizes the schedule when the box drops.
117#[cfg(feature = "security")]
118pub struct EncryptionManager {
119    cipher: Box<Aes256Gcm>,
120}
121
122#[cfg(feature = "security")]
123/// Bytes of stack `scrub_stack` overwrites below its caller's frame.
124const SCRUB_BYTES: usize = 256 * 1024;
125
126/// Overwrites the stack region below the caller's frame so secrets left there by callees
127/// (Argon2 temporaries, a cipher value before it moved to the heap) do not linger. Best
128/// effort: the language guarantees nothing about frame layout, so the key-zeroization test
129/// in this module measures the result rather than assuming it.
130#[cfg(feature = "security")]
131#[inline(never)]
132pub fn scrub_stack() {
133    let mut pad = [0u64; SCRUB_BYTES / 8];
134    for word in pad.iter_mut() {
135        // SAFETY: `word` is a valid, aligned, exclusive reference into `pad`; the volatile
136        // write keeps the store from being elided as dead.
137        unsafe { std::ptr::write_volatile(word, 0) };
138    }
139    std::hint::black_box(&pad);
140}
141
142#[cfg(feature = "security")]
143impl EncryptionManager {
144    /// Derive an AES-256 key from a passphrase using Argon2id and create the cipher.
145    /// The salt is stored at `{storage_path}/encryption.salt`. If it doesn't exist,
146    /// a new random salt is generated and saved.
147    pub fn new(passphrase: &str, storage_path: &Path) -> Result<Self> {
148        use argon2::Argon2;
149
150        let salt_path = storage_path.join("encryption.salt");
151        let salt = if salt_path.exists() {
152            let s = std::fs::read(&salt_path)?;
153            if s.len() != 16 {
154                return Err(anyhow!(
155                    "Invalid salt file: expected 16 bytes, got {}",
156                    s.len()
157                ));
158            }
159            s
160        } else {
161            let mut s = vec![0u8; 16];
162            use rand::Rng;
163            rand::rng().fill_bytes(&mut s);
164            // Durable before any frame is encrypted under it: a salt lost to a crash would
165            // make every frame written with the derived key unreadable.
166            super::durable_file::atomic_write(&salt_path, &s)?;
167            s
168        };
169
170        let mut key = [0u8; 32]; // codeql[rust/hard-coded-cryptographic-value] False positive: buffer is overwritten by Argon2
171        Argon2::default()
172            .hash_password_into(passphrase.as_bytes(), &salt, &mut key)
173            .map_err(|e| anyhow!("Argon2 key derivation failed: {}", e))?;
174
175        let cipher = Self::build_cipher(&key);
176        key.zeroize();
177        // Argon2's finalization and the cipher value before it moved into its box left copies
178        // of the key in callee frames.
179        scrub_stack();
180        Ok(Self { cipher: cipher? })
181    }
182
183    /// Builds the cipher in its own frame so the pre-box copy of the key schedule lies in
184    /// the region `scrub_stack` overwrites.
185    #[inline(never)]
186    fn build_cipher(key: &[u8; 32]) -> Result<Box<Aes256Gcm>> {
187        Ok(Box::new(
188            Aes256Gcm::new_from_slice(key)
189                .map_err(|e| anyhow!("AES-256-GCM init failed: {}", e))?,
190        ))
191    }
192
193    /// Encrypt data. Returns `[nonce:12][ciphertext+tag]`.
194    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
195        let mut nonce_bytes = [0u8; 12];
196        use rand::Rng;
197        rand::rng().fill_bytes(&mut nonce_bytes);
198        let nonce = Nonce::try_from(nonce_bytes.as_slice())
199            .map_err(|_| anyhow!("AES-256-GCM nonce construction failed"))?;
200
201        let ciphertext = self
202            .cipher
203            .encrypt(&nonce, plaintext)
204            .map_err(|e| anyhow!("Encryption failed: {}", e))?;
205
206        let mut output = Vec::with_capacity(12 + ciphertext.len());
207        output.extend_from_slice(&nonce_bytes);
208        output.extend_from_slice(&ciphertext);
209        Ok(output)
210    }
211
212    /// Like [`encrypt`](Self::encrypt), binding `aad` into the authentication tag.
213    pub fn encrypt_with_aad(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
214        use aes_gcm::aead::Payload;
215        let mut nonce_bytes = [0u8; 12];
216        use rand::Rng;
217        rand::rng().fill_bytes(&mut nonce_bytes);
218        let nonce = Nonce::try_from(nonce_bytes.as_slice())
219            .map_err(|_| anyhow!("AES-256-GCM nonce construction failed"))?;
220        let ciphertext = self
221            .cipher
222            .encrypt(
223                &nonce,
224                Payload {
225                    msg: plaintext,
226                    aad,
227                },
228            )
229            .map_err(|e| anyhow!("Encryption failed: {}", e))?;
230        let mut output = Vec::with_capacity(12 + ciphertext.len());
231        output.extend_from_slice(&nonce_bytes);
232        output.extend_from_slice(&ciphertext);
233        Ok(output)
234    }
235
236    /// Decrypt data produced by `encrypt_with_aad()` with the same `aad`.
237    pub fn decrypt_with_aad(&self, encrypted: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
238        use aes_gcm::aead::Payload;
239        if encrypted.len() < 12 {
240            return Err(anyhow!(
241                "Encrypted data too short: {} bytes",
242                encrypted.len()
243            ));
244        }
245        let nonce = Nonce::try_from(&encrypted[..12])
246            .map_err(|_| anyhow!("AES-256-GCM nonce construction failed"))?;
247        self.cipher
248            .decrypt(
249                &nonce,
250                Payload {
251                    msg: &encrypted[12..],
252                    aad,
253                },
254            )
255            .map_err(|e| anyhow!("Decryption failed: {}", e))
256    }
257
258    /// Decrypt data produced by `encrypt()`. Input: `[nonce:12][ciphertext+tag]`.
259    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>> {
260        if encrypted.len() < 12 {
261            return Err(anyhow!(
262                "Encrypted data too short: {} bytes (minimum 12 for nonce)",
263                encrypted.len()
264            ));
265        }
266        let nonce = Nonce::try_from(&encrypted[..12])
267            .map_err(|_| anyhow!("AES-256-GCM nonce construction failed"))?;
268        let ciphertext = &encrypted[12..];
269
270        self.cipher
271            .decrypt(&nonce, ciphertext)
272            .map_err(|e| anyhow!("Decryption failed: {}", e))
273    }
274}
275
276/// Hash an ID using SHA-256 for audit log entries (avoids storing raw IDs).
277#[cfg(feature = "security")]
278pub fn hash_id(id: &str) -> [u8; 32] {
279    use sha2::{Digest, Sha256};
280    let mut hasher = Sha256::new();
281    hasher.update(id.as_bytes());
282    hasher.finalize().into()
283}
284
285/// Stub for non-security builds -- hash_id using the built-in FxHash (non-crypto, 8 bytes zero-padded).
286#[cfg(not(feature = "security"))]
287pub fn hash_id(id: &str) -> [u8; 32] {
288    use fxhash::FxHasher;
289    use std::hash::Hasher;
290    let mut h = FxHasher::default();
291    h.write(id.as_bytes());
292    let hash = h.finish().to_le_bytes();
293    let mut out = [0u8; 32];
294    out[..8].copy_from_slice(&hash);
295    out
296}
297
298#[cfg(all(test, feature = "security"))]
299mod tests {
300    use super::*;
301    use tempfile::tempdir;
302
303    /// The AES key schedule lives in one heap allocation that is zeroized when the manager
304    /// drops: afterwards no readable heap or anonymous mapping holds the derived key. The
305    /// needle is kept XOR-masked so the test's own copy never matches, and each scan buffer
306    /// is zeroized before release so a scan cannot find its own earlier copy.
307    #[cfg(target_os = "linux")]
308    #[test]
309    fn derived_key_leaves_the_heap_when_the_manager_drops() -> Result<()> {
310        use argon2::Argon2;
311        use std::io::{Read, Seek, SeekFrom};
312
313        /// Where the masked needle occurs in the process's writable anonymous mappings, as
314        /// `range perms name +offset` lines.
315        fn heap_copies(masked: &[u8], mask: &[u8]) -> Result<Vec<String>> {
316            let maps = std::fs::read_to_string("/proc/self/maps")?;
317            let mut mem = std::fs::File::open("/proc/self/mem")?;
318            let mut hits = Vec::new();
319            for line in maps.lines() {
320                let mut parts = line.split_whitespace();
321                let (Some(range), Some(perms)) = (parts.next(), parts.next()) else {
322                    continue;
323                };
324                let name = parts.nth(3).unwrap_or("");
325                if !perms.starts_with("rw") || !(name.is_empty() || name == "[heap]") {
326                    continue;
327                }
328                let (lo, hi) = range
329                    .split_once('-')
330                    .ok_or_else(|| anyhow!("maps range {range}"))?;
331                let (lo, hi) = (u64::from_str_radix(lo, 16)?, u64::from_str_radix(hi, 16)?);
332                if hi <= lo || hi - lo > 1 << 32 {
333                    continue;
334                }
335                // Read in chunks so a page another test thread unmaps mid-scan costs one
336                // chunk, not the whole mapping; chunks overlap by the needle length so no
337                // occurrence straddles a boundary unseen.
338                const CHUNK: u64 = 1 << 16;
339                let overlap = masked.len() as u64 - 1;
340                let mut buf = vec![0u8; (CHUNK + overlap) as usize];
341                let mut at = lo;
342                while at < hi {
343                    let len = (CHUNK + overlap).min(hi - at) as usize;
344                    let chunk = &mut buf[..len];
345                    if mem.seek(SeekFrom::Start(at)).is_ok() && mem.read_exact(chunk).is_ok() {
346                        for (offset, w) in chunk.windows(masked.len()).enumerate() {
347                            if offset as u64 >= CHUNK && at + CHUNK < hi {
348                                break;
349                            }
350                            if w.iter()
351                                .zip(masked)
352                                .zip(mask)
353                                .all(|((b, n), m)| b ^ m == *n)
354                            {
355                                hits.push(format!(
356                                    "{range} {perms} {name:?} +{:#x}",
357                                    at - lo + offset as u64
358                                ));
359                            }
360                        }
361                    }
362                    chunk.zeroize();
363                    at += CHUNK;
364                }
365                buf.zeroize();
366            }
367            Ok(hits)
368        }
369
370        let dir = tempdir()?;
371        let passphrase = "zeroization probe passphrase";
372        let manager = EncryptionManager::new(passphrase, dir.path())?;
373        let salt = std::fs::read(dir.path().join("encryption.salt"))?;
374        let mask: [u8; 32] = std::array::from_fn(|i| (i as u8).wrapping_mul(0x9d) ^ 0x5a);
375        let mut key = [0u8; 32];
376        Argon2::default()
377            .hash_password_into(passphrase.as_bytes(), &salt, &mut key)
378            .map_err(|e| anyhow!("argon2: {e}"))?;
379        let masked: Vec<u8> = key.iter().zip(&mask).map(|(k, m)| k ^ m).collect();
380        key.zeroize();
381        // The test's own derivation left the key in callee frames, like the manager's did.
382        scrub_stack();
383        let ciphertext = manager.encrypt(b"payload")?;
384        // encrypt leaves key-schedule temporaries in the frames it used; like the derivation
385        // above, they are stack residue of this thread, not state the manager owns.
386        scrub_stack();
387        #[cfg(target_arch = "x86_64")]
388        if std::is_x86_feature_detected!("aes") {
389            let mut live = false;
390            for _ in 0..20 {
391                if !heap_copies(&masked, &mask)?.is_empty() {
392                    live = true;
393                    break;
394                }
395                std::thread::sleep(std::time::Duration::from_millis(50));
396            }
397            assert!(
398                live,
399                "a live AES-NI cipher keeps round key 0 (the key) in its heap schedule"
400            );
401        }
402        drop(manager);
403        let survivors = heap_copies(&masked, &mask)?;
404        assert!(
405            survivors.is_empty(),
406            "derived key survives the manager's drop at {survivors:?}"
407        );
408        assert!(!ciphertext.is_empty());
409        Ok(())
410    }
411
412    #[test]
413    fn test_signing_roundtrip() {
414        let dir = tempdir().unwrap();
415        let key_path = dir.path().join("test.key");
416        let mgr = SigningManager::new(&key_path).unwrap();
417
418        let data = b"hello world";
419        let sig = mgr.sign(data);
420        mgr.verify(data, &sig).unwrap();
421    }
422
423    #[test]
424    fn test_signing_tamper_detection() {
425        let dir = tempdir().unwrap();
426        let key_path = dir.path().join("test.key");
427        let mgr = SigningManager::new(&key_path).unwrap();
428
429        let data = b"hello world";
430        let sig = mgr.sign(data);
431        assert!(mgr.verify(b"tampered", &sig).is_err());
432    }
433
434    #[test]
435    fn test_signing_key_persistence() {
436        let dir = tempdir().unwrap();
437        let key_path = dir.path().join("test.key");
438
439        let vk1 = {
440            let mgr = SigningManager::new(&key_path).unwrap();
441            mgr.verifying_key_bytes()
442        };
443        let vk2 = {
444            let mgr = SigningManager::new(&key_path).unwrap();
445            mgr.verifying_key_bytes()
446        };
447        assert_eq!(vk1, vk2, "Reloaded key should match");
448    }
449
450    #[test]
451    fn test_encryption_roundtrip() {
452        let dir = tempdir().unwrap();
453        let mgr = EncryptionManager::new("test-passphrase", dir.path()).unwrap();
454
455        let plaintext = b"sensitive data here";
456        let encrypted = mgr.encrypt(plaintext).unwrap();
457        assert_ne!(&encrypted[12..], plaintext);
458
459        let decrypted = mgr.decrypt(&encrypted).unwrap();
460        assert_eq!(decrypted, plaintext);
461    }
462
463    #[test]
464    fn test_encryption_salt_persistence() {
465        let dir = tempdir().unwrap();
466
467        let encrypted = {
468            let mgr = EncryptionManager::new("passphrase", dir.path()).unwrap();
469            mgr.encrypt(b"test data").unwrap()
470        };
471
472        let mgr = EncryptionManager::new("passphrase", dir.path()).unwrap();
473        let decrypted = mgr.decrypt(&encrypted).unwrap();
474        assert_eq!(decrypted, b"test data");
475    }
476
477    #[test]
478    fn test_encryption_wrong_passphrase() {
479        let dir = tempdir().unwrap();
480
481        let encrypted = {
482            let mgr = EncryptionManager::new("correct", dir.path()).unwrap();
483            mgr.encrypt(b"secret").unwrap()
484        };
485
486        // Remove salt so second manager generates fresh key
487        std::fs::remove_file(dir.path().join("encryption.salt")).unwrap();
488        let mgr = EncryptionManager::new("wrong", dir.path()).unwrap();
489        assert!(mgr.decrypt(&encrypted).is_err());
490    }
491
492    #[test]
493    fn test_hash_id_deterministic() {
494        let h1 = hash_id("test-id");
495        let h2 = hash_id("test-id");
496        assert_eq!(h1, h2);
497        assert_ne!(hash_id("other"), h1);
498    }
499}