diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca383831..5598829e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added +- Bounded WebDriver BiDi loopback TCP transport that consumes one exact no-DNS connect target, retries only explicitly recoverable local transport failures within repository timeout and attempt ceilings, exposes the stream only after operating-system peer inspection and exact peer verification, supports a consuming handoff of the original stream with typed credential-free peer/session/TLS and bounded-attempt evidence, preserves typed causal errors, and performs no DNS, proxy/PAC, process authentication, TLS, WebSocket, BiDi message, browser-action, or Agent-authority step. - Exact WebDriver BiDi socket-peer verification that consumes an approved no-DNS connect target, requires the observed IP address and port to match exactly, preserves the TLS requirement and exact correlated session id, and remains inert metadata that does not authenticate an OS process, does not negotiate TLS, perform a WebSocket handshake, or grant Agent authority. - Explicit no-DNS WebDriver BiDi loopback connection targets that derive exact IPv4/IPv6 loopback `SocketAddr` metadata from a session-correlated endpoint, reject `localhost` as requiring separately trusted name resolution, preserve the TLS requirement and exact session id, perform no socket I/O, and grant no Agent authority. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index d5b26c1c3..a77d9b794 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -1,15 +1,29 @@ //! Direct-only policy-bound TCP connection authority for OriginWeave. //! -//! The crate consumes a validated connection plan, opens one exact socket -//! address without hostname resolution or proxy inheritance, verifies the -//! operating-system peer, and emits credential-free evidence. +//! The crate consumes validated connection plans, opens exact socket addresses +//! 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 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, NetworkError, SocketConnectionEvidence, }; +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_connection.rs b/crates/originweave-network/src/webdriver_bidi_connection.rs new file mode 100644 index 000000000..5d39bb5e3 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection.rs @@ -0,0 +1,254 @@ +use std::{ + io, + net::{SocketAddr, TcpStream}, + time::Duration, +}; + +use originweave_core::{VerifiedWebDriverBiDiSocketPeer, WebDriverBiDiWebSocketConnectTarget}; + +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +mod error; + +pub use error::WebDriverBiDiTcpConnectionError; + +#[cfg(test)] +mod tests; + +fn is_retryable_connect_error(kind: io::ErrorKind) -> bool { + matches!( + kind, + io::ErrorKind::TimedOut + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::Interrupted + ) +} + +/// Single-use authority to open one exact WebDriver BiDi loopback TCP destination. +/// +/// The plan consumes a session-correlated, no-DNS [`WebDriverBiDiWebSocketConnectTarget`] +/// produced by `originweave-core`. It applies the same bounded per-attempt timeout and retry +/// ceilings as the general direct-network connector, opens only the exact [`SocketAddr`] carried by +/// that target, and does not expose the stream until the operating system's observed peer has been +/// verified by the consumed target. +/// +/// This boundary performs no DNS lookup, proxy or PAC routing, Chromium/ChromeDriver process +/// authentication, TLS negotiation, WebSocket upgrade, BiDi framing, browser policy decision, or +/// Agent-authority grant. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnectionPlan { + target: WebDriverBiDiWebSocketConnectTarget, + connect_timeout: Duration, + maximum_attempts: u8, +} + +impl WebDriverBiDiTcpConnectionPlan { + /// Validate one bounded exact-loopback connection plan without performing network I/O. + pub fn new( + target: WebDriverBiDiWebSocketConnectTarget, + connect_timeout: Duration, + maximum_attempts: u8, + ) -> Result { + if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT { + return Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }); + } + if maximum_attempts == 0 || maximum_attempts > MAX_CONNECTION_ATTEMPTS { + return Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: maximum_attempts, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }); + } + + Ok(Self { + target, + connect_timeout, + maximum_attempts, + }) + } + + /// Open the exact approved loopback socket and expose it only after peer verification. + /// + /// Retry is limited to transport errors that can occur transiently while a local browser driver + /// listener is becoming ready. Peer-inspection and peer-mismatch failures are integrity failures + /// and therefore fail closed without retry or fallback. + pub fn connect(self) -> Result { + self.connect_with(&SystemWebDriverBiDiConnector) + } + + fn connect_with( + self, + connector: &dyn WebDriverBiDiSocketConnector, + ) -> Result { + let socket_address = self.target.socket_addr(); + let connect_timeout = self.connect_timeout; + let maximum_attempts = self.maximum_attempts; + let target = self.target; + let mut attempt_number = 1; + + loop { + match connector.connect_timeout(&socket_address, connect_timeout) { + Ok(stream) => { + let observed_peer = connector.peer_addr(&stream).map_err(|source| { + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address, + attempt_number, + source, + } + })?; + let verified_peer = + target + .verify_connected_peer(observed_peer) + .map_err(|source| WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number, + source, + })?; + return Ok(WebDriverBiDiTcpConnection { + stream, + verified_peer, + attempt_number, + connect_timeout, + }); + } + Err(source) + if is_retryable_connect_error(source.kind()) + && attempt_number < maximum_attempts => + { + attempt_number += 1; + } + Err(source) => { + if source.kind() == io::ErrorKind::TimedOut { + return Err(WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address, + attempt_count: attempt_number, + connect_timeout, + source, + }); + } + return Err(WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address, + attempt_count: attempt_number, + source, + }); + } + } + } + } +} + +trait WebDriverBiDiSocketConnector { + fn connect_timeout( + &self, + socket_address: &SocketAddr, + timeout: Duration, + ) -> io::Result; + + fn peer_addr(&self, stream: &TcpStream) -> io::Result; +} + +struct SystemWebDriverBiDiConnector; + +impl WebDriverBiDiSocketConnector for SystemWebDriverBiDiConnector { + fn connect_timeout( + &self, + socket_address: &SocketAddr, + timeout: Duration, + ) -> io::Result { + TcpStream::connect_timeout(socket_address, timeout) + } + + fn peer_addr(&self, stream: &TcpStream) -> io::Result { + stream.peer_addr() + } +} + +/// Established WebDriver BiDi TCP stream whose observed peer matched the approved target exactly. +/// +/// This wrapper proves only exact transport-destination equality for one bounded connection. The +/// caller must still establish any required TLS channel, complete a WebSocket handshake, bind the +/// transport to the expected browser process/session, and pass separate action-policy checks. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnection { + stream: TcpStream, + verified_peer: VerifiedWebDriverBiDiSocketPeer, + attempt_number: u8, + connect_timeout: Duration, +} + +impl WebDriverBiDiTcpConnection { + /// Borrow the verified TCP stream. + #[must_use] + pub const fn stream(&self) -> &TcpStream { + &self.stream + } + + /// Borrow the session-correlated exact peer evidence consumed by this connection. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + &self.verified_peer + } + + /// Return the one-based bounded attempt on which the connection succeeded. + #[must_use] + pub const fn attempt_number(&self) -> u8 { + self.attempt_number + } + + /// Return the per-attempt timeout applied while establishing this connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } + + /// Consume the wrapper into the original verified stream and credential-free transport evidence. + /// + /// This handoff does not clone the socket or create reusable connection authority. The returned + /// evidence records only the already-verified peer plus bounded connection-attempt metadata; it + /// does not authenticate a browser process, establish TLS, complete WebSocket framing, or grant + /// browser or Agent authority. + #[must_use] + pub fn into_parts(self) -> (TcpStream, WebDriverBiDiTcpConnectionEvidence) { + let evidence = WebDriverBiDiTcpConnectionEvidence { + verified_peer: self.verified_peer, + attempt_number: self.attempt_number, + connect_timeout: self.connect_timeout, + }; + (self.stream, evidence) + } +} + +/// Credential-free evidence retained when a verified WebDriver BiDi TCP stream is consumed. +/// +/// This value records exact peer/session/TLS-requirement metadata inherited from the consumed +/// no-DNS target together with the successful bounded attempt and per-attempt timeout. It is +/// transport evidence only and grants no process, TLS, WebSocket, browser-action, or Agent authority. +#[derive(Debug)] +pub struct WebDriverBiDiTcpConnectionEvidence { + verified_peer: VerifiedWebDriverBiDiSocketPeer, + attempt_number: u8, + connect_timeout: Duration, +} + +impl WebDriverBiDiTcpConnectionEvidence { + /// Borrow the exact session-correlated peer verified before stream exposure. + #[must_use] + pub const fn verified_peer(&self) -> &VerifiedWebDriverBiDiSocketPeer { + &self.verified_peer + } + + /// Return the one-based bounded attempt on which the connection succeeded. + #[must_use] + pub const fn attempt_number(&self) -> u8 { + self.attempt_number + } + + /// Return the per-attempt timeout applied while establishing the connection. + #[must_use] + pub const fn connect_timeout(&self) -> Duration { + self.connect_timeout + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/error.rs b/crates/originweave-network/src/webdriver_bidi_connection/error.rs new file mode 100644 index 000000000..226cd1d2b --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/error.rs @@ -0,0 +1,134 @@ +use std::{fmt, io, net::SocketAddr, time::Duration}; + +use originweave_core::WebDriverBiDiSocketPeerVerificationError; + +/// Deterministic failures while establishing one bounded WebDriver BiDi TCP transport. +#[derive(Debug)] +pub enum WebDriverBiDiTcpConnectionError { + /// The requested timeout was zero or exceeded [`crate::MAX_CONNECT_TIMEOUT`]. + InvalidConnectTimeout { + /// The rejected timeout. + connect_timeout: Duration, + /// The largest accepted per-attempt timeout. + maximum_timeout: Duration, + }, + /// The requested attempt count was outside `1..=MAX_CONNECTION_ATTEMPTS`. + InvalidAttemptCount { + /// The rejected attempt count. + attempt_count: u8, + /// The largest accepted attempt count. + maximum_attempts: u8, + }, + /// The final bounded connection attempt timed out. + ConnectionTimedOut { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Per-attempt timeout used by the plan. + connect_timeout: Duration, + /// Final operating-system timeout error. + source: io::Error, + }, + /// The final bounded connection attempt failed without a timeout. + ConnectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// Number of attempts completed before failure. + attempt_count: u8, + /// Final operating-system connection error. + source: io::Error, + }, + /// The established stream did not reveal an operating-system peer address. + PeerInspectionFailed { + /// Exact approved socket address submitted to the operating system. + socket_address: SocketAddr, + /// One-based attempt that established the stream. + attempt_number: u8, + /// Operating-system peer-inspection error. + source: io::Error, + }, + /// The established stream reported a peer other than the exact approved BiDi target. + PeerMismatch { + /// One-based attempt that established the stream. + attempt_number: u8, + /// Typed core peer-verification failure preserving expected and actual socket addresses. + source: WebDriverBiDiSocketPeerVerificationError, + }, +} + +impl WebDriverBiDiTcpConnectionError { + /// Return the number of transport attempts associated with this failure, when applicable. + #[must_use] + pub const fn attempt_count(&self) -> Option { + match self { + Self::ConnectionTimedOut { attempt_count, .. } + | Self::ConnectionFailed { attempt_count, .. } => Some(*attempt_count), + Self::PeerInspectionFailed { attempt_number, .. } + | Self::PeerMismatch { attempt_number, .. } => Some(*attempt_number), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} + +impl fmt::Display for WebDriverBiDiTcpConnectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidConnectTimeout { + connect_timeout, + maximum_timeout, + } => write!( + formatter, + "WebDriver BiDi connect timeout {connect_timeout:?} is outside 1ns..={maximum_timeout:?}", + ), + Self::InvalidAttemptCount { + attempt_count, + maximum_attempts, + } => write!( + formatter, + "WebDriver BiDi connection attempt count {attempt_count} is outside 1..={maximum_attempts}", + ), + Self::ConnectionTimedOut { + socket_address, + attempt_count, + connect_timeout, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} timed out after {attempt_count} attempts with per-attempt timeout {connect_timeout:?}", + ), + Self::ConnectionFailed { + socket_address, + attempt_count, + .. + } => write!( + formatter, + "WebDriver BiDi TCP connection to {socket_address} failed after {attempt_count} attempts", + ), + Self::PeerInspectionFailed { + socket_address, + attempt_number, + .. + } => write!( + formatter, + "WebDriver BiDi TCP peer inspection failed for {socket_address} on attempt {attempt_number}", + ), + Self::PeerMismatch { attempt_number, .. } => write!( + formatter, + "WebDriver BiDi TCP peer did not match the approved target on attempt {attempt_number}", + ), + } + } +} + +impl std::error::Error for WebDriverBiDiTcpConnectionError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::ConnectionTimedOut { source, .. } + | Self::ConnectionFailed { source, .. } + | Self::PeerInspectionFailed { source, .. } => Some(source), + Self::PeerMismatch { source, .. } => Some(source), + Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } => None, + } + } +} diff --git a/crates/originweave-network/src/webdriver_bidi_connection/tests.rs b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs new file mode 100644 index 000000000..e2d287ca1 --- /dev/null +++ b/crates/originweave-network/src/webdriver_bidi_connection/tests.rs @@ -0,0 +1,361 @@ +#![allow(clippy::expect_used)] + +use std::{ + cell::{Cell, RefCell}, + collections::VecDeque, + error::Error, + io, + net::{SocketAddr, TcpListener, TcpStream}, + time::Duration, +}; + +use originweave_core::{WebDriverBiDiWebSocketConnectTarget, WebDriverBiDiWebSocketEndpoint}; + +use super::{ + WebDriverBiDiSocketConnector, WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan, + is_retryable_connect_error, +}; +use crate::connection::{MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn socket_address() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 9515)) +} + +enum ConnectOutcome { + Success(TcpStream), + Error(io::ErrorKind), +} + +enum PeerOutcome { + Address(SocketAddr), + Error(io::ErrorKind), +} + +struct FakeConnector { + connect_outcomes: RefCell>, + peer_outcomes: RefCell>, + connect_calls: Cell, + peer_calls: Cell, +} + +impl FakeConnector { + fn new(connect_outcomes: Vec, peer_outcomes: Vec) -> Self { + Self { + connect_outcomes: RefCell::new(connect_outcomes.into()), + peer_outcomes: RefCell::new(peer_outcomes.into()), + connect_calls: Cell::new(0), + peer_calls: Cell::new(0), + } + } +} + +impl WebDriverBiDiSocketConnector for FakeConnector { + fn connect_timeout( + &self, + _socket_address: &SocketAddr, + _timeout: Duration, + ) -> io::Result { + self.connect_calls.set(self.connect_calls.get() + 1); + match self + .connect_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a connection outcome") + { + ConnectOutcome::Success(stream) => Ok(stream), + ConnectOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } + + fn peer_addr(&self, _stream: &TcpStream) -> io::Result { + self.peer_calls.set(self.peer_calls.get() + 1); + match self + .peer_outcomes + .borrow_mut() + .pop_front() + .expect("test must provide a peer outcome") + { + PeerOutcome::Address(address) => Ok(address), + PeerOutcome::Error(kind) => Err(io::Error::from(kind)), + } + } +} + +fn loopback_stream() -> TcpStream { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind loopback listener"); + let address = listener + .local_addr() + .expect("read loopback listener address"); + let client = TcpStream::connect(address).expect("connect loopback client"); + let (server, _) = listener.accept().expect("accept loopback client"); + drop(server); + client +} + +fn connect_target(secure: bool) -> WebDriverBiDiWebSocketConnectTarget { + let scheme = if secure { "wss" } else { "ws" }; + let endpoint = format!("{scheme}://127.0.0.1:9515/session/{SESSION_ID}"); + let admitted = WebDriverBiDiWebSocketEndpoint::new(&endpoint).expect("admit endpoint"); + let correlated = admitted + .correlate_session_id(SESSION_ID) + .expect("correlate endpoint"); + correlated + .into_explicit_connect_target() + .expect("derive explicit connect target") +} + +fn plan(maximum_attempts: u8) -> WebDriverBiDiTcpConnectionPlan { + WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + maximum_attempts, + ) + .expect("valid test plan") +} + +#[test] +fn validates_timeout_and_attempt_bounds_before_io() { + let zero_timeout = + WebDriverBiDiTcpConnectionPlan::new(connect_target(false), Duration::ZERO, 1); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let excessive_timeout = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + MAX_CONNECT_TIMEOUT + Duration::from_nanos(1), + 1, + ); + assert!(matches!( + excessive_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = + WebDriverBiDiTcpConnectionPlan::new(connect_target(false), Duration::from_millis(250), 0); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); + + let excessive_attempts = WebDriverBiDiTcpConnectionPlan::new( + connect_target(false), + Duration::from_millis(250), + MAX_CONNECTION_ATTEMPTS + 1, + ); + assert!(matches!( + excessive_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); +} + +#[test] +fn verified_peer_is_required_before_stream_exposure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(socket_address())], + ); + let connection = + WebDriverBiDiTcpConnectionPlan::new(connect_target(true), Duration::from_millis(250), 1) + .expect("valid plan") + .connect_with(&connector) + .expect("verified connection"); + + assert!(connection.stream().peer_addr().is_ok()); + assert_eq!(connection.verified_peer().socket_addr(), socket_address()); + assert!(connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_millis(250)); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); +} + +#[test] +fn all_recoverable_connect_kinds_can_retry_once() { + for kind in [ + io::ErrorKind::TimedOut, + io::ErrorKind::ConnectionRefused, + io::ErrorKind::ConnectionReset, + io::ErrorKind::ConnectionAborted, + io::ErrorKind::Interrupted, + ] { + assert!(is_retryable_connect_error(kind)); + let connector = FakeConnector::new( + vec![ + ConnectOutcome::Error(kind), + ConnectOutcome::Success(loopback_stream()), + ], + vec![PeerOutcome::Address(socket_address())], + ); + let connection = plan(2) + .connect_with(&connector) + .expect("second bounded attempt succeeds"); + assert_eq!(connection.attempt_number(), 2); + assert_eq!(connector.connect_calls.get(), 2); + assert_eq!(connector.peer_calls.get(), 1); + } + assert!(!is_retryable_connect_error(io::ErrorKind::PermissionDenied)); +} + +#[test] +fn final_timeout_preserves_source_and_attempt_count() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::TimedOut)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("timeout must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); + assert_eq!(error.attempt_count(), Some(1)); +} + +#[test] +fn exhausted_retryable_non_timeout_error_is_connection_failure() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::ConnectionRefused)], + Vec::new(), + ); + let error = plan(1) + .connect_with(&connector) + .expect_err("refusal must fail after the bounded final attempt"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert!(error.source().is_some()); +} + +#[test] +fn non_retryable_connection_error_fails_without_retry() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Error(io::ErrorKind::PermissionDenied)], + Vec::new(), + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("permission failure must not retry"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + attempt_count: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); +} + +#[test] +fn peer_inspection_failure_is_not_retried() { + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Error(io::ErrorKind::NotConnected)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer inspection failure must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); +} + +#[test] +fn peer_mismatch_is_not_retried_or_converted_to_success() { + let wrong_peer = SocketAddr::from(([127, 0, 0, 1], 9516)); + let connector = FakeConnector::new( + vec![ConnectOutcome::Success(loopback_stream())], + vec![PeerOutcome::Address(wrong_peer)], + ); + let error = plan(MAX_CONNECTION_ATTEMPTS) + .connect_with(&connector) + .expect_err("peer mismatch must fail closed"); + assert!(matches!( + error, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + .. + } + )); + assert_eq!(connector.connect_calls.get(), 1); + assert_eq!(connector.peer_calls.get(), 1); + assert!(error.source().is_some()); +} + +#[test] +fn error_display_source_and_attempt_contracts_cover_every_variant() { + let mismatch = connect_target(false) + .verify_connected_peer(SocketAddr::from(([127, 0, 0, 1], 9516))) + .expect_err("wrong peer must fail"); + let errors = [ + WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { + connect_timeout: Duration::ZERO, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }, + WebDriverBiDiTcpConnectionError::InvalidAttemptCount { + attempt_count: 0, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }, + WebDriverBiDiTcpConnectionError::ConnectionTimedOut { + socket_address: socket_address(), + attempt_count: 2, + connect_timeout: Duration::from_millis(250), + source: io::Error::from(io::ErrorKind::TimedOut), + }, + WebDriverBiDiTcpConnectionError::ConnectionFailed { + socket_address: socket_address(), + attempt_count: 3, + source: io::Error::from(io::ErrorKind::ConnectionRefused), + }, + WebDriverBiDiTcpConnectionError::PeerInspectionFailed { + socket_address: socket_address(), + attempt_number: 1, + source: io::Error::from(io::ErrorKind::NotConnected), + }, + WebDriverBiDiTcpConnectionError::PeerMismatch { + attempt_number: 1, + source: mismatch, + }, + ]; + + let messages: Vec = errors.iter().map(ToString::to_string).collect(); + assert!(messages[0].contains("outside 1ns")); + assert!(messages[1].contains("attempt count 0")); + assert!(messages[2].contains("timed out after 2 attempts")); + assert!(messages[3].contains("failed after 3 attempts")); + assert!(messages[4].contains("peer inspection failed")); + assert!(messages[5].contains("did not match the approved target")); + + assert_eq!(errors[0].attempt_count(), None); + assert_eq!(errors[1].attempt_count(), None); + assert_eq!(errors[2].attempt_count(), Some(2)); + assert_eq!(errors[3].attempt_count(), Some(3)); + assert_eq!(errors[4].attempt_count(), Some(1)); + assert_eq!(errors[5].attempt_count(), Some(1)); + assert!(errors[0].source().is_none()); + assert!(errors[1].source().is_none()); + assert!(errors[2].source().is_some()); + assert!(errors[3].source().is_some()); + assert!(errors[4].source().is_some()); + assert!(errors[5].source().is_some()); +} 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_tcp_connection.rs b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs new file mode 100644 index 000000000..fac15b6a5 --- /dev/null +++ b/crates/originweave-network/tests/webdriver_bidi_tcp_connection.rs @@ -0,0 +1,94 @@ +use std::{net::TcpListener, thread, time::Duration}; + +use originweave_core::WebDriverBiDiWebSocketEndpoint; +use originweave_network::{WebDriverBiDiTcpConnectionError, WebDriverBiDiTcpConnectionPlan}; + +const SESSION_ID: &str = "01234567-89ab-cdef-0123-456789abcdef"; + +fn connect_target(endpoint: &str) -> originweave_core::WebDriverBiDiWebSocketConnectTarget { + 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 literal loopback target") + }; + target +} + +#[test] +fn exact_loopback_target_opens_one_verified_bidi_tcp_stream() { + 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 target = connect_target(&endpoint); + let plan = WebDriverBiDiTcpConnectionPlan::new(target, Duration::from_secs(1), 1); + assert!(plan.is_ok(), "{plan:?}"); + let Ok(plan) = plan else { + return; + }; + + let connection = plan.connect(); + assert!(connection.is_ok(), "{connection:?}"); + let Ok(connection) = connection else { + return; + }; + + assert_eq!(connection.verified_peer().socket_addr(), local_addr); + assert!(!connection.verified_peer().requires_tls()); + assert_eq!(connection.verified_peer().session_id(), SESSION_ID); + assert_eq!(connection.attempt_number(), 1); + assert_eq!(connection.connect_timeout(), Duration::from_secs(1)); + + let (stream, evidence) = connection.into_parts(); + assert_eq!(stream.peer_addr().ok(), Some(local_addr)); + assert_eq!(evidence.verified_peer().socket_addr(), local_addr); + assert!(!evidence.verified_peer().requires_tls()); + assert_eq!(evidence.verified_peer().session_id(), SESSION_ID); + assert_eq!(evidence.attempt_number(), 1); + assert_eq!(evidence.connect_timeout(), Duration::from_secs(1)); + + 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 bidi_tcp_plan_rejects_invalid_retry_settings_before_io() { + let endpoint = format!("wss://127.0.0.1:9443/session/{SESSION_ID}"); + let zero_timeout = + WebDriverBiDiTcpConnectionPlan::new(connect_target(&endpoint), Duration::ZERO, 1); + assert!(matches!( + zero_timeout, + Err(WebDriverBiDiTcpConnectionError::InvalidConnectTimeout { .. }) + )); + + let zero_attempts = + WebDriverBiDiTcpConnectionPlan::new(connect_target(&endpoint), Duration::from_secs(1), 0); + assert!(matches!( + zero_attempts, + Err(WebDriverBiDiTcpConnectionError::InvalidAttemptCount { .. }) + )); +} 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:?}"); + } +}