1use std::marker::PhantomData;
42
43use zeroize::{Zeroize, Zeroizing};
44
45use auths_crypto::default_provider;
46
47use crate::channel_binding::{CHANNEL_BINDING_INFO, ChannelBinding, TLS_EXPORTER_LEN};
48use crate::domain_separation::ENVELOPE_INFO;
49use crate::sas::TransportKey;
50
51pub const MAX_MESSAGES_PER_SESSION: u32 = 1024;
54
55#[derive(Debug, thiserror::Error)]
57pub enum EnvelopeError {
58 #[error("envelope authentication failed")]
61 TagMismatch,
62
63 #[error("envelope counter not strictly monotonic: expected > {expected}, got {got}")]
66 CounterNotMonotonic {
67 expected: u32,
69 got: u32,
71 },
72
73 #[error("envelope AAD mismatch")]
76 AadMismatch,
77
78 #[error("envelope channel-binding mismatch — proof relayed onto a different TLS channel")]
83 ChannelBindingMismatch,
84
85 #[error("envelope session exhausted (>{MAX_MESSAGES_PER_SESSION} messages)")]
88 SessionExhausted,
89
90 #[error("envelope key derivation failed: {0}")]
92 KeyDerivation(String),
93
94 #[error("envelope encrypt/decrypt failed: {0}")]
97 Cipher(String),
98}
99
100pub struct Sealed;
102pub struct Open;
104
105pub struct Envelope<S> {
109 nonce: [u8; 12],
110 counter: u32,
111 payload: Vec<u8>,
112 aad_session_id: String,
113 aad_path: String,
114 aad_channel_binding: [u8; TLS_EXPORTER_LEN],
120 _state: PhantomData<S>,
121}
122
123impl<S> std::fmt::Debug for Envelope<S> {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct("Envelope")
129 .field("counter", &self.counter)
130 .field("session_id", &self.aad_session_id)
131 .field("path", &self.aad_path)
132 .field(
133 "payload",
134 &format_args!("<{} bytes redacted>", self.payload.len()),
135 )
136 .finish()
137 }
138}
139
140impl<S> Envelope<S> {
141 pub fn counter(&self) -> u32 {
143 self.counter
144 }
145 pub fn session_id(&self) -> &str {
147 &self.aad_session_id
148 }
149 pub fn path(&self) -> &str {
151 &self.aad_path
152 }
153}
154
155impl Envelope<Sealed> {
156 pub fn ciphertext(&self) -> &[u8] {
158 &self.payload
159 }
160
161 pub fn nonce(&self) -> &[u8; 12] {
163 &self.nonce
164 }
165}
166
167impl Envelope<Open> {
168 pub fn plaintext(&self) -> &[u8] {
170 &self.payload
171 }
172}
173
174pub struct EnvelopeSession {
179 key: Zeroizing<[u8; 32]>,
180 iv: [u8; 12],
181 next_counter: u32,
182 last_opened_counter: Option<u32>,
183 session_id: String,
184 channel_binding: ChannelBinding,
188}
189
190impl EnvelopeSession {
191 pub async fn new(
216 transport_key: &TransportKey,
217 session_id: String,
218 iv: [u8; 12],
219 channel_binding: ChannelBinding,
220 ) -> Result<Self, EnvelopeError> {
221 let provider = default_provider();
222 let mut info =
228 Vec::with_capacity(ENVELOPE_INFO.len() + CHANNEL_BINDING_INFO.len() + TLS_EXPORTER_LEN);
229 info.extend_from_slice(ENVELOPE_INFO);
230 info.extend_from_slice(CHANNEL_BINDING_INFO);
231 info.extend_from_slice(channel_binding.as_bytes());
232 let okm = provider
233 .hkdf_sha256_expand(transport_key.as_bytes(), &[], &info, 32)
234 .await
235 .map_err(|e| EnvelopeError::KeyDerivation(e.to_string()))?;
236 let mut key_bytes = [0u8; 32];
237 key_bytes.copy_from_slice(&okm);
238 Ok(Self {
239 key: Zeroizing::new(key_bytes),
240 iv,
241 next_counter: 1,
242 last_opened_counter: None,
243 session_id,
244 channel_binding,
245 })
246 }
247
248 pub async fn seal(
250 &mut self,
251 path: &str,
252 plaintext: &[u8],
253 ) -> Result<Envelope<Sealed>, EnvelopeError> {
254 if self.next_counter >= MAX_MESSAGES_PER_SESSION {
255 return Err(EnvelopeError::SessionExhausted);
256 }
257 let counter = self.next_counter;
258 self.next_counter = self.next_counter.wrapping_add(1);
259
260 let nonce = nonce_for_counter(&self.iv, counter);
261 let aad = build_aad(
262 &self.session_id,
263 path,
264 self.channel_binding.as_bytes(),
265 counter,
266 );
267 let provider = default_provider();
268 let ct = provider
269 .aead_encrypt(&self.key, &nonce, &aad, plaintext)
270 .await
271 .map_err(|e| EnvelopeError::Cipher(e.to_string()))?;
272
273 Ok(Envelope {
274 nonce,
275 counter,
276 payload: ct,
277 aad_session_id: self.session_id.clone(),
278 aad_path: path.to_string(),
279 aad_channel_binding: *self.channel_binding.as_bytes(),
280 _state: PhantomData,
281 })
282 }
283
284 pub async fn open(
287 &mut self,
288 path: &str,
289 env: Envelope<Sealed>,
290 ) -> Result<Envelope<Open>, EnvelopeError> {
291 if let Some(last) = self.last_opened_counter
292 && env.counter <= last
293 {
294 return Err(EnvelopeError::CounterNotMonotonic {
295 expected: last,
296 got: env.counter,
297 });
298 }
299
300 let nonce = nonce_for_counter(&self.iv, env.counter);
301 if nonce != env.nonce {
302 return Err(EnvelopeError::AadMismatch);
303 }
304 if path != env.aad_path {
305 return Err(EnvelopeError::AadMismatch);
306 }
307 if self.session_id != env.aad_session_id {
308 return Err(EnvelopeError::AadMismatch);
309 }
310 {
316 use subtle::ConstantTimeEq;
317 let matches: bool = self
318 .channel_binding
319 .as_bytes()
320 .ct_eq(&env.aad_channel_binding)
321 .into();
322 if !matches {
323 return Err(EnvelopeError::ChannelBindingMismatch);
324 }
325 }
326 let aad = build_aad(
327 &self.session_id,
328 path,
329 self.channel_binding.as_bytes(),
330 env.counter,
331 );
332
333 let provider = default_provider();
334 let pt = provider
335 .aead_decrypt(&self.key, &nonce, &aad, &env.payload)
336 .await
337 .map_err(|_| EnvelopeError::TagMismatch)?;
338
339 self.last_opened_counter = Some(env.counter);
340 Ok(Envelope {
341 nonce: env.nonce,
342 counter: env.counter,
343 payload: pt,
344 aad_session_id: env.aad_session_id,
345 aad_path: env.aad_path,
346 aad_channel_binding: env.aad_channel_binding,
347 _state: PhantomData,
348 })
349 }
350}
351
352impl Drop for EnvelopeSession {
353 fn drop(&mut self) {
354 self.iv.zeroize();
355 }
356}
357
358fn nonce_for_counter(iv: &[u8; 12], counter: u32) -> [u8; 12] {
359 let mut nonce = *iv;
360 let ctr = counter.to_be_bytes();
361 nonce[8] ^= ctr[0];
362 nonce[9] ^= ctr[1];
363 nonce[10] ^= ctr[2];
364 nonce[11] ^= ctr[3];
365 nonce
366}
367
368fn build_aad(session_id: &str, path: &str, channel_binding: &[u8], counter: u32) -> Vec<u8> {
369 let sid = session_id.as_bytes();
370 let p = path.as_bytes();
371 let cb = channel_binding;
372 let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4 + cb.len() + 4);
373 aad.extend_from_slice(&(sid.len() as u32).to_be_bytes());
374 aad.extend_from_slice(sid);
375 aad.extend_from_slice(&(p.len() as u32).to_be_bytes());
376 aad.extend_from_slice(p);
377 aad.extend_from_slice(&(cb.len() as u32).to_be_bytes());
378 aad.extend_from_slice(cb);
379 aad.extend_from_slice(&counter.to_be_bytes());
380 aad
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use crate::channel_binding::ChannelBinding;
387 use crate::sas::TransportKey;
388
389 fn channel(byte: u8) -> ChannelBinding {
393 ChannelBinding::from_exporter(&[byte; TLS_EXPORTER_LEN]).unwrap()
394 }
395
396 fn session_with_transport_key() -> (TransportKey, [u8; 12], String) {
397 let tk = TransportKey::new([0xA5; 32]);
398 let iv = [0x07; 12];
399 let session_id = "sess-kat".to_string();
400 (tk, iv, session_id)
401 }
402
403 #[tokio::test]
404 async fn seal_open_round_trip() {
405 let (tk, iv, sid) = session_with_transport_key();
406 let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0))
408 .await
409 .unwrap();
410 let mut receiver = EnvelopeSession::new(
411 &TransportKey::new([0xA5; 32]),
412 sid.clone(),
413 iv,
414 channel(0xC0),
415 )
416 .await
417 .unwrap();
418
419 let env = sender
420 .seal("/v1/pairing/sessions/x/response", b"hello world")
421 .await
422 .unwrap();
423 let opened = receiver
424 .open("/v1/pairing/sessions/x/response", env)
425 .await
426 .unwrap();
427 assert_eq!(opened.plaintext(), b"hello world");
428 }
429
430 #[tokio::test]
434 async fn proof_relayed_onto_different_channel_is_rejected() {
435 let (tk, iv, sid) = session_with_transport_key();
436 let mut on_channel_a = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xAA))
438 .await
439 .unwrap();
440 let mut on_channel_b = EnvelopeSession::new(
443 &TransportKey::new([0xA5; 32]),
444 sid.clone(),
445 iv,
446 channel(0xBB),
447 )
448 .await
449 .unwrap();
450
451 let proof = on_channel_a.seal("/p", b"valid-proof").await.unwrap();
452 let err = on_channel_b.open("/p", proof).await.unwrap_err();
453 assert!(
454 matches!(err, EnvelopeError::ChannelBindingMismatch),
455 "a proof relayed onto a foreign TLS channel must be rejected, got {err:?}"
456 );
457 }
458
459 #[tokio::test]
463 async fn forged_binding_label_still_fails_aead() {
464 let (tk, iv, sid) = session_with_transport_key();
465 let mut on_channel_a = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xAA))
466 .await
467 .unwrap();
468 let mut on_channel_b = EnvelopeSession::new(
469 &TransportKey::new([0xA5; 32]),
470 sid.clone(),
471 iv,
472 channel(0xBB),
473 )
474 .await
475 .unwrap();
476
477 let proof = on_channel_a.seal("/p", b"valid-proof").await.unwrap();
478 let forged = Envelope {
482 nonce: proof.nonce,
483 counter: proof.counter,
484 payload: proof.payload,
485 aad_session_id: proof.aad_session_id,
486 aad_path: proof.aad_path,
487 aad_channel_binding: [0xBB; TLS_EXPORTER_LEN],
488 _state: PhantomData::<Sealed>,
489 };
490 let err = on_channel_b.open("/p", forged).await.unwrap_err();
491 assert!(matches!(err, EnvelopeError::TagMismatch));
492 }
493
494 #[tokio::test]
495 async fn tampered_tag_yields_tag_mismatch() {
496 let (tk, iv, sid) = session_with_transport_key();
497 let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0))
498 .await
499 .unwrap();
500 let mut receiver = EnvelopeSession::new(
501 &TransportKey::new([0xA5; 32]),
502 sid.clone(),
503 iv,
504 channel(0xC0),
505 )
506 .await
507 .unwrap();
508
509 let env = sender.seal("/path", b"payload").await.unwrap();
510 let mut ct = env.payload.clone();
512 let last = ct.len() - 1;
513 ct[last] ^= 0x01;
514 let tampered = Envelope {
515 nonce: env.nonce,
516 counter: env.counter,
517 payload: ct,
518 aad_session_id: env.aad_session_id,
519 aad_path: env.aad_path,
520 aad_channel_binding: env.aad_channel_binding,
521 _state: PhantomData::<Sealed>,
522 };
523 let err = receiver.open("/path", tampered).await.unwrap_err();
524 assert!(matches!(err, EnvelopeError::TagMismatch));
525 }
526
527 #[tokio::test]
528 async fn aad_path_mismatch_yields_aad_mismatch_or_tag_mismatch() {
529 let (tk, iv, sid) = session_with_transport_key();
530 let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0))
531 .await
532 .unwrap();
533 let mut receiver = EnvelopeSession::new(
534 &TransportKey::new([0xA5; 32]),
535 sid.clone(),
536 iv,
537 channel(0xC0),
538 )
539 .await
540 .unwrap();
541
542 let env = sender.seal("/path-a", b"payload").await.unwrap();
543 let err = receiver.open("/path-b", env).await.unwrap_err();
544 assert!(matches!(err, EnvelopeError::AadMismatch));
546 }
547
548 #[tokio::test]
549 async fn counter_rollback_rejected() {
550 let (tk, iv, sid) = session_with_transport_key();
551 let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xC0))
552 .await
553 .unwrap();
554 let mut receiver = EnvelopeSession::new(
555 &TransportKey::new([0xA5; 32]),
556 sid.clone(),
557 iv,
558 channel(0xC0),
559 )
560 .await
561 .unwrap();
562
563 let e1 = sender.seal("/p", b"a").await.unwrap();
565 let e2 = sender.seal("/p", b"b").await.unwrap();
566 let e3 = sender.seal("/p", b"c").await.unwrap();
567 let _ = receiver.open("/p", e1).await.unwrap();
568 let _ = receiver.open("/p", e3).await.unwrap();
569 let err = receiver.open("/p", e2).await.unwrap_err();
571 assert!(matches!(err, EnvelopeError::CounterNotMonotonic { .. }));
572 }
573
574 #[tokio::test]
575 async fn cross_session_key_rejected() {
576 let iv = [0x07; 12];
577 let sid = "sess-cross".to_string();
578 let mut sender = EnvelopeSession::new(
579 &TransportKey::new([0xA5; 32]),
580 sid.clone(),
581 iv,
582 channel(0xC0),
583 )
584 .await
585 .unwrap();
586 let mut receiver = EnvelopeSession::new(
588 &TransportKey::new([0x5A; 32]),
589 sid.clone(),
590 iv,
591 channel(0xC0),
592 )
593 .await
594 .unwrap();
595
596 let env = sender.seal("/p", b"payload").await.unwrap();
597 let err = receiver.open("/p", env).await.unwrap_err();
598 assert!(matches!(err, EnvelopeError::TagMismatch));
599 }
600}