Skip to main content

openmoat_proxy/
sni.rs

1//! The server name (SNI) a TLS client sends in its `ClientHello` (RFC 8446 §4.1.2,
2//! RFC 6066 §3).
3//!
4//! The proxy reads the `ClientHello` a tunnel starts with, compares its SNI with
5//! the CONNECT host and only then forwards it, unchanged. Nothing is decrypted.
6//! A `ClientHello` may be split over several TLS records; they are reassembled up
7//! to [`MAX_HELLO`] bytes.
8
9use crate::request::normalise_name;
10
11/// Largest `ClientHello` handshake message accepted. Real ones are 0.5–2 KiB,
12/// more with post-quantum key shares; 64 KiB leaves room without letting a
13/// client make the proxy buffer without bound.
14pub const MAX_HELLO: usize = 64 * 1024;
15
16const RECORD_HEADER: usize = 5;
17const MAX_RECORD: usize = 16 * 1024 + 2048;
18const CONTENT_HANDSHAKE: u8 = 22;
19const HANDSHAKE_CLIENT_HELLO: u8 = 1;
20const EXT_SERVER_NAME: u16 = 0;
21const NAME_TYPE_HOST: u8 = 0;
22/// `encrypted_client_hello` (RFC 9849): the real server name travels in an
23/// encrypted inner `ClientHello`, the cleartext one may name a fronting host.
24const EXT_ECH: u16 = 0xfe0d;
25/// `encrypted_client_hello` as drafts 08 to 12 of draft-ietf-tls-esni numbered
26/// it (from draft 08 the codepoint is `0xfe00` plus the draft number; drafts
27/// 13 on use [`EXT_ECH`]). A client built on a draft still sends one, and the
28/// server it reaches may act on the encrypted inner name all the same.
29const EXT_ECH_DRAFTS: core::ops::RangeInclusive<u16> = 0xfe08..=0xfe0c;
30/// `encrypted_server_name`, the ECH drafts' predecessor (draft-ietf-tls-esni-02).
31const EXT_ESNI: u16 = 0xffce;
32
33/// What the start of a tunnel says about the server it wants.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Hello {
36    /// More bytes are needed.
37    Incomplete,
38    /// A complete `ClientHello`; `None` when it carries no SNI.
39    ServerName(Option<String>),
40    /// A complete `ClientHello` that carries an encrypted server name (ECH or
41    /// ESNI), with its cleartext SNI. The name the server acts on may be another.
42    Encrypted(Option<String>),
43}
44
45/// Why the start of a tunnel is not a `ClientHello` the proxy can check.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
47#[non_exhaustive]
48pub enum HelloError {
49    /// The bytes are not a TLS handshake record.
50    #[error("the tunnel does not start with a TLS handshake")]
51    NotTls,
52    /// A TLS handshake that is not a well-formed `ClientHello`.
53    #[error("malformed TLS ClientHello")]
54    Malformed,
55    /// Larger than [`MAX_HELLO`].
56    #[error("TLS ClientHello larger than {MAX_HELLO} bytes")]
57    TooLarge,
58}
59
60/// Read the SNI from the TLS records at the start of `buf`.
61pub fn server_name(buf: &[u8]) -> Result<Hello, HelloError> {
62    let mut handshake = Vec::new();
63    let mut pos = 0;
64    loop {
65        let Some(&[content, major, _, len_hi, len_lo]) = buf.get(pos..pos + RECORD_HEADER) else {
66            return Ok(Hello::Incomplete);
67        };
68        if content != CONTENT_HANDSHAKE || major != 3 {
69            return Err(HelloError::NotTls);
70        }
71        let len = usize::from(u16::from_be_bytes([len_hi, len_lo]));
72        if len == 0 || len > MAX_RECORD {
73            return Err(HelloError::Malformed);
74        }
75        let start = pos + RECORD_HEADER;
76        let Some(fragment) = buf.get(start..start + len) else {
77            return Ok(Hello::Incomplete);
78        };
79        handshake.extend_from_slice(fragment);
80        pos = start + len;
81
82        if let Some(&[kind, len0, len1, len2]) = handshake.get(..4) {
83            if kind != HANDSHAKE_CLIENT_HELLO {
84                return Err(HelloError::Malformed);
85            }
86            let body_len = usize::from(len0) << 16 | usize::from(len1) << 8 | usize::from(len2);
87            if body_len > MAX_HELLO {
88                return Err(HelloError::TooLarge);
89            }
90            if let Some(body) = handshake.get(4..4 + body_len) {
91                return client_hello_sni(body).map(|(name, encrypted)| {
92                    if encrypted {
93                        Hello::Encrypted(name)
94                    } else {
95                        Hello::ServerName(name)
96                    }
97                });
98            }
99        }
100    }
101}
102
103/// The cleartext SNI, and whether an ECH or ESNI extension is present.
104fn client_hello_sni(body: &[u8]) -> Result<(Option<String>, bool), HelloError> {
105    let mut r = Reader(body);
106    r.take(2 + 32)?; // legacy_version, random
107    let session_id_len = r.u8()?;
108    r.take(session_id_len.into())?;
109    let suites_len = r.u16()?;
110    r.take(suites_len.into())?;
111    let compression_len = r.u8()?;
112    r.take(compression_len.into())?;
113    if r.0.is_empty() {
114        return Ok((None, false));
115    }
116    let ext_len = r.u16()?;
117    let mut exts = Reader(r.take(ext_len.into())?);
118    r.end()?;
119    let mut name = None;
120    let mut encrypted = false;
121    while !exts.0.is_empty() {
122        let kind = exts.u16()?;
123        let len = exts.u16()?;
124        let data = exts.take(len.into())?;
125        if kind == EXT_SERVER_NAME {
126            if name.is_some() {
127                return Err(HelloError::Malformed);
128            }
129            name = Some(server_name_list(data)?);
130        }
131        encrypted |= kind == EXT_ECH || kind == EXT_ESNI || EXT_ECH_DRAFTS.contains(&kind);
132    }
133    Ok((name.flatten(), encrypted))
134}
135
136/// `ServerNameList`: at most one `host_name` entry (RFC 6066 §3).
137fn server_name_list(data: &[u8]) -> Result<Option<String>, HelloError> {
138    let mut r = Reader(data);
139    let list_len = r.u16()?;
140    let mut list = Reader(r.take(list_len.into())?);
141    r.end()?;
142    let mut host = None;
143    while !list.0.is_empty() {
144        let kind = list.u8()?;
145        let len = list.u16()?;
146        let value = list.take(len.into())?;
147        if kind != NAME_TYPE_HOST {
148            continue;
149        }
150        if host.is_some() {
151            return Err(HelloError::Malformed);
152        }
153        // The CONNECT host went through the same normalisation, so a name that
154        // is not a valid DNS name (empty, `.`, non-ASCII) could never match it.
155        let name = std::str::from_utf8(value)
156            .ok()
157            .and_then(normalise_name)
158            .ok_or(HelloError::Malformed)?;
159        host = Some(name);
160    }
161    Ok(host)
162}
163
164/// Bounds-checked big-endian reads; every short read is [`HelloError::Malformed`].
165struct Reader<'a>(&'a [u8]);
166
167impl<'a> Reader<'a> {
168    fn take(&mut self, n: usize) -> Result<&'a [u8], HelloError> {
169        let (head, rest) = self.0.split_at_checked(n).ok_or(HelloError::Malformed)?;
170        self.0 = rest;
171        Ok(head)
172    }
173
174    fn array<const N: usize>(&mut self) -> Result<[u8; N], HelloError> {
175        let (head, rest) = self.0.split_first_chunk().ok_or(HelloError::Malformed)?;
176        self.0 = rest;
177        Ok(*head)
178    }
179
180    fn u8(&mut self) -> Result<u8, HelloError> {
181        let [b] = self.array()?;
182        Ok(b)
183    }
184
185    fn u16(&mut self) -> Result<u16, HelloError> {
186        self.array().map(u16::from_be_bytes)
187    }
188
189    fn end(&self) -> Result<(), HelloError> {
190        if self.0.is_empty() {
191            Ok(())
192        } else {
193            Err(HelloError::Malformed)
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests;