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 = [`VERSION`]):
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 encrypted 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 an encrypted envelope of [`VERSION`].
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`] 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    // Ciphertext includes the mandatory AES-GCM authentication tag, so every
260    // encrypted section consumes its fixed framing plus at least 16 bytes.
261    let section_capacity = super::read::checked_section_capacity(
262        section_count,
263        trailer_start.saturating_sub(cursor),
264        ENC_SECTION_OVERHEAD + 16,
265    )?;
266    let mut sections = Vec::with_capacity(section_capacity);
267
268    for _ in 0..section_count {
269        // Each encrypted section: origin(8) + body_len(4) + nonce(12) + ciphertext(body_len) + crc(4)
270        if cursor + ENC_SECTION_OVERHEAD > trailer_start {
271            return Err(EnvelopeError::Truncated);
272        }
273        let origin_node_id = u64::from_le_bytes(read8(&bytes[cursor..cursor + 8]));
274        let ct_len = u32::from_le_bytes(read4(&bytes[cursor + 8..cursor + 12])) as usize;
275        let nonce_start = cursor + 12;
276        let nonce_end = nonce_start + 12;
277        let ct_start = nonce_end;
278        let ct_end = ct_start + ct_len;
279        let crc_end = ct_end + 4;
280
281        if crc_end > trailer_start {
282            return Err(EnvelopeError::Truncated);
283        }
284
285        // Verify ciphertext CRC (wire error detection, not authentication).
286        let ciphertext = &bytes[ct_start..ct_end];
287        let claimed_body_crc = u32::from_le_bytes(read4(&bytes[ct_end..crc_end]));
288        if crc32c::crc32c(ciphertext) != claimed_body_crc {
289            return Err(EnvelopeError::BodyCrcMismatch);
290        }
291
292        let section_nonce: [u8; 12] = bytes[nonce_start..nonce_end]
293            .try_into()
294            .expect("slice is 12 bytes");
295
296        // Decrypt — this verifies the AES-GCM authentication tag.
297        let plaintext = aes_decrypt(&dek, &section_nonce, ciphertext)?;
298
299        sections.push(Section {
300            origin_node_id,
301            body: plaintext,
302        });
303        cursor = crc_end;
304    }
305
306    if cursor != trailer_start {
307        return Err(EnvelopeError::Truncated);
308    }
309
310    Ok(Envelope { meta, sections })
311}
312
313// ── tests ────────────────────────────────────────────────────────────────────
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::backup_envelope::types::{DEFAULT_MAX_TOTAL_BYTES, EnvelopeMeta};
319    use crate::backup_envelope::write::EnvelopeWriter;
320
321    fn meta() -> EnvelopeMeta {
322        EnvelopeMeta {
323            tenant_id: 77,
324            source_vshard_count: 256,
325            hash_seed: 0xCAFE,
326            snapshot_watermark: 999,
327        }
328    }
329
330    fn test_kek() -> [u8; 32] {
331        [0xA1u8; 32]
332    }
333
334    fn test_kek2() -> [u8; 32] {
335        [0xB2u8; 32]
336    }
337
338    fn make_writer_with_sections() -> EnvelopeWriter {
339        let mut w = EnvelopeWriter::new(meta());
340        w.push_section(1, b"alpha payload".to_vec()).unwrap();
341        w.push_section(2, b"beta data chunk".to_vec()).unwrap();
342        w.push_section(3, vec![]).unwrap();
343        w
344    }
345
346    #[test]
347    fn encrypted_parse_rejects_huge_section_count_with_tiny_body_before_allocation() {
348        let kek = test_kek();
349        let mut bytes = EnvelopeWriter::new(meta())
350            .finalize_encrypted(&kek)
351            .unwrap();
352        bytes[40..42].copy_from_slice(&u16::MAX.to_le_bytes());
353        let header_crc = crc32c::crc32c(&bytes[..48]);
354        bytes[48..52].copy_from_slice(&header_crc.to_le_bytes());
355        let trailer_start = bytes.len() - TRAILER_LEN;
356        let trailer_crc = crc32c::crc32c(&bytes[..trailer_start]);
357        bytes[trailer_start..].copy_from_slice(&trailer_crc.to_le_bytes());
358        assert_eq!(
359            parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek),
360            Err(EnvelopeError::Truncated)
361        );
362    }
363
364    #[test]
365    fn encrypted_roundtrips_with_correct_kek() {
366        let kek = test_kek();
367        let w = make_writer_with_sections();
368        let bytes = w.finalize_encrypted(&kek).unwrap();
369
370        let env = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek).unwrap();
371        assert_eq!(env.meta, meta());
372        assert_eq!(env.sections.len(), 3);
373        assert_eq!(env.sections[0].origin_node_id, 1);
374        assert_eq!(env.sections[0].body, b"alpha payload");
375        assert_eq!(env.sections[1].origin_node_id, 2);
376        assert_eq!(env.sections[1].body, b"beta data chunk");
377        assert_eq!(env.sections[2].body, b"");
378    }
379
380    #[test]
381    fn wrong_kek_returns_wrong_backup_kek_error() {
382        let kek = test_kek();
383        let wrong_kek = test_kek2();
384
385        let w = make_writer_with_sections();
386        let bytes = w.finalize_encrypted(&kek).unwrap();
387
388        assert_eq!(
389            parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &wrong_kek).unwrap_err(),
390            EnvelopeError::WrongBackupKek,
391        );
392    }
393
394    #[test]
395    fn tampering_with_section_body_fails_auth_tag() {
396        let kek = test_kek();
397        let mut w = EnvelopeWriter::new(meta());
398        w.push_section(1, b"secret data".to_vec()).unwrap();
399        let mut bytes = w.finalize_encrypted(&kek).unwrap();
400
401        // Locate the first section's ciphertext start:
402        // HEADER(52) + CRYPTO_BLOCK(68) + origin(8) + body_len(4) + nonce(12) = 144
403        let ct_start = HEADER_LEN + CRYPTO_BLOCK_LEN + 8 + 4 + 12;
404        bytes[ct_start] ^= 0xFF; // flip one byte
405
406        // Also fix the ciphertext CRC so it doesn't fail on wire check before crypto.
407        let ct_len_start = HEADER_LEN + CRYPTO_BLOCK_LEN + 8;
408        let ct_len =
409            u32::from_le_bytes(bytes[ct_len_start..ct_len_start + 4].try_into().unwrap()) as usize;
410        let crc_start = ct_start + ct_len;
411        let new_crc = crc32c::crc32c(&bytes[ct_start..crc_start]);
412        bytes[crc_start..crc_start + 4].copy_from_slice(&new_crc.to_le_bytes());
413
414        // Also fix the trailer CRC.
415        let trailer_start = bytes.len() - TRAILER_LEN;
416        let new_trailer = crc32c::crc32c(&bytes[..trailer_start]);
417        bytes[trailer_start..].copy_from_slice(&new_trailer.to_le_bytes());
418
419        let err = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek).unwrap_err();
420        assert_eq!(err, EnvelopeError::DecryptionFailed);
421    }
422
423    #[test]
424    fn tampering_with_wrapped_dek_fails_decryption() {
425        let kek = test_kek();
426        let mut w = EnvelopeWriter::new(meta());
427        w.push_section(1, b"data".to_vec()).unwrap();
428        let mut bytes = w.finalize_encrypted(&kek).unwrap();
429
430        // wrapped_dek starts at HEADER(52) + fingerprint(8) + dek_nonce(12) = 72
431        let wd_start = HEADER_LEN + 8 + 12;
432        bytes[wd_start] ^= 0xFF;
433
434        // Fix trailer CRC so decryption is attempted.
435        let trailer_start = bytes.len() - TRAILER_LEN;
436        let new_trailer = crc32c::crc32c(&bytes[..trailer_start]);
437        bytes[trailer_start..].copy_from_slice(&new_trailer.to_le_bytes());
438
439        let err = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek).unwrap_err();
440        assert_eq!(err, EnvelopeError::DecryptionFailed);
441    }
442
443    #[test]
444    fn backup_kek_and_wal_kek_are_independent() {
445        // Write two different key files and verify that `FileKeyProvider`-like
446        // usage returns distinct keys. Here we simulate the independence by
447        // showing two different byte arrays produce different fingerprints —
448        // which is what matters for the separate-KEK requirement.
449        let wal_kek = [0x11u8; 32];
450        let backup_kek = [0x22u8; 32];
451
452        assert_ne!(
453            kek_fingerprint(&wal_kek),
454            kek_fingerprint(&backup_kek),
455            "wal kek and backup kek must have different fingerprints"
456        );
457
458        // A backup encrypted with backup_kek cannot be opened with wal_kek.
459        let mut w = EnvelopeWriter::new(meta());
460        w.push_section(1, b"payload".to_vec()).unwrap();
461        let bytes = w.finalize_encrypted(&backup_kek).unwrap();
462
463        assert_eq!(
464            parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &wal_kek).unwrap_err(),
465            EnvelopeError::WrongBackupKek,
466        );
467
468        // And succeeds with backup_kek.
469        let env = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &backup_kek).unwrap();
470        assert_eq!(env.sections[0].body, b"payload");
471    }
472
473    #[test]
474    fn empty_envelope_encrypted_roundtrips() {
475        let kek = test_kek();
476        let bytes = EnvelopeWriter::new(meta())
477            .finalize_encrypted(&kek)
478            .unwrap();
479        let env = parse_encrypted(&bytes, DEFAULT_MAX_TOTAL_BYTES, &kek).unwrap();
480        assert_eq!(env.meta, meta());
481        assert!(env.sections.is_empty());
482    }
483}