Skip to main content

zlayer_secrets/
cluster_dek.rs

1//! Cluster Data Encryption Key (DEK) primitives for Phase 1
2//! cluster-replicated secrets.
3//!
4//! The DEK itself never sits on disk — only sealed-box wraps (one per
5//! node) live in Raft via [`zlayer_types::storage::WrappedDek`]. Each
6//! node unwraps its own copy on startup with its node X25519 private key
7//! ([`crate::sealed::RecipientPrivateKey`]), holds it in [`Zeroizing`]
8//! memory, and uses it to en/decrypt [`zlayer_types::storage::ReplicatedSecret`]
9//! ciphertexts via XChaCha20-Poly1305 with a random 24-byte nonce
10//! prepended (matching the on-disk format used by [`crate::EncryptionKey`]).
11
12use std::collections::HashMap;
13
14use chacha20poly1305::aead::{Aead, KeyInit};
15use chacha20poly1305::{XChaCha20Poly1305, XNonce};
16use rand::rngs::OsRng;
17use rand::TryRngCore;
18use zeroize::Zeroizing;
19
20use zlayer_types::storage::WrappedDek;
21
22use crate::sealed::{self, RecipientPrivateKey, RecipientPublicKey};
23use crate::SecretsError;
24
25/// Size of the DEK in bytes (XChaCha20-Poly1305 key size).
26pub const DEK_SIZE: usize = 32;
27
28/// Size of the XChaCha20-Poly1305 nonce in bytes.
29pub const NONCE_SIZE: usize = 24;
30
31/// The cluster-wide DEK. 32 bytes of key material.
32///
33/// Held in [`Zeroizing`] memory; zeroed on drop. The type does NOT
34/// implement `Debug` / `Display` to keep accidental disclosure off
35/// the table.
36pub struct ClusterDek {
37    bytes: Zeroizing<[u8; DEK_SIZE]>,
38}
39
40impl ClusterDek {
41    /// Generate a fresh DEK from the operating system RNG.
42    ///
43    /// # Panics
44    /// Panics if the OS random number generator fails.
45    #[must_use]
46    pub fn generate() -> Self {
47        let mut bytes = Zeroizing::new([0u8; DEK_SIZE]);
48        OsRng.try_fill_bytes(bytes.as_mut()).expect("OS RNG failed");
49        Self { bytes }
50    }
51
52    /// Construct from raw bytes (e.g. after unwrapping). Bytes are copied
53    /// into the zeroized buffer; the source is left untouched.
54    #[must_use]
55    pub fn from_bytes(bytes: [u8; DEK_SIZE]) -> Self {
56        let mut buf = Zeroizing::new([0u8; DEK_SIZE]);
57        buf.copy_from_slice(&bytes);
58        Self { bytes: buf }
59    }
60
61    /// Sealed-box-wrap this DEK to a single recipient.
62    ///
63    /// Returns the raw libsodium sealed-box ciphertext bytes
64    /// (`ephemeral_pubkey || box(dek)`), suitable for direct insertion
65    /// into [`WrappedDek::wraps`].
66    ///
67    /// # Errors
68    /// Returns [`SecretsError::Encryption`] if the sealed-box construction
69    /// fails.
70    pub fn wrap(&self, recipient: &RecipientPublicKey) -> Result<Vec<u8>, SecretsError> {
71        sealed::seal_raw(self.bytes.as_ref(), recipient)
72            .map_err(|e| SecretsError::Encryption(format!("DEK wrap failed: {e}")))
73    }
74
75    /// Sealed-box-wrap this DEK to every recipient in a node-id-keyed map
76    /// and produce a [`WrappedDek`] envelope ready to commit through Raft.
77    ///
78    /// # Errors
79    /// Returns [`SecretsError::Encryption`] if any per-recipient wrap fails.
80    pub fn rewrap_for_set(
81        &self,
82        recipients: &HashMap<String, RecipientPublicKey>,
83        new_generation: u64,
84    ) -> Result<WrappedDek, SecretsError> {
85        let mut wraps: HashMap<String, Vec<u8>> = HashMap::with_capacity(recipients.len());
86        for (node_id, pubkey) in recipients {
87            let wrapped = self.wrap(pubkey).map_err(|e| {
88                SecretsError::Encryption(format!("DEK wrap failed for node {node_id}: {e}"))
89            })?;
90            wraps.insert(node_id.clone(), wrapped);
91        }
92        Ok(WrappedDek {
93            dek_generation: new_generation,
94            wraps,
95        })
96    }
97
98    /// Unwrap a sealed-box-encrypted DEK using a node's X25519 private key.
99    ///
100    /// # Errors
101    /// Returns [`SecretsError::Decryption`] if the sealed-box ciphertext
102    /// fails authentication, is malformed, or does not decode to exactly
103    /// 32 bytes of key material.
104    pub fn unwrap(node_priv: &RecipientPrivateKey, wrapped: &[u8]) -> Result<Self, SecretsError> {
105        let plaintext = sealed::open_raw(wrapped, node_priv)
106            .map_err(|e| SecretsError::Decryption(format!("DEK unwrap failed: {e}")))?;
107        if plaintext.len() != DEK_SIZE {
108            return Err(SecretsError::Decryption(format!(
109                "Unwrapped DEK has wrong length: expected {DEK_SIZE} bytes, got {}",
110                plaintext.len()
111            )));
112        }
113        // Copy into a fixed-size zeroized buffer; let the heap `Vec` drop
114        // (its `Zeroize` impl below scrubs the bytes first).
115        let mut buf = Zeroizing::new([0u8; DEK_SIZE]);
116        buf.copy_from_slice(&plaintext);
117        // Best-effort zeroize the heap-allocated unwrap buffer too.
118        let mut plaintext = plaintext;
119        zeroize::Zeroize::zeroize(&mut plaintext);
120        Ok(Self { bytes: buf })
121    }
122
123    /// Encrypt a plaintext payload under the DEK using XChaCha20-Poly1305.
124    /// On-wire format: `[24-byte nonce][ciphertext + tag]`.
125    ///
126    /// # Errors
127    /// Returns [`SecretsError::Encryption`] if cipher construction or the
128    /// AEAD encryption itself fails.
129    ///
130    /// # Panics
131    /// Panics if the OS random number generator fails to produce nonce bytes.
132    pub fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, SecretsError> {
133        let cipher = XChaCha20Poly1305::new_from_slice(self.bytes.as_ref())
134            .map_err(|e| SecretsError::Encryption(format!("Failed to create cipher: {e}")))?;
135
136        let mut nonce_bytes = [0u8; NONCE_SIZE];
137        OsRng
138            .try_fill_bytes(&mut nonce_bytes)
139            .expect("OS RNG failed");
140        let nonce = XNonce::from_slice(&nonce_bytes);
141
142        let ciphertext = cipher
143            .encrypt(nonce, plaintext)
144            .map_err(|e| SecretsError::Encryption(format!("Encryption failed: {e}")))?;
145
146        let mut out = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
147        out.extend_from_slice(&nonce_bytes);
148        out.extend_from_slice(&ciphertext);
149        Ok(out)
150    }
151
152    /// Decrypt the inverse of [`Self::encrypt`].
153    ///
154    /// Returns the plaintext wrapped in [`Zeroizing`] so it is scrubbed
155    /// from memory when the caller drops it.
156    ///
157    /// # Errors
158    /// Returns [`SecretsError::Decryption`] if:
159    /// - `blob` is shorter than [`NONCE_SIZE`].
160    /// - Cipher construction fails.
161    /// - AEAD authentication or decryption fails.
162    pub fn decrypt(&self, blob: &[u8]) -> Result<Zeroizing<Vec<u8>>, SecretsError> {
163        if blob.len() < NONCE_SIZE {
164            return Err(SecretsError::Decryption(format!(
165                "Data too short: expected at least {NONCE_SIZE} bytes for nonce, got {}",
166                blob.len()
167            )));
168        }
169        let cipher = XChaCha20Poly1305::new_from_slice(self.bytes.as_ref())
170            .map_err(|e| SecretsError::Decryption(format!("Failed to create cipher: {e}")))?;
171
172        let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE);
173        let nonce = XNonce::from_slice(nonce_bytes);
174
175        let plaintext = cipher
176            .decrypt(nonce, ciphertext)
177            .map_err(|e| SecretsError::Decryption(format!("Decryption failed: {e}")))?;
178        Ok(Zeroizing::new(plaintext))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn dek_round_trip_wrap_unwrap() {
188        let (sk, pk) = RecipientPrivateKey::generate();
189        let dek = ClusterDek::generate();
190
191        // `Zeroizing<[u8; 32]>` derefs to `[u8; 32]`, which is `Copy`.
192        let original: [u8; DEK_SIZE] = *dek.bytes;
193
194        let wrapped = dek.wrap(&pk).expect("wrap");
195        let unwrapped = ClusterDek::unwrap(&sk, &wrapped).expect("unwrap");
196
197        assert_eq!(*unwrapped.bytes, original);
198    }
199
200    #[test]
201    fn dek_round_trip_encrypt_decrypt() {
202        let dek = ClusterDek::generate();
203        let payload = b"the rain in spain falls mainly on the plain";
204
205        let blob = dek.encrypt(payload).expect("encrypt");
206        // nonce + ciphertext + 16-byte poly1305 tag
207        assert!(blob.len() > NONCE_SIZE + payload.len());
208
209        let plaintext = dek.decrypt(&blob).expect("decrypt");
210        assert_eq!(plaintext.as_slice(), payload);
211    }
212
213    #[test]
214    fn dek_decrypt_tamper_detection() {
215        let dek = ClusterDek::generate();
216        let payload = b"important replicated secret";
217
218        let mut blob = dek.encrypt(payload).expect("encrypt");
219
220        // Flip a byte in the ciphertext region (after the 24-byte nonce).
221        // Use a deterministic offset within the ciphertext to keep the test
222        // reproducible; the tamper still triggers Poly1305 rejection.
223        let target = NONCE_SIZE + 3;
224        assert!(target < blob.len(), "blob too short to tamper");
225        blob[target] ^= 0xA5;
226
227        let result = dek.decrypt(&blob);
228        assert!(matches!(result, Err(SecretsError::Decryption(_))));
229    }
230
231    #[test]
232    fn rewrap_for_set_emits_per_node_wraps() {
233        let (sk_a, pk_a) = RecipientPrivateKey::generate();
234        let (sk_b, pk_b) = RecipientPrivateKey::generate();
235
236        let mut recipients = HashMap::new();
237        recipients.insert("node-a".to_string(), pk_a);
238        recipients.insert("node-b".to_string(), pk_b);
239
240        let dek = ClusterDek::generate();
241        let original: [u8; DEK_SIZE] = *dek.bytes;
242
243        let envelope = dek.rewrap_for_set(&recipients, 7).expect("rewrap");
244
245        assert_eq!(envelope.dek_generation, 7);
246        assert_eq!(envelope.wraps.len(), 2);
247        assert!(envelope.wraps.contains_key("node-a"));
248        assert!(envelope.wraps.contains_key("node-b"));
249
250        // Each node can unwrap its own copy and recover the same DEK bytes.
251        let unwrapped_a = ClusterDek::unwrap(&sk_a, &envelope.wraps["node-a"]).expect("unwrap a");
252        let unwrapped_b = ClusterDek::unwrap(&sk_b, &envelope.wraps["node-b"]).expect("unwrap b");
253
254        assert_eq!(*unwrapped_a.bytes, original);
255        assert_eq!(*unwrapped_b.bytes, original);
256    }
257
258    #[test]
259    fn unwrap_with_wrong_key_fails() {
260        let (_sk_a, pk_a) = RecipientPrivateKey::generate();
261        let (sk_b, _pk_b) = RecipientPrivateKey::generate();
262
263        let dek = ClusterDek::generate();
264        let wrapped = dek.wrap(&pk_a).expect("wrap to A");
265
266        let result = ClusterDek::unwrap(&sk_b, &wrapped);
267        assert!(matches!(result, Err(SecretsError::Decryption(_))));
268    }
269}