auths_pairing_protocol/channel_binding.rs
1//! TLS channel binding for session proofs (anti-relay).
2//!
3//! A pairing session runs *inside* a TLS connection, but the cryptographic
4//! proofs it carries (the AEAD-sealed [`crate::envelope::Envelope`]s) are, by
5//! themselves, transport-agnostic: a proof captured on one TLS connection and
6//! replayed onto a *different* TLS connection would still open, because nothing
7//! ties the proof to the channel it was minted on. That is the classic
8//! relay / MITM attack — an attacker terminates the victim's TLS session,
9//! lifts a valid proof off it, and presents it on its own session to a
10//! third party.
11//!
12//! The fix is **channel binding**: fold a per-connection secret that *only the
13//! two TLS endpoints know* into the proof, so a proof minted on channel A
14//! cannot be opened on channel B. The secret is the **TLS exporter** —
15//! exported keying material per RFC 5705 (TLS ≤1.2) / RFC 8446 §7.5 (TLS 1.3),
16//! using the RFC 9266 `tls-exporter` label. Both endpoints of a TLS connection
17//! derive the *same* exporter value; two independent connections derive
18//! *different* values. Folding it into the envelope key (and AAD) makes the
19//! proof open only on the channel that minted it. This is the same shape as
20//! token-binding (RFC 8471) and DPoP (RFC 9449): a possession proof scoped to
21//! its transport.
22//!
23//! # Wire / interop parameters (NORMATIVE)
24//!
25//! - **Label:** [`TLS_EXPORTER_LABEL`] = `EXPORTER-Channel-Binding` (RFC 9266
26//! §3). This is the registered label a stock TLS stack uses for the
27//! `tls-exporter` channel binding; matching it byte-for-byte is what lets an
28//! auths endpoint interoperate with any RFC 9266 peer.
29//! - **Context:** *absent* (not empty). RFC 5705 distinguishes an absent
30//! context from a zero-length one; RFC 9266 specifies the exporter with no
31//! context value, so the adapter MUST pass "no context", not `b""`.
32//! - **Length:** [`TLS_EXPORTER_LEN`] = 32 bytes.
33//!
34//! # Ports and adapters
35//!
36//! This crate is transport-agnostic (no TLS stack dependency). The act of
37//! *extracting* the exporter from a live connection is therefore a **port**:
38//! [`ChannelBindingProvider`]. The TLS-aware crates (the pairing daemon, the
39//! CLI LAN server, the mobile client) implement it as a thin **adapter** over
40//! their concrete stack's `export_keying_material` — `rustls`'s
41//! `ConnectionCommon::export_keying_material`, OpenSSL's
42//! `SslRef::export_keying_material`, Go's `ConnectionState.ExportKeyingMaterial`.
43//! The core protocol only ever sees a parsed [`ChannelBinding`].
44
45use zeroize::{Zeroize, Zeroizing};
46
47/// RFC 9266 §3 exporter label for the `tls-exporter` channel binding.
48///
49/// A stock TLS stack producing a `tls-exporter` channel binding exports keying
50/// material under exactly this label. Byte-identical use here is what makes an
51/// auths endpoint's binding match an arbitrary RFC 9266 peer's.
52pub const TLS_EXPORTER_LABEL: &[u8] = b"EXPORTER-Channel-Binding";
53
54/// Length, in bytes, of the exported keying material used as the binding.
55///
56/// 32 bytes = 256 bits, matching the suite's TLS oracle and leaving no
57/// shortfall against the AEAD key it is folded into.
58pub const TLS_EXPORTER_LEN: usize = 32;
59
60/// HKDF `info` domain separator for folding a channel binding into a derived
61/// key. Distinct from every other label in [`crate::domain_separation`] so a
62/// channel-bound key can never collide with an unbound one.
63pub const CHANNEL_BINDING_INFO: &[u8] = b"auths-pairing-channel-binding-v1";
64
65/// Errors from parsing a channel binding or extracting one from a transport.
66#[derive(Debug, thiserror::Error)]
67pub enum ChannelBindingError {
68 /// The exporter material was not exactly [`TLS_EXPORTER_LEN`] bytes.
69 #[error("channel binding must be {expected} bytes of TLS exporter material, got {got}")]
70 WrongLength {
71 /// Required length ([`TLS_EXPORTER_LEN`]).
72 expected: usize,
73 /// Length actually supplied.
74 got: usize,
75 },
76
77 /// The underlying TLS stack refused to export keying material (no
78 /// handshake completed, the connection is not TLS 1.3-capable, or the
79 /// exporter is otherwise unavailable). A session that cannot produce a
80 /// binding MUST NOT fall back to an unbound proof — that would silently
81 /// reopen the relay hole. Surface this and refuse.
82 #[error("TLS exporter unavailable from transport: {0}")]
83 ExporterUnavailable(String),
84}
85
86/// A parsed TLS channel binding: the RFC 9266 `tls-exporter` keying material
87/// for one connection.
88///
89/// Parse, don't validate: the only way to hold a `ChannelBinding` is through
90/// [`ChannelBinding::from_exporter`], which enforces the length. Downstream
91/// code (the envelope key derivation) can therefore trust the bytes without
92/// re-checking. Two `ChannelBinding`s compare equal iff their exporter bytes
93/// match — i.e. iff they came from the *same* TLS connection. The comparison
94/// is constant-time so a relay attacker learns nothing from timing.
95#[derive(Clone)]
96pub struct ChannelBinding {
97 exporter: Zeroizing<[u8; TLS_EXPORTER_LEN]>,
98}
99
100impl ChannelBinding {
101 /// Parse raw TLS exporter material into a channel binding.
102 ///
103 /// `material` MUST be the keying material a TLS stack exported under
104 /// [`TLS_EXPORTER_LABEL`] with an *absent* context and length
105 /// [`TLS_EXPORTER_LEN`] — see the module docs for the normative
106 /// parameters. Any other length is rejected so an invalid binding is
107 /// unrepresentable past this boundary.
108 pub fn from_exporter(material: &[u8]) -> Result<Self, ChannelBindingError> {
109 if material.len() != TLS_EXPORTER_LEN {
110 return Err(ChannelBindingError::WrongLength {
111 expected: TLS_EXPORTER_LEN,
112 got: material.len(),
113 });
114 }
115 let mut buf = [0u8; TLS_EXPORTER_LEN];
116 buf.copy_from_slice(material);
117 let cb = Self {
118 exporter: Zeroizing::new(buf),
119 };
120 buf.zeroize();
121 Ok(cb)
122 }
123
124 /// The raw exporter bytes, for folding into a key derivation as HKDF
125 /// `info`-adjacent material. Not part of any serialized wire format — the
126 /// binding never travels; each endpoint recomputes it from its own TLS
127 /// connection.
128 pub fn as_bytes(&self) -> &[u8; TLS_EXPORTER_LEN] {
129 &self.exporter
130 }
131}
132
133// Manual `Debug` that redacts the exporter. The value is a per-connection
134// secret; logging it would hand a relay attacker the binding it needs to
135// recompute.
136impl std::fmt::Debug for ChannelBinding {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 f.debug_struct("ChannelBinding")
139 .field(
140 "exporter",
141 &format_args!("<{TLS_EXPORTER_LEN} bytes redacted>"),
142 )
143 .finish()
144 }
145}
146
147/// Constant-time equality: equal iff the two bindings come from the same TLS
148/// connection. Constant-time so a relay attacker probing "is my forged binding
149/// close to the real one?" learns nothing from response timing.
150impl PartialEq for ChannelBinding {
151 fn eq(&self, other: &Self) -> bool {
152 use subtle::ConstantTimeEq;
153 self.exporter.ct_eq(other.exporter.as_ref()).into()
154 }
155}
156
157impl Eq for ChannelBinding {}
158
159/// Port: extract the channel binding from a live transport.
160///
161/// Implemented by the TLS-aware crates as an adapter over their concrete
162/// stack. The core protocol depends only on this trait, never on a TLS
163/// library — ports and adapters at the transport edge.
164///
165/// The adapter MUST call its stack's keying-material exporter with
166/// [`TLS_EXPORTER_LABEL`], an *absent* context, and length
167/// [`TLS_EXPORTER_LEN`], then hand the bytes to
168/// [`ChannelBinding::from_exporter`]. An adapter that cannot produce a binding
169/// (handshake incomplete, not TLS 1.3) MUST return
170/// [`ChannelBindingError::ExporterUnavailable`] — never a placeholder — so the
171/// caller fails closed instead of minting an unbound, relay-able proof.
172pub trait ChannelBindingProvider {
173 /// The current connection's RFC 9266 channel binding.
174 fn channel_binding(&self) -> Result<ChannelBinding, ChannelBindingError>;
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn rejects_wrong_length() {
183 let err = ChannelBinding::from_exporter(&[0u8; 16]).unwrap_err();
184 assert!(matches!(
185 err,
186 ChannelBindingError::WrongLength {
187 expected: TLS_EXPORTER_LEN,
188 got: 16
189 }
190 ));
191 }
192
193 #[test]
194 fn same_exporter_is_equal() {
195 let a = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap();
196 let b = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap();
197 assert_eq!(a, b);
198 }
199
200 #[test]
201 fn different_exporter_is_unequal() {
202 // Two TLS connections export different keying material → distinct
203 // bindings. This is the property the anti-relay check rests on.
204 let a = ChannelBinding::from_exporter(&[0x11; TLS_EXPORTER_LEN]).unwrap();
205 let b = ChannelBinding::from_exporter(&[0x22; TLS_EXPORTER_LEN]).unwrap();
206 assert_ne!(a, b);
207 }
208
209 #[test]
210 fn label_is_the_rfc9266_registered_value() {
211 // Lock the wire label: a drift here silently breaks interop with every
212 // stock TLS stack producing a `tls-exporter` binding.
213 assert_eq!(TLS_EXPORTER_LABEL, b"EXPORTER-Channel-Binding");
214 assert_eq!(TLS_EXPORTER_LEN, 32);
215 }
216
217 #[test]
218 fn debug_redacts_exporter() {
219 let cb = ChannelBinding::from_exporter(&[0xAB; TLS_EXPORTER_LEN]).unwrap();
220 let s = format!("{cb:?}");
221 assert!(s.contains("redacted"));
222 assert!(!s.contains("ab"));
223 assert!(!s.contains("AB"));
224 }
225}