diff --git a/AGENTS.md b/AGENTS.md index 6f747c38e..edaa9c0fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,3 +116,7 @@ A skipped security, GPU, browser, TLS, or statistical test is not passing eviden ## Release contract A release requires all current-head checks, complete coverage and docs, updated `CHANGELOG.md`, SBOM and provenance, reproducible artifacts, compatibility evidence, security review, and an explicit version decision. Pre-alpha commits are not releases. + +## Reusable verification notes + +- RFC 6455 client-frame masks must come from the OS CSPRNG in production paths. Keep deterministic `WebDriverBiDiWebSocketMaskKey::new` values for fixtures only; exercise the live loopback write path with `cargo test -p originweave-network --test webdriver_bidi_websocket_masking_key_reuse --locked`. In this pinned dependency set, `getrandom 0.2` exposes `getrandom::getrandom` but its error does not implement `std::error::Error`; retain it in the typed frame error without manufacturing an error-chain source. diff --git a/CHANGELOG.md b/CHANGELOG.md index 829fba083..cae92c48e 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 +- OS-CSPRNG-backed WebDriver BiDi WebSocket text and Pong writes that acquire a fresh redacted RFC 6455 masking key before any frame bytes are emitted; entropy failure fails closed and deterministic injected keys remain limited to the existing fixture-oriented APIs. - Bounded RFC 6455 WebDriver BiDi opening-response validation on the exact peer-verified stream: it admits only HTTP/1.1 `101`, case-insensitive `Upgrade`/`Connection` tokens, and the client-key-correlated `Sec-WebSocket-Accept` value within monotonic time and header-size ceilings; it restores blocking mode and still does not implement WebSocket frames or grant browser/Agent authority. - 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. diff --git a/CLAUDE.md b/CLAUDE.md index ab08bc16f..db3824020 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,3 +12,4 @@ Additional constraints: - Do not add hostname reconnect, proxy-environment inheritance, dangerous certificate-verifier hooks, Common Name fallback, TLS 0-RTT, key logging, or secret extraction to a production TLS path. - Keep changes bounded to one product gap and preserve modular crate boundaries. - Never claim a test, benchmark, browser integration, TLS identity, GPU execution, release, or merge succeeded without current exact-head evidence. +- For production WebSocket client frames, use the OS-CSPRNG helper on the established stream. Keep injected mask bytes only in deterministic fixtures; `getrandom 0.2` error values are not `std::error::Error` in this pinned build. diff --git a/Cargo.lock b/Cargo.lock index 90b2ed7c5..a1089a241 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,7 @@ name = "originweave-network" version = "0.1.0" dependencies = [ "base64", + "getrandom", "originweave-core", "originweave-destination", "sha1", diff --git a/crates/originweave-network/Cargo.toml b/crates/originweave-network/Cargo.toml index 68ac4fc8c..37fad560a 100644 --- a/crates/originweave-network/Cargo.toml +++ b/crates/originweave-network/Cargo.toml @@ -12,6 +12,7 @@ publish = false [dependencies] base64 = "0.22.1" +getrandom = "0.2.17" originweave-core = { path = "../originweave-core" } originweave-destination = { path = "../originweave-destination" } sha1 = "0.10.6" diff --git a/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs b/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs index 4a680daed..901e6d9ea 100644 --- a/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs +++ b/crates/originweave-network/src/webdriver_bidi_websocket_frame.rs @@ -46,6 +46,23 @@ impl WebDriverBiDiWebSocketMaskKey { Self(value) } + /// Obtain one fresh four-byte frame masking key from the operating-system CSPRNG. + /// + /// The key remains redacted in diagnostics. Callers that need deterministic fixture bytes may + /// use [`Self::new`] instead; production frame writes should use the random-key convenience + /// methods on [`WebDriverBiDiWebSocketEstablished`]. + pub fn random() -> Result { + Self::from_random_fill(getrandom::getrandom) + } + + fn from_random_fill( + fill_random_bytes: fn(&mut [u8]) -> Result<(), getrandom::Error>, + ) -> Result { + let mut value = [0_u8; 4]; + fill_random_bytes(&mut value)?; + Ok(Self(value)) + } + /// Borrow the exact four-byte key used by the reviewed framing boundary. #[must_use] pub const fn as_bytes(&self) -> &[u8; 4] { @@ -261,6 +278,19 @@ impl WebDriverBiDiWebSocketEstablished { write_frame_with_clock(&mut self.raw.stream, &frame, frame_timeout, &mut now).map(|_| self) } + /// Write one final masked UTF-8 text frame using a fresh OS-CSPRNG masking key. + /// + /// Entropy acquisition happens before any frame bytes are written. A failure leaves no + /// reusable stream and never substitutes predictable key material. + pub fn write_text_frame_with_random_masking_key( + self, + text: &str, + frame_timeout: Duration, + ) -> Result { + random_masking_key() + .and_then(|masking_key| self.write_text_frame(text, masking_key, frame_timeout)) + } + /// Write one final masked RFC 6455 Pong control frame on this verified stream. /// /// Payloads above 125 bytes fail closed. The same adjacent masking-key guard used for text @@ -284,6 +314,19 @@ impl WebDriverBiDiWebSocketEstablished { write_frame_with_clock(&mut self.raw.stream, &frame, frame_timeout, &mut now).map(|_| self) } + /// Write one final masked RFC 6455 Pong frame using a fresh OS-CSPRNG masking key. + /// + /// Entropy acquisition happens before any frame bytes are written. A failure leaves no + /// reusable stream and never substitutes predictable key material. + pub fn write_pong_frame_with_random_masking_key( + self, + payload: &[u8], + frame_timeout: Duration, + ) -> Result { + random_masking_key() + .and_then(|masking_key| self.write_pong_frame(payload, masking_key, frame_timeout)) + } + /// Read one bounded RFC 6455 frame from this verified stream. /// /// Server frames must be unmasked. Reserved bits/opcodes, non-minimal lengths, oversized @@ -332,6 +375,11 @@ impl WebDriverBiDiWebSocketFrame { /// Fail-closed errors while reading or writing one bounded WebSocket frame. #[derive(Debug)] pub enum WebDriverBiDiWebSocketFrameError { + /// The operating-system CSPRNG could not provide a client frame masking key. + MaskingKeyGenerationFailed { + /// Underlying operating-system entropy error. + source: getrandom::Error, + }, /// The requested frame I/O deadline was zero or above the reviewed resource ceiling. InvalidFrameTimeout { /// Rejected caller-supplied deadline. @@ -411,6 +459,9 @@ pub enum WebDriverBiDiWebSocketFrameError { impl fmt::Display for WebDriverBiDiWebSocketFrameError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::MaskingKeyGenerationFailed { .. } => { + formatter.write_str("failed to obtain a WebSocket client masking key") + } Self::InvalidFrameTimeout { .. } => formatter .write_str("WebDriver BiDi WebSocket frame timeout is outside the reviewed bound"), Self::FrameTooLarge { .. } => { @@ -453,6 +504,7 @@ impl fmt::Display for WebDriverBiDiWebSocketFrameError { impl Error for WebDriverBiDiWebSocketFrameError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { + Self::MaskingKeyGenerationFailed { .. } => None, Self::FrameReadModeConfigurationFailed { source } | Self::FrameReadTimedOut { source, .. } | Self::FrameReadFailed { source, .. } @@ -469,6 +521,16 @@ impl Error for WebDriverBiDiWebSocketFrameError { } } +fn random_masking_key() -> Result { + map_masking_key_generation(WebDriverBiDiWebSocketMaskKey::random()) +} + +fn map_masking_key_generation( + result: Result, +) -> Result { + result.map_err(|source| WebDriverBiDiWebSocketFrameError::MaskingKeyGenerationFailed { source }) +} + fn validate_frame_timeout(frame_timeout: Duration) -> Result<(), WebDriverBiDiWebSocketFrameError> { if frame_timeout.is_zero() || frame_timeout > MAX_WEBSOCKET_FRAME_TIMEOUT { return Err(WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { @@ -938,6 +1000,35 @@ mod tests { assert!(history.reserve(first).is_ok()); } + #[test] + fn mask_key_random_fill_is_redacted_and_propagates_entropy_failure() { + fn fill_test_bytes(value: &mut [u8]) -> Result<(), getrandom::Error> { + value.copy_from_slice(&[9, 8, 7, 6]); + Ok(()) + } + + fn reject_random_fill(_value: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) + } + + let generated = WebDriverBiDiWebSocketMaskKey::from_random_fill(fill_test_bytes) + .expect("test entropy source succeeds"); + assert_eq!(generated.as_bytes(), &[9, 8, 7, 6]); + assert_eq!(format!("{generated:?}"), ""); + assert_eq!( + WebDriverBiDiWebSocketMaskKey::from_random_fill(reject_random_fill), + Err(getrandom::Error::UNSUPPORTED) + ); + assert!(random_masking_key().is_ok()); + let frame_error = map_masking_key_generation(Err(getrandom::Error::UNSUPPORTED)) + .expect_err("entropy failure maps to a frame error"); + assert_eq!( + frame_error.to_string(), + "failed to obtain a WebSocket client masking key" + ); + assert!(frame_error.source().is_none()); + } + #[test] fn serializer_uses_minimal_lengths_and_masks_payloads() { let key = WebDriverBiDiWebSocketMaskKey::new([1, 2, 3, 4]); @@ -1334,6 +1425,9 @@ mod tests { #[test] fn frame_errors_have_stable_messages_and_sources() { let errors = [ + WebDriverBiDiWebSocketFrameError::MaskingKeyGenerationFailed { + source: getrandom::Error::UNSUPPORTED, + }, WebDriverBiDiWebSocketFrameError::InvalidFrameTimeout { frame_timeout: Duration::ZERO, maximum_timeout: MAX_WEBSOCKET_FRAME_TIMEOUT, @@ -1373,7 +1467,7 @@ mod tests { }, ]; for (error, has_source) in errors.iter().zip([ - false, false, true, true, true, false, false, true, true, true, false, true, + false, false, false, true, true, true, false, false, true, true, true, false, true, ]) { assert!(!error.to_string().is_empty()); assert_eq!(error.source().is_some(), has_source); diff --git a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs index 34ce97174..b2292a0f7 100644 --- a/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs +++ b/crates/originweave-network/tests/webdriver_bidi_websocket_masking_key_reuse.rs @@ -18,6 +18,7 @@ const RFC6455_SAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; const REUSED_MASK_REASON: &str = "client masking key was reused for consecutive frames on this established WebSocket"; const OPENING_RESPONSE: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n"; +type MaskedFramePair = ([u8; 4], Vec, [u8; 4], Vec); fn connect( endpoint: &str, @@ -55,7 +56,10 @@ fn read_opening_request(stream: &mut TcpStream) -> io::Result<()> { Ok(()) } -fn read_masked_frame(stream: &mut TcpStream, expected_opcode: u8) -> io::Result> { +fn read_masked_frame_with_key( + stream: &mut TcpStream, + expected_opcode: u8, +) -> io::Result<([u8; 4], Vec)> { stream.set_read_timeout(Some(Duration::from_secs(2)))?; let mut header = [0_u8; 2]; stream.read_exact(&mut header)?; @@ -79,7 +83,11 @@ fn read_masked_frame(stream: &mut TcpStream, expected_opcode: u8) -> io::Result< for (index, byte) in payload.iter_mut().enumerate() { *byte ^= mask[index % mask.len()]; } - Ok(payload) + Ok((mask, payload)) +} + +fn read_masked_frame(stream: &mut TcpStream, expected_opcode: u8) -> io::Result> { + read_masked_frame_with_key(stream, expected_opcode).map(|(_mask, payload)| payload) } fn read_masked_text(stream: &mut TcpStream) -> io::Result { @@ -238,6 +246,35 @@ fn established_stream_round_trips_pong_and_unmasked_server_text() -> Result<(), Ok(()) } +#[test] +fn established_stream_uses_os_random_masks_for_text_and_pong() -> Result<(), Box> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let local_addr = listener.local_addr()?; + let server = thread::spawn(move || -> io::Result { + let (mut stream, _) = listener.accept()?; + read_opening_request(&mut stream)?; + stream.write_all(OPENING_RESPONSE)?; + let (text_mask, text_payload) = read_masked_frame_with_key(&mut stream, 0x1)?; + let (pong_mask, pong_payload) = read_masked_frame_with_key(&mut stream, 0x0a)?; + Ok((text_mask, text_payload, pong_mask, pong_payload)) + }); + + let endpoint = format!("ws://{local_addr}/session/{SESSION_ID}"); + let established = establish(&endpoint)?; + let established = established + .write_text_frame_with_random_masking_key("random-text", Duration::from_millis(500))?; + let _established = established + .write_pong_frame_with_random_masking_key(b"random-pong", Duration::from_millis(500))?; + + let (text_mask, text_payload, pong_mask, pong_payload) = server + .join() + .map_err(|_| io::Error::other("random-mask test server panicked"))??; + assert_ne!(text_mask, pong_mask); + assert_eq!(text_payload, b"random-text"); + assert_eq!(pong_payload, b"random-pong"); + Ok(()) +} + #[test] fn established_stream_rejects_payloads_above_reviewed_bounds() -> Result<(), Box> { for (text_case, payload_bytes, maximum_bytes) in [ diff --git a/docs/doctoring.md b/docs/doctoring.md index 23fa97613..4f4bee95e 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -14,7 +14,7 @@ WAI-ARIA 1.2 defines host-language `role` values as a token list: user agents sp UTS #39 Revision 32 is the current Unicode security-mechanisms standard and marks Default_Ignorable and bidirectional format characters as restricted in identifier profiles. UAX #9 defines the bidirectional format controls that can reorder displayed protocol text. UTR #36 Revision 15 remains a stabilized historical security-considerations report; its identifier recommendations are superseded by UTS #39 rather than cited as current normative profile rules. OriginWeave therefore rejects the reviewed format-character set in roles, shared identifiers, and registry external identifiers, and rejects those same characters inside accessible names while still allowing ordinary U+0020 spaces. -RFC 6455 carries the WebSocket opening handshake over HTTP/1.1, and RFC 9110 permits `obs-text` octets (`%x80-FF`) in field values while retaining ASCII field-name and delimiter syntax. RFC 6455 also specifies that unknown opening-handshake header fields are ignored. OriginWeave therefore treats unknown extension-field values as opaque compatibility data rather than requiring the entire opening response to be UTF-8, while keeping the authority-bearing `Upgrade`, `Connection`, and `Sec-WebSocket-Accept` checks fail closed: opaque replacement material cannot satisfy the reviewed ASCII token or exact accept-value contracts. Ignoring an unknown field never grants browser, network, secret, approval, or Agent authority. +RFC 6455 carries the WebSocket opening handshake over HTTP/1.1, and RFC 9110 permits `obs-text` octets (`%x80-FF`) in field values while retaining ASCII field-name and delimiter syntax. RFC 6455 also specifies that unknown opening-handshake header fields are ignored. OriginWeave therefore treats unknown extension-field values as opaque compatibility data rather than requiring the entire opening response to be UTF-8, while keeping the authority-bearing `Upgrade`, `Connection`, and `Sec-WebSocket-Accept` checks fail closed: opaque replacement material cannot satisfy the reviewed ASCII token or exact accept-value contracts. Ignoring an unknown field never grants browser, network, secret, approval, or Agent authority. RFC 6455 section 5.3 also requires a fresh, unpredictable client masking key per frame. The verified stream therefore offers OS-CSPRNG-backed text and Pong writes while retaining caller-injected keys only for deterministic fixtures; entropy failure emits no frame and diagnostic rendering never exposes key bytes. ### Browser origin equivalence