TunnelVision

Spec — Relay and opt-in TLS inspection (PacketTunnel/Relay, PacketTunnel/TLS)

Two responsibilities that share a module boundary: (1) forward every flow to the real internet (the default), and (2) for flows the user opted to inspect, terminate TLS locally, log plaintext, and re-originate to the true server. The security invariants here are the heart of the project — read ../decisions/0003-no-third-party-pinning-bypass.md.

Relay (always on)

public actor Relay {
    public init(reinject: @escaping ([Data], [NSNumber]) -> Void)   // packetFlow.writePackets

    /// Passthrough de un flujo: abre/usa una NWConnection saliente y reinyecta las respuestas.
    public func passthrough(_ packet: ParsedPacket, raw: Data) async

    /// Cierra y libera un flujo.
    public func close(_ key: FlowKey)
}

Implemented so far (M8, first half) — UDP passthrough ✅

The Relay actor exists and does the stateless half: UDP/QUIC passthrough. Two deviations from the sketch above, both deliberate:

close(_:) cancels and forgets a flow’s connection (called when FlowTable closes it); closeAll() tears down every connection on stopTunnel. Bounded failures: an oversize reply that won’t fit a datagram is counted (RelayStats.emitterFailures) and dropped, never fatal. Known loose end: the reinjected replies are not (yet) recorded back through the pipeline, so inbound bytes are delivered to the device but do not appear in the flow history/live feed — the pipeline records what the device sends; recording relayed replies is a later concern.

Implemented so far (M8, first half, cont.) — TCP passthrough state machine ✅

TCP passthrough is state, not serialization: the device believes it opened a TCP connection to the real server, but the extension intercepts its datagrams. So the extension has to terminate that TCP in userspace — play the server toward the device (SYN-ACK, cumulative ACKs, close) to extract the application byte stream — and re-originate that stream over its own NWConnection (which runs its own TCP against the real server). The two directions are two independent 32-bit sequence spaces that wrap (serial arithmetic, RFC 1982).

TCPRelayFlow (PacketTunnel/Relay/TCPRelayFlow.swift) is that state machine, and it is pure and synchronous — it knows nothing of NWConnection, the PacketEmitter, or the reinjector. It takes events (receiveFromDevice, receiveFromServer, serverDidConnect/Close/Fail) and returns a list of TCPRelayAction values (connectToServer, sendToServer(Data), closeServerSend, segmentToDevice(OutboundSegment), teardown) that the Relay actor will execute. This is the same pure-core/injected-shell split M7’s pipeline and the UDP relay use: the logic runs on the Simulator against direct assertions, and the device-only surface (the TCP NWConnection and the emitter call) is a thin shell. Design decisions:

Tests (21): the full handshake (SYN → deferred connect → SYN-ACK → established), device→server data with cumulative ACK (in order, out of order buffering then splicing, retransmit not double-forwarded), server→device data (single, MSS-split, window-limited then drained), graceful close in both orderings, data+FIN in one segment, device RST, server refusal and mid-connection failure, the two bounded degradations, UInt32 sequence wraparound in both spaces, and an OutboundSegment round-tripped through PacketEmitter.tcp + PacketParser (proving the chosen seq/ack/flags land correctly on the wire). 196 total.

Implemented so far (M8, first half, cont.) — TCP passthrough wiring ✅

TCPRelayFlow is now driven by the Relay actor, so TCP passthrough is complete in code (only the device-only provider hookup remains, part of M7’s DoD). passthrough routes a TCP packet to a per-FlowKey TCPRelayFlow, builds an InboundSegment from the parsed header + payload, and executes the returned TCPRelayActions against an injected connection:

Tests (9 wiring, 205 total): SYN opens the connection + defers the SYN-ACK, SYN-ACK on ready with the right seq/ack, device data forwarded to the server + cumulative ACK to the device, server data re-segmented to the device, graceful close (device FIN half-closes the server, server EOF emits our FIN, device ACK tears down), server refusal before SYN-ACK ⇒ connection-refused RST, device RST ⇒ silent teardown, close from the flow table cancels the connection, and closeAll across both protocols.

Implemented so far (M8, first half, cont.) — the provider hookup ✅

El relay ya está enchufado, que era lo único que separaba al proyecto de tener conectividad con el túnel encendido: TunnelRuntime lo crea en start con packetFlow.writePackets inyectado como reinject, el bucle de lectura le pasa todo datagrama que el pipeline no descarte, cada tick le cierra los flujos que la tabla dio por terminados (PacketPipeline.drainClosedFlowKeys) y stop le llama a closeAll() antes que a nadie. Detalle y decisiones en tunnel-provider.md. Lo device-only aquí es la comprobación, no el código: el cableado se valida por compilación bajo concurrencia estricta y lo que el pipeline le da al bucle se prueba en Simulator.

Comprobado en hardware (iPhone 17, 2026-08-12): con el túnel monitorizando el dispositivo conserva internet y le llega tráfico real. Y lo reinyectado ya se registra: el closure de reinyección entrega al dispositivo y encola el datagrama para PacketPipeline.record(reinjected:protocolFamily:) por una cola serial y con tope, así que las respuestas entran en el historial, en el feed en vivo y en el .pcap — hasta entonces bytesIn era siempre cero y la captura llevaba media conversación. Detalle y decisiones en tunnel-provider.md.

Still pending: la segunda mitad (terminación TLS opt-in), abajo. El cabo que había aquí —RelayStats sin viajar por el canal de control— está cerrado (M11): los contadores salen ya emparejados con los del pipeline dentro de un TunnelStats y se leen desde Ajustes › Session diagnostics (tunnel-provider.md, app-services.md). El tipo vive desde entonces en Shared/IPC/RelayStats.swift, porque cruza el canal.

Reinjection: the packet emitter (Shared/IP) ✅

NWConnection hands back transport bytes, not datagrams. To return them to the device via packetFlow.writePackets the whole IP datagram has to be rebuilt — IP + TCP/UDP headers, checksums, directions swapped. That is PacketEmitter, the mirror of M3’s PacketParser:

public enum PacketEmitter {
    public static func tcp(
        source: IPEndpoint, destination: IPEndpoint,
        sequence: UInt32, acknowledgment: UInt32,
        flags: TCPFlags, windowSize: UInt16, payload: Data = Data()
    ) throws -> Data

    public static func udp(source: IPEndpoint, destination: IPEndpoint, payload: Data) throws -> Data
}

public enum PacketEmitError: Error, Sendable, Equatable {
    case addressFamilyMismatch(source: IPVersion, destination: IPVersion)
    case datagramTooLarge(byteCount: Int, limit: Int)
}

It lives in Shared/IP, not in Relay/: serializing IP is the same layer as parsing it, the round-trip tests belong next to each other, and a future caller that just needs to inject a datagram (a RST, say) should not have to depend on the whole relay. Design decisions:

Tests (19): golden vectors for IPv4/TCP, IPv4/UDP, IPv6/TCP and IPv6/UDP-with-odd-payload, hand-computed byte by byte including the checksums — a golden captured from a run of the emitter would only prove it has not changed, not that it is right. On top of that: round-trips through M3’s PacketParser (two independent implementations that must agree), FlowKey canonicity across direction inversion, the UDP zero-checksum rule, flag masking, typed errors, and a sweep over payload lengths 0–64 (odd ones exercise the checksum’s zero padding) validated by a verifier written separately from InternetChecksum. InternetChecksum itself is anchored to external vectors (RFC 1071 §3 and the RFC 1624 §3 IPv4 header) so the emitter’s tests cannot confirm themselves. The emitted goldens were additionally fed to tcpdump -vv, which reports every checksum as correct / udp sum ok.

TLS inspection (opt-in, port-443 TCP only)

Preconditions (all must hold, else passthrough)

  1. The user turned inspection on.
  2. The flow is TCP to port 443.
  3. The user generated a local CA and installed+trusted it (the app knows this; if not, don’t even try).
  4. The flow is not already marked notInspectable.

Local certificate authority

public struct LocalCA: Sendable {
    /// Genera una CA raíz nueva; la clave privada se guarda en el llavero (Secure Enclave si hay).
    public static func generate() throws -> LocalCA
    /// Exporta el certificado raíz (DER) para que el usuario lo instale.
    public func exportRootCertificateDER() -> Data
    /// Emite un certificado leaf efímero para `host`, firmado por la CA. Cacheado por host.
    public func mintLeaf(forHost host: String, sans: [String]) throws -> SecIdentity
}

Implemented so far (M8, second half) — the certificate authority core ✅

LocalCA is split into a pure, Simulator-testable core and a thin Keychain/SecIdentity shell, the same pure-core/injected-shell rhythm as PacketPipeline→PacketTunnelProvider and TCPRelayFlow→Relay. The core is done; the shell is the remaining device-only piece.

CertificateAuthority (PacketTunnel/TLS) is that core — an actor holding a P-256 root key and its self-signed root certificate, minting ephemeral leaves cached per host:

public actor CertificateAuthority {
    public struct Configuration: Sendable { /* root/leaf names, validity, backdating */ }

    public static func generate(configuration: Configuration = .init(),
                                now: @escaping @Sendable () -> Date = Date.init) throws -> CertificateAuthority
    public func exportRootCertificateDER() -> Data
    public func mintLeaf(forHost host: String, sans: [String] = []) throws -> MintedCertificate
    public func clearLeafCache()
}

public struct MintedCertificate: Sendable, Equatable {
    public let host: String
    public let certificateDER: Data   // firmado por la CA raíz
    public let privateKeyDER: Data    // PKCS#8, para importar al Keychain y armar el SecIdentity
}

Why the split, and why MintedCertificate instead of SecIdentity here: iOS’s Security framework can only read certificates, not build or sign them, and it has no public SecIdentity constructor — a SecIdentity only exists once its private key sits in the Keychain. So the whole cryptographic heart (X.509 assembly + ECDSA-P256/SHA-256 signing) is pure and testable, and turning a MintedCertificate into a Keychain-backed SecIdentity (plus persisting the root key, Secure Enclave where available) is the device/Keychain shell — which is exactly the surface that cannot be unit-tested cleanly off-device.

The core is built on two pure helpers in the same module, since neither Security nor CryptoKit serializes certificates:

Tests (29): 18 for DER (golden vectors hand-computed or taken from well-known public OIDs — length short/long form, INTEGER rules, OID encoding, KeyUsage bit strings, UTC/Generalized time, context tags) and 11 for the CA. The certificates are not checked against this implementation: they are fed to Security — SecCertificateCreateWithData only accepts well-formed DER X.509, and SecTrustEvaluateWithError under an SSL policy validates the whole chain (signature, anchoring, validity, host-by-SAN, EKU). Covered: root is a well-formed self-signed CA; a leaf chains to its root for its host; it is rejected for a different host, under a different root, and after expiry (injected clock + verify date); extra dNSName and iPAddress SANs validate; minting is cached per host and cleared on demand; distinct hosts get distinct keys and serials; an empty host is rejected. 234 total.

