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 ||
21//!    u32_be(len(channel_binding)) || channel_binding || u32_be(counter)`.
22//!   Naive concatenation is forbidden (USENIX'23 AEAD-confusion).
23//! - **Channel binding (anti-relay):** the session is pinned to the TLS
24//!   connection it runs on via the RFC 9266 `tls-exporter` keying material
25//!   (see [`crate::channel_binding`]). The exporter is folded into BOTH the
26//!   envelope key derivation (so two channels derive different keys) AND the
27//!   AAD (so a cross-channel open is rejected explicitly). An envelope sealed
28//!   on channel A therefore cannot be opened on channel B — a relayed proof is
29//!   refused. This is the load-bearing negative property; without it the
30//!   envelope is transport-agnostic and a captured proof replays.
31//! - **Max messages per session:** 1024. More than any pairing flow needs;
32//!   abort with [`EnvelopeError::SessionExhausted`] at the cap.
33//! - **API is async** to match `CryptoProvider`'s async trait surface;
34//!   this avoids a sync-over-async executor inside the crate.
35//!
36//! # Typestate
37//!
38//! `Envelope<Sealed>` (carries ciphertext) and `Envelope<Open>` (carries
39//! plaintext) are separate types; `seal` and `open` can't be swapped.
40
41use 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
51/// Hard cap on messages per envelope session. Well above any pairing
52/// flow's natural message count.
53pub const MAX_MESSAGES_PER_SESSION: u32 = 1024;
54
55/// Errors produced by envelope open/seal.
56#[derive(Debug, thiserror::Error)]
57pub enum EnvelopeError {
58    /// AEAD tag did not verify. Could be a wrong key, wrong nonce, or
59    /// tampered ciphertext / AAD.
60    #[error("envelope authentication failed")]
61    TagMismatch,
62
63    /// Counter in incoming envelope ≤ last-seen counter — replay or
64    /// out-of-order.
65    #[error("envelope counter not strictly monotonic: expected > {expected}, got {got}")]
66    CounterNotMonotonic {
67        /// Last successfully-opened counter.
68        expected: u32,
69        /// Counter the incoming envelope carries.
70        got: u32,
71    },
72
73    /// AAD fields (session_id / path / counter) disagree between sealer and
74    /// opener.
75    #[error("envelope AAD mismatch")]
76    AadMismatch,
77
78    /// The envelope was sealed on a different TLS channel than the one it is
79    /// being opened on — its RFC 9266 channel binding does not match this
80    /// session's. This is the anti-relay rejection: a proof captured on one
81    /// TLS connection and replayed onto another is refused here.
82    #[error("envelope channel-binding mismatch — proof relayed onto a different TLS channel")]
83    ChannelBindingMismatch,
84
85    /// Session has reached the message cap; must renegotiate a fresh
86    /// `TransportKey`.
87    #[error("envelope session exhausted (>{MAX_MESSAGES_PER_SESSION} messages)")]
88    SessionExhausted,
89
90    /// HKDF-expand of the envelope key failed.
91    #[error("envelope key derivation failed: {0}")]
92    KeyDerivation(String),
93
94    /// Provider-layer AEAD error that is not a tag mismatch (e.g. invalid
95    /// key length).
96    #[error("envelope encrypt/decrypt failed: {0}")]
97    Cipher(String),
98}
99
100/// Phantom type: envelope has been sealed (contains ciphertext).
101pub struct Sealed;
102/// Phantom type: envelope has been opened (contains plaintext).
103pub struct Open;
104
105/// AEAD envelope. State parameter distinguishes sealed-for-transport from
106/// opened-for-use. Never deriving `Clone` / `Copy` — a sealed envelope is
107/// single-use (counter is one-shot).
108pub struct Envelope<S> {
109    nonce: [u8; 12],
110    counter: u32,
111    payload: Vec<u8>,
112    aad_session_id: String,
113    aad_path: String,
114    /// The RFC 9266 channel binding this envelope was sealed under. Carried
115    /// for the explicit same-process anti-relay check in [`EnvelopeSession::open`];
116    /// it is NOT a wire field — across a transport each endpoint recomputes its
117    /// own binding from its own TLS connection, and the binding is folded into
118    /// the key + AAD so a cross-channel open fails cryptographically regardless.
119    aad_channel_binding: [u8; TLS_EXPORTER_LEN],
120    _state: PhantomData<S>,
121}
122
123// Manual Debug that redacts payload. Never print plaintext (Open) or
124// ciphertext (Sealed) — printing either leaks surface for traffic analysis
125// or future plaintext recovery.
126impl<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    /// Counter value this envelope was sealed under.
142    pub fn counter(&self) -> u32 {
143        self.counter
144    }
145    /// Session id this envelope's AAD is bound to.
146    pub fn session_id(&self) -> &str {
147        &self.aad_session_id
148    }
149    /// Request path this envelope's AAD is bound to.
150    pub fn path(&self) -> &str {
151        &self.aad_path
152    }
153}
154
155impl Envelope<Sealed> {
156    /// Raw ciphertext || tag (16 bytes of Poly1305 tag at the end).
157    pub fn ciphertext(&self) -> &[u8] {
158        &self.payload
159    }
160
161    /// Nonce this envelope was sealed under.
162    pub fn nonce(&self) -> &[u8; 12] {
163        &self.nonce
164    }
165}
166
167impl Envelope<Open> {
168    /// Recovered plaintext.
169    pub fn plaintext(&self) -> &[u8] {
170        &self.payload
171    }
172}
173
174/// Session-local envelope state. Holds the envelope key (HKDF-derived from
175/// the `TransportKey`), the per-session IV, the last-seen counter, and a
176/// message budget. Consumed via `&mut self` so nonce reuse within a
177/// session is structurally prevented.
178pub 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    /// The TLS channel this session is bound to (RFC 9266 `tls-exporter`).
185    /// Folded into the key derivation and every AAD; an envelope sealed under a
186    /// different binding cannot be opened here.
187    channel_binding: ChannelBinding,
188}
189
190impl EnvelopeSession {
191    /// Derive a fresh envelope session from a `TransportKey`, bound to a TLS
192    /// channel.
193    ///
194    /// Args:
195    /// * `transport_key`: the session's transport key from
196    ///   [`crate::sas::derive_transport_key`].
197    /// * `session_id`: the pairing session id (part of every AAD).
198    /// * `iv`: per-session 96-bit IV. Produce via `OsRng` at session start;
199    ///   transmit out-of-band to the peer.
200    /// * `channel_binding`: the RFC 9266 `tls-exporter` value for the TLS
201    ///   connection this session runs over (from a
202    ///   [`crate::channel_binding::ChannelBindingProvider`] adapter). The
203    ///   binding is folded into the envelope key so two TLS channels derive
204    ///   different keys; a proof minted on one channel cannot open on another.
205    ///   There is no unbound constructor on purpose — an unbound envelope is
206    ///   relay-able, so the channel binding is a required argument, not an
207    ///   option.
208    ///
209    /// Usage:
210    /// ```ignore
211    /// let cb = tls_conn.channel_binding()?; // ChannelBindingProvider adapter
212    /// let session = EnvelopeSession::new(&transport_key, session_id, iv, cb).await?;
213    /// let env = session.seal("/v1/pairing/sessions/x/response", pt).await?;
214    /// ```
215    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        // Fold the channel binding into the key-derivation `info` so the
223        // envelope key is unique per (transport key, TLS channel) pair. Two
224        // sessions over different TLS connections derive different keys even
225        // with an identical transport key — the cryptographic half of the
226        // anti-relay guarantee.
227        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    /// Seal `plaintext`, binding to `path` and the next monotonic counter.
249    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    /// Open a `Envelope<Sealed>`. Enforces strict-monotonic counter over
285    /// the session.
286    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        // Anti-relay: refuse a proof minted on a different TLS channel. The
311        // constant-time compare avoids leaking how close a forged binding is.
312        // Even if this explicit check were bypassed, the binding is folded into
313        // the key + AAD, so the AEAD `open` below would still fail — this is
314        // defense-in-depth with a clear diagnostic.
315        {
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    // A stand-in TLS exporter for one channel. Two distinct values model two
390    // distinct TLS connections (the per-session property the Go TLS oracle
391    // confirms for the RFC 9266 `tls-exporter`).
392    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        // Same TLS channel on both ends → same binding → opens.
407        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    /// The load-bearing anti-relay test (RFC 9266 / Track B T0). A proof sealed
431    /// on TLS channel A and replayed onto a session bound to TLS channel B is
432    /// REJECTED — exactly the relay/MITM the channel binding exists to stop.
433    #[tokio::test]
434    async fn proof_relayed_onto_different_channel_is_rejected() {
435        let (tk, iv, sid) = session_with_transport_key();
436        // Channel A mints the proof.
437        let mut on_channel_a = EnvelopeSession::new(&tk, sid.clone(), iv, channel(0xAA))
438            .await
439            .unwrap();
440        // Channel B (a different TLS connection → different exporter) is where
441        // the attacker replays it.
442        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    /// Even if the explicit binding check were stripped, the binding is folded
460    /// into the key derivation, so a forged envelope that *claims* the receiver's
461    /// binding but was keyed under a different channel still fails the AEAD.
462    #[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        // Attacker rewrites the carried binding to match the victim channel B,
479        // hoping to pass the explicit equality check. The key it was sealed
480        // under is still channel A's, so the AEAD tag fails.
481        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        // Tamper the last byte (part of the Poly1305 tag).
511        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        // The AAD check short-circuits before the AEAD; expect AadMismatch.
545        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        // Seal three messages; open them in order.
564        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        // Now try to open the earlier-counter envelope.
570        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        // Different transport key ⇒ different derived envelope key.
587        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}