From 4f824881fd75ea424647632674ac0f1544042e1e Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Tue, 13 Oct 2026 15:21:43 +0800 Subject: [PATCH 1/2] [CTX-0073] feat(tls): peer leaf SPKI accessor for pin consumers New peer_leaf_spki_der(host,port,capability,provider,timeout) in bitty-network-tls: direct TcpStream + rustls handshake, leaf SPKI via existing x509 slicer, capability-gated, zero new deps. Re-exported through the http-gated tls glob; offline fixtures + ignored live test. Priority: P1 | Area: network | Labels: feat,area:network,P1 Milestone: v0.1.0 | RFC: n/a | Task: CTX-0073 --- crates/bitty-network-tls/src/lib.rs | 2 + crates/bitty-network-tls/src/peer.rs | 510 +++++++++++++++++++++++ crates/bitty-network-tls/src/provider.rs | 22 + crates/bitty-network-tls/src/x509.rs | 182 +++++++- 4 files changed, 714 insertions(+), 2 deletions(-) create mode 100644 crates/bitty-network-tls/src/peer.rs diff --git a/crates/bitty-network-tls/src/lib.rs b/crates/bitty-network-tls/src/lib.rs index 78a814f..ab37658 100644 --- a/crates/bitty-network-tls/src/lib.rs +++ b/crates/bitty-network-tls/src/lib.rs @@ -108,6 +108,8 @@ #![forbid(unsafe_code)] +mod peer; +pub use peer::{peer_leaf_spki_der, DEFAULT_PEER_TIMEOUT}; mod provider; pub use provider::{canonical_host, TlsProvider, TlsSelection, TlsTransport}; diff --git a/crates/bitty-network-tls/src/peer.rs b/crates/bitty-network-tls/src/peer.rs new file mode 100644 index 0000000..95f760a --- /dev/null +++ b/crates/bitty-network-tls/src/peer.rs @@ -0,0 +1,510 @@ +//! Peer leaf `SubjectPublicKeyInfo` accessor for SPKI-pin consumers. +//! +//! [`peer_leaf_spki_der`] opens one direct TLS connection to a host and +//! returns the DER bytes of the peer leaf certificate's +//! `subjectPublicKeyInfo`, which the caller hashes into an SPKI pin. +//! Hashing stays consumer-side: this module slices bytes, it never hashes. +//! +//! The fetch path, in order: +//! +//! 1. Capability first: [`NetworkCapability::check_host_port`] runs before +//! anything else is touched. Deny-all yields [`NetworkError::Offline`], an +//! allowlist miss yields the typed [`NetworkError::Denied`], and no socket +//! is opened in either case. +//! 2. Trust and identity resolve through the provider +//! ([`TlsProvider::peer_client_config`](crate::TlsProvider)), so the +//! handshake verifies exactly what a backend handshake would verify, +//! including the custom-root validity re-check. A policy refusal is a +//! typed [`NetworkError::Tls`]. +//! 3. One DNS resolution, one TCP connect to the first resolved address, one +//! TLS handshake with the provider's `ClientConfig`. No HTTP bytes are +//! sent after the handshake, and nothing is retried: any failure fails +//! closed instead of being attempted again. +//! 4. The leaf certificate (the first peer certificate) is sliced with the +//! existing X.509 DER reader ([`x509::leaf_spki_der`]), the socket is +//! closed, and the bytes are returned. +//! +//! Timeouts: `timeout` bounds DNS, TCP, and the handshake together, mirroring +//! `DEFAULT_REQUEST_TIMEOUT` of the http backend; [`DEFAULT_PEER_TIMEOUT`] applies +//! when the caller passes `None`. An expired deadline always surfaces +//! [`NetworkError::Timeout`]. Post-capability transport failures fail closed +//! as [`NetworkError::Offline`], mirroring the backends, which cannot vouch +//! for reachability and so report unreachable. +//! +//! [`NetworkError::Offline`]: bitty_network_api::NetworkError::Offline +//! [`NetworkError::Denied`]: bitty_network_api::NetworkError::Denied +//! [`NetworkError::Timeout`]: bitty_network_api::NetworkError::Timeout +//! [`NetworkError::Tls`]: bitty_network_api::NetworkError::Tls + +use std::net::{Shutdown, SocketAddr, TcpStream, ToSocketAddrs}; +use std::str::FromStr; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use bitty_network_api::{NetworkCapability, NetworkError}; +use rustls::pki_types::ServerName; +use rustls::ClientConnection; + +use super::provider::TlsProvider; +use super::x509; + +/// Default bound for one peer fetch (DNS, TCP, and TLS handshake together) +/// when the caller passes no timeout. +/// +/// Thirty seconds mirrors `DEFAULT_REQUEST_TIMEOUT` of the http backend and the +/// WebSocket handshake bound: generous for a loopback or nearby fetch, while +/// callers with a tighter budget pass their own deadline. +pub const DEFAULT_PEER_TIMEOUT: Duration = Duration::from_secs(30); + +/// Smallest remaining budget any step still waits for, so a nearly-exhausted +/// deadline still produces this call's own typed error instead of a zero wait. +const MIN_REMAINING: Duration = Duration::from_millis(1); + +/// Poll interval while waiting for the resolver thread, so an expired +/// deadline is noticed promptly without busy-waiting. +const DNS_WAIT_POLL: Duration = Duration::from_millis(1); + +/// Bound for the best-effort `close_notify` flush after a successful fetch. +/// +/// The fetch already succeeded when this applies, so the flush must stay +/// short: long enough for a loopback or nearby peer, never a second budget. +const PEER_CLOSE_FLUSH: Duration = Duration::from_secs(2); + +/// Fetch one peer's leaf `SubjectPublicKeyInfo` DER for SPKI-pin consumers. +/// +/// Opens a direct TLS connection to `host:port`, completes exactly one +/// handshake with the provider's client configuration, and returns the leaf +/// certificate's `subjectPublicKeyInfo` bytes. The caller hashes them; no +/// hash is computed here and no new dependency is introduced. +/// +/// Capability gating runs before any socket work; DNS resolves once, TCP +/// connects once to the first resolved address, and the handshake runs once. +/// No HTTP bytes follow the handshake and nothing is retried. `timeout` +/// bounds the whole fetch, or [`DEFAULT_PEER_TIMEOUT`] when `None`. +pub fn peer_leaf_spki_der( + host: &str, + port: u16, + capability: &NetworkCapability, + provider: &TlsProvider, + timeout: Option, +) -> Result, NetworkError> { + // Capability first: no resolve, no provider selection, and no socket when + // the grant does not cover this host and port. + capability.check_host_port(host, port)?; + let after = timeout.unwrap_or(DEFAULT_PEER_TIMEOUT); + let started = Instant::now(); + let absolute = deadline_from(started, after); + let config = provider + .peer_client_config(host) + .map_err(|reason| NetworkError::Tls { reason })?; + let name = server_name(host)?; + let address = resolve_first(host, port, after, started)?; + if Instant::now() >= absolute { + return Err(NetworkError::Timeout { after }); + } + let mut stream = TcpStream::connect_timeout(&address, remaining(after, started)) + .map_err(|error| map_io(&error, after))?; + // One handshake, driven to completion inside the same budget. Socket + // timeouts are refreshed every round so trickling records cannot extend + // the operation past its deadline. + let mut connection = ClientConnection::new(config, name).map_err(|_| NetworkError::Offline)?; + while connection.is_handshaking() { + if Instant::now() >= absolute { + return Err(NetworkError::Timeout { after }); + } + let round = remaining(after, started); + stream + .set_read_timeout(Some(round)) + .map_err(|_| NetworkError::Offline)?; + stream + .set_write_timeout(Some(round)) + .map_err(|_| NetworkError::Offline)?; + connection + .complete_io(&mut stream) + .map_err(|error| map_io(&error, after))?; + } + let leaf = connection + .peer_certificates() + .and_then(|certificates| certificates.first()) + .ok_or(NetworkError::Offline)?; + let spki = x509::leaf_spki_der(leaf).map_err(|_| NetworkError::Offline)?; + close_quietly(&mut connection, &stream); + Ok(spki) +} + +/// TLS server name for `host`: an IP literal stays an IP, anything else is +/// canonicalized exactly the way identity selection canonicalizes it, so SNI +/// and selection can never disagree about what "the same host" means. +/// +/// A host with no canonical form fails closed as +/// [`NetworkError::Offline`], mirroring how the backends treat a target they +/// cannot parse: there is no name to offer and no trust decision to report. +fn server_name(host: &str) -> Result, NetworkError> { + let canonical = super::provider::canonical_host(host).map_err(|_| NetworkError::Offline)?; + if let Ok(address) = std::net::IpAddr::from_str(&canonical) { + return Ok(ServerName::IpAddress(address.into())); + } + ServerName::try_from(canonical).map_err(|_| NetworkError::Offline) +} + +/// Resolve `host:port` to its first address, waiting at most for the budget +/// still left out of `after`. +/// +/// Resolution runs on a helper thread because the OS lookup has no deadline +/// of its own; a timed-out lookup may linger on that thread until the OS +/// returns, exactly like the WebSocket dial path, while this call already +/// reports [`NetworkError::Timeout`]. An unresolvable target fails closed as +/// [`NetworkError::Offline`]. +/// +/// [`NetworkError::Timeout`]: bitty_network_api::NetworkError::Timeout +/// [`NetworkError::Offline`]: bitty_network_api::NetworkError::Offline +fn resolve_first( + host: &str, + port: u16, + after: Duration, + started: Instant, +) -> Result { + let absolute = deadline_from(started, after); + if Instant::now() >= absolute { + return Err(NetworkError::Timeout { after }); + } + let authority = format_authority(host, port); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let result: std::io::Result> = authority + .as_str() + .to_socket_addrs() + .map(|addresses| addresses.collect()); + let _ = sender.send(result); + }); + loop { + if Instant::now() >= absolute { + return Err(NetworkError::Timeout { after }); + } + match receiver.recv_timeout(remaining(after, started).min(DNS_WAIT_POLL)) { + Ok(Ok(addresses)) => { + if let Some(address) = addresses.into_iter().next() { + return Ok(address); + } + return Err(NetworkError::Offline); + } + Ok(Err(_)) => return Err(NetworkError::Offline), + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => return Err(NetworkError::Offline), + } + } +} + +/// `host:port` authority text, bracketing IPv6 literals so the resolver and +/// the capability check split the same authority. +fn format_authority(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +/// Absolute instant `duration` after `start`. +fn deadline_from(start: Instant, duration: Duration) -> Instant { + start + duration +} + +/// Remaining fetch budget: the deadline minus elapsed, never below +/// [`MIN_REMAINING`] so a nearly-exhausted budget still produces the call's +/// own typed error. +fn remaining(after: Duration, started: Instant) -> Duration { + deadline_from(started, after) + .saturating_duration_since(Instant::now()) + .max(MIN_REMAINING) +} + +/// Map a blocking I/O failure to its typed error: expired deadlines become +/// [`NetworkError::Timeout`] carrying the effective deadline, everything else +/// fails closed as [`NetworkError::Offline`]. +/// +/// [`NetworkError::Timeout`]: bitty_network_api::NetworkError::Timeout +/// [`NetworkError::Offline`]: bitty_network_api::NetworkError::Offline +fn map_io(error: &std::io::Error, after: Duration) -> NetworkError { + if error.kind() == std::io::ErrorKind::TimedOut + || error.kind() == std::io::ErrorKind::WouldBlock + { + NetworkError::Timeout { after } + } else { + NetworkError::Offline + } +} + +/// Best-effort TLS shutdown after a successful fetch. +/// +/// The fetch already succeeded, so no failure here may fail the call: every +/// error is ignored. The `close_notify` flush is bounded by +/// [`PEER_CLOSE_FLUSH`], then the socket is shut down and dropped by the +/// caller. Skipping the flush on error still closes the TCP connection; the +/// peer observes a normal teardown either way. +fn close_quietly(connection: &mut ClientConnection, stream: &TcpStream) { + connection.send_close_notify(); + let _ = stream.set_read_timeout(Some(PEER_CLOSE_FLUSH)); + let _ = stream.set_write_timeout(Some(PEER_CLOSE_FLUSH)); + // The buffered alert either leaves within the flush bound or it does not; + // both outcomes close the socket below. + if let Ok(mut owned) = stream.try_clone() { + let _ = connection.complete_io(&mut owned); + } + let _ = stream.shutdown(Shutdown::Both); +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::net::TcpListener; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + /// Short bound for fail-closed tests: long enough for loopback, short + /// enough that a broken gate (one that dials) cannot stall the suite. + const TEST_TIMEOUT: Duration = Duration::from_secs(5); + + /// A loopback listener that is never accepted from: any accepted + /// connection would prove a socket was opened. + fn unaccepted_listener() -> TcpListener { + let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback listener"); + listener + .set_nonblocking(true) + .expect("nonblocking test listener"); + listener + } + + /// True when the listener holds a pending (abandoned) connection, which + /// proves a socket was opened despite the denial. + fn connection_attempted(listener: &TcpListener) -> bool { + listener.accept().is_ok() + } + + /// A denied fetch opens no socket: the capability gate runs before any + /// resolve, selection, or connect, so a deny-all grant leaves the + /// listener's backlog empty. + #[test] + fn gate_denies_before_connect() { + let listener = unaccepted_listener(); + let port = listener.local_addr().expect("listener port").port(); + let error = peer_leaf_spki_der( + "127.0.0.1", + port, + &NetworkCapability::offline(), + &TlsProvider::native_only(), + Some(TEST_TIMEOUT), + ) + .expect_err("deny-all denies the fetch"); + assert_eq!(error, NetworkError::Offline); + assert!( + !connection_attempted(&listener), + "no socket may be opened after a capability denial" + ); + } + + /// An allowlist miss on the host denies before connect, even for an + /// unroutable documentation address that no socket could reach: the typed + /// denial (not a timeout or unreachable) proves no dial was attempted. + #[test] + fn gate_denies_an_unlisted_host_before_connect() { + let error = peer_leaf_spki_der( + "192.0.2.1", + 443, + &NetworkCapability::offline().with_domain("allowed.example"), + &TlsProvider::native_only(), + Some(TEST_TIMEOUT), + ) + .expect_err("an unlisted host is denied"); + assert_eq!( + error, + NetworkError::Denied { + domain: "192.0.2.1".to_owned(), + } + ); + } + + /// An allowlist miss on the port denies before connect: the domain is + /// allowed but only on another port, so the fetch fails with the typed + /// denial and the listener sees no connection. + #[test] + fn gate_denies_an_unlisted_port_before_connect() { + let listener = unaccepted_listener(); + let port = listener.local_addr().expect("listener port").port(); + let other_port = if port == 1 { 2 } else { 1 }; + let error = peer_leaf_spki_der( + "127.0.0.1", + port, + &NetworkCapability::offline().with_domain_ports("127.0.0.1", [other_port]), + &TlsProvider::native_only(), + Some(TEST_TIMEOUT), + ) + .expect_err("an unlisted port is denied"); + assert_eq!( + error, + NetworkError::Denied { + domain: "127.0.0.1".to_owned(), + } + ); + assert!( + !connection_attempted(&listener), + "no socket may be opened after a capability denial" + ); + } + + /// An allowed fetch to a refused port fails closed as unreachable, not as + /// a timeout and never with a trust category: the gate passed, the dial + /// did not. + #[test] + fn refused_port_fails_closed_as_offline() { + let port = { + let held = TcpListener::bind("127.0.0.1:0").expect("a loopback listener"); + held.local_addr().expect("listener port").port() + }; + let error = peer_leaf_spki_der( + "127.0.0.1", + port, + &NetworkCapability::offline().with_domain("127.0.0.1"), + &TlsProvider::native_only(), + Some(TEST_TIMEOUT), + ) + .expect_err("a refused port fails the fetch"); + assert_eq!(error, NetworkError::Offline); + } + + /// The remaining-budget floor never returns zero, so a nearly-exhausted + /// deadline still produces this call's own typed error. + #[test] + fn remaining_never_returns_zero() { + let floor = remaining(Duration::from_nanos(1), Instant::now()); + assert!(floor >= MIN_REMAINING); + } + + /// Manual-only live handshake: one real TLS handshake against a loopback + /// rustls server, asserting the fetched SPKI equals the server leaf's + /// SPKI and that the server observed zero application bytes (no HTTP). + /// + /// Never runs in CI (`#[ignore]`): it binds loopback sockets and depends + /// on the platform root store loading for the custom-CA probe, so it is + /// for operator verification only. No secrets: the CA and leaf are minted + /// in memory at runtime and never committed. + #[test] + #[ignore = "manual-only live TLS handshake against a loopback server; never run in CI"] + fn live_handshake_returns_the_server_leaf_spki() { + use rcgen::{ + BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, KeyPair, + }; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + use rustls::ServerConnection; + use std::net::IpAddr; + + const LIVE_TIMEOUT: Duration = Duration::from_secs(10); + + // A runtime CA admitted as a custom anchor, valid across any manual + // run date in the next two decades. + let mut ca_params = CertificateParams::new(Vec::new()).expect("CA parameters"); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.distinguished_name = { + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "loopback peer-fetch CA"); + name + }; + ca_params.not_before = rcgen::date_time_ymd(2026, 1, 1); + ca_params.not_after = rcgen::date_time_ymd(2046, 1, 1); + let ca_key = KeyPair::generate().expect("a runtime CA key"); + let ca_certificate = ca_params + .self_signed(&ca_key) + .expect("a self-signed runtime CA"); + + // A leaf for the loopback address, signed by the runtime CA. + let mut leaf_params = CertificateParams::new(Vec::new()).expect("leaf parameters"); + leaf_params.is_ca = IsCa::NoCa; + leaf_params.subject_alt_names = vec![rcgen::SanType::IpAddress( + IpAddr::from_str("127.0.0.1").expect("loopback IP"), + )]; + leaf_params.distinguished_name = { + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "loopback peer"); + name + }; + leaf_params.not_before = rcgen::date_time_ymd(2026, 1, 1); + leaf_params.not_after = rcgen::date_time_ymd(2046, 1, 1); + let leaf_key = KeyPair::generate().expect("a runtime leaf key"); + let leaf_certificate = leaf_params + .signed_by(&leaf_key, &ca_certificate, &ca_key) + .expect("a CA-signed loopback leaf"); + let leaf_der: Vec = leaf_certificate.der().to_vec(); + let expected_spki = x509::leaf_spki_der(&leaf_der).expect("the runtime leaf slices"); + + // A loopback TLS server speaking this leaf, recording any application + // bytes the client sends after the handshake. + let server_config = Arc::new( + rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::aws_lc_rs::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("protocol versions") + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(leaf_der.clone())], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(leaf_key.serialize_der())), + ) + .expect("server identity"), + ); + let server_listener = TcpListener::bind("127.0.0.1:0").expect("a loopback TLS listener"); + let server_port = server_listener.local_addr().expect("listener port").port(); + let app_bytes = Arc::new(AtomicBool::new(false)); + let app_seen = Arc::clone(&app_bytes); + let server = thread::spawn(move || { + let (mut stream, _) = server_listener.accept().expect("one client"); + let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); + let mut connection = ServerConnection::new(server_config).expect("server connection"); + while connection.is_handshaking() { + connection + .complete_io(&mut stream) + .expect("server handshake I/O"); + } + // After the handshake the client must send no application bytes: + // absorb its `close_notify` flight, then drain *decrypted* data + // until its FIN. A raw-socket read would mistake the alert record + // itself for application data, so the drain runs through TLS. Any + // decrypted byte here is an HTTP (or other) violation. + let _ = connection.complete_io(&mut stream); + let mut app_data = Vec::new(); + { + use std::io::Read; + let mut tls = rustls::Stream::new(&mut connection, &mut stream); + let _ = tls.read_to_end(&mut app_data); + } + if !app_data.is_empty() { + app_seen.store(true, Ordering::Release); + } + }); + + let provider = TlsProvider::build(bitty_network_api::TlsConfig::new().with_ca( + bitty_network_api::PemSource::inline(ca_certificate.pem().into_bytes()), + )) + .expect("the runtime CA is admitted"); + let fetched = peer_leaf_spki_der( + "127.0.0.1", + server_port, + &NetworkCapability::offline().with_domain("127.0.0.1"), + &provider, + Some(LIVE_TIMEOUT), + ) + .expect("the loopback handshake fetches the leaf SPKI"); + server.join().expect("the server thread finishes"); + + assert_eq!( + fetched, expected_spki, + "the fetched SPKI is the server leaf's SPKI" + ); + assert!( + !app_bytes.load(Ordering::Acquire), + "the fetch sends no application bytes after the handshake" + ); + } +} diff --git a/crates/bitty-network-tls/src/provider.rs b/crates/bitty-network-tls/src/provider.rs index d9649a2..490d1e5 100644 --- a/crates/bitty-network-tls/src/provider.rs +++ b/crates/bitty-network-tls/src/provider.rs @@ -267,6 +267,28 @@ impl TlsProvider { configs.get(slot).map(Arc::clone) } + /// The client configuration for fetching one peer's leaf `SubjectPublicKeyInfo`. + /// + /// This is the configuration [`TlsProvider::select`] resolves for `host` + /// on the HTTP transport (so trust and client-identity selection match a + /// real handshake, including the custom-root validity re-check), except + /// when the policy is the default: a default policy selects no + /// configuration so that backends keep their own native-root construction, + /// but a peer fetch builds its own handshake and therefore needs one. The + /// fallback composes the platform store with no custom root and no client + /// identity, which is exactly what the backends' own default construction + /// is. ALPN offers `http/1.1` in both cases; the fetch sends no HTTP bytes + /// after the handshake regardless. + pub(crate) fn peer_client_config(&self, host: &str) -> Result, TlsFailure> { + if let Some(config) = self.select(TlsTransport::Http, host)?.config() { + return Ok(config); + } + let verifier = build_verifier(Vec::new())?; + let mut config = base_config(verifier)?.with_no_client_auth(); + config.alpn_protocols = alpn_for(TlsTransport::Http); + Ok(Arc::new(config)) + } + /// One plus the number of identity slots: the size of a per-slot client set. #[must_use] pub fn identity_slot_count(&self) -> usize { diff --git a/crates/bitty-network-tls/src/x509.rs b/crates/bitty-network-tls/src/x509.rs index 99af404..57f1219 100644 --- a/crates/bitty-network-tls/src/x509.rs +++ b/crates/bitty-network-tls/src/x509.rs @@ -287,7 +287,20 @@ impl<'a> Der<'a> { /// long-form length, a long form wider than the value can allow, and /// high-tag-number form. fn next(&mut self) -> Option<(u8, &'a [u8])> { - let (tag, after_tag) = self.input.split_first()?; + let (tag, contents, _) = self.next_raw()?; + Some((tag, contents)) + } + + /// Read one tag-length-value triple, advancing past it, and also return + /// the triple's exact raw bytes (tag, length, and contents together). + /// + /// The raw slice is a subslice of the reader's input, so it is byte-exact + /// by construction: no re-encoding step exists that could normalize it. + /// [`Der::next`] is this without the third element; the parse is shared, + /// so the two can never disagree about where an element ends. + fn next_raw(&mut self) -> Option<(u8, &'a [u8], &'a [u8])> { + let start = self.input; + let (tag, after_tag) = start.split_first()?; // High-tag-number form (low five tag bits all set) encodes a tag number // of 31 or more. No field this module reads uses it, so refusing it // keeps the reader from decoding a tag it would never match. @@ -298,9 +311,11 @@ impl<'a> Der<'a> { if length > after_length.len() { return None; } + let header_len = start.len() - after_length.len(); let (contents, rest) = after_length.split_at(length); self.input = rest; - Some((*tag, contents)) + let raw = &start[..header_len + length]; + Some((*tag, contents, raw)) } /// Read one triple, require `tag`, and return a reader over its contents. @@ -760,6 +775,78 @@ fn skip_name(reader: &mut Der<'_>) -> Result<(), RootRejection> { /// subjectPublicKey BIT STRING }`, read far enough to admit or refuse the key. fn read_public_key(tbs: &mut Der<'_>) -> Result<(), RootRejection> { let mut spki = tbs.expect_value(TAG_SEQUENCE)?; + admit_spki_body(&mut spki)?; + if !spki.is_empty() { + return Err(RootRejection::Malformed); + } + Ok(()) +} + +/// Largest peer certificate this module will slice a `SubjectPublicKeyInfo` +/// out of, in bytes. +/// +/// A peer leaf is kilobytes; the bound stops a hostile or corrupt peer from +/// making the slicer walk an unbounded buffer, and it is a policy value rather +/// than an intrinsic one. Certificates above it are refused fail-closed with +/// [`RootRejection::Malformed`], never truncated. +pub const MAX_PEER_CERT_DER_BYTES: usize = 64 * 1024; + +/// Slice the leaf `SubjectPublicKeyInfo` DER out of one certificate. +/// +/// Returns the exact DER bytes of the `subjectPublicKeyInfo` SEQUENCE +/// (tag, length, and contents together), suitable for hashing by an SPKI-pin +/// consumer. Hashing itself stays consumer-side: this function slices bytes, +/// it never hashes. +/// +/// The walk to the field mirrors [`admit_root`]'s structural checks up to the +/// public key, and the sliced value then passes the same admission check +/// [`read_public_key`] applies, so the two functions can never disagree about +/// whether a certificate carries a usable key. Anything after the public key +/// (extensions and the outer signature) is not walked: it cannot change the +/// sliced bytes. Empty, oversized, truncated, or otherwise malformed input is +/// [`RootRejection::Malformed`]; an unsupported or weak key is refused with +/// the same reason [`read_public_key`] would give. +pub fn leaf_spki_der(cert_der: &[u8]) -> Result, RootRejection> { + if cert_der.is_empty() || cert_der.len() > MAX_PEER_CERT_DER_BYTES { + return Err(RootRejection::Malformed); + } + let mut certificate = Der::read(cert_der); + let mut body = certificate.expect_value(TAG_SEQUENCE)?; + if !certificate.is_empty() { + return Err(RootRejection::Malformed); + } + let mut tbs = body.expect_value(TAG_SEQUENCE)?; + // version [0] EXPLICIT Version DEFAULT v1, same required-presence walk as + // `admit_root` so the two agree on structure. + if let Some((tag, _)) = tbs.next() { + if tag != TAG_CONTEXT_0 { + return Err(RootRejection::Malformed); + } + } + tbs.skip()?; // serialNumber INTEGER + tbs.skip()?; // signature AlgorithmIdentifier + skip_name(&mut tbs)?; // issuer Name + tbs.skip()?; // validity SEQUENCE + skip_name(&mut tbs)?; // subject Name + let (tag, contents, raw) = tbs.next_raw().ok_or(RootRejection::Malformed)?; + if tag != TAG_SEQUENCE { + return Err(RootRejection::Malformed); + } + let mut spki = Der::read(contents); + admit_spki_body(&mut spki)?; + if !spki.is_empty() { + return Err(RootRejection::Malformed); + } + Ok(raw.to_vec()) +} + +/// Admit or refuse one `SubjectPublicKeyInfo` body: the algorithm and key-size +/// policy shared by [`read_public_key`] and [`leaf_spki_der`]. +/// +/// Takes a reader over the SEQUENCE contents (not the triple itself); the +/// caller owns the tag check and the trailing-emptiness check, so both +/// callers state those the same way. +fn admit_spki_body(spki: &mut Der<'_>) -> Result<(), RootRejection> { let mut algorithm = spki.expect_value(TAG_SEQUENCE)?; let oid = read_oid(&mut algorithm)?; let parameters = algorithm.next(); @@ -1415,6 +1502,97 @@ mod tests { } } + /// A self-signed leaf certificate in DER, minted in memory: the slicer's + /// shape fixture. Generated at runtime like every other certificate in + /// these tests, so no certificate fixture is committed. + fn leaf_der() -> Vec { + ca_der(IsCa::NoCa, Vec::new()) + } + + /// The slicer returns the leaf's `SubjectPublicKeyInfo` as exactly one + /// DER element occurring byte-verbatim in the certificate. + /// + /// The pin consumer hashes these bytes, so "almost the right bytes" is + /// the failure mode: the slice must be one well-formed SEQUENCE and a + /// verbatim subsequence of the input, never a re-encoding. + #[test] + fn leaf_spki_slices_the_exact_subsequence() { + let certificate = leaf_der(); + let spki = leaf_spki_der(&certificate).expect("a real leaf slices"); + let mut reader = Der::read(&spki); + let (tag, contents) = reader.next().expect("the slice parses"); + assert_eq!(tag, TAG_SEQUENCE); + assert!( + reader.is_empty(), + "the slice is exactly one element, never a prefix of one" + ); + assert!(!contents.is_empty()); + assert!( + certificate + .windows(spki.len()) + .any(|window| window == spki.as_slice()), + "the slice occurs byte-verbatim in the certificate" + ); + } + + /// Empty, oversized, and malformed input is refused with a typed reason. + /// + /// Oversized refuses before parsing (one byte over the bound), and a full + /// bound of zeros is still malformed rather than accepted: the bound is a + /// ceiling, never a shape. + #[test] + fn leaf_spki_rejects_empty_oversized_and_malformed() { + assert_eq!(leaf_spki_der(&[]), Err(RootRejection::Malformed)); + assert_eq!( + leaf_spki_der(b"not a certificate"), + Err(RootRejection::Malformed) + ); + assert_eq!( + leaf_spki_der(&vec![0x30u8; MAX_PEER_CERT_DER_BYTES + 1]), + Err(RootRejection::Malformed) + ); + assert_eq!( + leaf_spki_der(&vec![0u8; MAX_PEER_CERT_DER_BYTES]), + Err(RootRejection::Malformed) + ); + let mut wrong_tag = leaf_der(); + wrong_tag[0] = TAG_SET; + assert_eq!(leaf_spki_der(&wrong_tag), Err(RootRejection::Malformed)); + } + + /// Every prefix of a real leaf certificate is refused, and none panics. + /// + /// The slicer stops after the public key, so unlike admission this is not + /// "prefixes are never whole"; it holds for a stricter reason: the outer + /// certificate length always overruns a truncated input, so no prefix can + /// parse as a certificate at all. + #[test] + fn no_truncation_of_a_leaf_is_sliced() { + let certificate = leaf_der(); + let cert_len = certificate.len(); + for cut in 0..cert_len { + assert!( + leaf_spki_der(&certificate[..cut]).is_err(), + "a truncated leaf prefix must be refused" + ); + } + assert!(leaf_spki_der(&certificate).is_ok()); + } + + /// Flipping any single bit of a real leaf either leaves the same SPKI + /// sliced or turns it into a typed refusal; it never panics. + #[test] + fn no_single_bit_corruption_of_a_leaf_panics() { + let certificate = leaf_der(); + for index in 0..certificate.len() { + for bit in 0..8u8 { + let mut corrupted = certificate.clone(); + corrupted[index] ^= 1 << bit; + let _ = leaf_spki_der(&corrupted); + } + } + } + /// A DER length is read at exactly one width, or not at all. /// /// A long-form length with a leading zero byte is a second spelling of a From 505d79c0f7c13ab4168517be528b712ee2e391f0 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Tue, 13 Oct 2026 15:28:53 +0800 Subject: [PATCH 2/2] [CTX-0073] fix(tls): clamp peer-fetch deadline instead of overflowing CodeRabbit Minor on PR #89: deadline_from used Instant addition that panics on Duration::MAX. Use checked_add with far-future clamp and fail-closed immediate fallback, plus a no-panic regression test. Priority: P1 | Area: network | Labels: fix,area:network,P1 Milestone: v0.1.0 | RFC: n/a | Task: CTX-0073 --- crates/bitty-network-tls/src/peer.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/bitty-network-tls/src/peer.rs b/crates/bitty-network-tls/src/peer.rs index 95f760a..823b4ec 100644 --- a/crates/bitty-network-tls/src/peer.rs +++ b/crates/bitty-network-tls/src/peer.rs @@ -206,9 +206,14 @@ fn format_authority(host: &str, port: u16) -> String { } } -/// Absolute instant `duration` after `start`. +/// Absolute instant `duration` after `start`, never panicking: an +/// unrepresentable sum clamps to a far-future instant, and a still-failing +/// fallback expires immediately (fail-closed) instead of hanging. fn deadline_from(start: Instant, duration: Duration) -> Instant { - start + duration + start + .checked_add(duration) + .or_else(|| start.checked_add(Duration::from_secs(u64::from(u32::MAX)))) + .unwrap_or(start) } /// Remaining fetch budget: the deadline minus elapsed, never below @@ -382,6 +387,17 @@ mod tests { assert!(floor >= MIN_REMAINING); } + /// A very large timeout never panics: the deadline clamps instead of + /// overflowing `Instant` addition (CodeRabbit Minor on PR #89). + #[test] + fn huge_timeout_never_panics_the_deadline() { + let start = Instant::now(); + let far = deadline_from(start, Duration::MAX); + assert!(far >= start); + let floored = remaining(Duration::MAX, start); + assert!(floored >= MIN_REMAINING); + } + /// Manual-only live handshake: one real TLS handshake against a loopback /// rustls server, asserting the fetched SPKI equals the server leaf's /// SPKI and that the server observed zero application bytes (no HTTP).