Implemented so far (M8, second half, cont.) — the CA Keychain shell ✅

LocalCA (Shared/TLS/LocalCA.swift — see the M10 note below; it was written in PacketTunnel/TLS) is the device-only shell over that core, matching the spec sketch above (Sendable struct). It adds the two things the core deliberately can’t do off-device: persisting the root key in the Keychain and turning a MintedCertificate into a presentable SecIdentity. Like NetworkRelayConnection and PacketTunnelProvider.swift, it is validated by compilation only under strict concurrency — the Keychain needs device entitlements, so its live correctness is a device smoke test.

public struct LocalCA: Sendable {
    public struct KeychainConfiguration: Sendable { /* accessGroup, rootKeyTag, leafLabelPrefix */ }

    public static func generate(keychain:configuration:) throws -> LocalCA        // new root key → persist → core
    public static func load(keychain:configuration:) throws -> LocalCA?           // restore from persisted key, nil if none
    public func exportRootCertificateDER() async -> Data
    public func mintLeaf(forHost host: String, sans: [String]) async throws -> SecIdentity
    public func clearLeafCache() async
    public func removeFromKeychain() throws                                       // reversibility
}
public enum LocalCAError: Error, Sendable, Equatable { case keychain(OSStatus), invalidStoredKey, identityUnavailable }

Design, and deviations from the sketch (all deliberate):

Tests (2 new, Simulator, on the seam — the Keychain shell itself is compile-only): a CA rebuilt from an existing key with make(rootKey:) mints leaves that validate under Security; and a leaf minted by a CA reloaded from the same key validates against the originally-exported root cert (same subject, different serial) — proving the key-only persistence design. 236 total.

Still pending: the TLSInterceptor below.

Implemented so far (M8, second half, cont.) — the TLS interceptor decision core ✅

TLSInterceptor is split into a pure, Simulator-testable decision core and a thin device-only termination shell, the same rhythm as everything else in M8. The core is done; the live handshake shell is the remaining device-only piece.

TLSInterception.swift (PacketTunnel/TLS) is that core — no network, no Keychain, no SecIdentity:

public enum TLSInterceptionPolicy {
    public enum Gate: Sendable, Equatable { case attempt(sni: String), abort(TLSInterceptError) }
    public static func gate(caReady: Bool, clientHelloSNI: String?) -> Gate

    public enum Decision: Sendable, Equatable { case inspected, notInspectable, fail(TLSInterceptError) }
    public static func decide(_ outcome: TLSTerminationOutcome) -> Decision
}

public enum TLSTerminationOutcome: Sendable, Equatable {
    case inspected, pinned, serverHandshakeFailed, userspaceStackError
}
public enum TLSInterceptError: Error, Sendable, Equatable {
    case noCA, noSNI, handshakeWithServerFailed, userspaceStackError
}

gate verifies only the interceptor’s residual preconditions (CA ready + SNI present); the other three preconditions above (inspection on, TCP-to-443, not already notInspectable) are the pipeline’s contract — PacketPipeline.route enforces them and only routes eligible flows to .inspect, and its 21 tests already cover them, so re-checking them here would duplicate that contract. decide maps the handshake outcome to the final action and is where the pinning-passthrough invariant lives: a pinned outcome resolves to notInspectable, never to an error that might invite a retry (ADR 0003).

TLSInterceptor.swift is the actor that orchestrates the policy against an injectable shell:

public protocol TLSTerminationEngine: Sendable {
    func terminate(flow: LiveFlow, sni: String) async -> TLSTerminationOutcome
}

public actor TLSInterceptor {
    public enum Result: Sendable, Equatable { case inspected, notInspectable }
    public init(engine: TLSTerminationEngine, caReady: @escaping @Sendable () -> Bool)
    public func intercept(_ flow: LiveFlow, clientHelloSNI: String?) async throws -> Result
}

Deviations from the sketch below, all deliberate:

Tests (16, 252 total): gate forcing passthrough (no CA → .noCA, absent/empty SNI → .noSNI, CA-before-SNI ordering, both present → .attempt); decide for every outcome incl. pinned → notInspectable; and the actor against a scripted engine — no-CA/no-SNI throw without touching the engine, a trusting client → .inspected (engine received the right flow + SNI), a pinning client → .notInspectable with the engine called exactly once (no retry, ADR 0003), server/stack failures → typed throws, and caReady re-read per call (a mid-run CA removal stops the next termination). The live userspace handshake is a device smoke test; this layer is the Simulator-testable decision.

Still pending: the production TLSTerminationEngine conformer (the userspace TLS server presenting the SNI leaf + the upstream NWConnection under system trust), device-only.

Implemented so far (2026-08-12) — the memory-fed TLS server ✅

This is the piece the roadmap had flagged as the only one in the project whose feasibility was unknown: a TLS server that has to be fed from a byte stream we already hold in memory, because the extension receives IP packets and not sockets. iOS allows it. The answer is not a deduction from reading headers — it is a Simulator test in which a real BoringSSL client, with its own trust evaluation, completes a handshake against a leaf we minted and exchanges plaintext both ways.

