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 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 pub fn sign(&self, data: &[u8]) -> [u8; 64] {
44 self.signing_key.sign(data).to_bytes()
45 }
46
47 #[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 #[allow(dead_code)]
58 pub fn verifying_key_bytes(&self) -> [u8; 32] {
59 self.verifying_key.to_bytes()
60 }
61
62 #[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#[cfg(feature = "security")]
139pub struct EncryptionManager {
140 cipher: Box<Aes256Gcm>,
141}
142
143#[cfg(feature = "security")]
144const SCRUB_BYTES: usize = 256 * 1024;
146
147#[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 unsafe { std::ptr::write_volatile(word, 0) };
159 }
160 std::hint::black_box(&pad);
161}
162
163#[cfg(feature = "security")]
164impl EncryptionManager {
165 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 scrub_stack();
184 Ok(Self { cipher: cipher? })
185 }
186
187 #[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 pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
199 self.encrypt_with_aad(plaintext, b"")
201 }
202
203 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 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 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#[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#[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 #[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 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 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 scrub_stack();
369 let ciphertext = manager.encrypt(b"payload")?;
370 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 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}