anvilsign in

collin/browser-terminal-extension

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::client::IntoClientRequest;
13use tokio_tungstenite::tungstenite::Message;
14
15use termbridge::{tls, Config, Server};
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<
76 tokio_rustls::client::TlsStream<tokio::net::TcpStream>,
77 >,
78 tokio_tungstenite::tungstenite::Error,
79> {
80 let connector = tokio_rustls::TlsConnector::from(client_config(id));
81 let tcp = tokio::net::TcpStream::connect(server.addr()).await.unwrap();
82 let dnsname = ServerName::try_from("127.0.0.1").unwrap();
83 let stream = connector
84 .connect(dnsname, tcp)
85 .await
86 .map_err(tokio_tungstenite::tungstenite::Error::Io)?;
87
88 let mut req = format!("wss://{}/", server.addr())
89 .into_client_request()
90 .unwrap();
91 if let Some(o) = origin {
92 req.headers_mut().insert("origin", o.parse().unwrap());
93 }
94 tokio_tungstenite::client_async(req, stream)
95 .await
96 .map(|(ws, _)| ws)
97}
98
99// ---------------------------------------------------------------------------
100
101#[tokio::test]
102async fn wss_happy_path() {
103 let dir = tempfile::tempdir().unwrap();
104 let id = identity(dir.path());
105 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
106
107 let mut ws = tls_connect(&server, &id, Some(PAIRED)).await.unwrap();
108 ws.send(Message::Text(
109 serde_json::json!({"type": "auth", "token": TOKEN})
110 .to_string()
111 .into(),
112 ))
113 .await
114 .unwrap();
115 let reply = ws.next().await.unwrap().unwrap();
116 assert!(reply.to_text().unwrap().contains("\"ok\""), "{reply:?}");
117}
118
119/// The whole point of the change: one port serves both, so Firefox's HTTPS-Only
120/// rewrite and Chrome's plain ws:// both work without configuration.
121#[tokio::test]
122async fn same_port_serves_plaintext_too() {
123 let dir = tempfile::tempdir().unwrap();
124 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
125
126 let mut req = server.url().into_client_request().unwrap();
127 req.headers_mut().insert("origin", PAIRED.parse().unwrap());
128 let (mut ws, _) = tokio_tungstenite::connect_async(req).await.unwrap();
129 ws.send(Message::Text(
130 serde_json::json!({"type": "auth", "token": TOKEN})
131 .to_string()
132 .into(),
133 ))
134 .await
135 .unwrap();
136 let reply = ws.next().await.unwrap().unwrap();
137 assert!(reply.to_text().unwrap().contains("\"ok\""), "{reply:?}");
138}
139
140/// TLS must not become a way around the origin allowlist.
141#[tokio::test]
142async fn origin_check_still_applies_over_tls() {
143 let dir = tempfile::tempdir().unwrap();
144 let id = identity(dir.path());
145 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
146
147 let err = tls_connect(&server, &id, Some(UNPAIRED))
148 .await
149 .expect_err("unpaired origin must be refused over wss too");
150 assert!(
151 matches!(&err, tokio_tungstenite::tungstenite::Error::Http(r) if r.status() == 403),
152 "got {err:?}"
153 );
154}
155
156#[tokio::test]
157async fn token_check_still_applies_over_tls() {
158 let dir = tempfile::tempdir().unwrap();
159 let id = identity(dir.path());
160 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
161
162 let mut ws = tls_connect(&server, &id, Some(PAIRED)).await.unwrap();
163 ws.send(Message::Text(
164 serde_json::json!({"type": "auth", "token": "wrong"})
165 .to_string()
166 .into(),
167 ))
168 .await
169 .unwrap();
170 let reply = ws.next().await.unwrap().unwrap();
171 assert!(reply.to_text().unwrap().contains("invalid token"), "{reply:?}");
172}
173
174#[tokio::test]
175async fn missing_origin_still_refused_over_tls() {
176 let dir = tempfile::tempdir().unwrap();
177 let id = identity(dir.path());
178 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
179
180 let err = tls_connect(&server, &id, None)
181 .await
182 .expect_err("no Origin must be refused over wss too");
183 assert!(
184 matches!(&err, tokio_tungstenite::tungstenite::Error::Http(r) if r.status() == 403),
185 "got {err:?}"
186 );
187}
188
189// --- key material -----------------------------------------------------------
190
191#[test]
192fn private_key_is_0600_and_cert_is_not_secret() {
193 use std::os::unix::fs::PermissionsExt;
194 let dir = tempfile::tempdir().unwrap();
195 tls::generate(dir.path()).unwrap();
196
197 let key_mode = std::fs::metadata(dir.path().join(tls::KEY_FILE))
198 .unwrap()
199 .permissions()
200 .mode()
201 & 0o777;
202 assert_eq!(key_mode, 0o600, "private key mode is {key_mode:04o}");
203
204 let dir_mode = std::fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
205 assert_eq!(dir_mode, 0o700);
206}
207
208#[test]
209fn refuses_a_world_readable_private_key() {
210 use std::os::unix::fs::PermissionsExt;
211 let dir = tempfile::tempdir().unwrap();
212 tls::generate(dir.path()).unwrap();
213 std::fs::set_permissions(
214 dir.path().join(tls::KEY_FILE),
215 std::fs::Permissions::from_mode(0o644),
216 )
217 .unwrap();
218 assert!(
219 tls::load_or_create(dir.path()).is_err(),
220 "a group/world readable key must be refused, not used"
221 );
222}
223
224#[test]
225fn certificate_is_not_a_ca() {
226 // Trusting our cert must not let it vouch for any other host. A CA cert in
227 // the browser's trust store would be a far bigger grant than intended.
228 let dir = tempfile::tempdir().unwrap();
229 let id = tls::generate(dir.path()).unwrap();
230 let der = pem_body_to_der(
231 id.cert_pem
232 .split("-----BEGIN CERTIFICATE-----")
233 .nth(1)
234 .unwrap()
235 .split("-----END CERTIFICATE-----")
236 .next()
237 .unwrap(),
238 );
239 // basicConstraints CA:TRUE encodes as 30 03 01 01 FF inside the extension.
240 let ca_true: &[u8] = &[0x30, 0x03, 0x01, 0x01, 0xff];
241 assert!(
242 !der.windows(ca_true.len()).any(|w| w == ca_true),
243 "certificate asserts CA:TRUE"
244 );
245}
246
247#[test]
248fn identity_is_stable_across_loads() {
249 let dir = tempfile::tempdir().unwrap();
250 let a = tls::load_or_create(dir.path()).unwrap();
251 let b = tls::load_or_create(dir.path()).unwrap();
252 assert_eq!(a.fingerprint, b.fingerprint, "cert must not churn per start");
253 assert_eq!(a.fingerprint.len(), 32 * 3 - 1, "SHA-256 hex with colons");
254
255 let c = tls::generate(dir.path()).unwrap();
256 assert_ne!(a.fingerprint, c.fingerprint, "--regenerate must make a new one");
257}
258
259/// The landing page exists so the certificate-trust visit is comprehensible.
260/// It must never leak the token — that is the exact mistake that made the
261/// reference implementation remotely exploitable.
262#[tokio::test]
263async fn landing_page_does_not_leak_the_token() {
264 use tokio::io::{AsyncReadExt, AsyncWriteExt};
265 let dir = tempfile::tempdir().unwrap();
266 let server = start_tls(dir.path(), vec![PAIRED.to_string()]).await;
267
268 let mut sock = tokio::net::TcpStream::connect(server.addr()).await.unwrap();
269 sock.write_all(
270 format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n\r\n", server.addr().port()).as_bytes(),
271 )
272 .await
273 .unwrap();
274
275 let mut body = Vec::new();
276 let _ = tokio::time::timeout(Duration::from_secs(2), sock.read_to_end(&mut body)).await;
277 let text = String::from_utf8_lossy(&body);
278
279 assert!(text.contains("200 OK"), "expected the landing page, got:\n{text}");
280 assert!(!text.contains(TOKEN), "landing page leaked the auth token");
281 assert!(
282 !text.to_lowercase().contains("\"token\""),
283 "landing page exposes a token field"
284 );
285}