Skip to main content

writerslogic_warm/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        let bytes = load_or_create::<32>(key_path, true)?;
34        let signing_key = SigningKey::from_bytes(&bytes);
35        let verifying_key = signing_key.verifying_key();
36        Ok(Self {
37            signing_key,
38            verifying_key,
39        })
40    }
41
42    /// Sign arbitrary data, returning a 64-byte Ed25519 signature.
43    pub fn sign(&self, data: &[u8]) -> [u8; 64] {
44        self.signing_key.sign(data).to_bytes()
45    }
46
47    /// Verify a signature against data.
48    #[allow(dead_code)]
49    pub fn verify(&self, data: &[u8], signature: &[u8; 64]) -> Result<()> {
50        let sig = ed25519_dalek::Signature::from_bytes(signature);
51        self.verifying_key
52            .verify(data, &sig)
53            .map_err(|e| anyhow!("Signature verification failed: {}", e))
54    }
55
56    /// Export the public verifying key (32 bytes).
57    #[allow(dead_code)]
58    pub fn verifying_key_bytes(&self) -> [u8; 32] {
59        self.verifying_key.to_bytes()
60    }
61
62    /// Move the signing key out for use by other managers. Only consumed by the
63    /// provenance layer today, so it is dead under `security`-without-`provenance`.
64    #[allow(dead_code)]
65    pub fn into_signing_key(self) -> SigningKey {
66        self.signing_key
67    }
68}
69
70#[cfg(feature = "security")]
71fn read_fixed<const N: usize>(
72    path: &Path,
73    private: bool,
74) -> Result<Option<zeroize::Zeroizing<[u8; N]>>> {
75    use std::io::Read;
76    let mut file = match super::durable_file::open_regular(path, false) {
77        Ok(file) => file,
78        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
79        Err(error) => return Err(error.into()),
80    };
81    anyhow::ensure!(
82        file.metadata()?.len() == N as u64,
83        "invalid identity file length: expected {N} bytes"
84    );
85    #[cfg(unix)]
86    {
87        use std::os::unix::fs::PermissionsExt;
88        anyhow::ensure!(
89            !private || file.metadata()?.permissions().mode() & 0o077 == 0,
90            "signing key permissions must exclude group and other access"
91        );
92    }
93    #[cfg(not(unix))]
94    let _ = private;
95    let mut bytes = zeroize::Zeroizing::new([0; N]);
96    file.read_exact(&mut *bytes)?;
97    let mut extra = zeroize::Zeroizing::new([0; 1]);
98    anyhow::ensure!(
99        file.read(&mut *extra)? == 0,
100        "identity file changed while reading"
101    );
102    Ok(Some(bytes))
103}
104
105#[cfg(feature = "security")]
106fn load_or_create<const N: usize>(
107    path: &Path,
108    private: bool,
109) -> Result<zeroize::Zeroizing<[u8; N]>> {
110    if let Some(bytes) = read_fixed(path, private)? {
111        super::durable_file::sync_directory(
112            path.parent()
113                .filter(|p| !p.as_os_str().is_empty())
114                .unwrap_or_else(|| Path::new(".")),
115        )?;
116        return Ok(bytes);
117    }
118    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
119        super::durable_file::create_identity_parent(parent)?;
120    }
121    let mut proposed = zeroize::Zeroizing::new([0; N]);
122    secure_random(&mut *proposed)?;
123    super::durable_file::atomic_create(path, &*proposed)?;
124    read_fixed(path, private)?.ok_or_else(|| anyhow!("identity disappeared after publication"))
125}
126
127#[cfg(feature = "security")]
128fn secure_random(bytes: &mut [u8]) -> Result<()> {
129    use rand::TryRng;
130    rand::rngs::SysRng
131        .try_fill_bytes(bytes)
132        .map_err(|_| anyhow!("operating system randomness unavailable"))
133}
134
135/// The AES-256-GCM cipher behind one heap allocation: moving the manager moves a pointer, not
136/// the key schedule (whose first two round keys are the key), and the `aes-gcm/zeroize`
137/// feature zeroizes the schedule when the box drops.
138#[cfg(feature = "security")]
139pub struct EncryptionManager {
140    cipher: Box<Aes256Gcm>,
141}
142
143#[cfg(feature = "security")]
144/// Bytes of stack `scrub_stack` overwrites below its caller's frame.
145const SCRUB_BYTES: usize = 256 * 1024;
146
147/// Overwrites the stack region below the caller's frame so secrets left there by callees
148/// (Argon2 temporaries, a cipher value before it moved to the heap) do not linger. Best
149/// effort: the language guarantees nothing about frame layout, so the key-zeroization test
150/// in this module measures the result rather than assuming it.
151#[cfg(feature = "security")]
152#[inline(never)]
153pub fn scrub_stack() {
154    let mut pad = [0u64; SCRUB_BYTES / 8];
155    for word in pad.iter_mut() {
156        // SAFETY: `word` is a valid, aligned, exclusive reference into `pad`; the volatile
157        // write keeps the store from being elided as dead.
158        unsafe { std::ptr::write_volatile(word, 0) };
159    }
160    std::hint::black_box(&pad);
161}
162
163#[cfg(feature = "security")]
164impl EncryptionManager {
165    /// Derive an AES-256 key from a passphrase using Argon2id and create the cipher.
166    /// The salt is stored at `{storage_path}/encryption.salt`. If it doesn't exist,
167    /// a new random salt is generated and saved.
168    pub fn new(passphrase: &str, storage_path: &Path) -> Result<Self> {
169        use argon2::Argon2;
170
171        let salt_path = storage_path.join("encryption.salt");
172        let salt = load_or_create::<16>(&salt_path, false)?;
173
174        let mut key = zeroize::Zeroizing::new([0u8; 32]);
175        Argon2::default()
176            .hash_password_into(passphrase.as_bytes(), &*salt, &mut *key)
177            .map_err(|e| anyhow!("Argon2 key derivation failed: {}", e))?;
178
179        let cipher = Self::build_cipher(&key);
180        key.zeroize();
181        // Argon2's finalization and the cipher value before it moved into its box left copies
182        // of the key in callee frames.
183        scrub_stack();
184        Ok(Self { cipher: cipher? })
185    }
186
187    /// Builds the cipher in its own frame so the pre-box copy of the key schedule lies in
188    /// the region `scrub_stack` overwrites.
189    #[inline(never)]
190    fn build_cipher(key: &[u8; 32]) -> Result<Box<Aes256Gcm>> {
191        Ok(Box::new(
192            Aes256Gcm::new_from_slice(key)
193                .map_err(|e| anyhow!("AES-256-GCM init failed: {}", e))?,
194        ))
195    }
196
197    /// Encrypt data. Returns `[nonce:12][ciphertext+tag]`.
198    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
199        // GCM with empty associated data is exactly GCM without it.
200        self.encrypt_with_aad(plaintext, b"")
201    }
202
203    /// Like [`encrypt`](Self::encrypt), binding `aad` into the authentication tag.
204    pub fn encrypt_with_aad(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
205        use aes_gcm::aead::Payload;
206        let mut nonce_bytes = [0u8; 12];
207        secure_random(&mut nonce_bytes)?;
208        let nonce = Nonce::try_from(nonce_bytes.as_slice())
209            .map_err(|_| anyhow!("AES-256-GCM nonce construction failed"))?;
210        let ciphertext = self
211            .cipher
212            .encrypt(
213                &nonce,
214                Payload {
215                    msg: plaintext,
216                    aad,
217                },
218            )
219            .map_err(|e| anyhow!("Encryption failed: {}", e))?;
220        let mut output = Vec::with_capacity(12 + ciphertext.len());
221        output.extend_from_slice(&nonce_bytes);
222        output.extend_from_slice(&ciphertext);
223        Ok(output)
224    }
225
226    /// Decrypt data produced by `encrypt_with_aad()` with the same `aad`.
227    pub fn decrypt_with_aad(&self, encrypted: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
228        use aes_gcm::aead::Payload;
229        if encrypted.len() < 12 {
230            return Err(anyhow!(
231                "Encrypted data too short: {} bytes",
232                encrypted.len()
233            ));
234        }
235        let nonce = Nonce::try_from(&encrypted[..12])
236            .map_err(|_| anyhow!("AES-256-GCM nonce construction failed"))?;
237        self.cipher
238            .decrypt(
239                &nonce,
240                Payload {
241                    msg: &encrypted[12..],
242                    aad,
243                },
244            )
245            .map_err(|e| anyhow!("Decryption failed: {}", e))
246    }
247
248    /// Decrypt data produced by `encrypt()`. Input: `[nonce:12][ciphertext+tag]`.
249    pub fn decrypt(&self, encrypted: &[u8]) -> Result<Vec<u8>> {
250        if encrypted.len() < 12 {
251            return Err(anyhow!(
252                "Encrypted data too short: {} bytes (minimum 12 for nonce)",
253                encrypted.len()
254            ));
255        }
256        let nonce = Nonce::try_from(&encrypted[..12])
257            .map_err(|_| anyhow!("AES-256-GCM nonce construction failed"))?;
258        let ciphertext = &encrypted[12..];
259
260        self.cipher
261            .decrypt(&nonce, ciphertext)
262            .map_err(|e| anyhow!("Decryption failed: {}", e))
263    }
264}
265
266/// Hash an ID using SHA-256 for audit log entries (avoids storing raw IDs).
267#[cfg(feature = "security")]
268pub fn hash_id(id: &str) -> [u8; 32] {
269    use sha2::{Digest, Sha256};
270    let mut hasher = Sha256::new();
271    hasher.update(id.as_bytes());
272    hasher.finalize().into()
273}
274
275/// Stub for non-security builds -- hash_id using Fx hashing (non-crypto, 8 bytes zero-padded).
276#[cfg(not(feature = "security"))]
277pub fn hash_id(id: &str) -> [u8; 32] {
278    let hash = crate::core::hash::fx64(id.as_bytes()).to_le_bytes();
279    let mut out = [0u8; 32];
280    out[..8].copy_from_slice(&hash);
281    out
282}
283
284#[cfg(all(test, feature = "security"))]
285mod tests {
286    use super::*;
287    use tempfile::tempdir;
288
289    /// The AES key schedule lives in one heap allocation that is zeroized when the manager
290    /// drops: afterwards no readable heap or anonymous mapping holds the derived key. The
291    /// needle is kept XOR-masked so the test's own copy never matches, and each scan buffer
292    /// is zeroized before release so a scan cannot find its own earlier copy.
293    #[cfg(target_os = "linux")]
294    #[test]
295    fn derived_key_leaves_the_heap_when_the_manager_drops() -> Result<()> {
296        use argon2::Argon2;
297        use std::io::{Read, Seek, SeekFrom};
298
299        /// Where the masked needle occurs in the process's writable anonymous mappings, as
300        /// `range perms name +offset` lines.
301        fn heap_copies(masked: &[u8], mask: &[u8]) -> Result<Vec<String>> {
302            let maps = std::fs::read_to_string("/proc/self/maps")?;
303            let mut mem = std::fs::File::open("/proc/self/mem")?;
304            let mut hits = Vec::new();
305            for line in maps.lines() {
306                let mut parts = line.split_whitespace();
307                let (Some(range), Some(perms)) = (parts.next(), parts.next()) else {
308                    continue;
309                };
310                let name = parts.nth(3).unwrap_or("");
311                if !perms.starts_with("rw") || !(name.is_empty() || name == "[heap]") {
312                    continue;
313                }
314                let (lo, hi) = range
315                    .split_once('-')
316                    .ok_or_else(|| anyhow!("maps range {range}"))?;
317                let (lo, hi) = (u64::from_str_radix(lo, 16)?, u64::from_str_radix(hi, 16)?);
318                if hi <= lo || hi - lo > 1 << 32 {
319                    continue;
320                }
321                // Read in chunks so a page another test thread unmaps mid-scan costs one
322                // chunk, not the whole mapping; chunks overlap by the needle length so no
323                // occurrence straddles a boundary unseen.
324                const CHUNK: u64 = 1 << 16;
325                let overlap = masked.len() as u64 - 1;
326                let mut buf = vec![0u8; (CHUNK + overlap) as usize];
327                let mut at = lo;
328                while at < hi {
329                    let len = (CHUNK + overlap).min(hi - at) as usize;
330                    let chunk = &mut buf[..len];
331                    if mem.seek(SeekFrom::Start(at)).is_ok() && mem.read_exact(chunk).is_ok() {
332                        for (offset, w) in chunk.windows(masked.len()).enumerate() {
333                            if offset as u64 >= CHUNK && at + CHUNK < hi {
334                                break;
335                            }
336                            if w.iter()
337                                .zip(masked)
338                                .zip(mask)
339                                .all(|((b, n), m)| b ^ m == *n)
340                            {
341                                hits.push(format!(
342                                    "{range} {perms} {name:?} +{:#x}",
343                                    at - lo + offset as u64
344                                ));
345                            }
346                        }
347                    }
348                    chunk.zeroize();
349                    at += CHUNK;
350                }
351                buf.zeroize();
352            }
353            Ok(hits)
354        }
355
356        let dir = tempdir()?;
357        let passphrase = "zeroization probe passphrase";
358        let manager = EncryptionManager::new(passphrase, dir.path())?;
359        let salt = std::fs::read(dir.path().join("encryption.salt"))?;
360        let mask: [u8; 32] = std::array::from_fn(|i| (i as u8).wrapping_mul(0x9d) ^ 0x5a);
361        let mut key = [0u8; 32];
362        Argon2::default()
363            .hash_password_into(passphrase.as_bytes(), &salt, &mut key)
364            .map_err(|e| anyhow!("argon2: {e}"))?;
365        let masked: Vec<u8> = key.iter().zip(&mask).map(|(k, m)| k ^ m).collect();
366        key.zeroize();
367        // The test's own derivation left the key in callee frames, like the manager's did.
368        scrub_stack();
369        let ciphertext = manager.encrypt(b"payload")?;
370        // encrypt leaves key-schedule temporaries in the frames it used; like the derivation
371        // above, they are stack residue of this thread, not state the manager owns.
372        scrub_stack();
373        #[cfg(target_arch = "x86_64")]
374        if std::is_x86_feature_detected!("aes") {
375            let mut live = false;
376            for _ in 0..20 {
377                if !heap_copies(&masked, &mask)?.is_empty() {
378                    live = true;
379                    break;
380                }
381                std::thread::sleep(std::time::Duration::from_millis(50));
382            }
383            assert!(
384                live,
385                "a live AES-NI cipher keeps round key 0 (the key) in its heap schedule"
386            );
387        }
388        drop(manager);
389        let survivors = heap_copies(&masked, &mask)?;
390        assert!(
391            survivors.is_empty(),
392            "derived key survives the manager's drop at {survivors:?}"
393        );
394        assert!(!ciphertext.is_empty());
395        Ok(())
396    }
397
398    #[test]
399    fn signature_verifies_only_the_signed_data() {
400        let dir = tempdir().unwrap();
401        let key_path = dir.path().join("test.key");
402        let mgr = SigningManager::new(&key_path).unwrap();
403
404        let data = b"hello world";
405        let sig = mgr.sign(data);
406        mgr.verify(data, &sig).unwrap();
407        assert!(mgr.verify(b"tampered", &sig).is_err());
408    }
409
410    #[test]
411    fn test_signing_key_persistence() {
412        let dir = tempdir().unwrap();
413        let key_path = dir.path().join("test.key");
414
415        let vk1 = {
416            let mgr = SigningManager::new(&key_path).unwrap();
417            mgr.verifying_key_bytes()
418        };
419        let vk2 = {
420            let mgr = SigningManager::new(&key_path).unwrap();
421            mgr.verifying_key_bytes()
422        };
423        assert_eq!(vk1, vk2, "Reloaded key should match");
424    }
425
426    #[test]
427    fn concurrent_identity_creation_converges_without_replacement() {
428        let directory = tempdir().unwrap();
429        let path = directory.path().join("nested/key");
430        let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
431        let keys: Vec<_> = std::thread::scope(|scope| {
432            let handles: Vec<_> = (0..8)
433                .map(|_| {
434                    let path = &path;
435                    let barrier = &barrier;
436                    scope.spawn(move || {
437                        barrier.wait();
438                        SigningManager::new(path).unwrap().verifying_key_bytes()
439                    })
440                })
441                .collect();
442            handles.into_iter().map(|h| h.join().unwrap()).collect()
443        });
444        assert!(keys.iter().all(|key| *key == keys[0]));
445        assert_eq!(
446            SigningManager::new(&path).unwrap().verifying_key_bytes(),
447            keys[0]
448        );
449        let before = std::fs::read(&path).unwrap();
450        assert!(!super::super::durable_file::atomic_create(&path, &[0; 32]).unwrap());
451        assert_eq!(std::fs::read(&path).unwrap(), before);
452    }
453
454    #[test]
455    fn concurrent_salt_creation_produces_interoperable_ciphertexts() {
456        let directory = tempdir().unwrap();
457        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
458        let encrypted: Vec<_> = std::thread::scope(|scope| {
459            let handles: Vec<_> = (0..2)
460                .map(|_| {
461                    let directory = &directory;
462                    let barrier = &barrier;
463                    scope.spawn(move || {
464                        barrier.wait();
465                        EncryptionManager::new("shared", directory.path())
466                            .unwrap()
467                            .encrypt_with_aad(b"stored", b"domain")
468                            .unwrap()
469                    })
470                })
471                .collect();
472            handles.into_iter().map(|h| h.join().unwrap()).collect()
473        });
474        let reopened = EncryptionManager::new("shared", directory.path()).unwrap();
475        for ciphertext in &encrypted {
476            assert_eq!(
477                reopened.decrypt_with_aad(ciphertext, b"domain").unwrap(),
478                b"stored"
479            );
480            assert!(reopened
481                .decrypt_with_aad(ciphertext, b"other-domain")
482                .is_err());
483        }
484        assert_ne!(encrypted[0][..12], encrypted[1][..12]);
485    }
486
487    #[test]
488    fn identity_lengths_and_links_fail_without_rewriting_input() {
489        let directory = tempdir().unwrap();
490        let key = directory.path().join("key");
491        for size in [31, 32, 33, 1 << 20] {
492            let file = std::fs::File::create(&key).unwrap();
493            file.set_len(size).unwrap();
494            #[cfg(unix)]
495            {
496                use std::os::unix::fs::PermissionsExt;
497                file.set_permissions(std::fs::Permissions::from_mode(0o600))
498                    .unwrap();
499            }
500            assert_eq!(SigningManager::new(&key).is_ok(), size == 32);
501            assert_eq!(std::fs::metadata(&key).unwrap().len(), size);
502        }
503        let salt = directory.path().join("encryption.salt");
504        for size in [15, 16, 17] {
505            let file = std::fs::File::create(&salt).unwrap();
506            file.set_len(size).unwrap();
507            assert_eq!(read_fixed::<16>(&salt, false).is_ok(), size == 16);
508        }
509        std::fs::remove_file(&key).unwrap();
510        std::fs::create_dir(&key).unwrap();
511        assert!(SigningManager::new(&key).is_err());
512        #[cfg(unix)]
513        {
514            use std::os::unix::fs::{symlink, PermissionsExt};
515            std::fs::remove_dir(&key).unwrap();
516            symlink(&salt, &key).unwrap();
517            assert!(SigningManager::new(&key).is_err());
518            assert!(key.symlink_metadata().unwrap().file_type().is_symlink());
519            std::fs::remove_file(&key).unwrap();
520            std::fs::write(&key, [0; 32]).unwrap();
521            std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap();
522            assert!(SigningManager::new(&key).is_err());
523        }
524    }
525
526    #[test]
527    fn identity_publication_crash_child() {
528        let Some(path) = crate::compatibility::var_os("WARM_IDENTITY_TEST_PATH") else {
529            return;
530        };
531        SigningManager::new(Path::new(&path)).unwrap();
532        panic!("expected publication interruption");
533    }
534
535    #[test]
536    fn identity_reopens_after_each_publication_interruption() {
537        for phase in ["synced", "published", "durable"] {
538            let directory = tempdir().unwrap();
539            let path = directory.path().join("key");
540            let output = std::process::Command::new(std::env::current_exe().unwrap())
541                .args([
542                    "--exact",
543                    "core::security::tests::identity_publication_crash_child",
544                ])
545                .env("WARM_IDENTITY_TEST_PATH", &path)
546                .env("WARM_IDENTITY_CRASH_AT", phase)
547                .output()
548                .unwrap();
549            assert_eq!(output.status.code(), Some(84), "{phase}: {output:?}");
550            let published = std::fs::read(&path).ok();
551            assert_eq!(published.is_some(), phase != "synced");
552            let manager = SigningManager::new(&path).unwrap();
553            if let Some(bytes) = published {
554                assert_eq!(std::fs::read(&path).unwrap(), bytes);
555            }
556            let next = SigningManager::new(&path).unwrap();
557            next.verify(b"recovered", &manager.sign(b"recovered"))
558                .unwrap();
559        }
560    }
561
562    #[test]
563    fn test_encryption_roundtrip() {
564        let dir = tempdir().unwrap();
565        let mgr = EncryptionManager::new("test-passphrase", dir.path()).unwrap();
566
567        let plaintext = b"sensitive data here";
568        let encrypted = mgr.encrypt(plaintext).unwrap();
569        assert_ne!(&encrypted[12..], plaintext);
570
571        let decrypted = mgr.decrypt(&encrypted).unwrap();
572        assert_eq!(decrypted, plaintext);
573    }
574
575    #[test]
576    fn test_encryption_salt_persistence() {
577        let dir = tempdir().unwrap();
578
579        let encrypted = {
580            let mgr = EncryptionManager::new("passphrase", dir.path()).unwrap();
581            mgr.encrypt(b"test data").unwrap()
582        };
583
584        let mgr = EncryptionManager::new("passphrase", dir.path()).unwrap();
585        let decrypted = mgr.decrypt(&encrypted).unwrap();
586        assert_eq!(decrypted, b"test data");
587    }
588
589    #[test]
590    fn test_encryption_wrong_passphrase() {
591        let dir = tempdir().unwrap();
592
593        let encrypted = {
594            let mgr = EncryptionManager::new("correct", dir.path()).unwrap();
595            mgr.encrypt(b"secret").unwrap()
596        };
597        let same_salt = EncryptionManager::new("wrong", dir.path()).unwrap();
598        assert!(same_salt.decrypt(&encrypted).is_err());
599
600        // A fresh salt derives yet another key.
601        std::fs::remove_file(dir.path().join("encryption.salt")).unwrap();
602        let mgr = EncryptionManager::new("wrong", dir.path()).unwrap();
603        assert!(mgr.decrypt(&encrypted).is_err());
604    }
605
606    #[test]
607    fn test_hash_id_deterministic() {
608        let h1 = hash_id("test-id");
609        let h2 = hash_id("test-id");
610        assert_eq!(h1, h2);
611        assert_ne!(hash_id("other"), h1);
612    }
613}