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
28fn check_key_file_wal(path: &std::path::Path) -> Result<()> {
35 let symlink_meta = std::fs::symlink_metadata(path).map_err(|e| WalError::EncryptionError {
36 detail: format!("cannot stat WAL key file {}: {e}", path.display()),
37 })?;
38
39 if symlink_meta.file_type().is_symlink() {
40 return Err(WalError::EncryptionError {
41 detail: format!(
42 "WAL key file {} is a symlink, which is not permitted \
43 (path traversal / TOCTOU risk)",
44 path.display()
45 ),
46 });
47 }
48
49 if !symlink_meta.is_file() {
50 return Err(WalError::EncryptionError {
51 detail: format!("WAL key file {} is not a regular file", path.display()),
52 });
53 }
54
55 #[cfg(unix)]
56 {
57 use std::os::unix::fs::MetadataExt as _;
58
59 let mode = symlink_meta.mode();
60 if mode & 0o077 != 0 {
61 return Err(WalError::EncryptionError {
62 detail: format!(
63 "WAL key file {} has insecure permissions: 0o{:03o} \
64 (must be 0o400 or 0o600 — no group or world access)",
65 path.display(),
66 mode & 0o777,
67 ),
68 });
69 }
70
71 let file_uid = symlink_meta.uid();
72 let process_uid = unsafe { libc::geteuid() };
74 if file_uid != process_uid {
75 return Err(WalError::EncryptionError {
76 detail: format!(
77 "WAL key file {} is owned by UID {} but process runs as UID {} \
78 — key files must be owned by the server process user",
79 path.display(),
80 file_uid,
81 process_uid,
82 ),
83 });
84 }
85 }
86
87 Ok(())
91}
92
93#[derive(Clone)]
99pub struct WalEncryptionKey {
100 cipher: Aes256Gcm,
101 key: Arc<SecureKey>,
107 epoch: [u8; 4],
110}
111
112impl WalEncryptionKey {
113 pub fn from_bytes(key: &[u8; 32]) -> Result<Self> {
123 Ok(Self {
124 cipher: Aes256Gcm::new(key.into()),
125 key: Arc::new(SecureKey::new(*key)),
126 epoch: random_epoch()?,
127 })
128 }
129
130 pub fn with_epoch(key: &[u8; 32], epoch: [u8; 4]) -> Self {
136 Self {
137 cipher: Aes256Gcm::new(key.into()),
138 key: Arc::new(SecureKey::new(*key)),
139 epoch,
140 }
141 }
142
143 pub fn with_fresh_epoch(&self) -> Result<Self> {
150 Ok(Self {
151 cipher: self.cipher.clone(),
152 key: Arc::clone(&self.key),
153 epoch: random_epoch()?,
154 })
155 }
156
157 pub fn from_file(path: &std::path::Path) -> Result<Self> {
165 check_key_file_wal(path)?;
166 let key_bytes = std::fs::read(path).map_err(WalError::Io)?;
167 if key_bytes.len() != 32 {
168 return Err(WalError::EncryptionError {
169 detail: format!(
170 "encryption key must be exactly 32 bytes, got {}",
171 key_bytes.len()
172 ),
173 });
174 }
175 let mut key_arr = zeroize::Zeroizing::new([0u8; 32]);
176 key_arr.copy_from_slice(&key_bytes);
177 Self::from_bytes(&key_arr)
178 }
179
180 pub fn encrypt(
186 &self,
187 lsn: u64,
188 header_bytes: &[u8; HEADER_SIZE],
189 plaintext: &[u8],
190 ) -> Result<Vec<u8>> {
191 self.encrypt_aad(lsn, header_bytes, plaintext)
192 }
193
194 pub fn encrypt_aad(&self, lsn: u64, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
197 let nonce = lsn_to_nonce(&self.epoch, lsn);
198 self.cipher
199 .encrypt(
200 &nonce,
201 aes_gcm::aead::Payload {
202 msg: plaintext,
203 aad,
204 },
205 )
206 .map_err(|_| WalError::EncryptionError {
207 detail: "AES-256-GCM encryption failed".into(),
208 })
209 }
210
211 pub fn epoch(&self) -> &[u8; 4] {
213 &self.epoch
214 }
215
216 pub fn decrypt(
225 &self,
226 epoch: &[u8; 4],
227 lsn: u64,
228 header_bytes: &[u8; HEADER_SIZE],
229 ciphertext: &[u8],
230 ) -> Result<Vec<u8>> {
231 self.decrypt_aad(epoch, lsn, header_bytes, ciphertext)
232 }
233
234 pub fn decrypt_aad(
236 &self,
237 epoch: &[u8; 4],
238 lsn: u64,
239 aad: &[u8],
240 ciphertext: &[u8],
241 ) -> Result<Vec<u8>> {
242 let nonce = lsn_to_nonce(epoch, lsn);
243 self.cipher
244 .decrypt(
245 &nonce,
246 aes_gcm::aead::Payload {
247 msg: ciphertext,
248 aad,
249 },
250 )
251 .map_err(|_| WalError::EncryptionError {
252 detail: "AES-256-GCM decryption failed (corrupted or wrong key)".into(),
253 })
254 }
255}
256
257#[derive(Clone)]
263pub struct KeyRing {
264 current: WalEncryptionKey,
265 previous: Option<WalEncryptionKey>,
266}
267
268impl KeyRing {
269 pub fn new(current: WalEncryptionKey) -> Self {
271 Self {
272 current,
273 previous: None,
274 }
275 }
276
277 pub fn with_previous(current: WalEncryptionKey, previous: WalEncryptionKey) -> Self {
279 Self {
280 current,
281 previous: Some(previous),
282 }
283 }
284
285 pub fn encrypt(
287 &self,
288 lsn: u64,
289 header_bytes: &[u8; HEADER_SIZE],
290 plaintext: &[u8],
291 ) -> Result<Vec<u8>> {
292 self.current.encrypt(lsn, header_bytes, plaintext)
293 }
294
295 pub fn encrypt_aad(&self, lsn: u64, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
297 self.current.encrypt_aad(lsn, aad, plaintext)
298 }
299
300 pub fn decrypt(
306 &self,
307 epoch: &[u8; 4],
308 lsn: u64,
309 header_bytes: &[u8; HEADER_SIZE],
310 ciphertext: &[u8],
311 ) -> Result<Vec<u8>> {
312 self.decrypt_aad(epoch, lsn, header_bytes, ciphertext)
313 }
314
315 pub fn decrypt_aad(
317 &self,
318 epoch: &[u8; 4],
319 lsn: u64,
320 aad: &[u8],
321 ciphertext: &[u8],
322 ) -> Result<Vec<u8>> {
323 match (
324 self.current.decrypt_aad(epoch, lsn, aad, ciphertext),
325 self.previous.as_ref(),
326 ) {
327 (Ok(plaintext), _) => Ok(plaintext),
328 (Err(_), Some(prev)) => prev.decrypt_aad(epoch, lsn, aad, ciphertext),
329 (Err(e), None) => Err(e),
330 }
331 }
332
333 pub fn current(&self) -> &WalEncryptionKey {
335 &self.current
336 }
337
338 pub fn has_previous(&self) -> bool {
340 self.previous.is_some()
341 }
342
343 pub fn clear_previous(&mut self) {
345 self.previous = None;
346 }
347}
348
349pub const AUTH_TAG_SIZE: usize = 16;
351
352pub const SEGMENT_ENVELOPE_PREAMBLE_SIZE: usize = 16;
369
370pub const SEGMENT_ENVELOPE_MIN_SIZE: usize = SEGMENT_ENVELOPE_PREAMBLE_SIZE + AUTH_TAG_SIZE;
372
373const SEGMENT_ENVELOPE_VERSION: u16 = 1;
375
376const SEGMENT_ENVELOPE_CIPHER_AES_256_GCM: u8 = 0;
378
379const SEGMENT_ENVELOPE_NONCE_LSN: u64 = 0;
382
383fn encode_envelope_preamble(
384 magic: &[u8; 4],
385 epoch: &[u8; 4],
386) -> [u8; SEGMENT_ENVELOPE_PREAMBLE_SIZE] {
387 let mut buf = [0u8; SEGMENT_ENVELOPE_PREAMBLE_SIZE];
388 buf[0..4].copy_from_slice(magic);
389 buf[4..6].copy_from_slice(&SEGMENT_ENVELOPE_VERSION.to_le_bytes());
390 buf[6] = SEGMENT_ENVELOPE_CIPHER_AES_256_GCM;
391 buf[7] = 0; buf[8..12].copy_from_slice(epoch);
393 buf
395}
396
397pub fn encrypt_segment_envelope(
403 key: &WalEncryptionKey,
404 magic: &[u8; 4],
405 plaintext: &[u8],
406) -> Result<Vec<u8>> {
407 let fresh_key = key.with_fresh_epoch()?;
408 let epoch = *fresh_key.epoch();
409 let preamble = encode_envelope_preamble(magic, &epoch);
410 let ciphertext = fresh_key.encrypt_aad(SEGMENT_ENVELOPE_NONCE_LSN, &preamble, plaintext)?;
411 let mut out = Vec::with_capacity(SEGMENT_ENVELOPE_PREAMBLE_SIZE + ciphertext.len());
412 out.extend_from_slice(&preamble);
413 out.extend_from_slice(&ciphertext);
414 Ok(out)
415}
416
417pub fn decrypt_segment_envelope(
422 key: &WalEncryptionKey,
423 magic: &[u8; 4],
424 blob: &[u8],
425) -> Result<Vec<u8>> {
426 if blob.len() < SEGMENT_ENVELOPE_MIN_SIZE {
427 return Err(WalError::EncryptionError {
428 detail: "encrypted envelope too short".into(),
429 });
430 }
431 let preamble: [u8; SEGMENT_ENVELOPE_PREAMBLE_SIZE] = blob[..SEGMENT_ENVELOPE_PREAMBLE_SIZE]
432 .try_into()
433 .expect("slice is preamble size");
434 if &preamble[0..4] != magic {
435 return Err(WalError::EncryptionError {
436 detail: "envelope preamble magic mismatch".into(),
437 });
438 }
439 let version = u16::from_le_bytes([preamble[4], preamble[5]]);
440 if version != SEGMENT_ENVELOPE_VERSION {
441 return Err(WalError::EncryptionError {
442 detail: format!("unsupported envelope preamble version {version}"),
443 });
444 }
445 let mut epoch = [0u8; 4];
446 epoch.copy_from_slice(&preamble[8..12]);
447 let ciphertext = &blob[SEGMENT_ENVELOPE_PREAMBLE_SIZE..];
448 key.decrypt_aad(&epoch, SEGMENT_ENVELOPE_NONCE_LSN, &preamble, ciphertext)
449}
450
451fn random_epoch() -> Result<[u8; 4]> {
457 let mut epoch = [0u8; 4];
458 getrandom::fill(&mut epoch).map_err(|e| WalError::EncryptionError {
459 detail: format!("getrandom failed while generating epoch: {e}"),
460 })?;
461 Ok(epoch)
462}
463
464fn lsn_to_nonce(epoch: &[u8; 4], lsn: u64) -> aes_gcm::Nonce<aes_gcm::aead::consts::U12> {
471 let mut nonce_bytes = [0u8; 12];
472 nonce_bytes[..4].copy_from_slice(epoch);
473 nonce_bytes[4..12].copy_from_slice(&lsn.to_le_bytes());
474 nonce_bytes.into()
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 fn test_key() -> WalEncryptionKey {
482 WalEncryptionKey::from_bytes(&[0x42u8; 32]).unwrap()
483 }
484
485 fn test_header(lsn: u64) -> [u8; HEADER_SIZE] {
486 let mut h = [0u8; HEADER_SIZE];
487 h[8..16].copy_from_slice(&lsn.to_le_bytes());
488 h
489 }
490
491 #[test]
492 fn encrypt_decrypt_roundtrip() {
493 let key = test_key();
494 let epoch = *key.epoch();
495 let header = test_header(1);
496 let plaintext = b"hello nodedb encryption";
497
498 let ciphertext = key.encrypt(1, &header, plaintext).unwrap();
499 assert_ne!(&ciphertext[..plaintext.len()], plaintext);
500 assert_eq!(ciphertext.len(), plaintext.len() + AUTH_TAG_SIZE);
501
502 let decrypted = key.decrypt(&epoch, 1, &header, &ciphertext).unwrap();
503 assert_eq!(decrypted, plaintext);
504 }
505
506 #[test]
507 fn wrong_key_fails() {
508 let key1 = WalEncryptionKey::from_bytes(&[0x01; 32]).unwrap();
509 let epoch1 = *key1.epoch();
510 let key2 = WalEncryptionKey::from_bytes(&[0x02; 32]).unwrap();
511 let header = test_header(1);
512
513 let ciphertext = key1.encrypt(1, &header, b"secret").unwrap();
514 assert!(key2.decrypt(&epoch1, 1, &header, &ciphertext).is_err());
515 }
516
517 #[test]
518 fn wrong_lsn_fails() {
519 let key = test_key();
520 let epoch = *key.epoch();
521 let header = test_header(1);
522
523 let ciphertext = key.encrypt(1, &header, b"secret").unwrap();
524 assert!(key.decrypt(&epoch, 2, &header, &ciphertext).is_err());
526 }
527
528 #[test]
529 fn tampered_ciphertext_fails() {
530 let key = test_key();
531 let epoch = *key.epoch();
532 let header = test_header(1);
533
534 let mut ciphertext = key.encrypt(1, &header, b"secret").unwrap();
535 ciphertext[0] ^= 0xFF;
536 assert!(key.decrypt(&epoch, 1, &header, &ciphertext).is_err());
537 }
538
539 #[test]
540 fn tampered_header_fails() {
541 let key = test_key();
542 let epoch = *key.epoch();
543 let header1 = test_header(1);
544
545 let ciphertext = key.encrypt(1, &header1, b"secret").unwrap();
546
547 let mut header2 = header1;
549 header2[0] = 0xFF;
550 assert!(key.decrypt(&epoch, 1, &header2, &ciphertext).is_err());
551 }
552
553 #[test]
554 fn empty_payload() {
555 let key = test_key();
556 let epoch = *key.epoch();
557 let header = test_header(1);
558
559 let ciphertext = key.encrypt(1, &header, b"").unwrap();
560 assert_eq!(ciphertext.len(), AUTH_TAG_SIZE); let decrypted = key.decrypt(&epoch, 1, &header, &ciphertext).unwrap();
563 assert!(decrypted.is_empty());
564 }
565
566 #[test]
567 fn different_lsns_produce_different_ciphertext() {
568 let key = test_key();
569 let plaintext = b"same payload";
570
571 let ct1 = key.encrypt(1, &test_header(1), plaintext).unwrap();
572 let ct2 = key.encrypt(2, &test_header(2), plaintext).unwrap();
573 assert_ne!(ct1, ct2);
574 }
575
576 #[test]
577 #[cfg(unix)]
578 fn from_file_0o600_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(0o600)).unwrap();
588
589 WalEncryptionKey::from_file(&path).expect("0o600 key file must be accepted");
590 }
591
592 #[test]
593 #[cfg(unix)]
594 fn from_file_0o400_accepted() {
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(0o400)).unwrap();
604
605 WalEncryptionKey::from_file(&path).expect("0o400 key file must be accepted");
606 }
607
608 #[test]
609 #[cfg(unix)]
610 fn from_file_0o644_rejected() {
611 use std::io::Write as _;
612 use std::os::unix::fs::PermissionsExt as _;
613
614 let dir = tempfile::tempdir().unwrap();
615 let path = dir.path().join("key.bin");
616 let mut f = std::fs::File::create(&path).unwrap();
617 f.write_all(&[0x42u8; 32]).unwrap();
618 drop(f);
619 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
620
621 let err = match WalEncryptionKey::from_file(&path) {
622 Ok(_) => panic!("expected insecure-permissions error, got Ok"),
623 Err(e) => e,
624 };
625 let detail = format!("{err:?}");
626 assert!(
627 detail.contains("insecure") || detail.contains("644"),
628 "expected insecure-permissions error, got: {detail}"
629 );
630 }
631
632 #[test]
633 #[cfg(unix)]
634 fn from_file_symlink_rejected() {
635 use std::io::Write as _;
636 use std::os::unix::fs::PermissionsExt as _;
637
638 let dir = tempfile::tempdir().unwrap();
639 let target = dir.path().join("target.bin");
640 let mut f = std::fs::File::create(&target).unwrap();
641 f.write_all(&[0x42u8; 32]).unwrap();
642 drop(f);
643 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
644
645 let link = dir.path().join("link.bin");
646 std::os::unix::fs::symlink(&target, &link).unwrap();
647
648 let err = match WalEncryptionKey::from_file(&link) {
649 Ok(_) => panic!("expected symlink rejection, got Ok"),
650 Err(e) => e,
651 };
652 let detail = format!("{err:?}");
653 assert!(
654 detail.contains("symlink"),
655 "expected symlink rejection, got: {detail}"
656 );
657 }
658
659 #[test]
660 #[cfg(unix)]
661 fn same_lsn_different_wal_lifetimes_produce_different_ciphertext() {
662 let key_bytes = [0x42u8; 32];
667 let key1 = WalEncryptionKey::from_bytes(&key_bytes).unwrap();
668 let key2 = WalEncryptionKey::from_bytes(&key_bytes).unwrap();
669 let header = test_header(1);
670 let pt = b"same plaintext in two wal lifetimes";
671
672 let ct1 = key1.encrypt(1, &header, pt).unwrap();
673 let ct2 = key2.encrypt(1, &header, pt).unwrap();
674
675 assert_ne!(
678 ct1, ct2,
679 "nonce reuse: same (key_bytes, lsn) must not produce identical ciphertext across WAL lifetimes"
680 );
681 }
682}