Skip to main content

nodedb_wal/
crypto.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! WAL payload encryption using AES-256-GCM.
4//!
5//! Design:
6//! - Header stays plaintext (needed for recovery scanning — magic, lsn, tenant_id)
7//! - Payload is encrypted before CRC computation
8//! - CRC covers the ciphertext (detects corruption of encrypted data)
9//! - Nonce = `[4-byte random epoch][8-byte LSN]` — epoch is generated per WAL
10//!   lifetime to prevent nonce reuse after snapshot restore or WAL truncation
11//! - Additional Authenticated Data (AAD) = header bytes (binds ciphertext to its header)
12//!
13//! On-disk format for encrypted payload:
14//! ```text
15//! [header(30B plaintext)] [ciphertext(payload_len bytes)] [auth_tag(16B)]
16//! ```
17//! `payload_len` includes the 16-byte auth tag.
18
19use 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
26/// Security gate for WAL key files: enforces no-symlink, regular-file,
27/// Unix mode bits (no group/world), and owner-UID checks.
28///
29/// Mirrors the logic in `nodedb::control::security::keystore::key_file_security`
30/// but is duplicated here so that `nodedb-wal` has zero dependency on the
31/// `nodedb` application crate.
32fn 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        // SAFETY: geteuid() is always safe to call; it has no preconditions.
71        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    // On Windows: ACL-based permission enforcement is not implemented.
86    // The symlink check above still applies on all platforms.
87
88    Ok(())
89}
90
91/// AES-256-GCM key with a random per-lifetime epoch for nonce disambiguation.
92///
93/// The epoch is generated randomly at construction time. Each WAL lifetime
94/// (process start, snapshot restore, segment creation) gets a fresh epoch,
95/// ensuring that nonces are never reused even if LSNs restart from 1.
96#[derive(Clone)]
97pub struct WalEncryptionKey {
98    cipher: Aes256Gcm,
99    /// Raw key bytes (kept for producing new instances with a fresh epoch).
100    key_bytes: [u8; 32],
101    /// Random 4-byte epoch: occupies the high 4 bytes of the 12-byte nonce.
102    /// Disambiguates nonces across WAL lifetimes with the same key.
103    epoch: [u8; 4],
104}
105
106impl WalEncryptionKey {
107    /// Create from a 32-byte key with a fresh random epoch.
108    ///
109    /// Returns an error if the OS RNG (`getrandom`) is unavailable. Without
110    /// a fresh epoch we cannot guarantee nonce uniqueness across WAL
111    /// lifetimes, so panicking would silently risk nonce reuse on RNG
112    /// failure — better to surface it.
113    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        // mlock key_bytes so they are not swapped to disk. Best-effort: if the
119        // OS refuses (e.g. RLIMIT_MEMLOCK exceeded in a container) we log and
120        // continue rather than aborting startup.
121        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    /// Create from a 32-byte key with a **caller-supplied epoch**.
131    ///
132    /// Use this when reopening a WAL segment whose epoch was read from the
133    /// on-disk preamble, so that the nonce is reconstructed identically to
134    /// the nonce used at encryption time.
135    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    /// Produce a new key instance with the same key material but a fresh
144    /// random epoch. Used when rolling to a new WAL segment — each segment
145    /// gets its own epoch so the per-segment nonce space is independent.
146    pub fn with_fresh_epoch(&self) -> Result<Self> {
147        Self::from_bytes(&self.key_bytes)
148    }
149
150    /// Load key from a file (must contain exactly 32 bytes).
151    ///
152    /// Before reading, the file is checked for:
153    /// - No symlinks (TOCTOU / path-traversal risk).
154    /// - Regular file (not a device, FIFO, or directory).
155    /// - Unix: no group or world access bits (`mode & 0o077 == 0`).
156    /// - Unix: file owner matches the current process UID.
157    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    /// Encrypt a payload. Returns ciphertext + auth_tag (16 bytes appended).
174    ///
175    /// - `lsn`: used to derive a deterministic nonce
176    /// - `header_bytes`: used as AAD (additional authenticated data)
177    /// - `plaintext`: the payload to encrypt
178    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    /// Encrypt with a caller-provided AAD slice (may be longer than HEADER_SIZE,
188    /// e.g. preamble bytes prepended to the header).
189    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    /// The random epoch for this key instance.
205    pub fn epoch(&self) -> &[u8; 4] {
206        &self.epoch
207    }
208
209    /// Decrypt a payload. Input is ciphertext + auth_tag (16 bytes at end).
210    ///
211    /// - `epoch`: must be the epoch that was used during encryption, read from
212    ///   the on-disk segment preamble — **not** `self.epoch`, which reflects
213    ///   the current in-memory lifetime and may differ after a restart.
214    /// - `lsn`: must match the LSN used during encryption
215    /// - `header_bytes`: must match the header used during encryption (AAD)
216    /// - `ciphertext`: the encrypted payload (includes 16-byte auth tag)
217    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    /// Decrypt with a caller-provided AAD slice.
228    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/// Key ring supporting dual-key reads for seamless key rotation.
251///
252/// During rotation: new writes use the current key, reads try current
253/// then fall back to previous. Once all old data is re-encrypted,
254/// the previous key is removed.
255#[derive(Clone)]
256pub struct KeyRing {
257    current: WalEncryptionKey,
258    previous: Option<WalEncryptionKey>,
259}
260
261impl KeyRing {
262    /// Create a key ring with only the current key.
263    pub fn new(current: WalEncryptionKey) -> Self {
264        Self {
265            current,
266            previous: None,
267        }
268    }
269
270    /// Create a key ring with current + previous key (for rotation).
271    pub fn with_previous(current: WalEncryptionKey, previous: WalEncryptionKey) -> Self {
272        Self {
273            current,
274            previous: Some(previous),
275        }
276    }
277
278    /// Encrypt using the current key.
279    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    /// Encrypt with a caller-provided AAD slice.
289    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    /// Decrypt: try current key first, then previous (if set).
294    ///
295    /// `epoch` is the encryption epoch stored in the WAL segment preamble.
296    /// This enables seamless key rotation — old data encrypted with the
297    /// previous key can still be read while new data uses the current key.
298    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    /// Decrypt with a caller-provided AAD slice.
309    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    /// Get the current key (for encryption operations).
327    pub fn current(&self) -> &WalEncryptionKey {
328        &self.current
329    }
330
331    /// Whether a previous key is present (rotation in progress).
332    pub fn has_previous(&self) -> bool {
333        self.previous.is_some()
334    }
335
336    /// Remove the previous key (rotation complete).
337    pub fn clear_previous(&mut self) {
338        self.previous = None;
339    }
340}
341
342/// AES-256-GCM auth tag size in bytes.
343pub const AUTH_TAG_SIZE: usize = 16;
344
345// ── Segment envelope ───────────────────────────────────────────────────────
346//
347// Shared at-rest framing reused by every engine (array, columnar, vector,
348// spatial, timeseries) so the AES-256-GCM call site lives in one place.
349//
350// Layout:
351// ```text
352// [magic (4B)] [version_u16_le (2B)] [cipher_u8 (1B)] [kid_u8 (1B)]
353// [epoch (4B)] [reserved (4B)] [AES-256-GCM ciphertext (plaintext + 16B tag)]
354// ```
355//
356// The 16-byte preamble is supplied as AAD, preventing preamble-swap attacks.
357// The nonce is derived from `(epoch, lsn = 0)`; per-write random epoch
358// guarantees nonce uniqueness without needing a monotonic counter.
359
360/// Size of the segment envelope preamble in bytes.
361pub const SEGMENT_ENVELOPE_PREAMBLE_SIZE: usize = 16;
362
363/// Minimum size of a valid encrypted envelope: preamble + AES-GCM auth tag.
364pub const SEGMENT_ENVELOPE_MIN_SIZE: usize = SEGMENT_ENVELOPE_PREAMBLE_SIZE + AUTH_TAG_SIZE;
365
366/// Current segment-envelope preamble layout version.
367const SEGMENT_ENVELOPE_VERSION: u16 = 1;
368
369/// Cipher algorithm tag stored in the preamble: 0 = AES-256-GCM.
370const SEGMENT_ENVELOPE_CIPHER_AES_256_GCM: u8 = 0;
371
372/// Fixed LSN input for envelope nonces. Per-write random epoch (stored in
373/// the preamble) is what actually disambiguates nonces.
374const 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; // kid = 0 (current KEK)
385    buf[8..12].copy_from_slice(epoch);
386    // buf[12..16] = reserved zeros
387    buf
388}
389
390/// Encrypt `plaintext` into a self-describing segment envelope.
391///
392/// Returns `preamble || AES-256-GCM(plaintext) || auth_tag`. The caller
393/// supplies the 4-byte `magic` that identifies its envelope variant
394/// (`SEGA`, `SEGC`, `SEGT`, `SEGV`, …).
395pub 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
410/// Decrypt a segment envelope produced by [`encrypt_segment_envelope`].
411///
412/// The caller supplies the expected `magic`; mismatches surface as
413/// [`WalError::EncryptionError`].
414pub 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
444/// Derive a 12-byte nonce from an epoch and LSN.
445///
446/// AES-256-GCM requires a 96-bit (12 byte) nonce that must never repeat
447/// for the same key. Layout: `[4-byte random epoch][8-byte LSN]`.
448/// The epoch is generated randomly per WAL lifetime, so even if LSNs
449/// restart from 1 after a snapshot restore, the nonces remain unique.
450fn 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        // Different LSN = different nonce = decryption fails.
505        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        // Tamper the AAD (header).
528        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); // Just the tag.
541
542        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        // Simulate two WAL lifetimes: same key bytes, same LSN=1, but
643        // separate WalEncryptionKey instances (each gets a fresh random epoch).
644        // This models: write at LSN=1, wipe WAL, restart with same key,
645        // write at LSN=1 again. The two ciphertexts must differ.
646        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        // SPEC: different WAL lifetimes (different epochs) must produce
656        // different ciphertext even with the same key bytes and LSN.
657        assert_ne!(
658            ct1, ct2,
659            "nonce reuse: same (key_bytes, lsn) must not produce identical ciphertext across WAL lifetimes"
660        );
661    }
662}