public protocol TLSServerSession: Sendable {            // PacketTunnel/TLS
    func start(
        onReady: @escaping @Sendable () -> Void,          // handshake done: plaintext exists
        onPlaintext: @escaping @Sendable (Data) -> Void,  // what the client sent, in the clear
        onEncrypted: @escaping @Sendable (Data) -> Void,  // TLS records to hand back to the client
        onClose: @escaping @Sendable (TLSServerSessionClosure) -> Void
    )
    func deliver(_ data: Data)      // TLS records arriving from the client (reassembled, in order)
    func deliverEnd()               // the client half-closed (FIN)
    func send(_ plaintext: Data)    // plaintext towards the client; the session encrypts it
    func closeSend()
    func cancel()
}

public enum TLSServerSessionClosure: Sendable, Equatable {
    case closed, rejectedByClient, failed(TLSServerSessionError)
}

The mechanism, and why it is a bridge. Network.framework — the only maintained TLS stack iOS exposes — only plays TLS server through an NWListener, and a listener is only ever fed by a socket. So LoopbackTLSServerSession raises a listener bound to 127.0.0.1 carrying the host’s leaf and dials its own plain-TCP connection to it. Records that came off the tunnel are written into that bridge and the records the stack produces are read back out of it (those are what gets reinjected towards the device); the connection the listener accepts is the TLS server side, so plaintext goes in and out of it. Apple’s stack does the TLS; what we add is a one-metre cable.

The alternative, measured rather than assumed. SecureTransport (SSLCreateContext + SSLSetIOFuncs) is memory-fed by design and needs no socket at all; it still exists on iOS 26 and still starts (SSLSetIOFuncs/SSLSetCertificate return errSecSuccess, SSLHandshake returns errSSLWouldBlock, i.e. the stack came up and is asking for bytes). It was rejected anyway: deprecated since iOS 13, no TLS 1.3, and its own header says “No longer supported. Use Network.framework.” Building v1’s inspection on an API that can vanish with the next system release is a worse risk than the cost of the bridge. The seam above is exactly what would make that swap cheap.

What the Simulator cannot answer, and is therefore a device check: whether a network extension may bind a loopback listener under iOS’s sandbox.

The CA moves to Shared/TLS (M10) ✅

The certificate authority — DERWriter, X509Certificate, CertificateAuthority, LocalCA — now lives in ../../Shared/TLS. Nothing about it changed; only where it is compiled.

Why: the CA is the user’s, not the tunnel’s, and both processes need it. The extension signs leaves with it during a handshake; the app has to generate it, hand its root certificate to the guided install flow, and check whether the system trusts it (../ux/onboarding-and-consent.md). This is the same move TunnelAddressing, the control-channel codec and the .pcap format made, and for the same reason: it is knowledge shared by both sides, so the only place both can see the same type is the shared framework.

There is no IPC under it. TunnelVision.entitlements and PacketTunnel.entitlements have always declared the same keychain-access-groups entry, and neither side passes an explicit kSecAttrAccessGroup, so both default to that first shared group and read the very same Keychain items. The app-side reader that uses this (CertificateStatusReader) and the alternative that was rejected — the extension publishing what it knew about the CA into the App Group container — are specified in app-services.md. The short version: a published status is a copy of a fact the user can change from iOS Settings whenever they want, and it would be written by a process that only runs while the tunnel is up, which is not when someone installs a certificate.

One consequence worth writing down: DER and X509 are internal to Shared (nobody outside the framework hand-serializes ASN.1), so their tests import the framework with @testable and the Shared target enables ENABLE_TESTABILITY in Debug. Making them public just to test them would have let the tests decide the product’s API.

Userspace termination flow

client (iPhone app) ──TLS(our leaf)──▶ userspace TCP+TLS server (us)
                                             │  plaintext logged here
                                             ▼
                             NWConnection ──TLS(system trust)──▶ real server
  1. A userspace TCP endpoint completes the client handshake (client presents to us; we present the minted leaf for the SNI host).
  2. If the client rejects our certificate (it pins), the handshake fails → set the flow’s status to notInspectable, tear down the userspace side, and relay the original flow untouched. This is the pinning-passthrough invariant; never retry to force it.
  3. If the client accepts, we hold plaintext. Open a separate NWConnection to the real server using normal system trust (we are an ordinary TLS client to it; no special privilege).
  4. Shuttle bytes both ways, logging plaintext to the inspector/pcap according to the capture detail level. Re-encrypt to the real server on the outbound side.
public enum TLSInterceptError: Error, Sendable {
    case noCA, noSNI, handshakeWithServerFailed, userspaceStackError
}

Implemented so far (2026-08-12) — the termination engine ✅

The other half of the engine, and with the feasibility question already answered it was ordinary construction: pairing the server session with the upstream leg and shuttling plaintext between them.

public protocol TLSTerminationEngine: Sendable {                    // PacketTunnel/TLS
    /// Builds (does not start) the termination of one flow towards `endpoint`, presenting `host`'s leaf.
    func makeTermination(
        host: String,
        to endpoint: IPEndpoint,
        plaintext: (@Sendable (Data, Direction) -> Void)?,
        onUpstreamTLS: (@Sendable (NegotiatedTLS) -> Void)?,   // see "What an inspected flow's server chose"
        onOutcome: @escaping @Sendable (TLSTerminationOutcome) -> Void
    ) async throws -> any RelayConnection
}

public actor TLSInterceptor {
    public init(engine: any TLSTerminationEngine, caReady: @escaping @Sendable () -> Bool)

    public func open(
        to endpoint: IPEndpoint,
        clientHelloSNI: String?,
        plaintext: (@Sendable (Data, Direction) -> Void)?,
        onUpstreamTLS: (@Sendable (NegotiatedTLS) -> Void)?,
        onResolve: @escaping @Sendable (TLSInterceptionPolicy.Decision) -> Void
    ) async throws -> any RelayConnection
}

