bp_core/storage/
encryption.rs1use crate::error::{BpError, BpResult};
33use chacha20poly1305::{
34 aead::{Aead, KeyInit},
35 ChaCha20Poly1305, Key, Nonce,
36};
37use rand::RngCore;
38
39const NONCE_LEN: usize = 12;
41
42pub struct ChunkCipher {
48 cipher: ChaCha20Poly1305,
49}
50
51impl ChunkCipher {
52 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 pub fn from_raw_key(key: [u8; 32]) -> Self {
72 let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
73 Self { cipher }
74 }
75
76 #[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 #[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 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 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#[cfg(test)]
141mod tests {
142 use super::*;
143
144 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 assert_ne!(blob1, blob2);
171 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 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 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 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 assert_eq!(c2.decrypt(&blob).unwrap(), b"data");
239 }
240}