use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
pub const APPLICATION_ENVELOPE_MAGIC: &[u8; 6] = b"ORTCE1";
pub const APPLICATION_ENVELOPE_VERSION_REPLAY: u8 = 2;
pub const APPLICATION_REPLAY_SEQUENCE_BYTES: usize = 8;
pub const APPLICATION_KEY_BYTES: usize = 32;
pub const APPLICATION_NONCE_BYTES: usize = 24;
pub const RAW_STREAM_TYPE_ID: u8 = 3;
pub const DEFAULT_MAX_RAW_FRAME_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplicationCryptoError {
InvalidKeyLength,
InvalidNonceLength,
RandomNonceFailed,
PlaintextRejected,
InvalidEnvelope,
DecryptFailed,
TypeMismatch,
}
fn constant_time_prefix_eq(input: &[u8], prefix: &[u8]) -> bool {
if input.len() < prefix.len() {
return false;
}
let mut diff = 0u8;
for (left, right) in input.iter().zip(prefix.iter()) {
diff |= left ^ right;
}
diff == 0
}
fn envelope_version(payload: &[u8]) -> Option<u8> {
if !constant_time_prefix_eq(payload, APPLICATION_ENVELOPE_MAGIC) {
return None;
}
let version = payload[APPLICATION_ENVELOPE_MAGIC.len()];
if version == APPLICATION_ENVELOPE_VERSION_REPLAY {
Some(version)
} else {
None
}
}
pub fn is_application_encrypted_payload(payload: &[u8]) -> bool {
envelope_version(payload).is_some_and(|_| {
payload.len()
> APPLICATION_ENVELOPE_MAGIC.len()
+ 1
+ APPLICATION_REPLAY_SEQUENCE_BYTES
+ APPLICATION_NONCE_BYTES
})
}
pub fn protect_application_payload(
key: &[u8],
type_id: u8,
payload: &[u8],
) -> Result<Vec<u8>, ApplicationCryptoError> {
let mut nonce = [0u8; APPLICATION_NONCE_BYTES];
getrandom::getrandom(&mut nonce).map_err(|_| ApplicationCryptoError::RandomNonceFailed)?;
protect_application_payload_with_nonce(key, type_id, payload, &nonce)
}
pub fn protect_application_payload_with_nonce(
key: &[u8],
type_id: u8,
payload: &[u8],
nonce: &[u8],
) -> Result<Vec<u8>, ApplicationCryptoError> {
protect_application_payload_with_nonce_and_sequence(key, type_id, payload, nonce, 0)
}
pub fn protect_application_payload_with_nonce_and_sequence(
key: &[u8],
type_id: u8,
payload: &[u8],
nonce: &[u8],
sequence: u64,
) -> Result<Vec<u8>, ApplicationCryptoError> {
if key.len() != APPLICATION_KEY_BYTES {
return Err(ApplicationCryptoError::InvalidKeyLength);
}
if nonce.len() != APPLICATION_NONCE_BYTES {
return Err(ApplicationCryptoError::InvalidNonceLength);
}
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|_| ApplicationCryptoError::InvalidKeyLength)?;
let nonce = XNonce::from_slice(nonce);
let mut plaintext = Vec::with_capacity(1 + payload.len());
plaintext.push(type_id);
plaintext.extend_from_slice(payload);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_slice())
.map_err(|_| ApplicationCryptoError::DecryptFailed)?;
let mut envelope = Vec::with_capacity(
APPLICATION_ENVELOPE_MAGIC.len()
+ 1
+ APPLICATION_REPLAY_SEQUENCE_BYTES
+ APPLICATION_NONCE_BYTES
+ ciphertext.len(),
);
envelope.extend_from_slice(APPLICATION_ENVELOPE_MAGIC);
envelope.push(APPLICATION_ENVELOPE_VERSION_REPLAY);
envelope.extend_from_slice(&sequence.to_be_bytes());
envelope.extend_from_slice(nonce);
envelope.extend_from_slice(&ciphertext);
Ok(envelope)
}
pub fn open_application_payload(
key: &[u8],
expected_type_id: u8,
payload: &[u8],
require_encrypted: bool,
) -> Result<Vec<u8>, ApplicationCryptoError> {
if key.len() != APPLICATION_KEY_BYTES {
return Err(ApplicationCryptoError::InvalidKeyLength);
}
if !is_application_encrypted_payload(payload) {
return if require_encrypted {
Err(ApplicationCryptoError::PlaintextRejected)
} else {
Ok(payload.to_vec())
};
}
let version = envelope_version(payload).ok_or(ApplicationCryptoError::InvalidEnvelope)?;
if version != APPLICATION_ENVELOPE_VERSION_REPLAY {
return Err(ApplicationCryptoError::InvalidEnvelope);
}
let nonce_offset = APPLICATION_ENVELOPE_MAGIC.len() + 1 + APPLICATION_REPLAY_SEQUENCE_BYTES;
let cipher_offset = nonce_offset + APPLICATION_NONCE_BYTES;
if payload.len() <= cipher_offset {
return Err(ApplicationCryptoError::InvalidEnvelope);
}
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|_| ApplicationCryptoError::InvalidKeyLength)?;
let nonce = XNonce::from_slice(&payload[nonce_offset..cipher_offset]);
let plaintext = cipher
.decrypt(nonce, &payload[cipher_offset..])
.map_err(|_| ApplicationCryptoError::DecryptFailed)?;
let Some((&type_id, body)) = plaintext.split_first() else {
return Err(ApplicationCryptoError::InvalidEnvelope);
};
if type_id != expected_type_id {
return Err(ApplicationCryptoError::TypeMismatch);
}
Ok(body.to_vec())
}
pub fn protect_raw_stream_frame(
key: &[u8],
payload: &[u8],
) -> Result<Vec<u8>, ApplicationCryptoError> {
let encrypted = protect_application_payload(key, RAW_STREAM_TYPE_ID, payload)?;
Ok(frame_encrypted_payload(&encrypted))
}
pub fn protect_raw_stream_frame_with_nonce(
key: &[u8],
payload: &[u8],
nonce: &[u8],
) -> Result<Vec<u8>, ApplicationCryptoError> {
let encrypted =
protect_application_payload_with_nonce(key, RAW_STREAM_TYPE_ID, payload, nonce)?;
Ok(frame_encrypted_payload(&encrypted))
}
pub fn frame_encrypted_payload(payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(4 + payload.len());
frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
frame.extend_from_slice(payload);
frame
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApplicationCryptoStreamError {
InvalidFrameLength,
FrameTooLarge,
AuthenticationFailed(ApplicationCryptoError),
IncompleteFrame,
}
#[derive(Debug, Clone)]
pub struct ApplicationCryptoFrameDecoder {
key: Vec<u8>,
max_frame_bytes: usize,
buffer: Vec<u8>,
}
impl ApplicationCryptoFrameDecoder {
pub fn new(key: &[u8]) -> Result<Self, ApplicationCryptoError> {
Self::with_max_frame_bytes(key, DEFAULT_MAX_RAW_FRAME_BYTES)
}
pub fn with_max_frame_bytes(
key: &[u8],
max_frame_bytes: usize,
) -> Result<Self, ApplicationCryptoError> {
if key.len() != APPLICATION_KEY_BYTES {
return Err(ApplicationCryptoError::InvalidKeyLength);
}
Ok(Self {
key: key.to_vec(),
max_frame_bytes,
buffer: Vec::new(),
})
}
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<Vec<u8>>, ApplicationCryptoStreamError> {
self.buffer.extend_from_slice(chunk);
let mut opened = Vec::new();
loop {
if self.buffer.len() < 4 {
break;
}
let frame_len = u32::from_be_bytes([
self.buffer[0],
self.buffer[1],
self.buffer[2],
self.buffer[3],
]) as usize;
if frame_len == 0 {
return Err(ApplicationCryptoStreamError::InvalidFrameLength);
}
if frame_len > self.max_frame_bytes {
return Err(ApplicationCryptoStreamError::FrameTooLarge);
}
if self.buffer.len() < 4 + frame_len {
break;
}
let encrypted = self.buffer[4..4 + frame_len].to_vec();
self.buffer.drain(..4 + frame_len);
let payload = open_application_payload(&self.key, RAW_STREAM_TYPE_ID, &encrypted, true)
.map_err(ApplicationCryptoStreamError::AuthenticationFailed)?;
opened.push(payload);
}
Ok(opened)
}
pub fn finish(&self) -> Result<(), ApplicationCryptoStreamError> {
if self.buffer.is_empty() {
Ok(())
} else {
Err(ApplicationCryptoStreamError::IncompleteFrame)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> [u8; APPLICATION_KEY_BYTES] {
[0x42; APPLICATION_KEY_BYTES]
}
fn nonce() -> [u8; APPLICATION_NONCE_BYTES] {
[0x24; APPLICATION_NONCE_BYTES]
}
#[test]
fn application_payload_envelope_round_trips_without_plaintext() {
let plaintext = b"native-super-secret-payload";
let envelope =
protect_application_payload_with_nonce(&key(), 0, plaintext, &nonce()).unwrap();
assert!(is_application_encrypted_payload(&envelope));
assert!(!String::from_utf8_lossy(&envelope).contains("native-super-secret-payload"));
let opened = open_application_payload(&key(), 0, &envelope, true).unwrap();
assert_eq!(opened, plaintext);
}
#[test]
fn application_payload_envelope_matches_typescript_xchacha_fixture() {
let key = [0x11; APPLICATION_KEY_BYTES];
let nonce = [0x22; APPLICATION_NONCE_BYTES];
let envelope =
protect_application_payload_with_nonce(&key, 0, b"interop-secret-payload", &nonce)
.unwrap();
assert_eq!(
hex::encode(envelope),
"4f5254434531020000000000000000222222222222222222222222222222222222222222222222830e897839cb8be4c83e71a8cf1cd5cf42f6bab87aff9e8eb208926635a43db794781e4d3047a2",
);
}
#[test]
fn application_payload_envelope_rejects_tampering() {
let plaintext = b"native-super-secret-payload";
let mut envelope =
protect_application_payload_with_nonce(&key(), 0, plaintext, &nonce()).unwrap();
let last = envelope.len() - 1;
envelope[last] ^= 0x55;
assert_eq!(
open_application_payload(&key(), 0, &envelope, true),
Err(ApplicationCryptoError::DecryptFailed),
);
}
#[test]
fn application_payload_envelope_rejects_wrong_type() {
let envelope =
protect_application_payload_with_nonce(&key(), 1, b"video payload", &nonce()).unwrap();
assert_eq!(
open_application_payload(&key(), 2, &envelope, true),
Err(ApplicationCryptoError::TypeMismatch),
);
}
#[test]
fn application_payload_envelope_rejects_foreign_key() {
let attacker_key = [0x11; APPLICATION_KEY_BYTES];
let victim_key = [0x22; APPLICATION_KEY_BYTES];
let envelope = protect_application_payload_with_nonce(
&attacker_key,
0,
b"cross-key-forgery",
&nonce(),
)
.unwrap();
assert_eq!(
open_application_payload(&victim_key, 0, &envelope, true),
Err(ApplicationCryptoError::DecryptFailed),
);
}
#[test]
fn raw_stream_decoder_rejects_foreign_key_frames() {
let attacker_key = [0x33; APPLICATION_KEY_BYTES];
let victim_key = [0x44; APPLICATION_KEY_BYTES];
let frame =
protect_raw_stream_frame_with_nonce(&attacker_key, b"foreign raw frame", &nonce())
.unwrap();
let mut decoder = ApplicationCryptoFrameDecoder::new(&victim_key).unwrap();
assert_eq!(
decoder.push(&frame),
Err(ApplicationCryptoStreamError::AuthenticationFailed(
ApplicationCryptoError::DecryptFailed
)),
);
}
#[test]
fn application_payload_envelope_rejects_plaintext_when_required() {
assert_eq!(
open_application_payload(&key(), 0, b"plaintext-downgrade", true),
Err(ApplicationCryptoError::PlaintextRejected),
);
}
#[test]
fn application_payload_envelope_can_allow_legacy_plaintext_when_requested() {
assert_eq!(
open_application_payload(&key(), 0, b"legacy plaintext", false).unwrap(),
b"legacy plaintext",
);
}
#[test]
fn raw_stream_frame_encrypts_and_decodes_split_frames() {
let plaintext = b"native raw stream secret";
let frame = protect_raw_stream_frame_with_nonce(&key(), plaintext, &nonce()).unwrap();
assert!(!String::from_utf8_lossy(&frame).contains("native raw stream secret"));
let split = frame.len() / 2;
let mut decoder = ApplicationCryptoFrameDecoder::new(&key()).unwrap();
assert!(decoder.push(&frame[..split]).unwrap().is_empty());
let opened = decoder.push(&frame[split..]).unwrap();
assert_eq!(opened, vec![plaintext.to_vec()]);
assert_eq!(decoder.finish(), Ok(()));
}
#[test]
fn raw_stream_decoder_opens_multiple_frames_from_one_chunk() {
let first = protect_raw_stream_frame_with_nonce(&key(), b"first chunk", &nonce()).unwrap();
let mut second_nonce = nonce();
second_nonce[0] ^= 0x01;
let second =
protect_raw_stream_frame_with_nonce(&key(), b"second chunk", &second_nonce).unwrap();
let mut combined = first;
combined.extend_from_slice(&second);
let mut decoder = ApplicationCryptoFrameDecoder::new(&key()).unwrap();
let opened = decoder.push(&combined).unwrap();
assert_eq!(
opened,
vec![b"first chunk".to_vec(), b"second chunk".to_vec()]
);
assert_eq!(decoder.finish(), Ok(()));
}
#[test]
fn raw_stream_decoder_rejects_plaintext_downgrade_frame() {
let plaintext_frame = frame_encrypted_payload(b"plaintext raw stream");
let mut decoder = ApplicationCryptoFrameDecoder::new(&key()).unwrap();
assert_eq!(
decoder.push(&plaintext_frame),
Err(ApplicationCryptoStreamError::AuthenticationFailed(
ApplicationCryptoError::PlaintextRejected
)),
);
}
#[test]
fn raw_stream_decoder_rejects_tampered_frame() {
let mut frame =
protect_raw_stream_frame_with_nonce(&key(), b"tamper me", &nonce()).unwrap();
let last = frame.len() - 1;
frame[last] ^= 0x7f;
let mut decoder = ApplicationCryptoFrameDecoder::new(&key()).unwrap();
assert_eq!(
decoder.push(&frame),
Err(ApplicationCryptoStreamError::AuthenticationFailed(
ApplicationCryptoError::DecryptFailed
)),
);
}
#[test]
fn raw_stream_decoder_reports_incomplete_frame_on_finish() {
let frame = protect_raw_stream_frame_with_nonce(&key(), b"partial", &nonce()).unwrap();
let mut decoder = ApplicationCryptoFrameDecoder::new(&key()).unwrap();
assert!(decoder.push(&frame[..frame.len() - 1]).unwrap().is_empty());
assert_eq!(
decoder.finish(),
Err(ApplicationCryptoStreamError::IncompleteFrame),
);
}
#[test]
fn raw_stream_decoder_rejects_invalid_lengths() {
let mut decoder = ApplicationCryptoFrameDecoder::with_max_frame_bytes(&key(), 8).unwrap();
assert_eq!(
decoder.push(&0u32.to_be_bytes()),
Err(ApplicationCryptoStreamError::InvalidFrameLength),
);
let oversized = 9u32.to_be_bytes();
let mut decoder = ApplicationCryptoFrameDecoder::with_max_frame_bytes(&key(), 8).unwrap();
assert_eq!(
decoder.push(&oversized),
Err(ApplicationCryptoStreamError::FrameTooLarge),
);
}
}