diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index 3c267b6ed..a77d9b794 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -4,14 +4,16 @@ //! without hostname resolution or proxy inheritance, verifies operating-system //! peers before exposing transport I/O, and emits credential-free evidence. //! It also bridges a session-correlated WebDriver BiDi loopback target from -//! `originweave-core` into one bounded exact TCP connection without granting -//! browser, WebSocket, TLS, policy, or Agent authority. +//! `originweave-core` into one bounded exact TCP connection and can bind an inert +//! RFC 6455 opening request to an already-verified plain BiDi stream without +//! granting browser, WebSocket, TLS, policy, or Agent authority. #![forbid(unsafe_code)] #![deny(missing_docs)] mod connection; mod webdriver_bidi_connection; +mod webdriver_bidi_websocket_handshake; pub use connection::{ ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, @@ -21,3 +23,7 @@ pub use webdriver_bidi_connection::{ WebDriverBiDiTcpConnection, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionEvidence, WebDriverBiDiTcpConnectionPlan, }; +pub use webdriver_bidi_websocket_handshake::{ + WebDriverBiDiWebSocketClientKey, WebDriverBiDiWebSocketHandshakeError, + WebDriverBiDiWebSocketHandshakePlan, +}; diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs new file mode 100644 index 000000000..026fa390b --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_websocket_handshake.rs @@ -0,0 +1,155 @@ +use std::fmt; + +use originweave_core::VerifiedWebDriverBiDiSocketPeer; + +use crate::WebDriverBiDiTcpConnection; + +const WEBSOCKET_CLIENT_KEY_LENGTH: usize = 24; +const REDACTED_WEBSOCKET_CLIENT_NONCE: &str = ""; + +fn is_base64_data_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') +} + +fn is_canonical_16_byte_base64(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == WEBSOCKET_CLIENT_KEY_LENGTH + && bytes[..22].iter().copied().all(is_base64_data_byte) + && matches!(bytes[21], b'A' | b'Q' | b'g' | b'w') + && bytes[22] == b'=' + && bytes[23] == b'=' +} + +/// Deterministic failures while preparing one WebDriver BiDi RFC 6455 opening request. +#[derive(Debug, Eq, PartialEq)] +pub enum WebDriverBiDiWebSocketHandshakeError { + /// The supplied client key was not the canonical base64 representation of exactly 16 bytes. + InvalidClientKey, + /// The verified WebDriver BiDi target requires TLS before a WebSocket opening request is sent. + TlsRequired, +} + +impl fmt::Display for WebDriverBiDiWebSocketHandshakeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidClientKey => formatter.write_str( + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes", + ), + Self::TlsRequired => formatter.write_str( + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiWebSocketHandshakeError {} + +/// Canonical RFC 6455 client key for one WebDriver BiDi opening handshake. +/// +/// RFC 6455 requires `Sec-WebSocket-Key` to be a nonce of 16 bytes encoded with base64. This type +/// validates only the canonical wire representation, including zero padding bits. It does not +/// generate entropy: callers remain responsible for supplying a fresh, unpredictable 16-byte nonce +/// for each connection attempt. Its [`fmt::Debug`] representation deliberately redacts the nonce so +/// diagnostic output cannot disclose handshake material. +#[derive(Eq, PartialEq)] +pub struct WebDriverBiDiWebSocketClientKey(String); + +impl fmt::Debug for WebDriverBiDiWebSocketClientKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("WebDriverBiDiWebSocketClientKey") + .field(&REDACTED_WEBSOCKET_CLIENT_NONCE) + .finish() + } +} + +impl WebDriverBiDiWebSocketClientKey { + /// Admit one canonical base64 client key representing exactly 16 bytes. + pub fn new(value: &str) -> Result { + if !is_canonical_16_byte_base64(value) { + return Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey); + } + Ok(Self(value.to_owned())) + } + + /// Borrow the exact canonical value for `Sec-WebSocket-Key` serialization. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Inert RFC 6455 opening request bound to one already-verified plain BiDi TCP connection. +/// +/// The plan consumes the verified TCP connection so the opening request cannot be detached from the +/// socket peer/session evidence that authorized its exact loopback destination. It serializes only +/// the fixed WebSocket version-13 request required for the admitted `/session/` resource +/// and retains the exact client key required to validate a later `Sec-WebSocket-Accept` response. +/// Secure `wss` targets fail closed here and require a separate authenticated TLS transport boundary +/// before any WebSocket bytes may be written. Its [`fmt::Debug`] representation omits the serialized +/// request and redacts the client nonce because the request embeds that nonce in `Sec-WebSocket-Key`. +/// +/// Construction performs no socket write, TLS operation, response parsing, `Sec-WebSocket-Accept` +/// validation, WebSocket framing, Chromium/ChromeDriver process authentication, browser action, or +/// Agent-authority grant. +pub struct WebDriverBiDiWebSocketHandshakePlan { + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + request: Vec, +} + +impl fmt::Debug for WebDriverBiDiWebSocketHandshakePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WebDriverBiDiWebSocketHandshakePlan") + .field("verified_peer", self.connection.verified_peer()) + .field("client_nonce", &REDACTED_WEBSOCKET_CLIENT_NONCE) + .field("request_byte_count", &self.request.len()) + .finish() + } +} + +impl WebDriverBiDiWebSocketHandshakePlan { + /// Bind one canonical opening request to an already-verified plain BiDi TCP connection. + pub fn new( + connection: WebDriverBiDiTcpConnection, + client_key: WebDriverBiDiWebSocketClientKey, + ) -> Result { + if connection.verified_peer().requires_tls() { + return Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired); + } + + let peer = connection.verified_peer(); + let request = format!( + "GET /session/{} HTTP/1.1\r\nHost: {}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {}\r\nSec-WebSocket-Version: 13\r\n\r\n", + peer.session_id(), + peer.socket_addr(), + client_key.as_str(), + ) + .into_bytes(); + + Ok(Self { + connection, + client_key, + request, + }) + } + + /// Borrow the exact serialized RFC 6455 opening-request bytes. + #[must_use] + pub fn request_bytes(&self) -> &[u8] { + &self.request + } + + /// Borrow the exact client key that a later server-handshake validator must correlate. + #[must_use] + pub const fn client_key(&self) -> &WebDriverBiDiWebSocketClientKey { + &self.client_key + } + + /// Borrow the exact peer/session evidence already verified before request construction. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + self.connection.verified_peer() + } +} diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs new file mode 100644 index 000000000..93550245a --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_handshake.rs @@ -0,0 +1,202 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{ + WebDriverBiDiTcpConnectionPlan, WebDriverBiDiWebSocketClientKey, + WebDriverBiDiWebSocketHandshakeError, WebDriverBiDiWebSocketHandshakePlan, +}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; +const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const REDACTED_CLIENT_KEY: &str = ""; + +fn connect(endpoint: &str) -> originweave_network::WebDriverBiDiTcpConnection { + let admitted = WebDriverBiDiWebSocketEndpoint::new(endpoint); + assert!(admitted.is_ok(), "{admitted:?}"); + let Ok(admitted) = admitted else { + unreachable!("asserted valid endpoint") + }; + let correlated = admitted.correlate_session_id(SESSION_ID); + assert!(correlated.is_ok(), "{correlated:?}"); + let Ok(correlated) = correlated else { + unreachable!("asserted correlated endpoint") + }; + let target = correlated.into_explicit_connect_target(); + assert!(target.is_ok(), "{target:?}"); + let Ok(target) = target else { + unreachable!("asserted explicit target") + }; + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + unreachable!("asserted connection plan") + }; + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + unreachable!("asserted loopback connection") + }; + connection +} + +#[test] +fn client_key_debug_redacts_websocket_nonce() { + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + + let debug = format!("{key:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); +} + +#[test] +fn handshake_plan_debug_redacts_websocket_nonce() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let debug = format!("{plan:?}"); + assert!(debug.contains(REDACTED_CLIENT_KEY)); + assert!(!debug.contains(RFC6455_SAMPLE_KEY)); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + +#[test] +fn plain_bidi_connection_serializes_exact_rfc6455_opening_request() { + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let expected = format!( + "GET /session/{SESSION_ID} HTTP/1.1\r\nHost: {local_addr}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {RFC6455_SAMPLE_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + assert_eq!(plan.request_bytes(), expected.as_bytes()); + assert_eq!(plan.client_key().as_str(), RFC6455_SAMPLE_KEY); + assert_eq!(plan.verified_peer().socket_addr(), local_addr); + assert_eq!(plan.verified_peer().session_id(), SESSION_ID); + assert!(!plan.verified_peer().requires_tls()); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +} + +#[test] +fn handshake_errors_render_actionable_fail_closed_messages() { + assert_eq!( + WebDriverBiDiWebSocketHandshakeError::InvalidClientKey.to_string(), + "WebDriver BiDi WebSocket client key is not canonical base64 for exactly 16 bytes" + ); + assert_eq!( + WebDriverBiDiWebSocketHandshakeError::TlsRequired.to_string(), + "WebDriver BiDi WebSocket target requires authenticated TLS before the opening request" + ); +} + +#[test] +fn handshake_plan_rejects_tls_required_stream_and_noncanonical_client_keys() { + let invalid_length = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQ="); + assert!(matches!( + invalid_length, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + let invalid_character = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZ!=="); + assert!(matches!( + invalid_character, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + let invalid_padding_bits = WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZR=="); + assert!(matches!( + invalid_padding_bits, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + let invalid_padding_character = + WebDriverBiDiWebSocketClientKey::new("dGhlIHNhbXBsZSBub25jZQA="); + assert!(matches!( + invalid_padding_character, + Err(WebDriverBiDiWebSocketHandshakeError::InvalidClientKey) + )); + + let listener = TcpListener::bind(("127.0.0.1", 0)); + assert!(listener.is_ok(), "{listener:?}"); + let Ok(listener) = listener else { + return; + }; + let local_addr = listener.local_addr(); + assert!(local_addr.is_ok(), "{local_addr:?}"); + let Ok(local_addr) = local_addr else { + return; + }; + let server = thread::spawn(move || listener.accept().map(|_| ())); + + let endpoint = format!("wss://{local_addr}/session/{SESSION_ID}"); + let connection = connect(&endpoint); + let key = WebDriverBiDiWebSocketClientKey::new(RFC6455_SAMPLE_KEY); + assert!(key.is_ok(), "{key:?}"); + let Ok(key) = key else { + return; + }; + let plan = WebDriverBiDiWebSocketHandshakePlan::new(connection, key); + assert!(matches!( + plan, + Err(WebDriverBiDiWebSocketHandshakeError::TlsRequired) + )); + + let server_result = server.join(); + assert!(server_result.is_ok(), "{server_result:?}"); + if let Ok(accept_result) = server_result { + assert!(accept_result.is_ok(), "{accept_result:?}"); + } +}