1use aes::Aes256;
2use aes::cipher::{BlockEncrypt, KeyInit as _, StreamCipher, generic_array::GenericArray};
3use ctr::cipher::KeyIvInit as _;
4use ghash::GHash;
5use ghash::universal_hash::UniversalHash as _;
6use subtle::ConstantTimeEq as _;
7use xmtp_content_types::{encryption::derive_key, remote_attachment::RemoteAttachment};
8
9use crate::{AttachmentError, AttachmentFailureCause};
10
11const TAG_LEN: usize = 16;
12const BLOCK_LEN: usize = 16;
13const MAX_GCM_INPUT_BYTES: u64 = (u32::MAX as u64 - 1) * BLOCK_LEN as u64;
15
16#[derive(Clone, PartialEq, Eq)]
18pub struct KeyMaterial {
19 pub secret: [u8; 32],
20 pub salt: [u8; 32],
21 pub nonce: [u8; 12],
22}
23
24impl std::fmt::Debug for KeyMaterial {
25 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 formatter.write_str("KeyMaterial { .. }")
27 }
28}
29
30impl KeyMaterial {
31 pub fn random() -> Self {
32 Self {
33 secret: xmtp_common::rand_array(),
34 salt: xmtp_common::rand_array(),
35 nonce: xmtp_common::rand_array(),
36 }
37 }
38
39 pub fn from_remote(ra: &RemoteAttachment) -> Result<Self, AttachmentError> {
40 if ra.content_digest.len() != 64
41 || !ra
42 .content_digest
43 .bytes()
44 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
45 {
46 return Err(AttachmentError::new(AttachmentFailureCause::Malformed));
47 }
48 Ok(Self {
49 secret: ra
50 .secret
51 .as_slice()
52 .try_into()
53 .map_err(|_| AttachmentError::new(AttachmentFailureCause::Malformed))?,
54 salt: ra
55 .salt
56 .as_slice()
57 .try_into()
58 .map_err(|_| AttachmentError::new(AttachmentFailureCause::Malformed))?,
59 nonce: ra
60 .nonce
61 .as_slice()
62 .try_into()
63 .map_err(|_| AttachmentError::new(AttachmentFailureCause::Malformed))?,
64 })
65 }
66}
67
68struct GcmCore {
69 cipher: ctr::Ctr32BE<Aes256>,
70 hash: GHash,
71 partial: [u8; BLOCK_LEN],
72 partial_len: usize,
73 ciphertext_len: u64,
74 tag_mask: [u8; TAG_LEN],
75}
76
77impl GcmCore {
78 fn new(material: &KeyMaterial) -> Self {
79 let key =
81 derive_key(&material.secret, &material.salt).expect("32-byte HKDF expansion is valid");
82 let aes = Aes256::new((&key).into());
83 let mut h = GenericArray::default();
84 aes.encrypt_block(&mut h);
85 let hash = GHash::new(&h);
86 let mut j0 = [0u8; 16];
87 j0[..12].copy_from_slice(&material.nonce);
88 j0[15] = 1;
89 let mut tag_mask = GenericArray::clone_from_slice(&j0);
90 aes.encrypt_block(&mut tag_mask);
91 let mut counter = j0;
92 counter[15] = 2;
93 Self {
94 cipher: ctr::Ctr32BE::<Aes256>::new((&key).into(), (&counter).into()),
95 hash,
96 partial: [0; BLOCK_LEN],
97 partial_len: 0,
98 ciphertext_len: 0,
99 tag_mask: tag_mask.into(),
100 }
101 }
102
103 fn check_input_len(&self, input_len: usize) -> Result<(), AttachmentError> {
104 let additional = u64::try_from(input_len)
105 .map_err(|_| AttachmentError::new(AttachmentFailureCause::TooLarge))?;
106 let remaining = MAX_GCM_INPUT_BYTES
107 .checked_sub(self.ciphertext_len)
108 .ok_or_else(|| AttachmentError::new(AttachmentFailureCause::TooLarge))?;
109 if additional > remaining {
110 return Err(AttachmentError::new(AttachmentFailureCause::TooLarge));
111 }
112 Ok(())
113 }
114
115 fn authenticate(&mut self, mut input: &[u8]) {
116 self.ciphertext_len += input.len() as u64;
117 if self.partial_len != 0 {
118 let take = (BLOCK_LEN - self.partial_len).min(input.len());
119 self.partial[self.partial_len..self.partial_len + take].copy_from_slice(&input[..take]);
120 self.partial_len += take;
121 input = &input[take..];
122 if self.partial_len == BLOCK_LEN {
123 self.hash.update(&[self.partial.into()]);
124 self.partial_len = 0;
125 } else {
126 return;
127 }
128 }
129 let blocks = input.len() / BLOCK_LEN;
130 self.hash.update_padded(&input[..blocks * BLOCK_LEN]);
131 let tail = &input[blocks * BLOCK_LEN..];
132 self.partial[..tail.len()].copy_from_slice(tail);
133 self.partial_len = tail.len();
134 }
135
136 fn tag(mut self) -> [u8; TAG_LEN] {
137 if self.partial_len != 0 {
138 self.partial[self.partial_len..].fill(0);
139 self.hash.update(&[self.partial.into()]);
140 }
141 let mut lengths = [0u8; BLOCK_LEN];
142 lengths[8..].copy_from_slice(&(self.ciphertext_len * 8).to_be_bytes());
143 self.hash.update(&[lengths.into()]);
144 let digest = self.hash.finalize();
145 let mut tag = self.tag_mask;
146 for (byte, hash_byte) in tag.iter_mut().zip(digest.iter()) {
147 *byte ^= hash_byte;
148 }
149 tag
150 }
151}
152
153pub struct GcmEncryptor(GcmCore);
155
156impl GcmEncryptor {
157 pub fn new(material: &KeyMaterial) -> Self {
158 Self(GcmCore::new(material))
159 }
160
161 pub fn update(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), AttachmentError> {
163 self.0.check_input_len(input.len())?;
164 let start = out.len();
165 out.extend_from_slice(input);
166 self.0.cipher.apply_keystream(&mut out[start..]);
167 self.0.authenticate(&out[start..]);
168 Ok(())
169 }
170
171 pub fn finish(self) -> [u8; TAG_LEN] {
172 self.0.tag()
173 }
174}
175
176pub struct GcmDecryptor {
181 core: GcmCore,
182 tail: Vec<u8>,
183}
184
185impl GcmDecryptor {
186 pub fn new(material: &KeyMaterial) -> Self {
187 Self {
188 core: GcmCore::new(material),
189 tail: Vec::with_capacity(TAG_LEN),
190 }
191 }
192
193 pub fn update(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<(), AttachmentError> {
195 let release = input.len().saturating_sub(TAG_LEN - self.tail.len());
196 self.core.check_input_len(release)?;
197 let from_tail = release.min(self.tail.len());
198 if from_tail != 0 {
199 let old = self.tail.drain(..from_tail).collect::<Vec<_>>();
200 self.decrypt_part(&old, out);
201 }
202 let from_input = release - from_tail;
203 self.decrypt_part(&input[..from_input], out);
204 self.tail.extend_from_slice(&input[from_input..]);
205 Ok(())
206 }
207
208 fn decrypt_part(&mut self, ciphertext: &[u8], out: &mut Vec<u8>) {
209 self.core.authenticate(ciphertext);
210 let start = out.len();
211 out.extend_from_slice(ciphertext);
212 self.core.cipher.apply_keystream(&mut out[start..]);
213 }
214
215 pub fn finish(self) -> Result<(), AttachmentError> {
216 let Self { core, tail } = self;
217 if tail.len() != TAG_LEN || !bool::from(core.tag().as_slice().ct_eq(tail.as_slice())) {
218 return Err(AttachmentError::new(
219 AttachmentFailureCause::DecryptionFailed,
220 ));
221 }
222 Ok(())
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _};
229
230 use super::*;
231
232 #[xmtp_common::test(unwrap_try = true)]
233 fn key_material_debug_hides_secrets() {
234 let material = KeyMaterial {
235 secret: [0xab; 32],
236 salt: [0xcd; 32],
237 nonce: [0xef; 12],
238 };
239 let debug = format!("{material:?}");
240 assert_eq!(debug, "KeyMaterial { .. }");
241 assert!(!debug.contains(&hex::encode(material.secret)));
242 }
243
244 fn check_lengths(material: &KeyMaterial, plaintext: &[u8], start: usize, end: usize) {
245 let key =
246 derive_key(&material.secret, &material.salt).expect("32-byte HKDF expansion is valid");
247 let cipher = Aes256Gcm::new((&key).into());
248 let mut seed = 0x91c3_7d52_u64 ^ start as u64;
249 for len in start..=end {
250 let expected = cipher
251 .encrypt((&material.nonce).into(), &plaintext[..len])
252 .expect("fixed key and nonce");
253 let mut encryptor = GcmEncryptor::new(material);
254 let mut actual = Vec::new();
255 let mut at = 0;
256 while at < len {
257 seed ^= seed << 13;
258 seed ^= seed >> 7;
259 seed ^= seed << 17;
260 let end = (at + 1 + (seed as usize % 32_768)).min(len);
261 encryptor
262 .update(&plaintext[at..end], &mut actual)
263 .expect("plaintext length is below the GCM limit");
264 at = end;
265 }
266 actual.extend_from_slice(&encryptor.finish());
267 assert_eq!(actual, expected, "length {len}");
268
269 let mut decryptor = GcmDecryptor::new(material);
270 let mut decoded = Vec::new();
271 let mut at = 0;
272 while at < actual.len() {
273 seed ^= seed << 13;
274 seed ^= seed >> 7;
275 seed ^= seed << 17;
276 let end = (at + 1 + (seed as usize % 24_576)).min(actual.len());
277 decryptor
278 .update(&actual[at..end], &mut decoded)
279 .expect("ciphertext length is below the GCM limit");
280 at = end;
281 }
282 decryptor.finish().expect("valid ciphertext and tag");
283 assert_eq!(decoded, plaintext[..len], "length {len}");
284
285 if [0, 1, 15, 16, 17, 1024, 70_000].contains(&len) {
286 let mut positions = vec![len, actual.len() - 1];
287 if len != 0 {
288 positions.extend([0, len - 1]);
289 }
290 for pos in positions {
291 for bit in 0..8 {
292 let mut changed = actual.clone();
293 changed[pos] ^= 1 << bit;
294 let mut decryptor = GcmDecryptor::new(material);
295 let mut out = Vec::new();
296 decryptor
297 .update(&changed, &mut out)
298 .expect("ciphertext length is below the GCM limit");
299 assert!(matches!(
300 decryptor.finish(),
301 Err(AttachmentError {
302 cause: AttachmentFailureCause::DecryptionFailed,
303 ..
304 })
305 ));
306 }
307 }
308 }
309 }
310 }
311
312 #[xmtp_common::test(unwrap_try = true)]
314 async fn gcm_stream_matches_one_shot() {
315 let material = KeyMaterial {
316 secret: [7; 32],
317 salt: [9; 32],
318 nonce: [11; 12],
319 };
320 let max_len = if cfg!(target_arch = "wasm32") {
323 1024
324 } else {
325 70_000
326 };
327 let mut plaintext = vec![0u8; max_len];
328 let mut seed = 0x91c3_7d52_u64;
329 for byte in &mut plaintext {
330 seed ^= seed << 13;
331 seed ^= seed >> 7;
332 seed ^= seed << 17;
333 *byte = seed as u8;
334 }
335 #[cfg(target_arch = "wasm32")]
336 check_lengths(&material, &plaintext, 0, max_len);
337 #[cfg(not(target_arch = "wasm32"))]
338 std::thread::scope(|scope| {
339 const WORKERS: usize = 10;
340 let mut handles = Vec::with_capacity(WORKERS);
341 let boundary = |worker: usize| {
343 ((max_len + 1) as f64 * (worker as f64 / WORKERS as f64).sqrt()) as usize
344 };
345 for worker in 0..WORKERS {
346 let start = boundary(worker);
347 let end = boundary(worker + 1) - 1;
348 let material = &material;
349 let plaintext = &plaintext;
350 handles.push(scope.spawn(move || check_lengths(material, plaintext, start, end)));
351 }
352 for handle in handles {
353 handle.join().expect("GCM worker completed");
354 }
355 });
356 }
357
358 #[xmtp_common::test(unwrap_try = true)]
359 async fn gcm_counter_limit_rejects_before_output() {
360 let material = KeyMaterial {
361 secret: [7; 32],
362 salt: [9; 32],
363 nonce: [11; 12],
364 };
365
366 let mut encryptor = GcmEncryptor::new(&material);
367 encryptor.0.ciphertext_len = MAX_GCM_INPUT_BYTES - 1;
368 let mut encrypted = vec![0x5a];
369 assert_eq!(
370 encryptor.update(&[1, 2], &mut encrypted).unwrap_err().cause,
371 AttachmentFailureCause::TooLarge
372 );
373 assert_eq!(encrypted, [0x5a]);
374 assert_eq!(encryptor.0.ciphertext_len, MAX_GCM_INPUT_BYTES - 1);
375 encryptor.update(&[1], &mut encrypted)?;
376 assert_eq!(encrypted.len(), 2);
377 assert_eq!(encryptor.0.ciphertext_len, MAX_GCM_INPUT_BYTES);
378 assert_eq!(
379 encryptor.update(&[2], &mut encrypted).unwrap_err().cause,
380 AttachmentFailureCause::TooLarge
381 );
382 assert_eq!(encrypted.len(), 2);
383
384 let mut decryptor = GcmDecryptor::new(&material);
385 decryptor.core.ciphertext_len = MAX_GCM_INPUT_BYTES - 1;
386 decryptor.tail = vec![0x3c; TAG_LEN];
387 let mut decrypted = vec![0x5a];
388 assert_eq!(
389 decryptor.update(&[1, 2], &mut decrypted).unwrap_err().cause,
390 AttachmentFailureCause::TooLarge
391 );
392 assert_eq!(decrypted, [0x5a]);
393 assert_eq!(decryptor.tail, [0x3c; TAG_LEN]);
394 assert_eq!(decryptor.core.ciphertext_len, MAX_GCM_INPUT_BYTES - 1);
395 decryptor.update(&[1], &mut decrypted)?;
396 assert_eq!(decrypted.len(), 2);
397 assert_eq!(decryptor.core.ciphertext_len, MAX_GCM_INPUT_BYTES);
398 assert_eq!(
399 decryptor.update(&[2], &mut decrypted).unwrap_err().cause,
400 AttachmentFailureCause::TooLarge
401 );
402 assert_eq!(decrypted.len(), 2);
403 }
404}