The engine is a factory, not an operation. The sketch above (terminate(flow:sni:) async -> TLSTerminationOutcome) assumed the engine could get hold of the flow’s bytes by itself. It cannot: the reassembled stream belongs to the relay, which runs the TCP state machine towards the device. So the engine builds the termination and hands it back, and the outcome — which is known when the flow ends, not when it opens — arrives through a callback. TLSInterceptor keeps its policy intact: gate before anything is touched, decide afterwards.

TLSTerminationConnection is shaped like a RelayConnection, because from the relay a termination is indistinguishable from an outbound connection: it takes the reassembled stream (send), returns bytes to re-segment towards the device (onReceive), has a moment when the way out is established (onReady) and an end (onClose). That is what makes the hookup a change of which connection gets created for an .inspect flow rather than a second path through the relay. What a termination adds over a plain connection — how the attempt to inspect went — leaves through a separate channel, because it is not transport information.

Tests (24, 1213 total): the pairing against doubles for both legs — which byte leaves through which door and in what order, plaintext held and flushed once the way out exists, the bounded ceiling, both directions observed, and every ending mapped (pinning, clean close before and after the handshake, session failure, upstream failure before and after ready, server half-close, caller cancel, outcome reported exactly once, traffic after the end dropped) — plus the interceptor against a scripted engine and the engine’s mint-before-dial ordering.

Implemented so far (2026-08-13) — the relay hookup ✅

The last piece of the engine, and the one that had to answer when. Three questions, and the shape of the answer to the first decided the other two.

public protocol FlowInspecting: Sendable {                    // PacketTunnel/Relay
    func open(
        to endpoint: IPEndpoint,
        clientHelloSNI: String?,
        plaintext: (@Sendable (Data, Direction) -> Void)?,
        onUpstreamTLS: (@Sendable (NegotiatedTLS) -> Void)?,
        onResolve: @escaping @Sendable (TLSInterceptionPolicy.Decision) -> Void
    ) async throws -> any RelayConnection
}
extension TLSInterceptor: FlowInspecting {}                   // the seam is its own shape

public protocol TLSStatusObserving: Sendable {                // PacketTunnel/Relay
    func observe(tlsStatus: TLSInspectionStatus, for key: FlowKey) async
}

public actor Relay {
    public func passthrough(_ packet: ParsedPacket, raw: Data) async
    public func inspect(_ packet: ParsedPacket, raw: Data) async    // opens the flow as a candidate
}

Tests (25, 1238 total): the relay half against doubles for the interceptor and both connections — the ClientHello held, the candidate’s plain connection opened and its SYN-ACK faithful, the swap with the held stream delivered, the stream after the swap, the cancelled connection unable to tear down the flow or re-send the SYN-ACK, replies re-segmented to the device, non-TLS and transient failures falling back with every byte intact, inspection turned off mid-handshake, a non-candidate never terminated, the FIN ordered behind the hold, the ceiling, and each outcome reported — plus the pinned-host retry end to end, the memory’s own bounds, and the pipeline half (status reaching the store without touching the name, and a notInspectable flow no longer routed to inspection).

Implemented so far (2026-08-13) — where the plaintext goes (piece 2, store half) ✅

The sink above now has somewhere to write to: Shared/Plaintext — a format, a streaming writer and a per-flow budget, specified in plaintext.md. Same architecture as the capture (bytes in rotating files, metadata in SQLite) and a separate directory, format and file extension, because a .pcap record is a bare IP datagram and this is a slice of an already-decrypted stream — and because the two deserve separate retention: one holds what travelled, the other what was inside. What bounds it is bounded twice: per record by the writer, and per flow and direction before the bytes reach it (a response of megabytes must not leave the following requests without room). It is the beginning that is kept, and what did not fit is counted rather than dropped silently. 44 tests, 1282 total.

Implemented so far (2026-08-13) — the wiring (piece 2, done) ✅

The sink is no longer nil. A chunk now travels: termination → relay → pipeline → its file and its index row.

public protocol PlaintextObserving: Sendable {                // PacketTunnel/Relay
    func observe(plaintext: Data, direction: Direction, for key: FlowKey) async
}
extension PacketPipeline: PlaintextObserving {}

public protocol PlaintextSink: Sendable {                     // PacketTunnel/Pipeline
    func openStream() async -> UInt64
    func write(_ plaintext: Data, stream: UInt64, direction: Direction, timestamp: Int64)
        async throws -> PlaintextLocation?
    func flush() async throws
}
extension PlaintextWriter: PlaintextSink {}

Tests (19, 1323 total): the pipeline half against doubles — the chunk written and indexed against its flow with its location intact, one conversation per flow, dating in both units from the same anchor, the budget keeping the beginning and telling the truncation, an exhausted budget not touching the disk at all, each direction with its own budget, a closed flow releasing its budget, a chunk without a flow refused, a write failure stopping only plaintext, batching at flush — plus the relay half: no sink without permission, chunks tagged with their FlowKey, order preserved across 40 chunks, turning persistence off stopping recording immediately, and closeAll() draining the queue.

What is left of TLS inspection is the sweep that enforces the ADR 0007 expiry, the screen that exposes its switch, and piece 4 (the decoded half of the Flow Inspector), plus the one device-only unknown that has been open since the server session was built: whether a network extension may bind a loopback listener under iOS’s sandbox.

Security invariants (do not violate)

SNI without decryption ✅

