1use aes_gcm::Aes256Gcm;
20use aes_gcm::aead::{Aead, KeyInit};
21
22use crate::error::{Result, WalError};
23use crate::record::HEADER_SIZE;
24use crate::secure_mem;
25
26fn check_key_file_wal(path: &std::path::Path) -> Result<()> {
33 let symlink_meta = std::fs::symlink_metadata(path).map_err(|e| WalError::EncryptionError {
34 detail: format!("cannot stat WAL key file {}: {e}", path.display()),
35 })?;
36
37 if symlink_meta.file_type().is_symlink() {
38 return Err(WalError::EncryptionError {
39 detail: format!(
40 "WAL key file {} is a symlink, which is not permitted \
41 (path traversal / TOCTOU risk)",
42 path.display()
43 ),
44 });
45 }
46
47 if !symlink_meta.is_file() {
48 return Err(WalError::EncryptionError {
49 detail: format!("WAL key file {} is not a regular file", path.display()),
50 });
51 }
52
53 #[cfg(unix)]
54 {
55 use std::os::unix::fs::MetadataExt as _;
56
57 let mode = symlink_meta.mode();
58 if mode & 0o077 != 0 {
59 return Err(WalError::EncryptionError {
60 detail: format!(
61 "WAL key file {} has insecure permissions: 0o{:03o} \
62 (must be 0o400 or 0o600 — no group or world access)",
63 path.display(),
64 mode & 0o777,
65 ),
66 });
67 }
68
69 let file_uid = symlink_meta.uid();
70 let process_uid = unsafe { libc::geteuid() };
72 if file_uid != process_uid {
73 return Err(WalError::EncryptionError {
74 detail: format!(
75 "WAL key file {} is owned by UID {} but process runs as UID {} \
76 — key files must be owned by the server process user",
77 path.display(),
78 file_uid,
79 process_uid,
80 ),
81 });
82 }
83 }
84
85 Ok(())
89}
90
91#[derive(Clone)]
97pub struct WalEncryptionKey {
98 cipher: Aes256Gcm,
99 key_bytes: [u8; 32],
101 epoch: [u8; 4],
104}
105
106impl WalEncryptionKey {
107 pub fn from_bytes(key: &[u8; 32]) -> Result<Self> {
114 let mut epoch = [0u8; 4];
115 getrandom::fill(&mut epoch).map_err(|e| WalError::EncryptionError {
116 detail: format!("getrandom failed while generating epoch: {e}"),
117 })?;
118 let mut key_bytes = *key;
122 secure_mem::mlock_key_bytes(key_bytes.as_mut_ptr(), 32);
123 Ok(Self {
124 cipher: Aes256Gcm::new(key.into()),
125 key_bytes,
126 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_bytes: *key,
139 epoch,
140 }
141 }
142
143 pub fn with_fresh_epoch(&self) -> Result<Self> {
147 Self::from_bytes(&self.key_bytes)
148 }
149
150 pub fn from_file(path: &std::path::Path) -> Result<Self> {
158 check_key_file_wal(path)?;
159 let key_bytes = std::fs::read(path).map_err(WalError::Io)?;
160 if key_bytes.len() != 32 {
161 return Err(WalError::EncryptionError {
162 detail: format!(
163 "encryption key must be exactly 32 bytes, got {}",
164 key_bytes.len()
165 ),
166 });
167 }
168 let mut key_arr = zeroize::Zeroizing::new([0u8; 32]);
169 key_arr.copy_from_slice(&key_bytes);
170 Self::from_bytes(&key_arr)
171 }
172
173 pub fn encrypt(
179 &self,
180 lsn: u64,
181 header_bytes: &[u8; HEADER_SIZE],
182 plaintext: &[u8],
183 ) -> Result<Vec<u8>> {
184 self.encrypt_aad(lsn, header_bytes, plaintext)
185 }
186
187 pub fn encrypt_aad(&self, lsn: u64, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
190 let nonce = lsn_to_nonce(&self.epoch, lsn);
191 self.cipher
192 .encrypt(
193 &nonce,
194 aes_gcm::aead::Payload {
195 msg: plaintext,
196 aad,
197 },
198 )
199 .map_err(|_| WalError::EncryptionError {
200 detail: "AES-256-GCM encryption failed".into(),
201 })
202 }
203
204 pub fn epoch(&self) -> &[u8; 4] {
206 &self.epoch
207 }
208
209 pub fn decrypt(
218 &self,
219 epoch: &[u8; 4],
220 lsn: u64,
221 header_bytes: &[u8; HEADER_SIZE],
222 ciphertext: &[u8],
223 ) -> Result<Vec<u8>> {
224 self.decrypt_aad(epoch, lsn, header_bytes, ciphertext)
225 }
226
227 pub fn decrypt_aad(
229 &self,
230 epoch: &[u8; 4],
231 lsn: u64,
232 aad: &[u8],
233 ciphertext: &[u8],
234 ) -> Result<Vec<u8>> {
235 let nonce = lsn_to_nonce(epoch, lsn);
236 self.cipher
237 .decrypt(
238 &nonce,
239 aes_gcm::aead::Payload {
240 msg: ciphertext,
241 aad,
242 },
243 )
244 .map_err(|_| WalError::EncryptionError {
245 detail: "AES-256-GCM decryption failed (corrupted or wrong key)".into(),
246 })
247 }
248}
249
250#[derive(Clone)]
256pub struct KeyRing {
257 current: WalEncryptionKey,
258 previous: Option<WalEncryptionKey>,
259}
260
261impl KeyRing {
262 pub fn new(current: WalEncryptionKey) -> Self {
264 Self {
265 current,
266 previous: None,
267 }
268 }
269
270 pub fn with_previous(current: WalEncryptionKey, previous: WalEncryptionKey) -> Self {
272 Self {
273 current,
274 previous: Some(previous),
275 }
276 }
277
278 pub fn encrypt(
280 &self,
281 lsn: u64,
282 header_bytes: &[u8; HEADER_SIZE],
283 plaintext: &[u8],
284 ) -> Result<Vec<u8>> {
285 self.current.encrypt(lsn, header_bytes, plaintext)
286 }
287
288 pub fn encrypt_aad(&self, lsn: u64, aad: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
290 self.current.encrypt_aad(lsn, aad, plaintext)
291 }
292
293 pub fn decrypt(
299 &self,
300 epoch: &[u8; 4],
301 lsn: u64,
302 header_bytes: &[u8; HEADER_SIZE],
303 ciphertext: &[u8],
304 ) -> Result<Vec<u8>> {
305 self.decrypt_aad(epoch, lsn, header_bytes, ciphertext)
306 }
307
308 pub fn decrypt_aad(
310 &self,
311 epoch: &[u8; 4],
312 lsn: u64,
313 aad: &[u8],
314 ciphertext: &[u8],
315 ) -> Result<Vec<u8>> {
316 match (
317 self.current.decrypt_aad(epoch, lsn, aad, ciphertext),
318 self.previous.as_ref(),
319 ) {
320 (Ok(plaintext), _) => Ok(plaintext),
321 (Err(_), Some(prev)) => prev.decrypt_aad(epoch, lsn, aad, ciphertext),
322 (Err(e), None) => Err(e),
323 }
324 }
325
326 pub fn current(&self) -> &WalEncryptionKey {
328 &self.current
329 }
330
331 pub fn has_previous(&self) -> bool {
333 self.previous.is_some()
334 }
335
336 pub fn clear_previous(&mut self) {
338 self.previous = None;
339 }
340}
341
342pub const AUTH_TAG_SIZE: usize = 16;
344
345pub const SEGMENT_ENVELOPE_PREAMBLE_SIZE: usize = 16;
362
363pub const SEGMENT_ENVELOPE_MIN_SIZE: usize = SEGMENT_ENVELOPE_PREAMBLE_SIZE + AUTH_TAG_SIZE;
365
366const SEGMENT_ENVELOPE_VERSION: u16 = 1;
368
369const SEGMENT_ENVELOPE_CIPHER_AES_256_GCM: u8 = 0;
371
372const SEGMENT_ENVELOPE_NONCE_LSN: u64 = 0;
375
376fn encode_envelope_preamble(
377 magic: &[u8; 4],
378 epoch: &[u8; 4],
379) -> [u8; SEGMENT_ENVELOPE_PREAMBLE_SIZE] {
380 let mut buf = [0u8; SEGMENT_ENVELOPE_PREAMBLE_SIZE];
381 buf[0..4].copy_from_slice(magic);
382 buf[4..6].copy_from_slice(&SEGMENT_ENVELOPE_VERSION.to_le_bytes());
383 buf[6] = SEGMENT_ENVELOPE_CIPHER_AES_256_GCM;
384 buf[7] = 0; buf[8..12].copy_from_slice(epoch);
386 buf
388}
389
390pub fn encrypt_segment_envelope(
396 key: &WalEncryptionKey,
397 magic: &[u8; 4],
398 plaintext: &[u8],
399) -> Result<Vec<u8>> {
400 let fresh_key = key.with_fresh_epoch()?;
401 let epoch = *fresh_key.epoch();
402 let preamble = encode_envelope_preamble(magic, &epoch);
403 let ciphertext = fresh_key.encrypt_aad(SEGMENT_ENVELOPE_NONCE_LSN, &preamble, plaintext)?;
404 let mut out = Vec::with_capacity(SEGMENT_ENVELOPE_PREAMBLE_SIZE + ciphertext.len());
405 out.extend_from_slice(&preamble);
406 out.extend_from_slice(&ciphertext);
407 Ok(out)
408}
409
410pub fn decrypt_segment_envelope(
415 key: &WalEncryptionKey,
416 magic: &[u8; 4],
417 blob: &[u8],
418) -> Result<Vec<u8>> {
419 if blob.len() < SEGMENT_ENVELOPE_MIN_SIZE {
420 return Err(WalError::EncryptionError {
421 detail: "encrypted envelope too short".into(),
422 });
423 }
424 let preamble: [u8; SEGMENT_ENVELOPE_PREAMBLE_SIZE] = blob[..SEGMENT_ENVELOPE_PREAMBLE_SIZE]
425 .try_into()
426 .expect("slice is preamble size");
427 if &preamble[0..4] != magic {
428 return Err(WalError::EncryptionError {
429 detail: "envelope preamble magic mismatch".into(),
430 });
431 }
432 let version = u16::from_le_bytes([preamble[4], preamble[5]]);
433 if version != SEGMENT_ENVELOPE_VERSION {
434 return Err(WalError::EncryptionError {
435 detail: format!("unsupported envelope preamble version {version}"),
436 });
437 }
438 let mut epoch = [0u8; 4];
439 epoch.copy_from_slice(&preamble[8..12]);
440 let ciphertext = &blob[SEGMENT_ENVELOPE_PREAMBLE_SIZE..];
441 key.decrypt_aad(&epoch, SEGMENT_ENVELOPE_NONCE_LSN, &preamble, ciphertext)
442}
443
444fn lsn_to_nonce(epoch: &[u8; 4], lsn: u64) -> aes_gcm::Nonce<aes_gcm::aead::consts::U12> {
451 let mut nonce_bytes = [0u8; 12];
452 nonce_bytes[..4].copy_from_slice(epoch);
453 nonce_bytes[4..12].copy_from_slice(&lsn.to_le_bytes());
454 nonce_bytes.into()
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 fn test_key() -> WalEncryptionKey {
462 WalEncryptionKey::from_bytes(&[0x42u8; 32]).unwrap()
463 }
464
465 fn test_header(lsn: u64) -> [u8; HEADER_SIZE] {
466 let mut h = [0u8; HEADER_SIZE];
467 h[8..16].copy_from_slice(&lsn.to_le_bytes());
468 h
469 }
470
471 #[test]
472 fn encrypt_decrypt_roundtrip() {
473 let key = test_key();
474 let epoch = *key.epoch();
475 let header = test_header(1);
476 let plaintext = b"hello nodedb encryption";
477
478 let ciphertext = key.encrypt(1, &header, plaintext).unwrap();
479 assert_ne!(&ciphertext[..plaintext.len()], plaintext);
480 assert_eq!(ciphertext.len(), plaintext.len() + AUTH_TAG_SIZE);
481
482 let decrypted = key.decrypt(&epoch, 1, &header, &ciphertext).unwrap();
483 assert_eq!(decrypted, plaintext);
484 }
485
486 #[test]
487 fn wrong_key_fails() {
488 let key1 = WalEncryptionKey::from_bytes(&[0x01; 32]).unwrap();
489 let epoch1 = *key1.epoch();
490 let key2 = WalEncryptionKey::from_bytes(&[0x02; 32]).unwrap();
491 let header = test_header(1);
492
493 let ciphertext = key1.encrypt(1, &header, b"secret").unwrap();
494 assert!(key2.decrypt(&epoch1, 1, &header, &ciphertext).is_err());
495 }
496
497 #[test]
498 fn wrong_lsn_fails() {
499 let key = test_key();
500 let epoch = *key.epoch();
501 let header = test_header(1);
502
503 let ciphertext = key.encrypt(1, &header, b"secret").unwrap();
504 assert!(key.decrypt(&epoch, 2, &header, &ciphertext).is_err());
506 }
507
508 #[test]
509 fn tampered_ciphertext_fails() {
510 let key = test_key();
511 let epoch = *key.epoch();
512 let header = test_header(1);
513
514 let mut ciphertext = key.encrypt(1, &header, b"secret").unwrap();
515 ciphertext[0] ^= 0xFF;
516 assert!(key.decrypt(&epoch, 1, &header, &ciphertext).is_err());
517 }
518
519 #[test]
520 fn tampered_header_fails() {
521 let key = test_key();
522 let epoch = *key.epoch();
523 let header1 = test_header(1);
524
525 let ciphertext = key.encrypt(1, &header1, b"secret").unwrap();
526
527 let mut header2 = header1;
529 header2[0] = 0xFF;
530 assert!(key.decrypt(&epoch, 1, &header2, &ciphertext).is_err());
531 }
532
533 #[test]
534 fn empty_payload() {
535 let key = test_key();
536 let epoch = *key.epoch();
537 let header = test_header(1);
538
539 let ciphertext = key.encrypt(1, &header, b"").unwrap();
540 assert_eq!(ciphertext.len(), AUTH_TAG_SIZE); let decrypted = key.decrypt(&epoch, 1, &header, &ciphertext).unwrap();
543 assert!(decrypted.is_empty());
544 }
545
546 #[test]
547 fn different_lsns_produce_different_ciphertext() {
548 let key = test_key();
549 let plaintext = b"same payload";
550
551 let ct1 = key.encrypt(1, &test_header(1), plaintext).unwrap();
552 let ct2 = key.encrypt(2, &test_header(2), plaintext).unwrap();
553 assert_ne!(ct1, ct2);
554 }
555
556 #[test]
557 #[cfg(unix)]
558 fn from_file_0o600_accepted() {
559 use std::io::Write as _;
560 use std::os::unix::fs::PermissionsExt as _;
561
562 let dir = tempfile::tempdir().unwrap();
563 let path = dir.path().join("key.bin");
564 let mut f = std::fs::File::create(&path).unwrap();
565 f.write_all(&[0x42u8; 32]).unwrap();
566 drop(f);
567 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
568
569 WalEncryptionKey::from_file(&path).expect("0o600 key file must be accepted");
570 }
571
572 #[test]
573 #[cfg(unix)]
574 fn from_file_0o400_accepted() {
575 use std::io::Write as _;
576 use std::os::unix::fs::PermissionsExt as _;
577
578 let dir = tempfile::tempdir().unwrap();
579 let path = dir.path().join("key.bin");
580 let mut f = std::fs::File::create(&path).unwrap();
581 f.write_all(&[0x42u8; 32]).unwrap();
582 drop(f);
583 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
584
585 WalEncryptionKey::from_file(&path).expect("0o400 key file must be accepted");
586 }
587
588 #[test]
589 #[cfg(unix)]
590 fn from_file_0o644_rejected() {
591 use std::io::Write as _;
592 use std::os::unix::fs::PermissionsExt as _;
593
594 let dir = tempfile::tempdir().unwrap();
595 let path = dir.path().join("key.bin");
596 let mut f = std::fs::File::create(&path).unwrap();
597 f.write_all(&[0x42u8; 32]).unwrap();
598 drop(f);
599 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
600
601 let err = match WalEncryptionKey::from_file(&path) {
602 Ok(_) => panic!("expected insecure-permissions error, got Ok"),
603 Err(e) => e,
604 };
605 let detail = format!("{err:?}");
606 assert!(
607 detail.contains("insecure") || detail.contains("644"),
608 "expected insecure-permissions error, got: {detail}"
609 );
610 }
611
612 #[test]
613 #[cfg(unix)]
614 fn from_file_symlink_rejected() {
615 use std::io::Write as _;
616 use std::os::unix::fs::PermissionsExt as _;
617
618 let dir = tempfile::tempdir().unwrap();
619 let target = dir.path().join("target.bin");
620 let mut f = std::fs::File::create(&target).unwrap();
621 f.write_all(&[0x42u8; 32]).unwrap();
622 drop(f);
623 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
624
625 let link = dir.path().join("link.bin");
626 std::os::unix::fs::symlink(&target, &link).unwrap();
627
628 let err = match WalEncryptionKey::from_file(&link) {
629 Ok(_) => panic!("expected symlink rejection, got Ok"),
630 Err(e) => e,
631 };
632 let detail = format!("{err:?}");
633 assert!(
634 detail.contains("symlink"),
635 "expected symlink rejection, got: {detail}"
636 );
637 }
638
639 #[test]
640 #[cfg(unix)]
641 fn same_lsn_different_wal_lifetimes_produce_different_ciphertext() {
642 let key_bytes = [0x42u8; 32];
647 let key1 = WalEncryptionKey::from_bytes(&key_bytes).unwrap();
648 let key2 = WalEncryptionKey::from_bytes(&key_bytes).unwrap();
649 let header = test_header(1);
650 let pt = b"same plaintext in two wal lifetimes";
651
652 let ct1 = key1.encrypt(1, &header, pt).unwrap();
653 let ct2 = key2.encrypt(1, &header, pt).unwrap();
654
655 assert_ne!(
658 ct1, ct2,
659 "nonce reuse: same (key_bytes, lsn) must not produce identical ciphertext across WAL lifetimes"
660 );
661 }
662}