use chacha20poly1305::{
KeyInit, XChaCha20Poly1305,
aead::{Aead, Payload},
};
use crate::{
errors::{CryptError, FileError},
file::FileMetadata,
memory::{SecureBytes, SecureKey},
v3::{
crypt,
header::{AadPurpose, FileHeader, HeaderBinding},
key::KeyDerivationParams,
},
};
pub const CHUNK_SIZE: u32 = 1024 * 1024;
pub const TAG_SIZE: usize = 16;
const MAX_COUNTER: u64 = (1 << 56) - 1;
fn chunk_nonce(prefix: &[u8; 16], counter: u64, is_last: bool) -> [u8; 24] {
let mut nonce = [0u8; 24];
nonce[..16].copy_from_slice(prefix);
nonce[16..23].copy_from_slice(&counter.to_le_bytes()[..7]);
nonce[23] = u8::from(is_last);
nonce
}
pub struct StreamSealer {
cipher: XChaCha20Poly1305,
nonce_prefix: [u8; 16],
chunk_size: usize,
aad: Vec<u8>,
counter: u64,
finished: bool,
}
impl StreamSealer {
pub fn begin(
metadata: &FileMetadata,
key: &SecureKey,
kdf_params: KeyDerivationParams,
salt: [u8; 16],
nonce_prefix: [u8; 16],
metadata_nonce: [u8; 24],
) -> Result<(FileHeader, StreamSealer), FileError> {
Self::begin_with_chunk_size(
metadata,
key,
kdf_params,
salt,
nonce_prefix,
metadata_nonce,
CHUNK_SIZE,
)
}
pub fn begin_with_chunk_size(
metadata: &FileMetadata,
key: &SecureKey,
kdf_params: KeyDerivationParams,
salt: [u8; 16],
nonce_prefix: [u8; 16],
metadata_nonce: [u8; 24],
chunk_size: u32,
) -> Result<(FileHeader, StreamSealer), FileError> {
let envelope = crate::v3::metadata::serialize(metadata)?;
let binding = HeaderBinding::new(
&salt,
&kdf_params,
&nonce_prefix,
chunk_size,
&metadata_nonce,
);
let (metadata_ciphertext, _) = crypt::encrypt_bytes(
envelope.as_slice(),
key.as_bytes(),
&metadata_nonce,
&binding.aad(AadPurpose::Metadata),
)?;
let content_aad = binding.aad(AadPurpose::Content);
let header = FileHeader::new(
salt,
kdf_params,
nonce_prefix,
chunk_size,
metadata_nonce,
metadata_ciphertext,
)?;
let sealer = StreamSealer {
cipher: XChaCha20Poly1305::new(key.as_bytes().into()),
nonce_prefix,
chunk_size: chunk_size as usize,
aad: content_aad,
counter: 0,
finished: false,
};
Ok((header, sealer))
}
pub fn chunk_plaintext_len(&self) -> usize {
self.chunk_size
}
pub fn seal_chunk(&mut self, plaintext: &[u8], is_last: bool) -> Result<Vec<u8>, FileError> {
if self.finished {
return Err(stream_error("chunk sealed after the final chunk"));
}
if !is_last && plaintext.len() != self.chunk_size {
return Err(stream_error("non-final chunk must be exactly chunk-sized"));
}
if plaintext.len() > self.chunk_size {
return Err(stream_error("chunk larger than the declared chunk size"));
}
if self.counter > MAX_COUNTER {
return Err(stream_error("chunk counter overflow"));
}
let nonce = chunk_nonce(&self.nonce_prefix, self.counter, is_last);
let ciphertext = self
.cipher
.encrypt(
(&nonce).into(),
Payload {
msg: plaintext,
aad: &self.aad,
},
)
.map_err(|e| {
FileError::Crypt(CryptError::EncryptionError(format!(
"Encryption failed: {}",
e
)))
})?;
self.counter += 1;
self.finished = is_last;
Ok(ciphertext)
}
pub fn finished(&self) -> bool {
self.finished
}
}
pub struct StreamOpener {
cipher: XChaCha20Poly1305,
nonce_prefix: [u8; 16],
chunk_size: usize,
aad: Vec<u8>,
counter: u64,
finished: bool,
}
impl StreamOpener {
pub fn new(header: &FileHeader, key: &SecureKey) -> StreamOpener {
StreamOpener {
cipher: XChaCha20Poly1305::new(key.as_bytes().into()),
nonce_prefix: *header.nonce_prefix(),
chunk_size: header.chunk_size() as usize,
aad: header.binding().aad(AadPurpose::Content),
counter: 0,
finished: false,
}
}
pub fn chunk_ciphertext_len(&self) -> usize {
self.chunk_size + TAG_SIZE
}
pub fn open_chunk(
&mut self,
ciphertext: &[u8],
is_last: bool,
) -> Result<SecureBytes, FileError> {
if self.finished {
return Err(stream_error("data present after the final chunk"));
}
if !is_last && ciphertext.len() != self.chunk_ciphertext_len() {
return Err(stream_error("non-final chunk has the wrong length"));
}
if ciphertext.len() < TAG_SIZE || ciphertext.len() > self.chunk_ciphertext_len() {
return Err(stream_error("chunk has an impossible length"));
}
if self.counter > MAX_COUNTER {
return Err(stream_error("chunk counter overflow"));
}
let nonce = chunk_nonce(&self.nonce_prefix, self.counter, is_last);
let plaintext = self
.cipher
.decrypt(
(&nonce).into(),
Payload {
msg: ciphertext,
aad: &self.aad,
},
)
.map_err(|_| {
FileError::Crypt(CryptError::DecryptionError(
"authentication failed (wrong password, or the file is corrupted)".to_string(),
))
})?;
self.counter += 1;
self.finished = is_last;
Ok(SecureBytes::new(plaintext))
}
pub fn finished(&self) -> bool {
self.finished
}
}
fn stream_error(msg: &str) -> FileError {
FileError::Crypt(CryptError::DecryptionError(format!(
"invalid content stream: {}",
msg
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::SecureString;
fn test_setup(chunk_size: u32) -> (FileHeader, StreamSealer, SecureKey) {
let key = SecureKey::new([9u8; 32]);
let metadata = FileMetadata::new(SecureString::new("a.txt".to_string()), None, None);
let (header, sealer) = StreamSealer::begin_with_chunk_size(
&metadata,
&key,
KeyDerivationParams::test_defaults(),
[1u8; 16],
[2u8; 16],
[3u8; 24],
chunk_size,
)
.unwrap();
(header, sealer, key)
}
fn seal_all(sealer: &mut StreamSealer, content: &[u8], chunk_size: usize) -> Vec<Vec<u8>> {
let pieces: Vec<&[u8]> = if content.is_empty() {
vec![&[][..]]
} else {
content.chunks(chunk_size).collect()
};
pieces
.iter()
.enumerate()
.map(|(i, piece)| sealer.seal_chunk(piece, i == pieces.len() - 1).unwrap())
.collect()
}
fn open_all(
header: &FileHeader,
key: &SecureKey,
chunks: &[Vec<u8>],
) -> Result<Vec<u8>, FileError> {
let mut opener = StreamOpener::new(header, key);
let mut out = Vec::new();
for (i, chunk) in chunks.iter().enumerate() {
let is_last = i == chunks.len() - 1;
out.extend_from_slice(opener.open_chunk(chunk, is_last)?.as_slice());
}
assert!(opener.finished());
Ok(out)
}
#[test]
fn multi_chunk_round_trip() {
let (header, mut sealer, key) = test_setup(8);
let content = b"this content spans multiple chunks".to_vec();
let chunks = seal_all(&mut sealer, &content, 8);
assert!(chunks.len() > 2);
assert_eq!(open_all(&header, &key, &chunks).unwrap(), content);
}
#[test]
fn empty_content_round_trip() {
let (header, mut sealer, key) = test_setup(8);
let chunks = seal_all(&mut sealer, b"", 8);
assert_eq!(chunks.len(), 1);
assert_eq!(open_all(&header, &key, &chunks).unwrap(), b"");
}
#[test]
fn exact_multiple_round_trip() {
let (header, mut sealer, key) = test_setup(8);
let content = b"0123456789abcdef".to_vec();
let chunks = seal_all(&mut sealer, &content, 8);
assert_eq!(chunks.len(), 2); assert_eq!(open_all(&header, &key, &chunks).unwrap(), content);
}
#[test]
fn truncation_is_detected() {
let (header, mut sealer, key) = test_setup(8);
let mut chunks = seal_all(&mut sealer, b"0123456789abcdefgh", 8);
chunks.pop();
assert!(open_all(&header, &key, &chunks).is_err());
}
#[test]
fn reordering_is_detected() {
let (header, mut sealer, key) = test_setup(8);
let mut chunks = seal_all(&mut sealer, b"0123456789abcdefgh", 8);
chunks.swap(0, 1);
assert!(open_all(&header, &key, &chunks).is_err());
}
#[test]
fn corruption_is_detected() {
let (header, mut sealer, key) = test_setup(8);
let mut chunks = seal_all(&mut sealer, b"0123456789abcdefgh", 8);
chunks[1][0] ^= 1;
assert!(open_all(&header, &key, &chunks).is_err());
}
#[test]
fn sealing_after_final_chunk_fails() {
let (_, mut sealer, _) = test_setup(8);
sealer.seal_chunk(b"tail", true).unwrap();
assert!(sealer.seal_chunk(b"more", true).is_err());
}
#[test]
fn short_non_final_chunk_fails() {
let (_, mut sealer, _) = test_setup(8);
assert!(sealer.seal_chunk(b"tiny", false).is_err());
}
#[test]
fn wrong_key_fails() {
let (header, mut sealer, key) = test_setup(8);
let chunks = seal_all(&mut sealer, b"content", 8);
let wrong = SecureKey::new([1u8; 32]);
assert!(open_all(&header, &wrong, &chunks).is_err());
assert!(open_all(&header, &key, &chunks).is_ok());
}
}