Even for non-inspected 443 flows, the SNI in the ClientHello is readable and is logged as FlowRecord.sni — useful metadata that requires no decryption and no CA. Implemented on 2026-08-12, and it is what earns the app its headline claim: the Timeline names who the device talked to instead of listing addresses.

public struct ClientHelloScanner: Sendable {          // PacketTunnel/TLS
    public enum Framing: Sendable, Equatable {
        case tlsRecords                               // a TCP stream
        case handshakeMessages                        // a QUIC CRYPTO stream: no record layer
    }
    public init(config: Config = Config(), framing: Framing = .tlsRecords)
    public private(set) var offer: ClientTLSOffer?    // § What the client offered, below
    public enum Outcome: Sendable, Equatable {
        case needMoreBytes
        case found(String)
        case unavailable(Reason)
    }
    public enum Reason: Sendable, Equatable {
        case notTLSHandshake, notClientHello, malformed, noServerName, tooLarge
    }
    public mutating func scan(_ bytes: Data) -> Outcome
}

public protocol SNIObserving: Sendable {              // PacketTunnel/Relay
    func observe(sni: String, for key: FlowKey) async
}

Three decisions worth keeping:

Reach, measured on a device (2026-08-12): this names TCP/443 flows carrying a real TLS ClientHello, and on a live iPhone that turned out to be a small share of the traffic — one named host in a whole Timeline. It is not a defect of the parser: QUIC carries its ClientHello inside the QUIC Initial packet, and some protocols on port 443 are not TLS at all (WhatsApp’s messaging connection speaks Noise). The live feed cannot show names at all by construction — the IPC ring carries PackedPacketMeta, which has no SNI, so the Dashboard’s top talkers are addresses on purpose. The reach was widened both ways afterwards, and neither is in the relay: names deduced from DNS, and the ClientHello of a QUIC client read out of its Initial packets by the pipeline (packet-parsing.md § Names from DNS and § Above L4: naming a QUIC flow).

Tests (43, 1184 total): the parser against hand-written golden vectors — every chunk boundary, byte-by-byte feeding, fragmentation across records, no-SNI/no-extensions/empty-list, non-TLS streams, a ServerHello, lying vectors, the size ceiling, host validation and sticky outcomes — plus the relay wiring (single segment, split across two segments, segments arriving out of order, reported once per flow, non-443 flows never scanned, forwarded stream untouched) and the pipeline half (the name reaches the store, on the closing record too, without changing tlsStatus).

What the client offered, without decryption ✅

The same ClientHello that names a flow also says what the client was willing to negotiate: the TLS versions it accepts and its application protocols (ALPN). It is read by the same scanner, in the same pass, and needs no CA either.

public enum OfferedTLSVersions: Sendable, Hashable, Codable {              // Shared/Models
    case listed([TLSProtocolVersion])
    case upTo(TLSProtocolVersion)
}
public struct ClientTLSOffer: Sendable, Hashable, Codable {                // Shared/Models
    public let versions: OfferedTLSVersions
    public let applicationProtocols: [String]
    public let omittedApplicationProtocols: Int
    public let hasEncryptedClientHello: Bool
}

public struct ClientHelloScanner: Sendable {                               // PacketTunnel/TLS
    public private(set) var offer: ClientTLSOffer?
}

public protocol ClientTLSObserving: Sendable {                             // PacketTunnel/Relay
    func observe(clientTLS: ClientTLSOffer, for key: FlowKey) async
}

Why it matters more than it looks: on an inspected flow the server’s answer is an answer to the tunnel’s ClientHello (§ What an inspected flow’s server chose), so what the app offered is the only thing that says whether the app itself would have accepted less. What had to be decided:

The four extension codes (server_name 0, ALPN 16, supported_versions 43, encrypted_client_hello 65037) were checked against the IANA TLS ExtensionType registry.

Not read: the cipher suites the client offered (the list is long and nothing planned needs it). Not read here: the offer of a QUIC client, whose ClientHello travels inside its Initial packets — the pipeline reads it, with this same scanner, and writes the same field (packet-parsing.md § Above L4: naming a QUIC flow).

Tests (58): ClientHelloOfferTests (31) — the list in order, the ceiling, the extension winning over legacy_version, GREASE dropped and all sixteen values recognised, a GREASE-only list, a draft kept raw, a hello without extensions, a repeated extension; ALPN in order, absent, GREASE, unprintable, overlong, beyond the cap, empty, not lowercased; ECH flagged and not; an offer without a name, extensions after the name, four ways a malformed list costs the offer and not the name, a block that breaks after and before the name; no offer while bytes are missing or for a non-TLS stream, the same offer at every chunk boundary, sticky once read. RelayClientOfferTests (11) — the offer leaves with the name, and without one, from a hello split across two segments, once per flow; none for a non-TLS stream, an unreadable offer does not cost the name, flows outside 443 never scanned; the offer observer alone arms the scanner, without it the offer is still counted, with neither nothing is scanned; the forwarded stream is untouched. Plus the pipeline (2), the store (14) and the FlowRecord round-trip.

What the server chose, without decryption

The ServerHello is in the clear for the same reason the ClientHello is: it precedes every key. So the TLS version and cipher suite a server picked can be read for flows that are not inspected — which is nearly all of them, and all of the ones that pin. Like the SNI, it needs no CA and does not touch ADR 0003.

