Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/originweave-network/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
96 changes: 95 additions & 1 deletion crates/originweave-network/src/webdriver_bidi_websocket_frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, getrandom::Error> {
Self::from_random_fill(getrandom::getrandom)
}

fn from_random_fill(
fill_random_bytes: fn(&mut [u8]) -> Result<(), getrandom::Error>,
) -> Result<Self, getrandom::Error> {
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] {
Expand Down Expand Up @@ -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<Self, WebDriverBiDiWebSocketFrameError> {
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
Expand All @@ -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<Self, WebDriverBiDiWebSocketFrameError> {
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 { .. } => {
Expand Down Expand Up @@ -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, .. }
Expand All @@ -469,6 +521,16 @@ impl Error for WebDriverBiDiWebSocketFrameError {
}
}

fn random_masking_key() -> Result<WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketFrameError> {
map_masking_key_generation(WebDriverBiDiWebSocketMaskKey::random())
}

fn map_masking_key_generation(
result: Result<WebDriverBiDiWebSocketMaskKey, getrandom::Error>,
) -> Result<WebDriverBiDiWebSocketMaskKey, WebDriverBiDiWebSocketFrameError> {
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 {
Expand Down Expand Up @@ -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:?}"), "<redacted WebSocket masking key>");
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]);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>, [u8; 4], Vec<u8>);

fn connect(
endpoint: &str,
Expand Down Expand Up @@ -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<Vec<u8>> {
fn read_masked_frame_with_key(
stream: &mut TcpStream,
expected_opcode: u8,
) -> io::Result<([u8; 4], Vec<u8>)> {
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let mut header = [0_u8; 2];
stream.read_exact(&mut header)?;
Expand All @@ -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<Vec<u8>> {
read_masked_frame_with_key(stream, expected_opcode).map(|(_mask, payload)| payload)
}

fn read_masked_text(stream: &mut TcpStream) -> io::Result<String> {
Expand Down Expand Up @@ -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<dyn Error>> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
let local_addr = listener.local_addr()?;
let server = thread::spawn(move || -> io::Result<MaskedFramePair> {
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<dyn Error>> {
for (text_case, payload_bytes, maximum_bytes) in [
Expand Down
2 changes: 1 addition & 1 deletion docs/doctoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading