1#[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 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 pub fn sign(&self, data: &[u8]) -> [u8; 64] {
67 self.signing_key.sign(data).to_bytes()
68 }
69
70 #[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 #[allow(dead_code)]
81 pub fn verifying_key_bytes(&self) -> [u8; 32] {
82 self.verifying_key.to_bytes()
83 }
84
85 #[allow(dead_code)]
88 pub fn into_signing_key(self) -> SigningKey {
89 self.signing_key
90 }
91}
92
93#[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#[cfg(feature = "security")]
118pub struct EncryptionManager {
119 cipher: Box<Aes256Gcm>,
120}
121
122#[cfg(feature = "security")]
123const SCRUB_BYTES: usize = 256 * 1024;
125
126#[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 unsafe { std::ptr::write_volatile(word, 0) };
138 }
139 std::hint::black_box(&pad);
140}
141
142#[cfg(feature = "security")]
143impl EncryptionManager {
144 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 super::durable_file::atomic_write(&salt_path, &s)?;
167 s
168 };
169
170 let mut key = [0u8; 32]; 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 scrub_stack();
180 Ok(Self { cipher: cipher? })
181 }
182
183 #[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 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 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 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 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#[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#[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 #[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 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 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 scrub_stack();
383 let ciphertext = manager.encrypt(b"payload")?;
384 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 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}