From dfb347f4e45fcaaa104033a5ad0e6ec93a3a69f5 Mon Sep 17 00:00:00 2001 From: Denis Kasak Date: Thu, 26 Mar 2026 15:46:59 +0100 Subject: [PATCH] feat(crypto): Implement STREAM-based file attachment encryption --- Cargo.lock | 2 + Cargo.toml | 2 + crates/matrix-sdk-crypto/Cargo.toml | 3 + .../src/file_encryption/mod.rs | 7 + .../src/file_encryption/stream_attachments.rs | 729 ++++++++++++++++++ crates/matrix-sdk-crypto/src/lib.rs | 5 + crates/matrix-sdk-store-encryption/Cargo.toml | 2 +- 7 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 crates/matrix-sdk-crypto/src/file_encryption/stream_attachments.rs diff --git a/Cargo.lock b/Cargo.lock index c6eaf57a7..de1cc7d8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3302,6 +3302,7 @@ dependencies = [ name = "matrix-sdk-crypto" version = "0.16.0" dependencies = [ + "aead", "aes", "anyhow", "aquamarine", @@ -3312,6 +3313,7 @@ dependencies = [ "bs58", "byteorder", "cfg-if", + "chacha20poly1305", "ctr", "eyeball", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index c01858078..158097b26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ resolver = "3" rust-version = "1.93" [workspace.dependencies] +aead = { version = "0.5.2", default-features = false, features = ["std", "stream"] } anyhow = { version = "1.0.100", default-features = false } aquamarine = { version = "0.6.0", default-features = false } as_variant = { version = "1.3.0", default-features = false } @@ -37,6 +38,7 @@ base64 = { version = "0.22.1", default-features = false, features = ["std"] } bitflags = { version = "2.10.0", default-features = false } byteorder = { version = "1.5.0", default-features = false, features = ["std"] } cfg-if = { version = "1.0.4", default-features = false } +chacha20poly1305 = { version = "0.10.1", default-features = false, features = ["std"] } clap = { version = "4.5.53", default-features = false, features = ["std", "help", "usage"] } chrono = { version = "0.4.42", default-features = false, features = ["clock", "std", "oldtime", "wasmbind"] } dirs = { version = "6.0.0", default-features = false } diff --git a/crates/matrix-sdk-crypto/Cargo.toml b/crates/matrix-sdk-crypto/Cargo.toml index 81aa36fb8..cb274c00b 100644 --- a/crates/matrix-sdk-crypto/Cargo.toml +++ b/crates/matrix-sdk-crypto/Cargo.toml @@ -29,6 +29,7 @@ experimental-encrypted-state-events = [ js = ["ruma/js", "vodozemac/js", "matrix-sdk-common/js"] qrcode = ["dep:matrix-sdk-qrcode"] experimental-algorithms = [] +stream-attachment-encryption = ["dep:aead", "dep:chacha20poly1305"] uniffi = ["dep:uniffi"] _disable-minimum-rotation-period-ms = [] @@ -41,6 +42,7 @@ test-send-sync = [] testing = ["matrix-sdk-test"] [dependencies] +aead = { workspace = true, optional = true } aes = { version = "0.8.4", default-features = false } aquamarine.workspace = true as_variant.workspace = true @@ -48,6 +50,7 @@ async-trait.workspace = true bs58 = { version = "0.5.1", default-features = false, features = ["std"] } byteorder.workspace = true cfg-if.workspace = true +chacha20poly1305 = { workspace = true, optional = true } ctr = { version = "0.9.2", default-features = false } eyeball.workspace = true futures-core.workspace = true diff --git a/crates/matrix-sdk-crypto/src/file_encryption/mod.rs b/crates/matrix-sdk-crypto/src/file_encryption/mod.rs index dfdf9206e..30430f3de 100644 --- a/crates/matrix-sdk-crypto/src/file_encryption/mod.rs +++ b/crates/matrix-sdk-crypto/src/file_encryption/mod.rs @@ -1,7 +1,14 @@ mod attachments; mod key_export; +#[cfg(feature = "stream-attachment-encryption")] +mod stream_attachments; pub use attachments::{ AttachmentDecryptor, AttachmentEncryptor, DecryptorError, MediaEncryptionInfo, }; pub use key_export::{KeyExportError, decrypt_room_key_export, encrypt_room_key_export}; +#[cfg(feature = "stream-attachment-encryption")] +pub use stream_attachments::{ + StreamAttachmentDecryptor, StreamAttachmentEncryptor, StreamDecryptorError, + StreamMediaEncryptionInfo, +}; diff --git a/crates/matrix-sdk-crypto/src/file_encryption/stream_attachments.rs b/crates/matrix-sdk-crypto/src/file_encryption/stream_attachments.rs new file mode 100644 index 000000000..657df4078 --- /dev/null +++ b/crates/matrix-sdk-crypto/src/file_encryption/stream_attachments.rs @@ -0,0 +1,729 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! STREAM (Rogaway et al.) based attachment encryption using +//! XChaCha20-Poly1305 as the underlying AEAD. +//! +//! STREAM splits the plaintext into fixed-size segments, each independently +//! authenticated with its own AEAD tag. This provides per-segment integrity +//! verification for file attachments, unlike the existing AES-CTR + SHA-256 +//! approach which only verifies integrity at EOF. + +use std::io::{self, Read}; + +use aead::{ + KeyInit, + stream::{DecryptorBE32, EncryptorBE32}, +}; +use chacha20poly1305::XChaCha20Poly1305; +use rand::{RngCore, thread_rng}; +use ruma::serde::Base64; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; + +/// Default plaintext segment size: 64 KiB. +const SEGMENT_SIZE: usize = 65536; + +/// XChaCha20-Poly1305 key size (256-bit) +const KEY_SIZE: usize = 32; + +/// Poly1305 authentication tag size. +const TAG_SIZE: usize = 16; + +/// Nonce prefix size for the `StreamBE32` STREAM variant. +/// 24 (XChaCha20 nonce) - 5 (4-byte counter + 1-byte last-segment flag) = 19. +const NONCE_PREFIX_SIZE: usize = 19; + +const VERSION: &str = "v1-stream"; + +/// Metadata needed to decrypt a STREAM-encrypted attachment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamMediaEncryptionInfo { + /// Encryption scheme version identifier + #[serde(rename = "v")] + pub version: String, + + /// Base64-encoded 256-bit symmetric key + pub key: Base64, + + /// Base64-encoded 19-byte nonce prefix + pub nonce_prefix: Base64, + + /// Plaintext segment size in bytes used during encryption + pub segment_size: u32, +} + +/// State machine for the encryptor's [`Read`] impl. +#[derive(Debug)] +enum EncryptorState { + /// Reading plaintext from the inner reader to fill a segment. + Accumulating, + /// A segment has been encrypted; serving ciphertext to the caller. + /// The bool tracks whether this was the last segment. + Serving { is_last: bool }, + /// The last segment has been encrypted and fully served. + Done, +} + +/// A reader wrapper that transparently encrypts the read contents in a +/// streaming fashion using STREAM with XChaCha20-Poly1305 as the AEAD. +/// +/// Each read returns encrypted ciphertext. Call [`info`](Self::info) at any +/// time to obtain the [`StreamMediaEncryptionInfo`] needed for decryption. +/// +/// # Examples +/// +/// ``` +/// # use std::io::{Cursor, Read}; +/// # use matrix_sdk_crypto::StreamAttachmentEncryptor; +/// let data = "Hello world".to_owned(); +/// let mut cursor = Cursor::new(data.clone()); +/// +/// let mut encryptor = StreamAttachmentEncryptor::new(&mut cursor); +/// +/// // This contains information the decryptor side will need to start decryption +/// let info = encryptor.info().clone(); +/// +/// let mut encrypted = Vec::new(); +/// encryptor.read_to_end(&mut encrypted).unwrap(); +/// ``` +pub struct StreamAttachmentEncryptor<'a, R: Read + ?Sized> { + inner: &'a mut R, + encryptor: Option>, + plaintext_buf: Vec, + ciphertext_buf: Vec, + ct_pos: usize, + state: EncryptorState, + info: StreamMediaEncryptionInfo, +} + +impl std::fmt::Debug for StreamAttachmentEncryptor<'_, R> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamAttachmentEncryptor") + .field("inner", &self.inner) + .field("state", &self.state) + .finish() + } +} + +impl<'a, R: Read + ?Sized + 'a> StreamAttachmentEncryptor<'a, R> { + /// Wrap the given reader, encrypting all data read from it using STREAM + /// with XChaCha20-Poly1305. + pub fn new(reader: &'a mut R) -> Self { + Self::with_segment_size(reader, SEGMENT_SIZE as u32) + } + + /// Like [`new`](Self::new) but with a custom plaintext segment size. + pub fn with_segment_size(reader: &'a mut R, segment_size: u32) -> Self { + let mut key = [0u8; KEY_SIZE]; + let mut nonce_prefix = [0u8; NONCE_PREFIX_SIZE]; + + let mut rng = thread_rng(); + rng.fill_bytes(&mut key); + rng.fill_bytes(&mut nonce_prefix); + + let cipher = XChaCha20Poly1305::new((&key).into()); + let encryptor = EncryptorBE32::from_aead(cipher, (&nonce_prefix).into()); + + let info = StreamMediaEncryptionInfo { + version: VERSION.to_owned(), + key: Base64::new(key.to_vec()), + nonce_prefix: Base64::new(nonce_prefix.to_vec()), + segment_size, + }; + + key.zeroize(); + + StreamAttachmentEncryptor { + inner: reader, + encryptor: Some(encryptor), + plaintext_buf: Vec::with_capacity(segment_size as usize), + ciphertext_buf: Vec::new(), + ct_pos: 0, + state: EncryptorState::Accumulating, + info, + } + } + + #[cfg(test)] + fn with_key_and_nonce( + reader: &'a mut R, + key: &[u8; KEY_SIZE], + nonce_prefix: &[u8; NONCE_PREFIX_SIZE], + ) -> Self { + let cipher = XChaCha20Poly1305::new(key.into()); + let encryptor = EncryptorBE32::from_aead(cipher, nonce_prefix.into()); + + let info = StreamMediaEncryptionInfo { + version: VERSION.to_owned(), + key: Base64::new(key.to_vec()), + nonce_prefix: Base64::new(nonce_prefix.to_vec()), + segment_size: SEGMENT_SIZE as u32, + }; + + StreamAttachmentEncryptor { + inner: reader, + encryptor: Some(encryptor), + plaintext_buf: Vec::with_capacity(SEGMENT_SIZE), + ciphertext_buf: Vec::new(), + ct_pos: 0, + state: EncryptorState::Accumulating, + info, + } + } + + /// Return the encryption metadata needed for decryption. + /// + /// This is available immediately after the encryptor is constructed. + pub fn info(&self) -> &StreamMediaEncryptionInfo { + &self.info + } + + /// Fill the plaintext buffer from the inner reader until a full segment + /// is accumulated or the inner reader reaches EOF. + /// + /// Returns `true` if the inner reader reached EOF. + fn fill_plaintext_buf(&mut self) -> io::Result { + let segment_size = self.info.segment_size as usize; + let mut tmp = [0u8; 8192]; + + while self.plaintext_buf.len() < segment_size { + let max = tmp.len().min(segment_size - self.plaintext_buf.len()); + match self.inner.read(&mut tmp[..max]) { + Ok(0) => return Ok(true), + Ok(n) => self.plaintext_buf.extend_from_slice(&tmp[..n]), + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + + Ok(false) + } +} + +impl Read for StreamAttachmentEncryptor<'_, R> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + loop { + match self.state { + EncryptorState::Accumulating => { + let inner_eof = self.fill_plaintext_buf()?; + let segment_size = self.info.segment_size as usize; + + if inner_eof { + // Inner reader is done. Encrypt as last segment + // (possibly empty, if input was an exact multiple + // of segment_size). + let encryptor = self.encryptor.take().ok_or_else(|| { + io::Error::other("STREAM encryptor already finalized") + })?; + + self.ciphertext_buf = + encryptor.encrypt_last(&self.plaintext_buf[..]).map_err(|e| { + io::Error::other(format!("STREAM encryption error: {e}")) + })?; + self.plaintext_buf.zeroize(); + self.state = EncryptorState::Serving { is_last: true }; + } else if self.plaintext_buf.len() == segment_size { + // Full intermediate segment. + let encryptor = self.encryptor.as_mut().ok_or_else(|| { + io::Error::other("STREAM encryptor already finalized") + })?; + + self.ciphertext_buf = + encryptor.encrypt_next(&self.plaintext_buf[..]).map_err(|e| { + io::Error::other(format!("STREAM encryption error: {e}")) + })?; + self.plaintext_buf.zeroize(); + self.state = EncryptorState::Serving { is_last: false }; + } + // No return; we deliberately loop to transition to the next + // state, which is serving from the freshly filled + // ciphertext buffer. + } + + EncryptorState::Serving { is_last } => { + let available = &self.ciphertext_buf[self.ct_pos..]; + if available.is_empty() { + // Buffer fully consumed so we need to transition to the next state. + self.ciphertext_buf.clear(); + self.ct_pos = 0; + self.state = if is_last { + EncryptorState::Done + } else { + EncryptorState::Accumulating + }; + continue; + } + + let to_copy = available.len().min(buf.len()); + buf[..to_copy].copy_from_slice(&available[..to_copy]); + self.ct_pos += to_copy; + return Ok(to_copy); + } + + EncryptorState::Done => return Ok(0), + } + } + } +} + +/// State machine for the decryptor's [`Read`] impl. +#[derive(Debug)] +enum DecryptorState { + /// Accumulating ciphertext from the inner reader to fill a segment. + Accumulating, + /// A segment has been decrypted; serving plaintext to the caller. + /// The bool tracks whether this was the last segment. + Serving { is_last: bool }, + /// The last segment has been decrypted and fully served. + Done, +} + +/// A reader wrapper that transparently decrypts the read contents, +/// reversing the encryption performed by [`StreamAttachmentEncryptor`]. +/// +/// Each read returns decrypted plaintext. Authentication is verified +/// per-segment: a tampered segment causes an immediate error rather than +/// waiting until EOF. +/// +/// # Examples +/// +/// ``` +/// # use std::io::{Cursor, Read}; +/// # use matrix_sdk_crypto::{StreamAttachmentEncryptor, StreamAttachmentDecryptor}; +/// let data = "Hello world".to_owned(); +/// let mut cursor = Cursor::new(data.clone()); +/// +/// let mut encryptor = StreamAttachmentEncryptor::new(&mut cursor); +/// let info = encryptor.info().clone(); +/// let mut encrypted = Vec::new(); +/// encryptor.read_to_end(&mut encrypted).unwrap(); +/// +/// let mut cursor = Cursor::new(encrypted); +/// let mut decryptor = StreamAttachmentDecryptor::new(&mut cursor, info).unwrap(); +/// let mut decrypted = Vec::new(); +/// decryptor.read_to_end(&mut decrypted).unwrap(); +/// assert_eq!(data.as_bytes(), &decrypted[..]); +/// ``` +pub struct StreamAttachmentDecryptor<'a, R: Read> { + inner: &'a mut R, + decryptor: Option>, + segment_buf: Vec, + plaintext_buf: Vec, + pt_pos: usize, + state: DecryptorState, +} + +impl std::fmt::Debug for StreamAttachmentDecryptor<'_, R> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StreamAttachmentDecryptor") + .field("inner", &self.inner) + .field("state", &self.state) + .finish() + } +} + +/// Error type for STREAM attachment decryption. +#[derive(Debug, thiserror::Error)] +pub enum StreamDecryptorError { + /// The version field doesn't match the expected value. + #[error("Unknown version for STREAM-encrypted attachment: {0}")] + UnknownVersion(String), + + /// The supplied key has an invalid length. + #[error("Invalid key length: expected {expected}, got {got}")] + InvalidKeyLength { + /// Expected key length in bytes. + expected: usize, + /// Actual key length in bytes. + got: usize, + }, + + /// The supplied nonce prefix has an invalid length. + #[error("Invalid nonce prefix length: expected {expected}, got {got}")] + InvalidNoncePrefixLength { + /// Expected nonce prefix length in bytes. + expected: usize, + /// Actual nonce prefix length in bytes. + got: usize, + }, +} + +impl<'a, R: Read + 'a> StreamAttachmentDecryptor<'a, R> { + /// Wrap the given reader, decrypting STREAM-encrypted data on the fly. + pub fn new( + input: &'a mut R, + info: StreamMediaEncryptionInfo, + ) -> Result { + if info.version != VERSION { + return Err(StreamDecryptorError::UnknownVersion(info.version)); + } + + let key_bytes = info.key.as_bytes(); + if key_bytes.len() != KEY_SIZE { + return Err(StreamDecryptorError::InvalidKeyLength { + expected: KEY_SIZE, + got: key_bytes.len(), + }); + } + + let nonce_bytes = info.nonce_prefix.as_bytes(); + if nonce_bytes.len() != NONCE_PREFIX_SIZE { + return Err(StreamDecryptorError::InvalidNoncePrefixLength { + expected: NONCE_PREFIX_SIZE, + got: nonce_bytes.len(), + }); + } + + let cipher = XChaCha20Poly1305::new(key_bytes.into()); + let decryptor = DecryptorBE32::from_aead(cipher, nonce_bytes.into()); + + Ok(StreamAttachmentDecryptor { + inner: input, + decryptor: Some(decryptor), + segment_buf: vec![0u8; info.segment_size as usize + TAG_SIZE], + plaintext_buf: Vec::new(), + pt_pos: 0, + state: DecryptorState::Accumulating, + }) + } +} + +impl Read for StreamAttachmentDecryptor<'_, R> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + loop { + match self.state { + DecryptorState::Accumulating => { + // Read one encrypted segment from the inner reader. + let encrypted_segment_size = self.segment_buf.len(); + let mut total_read = 0; + + while total_read < encrypted_segment_size { + match self.inner.read(&mut self.segment_buf[total_read..]) { + Ok(0) => break, + Ok(n) => total_read += n, + Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + + if total_read == 0 { + // If we are in the Accumulating state, we will either + // encounter a final segment (so total_read will be > 0) + // or the stream will have been truncated. + return Err(io::Error::other( + "STREAM decryption error: unexpected end of data (truncated stream)", + )); + } + + let is_last = total_read < encrypted_segment_size; + + if is_last { + let decryptor = self.decryptor.take().ok_or_else(|| { + io::Error::other("STREAM decryptor already finalized") + })?; + + self.plaintext_buf = decryptor + .decrypt_last(&self.segment_buf[..total_read]) + .map_err(|_| { + io::Error::other("STREAM decryption error: authentication failed") + })?; + } else { + let decryptor = self.decryptor.as_mut().ok_or_else(|| { + io::Error::other("STREAM decryptor already finalized") + })?; + + self.plaintext_buf = decryptor + .decrypt_next(&self.segment_buf[..]) + .map_err(|_| { + io::Error::other("STREAM decryption error: authentication failed") + })?; + } + + self.pt_pos = 0; + self.state = DecryptorState::Serving { is_last }; + // No return; we deliberately loop to transition to the next + // state, which is serving from the freshly filled + // plaintext buffer. + } + + DecryptorState::Serving { is_last } => { + let available = &self.plaintext_buf[self.pt_pos..]; + if available.is_empty() { + // Buffer fully consumed so we need to transition to the next state. + self.plaintext_buf.zeroize(); + self.pt_pos = 0; + self.state = if is_last { + DecryptorState::Done + } else { + DecryptorState::Accumulating + }; + continue; + } + + let to_copy = available.len().min(buf.len()); + buf[..to_copy].copy_from_slice(&available[..to_copy]); + self.pt_pos += to_copy; + return Ok(to_copy); + } + + DecryptorState::Done => return Ok(0), + } + } + } +} + +#[cfg(test)] +mod tests { + use std::io::{Cursor, Read}; + + use super::*; + + /// Helper: encrypt data and return (ciphertext, info). + fn encrypt(data: &[u8]) -> (Vec, StreamMediaEncryptionInfo) { + let mut cursor = Cursor::new(data); + let mut encryptor = StreamAttachmentEncryptor::new(&mut cursor); + let info = encryptor.info().clone(); + let mut encrypted = Vec::new(); + encryptor.read_to_end(&mut encrypted).unwrap(); + (encrypted, info) + } + + /// Helper: decrypt ciphertext with info, returning plaintext. + fn decrypt(ciphertext: &[u8], info: StreamMediaEncryptionInfo) -> Result, io::Error> { + let mut cursor = Cursor::new(ciphertext); + let mut decryptor = StreamAttachmentDecryptor::new(&mut cursor, info) + .map_err(|e| io::Error::other(e.to_string()))?; + let mut decrypted = Vec::new(); + decryptor.read_to_end(&mut decrypted)?; + Ok(decrypted) + } + + // Functional tests + + #[test] + fn encrypt_decrypt_roundtrip() { + let data = b"Hello world"; + let (encrypted, info) = encrypt(data); + + assert_ne!(&encrypted[..], data); + + let decrypted = decrypt(&encrypted, info).unwrap(); + assert_eq!(&decrypted[..], data); + } + + #[test] + fn large_data_roundtrip() { + let mut data = vec![0u8; 1024 * 1024]; // 1 MiB + let mut rng = thread_rng(); + rng.fill_bytes(&mut data); + + let (encrypted, info) = encrypt(&data); + let decrypted = decrypt(&encrypted, info).unwrap(); + assert_eq!(decrypted, data); + } + + #[test] + fn segment_boundary_exact_roundtrip() { + let data = vec![0xAB; SEGMENT_SIZE]; + let (encrypted, info) = encrypt(&data); + let decrypted = decrypt(&encrypted, info).unwrap(); + assert_eq!(decrypted, data); + } + + #[test] + fn segment_boundary_minus_one_roundtrip() { + let data = vec![0xAB; SEGMENT_SIZE - 1]; + let (encrypted, info) = encrypt(&data); + let decrypted = decrypt(&encrypted, info).unwrap(); + assert_eq!(decrypted, data); + } + + #[test] + fn segment_boundary_plus_one_roundtrip() { + let data = vec![0xAB; SEGMENT_SIZE + 1]; + let (encrypted, info) = encrypt(&data); + let decrypted = decrypt(&encrypted, info).unwrap(); + assert_eq!(decrypted, data); + } + + #[test] + fn empty_input_roundtrip() { + let data = b""; + let (encrypted, info) = encrypt(data); + let decrypted = decrypt(&encrypted, info).unwrap(); + assert_eq!(&decrypted[..], data.as_slice()); + } + + #[test] + fn small_read_buffer() { + let data = b"It is I, Sahasrahla"; + let (encrypted, info) = encrypt(data); + + let mut cursor = Cursor::new(&encrypted); + let mut decryptor = StreamAttachmentDecryptor::new(&mut cursor, info).unwrap(); + + // Read one byte at a time. + let mut decrypted = Vec::new(); + let mut one_byte = [0u8; 1]; + loop { + match decryptor.read(&mut one_byte) { + Ok(0) => break, + Ok(n) => { + decrypted.extend_from_slice(&one_byte[..n]); + assert_eq!(n, 1); + } + Err(e) => panic!("unexpected error: {e}"), + } + } + + assert_eq!(&decrypted[..], data.as_slice()); + } + + // Security tests + + #[test] + fn tampering_detection() { + // Create data spanning multiple segments. + let data = vec![0xAB; SEGMENT_SIZE * 3]; + let (mut encrypted, info) = encrypt(&data); + + // Tamper with a byte in the second segment. + let second_segment_offset = SEGMENT_SIZE + TAG_SIZE + 10; + if second_segment_offset < encrypted.len() { + encrypted[second_segment_offset] ^= 0xFF; + } + + let result = decrypt(&encrypted, info); + assert!(result.is_err(), "Decryption should fail on tampered data"); + } + + #[test] + fn truncation_detection() { + let data = vec![0xAB; SEGMENT_SIZE * 2]; + let (encrypted, info) = encrypt(&data); + + // Truncate to just the first segment. + let truncated = &encrypted[..SEGMENT_SIZE + TAG_SIZE]; + let result = decrypt(truncated, info); + assert!(result.is_err(), "Decryption should fail on truncated data"); + } + + #[test] + fn reordering_detection() { + // Create data with 3 segments to swap segments 2 and 1 + let data = vec![0xAB; SEGMENT_SIZE * 3]; + let (encrypted, info) = encrypt(&data); + + let ct_segment = SEGMENT_SIZE + TAG_SIZE; + let mut reordered = Vec::new(); + // Segment 0 stays in place. + reordered.extend_from_slice(&encrypted[..ct_segment]); + // Swap segment 1 and segment 2. + let segment1 = &encrypted[ct_segment..ct_segment * 2]; + let segment2 = &encrypted[ct_segment * 2..]; + reordered.extend_from_slice(segment2); + reordered.extend_from_slice(segment1); + + let result = decrypt(&reordered, info); + assert!(result.is_err(), "Decryption should fail on reordered segments"); + } + + // Metamorphic / property tests + + #[test] + fn length_relationship() { + for &size in + &[0, 1, SEGMENT_SIZE - 1, SEGMENT_SIZE, SEGMENT_SIZE + 1, SEGMENT_SIZE * 3 + 42] + { + let data = vec![0xAB; size]; + let (encrypted, _info) = encrypt(&data); + + // When size is an exact multiple of SEGMENT_SIZE (and > 0), the + // encryptor can't know the inner reader is exhausted until it + // tries to read again, so it emits an intermediate segment followed + // by an empty last segment. Hence: size / SEGMENT_SIZE + 1. + let num_segments = if size == 0 { + 1 // empty input still produces one (empty) last segment with a tag + } else { + size / SEGMENT_SIZE + 1 + }; + + let expected_ct_len = size + num_segments * TAG_SIZE; + assert_eq!( + encrypted.len(), + expected_ct_len, + "Length mismatch for plaintext size {size}: \ + expected {expected_ct_len}, got {}", + encrypted.len() + ); + } + } + + #[test] + fn prefix_non_stability() { + // Encrypting a prefix of some data (e.g. data[..n], for some n) MUST NOT produce a prefix + // of encrypting data as a whole, because STREAM includes the "last segment" flag as part + // of the nonce. + let key = [0x42u8; KEY_SIZE]; + let nonce_prefix = [0x13u8; NONCE_PREFIX_SIZE]; + + let full_data = vec![0xAB; SEGMENT_SIZE + 100]; + let prefix_data = &full_data[..SEGMENT_SIZE]; // exactly one segment + + let mut cursor_full = Cursor::new(&full_data); + let mut enc_full = + StreamAttachmentEncryptor::with_key_and_nonce(&mut cursor_full, &key, &nonce_prefix); + let mut ct_full = Vec::new(); + enc_full.read_to_end(&mut ct_full).unwrap(); + + let mut cursor_prefix = Cursor::new(prefix_data); + let mut enc_prefix = + StreamAttachmentEncryptor::with_key_and_nonce(&mut cursor_prefix, &key, &nonce_prefix); + let mut ct_prefix = Vec::new(); + enc_prefix.read_to_end(&mut ct_prefix).unwrap(); + + // The prefix ciphertext is one segment encrypted as "last". + // The full ciphertext's first segment is encrypted as "not last". + // They must differ. + let first_segment_of_full = &ct_full[..SEGMENT_SIZE + TAG_SIZE]; + assert_ne!( + first_segment_of_full, + &ct_prefix[..], + "Prefix encryption must differ from the corresponding segment \ + in the full encryption (due to nonce difference because of the last-segment flag)" + ); + } + + #[test] + fn deterministic_with_fixed_key_nonce() { + let key = [0x42u8; KEY_SIZE]; + let nonce_prefix = [0x13u8; NONCE_PREFIX_SIZE]; + let data = b"It's dangerous to go alone; take this!"; + + let mut cursor1 = Cursor::new(data.as_slice()); + let mut enc1 = + StreamAttachmentEncryptor::with_key_and_nonce(&mut cursor1, &key, &nonce_prefix); + let mut ct1 = Vec::new(); + enc1.read_to_end(&mut ct1).unwrap(); + + let mut cursor2 = Cursor::new(data.as_slice()); + let mut enc2 = + StreamAttachmentEncryptor::with_key_and_nonce(&mut cursor2, &key, &nonce_prefix); + let mut ct2 = Vec::new(); + enc2.read_to_end(&mut ct2).unwrap(); + + assert_eq!(ct1, ct2, "Same key + nonce + plaintext must produce identical ciphertext"); + } +} diff --git a/crates/matrix-sdk-crypto/src/lib.rs b/crates/matrix-sdk-crypto/src/lib.rs index cf1b06362..a633a5aa0 100644 --- a/crates/matrix-sdk-crypto/src/lib.rs +++ b/crates/matrix-sdk-crypto/src/lib.rs @@ -85,6 +85,11 @@ pub use file_encryption::{ AttachmentDecryptor, AttachmentEncryptor, DecryptorError, KeyExportError, MediaEncryptionInfo, decrypt_room_key_export, encrypt_room_key_export, }; +#[cfg(feature = "stream-attachment-encryption")] +pub use file_encryption::{ + StreamAttachmentDecryptor, StreamAttachmentEncryptor, StreamDecryptorError, + StreamMediaEncryptionInfo, +}; pub use gossiping::{GossipRequest, GossippedSecret}; pub use identities::{ Device, DeviceData, LocalTrust, OtherUserIdentity, OtherUserIdentityData, OwnUserIdentity, diff --git a/crates/matrix-sdk-store-encryption/Cargo.toml b/crates/matrix-sdk-store-encryption/Cargo.toml index 323573338..f534a9be3 100644 --- a/crates/matrix-sdk-store-encryption/Cargo.toml +++ b/crates/matrix-sdk-store-encryption/Cargo.toml @@ -19,7 +19,7 @@ js = ["dep:getrandom", "getrandom?/wasm_js"] [dependencies] base64.workspace = true blake3 = { version = "1.8.2", default-features = false } -chacha20poly1305 = { version = "0.10.1", default-features = false, features = ["std"] } +chacha20poly1305.workspace = true getrandom = { workspace = true, optional = true } hmac.workspace = true pbkdf2.workspace = true