bp_core/storage/
encryption.rs

1//! Per-chunk encryption for BillPouch storage ("encryption at rest").
2//!
3//! ## Overview
4//!
5//! Before a chunk is RLNC-encoded and distributed across Pouch peers, it is
6//! encrypted with a **per-user Content Encryption Key (CEK)** using
7//! ChaCha20-Poly1305.  This ensures that:
8//!
9//! 1. Pouch nodes holding fragments never have access to plaintext data.
10//! 2. Even nodes in the same network cannot read files owned by other users.
11//!
12//! ## CEK derivation
13//!
14//! ```text
15//! cek = BLAKE3_keyed(identity.secret_material(),
16//!                    "billpouch/cek/v1" || BLAKE3(plaintext_chunk))
17//! ```
18//!
19//! The CEK is deterministic for a given `(identity, plaintext chunk)` pair.
20//! The daemon stores `(chunk_id → plaintext_hash)` in memory so that
21//! [`ChunkCipher::for_user`] can be re-derived at GetFile time.
22//!
23//! ## On-disk / on-wire format
24//!
25//! ```text
26//! [ nonce (12 bytes) | ciphertext + poly1305 tag (len + 16 bytes) ]
27//! ```
28//!
29//! The nonce is randomly generated at encryption time.  The total overhead is
30//! 28 bytes per chunk (12-byte nonce + 16-byte authentication tag).
31
32use crate::error::{BpError, BpResult};
33use chacha20poly1305::{
34    aead::{Aead, KeyInit},
35    ChaCha20Poly1305, Key, Nonce,
36};
37use rand::RngCore;
38
39/// Nonce length for ChaCha20-Poly1305.
40const NONCE_LEN: usize = 12;
41
42/// `ChunkCipher` wraps the ChaCha20-Poly1305 key used to encrypt and decrypt
43/// chunk data before/after RLNC coding.
44///
45/// **Production path:** create via [`ChunkCipher::for_user`] which derives a
46/// CEK from the owner’s identity secret and the plaintext chunk hash.
47pub struct ChunkCipher {
48    cipher: ChaCha20Poly1305,
49}
50
51impl ChunkCipher {
52    /// Derive the Content Encryption Key for a chunk and build a cipher.
53    ///
54    /// `secret_material` comes from [`crate::identity::Identity::secret_material`].
55    /// `plaintext_hash` is `BLAKE3(chunk_data)` computed before encryption.
56    ///
57    /// ```text
58    /// cek = BLAKE3_keyed(secret_material, "billpouch/cek/v1" || plaintext_hash)
59    /// ```
60    pub fn for_user(secret_material: &[u8; 32], plaintext_hash: &[u8; 32]) -> Self {
61        let mut h = blake3::Hasher::new_keyed(secret_material);
62        h.update(b"billpouch/cek/v1");
63        h.update(plaintext_hash);
64        let cek: [u8; 32] = *h.finalize().as_bytes();
65        Self::from_raw_key(cek)
66    }
67
68    /// Build a cipher from a raw 32-byte key.
69    ///
70    /// Useful for tests and for future key-wrapping schemes.
71    pub fn from_raw_key(key: [u8; 32]) -> Self {
72        let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
73        Self { cipher }
74    }
75
76    /// **Test/legacy only** — derive a cipher from a network-scoped key.
77    ///
78    /// <div class="warning">
79    /// Not for production: uses the insecure deterministic
80    /// [`NetworkMetaKey::for_network`] derivation.  Use [`for_user`](Self::for_user)
81    /// in production code.
82    /// </div>
83    #[doc(hidden)]
84    pub fn for_network(network_id: &str) -> Self {
85        use crate::storage::manifest::NetworkMetaKey;
86        let net_key = NetworkMetaKey::for_network(network_id);
87        Self::from_meta_key(&net_key)
88    }
89
90    /// Build a cipher from an already-computed [`NetworkMetaKey`].
91    ///
92    /// **Test/legacy only** — prefer [`for_user`](Self::for_user).
93    #[doc(hidden)]
94    pub fn from_meta_key(meta_key: &crate::storage::manifest::NetworkMetaKey) -> Self {
95        let mut h = blake3::Hasher::new_keyed(&meta_key.0);
96        h.update(b"billpouch/chunk-enc/v1");
97        let chunk_key: [u8; 32] = *h.finalize().as_bytes();
98        Self::from_raw_key(chunk_key)
99    }
100
101    /// Encrypt `plaintext` and return `nonce(12) || ciphertext_with_tag(len+16)`.
102    ///
103    /// A fresh random nonce is generated for every call, guaranteeing that
104    /// repeated encryptions of the same plaintext produce different outputs.
105    pub fn encrypt(&self, plaintext: &[u8]) -> BpResult<Vec<u8>> {
106        let mut nonce_bytes = [0u8; NONCE_LEN];
107        rand::thread_rng().fill_bytes(&mut nonce_bytes);
108        let nonce = Nonce::from_slice(&nonce_bytes);
109        let ciphertext = self
110            .cipher
111            .encrypt(nonce, plaintext)
112            .map_err(|e| BpError::Storage(format!("Chunk encryption failed: {e}")))?;
113
114        let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
115        out.extend_from_slice(&nonce_bytes);
116        out.extend_from_slice(&ciphertext);
117        Ok(out)
118    }
119
120    /// Decrypt a blob produced by [`encrypt`](Self::encrypt).
121    ///
122    /// Returns the original plaintext or an error if the tag verification
123    /// fails (wrong key, wrong network, or corrupted data).
124    pub fn decrypt(&self, blob: &[u8]) -> BpResult<Vec<u8>> {
125        if blob.len() < NONCE_LEN + 16 {
126            return Err(BpError::Storage(
127                "Encrypted chunk blob is too short (corrupted?)".into(),
128            ));
129        }
130        let nonce = Nonce::from_slice(&blob[..NONCE_LEN]);
131        let ciphertext = &blob[NONCE_LEN..];
132        self.cipher.decrypt(nonce, ciphertext).map_err(|_| {
133            BpError::Storage("Chunk decryption failed — wrong network key or corrupted data".into())
134        })
135    }
136}
137
138// ── Tests ─────────────────────────────────────────────────────────────────────
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    /// Fixed-key cipher for tests — isolates cipher mechanics from key management.
145    fn cipher(label: &str) -> ChunkCipher {
146        let key = *blake3::Hasher::new()
147            .update(b"bp-test-cipher")
148            .update(label.as_bytes())
149            .finalize()
150            .as_bytes();
151        ChunkCipher::from_raw_key(key)
152    }
153
154    #[test]
155    fn encrypt_decrypt_roundtrip() {
156        let c = cipher("amici");
157        let data = b"Hello, BillPouch! This is a test chunk payload.";
158        let blob = c.encrypt(data).unwrap();
159        let back = c.decrypt(&blob).unwrap();
160        assert_eq!(back.as_slice(), data.as_ref());
161    }
162
163    #[test]
164    fn different_nonces_per_call() {
165        let c = cipher("amici");
166        let data = b"same plaintext";
167        let blob1 = c.encrypt(data).unwrap();
168        let blob2 = c.encrypt(data).unwrap();
169        // Different nonces → different ciphertexts
170        assert_ne!(blob1, blob2);
171        // Both still decrypt correctly
172        assert_eq!(c.decrypt(&blob1).unwrap(), data);
173        assert_eq!(c.decrypt(&blob2).unwrap(), data);
174    }
175
176    #[test]
177    fn wrong_key_decryption_fails() {
178        let c1 = cipher("amici");
179        let c2 = cipher("lavoro");
180        let blob = c1.encrypt(b"secret data").unwrap();
181        assert!(
182            c2.decrypt(&blob).is_err(),
183            "Decryption with wrong key must fail"
184        );
185    }
186
187    #[test]
188    fn tampered_ciphertext_fails() {
189        let c = cipher("amici");
190        let mut blob = c.encrypt(b"original data").unwrap();
191        // Flip a byte inside the ciphertext (after the 12-byte nonce).
192        blob[NONCE_LEN + 3] ^= 0xAA;
193        assert!(
194            c.decrypt(&blob).is_err(),
195            "Tampered ciphertext must fail auth"
196        );
197    }
198
199    #[test]
200    fn too_short_blob_fails() {
201        let c = cipher("amici");
202        assert!(c.decrypt(&[0u8; 20]).is_err());
203        assert!(c.decrypt(&[]).is_err());
204    }
205
206    #[test]
207    fn different_keys_are_isolated() {
208        let c1 = cipher("amici");
209        let c2 = cipher("lavoro");
210        let plain = b"cross-key probe";
211        let blob1 = c1.encrypt(plain).unwrap();
212        let blob2 = c2.encrypt(plain).unwrap();
213        assert!(c2.decrypt(&blob1).is_err());
214        assert!(c1.decrypt(&blob2).is_err());
215    }
216
217    #[test]
218    fn for_user_produces_distinct_ceks() {
219        // Two users with different secret material encrypt the same plaintext.
220        let sm1 = [0x11u8; 32];
221        let sm2 = [0x22u8; 32];
222        let ph = *blake3::hash(b"my chunk").as_bytes();
223        let c1 = ChunkCipher::for_user(&sm1, &ph);
224        let c2 = ChunkCipher::for_user(&sm2, &ph);
225        let blob = c1.encrypt(b"secret").unwrap();
226        // User 2 cannot decrypt user 1’s data.
227        assert!(c2.decrypt(&blob).is_err());
228    }
229
230    #[test]
231    fn for_user_same_input_same_cek() {
232        let sm = [0xAAu8; 32];
233        let ph = *blake3::hash(b"chunk content").as_bytes();
234        let c1 = ChunkCipher::for_user(&sm, &ph);
235        let c2 = ChunkCipher::for_user(&sm, &ph);
236        let blob = c1.encrypt(b"data").unwrap();
237        // Re-derived cipher decrypts correctly.
238        assert_eq!(c2.decrypt(&blob).unwrap(), b"data");
239    }
240}