1use std::sync::Arc;
20
21use aes_gcm::Aes256Gcm;
22use aes_gcm::aead::{Aead, KeyInit};
23
24use crate::error::{Result, WalError};
25use crate::record::HEADER_SIZE;
26use crate::secure_mem::SecureKey;
27
28pub use crate::segment_envelope::{
29 SEGMENT_ENVELOPE_DATA_KEY_ENCRYPTION_BUDGET, SEGMENT_ENVELOPE_MAX_PLAINTEXT_BYTES,
30 SEGMENT_ENVELOPE_MIN_SIZE, SEGMENT_ENVELOPE_PREAMBLE_SIZE, decrypt_segment_envelope,
31 encrypt_segment_envelope,
32};
33
34fn check_key_file_wal(path: &std::path::Path) -> Result<()> {
41 let symlink_meta = std::fs::symlink_metadata(path).map_err(|e| WalError::EncryptionError {
42 detail: format!("cannot stat WAL key file {}: {e}", path.display()),
43 })?;
44
45 if symlink_meta.file_type().is_symlink() {
46 return Err(WalError::EncryptionError {
47 detail: format!(
48 "WAL key file {} is a symlink, which is not permitted \
49 (path traversal / TOCTOU risk)",
50 path.display()
51 ),
52 });
53 }
54
55 if !symlink_meta.is_file() {
56 return Err(WalError::EncryptionError {
57 detail: format!("WAL key file {} is not a regular file", path.display()),
58 });
59 }
60
61 #[cfg(unix)]
62 {
63 use std::os::unix::fs::MetadataExt as _;
64
65 let mode = symlink_meta.mode();
66 if mode & 0o077 != 0 {
67 return Err(WalError::EncryptionError {
68 detail: format!(
69 "WAL key file {} has insecure permissions: 0o{:03o} \
70 (must be 0o400 or 0o600 — no group or world access)",
71 path.display(),
72 mode & 0o777,
73 ),
74 });
75 }
76
77 let file_uid = symlink_meta.uid();
78 let process_uid = unsafe { libc::geteuid() };
80 if file_uid != process_uid {
81 return Err(WalError::EncryptionError {
82 detail: format!(
83 "WAL key file {} is owned by UID {} but process runs as UID {} \
84 — key files must be owned by the server process user",
85 path.display(),
86 file_uid,
87 process_uid,
88 ),
89 });
90 }
91 }
92
93 Ok(())
97}
98
99#[derive(Clone)]
105pub struct WalEncryptionKey {
106 cipher: Aes256Gcm,
107 key: Arc<SecureKey>,
113 epoch: [u8; 4],
116}
117
118impl WalEncryptionKey {
119 pub fn from_bytes(key: &[u8; 32]) -> Result<Self> {
129 Ok(Self {
130 cipher: Aes256Gcm::new(key.into()),
131 key: Arc::new(SecureKey::new(*key)),
132 epoch: random_epoch()?,
133 })
134 }
135
136 pub fn with_epoch(key: &[u8; 32], epoch: [u8; 4]) -> Self {
142 Self {
143 cipher: Aes256Gcm::new(key.into()),
144 key: Arc::new(SecureKey::new(*key)),
145 epoch,
146 }
147 }
148
149 pub fn with_fresh_epoch(&self) -> Result<Self> {
156 Ok(Self {
157 cipher: self.cipher.clone(),
158 key: Arc::clone(&self.key),
159 epoch: random_epoch()?,
160 })
161 }
162
163 pub fn from_file(path: &std::path::Path) -> Result<Self> {
171 check_key_file_wal(path)?;
172 let key_bytes = std::fs::read(path).map_err(WalError::Io)?;
173 if key_bytes.len() != 32 {
174 return Err(WalError::EncryptionError {
175 detail: format!(
176 "encryption key must be exactly 32 bytes, got {}",
177 key_bytes.len()
178 ),
179 });
180 }
181 let mut key_arr = zeroize::Zeroizing::new([0u8; 32]);
182 key_arr.copy_from_slice(&key_bytes);
183 Self::from_bytes(&key_arr)
184 }
185
186 pub fn encrypt(
192 &self,
193 lsn: u64,
194 header_bytes: &[u8; HEADER_SIZE],
195 plaintext: &[u8],
196 ) -> Result<Vec<u8>> {
197 self.encrypt_aad(lsn, header_bytes, plaintext)
198 }
199
200 pub fn encrypt_aad(&self, lsn: u64, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
203 let nonce = lsn_to_nonce(&self.epoch, lsn);
204 self.cipher
205 .encrypt(
206 &nonce,
207 aes_gcm::aead::Payload {
208 msg: plaintext,
209 aad,
210 },
211 )
212 .map_err(|_| WalError::EncryptionError {
213 detail: "AES-256-GCM encryption failed".into(),
214 })
215 }
216
217 pub fn epoch(&self) -> &[u8; 4] {
219 &self.epoch
220 }
221
222 pub(crate) fn key_bytes(&self) -> &[u8; 32] {
223 self.key.as_bytes()
224 }
225
226 pub fn derive_subkey(&self, domain: &[u8]) -> Result<[u8; 32]> {
231 let hkdf =
232 hkdf::Hkdf::<sha2::Sha256>::new(Some(b"nodedb-wal-subkey-v1"), self.key.as_bytes());
233 let mut output = [0u8; 32];
234 hkdf.expand(domain, &mut output)
235 .map_err(|_| WalError::EncryptionError {
236 detail: "WAL subkey derivation failed".into(),
237 })?;
238 Ok(output)
239 }
240
241 pub fn decrypt(
250 &self,
251 epoch: &[u8; 4],
252 lsn: u64,
253 header_bytes: &[u8; HEADER_SIZE],
254 ciphertext: &[u8],
255 ) -> Result<Vec<u8>> {
256 self.decrypt_aad(epoch, lsn, header_bytes, ciphertext)
257 }
258
259 pub fn decrypt_aad(
261 &self,
262 epoch: &[u8; 4],
263 lsn: u64,
264 aad: &[u8],
265 ciphertext: &[u8],
266 ) -> Result<Vec<u8>> {
267 let nonce = lsn_to_nonce(epoch, lsn);
268 self.cipher
269 .decrypt(
270 &nonce,
271 aes_gcm::aead::Payload {
272 msg: ciphertext,
273 aad,
274 },
275 )
276 .map_err(|_| WalError::EncryptionError {
277 detail: "AES-256-GCM decryption failed (corrupted or wrong key)".into(),
278 })
279 }
280}
281
282#[derive(Clone)]
288pub struct KeyRing {
289 current: WalEncryptionKey,
290 previous: Option<WalEncryptionKey>,
291}
292
293impl KeyRing {
294 pub fn new(current: WalEncryptionKey) -> Self {
296 Self {
297 current,
298 previous: None,
299 }
300 }
301
302 pub fn with_previous(current: WalEncryptionKey, previous: WalEncryptionKey) -> Self {
304 Self {
305 current,
306 previous: Some(previous),
307 }
308 }
309
310 pub fn encrypt(
312 &self,
313 lsn: u64,
314 header_bytes: &[u8; HEADER_SIZE],
315 plaintext: &[u8],
316 ) -> Result<Vec<u8>> {
317 self.current.encrypt(lsn, header_bytes, plaintext)
318 }
319
320 pub fn encrypt_aad(&self, lsn: u64, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
322 self.current.encrypt_aad(lsn, aad, plaintext)
323 }
324
325 pub fn decrypt(
331 &self,
332 epoch: &[u8; 4],
333 lsn: u64,
334 header_bytes: &[u8; HEADER_SIZE],
335 ciphertext: &[u8],
336 ) -> Result<Vec<u8>> {
337 self.decrypt_aad(epoch, lsn, header_bytes, ciphertext)
338 }
339
340 pub fn decrypt_aad(
342 &self,
343 epoch: &[u8; 4],
344 lsn: u64,
345 aad: &[u8],
346 ciphertext: &[u8],
347 ) -> Result<Vec<u8>> {
348 match (
349 self.current.decrypt_aad(epoch, lsn, aad, ciphertext),
350 self.previous.as_ref(),
351 ) {
352 (Ok(plaintext), _) => Ok(plaintext),
353 (Err(_), Some(prev)) => prev.decrypt_aad(epoch, lsn, aad, ciphertext),
354 (Err(e), None) => Err(e),
355 }
356 }
357
358 pub fn current(&self) -> &WalEncryptionKey {
360 &self.current
361 }
362
363 pub fn previous(&self) -> Option<&WalEncryptionKey> {
365 self.previous.as_ref()
366 }
367
368 pub fn has_previous(&self) -> bool {
370 self.previous.is_some()
371 }
372
373 pub fn clear_previous(&mut self) {
375 self.previous = None;
376 }
377}
378
379pub const AUTH_TAG_SIZE: usize = 16;
381
382fn random_epoch() -> Result<[u8; 4]> {
388 let mut epoch = [0u8; 4];
389 getrandom::fill(&mut epoch).map_err(|e| WalError::EncryptionError {
390 detail: format!("getrandom failed while generating epoch: {e}"),
391 })?;
392 Ok(epoch)
393}
394
395fn lsn_to_nonce(epoch: &[u8; 4], lsn: u64) -> aes_gcm::Nonce<aes_gcm::aead::consts::U12> {
402 let [e0, e1, e2, e3] = *epoch;
403 let [l0, l1, l2, l3, l4, l5, l6, l7] = lsn.to_le_bytes();
404 let nonce_bytes: [u8; 12] = [e0, e1, e2, e3, l0, l1, l2, l3, l4, l5, l6, l7];
405 nonce_bytes.into()
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn test_key() -> WalEncryptionKey {
413 WalEncryptionKey::from_bytes(&[0x42u8; 32]).unwrap()
414 }
415
416 fn test_header(lsn: u64) -> [u8; HEADER_SIZE] {
417 let mut h = [0u8; HEADER_SIZE];
418 h[8..16].copy_from_slice(&lsn.to_le_bytes());
419 h
420 }
421
422 #[test]
423 fn encrypt_decrypt_roundtrip() {
424 let key = test_key();
425 let epoch = *key.epoch();
426 let header = test_header(1);
427 let plaintext = b"hello nodedb encryption";
428
429 let ciphertext = key.encrypt(1, &header, plaintext).unwrap();
430 assert_ne!(&ciphertext[..plaintext.len()], plaintext);
431 assert_eq!(ciphertext.len(), plaintext.len() + AUTH_TAG_SIZE);
432
433 let decrypted = key.decrypt(&epoch, 1, &header, &ciphertext).unwrap();
434 assert_eq!(decrypted, plaintext);
435 }
436
437 #[test]
438 fn wrong_key_fails() {
439 let key1 = WalEncryptionKey::from_bytes(&[0x01; 32]).unwrap();
440 let epoch1 = *key1.epoch();
441 let key2 = WalEncryptionKey::from_bytes(&[0x02; 32]).unwrap();
442 let header = test_header(1);
443
444 let ciphertext = key1.encrypt(1, &header, b"secret").unwrap();
445 assert!(key2.decrypt(&epoch1, 1, &header, &ciphertext).is_err());
446 }
447
448 #[test]
449 fn wrong_lsn_fails() {
450 let key = test_key();
451 let epoch = *key.epoch();
452 let header = test_header(1);
453
454 let ciphertext = key.encrypt(1, &header, b"secret").unwrap();
455 assert!(key.decrypt(&epoch, 2, &header, &ciphertext).is_err());
457 }
458
459 #[test]
460 fn tampered_ciphertext_fails() {
461 let key = test_key();
462 let epoch = *key.epoch();
463 let header = test_header(1);
464
465 let mut ciphertext = key.encrypt(1, &header, b"secret").unwrap();
466 ciphertext[0] ^= 0xFF;
467 assert!(key.decrypt(&epoch, 1, &header, &ciphertext).is_err());
468 }
469
470 #[test]
471 fn tampered_header_fails() {
472 let key = test_key();
473 let epoch = *key.epoch();
474 let header1 = test_header(1);
475
476 let ciphertext = key.encrypt(1, &header1, b"secret").unwrap();
477
478 let mut header2 = header1;
480 header2[0] = 0xFF;
481 assert!(key.decrypt(&epoch, 1, &header2, &ciphertext).is_err());
482 }
483
484 #[test]
485 fn empty_payload() {
486 let key = test_key();
487 let epoch = *key.epoch();
488 let header = test_header(1);
489
490 let ciphertext = key.encrypt(1, &header, b"").unwrap();
491 assert_eq!(ciphertext.len(), AUTH_TAG_SIZE); let decrypted = key.decrypt(&epoch, 1, &header, &ciphertext).unwrap();
494 assert!(decrypted.is_empty());
495 }
496
497 #[test]
498 fn different_lsns_produce_different_ciphertext() {
499 let key = test_key();
500 let plaintext = b"same payload";
501
502 let ct1 = key.encrypt(1, &test_header(1), plaintext).unwrap();
503 let ct2 = key.encrypt(2, &test_header(2), plaintext).unwrap();
504 assert_ne!(ct1, ct2);
505 }
506
507 #[test]
508 fn nonce_construction_is_injective_for_bounded_generated_wal_pairs() {
509 use std::collections::HashSet;
510
511 const GENERATED_PAIR_BUDGET: usize = 4_096;
512
513 let mut pairs = vec![
518 ([0x00; 4], 0),
519 ([0x00; 4], 1),
520 ([0x00; 4], u32::MAX as u64),
521 ([0x00; 4], u32::MAX as u64 + 1),
522 ([0x00; 4], u64::MAX),
523 ([0xff; 4], 0),
524 ([0xff; 4], u64::MAX),
525 ([0x01, 0x00, 0x00, 0x00], 0),
526 ([0x00, 0x01, 0x00, 0x00], 0),
527 ];
528 let mut state = 0xD1B5_4A32_D192_ED03u64;
529 for _ in 0..GENERATED_PAIR_BUDGET {
530 state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
534 let epoch = (state ^ (state >> 32)).to_le_bytes()[..4]
535 .try_into()
536 .expect("four-byte epoch");
537 state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
538 let lsn = state ^ (state >> 30);
539 pairs.push((epoch, lsn));
540 }
541
542 let mut distinct_pairs = HashSet::with_capacity(pairs.len());
543 let mut distinct_nonces = HashSet::with_capacity(pairs.len());
544 for (epoch, lsn) in pairs {
545 assert!(
546 distinct_pairs.insert((epoch, lsn)),
547 "generated corpus must contain only distinct (epoch, LSN) pairs"
548 );
549 let nonce = lsn_to_nonce(&epoch, lsn);
550 let mut nonce_bytes = [0u8; 12];
551 nonce_bytes.copy_from_slice(&nonce);
552 assert!(
553 distinct_nonces.insert(nonce_bytes),
554 "distinct (epoch, LSN) pairs must not share a 96-bit nonce"
555 );
556 }
557 assert_eq!(distinct_nonces.len(), distinct_pairs.len());
558 }
559
560 #[test]
561 #[cfg(unix)]
562 fn from_file_0o600_accepted() {
563 use std::io::Write as _;
564 use std::os::unix::fs::PermissionsExt as _;
565
566 let dir = tempfile::tempdir().unwrap();
567 let path = dir.path().join("key.bin");
568 let mut f = std::fs::File::create(&path).unwrap();
569 f.write_all(&[0x42u8; 32]).unwrap();
570 drop(f);
571 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
572
573 WalEncryptionKey::from_file(&path).expect("0o600 key file must be accepted");
574 }
575
576 #[test]
577 #[cfg(unix)]
578 fn from_file_0o400_accepted() {
579 use std::io::Write as _;
580 use std::os::unix::fs::PermissionsExt as _;
581
582 let dir = tempfile::tempdir().unwrap();
583 let path = dir.path().join("key.bin");
584 let mut f = std::fs::File::create(&path).unwrap();
585 f.write_all(&[0x42u8; 32]).unwrap();
586 drop(f);
587 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
588
589 WalEncryptionKey::from_file(&path).expect("0o400 key file must be accepted");
590 }
591
592 #[test]
593 #[cfg(unix)]
594 fn from_file_0o644_rejected() {
595 use std::io::Write as _;
596 use std::os::unix::fs::PermissionsExt as _;
597
598 let dir = tempfile::tempdir().unwrap();
599 let path = dir.path().join("key.bin");
600 let mut f = std::fs::File::create(&path).unwrap();
601 f.write_all(&[0x42u8; 32]).unwrap();
602 drop(f);
603 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
604
605 let err = match WalEncryptionKey::from_file(&path) {
606 Ok(_) => panic!("expected insecure-permissions error, got Ok"),
607 Err(e) => e,
608 };
609 let detail = format!("{err:?}");
610 assert!(
611 detail.contains("insecure") || detail.contains("644"),
612 "expected insecure-permissions error, got: {detail}"
613 );
614 }
615
616 #[test]
617 #[cfg(unix)]
618 fn from_file_symlink_rejected() {
619 use std::io::Write as _;
620 use std::os::unix::fs::PermissionsExt as _;
621
622 let dir = tempfile::tempdir().unwrap();
623 let target = dir.path().join("target.bin");
624 let mut f = std::fs::File::create(&target).unwrap();
625 f.write_all(&[0x42u8; 32]).unwrap();
626 drop(f);
627 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
628
629 let link = dir.path().join("link.bin");
630 std::os::unix::fs::symlink(&target, &link).unwrap();
631
632 let err = match WalEncryptionKey::from_file(&link) {
633 Ok(_) => panic!("expected symlink rejection, got Ok"),
634 Err(e) => e,
635 };
636 let detail = format!("{err:?}");
637 assert!(
638 detail.contains("symlink"),
639 "expected symlink rejection, got: {detail}"
640 );
641 }
642
643 #[test]
644 #[cfg(unix)]
645 fn same_lsn_different_wal_lifetimes_produce_different_ciphertext() {
646 let key_bytes = [0x42u8; 32];
651 let key1 = WalEncryptionKey::from_bytes(&key_bytes).unwrap();
652 let key2 = WalEncryptionKey::from_bytes(&key_bytes).unwrap();
653 let header = test_header(1);
654 let pt = b"same plaintext in two wal lifetimes";
655
656 let ct1 = key1.encrypt(1, &header, pt).unwrap();
657 let ct2 = key2.encrypt(1, &header, pt).unwrap();
658
659 assert_ne!(
662 ct1, ct2,
663 "nonce reuse: same (key_bytes, lsn) must not produce identical ciphertext across WAL lifetimes"
664 );
665 }
666}