Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
25bcc84
test(network): require freshness at socket use
seonghobae Aug 10, 2026
43ff003
fix(network): reauthorize resolution at socket use
seonghobae Aug 10, 2026
623e098
test(network): assert destination freshness at socket use
seonghobae Aug 10, 2026
2783d71
fix(network): fail closed on delayed fresh-plan use
seonghobae Aug 10, 2026
492f74c
test(network): prove all fresh-plan connect paths expire
seonghobae Aug 10, 2026
ec81031
docs(changelog): record socket-use freshness recheck
seonghobae Aug 10, 2026
910f1cf
test(network): reject socket port origin drift
seonghobae Aug 15, 2026
c7634c6
fix(network): bind fresh socket plan to origin port
seonghobae Aug 15, 2026
cb57e2f
test(network): bind freshness fixtures to origin ports
seonghobae Aug 15, 2026
4588380
style(network): apply pinned rustfmt output
seonghobae Aug 15, 2026
6277c67
test(network): keep origin-port helper panic-free
seonghobae Aug 15, 2026
1f504ed
test(network): cover canonical-port parameter rejection
seonghobae Aug 15, 2026
f8ef916
test(network): cover origin-port helper without dead error closure
seonghobae Aug 15, 2026
3f5f4a6
style(network): format fresh resolution regression
seonghobae Aug 15, 2026
1055ad0
test(network): remove synthetic coverage branch
seonghobae Aug 15, 2026
c972d67
docs(changelog): record origin-port socket authority
seonghobae Aug 15, 2026
5a410cb
test(network): cover HTTPS IPv6 origin port authority
seonghobae Aug 15, 2026
2b6495d
test(network): require typed origin port mismatch
seonghobae Aug 15, 2026
d5ac949
fix(network): distinguish origin port authority mismatch
seonghobae Aug 15, 2026
faa7932
fix(network): return typed origin port mismatch
seonghobae Aug 15, 2026
3739602
test(network): cover origin port mismatch error contract
seonghobae Aug 15, 2026
221bd05
test(network): close exact coverage gaps
seonghobae Aug 15, 2026
143394a
test(network): cover origin port error contract in unit crate
seonghobae Aug 15, 2026
8b8a5b4
merge: align socket-use freshness with current prerequisite
seonghobae Aug 15, 2026
1560d75
merge: align origin-port authority with current prerequisite
seonghobae Aug 15, 2026
0d0fbba
test(network): preserve zero-port input error precedence
seonghobae Aug 16, 2026
25315b2
fix(network): reject zero port before origin binding
seonghobae Aug 16, 2026
347cf6a
docs(changelog): preserve zero-port input classification
seonghobae Aug 16, 2026
8d27a44
test(network): reject malformed settings before authority checks
seonghobae Aug 16, 2026
f118833
fix(network): validate settings before authority checks
seonghobae Aug 16, 2026
0504951
style(network): apply canonical validation import formatting
seonghobae Aug 16, 2026
63576fb
test(network): cover fresh-plan validation upper bounds
seonghobae Aug 16, 2026
035488e
test(network): cover plan-admission freshness interval
seonghobae Aug 16, 2026
e277957
style(network): apply canonical rustfmt to freshness regression
seonghobae Aug 16, 2026
8030c6d
chore(network): realign socket-use freshness with current prerequisite
seonghobae Aug 16, 2026
a332f08
fix(network): anchor freshness before plan construction
seonghobae Aug 17, 2026
9f43774
merge(network): align origin-port authority with current freshness pr…
seonghobae Aug 17, 2026
c3734fa
fix(network): preserve current prerequisite while binding origin port
seonghobae Aug 17, 2026
6f6c926
test(network): cover compatibility anchor without dead error closure
seonghobae Aug 17, 2026
c4a6107
test(network): remove uncovered setup closures from compatibility proof
seonghobae Aug 17, 2026
d62435f
style(network): apply canonical rustfmt to compatibility proof
seonghobae Aug 17, 2026
47742ca
test(network): keep compatibility proof closure-free and fallible
seonghobae Aug 17, 2026
5f138df
test(network): remove uncovered pattern assertion from freshness proof
seonghobae Aug 17, 2026
76f94b0
test(network): satisfy strict Clippy in freshness proof
seonghobae Aug 17, 2026
fb387a0
style(network): apply canonical Rust formatting
seonghobae Aug 17, 2026
1f839f2
test(network): avoid forbidden panic in freshness regression
seonghobae Aug 17, 2026
cab6b43
chore(stack): merge current socket-planning prerequisite
seonghobae Aug 25, 2026
f2629f4
chore(network): restack origin port binding
seonghobae Sep 4, 2026
c28b149
test(tls): use explicit socket freshness time
seonghobae Sep 4, 2026
6714e47
chore(network): restack origin port binding
seonghobae Sep 4, 2026
14cd1bd
chore(network): restack on active freshness planner
seonghobae Sep 4, 2026
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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions crates/originweave-network/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 { .. }
Expand All @@ -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,
Expand Down Expand Up @@ -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 { .. }
Expand Down
195 changes: 186 additions & 9 deletions crates/originweave-network/src/fresh_connection.rs
Original file line number Diff line number Diff line change
@@ -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.
///
Expand All @@ -18,6 +41,7 @@ pub struct FreshConnectionPlan {
resolution_approved_at: Duration,
resolution_valid_until: Duration,
resolution_authorized_at: Duration,
authorized_instant: Instant,
}

impl FreshConnectionPlan {
Expand All @@ -29,12 +53,53 @@ impl FreshConnectionPlan {
connect_timeout: Duration,
maximum_attempts: u8,
) -> Result<Self, NetworkError> {
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<Self, NetworkError> {
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,
Expand All @@ -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,
})
}

Expand All @@ -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<DirectTcpConnection, NetworkError> {
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<DirectTcpConnection, NetworkError> {
/// `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<DirectTcpConnection, NetworkError> {
if current_time < self.resolution_authorized_at {
return Err(NetworkError::DestinationNotApproved {
socket_address: self.socket_address,
Expand All @@ -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::<DestinationError>())
.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);
}
}
Loading
Loading