diff --git a/CHANGELOG.md b/CHANGELOG.md index f747adeae..cf7785636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- Restored pinned Rust formatting for the default HTTP/HTTPS port regression while preserving all accepted-port and mismatched-port assertions and the existing freshness policy. +- Corrected the direct-connection ADR and research notes to describe the public freshness-checked API, its trusted monotonic clock contract, and revalidation before socket I/O. - Refreshed the product-gap queue to 126 open pull requests (54 ready, 72 draft) after #190, #188, #185, #192, #182, #184, #115, #181, #116, #117, #118, #183, #114, #127, #112, #109, #186, #110, #108, #111, #174, and #113 were merged into their immediate stacked prerequisites. PRs #147, #146, #145, #144, #143, #142, #141, #139, #136, #132, #129, and #128 moved to ready after exact-head checks and thread review; these are queue-consolidation results, not protected-main shipment. ### Added @@ -25,6 +27,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Fail-closed resolved-destination policy with IPv4/IPv6 special-purpose and reviewed cloud-platform endpoint classification, IPv4-mapped canonicalization, explicit class grants, non-empty origin-bound DNS snapshots capped at 256 resolver addresses, concrete connection pinning, DNS-set expansion detection, and per-hop redirect reauthorization. - Bounded resolution-freshness authority with trusted monotonic approval time, capped non-zero validity, half-open use windows, non-expanding revalidation, and credential-free authorization timestamps. - Direct-only `originweave-network` TCP boundary with explicit canonical `SocketAddr` authority, zero IPv6 flow and scope metadata unless separately modeled, a non-cloneable single-use plan, a 30-second per-attempt timeout ceiling, at most four attempts, exact `peer_addr` verification before stream exposure, and no hostname re-resolution or ambient proxy inheritance. +- Fresh-resolution network adapter that consumes a validated `FreshResolutionSnapshot` plus caller-supplied trusted monotonic time before constructing the existing exact-socket `ConnectionPlan`, retains credential-free approval/validity/authorization timestamps, and preserves the original direct-connect validation and single-use stream boundary. - Authenticated `originweave-tls` service-identity boundary that consumes an existing verified TCP stream, requires exact TLS-origin and transport-origin equality, derives RFC 9525 DNS or literal-IP reference identity only from the canonical HTTPS origin, validates WebPKI with explicit roots and fixed time, permits only TLS 1.2 and TLS 1.3, and never reconnects or resolves. - Bounded TLS policy for total handshake time, ALPN identifiers, trust-root count and bytes, and server-presented certificate count and bytes, with explicit optional-versus-required ALPN behavior and `NotConfigured` revocation evidence. - Deterministic TLS revocation-material freshness authority with a strict signed `thisUpdate`→`nextUpdate` half-open window and typed invalid-window, not-yet-valid, and stale failures, without claiming OCSP/CRL acquisition, cryptographic validation, or certificate revocation status. @@ -70,6 +73,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Security +- Bound every direct TCP socket port to the effective port of its approved logical origin, preventing an origin-approved IP address from becoming authority for another service on the same host. - Explicit proxy server identifiers require ASCII decimal port tokens before numeric range parsing, preventing Rust-specific leading-plus spellings from widening proxy authority. - Raw page content cannot become a trusted instruction. - Raw secrets are rejected and secret-capable actions require an opaque broker handle. @@ -102,4 +106,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/crates/originweave-core/src/lib.rs b/crates/originweave-core/src/lib.rs index e33a7e7e5..a71af1c1f 100644 --- a/crates/originweave-core/src/lib.rs +++ b/crates/originweave-core/src/lib.rs @@ -15,6 +15,7 @@ use std::net::{Ipv4Addr, Ipv6Addr}; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Origin { canonical: String, + port: u16, } impl Origin { @@ -54,11 +55,15 @@ impl Origin { return Err(OriginError::InsecureRemoteOrigin); } let normalized_port = normalize_default_port(&scheme, port); + let effective_port = normalized_port.unwrap_or(if scheme == "https" { 443 } else { 80 }); let canonical = match normalized_port { Some(port_number) => format!("{scheme}://{host}:{port_number}"), None => format!("{scheme}://{host}"), }; - Ok(Self { canonical }) + Ok(Self { + canonical, + port: effective_port, + }) } /// Return the normalized origin string. @@ -90,6 +95,12 @@ impl Origin { }; &authority[host_start..host_end] } + + /// Return the effective origin port, including the scheme default. + #[must_use] + pub const fn port(&self) -> u16 { + self.port + } } impl fmt::Display for Origin { diff --git a/crates/originweave-core/tests/contracts.rs b/crates/originweave-core/tests/contracts.rs index 45f5e5633..7d8d7d9ed 100644 --- a/crates/originweave-core/tests/contracts.rs +++ b/crates/originweave-core/tests/contracts.rs @@ -34,8 +34,12 @@ fn origin_accepts_secure_and_loopback_origins() { assert_eq!(secure_ipv6.as_str(), "https://[2001:db8::1]"); assert_eq!(secure.scheme(), "https"); assert_eq!(secure.host(), "example.com"); + assert_eq!(secure.port(), 443); + assert_eq!(secure_custom.port(), 8443); assert_eq!(localhost.scheme(), "http"); assert_eq!(localhost.host(), "localhost"); + assert_eq!(localhost.port(), 80); + assert_eq!(localhost_custom.port(), 8080); assert_eq!(ipv4.host(), "127.0.0.1"); assert_eq!(ipv6.host(), "::1"); assert_eq!(secure_ipv6.host(), "2001:db8::1"); diff --git a/crates/originweave-destination/src/resolution.rs b/crates/originweave-destination/src/resolution.rs index 45620e6cd..fade9d7a9 100644 --- a/crates/originweave-destination/src/resolution.rs +++ b/crates/originweave-destination/src/resolution.rs @@ -371,6 +371,17 @@ impl FreshResolutionSnapshot { }) } + /// Return the validated untimed snapshot underlying this freshness authority. + /// + /// Callers that use this view must still enforce freshness separately. It is + /// exposed so a transport planner can reuse the existing canonical socket + /// validation only after [`FreshResolutionSnapshot::authorize_connection`] + /// succeeds for the same operation. + #[must_use] + pub const fn resolution_snapshot(&self) -> &ResolutionSnapshot { + &self.snapshot + } + /// Return the logical origin whose DNS answer was approved. #[must_use] pub const fn origin(&self) -> &Origin { diff --git a/crates/originweave-network/src/connection.rs b/crates/originweave-network/src/connection.rs index bbd34c5a7..224fbf64c 100644 --- a/crates/originweave-network/src/connection.rs +++ b/crates/originweave-network/src/connection.rs @@ -52,6 +52,13 @@ impl ConnectionPlan { if socket_address.port() == 0 { return Err(NetworkError::InvalidPort); } + let origin_port = resolution.origin().port(); + if socket_address.port() != origin_port { + return Err(NetworkError::OriginPortMismatch { + socket_port: socket_address.port(), + origin_port, + }); + } if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT { return Err(NetworkError::InvalidConnectTimeout { connect_timeout, @@ -273,6 +280,13 @@ impl SocketConnectionEvidence { pub enum NetworkError { /// The requested destination port was zero. InvalidPort, + /// The requested socket port did not match the approved logical origin. + OriginPortMismatch { + /// The rejected socket port. + socket_port: u16, + /// The effective port bound to the logical origin. + origin_port: u16, + }, /// The timeout was zero or exceeded [`MAX_CONNECT_TIMEOUT`]. InvalidConnectTimeout { /// The rejected timeout. @@ -351,6 +365,7 @@ impl NetworkError { Self::PeerInspectionFailed { attempt_number, .. } | Self::PeerMismatch { attempt_number, .. } => Some(*attempt_number), Self::InvalidPort + | Self::OriginPortMismatch { .. } | Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } | Self::DestinationNotApproved { .. } @@ -363,6 +378,13 @@ impl fmt::Display for NetworkError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidPort => formatter.write_str("connection port must be within 1..=65535"), + Self::OriginPortMismatch { + socket_port, + origin_port, + } => write!( + formatter, + "connection port {socket_port} does not match origin port {origin_port}", + ), Self::InvalidConnectTimeout { connect_timeout, maximum_timeout, @@ -436,6 +458,7 @@ impl std::error::Error for NetworkError { | Self::ConnectionFailed { source, .. } | Self::PeerInspectionFailed { source, .. } => Some(source), Self::InvalidPort + | Self::OriginPortMismatch { .. } | Self::InvalidConnectTimeout { .. } | Self::InvalidAttemptCount { .. } | Self::NonCanonicalSocketAddress { .. } @@ -533,18 +556,18 @@ mod tests { client } - fn loopback_snapshot() -> ResolutionSnapshot { + fn loopback_snapshot(port: u16) -> ResolutionSnapshot { ResolutionSnapshot::approve( - Origin::parse("http://localhost").expect("loopback origin"), + Origin::parse(&format!("http://localhost:{port}")).expect("loopback origin"), [IpAddr::V4(Ipv4Addr::LOCALHOST)], &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), ) .expect("managed loopback snapshot") } - fn ipv6_loopback_snapshot() -> ResolutionSnapshot { + fn ipv6_loopback_snapshot(port: u16) -> ResolutionSnapshot { ResolutionSnapshot::approve( - Origin::parse("http://[::1]").expect("IPv6 loopback origin"), + Origin::parse(&format!("http://[::1]:{port}")).expect("IPv6 loopback origin"), [IpAddr::V6(Ipv6Addr::LOCALHOST)], &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), ) @@ -553,7 +576,7 @@ mod tests { fn plan(maximum_attempts: u8) -> ConnectionPlan { ConnectionPlan::new( - &loopback_snapshot(), + &loopback_snapshot(requested_socket().port()), requested_socket(), Duration::from_secs(2), maximum_attempts, @@ -705,7 +728,7 @@ mod tests { #[test] fn validation_errors_cover_every_public_contract() { - let snapshot = loopback_snapshot(); + let snapshot = loopback_snapshot(80); let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 80); let validation_errors = [ ConnectionPlan::new( @@ -733,6 +756,13 @@ mod tests { MAX_CONNECTION_ATTEMPTS + 1, ) .expect_err("excessive attempts must fail"), + ConnectionPlan::new( + &snapshot, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 81), + Duration::from_secs(1), + 1, + ) + .expect_err("origin-port mismatch must fail"), ]; for error in validation_errors { assert!(!error.to_string().is_empty()); @@ -740,7 +770,7 @@ mod tests { assert_eq!(error.attempt_count(), None); } - let denied_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443); + let denied_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 80); let denied = ConnectionPlan::new(&snapshot, denied_socket, Duration::from_secs(1), 1) .expect_err("address absent from snapshot must fail"); assert!(denied.to_string().contains("not approved")); @@ -750,7 +780,7 @@ mod tests { let mapped = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 1); let noncanonical = ConnectionPlan::new( &snapshot, - SocketAddr::new(IpAddr::V6(mapped), 443), + SocketAddr::new(IpAddr::V6(mapped), 80), Duration::from_secs(1), 1, ) @@ -759,7 +789,7 @@ mod tests { assert!(noncanonical.source().is_none()); assert_eq!(noncanonical.attempt_count(), None); - let ipv6_snapshot = ipv6_loopback_snapshot(); + let ipv6_snapshot = ipv6_loopback_snapshot(443); let canonical_ipv6_socket = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 443, 0, 0)); assert!( @@ -815,8 +845,9 @@ mod tests { stream.write_all(b"ok").expect("server response must write"); }); - let origin = Origin::parse("http://localhost").expect("loopback origin"); - let snapshot = loopback_snapshot(); + let origin = + Origin::parse(&format!("http://localhost:{}", socket.port())).expect("loopback origin"); + let snapshot = loopback_snapshot(socket.port()); let connection = ConnectionPlan::new(&snapshot, socket, Duration::from_secs(1), 1) .expect("plan must validate") .connect() @@ -853,10 +884,15 @@ mod tests { let socket = listener.local_addr().expect("reserved address"); drop(listener); - let error = ConnectionPlan::new(&loopback_snapshot(), socket, Duration::from_secs(1), 3) - .expect("plan must validate") - .connect() - .expect_err("closed loopback port must fail"); + let error = ConnectionPlan::new( + &loopback_snapshot(socket.port()), + socket, + Duration::from_secs(1), + 3, + ) + .expect("plan must validate") + .connect() + .expect_err("closed loopback port must fail"); assert_eq!(error.attempt_count(), Some(3)); assert!(error.source().is_some()); diff --git a/crates/originweave-network/src/fresh_connection.rs b/crates/originweave-network/src/fresh_connection.rs new file mode 100644 index 000000000..f4c77764d --- /dev/null +++ b/crates/originweave-network/src/fresh_connection.rs @@ -0,0 +1,98 @@ +use std::net::SocketAddr; +use std::time::Duration; + +use originweave_destination::{DestinationError, FreshResolutionSnapshot}; + +use crate::connection::{ConnectionPlan, DirectTcpConnection, NetworkError}; + +/// A single-use direct connection plan authorized by a fresh resolution window. +/// +/// This adapter composes the destination crate's monotonic freshness authority +/// with the existing exact-socket connection planner. It performs no DNS lookup, +/// wall-clock read, proxy selection, TLS, HTTP, browser control, or persistence. +#[derive(Debug)] +pub struct FreshConnectionPlan { + connection_plan: ConnectionPlan, + resolution: FreshResolutionSnapshot, + socket_address: SocketAddr, + resolution_approved_at: Duration, + resolution_valid_until: Duration, + resolution_authorized_at: Duration, +} + +impl FreshConnectionPlan { + /// Validate freshness and one exact direct-connection request without I/O. + pub fn new( + resolution: &FreshResolutionSnapshot, + current_time: Duration, + socket_address: SocketAddr, + connect_timeout: Duration, + maximum_attempts: u8, + ) -> Result { + let fresh_evidence = resolution + .authorize_connection(socket_address.ip(), current_time) + .map_err(|source| NetworkError::DestinationNotApproved { + socket_address, + source, + })?; + let connection_plan = ConnectionPlan::new( + resolution.resolution_snapshot(), + socket_address, + connect_timeout, + maximum_attempts, + )?; + Ok(Self { + connection_plan, + resolution: resolution.clone(), + socket_address, + resolution_approved_at: fresh_evidence.resolution_approved_at(), + resolution_valid_until: fresh_evidence.resolution_valid_until(), + resolution_authorized_at: fresh_evidence.authorized_at(), + }) + } + + /// Return the trusted monotonic time at which resolution was approved. + #[must_use] + pub const fn resolution_approved_at(&self) -> Duration { + self.resolution_approved_at + } + + /// Return the exclusive end of the resolution authority window. + #[must_use] + pub const fn resolution_valid_until(&self) -> Duration { + self.resolution_valid_until + } + + /// Return the trusted monotonic time used to authorize this plan. + #[must_use] + pub const fn resolution_authorized_at(&self) -> Duration { + self.resolution_authorized_at + } + + /// Open the exact approved socket only while resolution authority is still fresh. + /// + /// `current_time` must come from the same caller-owned trusted monotonic clock + /// domain used when this plan was created. Freshness is re-authorized immediately + /// before socket I/O so a plan cannot be created inside the validity window and + /// replayed after expiry. A supplied time earlier than the plan's own authorization + /// checkpoint fails closed instead of permitting clock regression. The plan remains + /// single-use because this method consumes `self`. + pub fn connect(self, current_time: Duration) -> Result { + if current_time < self.resolution_authorized_at { + return Err(NetworkError::DestinationNotApproved { + socket_address: self.socket_address, + source: DestinationError::ResolutionUseBeforeApproval { + approved_at: self.resolution_authorized_at, + current_time, + }, + }); + } + self.resolution + .authorize_connection(self.socket_address.ip(), current_time) + .map_err(|source| NetworkError::DestinationNotApproved { + socket_address: self.socket_address, + source, + })?; + self.connection_plan.connect() + } +} diff --git a/crates/originweave-network/src/lib.rs b/crates/originweave-network/src/lib.rs index d5b26c1c3..3a5bbab0a 100644 --- a/crates/originweave-network/src/lib.rs +++ b/crates/originweave-network/src/lib.rs @@ -3,13 +3,25 @@ //! 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. +//! +//! Direct planning from an untimed resolution snapshot is intentionally not a +//! public capability. External callers must cross the fresh-resolution boundary +//! before they can obtain socket authority. +//! +//! ```compile_fail +//! use originweave_network::ConnectionPlan; +//! +//! fn stale_resolution_bypass(_: Option) {} +//! ``` #![forbid(unsafe_code)] #![deny(missing_docs)] mod connection; +mod fresh_connection; pub use connection::{ - ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, - NetworkError, SocketConnectionEvidence, + DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, NetworkError, + SocketConnectionEvidence, }; +pub use fresh_connection::FreshConnectionPlan; diff --git a/crates/originweave-network/tests/fresh_resolution_plan.rs b/crates/originweave-network/tests/fresh_resolution_plan.rs new file mode 100644 index 000000000..3451b109b --- /dev/null +++ b/crates/originweave-network/tests/fresh_resolution_plan.rs @@ -0,0 +1,230 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{ + AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, +}; +use originweave_network::{FreshConnectionPlan, NetworkError}; + +fn fresh_loopback_snapshot(port: u16) -> Result { + let origin = Origin::parse(&format!("http://localhost:{port}")) + .map_err(|error| format!("loopback origin fixture is invalid: {error:?}"))?; + FreshResolutionSnapshot::approve( + origin, + [IpAddr::V4(Ipv4Addr::LOCALHOST)], + &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), + Duration::from_secs(10), + Duration::from_secs(5), + ) + .map_err(|error| format!("fresh loopback snapshot is invalid: {error}")) +} + +#[test] +fn connection_plan_requires_a_current_fresh_resolution_authority() -> Result<(), String> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .map_err(|error| format!("bind loopback listener: {error}"))?; + let socket = listener + .local_addr() + .map_err(|error| format!("read loopback listener address: {error}"))?; + let snapshot = fresh_loopback_snapshot(socket.port())?; + + let plan = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::from_secs(1), + 1, + ) + .map_err(|error| format!("authorize fresh connection plan: {error}"))?; + + assert_eq!(plan.resolution_approved_at(), Duration::from_secs(10)); + assert_eq!(plan.resolution_valid_until(), Duration::from_secs(15)); + assert_eq!(plan.resolution_authorized_at(), Duration::from_secs(12)); + + let connection = plan + .connect(Duration::from_secs(12)) + .map_err(|error| format!("connect fresh loopback plan: {error}"))?; + assert_eq!(connection.evidence().requested_socket(), socket); + assert_eq!(connection.evidence().observed_peer(), socket); + Ok(()) +} + +#[test] +fn expired_resolution_cannot_create_a_connection_plan() -> Result<(), String> { + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080); + let snapshot = fresh_loopback_snapshot(socket.port())?; + + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(15), + socket, + Duration::from_secs(1), + 1, + ); + + assert!(matches!( + result, + Err(NetworkError::DestinationNotApproved { + source: DestinationError::ResolutionApprovalExpired { + valid_until, + current_time, + }, + .. + }) if valid_until == Duration::from_secs(15) + && current_time == Duration::from_secs(15) + )); + Ok(()) +} + +#[test] +fn plan_must_still_be_fresh_at_actual_socket_use() -> Result<(), String> { + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + let snapshot = fresh_loopback_snapshot(socket.port())?; + let plan = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::from_secs(1), + 1, + ) + .map_err(|error| format!("authorize fresh connection plan: {error}"))?; + + let result = plan.connect(Duration::from_secs(15)); + assert!(matches!( + result, + Err(NetworkError::DestinationNotApproved { + source: DestinationError::ResolutionApprovalExpired { + valid_until, + current_time, + }, + .. + }) if valid_until == Duration::from_secs(15) + && current_time == Duration::from_secs(15) + )); + Ok(()) +} + +#[test] +fn socket_use_time_cannot_regress_before_plan_authorization() -> Result<(), String> { + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + let snapshot = fresh_loopback_snapshot(socket.port())?; + let plan = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::from_secs(1), + 1, + ) + .map_err(|error| format!("authorize fresh connection plan: {error}"))?; + + let result = plan.connect(Duration::from_secs(11)); + assert!(matches!( + result, + Err(NetworkError::DestinationNotApproved { + source: DestinationError::ResolutionUseBeforeApproval { + approved_at, + current_time, + }, + .. + }) if approved_at == Duration::from_secs(12) + && current_time == Duration::from_secs(11) + )); + Ok(()) +} + +#[test] +fn fresh_resolution_still_requires_valid_connection_parameters() -> Result<(), String> { + let invalid_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + let snapshot = fresh_loopback_snapshot(80)?; + + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + invalid_socket, + Duration::from_secs(1), + 1, + ); + + assert!(matches!(result, Err(NetworkError::InvalidPort))); + Ok(()) +} + +#[test] +fn connection_port_must_match_the_approved_logical_origin() -> Result<(), String> { + let origin = Origin::parse("http://localhost:8080") + .map_err(|error| format!("loopback origin fixture is invalid: {error:?}"))?; + let snapshot = FreshResolutionSnapshot::approve( + origin, + [IpAddr::V4(Ipv4Addr::LOCALHOST)], + &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), + Duration::from_secs(10), + Duration::from_secs(5), + ) + .map_err(|error| format!("fresh loopback snapshot is invalid: {error}"))?; + let mismatched_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081); + + assert!(matches!( + FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + mismatched_socket, + Duration::from_secs(1), + 1, + ), + Err(NetworkError::OriginPortMismatch { + socket_port: 8081, + origin_port: 8080, + }) + )); + Ok(()) +} + +#[test] +fn default_origin_ports_are_enforced_for_http_and_https() -> Result<(), String> { + let policy = DestinationPolicy::from_allowed_classes([AddressClass::Loopback]); + let cases = [ + ("http://localhost", 80_u16, 81_u16), + ("https://localhost", 443_u16, 444_u16), + ]; + + for (origin_text, expected_port, mismatched_port) in cases { + let origin = Origin::parse(origin_text) + .map_err(|error| format!("default-port origin fixture is invalid: {error:?}"))?; + let snapshot = FreshResolutionSnapshot::approve( + origin, + [IpAddr::V4(Ipv4Addr::LOCALHOST)], + &policy, + Duration::from_secs(10), + Duration::from_secs(5), + ) + .map_err(|error| format!("fresh default-port snapshot is invalid: {error}"))?; + let matching_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), expected_port); + let mismatched_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), mismatched_port); + + assert!( + FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + matching_socket, + Duration::from_secs(1), + 1, + ) + .is_ok() + ); + assert!(matches!( + FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + mismatched_socket, + Duration::from_secs(1), + 1, + ), + Err(NetworkError::OriginPortMismatch { + socket_port, + origin_port, + }) if socket_port == mismatched_port && origin_port == expected_port + )); + } + Ok(()) +} diff --git a/crates/originweave-tls/tests/handshake_deadline.rs b/crates/originweave-tls/tests/handshake_deadline.rs index 4f5a06faa..bb2d9c722 100644 --- a/crates/originweave-tls/tests/handshake_deadline.rs +++ b/crates/originweave-tls/tests/handshake_deadline.rs @@ -5,8 +5,8 @@ use std::thread; use std::time::Duration; use originweave_core::Origin; -use originweave_destination::{AddressClass, DestinationPolicy, ResolutionSnapshot}; -use originweave_network::ConnectionPlan; +use originweave_destination::{AddressClass, DestinationPolicy, FreshResolutionSnapshot}; +use originweave_network::FreshConnectionPlan; use originweave_tls::{ AlpnRequirement, TlsClientPolicy, TlsError, TlsHandshakePlan, TrustBundleIdentifier, TrustRootBundle, @@ -15,21 +15,32 @@ use rustls::pki_types::UnixTime; const TRUSTED_TIME_SECONDS: u64 = 1_767_225_600; const HARD_DEADLINE: Duration = Duration::from_nanos(1); +const RESOLUTION_APPROVED_AT: Duration = Duration::from_secs(10); +const RESOLUTION_VALIDITY: Duration = Duration::from_secs(5); +const RESOLUTION_AUTHORIZED_AT: Duration = Duration::from_secs(12); fn direct_connection( origin: &Origin, socket_address: SocketAddr, ) -> originweave_network::DirectTcpConnection { - let snapshot = ResolutionSnapshot::approve( + let snapshot = FreshResolutionSnapshot::approve( origin.clone(), [socket_address.ip()], &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), + RESOLUTION_APPROVED_AT, + RESOLUTION_VALIDITY, ) .expect("managed loopback resolution must be approved"); - ConnectionPlan::new(&snapshot, socket_address, Duration::from_secs(1), 1) - .expect("direct connection plan") - .connect() - .expect("loopback TCP connection") + FreshConnectionPlan::new( + &snapshot, + RESOLUTION_AUTHORIZED_AT, + socket_address, + Duration::from_secs(1), + 1, + ) + .expect("fresh direct connection plan") + .connect(RESOLUTION_AUTHORIZED_AT) + .expect("loopback TCP connection") } #[test] diff --git a/crates/originweave-tls/tests/handshake_integration.rs b/crates/originweave-tls/tests/handshake_integration.rs index bb758cdcb..4f8b6ceff 100644 --- a/crates/originweave-tls/tests/handshake_integration.rs +++ b/crates/originweave-tls/tests/handshake_integration.rs @@ -6,8 +6,8 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; use originweave_core::Origin; -use originweave_destination::{AddressClass, DestinationPolicy, ResolutionSnapshot}; -use originweave_network::{ConnectionPlan, DirectTcpConnection}; +use originweave_destination::{AddressClass, DestinationPolicy, FreshResolutionSnapshot}; +use originweave_network::{DirectTcpConnection, FreshConnectionPlan}; use originweave_tls::TlsReferenceIdentity; use originweave_tls::{ AlpnRequirement, NegotiatedAlpn, RevocationStatus, TlsClientPolicy, TlsError, TlsHandshakePlan, @@ -22,6 +22,9 @@ use rustls::{ServerConfig, ServerConnection, SupportedProtocolVersion}; const TRUSTED_TIME_SECONDS: u64 = 1_767_225_600; const TEST_TIMEOUT: Duration = Duration::from_secs(3); +const RESOLUTION_APPROVED_AT: Duration = Duration::from_secs(10); +const RESOLUTION_VALIDITY: Duration = Duration::from_secs(5); +const RESOLUTION_AUTHORIZED_AT: Duration = Duration::from_secs(12); type ServerResult = Result>, String>; @@ -134,16 +137,24 @@ fn origin_for(host: &str, socket_address: SocketAddr) -> Origin { } fn direct_connection(origin: &Origin, socket_address: SocketAddr) -> DirectTcpConnection { - let snapshot = ResolutionSnapshot::approve( + let snapshot = FreshResolutionSnapshot::approve( origin.clone(), [socket_address.ip()], &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), + RESOLUTION_APPROVED_AT, + RESOLUTION_VALIDITY, ) .expect("managed loopback resolution must be approved"); - ConnectionPlan::new(&snapshot, socket_address, Duration::from_secs(2), 1) - .expect("direct connection plan") - .connect() - .expect("loopback TCP connection") + FreshConnectionPlan::new( + &snapshot, + RESOLUTION_AUTHORIZED_AT, + socket_address, + Duration::from_secs(2), + 1, + ) + .expect("fresh direct connection plan") + .connect(RESOLUTION_AUTHORIZED_AT) + .expect("loopback TCP connection") } fn trust_bundle(root_der: Vec, identifier: &str) -> TrustRootBundle { diff --git a/crates/originweave-tls/tests/validity_horizon_integration.rs b/crates/originweave-tls/tests/validity_horizon_integration.rs index 88c61c574..f2049073e 100644 --- a/crates/originweave-tls/tests/validity_horizon_integration.rs +++ b/crates/originweave-tls/tests/validity_horizon_integration.rs @@ -7,8 +7,8 @@ use std::thread; use std::time::Duration; use originweave_core::Origin; -use originweave_destination::{AddressClass, DestinationPolicy, ResolutionSnapshot}; -use originweave_network::{ConnectionPlan, DirectTcpConnection}; +use originweave_destination::{AddressClass, DestinationPolicy, FreshResolutionSnapshot}; +use originweave_network::{DirectTcpConnection, FreshConnectionPlan}; use originweave_tls::{ AlpnRequirement, LeafValidityHorizon, LeafValidityHorizonError, TlsClientPolicy, TlsError, TlsHandshakePlan, TrustBundleIdentifier, TrustRootBundle, @@ -22,6 +22,9 @@ use rustls::{ServerConfig, ServerConnection}; const TRUSTED_TIME_SECONDS: u64 = 1_767_225_600; const TEST_TIMEOUT: Duration = Duration::from_secs(3); +const RESOLUTION_APPROVED_AT: Duration = Duration::from_secs(10); +const RESOLUTION_VALIDITY: Duration = Duration::from_secs(5); +const RESOLUTION_AUTHORIZED_AT: Duration = Duration::from_secs(12); fn server_material() -> ( Vec, @@ -104,16 +107,24 @@ fn spawn_server() -> (SocketAddr, Vec, thread::JoinHandle } fn direct_connection(origin: &Origin, socket_address: SocketAddr) -> DirectTcpConnection { - let snapshot = ResolutionSnapshot::approve( + let snapshot = FreshResolutionSnapshot::approve( origin.clone(), [socket_address.ip()], &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), + RESOLUTION_APPROVED_AT, + RESOLUTION_VALIDITY, ) .expect("managed loopback resolution must be approved"); - ConnectionPlan::new(&snapshot, socket_address, Duration::from_secs(2), 1) - .expect("direct connection plan") - .connect() - .expect("loopback TCP connection") + FreshConnectionPlan::new( + &snapshot, + RESOLUTION_AUTHORIZED_AT, + socket_address, + Duration::from_secs(2), + 1, + ) + .expect("fresh direct connection plan") + .connect(RESOLUTION_AUTHORIZED_AT) + .expect("loopback TCP connection") } fn test_policy() -> TlsClientPolicy { diff --git a/docs/adr/0005-direct-socket-binding.md b/docs/adr/0005-direct-socket-binding.md index cb3537fb5..9f58ff059 100644 --- a/docs/adr/0005-direct-socket-binding.md +++ b/docs/adr/0005-direct-socket-binding.md @@ -18,12 +18,13 @@ Create an independently reusable `originweave-network` Rust crate with a **direc A caller supplies: -- an existing `ResolutionSnapshot`; +- an existing `FreshResolutionSnapshot`; +- caller-supplied trusted monotonic time from the snapshot's clock domain; - one explicit canonical `SocketAddr`; - a timeout in `1ns..=30s`; - an attempt count in `1..=4`. -`ConnectionPlan::new` rejects port zero, invalid bounds, addresses absent from the snapshot, and IPv4-mapped IPv6 or any other form that differs from the snapshot's canonical address. The plan is non-cloneable and is consumed by `connect`, preventing accidental replay of the same authority. +`FreshConnectionPlan::new` is the public construction boundary. It validates resolution freshness at the supplied trusted monotonic time and delegates exact-socket validation to the private `ConnectionPlan::new` implementation. That implementation rejects port zero, a port different from the effective port of the snapshot's logical origin, invalid bounds, addresses absent from the snapshot, and IPv4-mapped IPv6 or any other form that differs from the snapshot's canonical address. The plan is non-cloneable. `connect(current_time)` consumes it, rejects time regression, and revalidates the resolution's half-open validity window immediately before starting socket I/O, so a plan created while fresh cannot first be used after expiry. Both calls require trusted monotonic time from the same caller-owned clock domain; the adapter does not read a wall clock or authenticate caller-supplied time. The production path calls `TcpStream::connect_timeout` with that exact `SocketAddr`; it never accepts a hostname and never resolves again. After the operating system establishes the stream, the crate calls `peer_addr`. The stream is exposed only when the observed peer matches the requested IP and port exactly. @@ -32,14 +33,17 @@ Connection retries use an explicit conservative allow-list. Only `TimedOut`, `Co ```mermaid sequenceDiagram participant Adapter as Trusted browser-network adapter - participant Snapshot as ResolutionSnapshot - participant Plan as ConnectionPlan + participant Snapshot as FreshResolutionSnapshot + participant Plan as FreshConnectionPlan participant OS as Operating-system TCP stack participant Evidence as SocketConnectionEvidence - Adapter->>Snapshot: authorize_connection(requested_ip) - Snapshot-->>Adapter: canonical address and class - Adapter->>Plan: new(snapshot, exact SocketAddr, bounds) + Adapter->>Plan: new(snapshot, trusted time, exact SocketAddr, bounds) + Plan->>Snapshot: authorize_connection(requested_ip, trusted time) + Snapshot-->>Plan: fresh canonical address and class + Adapter->>Plan: connect(current_time) + Plan->>Plan: reject time regression + Plan->>Snapshot: revalidate freshness before socket I/O Plan->>OS: TcpStream::connect_timeout(exact SocketAddr) OS-->>Plan: established stream or typed I/O failure alt explicit transient failure and attempt remains @@ -124,6 +128,7 @@ The merge gate requires: - a real loopback `TcpListener`/`TcpStream` integration test; - canonical-address, port, timeout, attempt, and destination-policy boundary tests; +- fresh-plan tests for expiry at first use, exclusive expiry, and trusted-time regression; - behavioral tests proving every allow-listed transient error may retry within the bound; - behavioral tests proving representative deterministic errors stop after one attempt and retain their exact source; - deterministic connector tests for timeout, connection failure, peer-inspection failure, and peer mismatch; @@ -135,4 +140,4 @@ The merge gate requires: ## Standards -RFC 9293 defines the current Standards Track TCP specification and identifies a TCP connection by its pair of endpoint sockets. Rust 1.97.1 documents `TcpStream::connect_timeout` as a connection attempt to one supplied `SocketAddr`, `peer_addr` as the established stream's remote socket address, and `io::ErrorKind` as a non-exhaustive classification. OriginWeave uses those properties as the direct transport proof and explicit retry boundary while retaining separate TLS, HTTP, proxy, and Chromium decisions. \ No newline at end of file +RFC 9293 defines the current Standards Track TCP specification and identifies a TCP connection by its pair of endpoint sockets. Rust 1.97.1 documents `TcpStream::connect_timeout` as a connection attempt to one supplied `SocketAddr`, `peer_addr` as the established stream's remote socket address, and `io::ErrorKind` as a non-exhaustive classification. OriginWeave uses those properties as the direct transport proof and explicit retry boundary while retaining separate TLS, HTTP, proxy, and Chromium decisions. diff --git a/docs/doctoring.md b/docs/doctoring.md index ec51daaf3..b1e289e34 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -60,10 +60,16 @@ RFC 9293 consolidates the current Standards Track Transmission Control Protocol Rust 1.97.1 documents `TcpStream::connect_timeout` as attempting a connection to one supplied `SocketAddr`, with a timeout applied to that individual address. Unlike hostname-based connection APIs, this call does not give the adapter another collection of addresses to resolve or select. `TcpStream::peer_addr` reports the remote socket address of an established stream. -OriginWeave therefore creates a separate direct-only network kernel. A non-cloneable plan accepts one canonical `SocketAddr` already authorized by a `ResolutionSnapshot`, rejects port zero and unbounded timeouts or attempts, calls `connect_timeout` with that exact address, and checks `peer_addr` before exposing the stream. Requested and observed peers must match in both IP and port. IPv4-mapped IPv6 is rejected at this layer when the snapshot authorized its canonical IPv4 form. +OriginWeave therefore creates a separate direct-only network kernel. Its public `FreshConnectionPlan` accepts a `FreshResolutionSnapshot`, caller-supplied trusted monotonic time, and one canonical `SocketAddr`. Construction checks freshness; `connect(current_time)` rejects time regression and revalidates the half-open resolution window immediately before starting socket I/O. Both timestamps must come from the same trusted clock domain. The private exact-socket planner requires the socket port to equal the effective port of the snapshot's scheme-host-port origin, rejects port zero and unbounded timeouts or attempts, calls `connect_timeout` with that exact address, and checks `peer_addr` before exposing the stream. Requested and observed peers must match in both IP and port. IPv4-mapped IPv6 is rejected at this layer when the snapshot authorized its canonical IPv4 form. This proof is deliberately narrower than safe browser navigation. It does not validate TLS server names, certificates, certificate chains, or ALPN; it does not authorize a proxy or PAC route; it does not parse HTTP or bound response resources; and it does not prove that Chromium's Network Service consumed the verified stream. Those remain separate merge-gated adapters. TCP peer equality is transport evidence, not application identity. +### Default-port regression quality follow-up + +PR #50 predecessor `e981ac45d0bfcd3906fc64dae5f4490edf39f9e5` added default-origin cases that accept HTTP port 80 and HTTPS port 443 and reject ports 81 and 444 with exact mismatch evidence. All seven focused fresh-resolution tests passed, but Rust 1.97.1 formatting failed on the two socket declarations and the new assertion layout. Applying the pinned formatter preserves those assertions, the explicit non-default-port regression, and every production source file. These cases construct plans without connecting to privileged ports; they prove deterministic port admission, not a completed HTTP/TLS exchange or browser navigation. + +The formatted tree passed Rust 1.97.1 formatting, locked workspace checking and tests, all-target/all-feature Clippy with warnings denied, and rustdoc with warnings denied. All 152 Python contracts and Python compilation passed. The pinned coverage run measured 530 functions, 4,509 lines, 5,441 regions, and 660 branches at 100%; cargo-llvm-cov still emits its unstable branch-option warning, so numeric coverage is not warning-free measurement or release acceptance. Hosted checks must run again on the resulting commit. + ### TLS service identity RFC 9846 is the current Standards Track TLS 1.3 specification and obsoletes RFC 8446. It defines a secure channel over a reliable, ordered byte stream and explicitly leaves application service-identity interpretation to the integrating protocol. It points application protocols to RFC 9525. RFC 9846 also reiterates that 0-RTT has weaker forward-secrecy and replay properties than ordinary 1-RTT application data. OriginWeave therefore cites RFC 9846 as the current TLS 1.3 authority, permits TLS 1.2 only for application interoperability, prefers TLS 1.3 through rustls ordering, and disables 0-RTT in the first slice. diff --git a/tests/test_network_governance.py b/tests/test_network_governance.py index 2d8b122b7..8b0e221be 100644 --- a/tests/test_network_governance.py +++ b/tests/test_network_governance.py @@ -46,6 +46,16 @@ def test_docs_keep_transport_scope_explicit(self) -> None: self.assertIn("proxy", text.lower()) self.assertIn("Chromium", text) + doctoring = (ROOT / "docs/doctoring.md").read_text(encoding="utf-8") + direct_binding = doctoring.split("### Direct TCP peer binding", 1)[1].split( + "### TLS service identity", 1 + )[0] + for text in (adr, direct_binding): + self.assertIn("FreshConnectionPlan", text) + self.assertIn("FreshResolutionSnapshot", text) + self.assertIn("trusted monotonic time", text) + self.assertIn("connect(current_time)", text) + if __name__ == "__main__": unittest.main()