Skip to main content

nodedb_wal/
crypto.rs

1// SPDX-License-Identifier: Apache-2.0
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 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
34/// Security gate for WAL key files: enforces no-symlink, regular-file,
35/// Unix mode bits (no group/world), and owner-UID checks.
36///
37/// Mirrors the logic in `nodedb::control::security::keystore::key_file_security`
38/// but is duplicated here so that `nodedb-wal` has zero dependency on the
39/// `nodedb` application crate.
40fn 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        // SAFETY: geteuid() is always safe to call; it has no preconditions.
79        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    // On Windows: ACL-based permission enforcement is not implemented.
94    // The symlink check above still applies on all platforms.
95
96    Ok(())
97}
98
99/// AES-256-GCM key with a random per-lifetime epoch for nonce disambiguation.
100///
101/// The epoch is generated randomly at construction time. Each WAL lifetime
102/// (process start, snapshot restore, segment creation) gets a fresh epoch,
103/// ensuring that nonces are never reused even if LSNs restart from 1.
104#[derive(Clone)]
105pub struct WalEncryptionKey {
106    cipher: Aes256Gcm,
107    /// Key material held in a zeroized, mlocked allocation (kept for producing
108    /// new instances with a fresh epoch). Shared via `Arc` so cloning a key —
109    /// or rolling to a fresh epoch — does not duplicate the secret or its
110    /// `mlock`; the single allocation is zeroized and munlocked when the last
111    /// reference drops.
112    key: Arc<SecureKey>,
113    /// Random 4-byte epoch: occupies the high 4 bytes of the 12-byte nonce.
114    /// Disambiguates nonces across WAL lifetimes with the same key.
115    epoch: [u8; 4],
116}
117
118impl WalEncryptionKey {
119    /// Create from a 32-byte key with a fresh random epoch.
120    ///
121    /// Returns an error if the OS RNG (`getrandom`) is unavailable. Without
122    /// a fresh epoch we cannot guarantee nonce uniqueness across WAL
123    /// lifetimes, so panicking would silently risk nonce reuse on RNG
124    /// failure — better to surface it.
125    ///
126    /// The key material is moved into a [`SecureKey`], which mlocks it against
127    /// swap and zeroizes it on drop.
128    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    /// Create from a 32-byte key with a **caller-supplied epoch**.
137    ///
138    /// Use this when reopening a WAL segment whose epoch was read from the
139    /// on-disk preamble, so that the nonce is reconstructed identically to
140    /// the nonce used at encryption time.
141    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    /// Produce a new key instance with the same key material but a fresh
150    /// random epoch. Used when rolling to a new WAL segment — each segment
151    /// gets its own epoch so the per-segment nonce space is independent.
152    ///
153    /// The mlocked key allocation is shared with `self` via `Arc`; only the
154    /// epoch differs, so no additional secret copy is made.
155    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    /// Load key from a file (must contain exactly 32 bytes).
164    ///
165    /// Before reading, the file is checked for:
166    /// - No symlinks (TOCTOU / path-traversal risk).
167    /// - Regular file (not a device, FIFO, or directory).
168    /// - Unix: no group or world access bits (`mode & 0o077 == 0`).
169    /// - Unix: file owner matches the current process UID.
170    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    /// Encrypt a payload. Returns ciphertext + auth_tag (16 bytes appended).
187    ///
188    /// - `lsn`: used to derive a deterministic nonce
189    /// - `header_bytes`: used as AAD (additional authenticated data)
190    /// - `plaintext`: the payload to encrypt
191    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    /// Encrypt with a caller-provided AAD slice (may be longer than HEADER_SIZE,
201    /// e.g. preamble bytes prepended to the header).
202    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    /// The random epoch for this key instance.
218    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    /// Derive a domain-separated application subkey without exposing the WAL
227    /// encryption key itself. The result is stable across process lifetimes
228    /// for the same configured key and deliberately excludes the random WAL
229    /// nonce epoch.
230    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    /// Decrypt a payload. Input is ciphertext + auth_tag (16 bytes at end).
242    ///
243    /// - `epoch`: must be the epoch that was used during encryption, read from
244    ///   the on-disk segment preamble — **not** `self.epoch`, which reflects
245    ///   the current in-memory lifetime and may differ after a restart.
246    /// - `lsn`: must match the LSN used during encryption
247    /// - `header_bytes`: must match the header used during encryption (AAD)
248    /// - `ciphertext`: the encrypted payload (includes 16-byte auth tag)
249    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    /// Decrypt with a caller-provided AAD slice.
260    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/// Key ring supporting dual-key reads for seamless key rotation.
283///
284/// During rotation: new writes use the current key, reads try current
285/// then fall back to previous. Once all old data is re-encrypted,
286/// the previous key is removed.
287#[derive(Clone)]
288pub struct KeyRing {
289    current: WalEncryptionKey,
290    previous: Option<WalEncryptionKey>,
291}
292
293impl KeyRing {
294    /// Create a key ring with only the current key.
295    pub fn new(current: WalEncryptionKey) -> Self {
296        Self {
297            current,
298            previous: None,
299        }
300    }
301
302    /// Create a key ring with current + previous key (for rotation).
303    pub fn with_previous(current: WalEncryptionKey, previous: WalEncryptionKey) -> Self {
304        Self {
305            current,
306            previous: Some(previous),
307        }
308    }
309
310    /// Encrypt using the current key.
311    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    /// Encrypt with a caller-provided AAD slice.
321    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    /// Decrypt: try current key first, then previous (if set).
326    ///
327    /// `epoch` is the encryption epoch stored in the WAL segment preamble.
328    /// This enables seamless key rotation — old data encrypted with the
329    /// previous key can still be read while new data uses the current key.
330    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    /// Decrypt with a caller-provided AAD slice.
341    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    /// Get the current key (for encryption operations).
359    pub fn current(&self) -> &WalEncryptionKey {
360        &self.current
361    }
362
363    /// Previous key retained during an in-progress rotation.
364    pub fn previous(&self) -> Option<&WalEncryptionKey> {
365        self.previous.as_ref()
366    }
367
368    /// Whether a previous key is present (rotation in progress).
369    pub fn has_previous(&self) -> bool {
370        self.previous.is_some()
371    }
372
373    /// Remove the previous key (rotation complete).
374    pub fn clear_previous(&mut self) {
375        self.previous = None;
376    }
377}
378
379/// AES-256-GCM auth tag size in bytes.
380pub const AUTH_TAG_SIZE: usize = 16;
381
382/// Generate a fresh random 4-byte epoch.
383///
384/// Returns an error if the OS RNG (`getrandom`) is unavailable — a fresh epoch
385/// is what guarantees nonce uniqueness across WAL lifetimes, so RNG failure
386/// must surface rather than silently risk nonce reuse.
387fn 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
395/// Derive a 12-byte nonce from an epoch and LSN.
396///
397/// AES-256-GCM requires a 96-bit (12 byte) nonce that must never repeat
398/// for the same key. Layout: `[4-byte random epoch][8-byte LSN]`.
399/// The epoch is generated randomly per WAL lifetime, so even if LSNs
400/// restart from 1 after a snapshot restore, the nonces remain unique.
401fn 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        // Different LSN = different nonce = decryption fails.
456        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        // Tamper the AAD (header).
479        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); // Just the tag.
492
493        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        // Exercise the endpoints and the 32-bit transition in the LSN space,
514        // then add a fixed-size deterministic corpus. The production domain is
515        // the full u64 LSN space; this bounded test corpus makes the injective
516        // [epoch || lsn.to_le_bytes()] construction executable in unit tests.
517        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            // A fixed arithmetic/xorshift sequence produces a deterministic,
531            // well-distributed corpus; pair uniqueness is asserted below rather
532            // than assumed.
533            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        // Simulate two WAL lifetimes: same key bytes, same LSN=1, but
647        // separate WalEncryptionKey instances (each gets a fresh random epoch).
648        // This models: write at LSN=1, wipe WAL, restart with same key,
649        // write at LSN=1 again. The two ciphertexts must differ.
650        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        // SPEC: different WAL lifetimes (different epochs) must produce
660        // different ciphertext even with the same key bytes and LSN.
661        assert_ne!(
662            ct1, ct2,
663            "nonce reuse: same (key_bytes, lsn) must not produce identical ciphertext across WAL lifetimes"
664        );
665    }
666}