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
28/// Security gate for WAL key files: enforces no-symlink, regular-file,
29/// Unix mode bits (no group/world), and owner-UID checks.
30///
31/// Mirrors the logic in `nodedb::control::security::keystore::key_file_security`
32/// but is duplicated here so that `nodedb-wal` has zero dependency on the
33/// `nodedb` application crate.
34fn 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        // SAFETY: geteuid() is always safe to call; it has no preconditions.
73        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    // On Windows: ACL-based permission enforcement is not implemented.
88    // The symlink check above still applies on all platforms.
89
90    Ok(())
91}
92
93/// AES-256-GCM key with a random per-lifetime epoch for nonce disambiguation.
94///
95/// The epoch is generated randomly at construction time. Each WAL lifetime
96/// (process start, snapshot restore, segment creation) gets a fresh epoch,
97/// ensuring that nonces are never reused even if LSNs restart from 1.
98#[derive(Clone)]
99pub struct WalEncryptionKey {
100    cipher: Aes256Gcm,
101    /// Key material held in a zeroized, mlocked allocation (kept for producing
102    /// new instances with a fresh epoch). Shared via `Arc` so cloning a key —
103    /// or rolling to a fresh epoch — does not duplicate the secret or its
104    /// `mlock`; the single allocation is zeroized and munlocked when the last
105    /// reference drops.
106    key: Arc<SecureKey>,
107    /// Random 4-byte epoch: occupies the high 4 bytes of the 12-byte nonce.
108    /// Disambiguates nonces across WAL lifetimes with the same key.
109    epoch: [u8; 4],
110}
111
112impl WalEncryptionKey {
113    /// Create from a 32-byte key with a fresh random epoch.
114    ///
115    /// Returns an error if the OS RNG (`getrandom`) is unavailable. Without
116    /// a fresh epoch we cannot guarantee nonce uniqueness across WAL
117    /// lifetimes, so panicking would silently risk nonce reuse on RNG
118    /// failure — better to surface it.
119    ///
120    /// The key material is moved into a [`SecureKey`], which mlocks it against
121    /// swap and zeroizes it on drop.
122    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    /// 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: Arc::new(SecureKey::new(*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    ///
147    /// The mlocked key allocation is shared with `self` via `Arc`; only the
148    /// epoch differs, so no additional secret copy is made.
149    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    /// Load key from a file (must contain exactly 32 bytes).
158    ///
159    /// Before reading, the file is checked for:
160    /// - No symlinks (TOCTOU / path-traversal risk).
161    /// - Regular file (not a device, FIFO, or directory).
162    /// - Unix: no group or world access bits (`mode & 0o077 == 0`).
163    /// - Unix: file owner matches the current process UID.
164    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    /// Encrypt a payload. Returns ciphertext + auth_tag (16 bytes appended).
181    ///
182    /// - `lsn`: used to derive a deterministic nonce
183    /// - `header_bytes`: used as AAD (additional authenticated data)
184    /// - `plaintext`: the payload to encrypt
185    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    /// Encrypt with a caller-provided AAD slice (may be longer than HEADER_SIZE,
195    /// e.g. preamble bytes prepended to the header).
196    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    /// The random epoch for this key instance.
212    pub fn epoch(&self) -> &[u8; 4] {
213        &self.epoch
214    }
215
216    /// Decrypt a payload. Input is ciphertext + auth_tag (16 bytes at end).
217    ///
218    /// - `epoch`: must be the epoch that was used during encryption, read from
219    ///   the on-disk segment preamble — **not** `self.epoch`, which reflects
220    ///   the current in-memory lifetime and may differ after a restart.
221    /// - `lsn`: must match the LSN used during encryption
222    /// - `header_bytes`: must match the header used during encryption (AAD)
223    /// - `ciphertext`: the encrypted payload (includes 16-byte auth tag)
224    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    /// Decrypt with a caller-provided AAD slice.
235    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/// Key ring supporting dual-key reads for seamless key rotation.
258///
259/// During rotation: new writes use the current key, reads try current
260/// then fall back to previous. Once all old data is re-encrypted,
261/// the previous key is removed.
262#[derive(Clone)]
263pub struct KeyRing {
264    current: WalEncryptionKey,
265    previous: Option<WalEncryptionKey>,
266}
267
268impl KeyRing {
269    /// Create a key ring with only the current key.
270    pub fn new(current: WalEncryptionKey) -> Self {
271        Self {
272            current,
273            previous: None,
274        }
275    }
276
277    /// Create a key ring with current + previous key (for rotation).
278    pub fn with_previous(current: WalEncryptionKey, previous: WalEncryptionKey) -> Self {
279        Self {
280            current,
281            previous: Some(previous),
282        }
283    }
284
285    /// Encrypt using the current key.
286    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    /// Encrypt with a caller-provided AAD slice.
296    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    /// Decrypt: try current key first, then previous (if set).
301    ///
302    /// `epoch` is the encryption epoch stored in the WAL segment preamble.
303    /// This enables seamless key rotation — old data encrypted with the
304    /// previous key can still be read while new data uses the current key.
305    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    /// Decrypt with a caller-provided AAD slice.
316    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    /// Get the current key (for encryption operations).
334    pub fn current(&self) -> &WalEncryptionKey {
335        &self.current
336    }
337
338    /// Whether a previous key is present (rotation in progress).
339    pub fn has_previous(&self) -> bool {
340        self.previous.is_some()
341    }
342
343    /// Remove the previous key (rotation complete).
344    pub fn clear_previous(&mut self) {
345        self.previous = None;
346    }
347}
348
349/// AES-256-GCM auth tag size in bytes.
350pub const AUTH_TAG_SIZE: usize = 16;
351
352// ── Segment envelope ───────────────────────────────────────────────────────
353//
354// Shared at-rest framing reused by every engine (array, columnar, vector,
355// spatial, timeseries) so the AES-256-GCM call site lives in one place.
356//
357// Layout:
358// ```text
359// [magic (4B)] [version_u16_le (2B)] [cipher_u8 (1B)] [kid_u8 (1B)]
360// [epoch (4B)] [reserved (4B)] [AES-256-GCM ciphertext (plaintext + 16B tag)]
361// ```
362//
363// The 16-byte preamble is supplied as AAD, preventing preamble-swap attacks.
364// The nonce is derived from `(epoch, lsn = 0)`; per-write random epoch
365// guarantees nonce uniqueness without needing a monotonic counter.
366
367/// Size of the segment envelope preamble in bytes.
368pub const SEGMENT_ENVELOPE_PREAMBLE_SIZE: usize = 16;
369
370/// Minimum size of a valid encrypted envelope: preamble + AES-GCM auth tag.
371pub const SEGMENT_ENVELOPE_MIN_SIZE: usize = SEGMENT_ENVELOPE_PREAMBLE_SIZE + AUTH_TAG_SIZE;
372
373/// Current segment-envelope preamble layout version.
374const SEGMENT_ENVELOPE_VERSION: u16 = 1;
375
376/// Cipher algorithm tag stored in the preamble: 0 = AES-256-GCM.
377const SEGMENT_ENVELOPE_CIPHER_AES_256_GCM: u8 = 0;
378
379/// Fixed LSN input for envelope nonces. Per-write random epoch (stored in
380/// the preamble) is what actually disambiguates nonces.
381const 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; // kid = 0 (current KEK)
392    buf[8..12].copy_from_slice(epoch);
393    // buf[12..16] = reserved zeros
394    buf
395}
396
397/// Encrypt `plaintext` into a self-describing segment envelope.
398///
399/// Returns `preamble || AES-256-GCM(plaintext) || auth_tag`. The caller
400/// supplies the 4-byte `magic` that identifies its envelope variant
401/// (`SEGA`, `SEGC`, `SEGT`, `SEGV`, …).
402pub 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
417/// Decrypt a segment envelope produced by [`encrypt_segment_envelope`].
418///
419/// The caller supplies the expected `magic`; mismatches surface as
420/// [`WalError::EncryptionError`].
421pub 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
451/// Generate a fresh random 4-byte epoch.
452///
453/// Returns an error if the OS RNG (`getrandom`) is unavailable — a fresh epoch
454/// is what guarantees nonce uniqueness across WAL lifetimes, so RNG failure
455/// must surface rather than silently risk nonce reuse.
456fn 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
464/// Derive a 12-byte nonce from an epoch and LSN.
465///
466/// AES-256-GCM requires a 96-bit (12 byte) nonce that must never repeat
467/// for the same key. Layout: `[4-byte random epoch][8-byte LSN]`.
468/// The epoch is generated randomly per WAL lifetime, so even if LSNs
469/// restart from 1 after a snapshot restore, the nonces remain unique.
470fn 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        // Different LSN = different nonce = decryption fails.
525        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        // Tamper the AAD (header).
548        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); // Just the tag.
561
562        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        // Simulate two WAL lifetimes: same key bytes, same LSN=1, but
663        // separate WalEncryptionKey instances (each gets a fresh random epoch).
664        // This models: write at LSN=1, wipe WAL, restart with same key,
665        // write at LSN=1 again. The two ciphertexts must differ.
666        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        // SPEC: different WAL lifetimes (different epochs) must produce
676        // different ciphertext even with the same key bytes and LSN.
677        assert_ne!(
678            ct1, ct2,
679            "nonce reuse: same (key_bytes, lsn) must not produce identical ciphertext across WAL lifetimes"
680        );
681    }
682}