use std::marker::PhantomData;
use zeroize::{Zeroize, Zeroizing};
use auths_crypto::default_provider;
use crate::domain_separation::ENVELOPE_INFO;
use crate::sas::TransportKey;
pub const MAX_MESSAGES_PER_SESSION: u32 = 1024;
#[derive(Debug, thiserror::Error)]
pub enum EnvelopeError {
#[error("envelope authentication failed")]
TagMismatch,
#[error("envelope counter not strictly monotonic: expected > {expected}, got {got}")]
CounterNotMonotonic {
expected: u32,
got: u32,
},
#[error("envelope AAD mismatch")]
AadMismatch,
#[error("envelope session exhausted (>{MAX_MESSAGES_PER_SESSION} messages)")]
SessionExhausted,
#[error("envelope key derivation failed: {0}")]
KeyDerivation(String),
#[error("envelope encrypt/decrypt failed: {0}")]
Cipher(String),
}
pub struct Sealed;
pub struct Open;
pub struct Envelope<S> {
nonce: [u8; 12],
counter: u32,
payload: Vec<u8>,
aad_session_id: String,
aad_path: String,
_state: PhantomData<S>,
}
impl<S> std::fmt::Debug for Envelope<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Envelope")
.field("counter", &self.counter)
.field("session_id", &self.aad_session_id)
.field("path", &self.aad_path)
.field(
"payload",
&format_args!("<{} bytes redacted>", self.payload.len()),
)
.finish()
}
}
impl<S> Envelope<S> {
pub fn counter(&self) -> u32 {
self.counter
}
pub fn session_id(&self) -> &str {
&self.aad_session_id
}
pub fn path(&self) -> &str {
&self.aad_path
}
}
impl Envelope<Sealed> {
pub fn ciphertext(&self) -> &[u8] {
&self.payload
}
pub fn nonce(&self) -> &[u8; 12] {
&self.nonce
}
}
impl Envelope<Open> {
pub fn plaintext(&self) -> &[u8] {
&self.payload
}
}
pub struct EnvelopeSession {
key: Zeroizing<[u8; 32]>,
iv: [u8; 12],
next_counter: u32,
last_opened_counter: Option<u32>,
session_id: String,
}
impl EnvelopeSession {
pub async fn new(
transport_key: &TransportKey,
session_id: String,
iv: [u8; 12],
) -> Result<Self, EnvelopeError> {
let provider = default_provider();
let okm = provider
.hkdf_sha256_expand(transport_key.as_bytes(), &[], ENVELOPE_INFO, 32)
.await
.map_err(|e| EnvelopeError::KeyDerivation(e.to_string()))?;
let mut key_bytes = [0u8; 32];
key_bytes.copy_from_slice(&okm);
Ok(Self {
key: Zeroizing::new(key_bytes),
iv,
next_counter: 1,
last_opened_counter: None,
session_id,
})
}
pub async fn seal(
&mut self,
path: &str,
plaintext: &[u8],
) -> Result<Envelope<Sealed>, EnvelopeError> {
if self.next_counter >= MAX_MESSAGES_PER_SESSION {
return Err(EnvelopeError::SessionExhausted);
}
let counter = self.next_counter;
self.next_counter = self.next_counter.wrapping_add(1);
let nonce = nonce_for_counter(&self.iv, counter);
let aad = build_aad(&self.session_id, path, counter);
let provider = default_provider();
let ct = provider
.aead_encrypt(&self.key, &nonce, &aad, plaintext)
.await
.map_err(|e| EnvelopeError::Cipher(e.to_string()))?;
Ok(Envelope {
nonce,
counter,
payload: ct,
aad_session_id: self.session_id.clone(),
aad_path: path.to_string(),
_state: PhantomData,
})
}
pub async fn open(
&mut self,
path: &str,
env: Envelope<Sealed>,
) -> Result<Envelope<Open>, EnvelopeError> {
if let Some(last) = self.last_opened_counter
&& env.counter <= last
{
return Err(EnvelopeError::CounterNotMonotonic {
expected: last,
got: env.counter,
});
}
let nonce = nonce_for_counter(&self.iv, env.counter);
if nonce != env.nonce {
return Err(EnvelopeError::AadMismatch);
}
if path != env.aad_path {
return Err(EnvelopeError::AadMismatch);
}
if self.session_id != env.aad_session_id {
return Err(EnvelopeError::AadMismatch);
}
let aad = build_aad(&self.session_id, path, env.counter);
let provider = default_provider();
let pt = provider
.aead_decrypt(&self.key, &nonce, &aad, &env.payload)
.await
.map_err(|_| EnvelopeError::TagMismatch)?;
self.last_opened_counter = Some(env.counter);
Ok(Envelope {
nonce: env.nonce,
counter: env.counter,
payload: pt,
aad_session_id: env.aad_session_id,
aad_path: env.aad_path,
_state: PhantomData,
})
}
}
impl Drop for EnvelopeSession {
fn drop(&mut self) {
self.iv.zeroize();
}
}
fn nonce_for_counter(iv: &[u8; 12], counter: u32) -> [u8; 12] {
let mut nonce = *iv;
let ctr = counter.to_be_bytes();
nonce[8] ^= ctr[0];
nonce[9] ^= ctr[1];
nonce[10] ^= ctr[2];
nonce[11] ^= ctr[3];
nonce
}
fn build_aad(session_id: &str, path: &str, counter: u32) -> Vec<u8> {
let sid = session_id.as_bytes();
let p = path.as_bytes();
let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4);
aad.extend_from_slice(&(sid.len() as u32).to_be_bytes());
aad.extend_from_slice(sid);
aad.extend_from_slice(&(p.len() as u32).to_be_bytes());
aad.extend_from_slice(p);
aad.extend_from_slice(&counter.to_be_bytes());
aad
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sas::TransportKey;
fn session_with_transport_key() -> (TransportKey, [u8; 12], String) {
let tk = TransportKey::new([0xA5; 32]);
let iv = [0x07; 12];
let session_id = "sess-kat".to_string();
(tk, iv, session_id)
}
#[tokio::test]
async fn seal_open_round_trip() {
let (tk, iv, sid) = session_with_transport_key();
let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap();
let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
.await
.unwrap();
let env = sender
.seal("/v1/pairing/sessions/x/response", b"hello world")
.await
.unwrap();
let opened = receiver
.open("/v1/pairing/sessions/x/response", env)
.await
.unwrap();
assert_eq!(opened.plaintext(), b"hello world");
}
#[tokio::test]
async fn tampered_tag_yields_tag_mismatch() {
let (tk, iv, sid) = session_with_transport_key();
let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap();
let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
.await
.unwrap();
let env = sender.seal("/path", b"payload").await.unwrap();
let mut ct = env.payload.clone();
let last = ct.len() - 1;
ct[last] ^= 0x01;
let tampered = Envelope {
nonce: env.nonce,
counter: env.counter,
payload: ct,
aad_session_id: env.aad_session_id,
aad_path: env.aad_path,
_state: PhantomData::<Sealed>,
};
let err = receiver.open("/path", tampered).await.unwrap_err();
assert!(matches!(err, EnvelopeError::TagMismatch));
}
#[tokio::test]
async fn aad_path_mismatch_yields_aad_mismatch_or_tag_mismatch() {
let (tk, iv, sid) = session_with_transport_key();
let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap();
let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
.await
.unwrap();
let env = sender.seal("/path-a", b"payload").await.unwrap();
let err = receiver.open("/path-b", env).await.unwrap_err();
assert!(matches!(err, EnvelopeError::AadMismatch));
}
#[tokio::test]
async fn counter_rollback_rejected() {
let (tk, iv, sid) = session_with_transport_key();
let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap();
let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
.await
.unwrap();
let e1 = sender.seal("/p", b"a").await.unwrap();
let e2 = sender.seal("/p", b"b").await.unwrap();
let e3 = sender.seal("/p", b"c").await.unwrap();
let _ = receiver.open("/p", e1).await.unwrap();
let _ = receiver.open("/p", e3).await.unwrap();
let err = receiver.open("/p", e2).await.unwrap_err();
assert!(matches!(err, EnvelopeError::CounterNotMonotonic { .. }));
}
#[tokio::test]
async fn cross_session_key_rejected() {
let iv = [0x07; 12];
let sid = "sess-cross".to_string();
let mut sender = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
.await
.unwrap();
let mut receiver = EnvelopeSession::new(&TransportKey::new([0x5A; 32]), sid.clone(), iv)
.await
.unwrap();
let env = sender.seal("/p", b"payload").await.unwrap();
let err = receiver.open("/p", env).await.unwrap_err();
assert!(matches!(err, EnvelopeError::TagMismatch));
}
}