public struct TLSProtocolVersion: RawRepresentable, Sendable, Hashable {   // Shared/Models
    public let rawValue: UInt16
    public static let ssl30, tls10, tls11, tls12, tls13: TLSProtocolVersion
}
public struct TLSCipherSuite: RawRepresentable, Sendable, Hashable {       // Shared/Models
    public let rawValue: UInt16
}
public enum TLSAnswerSource: String, Sendable, Hashable {                  // Shared/Models
    case serverHello, upstreamConnection
}
public struct NegotiatedTLS: Sendable, Hashable {                          // Shared/Models
    public let version: TLSProtocolVersion
    public let cipherSuite: TLSCipherSuite
    public let fromHelloRetryRequest: Bool
    public let source: TLSAnswerSource
}

public struct ServerHelloScanner: Sendable {                               // PacketTunnel/TLS
    public enum Outcome: Sendable, Equatable {
        case needMoreBytes
        case found(NegotiatedTLS)
        case unavailable(Reason)
    }
    public enum Reason: Sendable, Equatable {
        case notTLSHandshake, alert(description: UInt8), notServerHello, malformed, tooLarge
    }
    public mutating func scan(_ bytes: Data) -> Outcome
}

It is the ClientHelloScanner’s twin for the inbound stream — incremental, bounded by the same 16 KiB ceiling, sticky once it settles — and the two share their bounds-checked cursor (TLSByteReader). It is a pure value: it is given the inbound bytes and says what they hold. What is specific to this direction:

The hookup: a flow leaves the tunnel with the server’s answer ✅

The relay feeds one ServerHelloScanner per flow from the inbound stream, the mirror image of what Relay.readHandshake does for the ClientHello, and the answer leaves through a seam of the same cut as SNIObserving:

public enum ServerTLSAnswer: Sendable, Hashable, Codable {                 // Shared/Models
    case negotiated(NegotiatedTLS)
    case refused(alert: UInt8)
}

public protocol ServerTLSObserving: Sendable {                             // PacketTunnel/Relay
    func observe(serverTLS: ServerTLSAnswer, for key: FlowKey) async
}

PacketPipeline is the conformer and writes it to the flow table (FlowTable.setServerTLS), so the flow’s next record — or its closing one — carries it to the store as FlowRecord.serverTLS (schema v8, persistence.md). What had to be decided:

What an inspected flow’s server chose ✅

An inspected flow has two TLS connections, and the ServerHello the device receives belongs to the wrong one. The real server answers on the upstream leg — the NWConnection the termination opens — which Network.framework encrypts, so there is no stream to read. There is its result:

enum UpstreamTLSReading {                                                  // PacketTunnel/TLS
    static func negotiated(version: tls_protocol_version_t, cipherSuite: tls_ciphersuite_t) -> NegotiatedTLS?
    static func negotiated(by connection: NWConnection) -> NegotiatedTLS?
}

NetworkRelayConnection takes an optional onTLSNegotiated and calls it when the connection becomes ready, before onReady. NetworkTLSTerminationEngine builds the upstream leg with it, and the reading travels up the same way the outcome does — makeTermination(onUpstreamTLS:), TLSInterceptor.open(onUpstreamTLS:), FlowInspecting — to the relay, which hands it to the same ServerTLSObserving seam as ServerTLSAnswer.negotiated. Nothing downstream of the seam changed shape. This asks our own connection what it negotiated, which any TLS client may do of its own; ADR 0003 is not involved. What had to be decided:

Unproven until a device runs it: that the system trust evaluation the production upstream leg uses (no verify block, by design) leaves the metadata readable at .ready exactly as the test anchor does. Nothing suggests otherwise; the tests exercise the same NetworkRelayConnection.

Tests (34): hand-written vectors, as for the ClientHello — the version from the extension and from legacy_version, with and without an extensions block, an unpublished version kept raw; the HelloRetryRequest and a random one bit away from it; every chunk boundary, byte-by-byte feeding, fragmentation across records and the TLS 1.2 record that carries the certificate too; alerts (whole, split, wrong size, cutting a partial hello); non-TLS streams; a ClientHello; truncated bodies and lying vectors; the three ways of exceeding the ceiling; sticky outcomes.

Tests of the hookup (11, RelayServerHelloTests): the answer from one chunk and from two, a HelloRetryRequest marked, reported once per flow, an alert as a refusal, a non-TLS stream counted and not reported, flows outside 443 and a relay without an observer never scanned — and the three that only the relay can answer: the ServerHello of our own termination is never read, a candidate that falls back to passthrough is, and after a rollback the real server is read from the start.

Tests of the upstream reading (16): UpstreamTLSReadingTests — the system’s versions and suites against their wire values, a value the SDK does not name kept as given, no version is no reading; and, against a real TLS server on loopback through the production NetworkRelayConnection: a TLS 1.3 server read as 1.3 with one of its three suites, a server capped at 1.2 read as 1.2, the reading delivered before onReady, and a plain TCP connection reporting nothing. RelayServerHelloTests — an inspected flow carries the upstream reading and no stream is scanned for it, a pinned flow keeps it, no observer means no callback, and a reading that arrives after a rollback or after the flow is gone is dropped. TLSInterceptorTests and NetworkTLSTerminationEngineTests — the callback reaches the caller untouched and the upstream leg is built with it.

The certificate chain of TLS ≤ 1.2 ✅

Up to TLS 1.2 the server’s Certificate message travels in the clear, right behind its ServerHello and before any key exists. Reading it decrypts nothing, needs no local CA and does not touch ADR 0003 — it is the same kind of read as the two hellos. TLS 1.3 encrypts it, so there the tool reads nothing and says so rather than leaving an empty field.

