Skip to main content

nodedb_wal/
preamble.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! Segment preamble: 16-byte plaintext header written at offset 0 of every
4//! WAL segment file and every storage segment file.
5//!
6//! The preamble persists the encryption epoch so that the correct nonce can be
7//! reconstructed after a process restart, snapshot restore, or segment copy.
8//!
9//! ## On-disk layout (16 bytes, plaintext)
10//!
11//! ```text
12//! Bytes  Field        Type     Notes
13//! ─────────────────────────────────────────────────────────────────
14//!  0.. 4  magic        [u8;4]   b"WALP" for WAL segments
15//!                               b"SEGP" for storage segments
16//!  4.. 6  version      u16 LE   Must be 1; future versions can change layout
17//!  6.. 7  cipher_alg   u8       0 = AES-256-GCM; all others reserved
18//!  7.. 8  kid          u8       Key ID within a key-ring slot (0 = current KEK)
19//!  8..12  epoch        [u8;4]   Random per-WAL-lifetime epoch for nonce construction
20//! 12..16  reserved     [u8;4]   Must be zero on write; ignored on read
21//! ```
22//!
23//! The preamble is **plaintext** and is included as Additional Authenticated
24//! Data (AAD) on every encrypted record / segment payload. This prevents an
25//! attacker from swapping preambles between segments (preamble-swap defense).
26//!
27//! ## Rejected versions
28//!
29//! Any preamble with `version != PREAMBLE_VERSION` is rejected at open time.
30//! Pre-launch: no migration path. Hard error, not a warning.
31
32use crate::error::{Result, WalError};
33
34/// Size of the preamble in bytes.
35pub const PREAMBLE_SIZE: usize = 16;
36
37/// Preamble version this binary understands.
38pub const PREAMBLE_VERSION: u16 = 1;
39
40/// `cipher_alg` value for AES-256-GCM.
41pub const CIPHER_AES_256_GCM: u8 = 0;
42
43/// Magic bytes for WAL segment preambles.
44pub const WAL_PREAMBLE_MAGIC: [u8; 4] = *b"WALP";
45
46/// Magic bytes for storage segment preambles.
47pub const SEG_PREAMBLE_MAGIC: [u8; 4] = *b"SEGP";
48
49/// Preamble written at offset 0 of every segment file (WAL or storage).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct SegmentPreamble {
52    /// File-type magic: [`WAL_PREAMBLE_MAGIC`] or [`SEG_PREAMBLE_MAGIC`].
53    pub magic: [u8; 4],
54    /// Layout version (must equal [`PREAMBLE_VERSION`]).
55    pub version: u16,
56    /// Cipher algorithm: 0 = AES-256-GCM.
57    pub cipher_alg: u8,
58    /// Key ID within the active key ring (0 = current KEK).
59    pub kid: u8,
60    /// Random epoch generated at WAL-lifetime start; used as the high 4 bytes
61    /// of every AES-GCM nonce in this segment.
62    pub epoch: [u8; 4],
63    // reserved: 4 bytes (zero on write, ignored on read)
64}
65
66impl SegmentPreamble {
67    /// Construct a new preamble for a WAL segment.
68    pub fn new_wal(epoch: [u8; 4]) -> Self {
69        Self {
70            magic: WAL_PREAMBLE_MAGIC,
71            version: PREAMBLE_VERSION,
72            cipher_alg: CIPHER_AES_256_GCM,
73            kid: 0,
74            epoch,
75        }
76    }
77
78    /// Construct a new preamble for a storage segment.
79    pub fn new_seg(epoch: [u8; 4]) -> Self {
80        Self {
81            magic: SEG_PREAMBLE_MAGIC,
82            version: PREAMBLE_VERSION,
83            cipher_alg: CIPHER_AES_256_GCM,
84            kid: 0,
85            epoch,
86        }
87    }
88
89    /// Serialize to exactly [`PREAMBLE_SIZE`] bytes.
90    pub fn to_bytes(&self) -> [u8; PREAMBLE_SIZE] {
91        let mut buf = [0u8; PREAMBLE_SIZE];
92        buf[0..4].copy_from_slice(&self.magic);
93        buf[4..6].copy_from_slice(&self.version.to_le_bytes());
94        buf[6] = self.cipher_alg;
95        buf[7] = self.kid;
96        buf[8..12].copy_from_slice(&self.epoch);
97        // buf[12..16] stays zero (reserved)
98        buf
99    }
100
101    /// Deserialize and validate a preamble, checking magic and version.
102    ///
103    /// `expected_magic` must be either [`WAL_PREAMBLE_MAGIC`] or
104    /// [`SEG_PREAMBLE_MAGIC`].
105    pub fn from_bytes(buf: &[u8; PREAMBLE_SIZE], expected_magic: &[u8; 4]) -> Result<Self> {
106        let magic: [u8; 4] = [buf[0], buf[1], buf[2], buf[3]];
107
108        if &magic != expected_magic {
109            return Err(WalError::EncryptionError {
110                detail: format!(
111                    "preamble magic mismatch: expected {:?}, got {:?}",
112                    expected_magic, magic
113                ),
114            });
115        }
116
117        let version = u16::from_le_bytes([buf[4], buf[5]]);
118        if version != PREAMBLE_VERSION {
119            return Err(WalError::UnsupportedVersion {
120                version,
121                supported: PREAMBLE_VERSION,
122            });
123        }
124
125        let cipher_alg = buf[6];
126        let kid = buf[7];
127        let epoch: [u8; 4] = [buf[8], buf[9], buf[10], buf[11]];
128
129        Ok(Self {
130            magic,
131            version,
132            cipher_alg,
133            kid,
134            epoch,
135        })
136    }
137
138    /// The epoch bytes, for passing to nonce construction.
139    pub fn epoch(&self) -> &[u8; 4] {
140        &self.epoch
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn wal_preamble_roundtrip() {
150        let epoch = [0xAA, 0xBB, 0xCC, 0xDD];
151        let p = SegmentPreamble::new_wal(epoch);
152        let bytes = p.to_bytes();
153        let parsed = SegmentPreamble::from_bytes(&bytes, &WAL_PREAMBLE_MAGIC).unwrap();
154        assert_eq!(p, parsed);
155        assert_eq!(parsed.epoch, epoch);
156        assert_eq!(parsed.cipher_alg, CIPHER_AES_256_GCM);
157        assert_eq!(parsed.kid, 0);
158        assert_eq!(parsed.version, PREAMBLE_VERSION);
159    }
160
161    #[test]
162    fn seg_preamble_roundtrip() {
163        let epoch = [0x11, 0x22, 0x33, 0x44];
164        let p = SegmentPreamble::new_seg(epoch);
165        let bytes = p.to_bytes();
166        let parsed = SegmentPreamble::from_bytes(&bytes, &SEG_PREAMBLE_MAGIC).unwrap();
167        assert_eq!(p, parsed);
168    }
169
170    #[test]
171    fn wrong_magic_rejected() {
172        let p = SegmentPreamble::new_wal([0u8; 4]);
173        let bytes = p.to_bytes();
174        // Reading a WAL preamble as a SEG preamble must fail.
175        assert!(SegmentPreamble::from_bytes(&bytes, &SEG_PREAMBLE_MAGIC).is_err());
176    }
177
178    #[test]
179    fn unsupported_version_rejected() {
180        let p = SegmentPreamble::new_wal([0u8; 4]);
181        let mut bytes = p.to_bytes();
182        // Bump version to 2 (unsupported).
183        bytes[4] = 2;
184        bytes[5] = 0;
185        assert!(matches!(
186            SegmentPreamble::from_bytes(&bytes, &WAL_PREAMBLE_MAGIC),
187            Err(WalError::UnsupportedVersion { version: 2, .. })
188        ));
189    }
190
191    #[test]
192    fn kid_and_cipher_alg_roundtrip() {
193        // Construct manually with non-zero kid.
194        let p = SegmentPreamble {
195            magic: WAL_PREAMBLE_MAGIC,
196            version: PREAMBLE_VERSION,
197            cipher_alg: CIPHER_AES_256_GCM,
198            kid: 3,
199            epoch: [1, 2, 3, 4],
200        };
201        let bytes = p.to_bytes();
202        let parsed = SegmentPreamble::from_bytes(&bytes, &WAL_PREAMBLE_MAGIC).unwrap();
203        assert_eq!(parsed.kid, 3);
204        assert_eq!(parsed.epoch, [1, 2, 3, 4]);
205    }
206
207    #[test]
208    fn reserved_bytes_are_zero() {
209        let p = SegmentPreamble::new_wal([0xFF; 4]);
210        let bytes = p.to_bytes();
211        assert_eq!(&bytes[12..16], &[0u8, 0, 0, 0]);
212    }
213}