Skip to main content

auths_pairing_protocol/
envelope.rs

1//! `SecureEnvelope` — AEAD-wrapped session payloads (fn-129.T7).
2//!
3//! Every body the daemon POSTs under a pairing session gets wrapped in this
4//! envelope. The envelope binds:
5//! - **Ciphertext + tag** to a session-specific AEAD key derived from the
6//!   `TransportKey` via HKDF with `ENVELOPE_INFO` domain separation.
7//! - **Request context** (session id, path, monotonic counter) as AAD, in
8//!   a length-prefixed layout that defeats AAD-confusion attacks.
9//!
10//! # Committed design decisions
11//!
12//! - **Nonce scheme:** deterministic counter XOR'd with a per-session
13//!   96-bit IV (RFC 8446 §5.3). Doubles as the monotonic-counter replay
14//!   defense — one value, not two.
15//! - **AEAD primitive:** ChaCha20-Poly1305 for the default build;
16//!   AES-256-GCM under `--features cnsa`. Dispatched through
17//!   `CryptoProvider::aead_{encrypt,decrypt}` (fn-128.T2) so the swap is
18//!   automatic.
19//! - **AAD layout (length-prefixed):**
20//!   `u32_be(len(session_id)) || session_id || u32_be(len(path)) || path || u32_be(counter)`.
21//!   Naive concatenation is forbidden (USENIX'23 AEAD-confusion).
22//! - **Max messages per session:** 1024. More than any pairing flow needs;
23//!   abort with [`EnvelopeError::SessionExhausted`] at the cap.
24//! - **API is async** to match `CryptoProvider`'s async trait surface;
25//!   this avoids a sync-over-async executor inside the crate.
26//!
27//! # Typestate
28//!
29//! `Envelope<Sealed>` (carries ciphertext) and `Envelope<Open>` (carries
30//! plaintext) are separate types; `seal` and `open` can't be swapped.
31
32use std::marker::PhantomData;
33
34use zeroize::{Zeroize, Zeroizing};
35
36use auths_crypto::default_provider;
37
38use crate::domain_separation::ENVELOPE_INFO;
39use crate::sas::TransportKey;
40
41/// Hard cap on messages per envelope session. Well above any pairing
42/// flow's natural message count.
43pub const MAX_MESSAGES_PER_SESSION: u32 = 1024;
44
45/// Errors produced by envelope open/seal.
46#[derive(Debug, thiserror::Error)]
47pub enum EnvelopeError {
48    /// AEAD tag did not verify. Could be a wrong key, wrong nonce, or
49    /// tampered ciphertext / AAD.
50    #[error("envelope authentication failed")]
51    TagMismatch,
52
53    /// Counter in incoming envelope ≤ last-seen counter — replay or
54    /// out-of-order.
55    #[error("envelope counter not strictly monotonic: expected > {expected}, got {got}")]
56    CounterNotMonotonic {
57        /// Last successfully-opened counter.
58        expected: u32,
59        /// Counter the incoming envelope carries.
60        got: u32,
61    },
62
63    /// AAD fields (session_id / path / counter) disagree between sealer and
64    /// opener.
65    #[error("envelope AAD mismatch")]
66    AadMismatch,
67
68    /// Session has reached the message cap; must renegotiate a fresh
69    /// `TransportKey`.
70    #[error("envelope session exhausted (>{MAX_MESSAGES_PER_SESSION} messages)")]
71    SessionExhausted,
72
73    /// HKDF-expand of the envelope key failed.
74    #[error("envelope key derivation failed: {0}")]
75    KeyDerivation(String),
76
77    /// Provider-layer AEAD error that is not a tag mismatch (e.g. invalid
78    /// key length).
79    #[error("envelope encrypt/decrypt failed: {0}")]
80    Cipher(String),
81}
82
83/// Phantom type: envelope has been sealed (contains ciphertext).
84pub struct Sealed;
85/// Phantom type: envelope has been opened (contains plaintext).
86pub struct Open;
87
88/// AEAD envelope. State parameter distinguishes sealed-for-transport from
89/// opened-for-use. Never deriving `Clone` / `Copy` — a sealed envelope is
90/// single-use (counter is one-shot).
91pub struct Envelope<S> {
92    nonce: [u8; 12],
93    counter: u32,
94    payload: Vec<u8>,
95    aad_session_id: String,
96    aad_path: String,
97    _state: PhantomData<S>,
98}
99
100// Manual Debug that redacts payload. Never print plaintext (Open) or
101// ciphertext (Sealed) — printing either leaks surface for traffic analysis
102// or future plaintext recovery.
103impl<S> std::fmt::Debug for Envelope<S> {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("Envelope")
106            .field("counter", &self.counter)
107            .field("session_id", &self.aad_session_id)
108            .field("path", &self.aad_path)
109            .field(
110                "payload",
111                &format_args!("<{} bytes redacted>", self.payload.len()),
112            )
113            .finish()
114    }
115}
116
117impl<S> Envelope<S> {
118    /// Counter value this envelope was sealed under.
119    pub fn counter(&self) -> u32 {
120        self.counter
121    }
122    /// Session id this envelope's AAD is bound to.
123    pub fn session_id(&self) -> &str {
124        &self.aad_session_id
125    }
126    /// Request path this envelope's AAD is bound to.
127    pub fn path(&self) -> &str {
128        &self.aad_path
129    }
130}
131
132impl Envelope<Sealed> {
133    /// Raw ciphertext || tag (16 bytes of Poly1305 tag at the end).
134    pub fn ciphertext(&self) -> &[u8] {
135        &self.payload
136    }
137
138    /// Nonce this envelope was sealed under.
139    pub fn nonce(&self) -> &[u8; 12] {
140        &self.nonce
141    }
142}
143
144impl Envelope<Open> {
145    /// Recovered plaintext.
146    pub fn plaintext(&self) -> &[u8] {
147        &self.payload
148    }
149}
150
151/// Session-local envelope state. Holds the envelope key (HKDF-derived from
152/// the `TransportKey`), the per-session IV, the last-seen counter, and a
153/// message budget. Consumed via `&mut self` so nonce reuse within a
154/// session is structurally prevented.
155pub struct EnvelopeSession {
156    key: Zeroizing<[u8; 32]>,
157    iv: [u8; 12],
158    next_counter: u32,
159    last_opened_counter: Option<u32>,
160    session_id: String,
161}
162
163impl EnvelopeSession {
164    /// Derive a fresh envelope session from a `TransportKey`.
165    ///
166    /// Args:
167    /// * `transport_key`: the session's transport key from
168    ///   [`crate::sas::derive_transport_key`].
169    /// * `session_id`: the pairing session id (part of every AAD).
170    /// * `iv`: per-session 96-bit IV. Produce via `OsRng` at session start;
171    ///   transmit out-of-band to the peer.
172    ///
173    /// Usage:
174    /// ```ignore
175    /// let session = EnvelopeSession::new(&transport_key, session_id, iv).await?;
176    /// let env = session.seal("/v1/pairing/sessions/x/response", pt).await?;
177    /// ```
178    pub async fn new(
179        transport_key: &TransportKey,
180        session_id: String,
181        iv: [u8; 12],
182    ) -> Result<Self, EnvelopeError> {
183        let provider = default_provider();
184        let okm = provider
185            .hkdf_sha256_expand(transport_key.as_bytes(), &[], ENVELOPE_INFO, 32)
186            .await
187            .map_err(|e| EnvelopeError::KeyDerivation(e.to_string()))?;
188        let mut key_bytes = [0u8; 32];
189        key_bytes.copy_from_slice(&okm);
190        Ok(Self {
191            key: Zeroizing::new(key_bytes),
192            iv,
193            next_counter: 1,
194            last_opened_counter: None,
195            session_id,
196        })
197    }
198
199    /// Seal `plaintext`, binding to `path` and the next monotonic counter.
200    pub async fn seal(
201        &mut self,
202        path: &str,
203        plaintext: &[u8],
204    ) -> Result<Envelope<Sealed>, EnvelopeError> {
205        if self.next_counter >= MAX_MESSAGES_PER_SESSION {
206            return Err(EnvelopeError::SessionExhausted);
207        }
208        let counter = self.next_counter;
209        self.next_counter = self.next_counter.wrapping_add(1);
210
211        let nonce = nonce_for_counter(&self.iv, counter);
212        let aad = build_aad(&self.session_id, path, counter);
213        let provider = default_provider();
214        let ct = provider
215            .aead_encrypt(&self.key, &nonce, &aad, plaintext)
216            .await
217            .map_err(|e| EnvelopeError::Cipher(e.to_string()))?;
218
219        Ok(Envelope {
220            nonce,
221            counter,
222            payload: ct,
223            aad_session_id: self.session_id.clone(),
224            aad_path: path.to_string(),
225            _state: PhantomData,
226        })
227    }
228
229    /// Open a `Envelope<Sealed>`. Enforces strict-monotonic counter over
230    /// the session.
231    pub async fn open(
232        &mut self,
233        path: &str,
234        env: Envelope<Sealed>,
235    ) -> Result<Envelope<Open>, EnvelopeError> {
236        if let Some(last) = self.last_opened_counter
237            && env.counter <= last
238        {
239            return Err(EnvelopeError::CounterNotMonotonic {
240                expected: last,
241                got: env.counter,
242            });
243        }
244
245        let nonce = nonce_for_counter(&self.iv, env.counter);
246        if nonce != env.nonce {
247            return Err(EnvelopeError::AadMismatch);
248        }
249        if path != env.aad_path {
250            return Err(EnvelopeError::AadMismatch);
251        }
252        if self.session_id != env.aad_session_id {
253            return Err(EnvelopeError::AadMismatch);
254        }
255        let aad = build_aad(&self.session_id, path, env.counter);
256
257        let provider = default_provider();
258        let pt = provider
259            .aead_decrypt(&self.key, &nonce, &aad, &env.payload)
260            .await
261            .map_err(|_| EnvelopeError::TagMismatch)?;
262
263        self.last_opened_counter = Some(env.counter);
264        Ok(Envelope {
265            nonce: env.nonce,
266            counter: env.counter,
267            payload: pt,
268            aad_session_id: env.aad_session_id,
269            aad_path: env.aad_path,
270            _state: PhantomData,
271        })
272    }
273}
274
275impl Drop for EnvelopeSession {
276    fn drop(&mut self) {
277        self.iv.zeroize();
278    }
279}
280
281fn nonce_for_counter(iv: &[u8; 12], counter: u32) -> [u8; 12] {
282    let mut nonce = *iv;
283    let ctr = counter.to_be_bytes();
284    nonce[8] ^= ctr[0];
285    nonce[9] ^= ctr[1];
286    nonce[10] ^= ctr[2];
287    nonce[11] ^= ctr[3];
288    nonce
289}
290
291fn build_aad(session_id: &str, path: &str, counter: u32) -> Vec<u8> {
292    let sid = session_id.as_bytes();
293    let p = path.as_bytes();
294    let mut aad = Vec::with_capacity(4 + sid.len() + 4 + p.len() + 4);
295    aad.extend_from_slice(&(sid.len() as u32).to_be_bytes());
296    aad.extend_from_slice(sid);
297    aad.extend_from_slice(&(p.len() as u32).to_be_bytes());
298    aad.extend_from_slice(p);
299    aad.extend_from_slice(&counter.to_be_bytes());
300    aad
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::sas::TransportKey;
307
308    fn session_with_transport_key() -> (TransportKey, [u8; 12], String) {
309        let tk = TransportKey::new([0xA5; 32]);
310        let iv = [0x07; 12];
311        let session_id = "sess-kat".to_string();
312        (tk, iv, session_id)
313    }
314
315    #[tokio::test]
316    async fn seal_open_round_trip() {
317        let (tk, iv, sid) = session_with_transport_key();
318        let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap();
319        // Fresh receiver derives the same key from the same transport key + iv.
320        let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
321            .await
322            .unwrap();
323
324        let env = sender
325            .seal("/v1/pairing/sessions/x/response", b"hello world")
326            .await
327            .unwrap();
328        let opened = receiver
329            .open("/v1/pairing/sessions/x/response", env)
330            .await
331            .unwrap();
332        assert_eq!(opened.plaintext(), b"hello world");
333    }
334
335    #[tokio::test]
336    async fn tampered_tag_yields_tag_mismatch() {
337        let (tk, iv, sid) = session_with_transport_key();
338        let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap();
339        let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
340            .await
341            .unwrap();
342
343        let env = sender.seal("/path", b"payload").await.unwrap();
344        // Tamper the last byte (part of the Poly1305 tag).
345        let mut ct = env.payload.clone();
346        let last = ct.len() - 1;
347        ct[last] ^= 0x01;
348        let tampered = Envelope {
349            nonce: env.nonce,
350            counter: env.counter,
351            payload: ct,
352            aad_session_id: env.aad_session_id,
353            aad_path: env.aad_path,
354            _state: PhantomData::<Sealed>,
355        };
356        let err = receiver.open("/path", tampered).await.unwrap_err();
357        assert!(matches!(err, EnvelopeError::TagMismatch));
358    }
359
360    #[tokio::test]
361    async fn aad_path_mismatch_yields_aad_mismatch_or_tag_mismatch() {
362        let (tk, iv, sid) = session_with_transport_key();
363        let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap();
364        let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
365            .await
366            .unwrap();
367
368        let env = sender.seal("/path-a", b"payload").await.unwrap();
369        let err = receiver.open("/path-b", env).await.unwrap_err();
370        // The AAD check short-circuits before the AEAD; expect AadMismatch.
371        assert!(matches!(err, EnvelopeError::AadMismatch));
372    }
373
374    #[tokio::test]
375    async fn counter_rollback_rejected() {
376        let (tk, iv, sid) = session_with_transport_key();
377        let mut sender = EnvelopeSession::new(&tk, sid.clone(), iv).await.unwrap();
378        let mut receiver = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
379            .await
380            .unwrap();
381
382        // Seal three messages; open them in order.
383        let e1 = sender.seal("/p", b"a").await.unwrap();
384        let e2 = sender.seal("/p", b"b").await.unwrap();
385        let e3 = sender.seal("/p", b"c").await.unwrap();
386        let _ = receiver.open("/p", e1).await.unwrap();
387        let _ = receiver.open("/p", e3).await.unwrap();
388        // Now try to open the earlier-counter envelope.
389        let err = receiver.open("/p", e2).await.unwrap_err();
390        assert!(matches!(err, EnvelopeError::CounterNotMonotonic { .. }));
391    }
392
393    #[tokio::test]
394    async fn cross_session_key_rejected() {
395        let iv = [0x07; 12];
396        let sid = "sess-cross".to_string();
397        let mut sender = EnvelopeSession::new(&TransportKey::new([0xA5; 32]), sid.clone(), iv)
398            .await
399            .unwrap();
400        // Different transport key ⇒ different derived envelope key.
401        let mut receiver = EnvelopeSession::new(&TransportKey::new([0x5A; 32]), sid.clone(), iv)
402            .await
403            .unwrap();
404
405        let env = sender.seal("/p", b"payload").await.unwrap();
406        let err = receiver.open("/p", env).await.unwrap_err();
407        assert!(matches!(err, EnvelopeError::TagMismatch));
408    }
409}