| 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 | |
| 7 | use std::sync::Arc; |
| 8 | use std::time::Duration; |
| 9 | |
| 10 | use futures_util::{SinkExt, StreamExt}; |
| 11 | use tokio_rustls::rustls::pki_types::ServerName; |
| 12 | use tokio_tungstenite::tungstenite::Message; |
| 13 | use tokio_tungstenite::tungstenite::client::IntoClientRequest; |
| 14 | |
| 15 | use termbridge::{Config, Server, tls}; |
| 16 | |
| 17 | const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; |
| 18 | const PAIRED: &str = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; |
| 19 | const UNPAIRED: &str = "https://evil.example"; |
| 20 | |
| 21 | fn identity(dir: &std::path::Path) -> tls::Identity { |
| 22 | tls::load_or_create(dir).unwrap() |
| 23 | } |
| 24 | |
| 25 | async 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. |
| 35 | fn 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 | |
| 49 | fn 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 | |
| 70 | async 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] |
| 100 | async 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] |
| 120 | async 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] |
| 140 | async 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] |
| 155 | async 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] |
| 176 | async 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] |
| 193 | fn 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] |
| 210 | fn 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] |
| 226 | fn 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] |
| 249 | fn 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] |
| 270 | async 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 | } |