public struct ServerCertificateScanner: Sendable {                         // PacketTunnel/TLS
    public struct Config { maxChainBytes = 32768; maxCertificates = 8; maxNameLength = 256 }
    public enum Outcome { case needMoreBytes, found(ServerCertificateReading), unavailable(Reason) }
    public enum Reason { case notTLSHandshake, alert(description: UInt8), unexpectedMessage(type: UInt8), malformed }
    public init(resuming: ServerHelloScanner.Remainder, config: Config = Config())
    public mutating func scan(_ bytes: Data) -> Outcome
}

public enum ServerCertificateReader {                                      // Shared/TLS
    public static func certificate(fromDER: ArraySlice<UInt8>, maxNameLength: Int) -> ServerCertificate?
}

public struct ServerCertificate { subject: CertificateName; issuer: CertificateName; notAfter: Date }   // Shared/Models
public struct ServerCertificateChain { certificates: [ServerCertificate]; isComplete: Bool }
public enum ServerCertificateReading { case chain(ServerCertificateChain), notSent(ServerCertificateAbsence) }
public enum ServerCertificateVisibility {
    case presented(ServerCertificateChain), notSent(ServerCertificateAbsence)
    case encryptedInHandshake, replacedByInspection, noNegotiation, notRead
    public init(answer: ServerTLSAnswer?, reading: ServerCertificateReading?)
}

What had to be decided:

Not read, on purpose: the upstream leg of an inspected flow. There the tunnel is the TLS client and the system would hand over the peer’s chain (in 1.3 too), but that is a different source answering a different client, as with the version, and it is not part of this increment. Nor notBefore, the SANs, or a CertificateStatus.

Unproven until a device runs it: that real TLS 1.2 servers’ flights are read (the vectors are built with the local CA’s own DER writer and by hand, not captured).

Tests (81 + 8): DERReaderTests (10) — short and long form, a slice inside a larger buffer, lengths that declare more than there is, indefinite length, more than four length bytes, high tag numbers, truncated headers. ServerCertificateReaderTests (27) — the three fields; certificates the local CA really issues; a v1 certificate; attribute order, short names and OIDs, multi-valued RDNs; five string types; a value that tries to forge an attribute, the special characters, control and format characters; values that are not text; the name limit; OIDs including a first subidentifier of two bytes; both time forms, both centuries of the short one, leap days, what the writer writes, nine malformed times; and every truncation of a certificate. ServerCertificateScannerTests (33) — the chain in the ServerHello’s record, in its own, arriving later, split across records and at seven chunk sizes; what follows it ignored; the two shapes of a resumed session and the certificate-less handshake; alerts, unexpected messages, non-handshake records, a ChangeCipherSpec mid-message, lying lengths; an unreadable certificate stopping the reading; the count limit, the byte limit, a message that declares 16 MiB; and the hand-over from ServerHelloScanner. ServerCertificateVisibilityTests (11). And in RelayServerHelloTests (8): the chain from the shared record, the version reported before the chain arrives, nothing read after a TLS 1.3 hello, a resumed session, an unreadable flight counted and not reported, once per flow, our own termination’s certificate never read, and the real server’s read after a rollback.

How a stream opened ✅

Everything above reads a handshake on port 443. Off that port nothing was looked at, and a flow’s TLSInspectionStatus was whatever its port said — which made “this connection went in the clear” something no flow could prove. StreamOpeningScanner (PacketTunnel/Relay) closes that: it reads the first bytes the device sends on every TCP flow and says which of three things they are (StreamOpening, data-model.md).

public struct StreamOpeningScanner: Sendable {
    public struct Config: Sendable { public var maxRequestLineBytes: Int }   // 8192
    public enum Outcome: Sendable, Equatable { case needMoreBytes, decided(StreamOpening) }
    public init(config: Config = Config())
    public mutating func scan(_ data: Data) -> Outcome
}

public protocol StreamOpeningObserving: Sendable {
    func observe(streamOpening: StreamOpening, for key: FlowKey) async
}

The hookup. The relay creates one scanner per TCP flow when it has someone to tell, and feeds it where the state machine hands over the device’s bytes (.sendToServer) — the one point every byte passes exactly once, whether it is then sent to the server, held for an inspection candidate or handed to a termination. It is separate from readHandshake on purpose: that one exists only on 443 and decides whether a flow is inspected; this one runs on every port and only records. The reading leaves through StreamOpeningObserving, once per flow, and the flow table stores it and raises a plaintext flow to encrypted when it is a TLS handshake (FlowTable.setStreamOpening). Schema v14 (persistence.md).

Three counters in RelayStats: streamOpeningsTLS, streamOpeningsHTTP, streamOpeningsUnrecognised. Against tcpFlowsOpened they say how many flows closed before sending enough to decide.

It decrypts nothing and reads only what the owner’s device sent in the clear; ADR 0003 is not involved.

What it does not read: the server’s side (a protocol where the server speaks first is unrecognised or has no reading), a connection upgraded later (STARTTLS), and UDP — a datagram that is not QUIC is not looked at.

StreamOpeningScannerTests (15) — the ClientHello record on every minor version and at its length limits; what is a record but not a ClientHello; the request line in its forms, including absolute-form, CONNECT, OPTIONS * and the HTTP/2 preface; nothing decided before the end of the line; nine things that only look like a request line; other protocols; the ceiling; every split of three streams; and that it stays decided. RelayStreamOpeningTests (9) — HTTP on 80, TLS on 5223, 8443, 993 and 443, the unrecognised on any port, a line split across segments read once, the stream to the server unchanged, a flow that sends nothing, no scanner without an observer, and a candidate for inspection whose bytes are held.

Tests