anvilsign in

collin/browser-terminal-extension

main / daemon / tests / tls.rs
1//! TLS is a new attack surface, so it gets the same scrutiny as the rest.
2//!
3//! The rule under test: enabling wss:// must not weaken anything. Every check
4//! that applies over ws:// applies identically over wss://, and the private key
5//! is guarded like the token.
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use futures_util::{SinkExt, StreamExt};
11use tokio_rustls::rustls::pki_types::ServerName;
12use tokio_tungstenite::tungstenite::Message;
13use tokio_tungstenite::tungstenite::client::IntoClientRequest;
14
15use termbridge::{Config, Server, tls};
16
17const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
18const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop";
19const UNPAIRED: &str = "https://evil.example";
20
21fn identity(dir: &std::path::Path) -> tls::Identity {
22 tls::load_or_create(dir).unwrap()
23}
24
25async fn start_tls(dir: &std::path::Path, paired: Vec<String>) -> Server {
26 let mut cfg = Config::new(TOKEN, paired);
27 cfg.echo_only = true;
28 cfg.auth_timeout = Duration::from_millis(500);
29 cfg.tls = Some(tls::acceptor(&identity(dir)).unwrap());
30 Server::start(cfg, 0).await.unwrap()
31}
32
33/// A client that trusts only our generated certificate — the browser's position
34/// after the user accepts the exception.
35fn client_config(id: &tls::Identity) -> Arc<tokio_rustls::rustls::ClientConfig> {
36 let mut roots = tokio_rustls::rustls::RootCertStore::empty();
37 for block in id.cert_pem.split("-----BEGIN CERTIFICATE-----").skip(1) {
38 let body = block.split("-----END CERTIFICATE-----").next().unwrap();
39 let der = pem_body_to_der(body);
40 roots.add(der.into()).unwrap();
41 }
42 Arc::new(
43 tokio_rustls::rustls::ClientConfig::builder()
44 .with_root_certificates(roots)
45 .with_no_client_auth(),
46 )
47}
48
49fn pem_body_to_der(body: &str) -> Vec<u8> {
50 const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
51 let mut rev = [255u8; 256];
52 for (i, c) in T.iter().enumerate() {
53 rev[*c as usize] = i as u8;
54 }
55 let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32);
56 for c in body.bytes().filter(|c| !c.is_ascii_whitespace()) {
57 if c == b'=' {
58 break;
59 }
60 acc = (acc << 6) | rev[c as usize] as u32;
61 bits += 6;
62 if bits >= 8 {
63 bits -= 8;
64 out.push((acc >> bits) as u8);
65 }
66 }
67 out
68}
69
70async fn tls_connect(
71 server: &Server,
72 id: &tls::Identity,
73 origin: Option<&str>,
74) -> Result<
75 tokio_tungstenite::WebSocketStream<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>,
76 tokio_tungstenite::tungstenite::Error,
77> {
78 let connector = tokio_rustls::TlsConnector::from(client_config(id));
79 let tcp = tokio::net::TcpStream::connect(server.addr()).await.unwrap();
80 let dnsname = ServerName::try_from("127.0.0.1").unwrap();
81 let stream = connector
82 .connect(dnsname, tcp)
83 .await
84 .map_err(tokio_tungstenite::tungstenite::Error::Io)?;
85
86 let mut req = format!("wss://{}/", server.addr())
87 .into_client_request()
88 .unwrap();
89 if let Some(o) = origin {
90 req.headers_mut().insert("origin", o.parse().unwrap());
91 }
92 tokio_tungstenite::client_async(req, stream)
93 .await
94 .map(|(ws, _)| ws)
95}
96
97// ---------------------------------------------------------------------------
98
99#[tokio::test]
100async fn wss_happy_path() {
101 let dir = tempfile::tempdir().unwrap();
102 let id = identity(dir.path());
103 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
104
105 let mut ws = tls_connect(&server, &id, Some(PAIRED)).await.unwrap();
106 ws.send(Message::Text(
107 serde_json::json!({"type": "auth", "token": TOKEN})
108 .to_string()
109 .into(),
110 ))
111 .await
112 .unwrap();
113 let reply = ws.next().await.unwrap().unwrap();
114 assert!(reply.to_text().unwrap().contains("\"ok\""), "{reply:?}");
115}
116
117/// The whole point of the change: one port serves both, so Firefox's HTTPS-Only
118/// rewrite and Chrome's plain ws:// both work without configuration.
119#[tokio::test]
120async fn same_port_serves_plaintext_too() {
121 let dir = tempfile::tempdir().unwrap();
122 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
123
124 let mut req = server.url().into_client_request().unwrap();
125 req.headers_mut().insert("origin", PAIRED.parse().unwrap());
126 let (mut ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
127 ws.send(Message::Text(
128 serde_json::json!({"type": "auth", "token": TOKEN})
129 .to_string()
130 .into(),
131 ))
132 .await
133 .unwrap();
134 let reply = ws.next().await.unwrap().unwrap();
135 assert!(reply.to_text().unwrap().contains("\"ok\""), "{reply:?}");
136}
137
138/// TLS must not become a way around the origin allowlist.
139#[tokio::test]
140async fn origin_check_still_applies_over_tls() {
141 let dir = tempfile::tempdir().unwrap();
142 let id = identity(dir.path());
143 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
144
145 let err = tls_connect(&server, &id, Some(UNPAIRED))
146 .await
147 .expect_err("unpaired origin must be refused over wss too");
148 assert!(
149 matches!(&err, tokio_tungstenite::tungstenite::Error::Http(r) if r.status() == 403),
150 "got {err:?}"
151 );
152}
153
154#[tokio::test]
155async fn token_check_still_applies_over_tls() {
156 let dir = tempfile::tempdir().unwrap();
157 let id = identity(dir.path());
158 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
159
160 let mut ws = tls_connect(&server, &id, Some(PAIRED)).await.unwrap();
161 ws.send(Message::Text(
162 serde_json::json!({"type": "auth", "token": "wrong"})
163 .to_string()
164 .into(),
165 ))
166 .await
167 .unwrap();
168 let reply = ws.next().await.unwrap().unwrap();
169 assert!(
170 reply.to_text().unwrap().contains("invalid token"),
171 "{reply:?}"
172 );
173}
174
175#[tokio::test]
176async fn missing_origin_still_refused_over_tls() {
177 let dir = tempfile::tempdir().unwrap();
178 let id = identity(dir.path());
179 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
180
181 let err = tls_connect(&server, &id, None)
182 .await
183 .expect_err("no Origin must be refused over wss too");
184 assert!(
185 matches!(&err, tokio_tungstenite::tungstenite::Error::Http(r) if r.status() == 403),
186 "got {err:?}"
187 );
188}
189
190// --- key material -----------------------------------------------------------
191
192#[test]
193fn private_key_is_0600_and_cert_is_not_secret() {
194 use std::os::unix::fs::PermissionsExt;
195 let dir = tempfile::tempdir().unwrap();
196 tls::generate(dir.path()).unwrap();
197
198 let key_mode = std::fs::metadata(dir.path().join(tls::KEY_FILE))
199 .unwrap()
200 .permissions()
201 .mode()
202 & 0o777;
203 assert_eq!(key_mode, 0o600, "private key mode is {key_mode:04o}");
204
205 let dir_mode = std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
206 assert_eq!(dir_mode, 0o700);
207}
208
209#[test]
210fn refuses_a_world_readable_private_key() {
211 use std::os::unix::fs::PermissionsExt;
212 let dir = tempfile::tempdir().unwrap();
213 tls::generate(dir.path()).unwrap();
214 std::fs::set_permissions(
215 dir.path().join(tls::KEY_FILE),
216 std::fs::Permissions::from_mode(0o644),
217 )
218 .unwrap();
219 assert!(
220 tls::load_or_create(dir.path()).is_err(),
221 "a group/world readable key must be refused, not used"
222 );
223}
224
225#[test]
226fn certificate_is_not_a_ca() {
227 // Trusting our cert must not let it vouch for any other host. A CA cert in
228 // the browser's trust store would be a far bigger grant than intended.
229 let dir = tempfile::tempdir().unwrap();
230 let id = tls::generate(dir.path()).unwrap();
231 let der = pem_body_to_der(
232 id.cert_pem
233 .split("-----BEGIN CERTIFICATE-----")
234 .nth(1)
235 .unwrap()
236 .split("-----END CERTIFICATE-----")
237 .next()
238 .unwrap(),
239 );
240 // basicConstraints CA:TRUE encodes as 30 03 01 01 FF inside the extension.
241 let ca_true: &[u8] = &[0x30, 0x03, 0x01, 0x01, 0xff];
242 assert!(
243 !der.windows(ca_true.len()).any(|w| w == ca_true),
244 "certificate asserts CA:TRUE"
245 );
246}
247
248#[test]
249fn identity_is_stable_across_loads() {
250 let dir = tempfile::tempdir().unwrap();
251 let a = tls::load_or_create(dir.path()).unwrap();
252 let b = tls::load_or_create(dir.path()).unwrap();
253 assert_eq!(
254 a.fingerprint, b.fingerprint,
255 "cert must not churn per start"
256 );
257 assert_eq!(a.fingerprint.len(), 32 * 3 - 1, "SHA-256 hex with colons");
258
259 let c = tls::generate(dir.path()).unwrap();
260 assert_ne!(
261 a.fingerprint, c.fingerprint,
262 "--regenerate must make a new one"
263 );
264}
265
266/// The landing page exists so the certificate-trust visit is comprehensible.
267/// It must never leak the token — that is the exact mistake that made the
268/// reference implementation remotely exploitable.
269#[tokio::test]
270async fn landing_page_does_not_leak_the_token() {
271 use tokio::io::{AsyncReadExt, AsyncWriteExt};
272 let dir = tempfile::tempdir().unwrap();
273 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
274
275 let mut sock = tokio::net::TcpStream::connect(server.addr()).await.unwrap();
276 sock.write_all(
277 format!(
278 "GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n\r\n",
279 server.addr().port()
280 )
281 .as_bytes(),
282 )
283 .await
284 .unwrap();
285
286 let mut body = Vec::new();
287 let _ = tokio::time::timeout(Duration::from_secs(2), sock.read_to_end(&mut body)).await;
288 let text = String::from_utf8_lossy(&body);
289
290 assert!(
291 text.contains("200 OK"),
292 "expected the landing page, got:\n{text}"
293 );
294 assert!(!text.contains(TOKEN), "landing page leaked the auth token");
295 assert!(
296 !text.to_lowercase().contains("\"token\""),
297 "landing page exposes a token field"
298 );
299}