diff --git a/CHANGELOG.md b/CHANGELOG.md index fb7531297..290a1e7ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,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. +- 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, reauthorizes the same exact address immediately before socket I/O, preserves the direct-connect and single-use boundaries, and anchors compatibility-path elapsed time at the start of plan admission so construction latency cannot widen the freshness window. - 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. @@ -86,6 +86,9 @@ All notable changes to OriginWeave are documented in this file. The format follo - Resolver answers must remain a non-empty subset of the origin-bound approved address set; any newly introduced address fails closed as a possible DNS-rebinding event. - Every redirect rechecks target-origin authority, target-bound resolution, HTTPS downgrade, complete-target cycle state, and hop capacity before policy state changes. - Direct TCP plans reject port zero, zero or excessive timeouts, excessive attempts, unapproved IPs, non-canonical IPv4-mapped IPv6 sockets, and IPv6 flow or scope metadata not represented in destination authority before connection I/O. +- Fresh-resolution direct TCP plans require the requested socket port to equal the canonical origin's effective HTTP or HTTPS port, preventing same-IP authority from widening to a different service. +- Fresh-resolution direct TCP plans reject socket port zero before consulting resolution or origin-port state, preserving `InvalidPort` for malformed input instead of misclassifying it as an authority mismatch. +- Compatibility fresh-resolution plans start their process-local elapsed-time anchor before validation and exact-socket plan construction, so admission work cannot silently extend a short-lived resolution authority window. - Direct connection code accepts only an explicit `SocketAddr`, never a hostname, and does not read proxy environment variables. - Established streams are discarded when peer inspection fails or the observed remote IP or port differs from the approved socket. - TLS accepts only an already verified direct stream, never a hostname or new socket, and requires the TLS origin to match the transport-authority origin exactly. diff --git a/crates/originweave-network/src/connection.rs b/crates/originweave-network/src/connection.rs index bbd34c5a7..1002b4f8e 100644 --- a/crates/originweave-network/src/connection.rs +++ b/crates/originweave-network/src/connection.rs @@ -273,6 +273,13 @@ impl SocketConnectionEvidence { pub enum NetworkError { /// The requested destination port was zero. InvalidPort, + /// The requested socket port did not match the canonical origin's effective port. + OriginPortMismatch { + /// The nonzero socket port requested by the caller. + requested_port: u16, + /// The HTTP or HTTPS port authorized by the canonical origin. + expected_port: u16, + }, /// The timeout was zero or exceeded [`MAX_CONNECT_TIMEOUT`]. InvalidConnectTimeout { /// The rejected timeout. @@ -351,6 +358,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 +371,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 { + requested_port, + expected_port, + } => write!( + formatter, + "socket port {requested_port} does not match canonical origin port {expected_port}", + ), Self::InvalidConnectTimeout { connect_timeout, maximum_timeout, @@ -436,6 +451,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 { .. } diff --git a/crates/originweave-network/src/fresh_connection.rs b/crates/originweave-network/src/fresh_connection.rs index f4c77764d..7d1af709d 100644 --- a/crates/originweave-network/src/fresh_connection.rs +++ b/crates/originweave-network/src/fresh_connection.rs @@ -1,9 +1,32 @@ use std::net::SocketAddr; -use std::time::Duration; +use std::time::{Duration, Instant}; +use originweave_core::Origin; use originweave_destination::{DestinationError, FreshResolutionSnapshot}; -use crate::connection::{ConnectionPlan, DirectTcpConnection, NetworkError}; +use crate::connection::{ + ConnectionPlan, DirectTcpConnection, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, NetworkError, +}; + +fn effective_origin_port(origin: &Origin) -> u16 { + let default_port = match origin.scheme() { + "https" => 443, + _ => 80, + }; + let authority = &origin.as_str()[origin.scheme().len() + 3..]; + let explicit_port = if authority.starts_with('[') { + authority.rsplit_once("]:").map(|(_, port)| port) + } else { + authority.rsplit_once(':').map(|(_, port)| port) + }; + + match explicit_port { + Some(port) => port + .bytes() + .fold(0_u16, |value, digit| value * 10 + u16::from(digit - b'0')), + None => default_port, + } +} /// A single-use direct connection plan authorized by a fresh resolution window. /// @@ -18,6 +41,7 @@ pub struct FreshConnectionPlan { resolution_approved_at: Duration, resolution_valid_until: Duration, resolution_authorized_at: Duration, + authorized_instant: Instant, } impl FreshConnectionPlan { @@ -29,12 +53,53 @@ impl FreshConnectionPlan { connect_timeout: Duration, maximum_attempts: u8, ) -> Result { + let authorization_started_at = Instant::now(); + Self::new_with_authorization_instant( + resolution, + current_time, + socket_address, + connect_timeout, + maximum_attempts, + authorization_started_at, + ) + } + + fn new_with_authorization_instant( + resolution: &FreshResolutionSnapshot, + current_time: Duration, + socket_address: SocketAddr, + connect_timeout: Duration, + maximum_attempts: u8, + authorization_started_at: Instant, + ) -> Result { + if socket_address.port() == 0 { + return Err(NetworkError::InvalidPort); + } + if connect_timeout.is_zero() || connect_timeout > MAX_CONNECT_TIMEOUT { + return Err(NetworkError::InvalidConnectTimeout { + connect_timeout, + maximum_timeout: MAX_CONNECT_TIMEOUT, + }); + } + if maximum_attempts == 0 || maximum_attempts > MAX_CONNECTION_ATTEMPTS { + return Err(NetworkError::InvalidAttemptCount { + attempt_count: maximum_attempts, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }); + } let fresh_evidence = resolution .authorize_connection(socket_address.ip(), current_time) .map_err(|source| NetworkError::DestinationNotApproved { socket_address, source, })?; + let expected_port = effective_origin_port(resolution.origin()); + if socket_address.port() != expected_port { + return Err(NetworkError::OriginPortMismatch { + requested_port: socket_address.port(), + expected_port, + }); + } let connection_plan = ConnectionPlan::new( resolution.resolution_snapshot(), socket_address, @@ -48,6 +113,7 @@ impl FreshConnectionPlan { resolution_approved_at: fresh_evidence.resolution_approved_at(), resolution_valid_until: fresh_evidence.resolution_valid_until(), resolution_authorized_at: fresh_evidence.authorized_at(), + authorized_instant: authorization_started_at, }) } @@ -69,15 +135,30 @@ impl FreshConnectionPlan { self.resolution_authorized_at } + /// Open the exact approved socket using the elapsed monotonic time since plan authorization. + /// + /// This compatibility path anchors a process-local [`Instant`] when the + /// caller-supplied trusted resolution time is admitted. Actual elapsed time + /// is added to that authorization value before socket I/O, so callers that + /// do not supply a second timestamp cannot replay a plan indefinitely after + /// its freshness window expires. New authority-bearing call sites should use + /// [`FreshConnectionPlan::connect_at`] with their trusted monotonic clock. + pub fn connect(self) -> Result { + let current_time = self + .resolution_authorized_at + .saturating_add(self.authorized_instant.elapsed()); + self.connect_at(current_time) + } + /// 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 { + /// `current_time` must come from the same 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 then + /// replayed after that authority expires. A time earlier than the plan's own + /// authorization checkpoint fails closed. The plan remains single-use because + /// this method consumes `self`. + pub fn connect_at(self, current_time: Duration) -> Result { if current_time < self.resolution_authorized_at { return Err(NetworkError::DestinationNotApproved { socket_address: self.socket_address, @@ -96,3 +177,99 @@ impl FreshConnectionPlan { self.connection_plan.connect() } } + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use std::time::{Duration, Instant}; + + use originweave_core::Origin; + use originweave_destination::{ + AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, + }; + + use super::{FreshConnectionPlan, NetworkError, effective_origin_port}; + + #[test] + fn effective_origin_port_covers_default_and_explicit_authorities() { + let fixtures = [ + ("http://localhost", 80), + ("https://example.com", 443), + ("http://localhost:8080", 8080), + ("http://[::1]:8443", 8443), + ]; + + for (origin, expected_port) in fixtures { + let actual_port = Origin::parse(origin).map(|parsed| effective_origin_port(&parsed)); + assert_eq!(actual_port.ok(), Some(expected_port)); + } + } + + #[test] + fn compatibility_anchor_includes_time_spent_before_plan_completion() { + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + let origin = Origin::parse("http://localhost:9"); + assert_eq!( + origin.as_ref().map(Origin::as_str), + Ok("http://localhost:9") + ); + + for origin in origin.into_iter() { + let snapshot = FreshResolutionSnapshot::approve( + origin, + [IpAddr::V4(Ipv4Addr::LOCALHOST)], + &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), + Duration::from_secs(10), + Duration::from_millis(1), + ); + assert!(snapshot.is_ok()); + + for snapshot in snapshot.into_iter() { + let authorization_started_at = Instant::now(); + std::thread::sleep(Duration::from_millis(5)); + let plan = FreshConnectionPlan::new_with_authorization_instant( + &snapshot, + Duration::from_secs(10), + socket, + Duration::from_secs(1), + 1, + authorization_started_at, + ); + assert!(plan.is_ok()); + + for plan in plan.into_iter() { + let result = plan.connect(); + assert!(result.is_err()); + + for error in result.err().into_iter() { + let actual = std::error::Error::source(&error) + .and_then(|source| source.downcast_ref::()) + .map(std::mem::discriminant); + let expected = Some(std::mem::discriminant( + &DestinationError::ResolutionApprovalExpired { + valid_until: Duration::ZERO, + current_time: Duration::ZERO, + }, + )); + assert_eq!(actual, expected); + } + } + } + } + } + + #[test] + fn origin_port_mismatch_error_is_deterministic_and_source_free() { + let error = NetworkError::OriginPortMismatch { + requested_port: 8080, + expected_port: 80, + }; + + assert_eq!( + error.to_string(), + "socket port 8080 does not match canonical origin port 80" + ); + assert!(std::error::Error::source(&error).is_none()); + assert_eq!(error.attempt_count(), None); + } +} diff --git a/crates/originweave-network/tests/fresh_resolution_plan.rs b/crates/originweave-network/tests/fresh_resolution_plan.rs index 5aa315569..93f38c063 100644 --- a/crates/originweave-network/tests/fresh_resolution_plan.rs +++ b/crates/originweave-network/tests/fresh_resolution_plan.rs @@ -1,14 +1,16 @@ -use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; +use std::error::Error; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener}; +use std::thread; use std::time::Duration; use originweave_core::Origin; use originweave_destination::{ AddressClass, DestinationError, DestinationPolicy, FreshResolutionSnapshot, }; -use originweave_network::{FreshConnectionPlan, NetworkError}; +use originweave_network::{FreshConnectionPlan, MAX_CONNECTION_ATTEMPTS, NetworkError}; -fn fresh_loopback_snapshot() -> Result { - let origin = Origin::parse("http://localhost") +fn fresh_loopback_snapshot_for_port(port: u16) -> Result { + let origin = Origin::parse(&format!("http://localhost:{port}")) .map_err(|error| format!("loopback origin fixture is invalid: {error:?}"))?; FreshResolutionSnapshot::approve( origin, @@ -20,14 +22,27 @@ fn fresh_loopback_snapshot() -> Result { .map_err(|error| format!("fresh loopback snapshot is invalid: {error}")) } +fn fresh_default_loopback_snapshot() -> Result { + let origin = Origin::parse("http://localhost") + .map_err(|error| format!("default 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 default loopback snapshot is invalid: {error}")) +} + #[test] fn connection_plan_requires_a_current_fresh_resolution_authority() -> Result<(), String> { - let snapshot = fresh_loopback_snapshot()?; 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_for_port(socket.port())?; let plan = FreshConnectionPlan::new( &snapshot, @@ -43,7 +58,7 @@ fn connection_plan_requires_a_current_fresh_resolution_authority() -> Result<(), assert_eq!(plan.resolution_authorized_at(), Duration::from_secs(12)); let connection = plan - .connect(Duration::from_secs(12)) + .connect_at(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); @@ -52,8 +67,8 @@ fn connection_plan_requires_a_current_fresh_resolution_authority() -> Result<(), #[test] fn expired_resolution_cannot_create_a_connection_plan() -> Result<(), String> { - let snapshot = fresh_loopback_snapshot()?; let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080); + let snapshot = fresh_loopback_snapshot_for_port(socket.port())?; let result = FreshConnectionPlan::new( &snapshot, @@ -79,8 +94,8 @@ fn expired_resolution_cannot_create_a_connection_plan() -> Result<(), String> { #[test] fn plan_must_still_be_fresh_at_actual_socket_use() -> Result<(), String> { - let snapshot = fresh_loopback_snapshot()?; let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + let snapshot = fresh_loopback_snapshot_for_port(socket.port())?; let plan = FreshConnectionPlan::new( &snapshot, Duration::from_secs(12), @@ -90,7 +105,7 @@ fn plan_must_still_be_fresh_at_actual_socket_use() -> Result<(), String> { ) .map_err(|error| format!("authorize fresh connection plan: {error}"))?; - let result = plan.connect(Duration::from_secs(15)); + let result = plan.connect_at(Duration::from_secs(15)); assert!(matches!( result, Err(NetworkError::DestinationNotApproved { @@ -107,8 +122,8 @@ fn plan_must_still_be_fresh_at_actual_socket_use() -> Result<(), String> { #[test] fn socket_use_time_cannot_regress_before_plan_authorization() -> Result<(), String> { - let snapshot = fresh_loopback_snapshot()?; let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + let snapshot = fresh_loopback_snapshot_for_port(socket.port())?; let plan = FreshConnectionPlan::new( &snapshot, Duration::from_secs(12), @@ -118,7 +133,7 @@ fn socket_use_time_cannot_regress_before_plan_authorization() -> Result<(), Stri ) .map_err(|error| format!("authorize fresh connection plan: {error}"))?; - let result = plan.connect(Duration::from_secs(11)); + let result = plan.connect_at(Duration::from_secs(11)); assert!(matches!( result, Err(NetworkError::DestinationNotApproved { @@ -133,19 +148,217 @@ fn socket_use_time_cannot_regress_before_plan_authorization() -> Result<(), Stri Ok(()) } +#[test] +fn compatibility_connect_path_expires_from_real_monotonic_elapsed_time() -> Result<(), String> { + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 9); + let origin = Origin::parse("http://localhost:9") + .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_millis(1), + ) + .map_err(|error| format!("short-lived snapshot is invalid: {error}"))?; + let plan = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(10), + socket, + Duration::from_secs(1), + 1, + ) + .map_err(|error| format!("authorize short-lived connection plan: {error}"))?; + + thread::sleep(Duration::from_millis(5)); + let result = plan.connect(); + assert!(matches!( + result, + Err(NetworkError::DestinationNotApproved { + source: DestinationError::ResolutionApprovalExpired { .. }, + .. + }) + )); + Ok(()) +} + #[test] fn fresh_resolution_still_requires_valid_connection_parameters() -> Result<(), String> { - let snapshot = fresh_loopback_snapshot()?; - let invalid_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + let snapshot = fresh_default_loopback_snapshot()?; + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 80); + + let zero_timeout = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::ZERO, + 1, + ); + assert!(matches!( + zero_timeout, + Err(NetworkError::InvalidConnectTimeout { + connect_timeout, + .. + }) if connect_timeout == Duration::ZERO + )); + + let zero_attempts = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::from_secs(1), + 0, + ); + assert!(matches!( + zero_attempts, + Err(NetworkError::InvalidAttemptCount { + attempt_count: 0, + .. + }) + )); + + let excessive_attempts = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::from_secs(1), + MAX_CONNECTION_ATTEMPTS + 1, + ); + assert!(matches!( + excessive_attempts, + Err(NetworkError::InvalidAttemptCount { + attempt_count, + maximum_attempts: MAX_CONNECTION_ATTEMPTS, + }) if attempt_count == MAX_CONNECTION_ATTEMPTS + 1 + )); + Ok(()) +} + +#[test] +fn fresh_resolution_rejects_noncanonical_mapped_socket() -> Result<(), String> { + let snapshot = fresh_default_loopback_snapshot()?; + let mapped_loopback = Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 1); + let socket = SocketAddr::new(IpAddr::V6(mapped_loopback), 80); let result = FreshConnectionPlan::new( &snapshot, Duration::from_secs(12), - invalid_socket, + socket, Duration::from_secs(1), 1, ); - assert!(matches!(result, Err(NetworkError::InvalidPort))); + assert!(matches!( + result, + Err(NetworkError::NonCanonicalSocketAddress { + socket_address, + canonical_address, + }) if socket_address == socket + && canonical_address == IpAddr::V4(Ipv4Addr::LOCALHOST) + )); + Ok(()) +} + +#[test] +fn connection_plan_rejects_socket_port_that_changes_default_origin() -> Result<(), String> { + let snapshot = fresh_default_loopback_snapshot()?; + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080); + + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::from_secs(1), + 1, + ); + let error = match result { + Err(error) => error, + Ok(_plan) => return Err("origin-port drift unexpectedly produced a connection plan".into()), + }; + + assert!(matches!( + &error, + NetworkError::OriginPortMismatch { + requested_port, + expected_port, + } if *requested_port == 8080 && *expected_port == 80 + )); + assert_eq!( + error.to_string(), + "socket port 8080 does not match canonical origin port 80" + ); + assert!(error.source().is_none()); + assert_eq!(error.attempt_count(), None); + Ok(()) +} + +#[test] +fn connection_plan_enforces_default_https_port_for_ipv6_origin() -> Result<(), String> { + let origin = Origin::parse("https://[::1]") + .map_err(|error| format!("default HTTPS IPv6 origin fixture is invalid: {error:?}"))?; + let snapshot = FreshResolutionSnapshot::approve( + origin, + [IpAddr::V6(Ipv6Addr::LOCALHOST)], + &DestinationPolicy::from_allowed_classes([AddressClass::Loopback]), + Duration::from_secs(10), + Duration::from_secs(5), + ) + .map_err(|error| format!("fresh HTTPS IPv6 snapshot is invalid: {error}"))?; + + let authorized = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 443), + Duration::from_secs(1), + 1, + ); + assert!(authorized.is_ok()); + + let wrong_port = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 444), + Duration::from_secs(1), + 1, + ); + assert!(matches!( + wrong_port, + Err(NetworkError::OriginPortMismatch { + requested_port: 444, + expected_port: 443, + }) + )); + Ok(()) +} + +#[test] +fn connection_plan_rejects_socket_port_that_changes_explicit_origin() -> Result<(), String> { + let origin = Origin::parse("http://localhost:8080") + .map_err(|error| format!("explicit-port 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 explicit-port snapshot is invalid: {error}"))?; + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081); + + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::from_secs(1), + 1, + ); + + assert!(matches!( + result, + Err(NetworkError::OriginPortMismatch { + requested_port: 8081, + expected_port: 8080, + }) + )); Ok(()) } diff --git a/crates/originweave-network/tests/fresh_resolution_port_error.rs b/crates/originweave-network/tests/fresh_resolution_port_error.rs new file mode 100644 index 000000000..1d38010c4 --- /dev/null +++ b/crates/originweave-network/tests/fresh_resolution_port_error.rs @@ -0,0 +1,114 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use originweave_core::Origin; +use originweave_destination::{AddressClass, DestinationPolicy, FreshResolutionSnapshot}; +use originweave_network::{ + FreshConnectionPlan, MAX_CONNECT_TIMEOUT, MAX_CONNECTION_ATTEMPTS, NetworkError, +}; + +fn loopback_snapshot() -> Result { + let origin = Origin::parse("http://localhost") + .map_err(|error| format!("default loopback origin 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 zero_socket_port_remains_invalid_input_before_origin_mismatch() -> Result<(), String> { + let snapshot = loopback_snapshot()?; + let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + socket, + Duration::from_secs(1), + 1, + ); + + assert!(matches!(result, Err(NetworkError::InvalidPort))); + Ok(()) +} + +#[test] +fn malformed_connection_settings_fail_before_origin_port_authority() -> Result<(), String> { + let snapshot = loopback_snapshot()?; + let wrong_port_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 81); + + for invalid_timeout in [ + Duration::ZERO, + MAX_CONNECT_TIMEOUT.saturating_add(Duration::from_nanos(1)), + ] { + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + wrong_port_socket, + invalid_timeout, + 1, + ); + assert!(matches!( + result, + Err(NetworkError::InvalidConnectTimeout { .. }) + )); + } + + for invalid_attempt_count in [0, MAX_CONNECTION_ATTEMPTS + 1] { + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + wrong_port_socket, + Duration::from_secs(1), + invalid_attempt_count, + ); + assert!(matches!( + result, + Err(NetworkError::InvalidAttemptCount { .. }) + )); + } + Ok(()) +} + +#[test] +fn malformed_connection_settings_fail_before_resolution_membership() -> Result<(), String> { + let snapshot = loopback_snapshot()?; + let unapproved_socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 80); + + for invalid_timeout in [ + Duration::ZERO, + MAX_CONNECT_TIMEOUT.saturating_add(Duration::from_nanos(1)), + ] { + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + unapproved_socket, + invalid_timeout, + 1, + ); + assert!(matches!( + result, + Err(NetworkError::InvalidConnectTimeout { .. }) + )); + } + + for invalid_attempt_count in [0, MAX_CONNECTION_ATTEMPTS + 1] { + let result = FreshConnectionPlan::new( + &snapshot, + Duration::from_secs(12), + unapproved_socket, + Duration::from_secs(1), + invalid_attempt_count, + ); + assert!(matches!( + result, + Err(NetworkError::InvalidAttemptCount { .. }) + )); + } + Ok(()) +} diff --git a/crates/originweave-tls/tests/handshake_deadline.rs b/crates/originweave-tls/tests/handshake_deadline.rs index bb2d9c722..f2f3b6d03 100644 --- a/crates/originweave-tls/tests/handshake_deadline.rs +++ b/crates/originweave-tls/tests/handshake_deadline.rs @@ -39,7 +39,7 @@ fn direct_connection( 1, ) .expect("fresh direct connection plan") - .connect(RESOLUTION_AUTHORIZED_AT) + .connect_at(RESOLUTION_AUTHORIZED_AT) .expect("loopback TCP connection") } diff --git a/crates/originweave-tls/tests/handshake_integration.rs b/crates/originweave-tls/tests/handshake_integration.rs index 4f8b6ceff..b275eac8b 100644 --- a/crates/originweave-tls/tests/handshake_integration.rs +++ b/crates/originweave-tls/tests/handshake_integration.rs @@ -153,7 +153,7 @@ fn direct_connection(origin: &Origin, socket_address: SocketAddr) -> DirectTcpCo 1, ) .expect("fresh direct connection plan") - .connect(RESOLUTION_AUTHORIZED_AT) + .connect_at(RESOLUTION_AUTHORIZED_AT) .expect("loopback TCP connection") } diff --git a/crates/originweave-tls/tests/validity_horizon_integration.rs b/crates/originweave-tls/tests/validity_horizon_integration.rs index f2049073e..35c725a42 100644 --- a/crates/originweave-tls/tests/validity_horizon_integration.rs +++ b/crates/originweave-tls/tests/validity_horizon_integration.rs @@ -123,7 +123,7 @@ fn direct_connection(origin: &Origin, socket_address: SocketAddr) -> DirectTcpCo 1, ) .expect("fresh direct connection plan") - .connect(RESOLUTION_AUTHORIZED_AT) + .connect_at(RESOLUTION_AUTHORIZED_AT) .expect("loopback TCP connection") } diff --git a/docs/traceability/resolution-freshness-authority.md b/docs/traceability/resolution-freshness-authority.md index edb91e4bb..ba37d7411 100644 --- a/docs/traceability/resolution-freshness-authority.md +++ b/docs/traceability/resolution-freshness-authority.md @@ -48,7 +48,7 @@ PR #54 follows #50 because a plan authorized within the resolution window could The accepted active-branch remedy keeps the admitted freshness snapshot with the non-cloneable single-use plan and revalidates it at the socket-use boundary. `connect_at(current_time)` is the explicit deterministic path and rejects both expiry and an authorization-time regression using the existing destination error taxonomy. The compatibility `connect()` path does not freeze the old authorization timestamp: it anchors a process-local monotonic `Instant` at plan construction, adds actual elapsed time to the admitted authorization time, and delegates to `connect_at`, so delayed legacy callers cannot replay stale authority indefinitely. -The regression suite proves explicit success, deadline expiry, trusted-time regression, unchanged connection-parameter validation, and expiry of the compatibility path with a deliberately short real monotonic interval. Current exact head `ec81031c537f2b662910c1ce78c7ae0e0bfc9c1e` passes CI run `31418337788`. This remains active-PR evidence and does not add DNS lookup, a wall-clock authority, proxy/PAC, or a resolver service. +The regression suite proves explicit success, deadline expiry, trusted-time regression, unchanged connection-parameter validation, and expiry of the compatibility path with a deliberately short real monotonic interval. A later stack convergence exposed three TLS integration fixtures that still called the compatibility method with an explicit time: exact-head CI run `32858865789` failed both workspace compilation and coverage on that signature mismatch. The fixtures now use the explicit trusted-time socket-use path, restoring the intended cross-crate authority contract. This remains active-PR evidence and does not add DNS lookup, a wall-clock authority, proxy/PAC, or a resolver service; the repaired head still requires fresh exact-head CI and coverage evidence. ## Deterministic authority contract