feat(crypto): Implement STREAM-based file attachment encryption

This commit is contained in:
Denis Kasak
2026-03-26 15:46:59 +01:00
parent 81bed18550
commit dfb347f4e4
7 changed files with 749 additions and 1 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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 }

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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<EncryptorBE32<XChaCha20Poly1305>>,
plaintext_buf: Vec<u8>,
ciphertext_buf: Vec<u8>,
ct_pos: usize,
state: EncryptorState,
info: StreamMediaEncryptionInfo,
}
impl<R: Read + ?Sized + std::fmt::Debug> 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<bool> {
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<R: Read + ?Sized> Read for StreamAttachmentEncryptor<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
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<DecryptorBE32<XChaCha20Poly1305>>,
segment_buf: Vec<u8>,
plaintext_buf: Vec<u8>,
pt_pos: usize,
state: DecryptorState,
}
impl<R: Read + std::fmt::Debug> 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<Self, StreamDecryptorError> {
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<R: Read> Read for StreamAttachmentDecryptor<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
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<u8>, 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<Vec<u8>, 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");
}
}

View File

@@ -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,

View File

@@ -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