use crate::merkle::{leaf_of, Leaf};
use crate::secret::{MetaKey, PayloadKey, SecretBuf};
use crate::{label, AeadAlg, CryptoError};
use chacha20poly1305::{
aead::{Aead, AeadInOut, Payload},
KeyInit, XChaCha20Poly1305,
};
use zeroize::Zeroizing;
pub const NONCE_LEN: usize = 24;
pub const TAG_LEN: usize = 16;
pub const CHUNK_AAD_LEN: usize = 32;
pub const META_AAD_LEN: usize = label::PRIVATE_META.len().saturating_add(16);
const _: () = assert!(
label::CHUNK
.len()
.saturating_add(16)
.saturating_add(4)
.saturating_add(1)
== CHUNK_AAD_LEN,
"метка \"CC/v1/chunk\" разъехалась с CHUNK_AAD_LEN"
);
pub fn chunk_aad(file_id: &[u8; 16], index: u32, alg: AeadAlg) -> [u8; CHUNK_AAD_LEN] {
let index_be = index.to_be_bytes();
let alg_id = alg as u8;
let source = label::CHUNK
.as_bytes()
.iter()
.chain(file_id.iter())
.chain(index_be.iter())
.chain(core::iter::once(&alg_id));
let mut aad = [0u8; CHUNK_AAD_LEN];
for (slot, byte) in aad.iter_mut().zip(source) {
*slot = *byte;
}
aad
}
pub fn metadata_aad(file_id: &[u8; 16]) -> [u8; META_AAD_LEN] {
let source = label::PRIVATE_META.as_bytes().iter().chain(file_id.iter());
let mut aad = [0u8; META_AAD_LEN];
for (slot, byte) in aad.iter_mut().zip(source) {
*slot = *byte;
}
aad
}
pub fn ensure_supported(alg: AeadAlg) -> Result<(), CryptoError> {
match alg {
AeadAlg::XChaCha20Poly1305 => Ok(()),
AeadAlg::Aes256Gcm | AeadAlg::Aes256GcmSiv => Err(CryptoError::UnsupportedAlgorithm),
}
}
fn seal_with(
key: &[u8; 32],
alg: AeadAlg,
nonce: &[u8; NONCE_LEN],
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, CryptoError> {
match alg {
AeadAlg::XChaCha20Poly1305 => XChaCha20Poly1305::new(key.into())
.encrypt(nonce.into(), Payload { msg: plaintext, aad })
.map_err(|_| CryptoError::BadLength),
AeadAlg::Aes256Gcm | AeadAlg::Aes256GcmSiv => Err(CryptoError::UnsupportedAlgorithm),
}
}
fn open_in_place(
key: &[u8; 32],
alg: AeadAlg,
nonce: &[u8; NONCE_LEN],
buffer: &mut [u8],
tag: &[u8; TAG_LEN],
aad: &[u8],
) -> Result<(), CryptoError> {
match alg {
AeadAlg::XChaCha20Poly1305 => XChaCha20Poly1305::new(key.into())
.decrypt_inout_detached(nonce.into(), aad, buffer.into(), tag.into())
.map_err(|_| CryptoError::Authentication),
AeadAlg::Aes256Gcm | AeadAlg::Aes256GcmSiv => Err(CryptoError::UnsupportedAlgorithm),
}
}
fn open_with(
key: &[u8; 32],
alg: AeadAlg,
nonce: &[u8; NONCE_LEN],
ct_and_tag: &[u8],
aad: &[u8],
) -> Result<Vec<u8>, CryptoError> {
match alg {
AeadAlg::XChaCha20Poly1305 => XChaCha20Poly1305::new(key.into())
.decrypt(nonce.into(), Payload { msg: ct_and_tag, aad })
.map_err(|_| CryptoError::Authentication),
AeadAlg::Aes256Gcm | AeadAlg::Aes256GcmSiv => Err(CryptoError::UnsupportedAlgorithm),
}
}
pub fn seal_chunk_hedged(
key: &PayloadKey,
alg: AeadAlg,
file_id: &[u8; 16],
index: u32,
nonce_seed: &[u8; NONCE_LEN],
plaintext: &[u8],
out: &mut Vec<u8>,
) -> Result<([u8; NONCE_LEN], Leaf), CryptoError> {
let aad = chunk_aad(file_id, index, alg);
let nonce = crate::kdf::hedged_nonce::<NONCE_LEN>(crate::label::FRAME_NONCE, nonce_seed, plaintext, &aad)?;
let leaf = seal_chunk_with_aad(key, alg, index, &nonce, plaintext, &aad, out)?;
Ok((nonce, leaf))
}
#[cfg(any(test, feature = "explicit-nonce"))]
pub fn seal_chunk(
key: &PayloadKey,
alg: AeadAlg,
file_id: &[u8; 16],
index: u32,
nonce: &[u8; NONCE_LEN],
plaintext: &[u8],
out: &mut Vec<u8>,
) -> Result<Leaf, CryptoError> {
let aad = chunk_aad(file_id, index, alg);
seal_chunk_with_aad(key, alg, index, nonce, plaintext, &aad, out)
}
fn seal_chunk_with_aad(
key: &PayloadKey,
alg: AeadAlg,
index: u32,
nonce: &[u8; NONCE_LEN],
plaintext: &[u8],
aad: &[u8; CHUNK_AAD_LEN],
out: &mut Vec<u8>,
) -> Result<Leaf, CryptoError> {
out.clear();
let sealed = seal_with(key.expose(), alg, nonce, plaintext, aad)?;
let (ct, tag) = sealed.split_last_chunk::<TAG_LEN>().ok_or(CryptoError::BadLength)?;
let leaf = leaf_of(index, nonce, tag, ct);
out.extend_from_slice(&sealed);
Ok(leaf)
}
pub fn open_chunk(
key: &PayloadKey,
alg: AeadAlg,
file_id: &[u8; 16],
index: u32,
nonce: &[u8; NONCE_LEN],
ct_and_tag: &[u8],
out: &mut SecretBuf,
) -> Result<Leaf, CryptoError> {
out.wipe();
match open_chunk_inner(key, alg, file_id, index, nonce, ct_and_tag, out) {
Ok(leaf) => Ok(leaf),
Err(err) => {
out.wipe();
Err(err)
}
}
}
fn open_chunk_inner(
key: &PayloadKey,
alg: AeadAlg,
file_id: &[u8; 16],
index: u32,
nonce: &[u8; NONCE_LEN],
ct_and_tag: &[u8],
out: &mut SecretBuf,
) -> Result<Leaf, CryptoError> {
let (ct, tag) = ct_and_tag
.split_last_chunk::<TAG_LEN>()
.ok_or(CryptoError::BadLength)?;
let aad = chunk_aad(file_id, index, alg);
let room = out.as_capacity_mut().get_mut(..ct.len()).ok_or(CryptoError::BadLength)?;
room.copy_from_slice(ct);
out.declare_len(ct.len())?;
open_in_place(key.expose(), alg, nonce, out.as_declared_mut(), tag, &aad)?;
Ok(leaf_of(index, nonce, tag, ct))
}
pub fn seal_metadata_hedged(
key: &MetaKey,
file_id: &[u8; 16],
nonce_seed: &[u8; NONCE_LEN],
plaintext: &[u8],
) -> Result<([u8; NONCE_LEN], Vec<u8>), CryptoError> {
let aad = metadata_aad(file_id);
let nonce =
crate::kdf::hedged_nonce::<NONCE_LEN>(label::META_NONCE, nonce_seed, plaintext, &aad)?;
let ct = seal_with(key.expose(), AeadAlg::XChaCha20Poly1305, &nonce, plaintext, &aad)?;
Ok((nonce, ct))
}
#[cfg(any(test, feature = "explicit-nonce"))]
pub fn seal_metadata(
key: &MetaKey,
file_id: &[u8; 16],
nonce: &[u8; NONCE_LEN],
plaintext: &[u8],
) -> Result<Vec<u8>, CryptoError> {
let aad = metadata_aad(file_id);
seal_with(key.expose(), AeadAlg::XChaCha20Poly1305, nonce, plaintext, &aad)
}
pub fn open_metadata(
key: &MetaKey,
file_id: &[u8; 16],
nonce: &[u8; NONCE_LEN],
ct_and_tag: &[u8],
) -> Result<Zeroizing<Vec<u8>>, CryptoError> {
let aad = metadata_aad(file_id);
let plaintext = open_with(key.expose(), AeadAlg::XChaCha20Poly1305, nonce, ct_and_tag, &aad)?;
Ok(Zeroizing::new(plaintext))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
const FILE_ID: [u8; 16] = [0x11; 16];
const OTHER_FILE_ID: [u8; 16] = [0x12; 16];
const NONCE: [u8; NONCE_LEN] = [0x21; NONCE_LEN];
const CHUNK_64_KIB: usize = 65536;
fn meta_key() -> MetaKey {
MetaKey::from_bytes([0x33; 32])
}
fn key() -> PayloadKey {
PayloadKey::from_bytes([0x33; 32])
}
fn payload(len: usize) -> Vec<u8> {
(0..len).map(|i| (i % 251) as u8).collect()
}
#[test]
fn chunk_aad_is_exactly_the_bytes_the_format_prescribes() {
let aad = chunk_aad(&FILE_ID, 7, AeadAlg::XChaCha20Poly1305);
let mut expected = Vec::new();
expected.extend_from_slice(b"CC/v1/chunk");
expected.extend_from_slice(&FILE_ID);
expected.extend_from_slice(&7u32.to_be_bytes());
expected.push(1);
assert_eq!(expected.len(), CHUNK_AAD_LEN, "сумма полей AAD не 32 байта");
assert_eq!(aad.as_slice(), expected.as_slice());
assert_eq!(label::CHUNK.len(), 11, "метка чанка обязана быть 11 байт");
}
#[test]
fn a_chunk_round_trips_at_every_boundary_length() {
for len in [0usize, 1, 65535, CHUNK_64_KIB] {
let pt = payload(len);
let mut frame = Vec::new();
let sealed_leaf =
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 3, &NONCE, &pt, &mut frame)
.unwrap();
assert_eq!(frame.len(), len.saturating_add(TAG_LEN), "кадр не равен ct‖tag");
let mut got = SecretBuf::with_capacity(1 << 17);
let opened_leaf =
open_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 3, &NONCE, &frame, &mut got)
.unwrap();
assert_eq!(got.as_slice(), pt.as_slice(), "длина {len}");
assert_eq!(sealed_leaf, opened_leaf);
}
}
#[test]
fn the_leaf_from_sealing_equals_the_leaf_from_opening() {
let pt = payload(1000);
let mut frame = Vec::new();
let sealed =
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 9, &NONCE, &pt, &mut frame)
.unwrap();
let mut got = SecretBuf::with_capacity(1 << 17);
let opened =
open_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 9, &NONCE, &frame, &mut got)
.unwrap();
assert_eq!(sealed, opened);
let mut other = Vec::new();
let neighbour =
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 10, &NONCE, &pt, &mut other)
.unwrap();
assert_ne!(sealed, neighbour);
}
#[test]
fn a_chunk_offered_as_its_neighbour_does_not_open() {
let pt = payload(4096);
let mut frame = Vec::new();
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 4, &NONCE, &pt, &mut frame)
.unwrap();
let mut got = SecretBuf::with_capacity(1 << 17);
let err =
open_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 5, &NONCE, &frame, &mut got)
.unwrap_err();
assert_eq!(err, CryptoError::Authentication);
assert!(got.is_empty());
}
#[test]
fn a_chunk_from_another_file_does_not_open() {
let pt = payload(4096);
let mut frame = Vec::new();
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 4, &NONCE, &pt, &mut frame)
.unwrap();
let mut got = SecretBuf::with_capacity(1 << 17);
let err = open_chunk(
&key(),
AeadAlg::XChaCha20Poly1305,
&OTHER_FILE_ID,
4,
&NONCE,
&frame,
&mut got,
)
.unwrap_err();
assert_eq!(err, CryptoError::Authentication);
assert!(got.is_empty());
}
#[test]
fn flipping_any_byte_of_ciphertext_or_tag_is_caught() {
let pt = payload(64);
let mut frame = Vec::new();
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 0, &NONCE, &pt, &mut frame)
.unwrap();
for (pos, _) in frame.iter().enumerate() {
let mut broken = frame.clone();
*broken.get_mut(pos).unwrap() ^= 1;
let mut got = SecretBuf::with_capacity(1 << 17);
let err = open_chunk(
&key(),
AeadAlg::XChaCha20Poly1305,
&FILE_ID,
0,
&NONCE,
&broken,
&mut got,
)
.unwrap_err();
assert_eq!(err, CryptoError::Authentication, "байт {pos} прошёл проверку");
}
}
#[test]
fn a_failed_open_leaves_no_bytes_in_the_output_buffer() {
let pt = payload(2048);
let mut frame = Vec::new();
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 1, &NONCE, &pt, &mut frame)
.unwrap();
*frame.get_mut(0).unwrap() ^= 0xff;
let mut got = SecretBuf::with_capacity(4096);
got.fill_from(&[0xaa; 4096]).unwrap();
let err =
open_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 1, &NONCE, &frame, &mut got)
.unwrap_err();
assert_eq!(err, CryptoError::Authentication);
assert!(got.is_empty(), "в буфере осталось {} байт", got.len());
let mut short = SecretBuf::with_capacity(32);
let err = open_chunk(
&key(),
AeadAlg::XChaCha20Poly1305,
&FILE_ID,
1,
&NONCE,
&[0u8; 4],
&mut short,
)
.unwrap_err();
assert_eq!(err, CryptoError::BadLength);
assert!(short.is_empty());
}
#[test]
fn the_same_plaintext_under_different_nonces_never_repeats_a_ciphertext() {
let pt = payload(1024);
let nonce_b: [u8; NONCE_LEN] = [0x22; NONCE_LEN];
let mut first = Vec::new();
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 2, &NONCE, &pt, &mut first)
.unwrap();
let mut second = Vec::new();
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 2, &nonce_b, &pt, &mut second)
.unwrap();
assert_ne!(first, second, "тот же ключ и nonce дал бы повторное использование");
}
#[test]
fn sealing_overwrites_whatever_the_output_buffer_held_before() {
let pt = payload(10);
let mut out = vec![0xcc; 100];
seal_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 0, &NONCE, &pt, &mut out).unwrap();
assert_eq!(out.len(), pt.len().saturating_add(TAG_LEN));
}
#[test]
fn metadata_round_trips_under_its_own_key() {
let meta = b"secret-report.docx\0application/pdf".to_vec();
let sealed = seal_metadata(&meta_key(), &FILE_ID, &NONCE, &meta).unwrap();
assert_eq!(sealed.len(), meta.len().saturating_add(TAG_LEN));
let opened = open_metadata(&meta_key(), &FILE_ID, &NONCE, &sealed).unwrap();
assert_eq!(opened.as_slice(), meta.as_slice());
let err = open_metadata(&meta_key(), &OTHER_FILE_ID, &NONCE, &sealed).unwrap_err();
assert_eq!(err, CryptoError::Authentication);
}
#[test]
fn private_metadata_never_opens_as_a_chunk() {
let meta = payload(64);
let sealed = seal_metadata(&meta_key(), &FILE_ID, &NONCE, &meta).unwrap();
let mut got = SecretBuf::with_capacity(1 << 17);
let err =
open_chunk(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 0, &NONCE, &sealed, &mut got)
.unwrap_err();
assert_eq!(err, CryptoError::Authentication);
assert!(got.is_empty());
}
#[test]
fn unsupported_aead_profiles_are_refused_not_silently_substituted() {
let pt = payload(32);
for alg in [AeadAlg::Aes256Gcm, AeadAlg::Aes256GcmSiv] {
let mut out = Vec::new();
let err = seal_chunk(&key(), alg, &FILE_ID, 0, &NONCE, &pt, &mut out).unwrap_err();
assert_eq!(err, CryptoError::UnsupportedAlgorithm);
assert!(out.is_empty());
let mut got = SecretBuf::with_capacity(1 << 17);
let err =
open_chunk(&key(), alg, &FILE_ID, 0, &NONCE, &[0u8; 48], &mut got).unwrap_err();
assert_eq!(err, CryptoError::UnsupportedAlgorithm);
assert!(got.is_empty());
}
}
#[test]
fn the_same_seed_still_yields_different_nonces_for_different_plaintexts() {
let seed = [0x33u8; NONCE_LEN];
let mut first = Vec::new();
let mut second = Vec::new();
let (nonce_a, _) =
seal_chunk_hedged(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 0, &seed, b"one", &mut first)
.unwrap();
let (nonce_b, _) =
seal_chunk_hedged(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 0, &seed, b"two", &mut second)
.unwrap();
assert_ne!(nonce_a, nonce_b, "разный текст обязан дать разный nonce при том же засеве");
assert_ne!(first, second);
}
#[test]
fn the_same_seed_and_the_same_plaintext_repeat_and_that_is_the_known_limit() {
let seed = [0x44u8; NONCE_LEN];
let mut first = Vec::new();
let mut second = Vec::new();
let (nonce_a, _) =
seal_chunk_hedged(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 0, &seed, b"same", &mut first)
.unwrap();
let (nonce_b, _) =
seal_chunk_hedged(&key(), AeadAlg::XChaCha20Poly1305, &FILE_ID, 0, &seed, b"same", &mut second)
.unwrap();
assert_eq!(nonce_a, nonce_b);
assert_eq!(first, second);
}
}