Skip to main content

nodedb_types/backup_envelope/
crypto.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Per-backup DEK + KEK wrapping for encrypted backup envelopes.
4//!
5//! ## Wire layout after the 52-byte HEADER (version byte = 1):
6//!
7//! ```text
8//! ┌─ CRYPTO BLOCK (68 bytes) ──────────────────────────────────────────────┐
9//! │ kek_fingerprint : [u8; 8]   first 8 bytes of SHA-256(KEK)             │
10//! │ dek_nonce       : [u8; 12]  AES-256-GCM nonce for DEK wrapping        │
11//! │ wrapped_dek     : [u8; 48]  AES-256-GCM(KEK, dek_nonce, DEK)         │
12//! │                             = 32-byte ciphertext + 16-byte tag        │
13//! └────────────────────────────────────────────────────────────────────────┘
14//! ┌─ ENCRYPTED SECTION × section_cnt ──────────────────────────────────────┐
15//! │ origin_node_id  : u64                                                  │
16//! │ body_len        : u32  (length of ciphertext, i.e. plaintext + 16 tag) │
17//! │ section_nonce   : [u8; 12]  fresh random nonce per section             │
18//! │ encrypted_body  : body_len bytes  (AES-256-GCM ciphertext + tag)       │
19//! │ body_crc        : u32  (crc32c of the ciphertext — wire error only)    │
20//! └────────────────────────────────────────────────────────────────────────┘
21//! ┌─ TRAILER ───────────────────────────────────────────────────────────────┐
22//! │ trailer_crc : u32  (crc32c over everything preceding this field)       │
23//! └─────────────────────────────────────────────────────────────────────────┘
24//! ```
25//!
26//! ### Nonce strategy
27//!
28//! Each section receives an independent 12-byte random nonce from
29//! `getrandom`. Random per-section nonces are preferred over
30//! deterministic `base_nonce XOR section_index` because they carry no
31//! implicit ordering requirement and remain safe even if sections are
32//! ever reordered, duplicated by a buggy producer, or if the same DEK
33//! is reused across multiple calls (it must not be, but the random nonce
34//! provides defence in depth). With 12-byte nonces the collision
35//! probability for a single DEK lifetime (one backup) is negligible even
36//! for millions of sections: P ≈ n²/2^97.
37//!
38//! ### KEK fingerprint
39//!
40//! The first 8 bytes of SHA-256(KEK) are embedded so that the restore
41//! path can detect a mismatched KEK before attempting any cryptography,
42//! providing a clear `WrongBackupKek` error rather than an opaque
43//! authentication failure.
44
45use aes_gcm::Aes256Gcm;
46// aes-gcm 0.10 still re-exports `generic_array` 0.14 even though that crate's
47// types are now marked deprecated in favour of generic-array 1.x. Upgrading is
48// gated on aes-gcm itself shipping a release on generic-array 1.x; until then
49// allow the deprecation locally rather than spamming the build log.
50#[allow(deprecated)]
51use aes_gcm::aead::generic_array::GenericArray;
52use aes_gcm::aead::{Aead, KeyInit};
53use sha2::{Digest, Sha256};
54
55use super::types::{Envelope, EnvelopeError, EnvelopeMeta, Section, read2, read4, read8};
56use super::types::{HEADER_LEN, MAGIC, TRAILER_LEN, VERSION};
57use super::write::{EnvelopeWriter, write_header};
58
59/// Size of the crypto block inserted after the header in version-2 envelopes.
60///
61/// Layout: kek_fingerprint(8) + dek_nonce(12) + wrapped_dek(48) = 68 bytes.
62const CRYPTO_BLOCK_LEN: usize = 68;
63
64/// Per-section overhead in an encrypted envelope:
65/// origin(8) + body_len(4) + section_nonce(12) + body_crc(4) = 28 bytes.
66/// (The body itself is `plaintext_len + 16` due to the AES-GCM tag.)
67const ENC_SECTION_OVERHEAD: usize = 28;
68
69// ── KEK fingerprint ──────────────────────────────────────────────────────────
70
71/// Compute the 8-byte KEK fingerprint: first 8 bytes of SHA-256(kek).
72fn kek_fingerprint(kek: &[u8; 32]) -> [u8; 8] {
73    let hash = Sha256::digest(kek.as_slice());
74    let mut fp = [0u8; 8];
75    fp.copy_from_slice(&hash[..8]);
76    fp
77}
78
79// ── random helpers ───────────────────────────────────────────────────────────
80
81fn random_bytes<const N: usize>() -> Result<[u8; N], EnvelopeError> {
82    let mut buf = [0u8; N];
83    getrandom::fill(&mut buf).map_err(|e| EnvelopeError::RandomFailure(e.to_string()))?;
84    Ok(buf)
85}
86
87// ── AES-256-GCM helpers ──────────────────────────────────────────────────────
88
89#[allow(deprecated)]
90fn aes_encrypt(
91    key_bytes: &[u8; 32],
92    nonce_bytes: &[u8; 12],
93    plaintext: &[u8],
94) -> Result<Vec<u8>, EnvelopeError> {
95    let key = GenericArray::from(*key_bytes);
96    let cipher = Aes256Gcm::new(&key);
97    let nonce = GenericArray::from(*nonce_bytes);
98    cipher
99        .encrypt(&nonce, plaintext)
100        .map_err(|_| EnvelopeError::EncryptionFailed)
101}
102
103#[allow(deprecated)]
104fn aes_decrypt(
105    key_bytes: &[u8; 32],
106    nonce_bytes: &[u8; 12],
107    ciphertext: &[u8],
108) -> Result<Vec<u8>, EnvelopeError> {
109    let key = GenericArray::from(*key_bytes);
110    let cipher = Aes256Gcm::new(&key);
111    let nonce = GenericArray::from(*nonce_bytes);
112    cipher
113        .decrypt(&nonce, ciphertext)
114        .map_err(|_| EnvelopeError::DecryptionFailed)
115}
116
117// ── EnvelopeWriter extension ─────────────────────────────────────────────────
118
119impl EnvelopeWriter {
120    /// Finalize with encryption. Produces a version-1 encrypted envelope.
121    ///
122    /// - Generates a random 32-byte DEK via `getrandom`.
123    /// - Wraps the DEK with the KEK using AES-256-GCM (random 12-byte nonce).
124    /// - Encrypts each section body with the DEK (independent random 12-byte nonce).
125    /// - Embeds the KEK fingerprint so restore can detect the wrong KEK up front.
126    pub fn finalize_encrypted(self, kek: &[u8; 32]) -> Result<Vec<u8>, EnvelopeError> {
127        // Generate a fresh DEK for this backup.
128        let dek: [u8; 32] = random_bytes()?;
129
130        // Wrap the DEK with the KEK.
131        let dek_nonce: [u8; 12] = random_bytes()?;
132        let wrapped_dek = aes_encrypt(kek, &dek_nonce, &dek)?;
133        // wrapped_dek should be 48 bytes: 32 ciphertext + 16 tag.
134        debug_assert_eq!(wrapped_dek.len(), 48);
135
136        let fingerprint = kek_fingerprint(kek);
137
138        // Pre-encrypt all section bodies so we know the final size.
139        let mut enc_sections: Vec<(u64, [u8; 12], Vec<u8>)> =
140            Vec::with_capacity(self.sections.len());
141        for section in &self.sections {
142            let nonce: [u8; 12] = random_bytes()?;
143            let ciphertext = aes_encrypt(&dek, &nonce, &section.body)?;
144            enc_sections.push((section.origin_node_id, nonce, ciphertext));
145        }
146
147        // Compute total size.
148        let mut total_size = HEADER_LEN + CRYPTO_BLOCK_LEN + TRAILER_LEN;
149        for (_, _, ct) in &enc_sections {
150            total_size += ENC_SECTION_OVERHEAD + ct.len();
151        }
152
153        let mut out = Vec::with_capacity(total_size);
154
155        // Header.
156        write_header(&mut out, &self.meta, self.sections.len() as u16, VERSION);
157
158        // Crypto block.
159        out.extend_from_slice(&fingerprint);
160        out.extend_from_slice(&dek_nonce);
161        out.extend_from_slice(&wrapped_dek);
162
163        // Encrypted sections.
164        for (origin_node_id, nonce, ciphertext) in &enc_sections {
165            out.extend_from_slice(&origin_node_id.to_le_bytes());
166            out.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
167            out.extend_from_slice(nonce);
168            out.extend_from_slice(ciphertext);
169            let body_crc = crc32c::crc32c(ciphertext);
170            out.extend_from_slice(&body_crc.to_le_bytes());
171        }
172
173        // Trailer CRC over everything emitted so far.
174        let trailer_crc = crc32c::crc32c(&out);
175        out.extend_from_slice(&trailer_crc.to_le_bytes());
176
177        Ok(out)
178    }
179}
180
181// ── Decryption ────────────────────────────────────────────────────────────────
182
183/// Parse and decrypt an encrypted backup envelope (version 1 with crypto block).
184///
185/// Verifies the KEK fingerprint before attempting decryption, surfacing
186/// [`EnvelopeError::WrongBackupKek`] when the presented key does not match
187/// the one used at backup time. On tag mismatch surfaces
188/// [`EnvelopeError::DecryptionFailed`].
189pub fn parse_encrypted(
190    bytes: &[u8],
191    max_total: u64,
192    kek: &[u8; 32],
193) -> Result<Envelope, EnvelopeError> {
194    if bytes.len() as u64 > max_total {
195        return Err(EnvelopeError::OverSizeTotal { cap: max_total });
196    }
197
198    let min_len = HEADER_LEN + CRYPTO_BLOCK_LEN + TRAILER_LEN;
199    if bytes.len() < min_len {
200        return Err(EnvelopeError::Truncated);
201    }
202
203    // Header checks.
204    let header_bytes = &bytes[..HEADER_LEN];
205    if &header_bytes[0..4] != MAGIC {
206        return Err(EnvelopeError::BadMagic);
207    }
208    let version = header_bytes[4];
209    if version != VERSION {
210        return Err(EnvelopeError::UnsupportedVersion(version));
211    }
212
213    // Validate header CRC.
214    let claimed_header_crc = u32::from_le_bytes(read4(&header_bytes[48..52]));
215    let actual_header_crc = crc32c::crc32c(&header_bytes[..48]);
216    if claimed_header_crc != actual_header_crc {
217        return Err(EnvelopeError::HeaderCrcMismatch);
218    }
219
220    let meta = EnvelopeMeta {
221        tenant_id: u64::from_le_bytes(read8(&header_bytes[8..16])),
222        source_vshard_count: u16::from_le_bytes(read2(&header_bytes[16..18])),
223        hash_seed: u64::from_le_bytes(read8(&header_bytes[24..32])),
224        snapshot_watermark: u64::from_le_bytes(read8(&header_bytes[32..40])),
225    };
226    let section_count = u16::from_le_bytes(read2(&header_bytes[40..42]));
227
228    // Crypto block immediately after header.
229    let cb_start = HEADER_LEN;
230    let cb = &bytes[cb_start..cb_start + CRYPTO_BLOCK_LEN];
231    let stored_fingerprint: [u8; 8] = cb[0..8].try_into().expect("slice is 8 bytes");
232    let dek_nonce: [u8; 12] = cb[8..20].try_into().expect("slice is 12 bytes");
233    let wrapped_dek: &[u8] = &cb[20..68]; // 48 bytes
234
235    // Verify fingerprint before any crypto work.
236    let presented_fingerprint = kek_fingerprint(kek);
237    if presented_fingerprint != stored_fingerprint {
238        return Err(EnvelopeError::WrongBackupKek);
239    }
240
241    // Unwrap the DEK.
242    let dek_vec = aes_decrypt(kek, &dek_nonce, wrapped_dek)?;
243    if dek_vec.len() != 32 {
244        return Err(EnvelopeError::DecryptionFailed);
245    }
246    let mut dek = [0u8; 32];
247    dek.copy_from_slice(&dek_vec);
248
249    // Trailer.
250    let trailer_start = bytes.len() - TRAILER_LEN;
251    let claimed_trailer_crc = u32::from_le_bytes(read4(&bytes[trailer_start..]));
252    let actual_trailer_crc = crc32c::crc32c(&bytes[..trailer_start]);
253    if claimed_trailer_crc != actual_trailer_crc {
254        return Err(EnvelopeError::TrailerCrcMismatch);
255    }
256
257    // Parse and decrypt sections.
258    let mut cursor = HEADER_LEN + CRYPTO_BLOCK_LEN;
259    let mut sections = Vec::with_capacity(section_count as usize);
260
261    for _ in 0..section_count {
262        // Each encrypted section: origin(8) + body_len(4) + nonce(12) + ciphertext(body_len) + crc(4)
263        if cursor + ENC_SECTION_OVERHEAD > trailer_start {
264            return Err(EnvelopeError::Truncated);
265        }
266        let origin_node_id = u64::from_le_bytes(read8(&bytes[cursor..cursor + 8]));
267        let ct_len = u32::from_le_bytes(read4(&bytes[cursor + 8..cursor + 12])) as usize;
268        let nonce_start = cursor + 12;
269        let nonce_end = nonce_start + 12;
270        let ct_start = nonce_end;
271        let ct_end = ct_start + ct_len;
272        let crc_end = ct_end + 4;
273
274        if crc_end > trailer_start {
275            return Err(EnvelopeError::Truncated);
276        }
277
278        // Verify ciphertext CRC (wire error detection, not authentication).
279        let ciphertext = &bytes[ct_start..ct_end];
280        let claimed_body_crc = u32::from_le_bytes(read4(&bytes[ct_end..crc_end]));
281        if crc32c::crc32c(ciphertext) != claimed_body_crc {
282            return Err(EnvelopeError::BodyCrcMismatch);
283        }
284
285        let section_nonce: [u8; 12] = bytes[nonce_start..nonce_end]
286            .try_into()
287            .expect("slice is 12 bytes");
288
289        // Decrypt — this verifies the AES-GCM authentication tag.
290        let plaintext = aes_decrypt(&dek, &section_nonce, ciphertext)?;
291
292        sections.push(Section {
293            origin_node_id,
294            body: plaintext,
295        });
296        cursor = crc_end;
297    }
298
299    if cursor != trailer_start {
300        return Err(EnvelopeError::Truncated);
301    }
302
303    Ok(Envelope { meta, sections })
304}
305
306// ── tests ────────────────────────────────────────────────────────────────────
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::backup_envelope::types::{DEFAULT_MAX_TOTAL_BYTES, EnvelopeMeta};
312    use crate::backup_envelope::write::EnvelopeWriter;
313
314    fn meta() -> EnvelopeMeta {
315        EnvelopeMeta {
316            tenant_id: 77,
317            source_vshard_count: 256,
318            hash_seed: 0xCAFE,
319            snapshot_watermark: 999,
320        }
321    }
322
323    fn test_kek() -> [u8; 32] {
324        [0xA1u8; 32]
325    }
326
327    fn test_kek2() -> [u8; 32] {
328        [0xB2u8; 32]
329    }
330
331    fn make_writer_with_sections() -> EnvelopeWriter {
332        let mut w = EnvelopeWriter::new(meta());
333        w.push_section(1, b"alpha payload".to_vec()).unwrap();
334        w.push_section(2, b"beta data chunk".to_vec()).unwrap();
335        w.push_section(3, vec![]).unwrap();
336        w
337    }
338
339    #[test]
340    fn encrypted_roundtrips_with_correct_kek() {
341        let kek = test_kek();
342        let w = make_writer_with_sections();
343        let bytes = w.finalize_encrypted(&kek).unwrap();
344
345        let env = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek).unwrap();
346        assert_eq!(env.meta, meta());
347        assert_eq!(env.sections.len(), 3);
348        assert_eq!(env.sections[0].origin_node_id, 1);
349        assert_eq!(env.sections[0].body, b"alpha payload");
350        assert_eq!(env.sections[1].origin_node_id, 2);
351        assert_eq!(env.sections[1].body, b"beta data chunk");
352        assert_eq!(env.sections[2].body, b"");
353    }
354
355    #[test]
356    fn wrong_kek_returns_wrong_backup_kek_error() {
357        let kek = test_kek();
358        let wrong_kek = test_kek2();
359
360        let w = make_writer_with_sections();
361        let bytes = w.finalize_encrypted(&kek).unwrap();
362
363        assert_eq!(
364            parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &wrong_kek).unwrap_err(),
365            EnvelopeError::WrongBackupKek,
366        );
367    }
368
369    #[test]
370    fn tampering_with_section_body_fails_auth_tag() {
371        let kek = test_kek();
372        let mut w = EnvelopeWriter::new(meta());
373        w.push_section(1, b"secret data".to_vec()).unwrap();
374        let mut bytes = w.finalize_encrypted(&kek).unwrap();
375
376        // Locate the first section's ciphertext start:
377        // HEADER(52) + CRYPTO_BLOCK(68) + origin(8) + body_len(4) + nonce(12) = 144
378        let ct_start = HEADER_LEN + CRYPTO_BLOCK_LEN + 8 + 4 + 12;
379        bytes[ct_start] ^= 0xFF; // flip one byte
380
381        // Also fix the ciphertext CRC so it doesn't fail on wire check before crypto.
382        let ct_len_start = HEADER_LEN + CRYPTO_BLOCK_LEN + 8;
383        let ct_len =
384            u32::from_le_bytes(bytes[ct_len_start..ct_len_start + 4].try_into().unwrap()) as usize;
385        let crc_start = ct_start + ct_len;
386        let new_crc = crc32c::crc32c(&bytes[ct_start..crc_start]);
387        bytes[crc_start..crc_start + 4].copy_from_slice(&new_crc.to_le_bytes());
388
389        // Also fix the trailer CRC.
390        let trailer_start = bytes.len() - TRAILER_LEN;
391        let new_trailer = crc32c::crc32c(&bytes[..trailer_start]);
392        bytes[trailer_start..].copy_from_slice(&new_trailer.to_le_bytes());
393
394        let err = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek).unwrap_err();
395        assert_eq!(err, EnvelopeError::DecryptionFailed);
396    }
397
398    #[test]
399    fn tampering_with_wrapped_dek_fails_decryption() {
400        let kek = test_kek();
401        let mut w = EnvelopeWriter::new(meta());
402        w.push_section(1, b"data".to_vec()).unwrap();
403        let mut bytes = w.finalize_encrypted(&kek).unwrap();
404
405        // wrapped_dek starts at HEADER(52) + fingerprint(8) + dek_nonce(12) = 72
406        let wd_start = HEADER_LEN + 8 + 12;
407        bytes[wd_start] ^= 0xFF;
408
409        // Fix trailer CRC so decryption is attempted.
410        let trailer_start = bytes.len() - TRAILER_LEN;
411        let new_trailer = crc32c::crc32c(&bytes[..trailer_start]);
412        bytes[trailer_start..].copy_from_slice(&new_trailer.to_le_bytes());
413
414        let err = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek).unwrap_err();
415        assert_eq!(err, EnvelopeError::DecryptionFailed);
416    }
417
418    #[test]
419    fn backup_kek_and_wal_kek_are_independent() {
420        // Write two different key files and verify that `FileKeyProvider`-like
421        // usage returns distinct keys. Here we simulate the independence by
422        // showing two different byte arrays produce different fingerprints —
423        // which is what matters for the separate-KEK requirement.
424        let wal_kek = [0x11u8; 32];
425        let backup_kek = [0x22u8; 32];
426
427        assert_ne!(
428            kek_fingerprint(&wal_kek),
429            kek_fingerprint(&backup_kek),
430            "wal kek and backup kek must have different fingerprints"
431        );
432
433        // A backup encrypted with backup_kek cannot be opened with wal_kek.
434        let mut w = EnvelopeWriter::new(meta());
435        w.push_section(1, b"payload".to_vec()).unwrap();
436        let bytes = w.finalize_encrypted(&backup_kek).unwrap();
437
438        assert_eq!(
439            parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &wal_kek).unwrap_err(),
440            EnvelopeError::WrongBackupKek,
441        );
442
443        // And succeeds with backup_kek.
444        let env = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &backup_kek).unwrap();
445        assert_eq!(env.sections[0].body, b"payload");
446    }
447
448    #[test]
449    fn empty_envelope_encrypted_roundtrips() {
450        let kek = test_kek();
451        let bytes = EnvelopeWriter::new(meta())
452            .finalize_encrypted(&kek)
453            .unwrap();
454        let env = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek).unwrap();
455        assert_eq!(env.meta, meta());
456        assert!(env.sections.is_empty());
457    }
458}