Files
alknet/docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md
T
glm-5.2 613bf680cd docs(arch): ConnectionCredentials decouples dial from call protocol (ADR-091) + migration plan cleanups
ADR-091: ConnectionCredentials — decouple the dial credential bundle
from the call protocol. CallCredentials (call-protocol-level, carries
auth_token) was being used as the dial's credential type, coupling the
dial to the call protocol. The auth_token is a hub-layer identity
correlation mechanism (browsers, alknet/register), not a transport
credential. ConnectionCredentials (transport-level: local_identity +
remote_identity) is the dial's credential bundle; all three dial
signatures unify on &ConnectionCredentials; dial_iroh's node_id
parameter is derived from remote_identity.fingerprint. CallCredentials
stays in alknet-call with auth_token. The shape is validated by the
future dial_ssh pattern (russh's check_server_key + authenticate_publickey
consume the same two dimensions).

Amends ADR-089 §3 (dial signatures) and §5 (move consequence), ADR-087
(input framing). Updates client/tls/call/core crate specs and the
extraction plan's Phase 0/3/4/5.

Migration plan cleanups (findings.md):
- Remove duplicated ordering-rationale bullets (copy-paste artifact)
- TL;DR: six phases -> seven (Phase 0 promoted); four compilable
  intermediate states -> each phase leaves workspace compilable
- Remove inline 'Wait — that's 10, not 8' self-correction; fix
  Category B header to (10 tests)
- Replace contradictory line ranges in Net Phase 5 test impact with
  name-based references
- Decide quinn feature fate: removed (not no-op)
- Note webpki-roots always-present per ADR-088 §5 in Phase 1 dep list
2026-07-17 05:56:47 +00:00

15 KiB

ADR-087: TlsClientConfig Is Not Blocked on the Dial Seam

Status

Accepted (resolves OQ-64; input framing amended 2026-07-16 by ADR-091 — ClientVerifierContext is derived from ConnectionCredentials.remote_identity, not CallCredentials.remote_identity; the auth_token is not in the dial's credential bundle)

Context

The circular hedge

OQ-64 and OQ-55 were linked in a way that formed a circular dependency:

  • OQ-64 said: "the client-side TLS helper is blocked on the AlknetClient dial-seam extraction (OQ-55), because the TLS helper and the dial are the same seam."
  • OQ-55 said: "the dial seam is blocked on a second transport's real dial existing."
  • A second transport's dial requires a rustls::ClientConfig to dial with — which is the client-side TLS helper.

This is circular: the prerequisite (TLS config) is deferred behind the thing that needs it (the dial). No second transport can dial until it has a TLS config; the TLS config is deferred until a second transport dials. The dial never arrives because the config never arrives because the dial never arrives. Schrödinger's code — required and not required until observed.

This is the same pattern that stalled the server-side transport generalization before ADR-065 broke it: "we can't generalize until we have two transports, but we can't build the second transport until we generalize." ADR-065 broke it by separating the Connection (the take-over, transport-agnostic, built now) from the dial (the establishment, transport-specific, per-transport). This ADR does the same on the client side.

The two things that were conflated

The "same seam" claim conflated two distinct concerns:

  1. TlsClientConfig — the rustls::ClientConfig + ADR-034 verifier selection (fingerprint pin for known peers, CA-verify for unknown X.509, fail-closed for unknown raw-key) + ADR-084 crypto provider (aws_lc_rs). This is transport-agnostic. The verifier selection rule (ADR-034 §3) is keyed on PeerEntry presence and remote cert type, not on transport. The crypto provider (ADR-084) is the same on all paths. The fingerprint normalization (ADR-030 §6, ed25519:<hex> / SHA256:<hex>) is transport-agnostic. All decisions are made. There is nothing to discover from a second transport's dial — the rule is the same regardless of whether the dial is QUIC, TCP+TLS, or iroh.

  2. The dial (AlknetClient::dial()) — transport-specific connection establishment. QUIC dial (quinn::Endpoint::connect), TCP+TLS dial (TcpStream::connect + TlsConnector::connect), iroh dial (iroh::Endpoint::connect). Extracting a transport-polymorphic dial from one shape (QUIC) would bake QUIC in as the establishment shape — the same welding ADR-065 unwound on the server side. This is the legitimate deferral (OQ-55, unchanged).

The TLS config is a prerequisite for the dial, not a consequence of it. You build the rustls::ClientConfig first, then you dial with it. The dial passes the config to the transport-specific connector (quinn::Endpoint::connect_with takes a ClientConfig; TlsConnector::connect takes a ClientConfig; iroh takes a SecretKey — the one exception, see "Iroh" below). The config does not flow from the dial; it flows into it.

The hub makes this non-optional

A hub has to be a client. The hub dials out to workers it supervises; a hub (B) that connects to another hub (A) is a client from A's perspective. The hub README already has dial_worker_connection and supervise_worker — those are client operations that need a client-side TLS config. The first hub deployment (web + native, per ADR-086) dials workers over QUIC (native endpoint, raw key). That dial needs a rustls::ClientConfig with the ADR-034 verifier (fingerprint pin for the worker's known Ed25519 key).

There is no "later" for this. The first hub deployment needs the client-side TLS config. Deferring it behind the dial-seam extraction (OQ-55, which is genuinely blocked on a second transport) means the first hub cannot be built — or, worse, each client rebuilds the verifier selection + provider wiring standalone, and the duplicated boilerplate drifts (one crate uses aws_lc_rs, another uses ring, the convention breaks silently — exactly the ADR-084 consistency risk the convention was supposed to prevent).

alknet-worker cannot exist without a client (it dials a hub). The hub cannot exist without a client (it dials workers and other hubs). The client-side TLS config is on the critical path for both. It is not a future extraction; it is a present prerequisite.

Decision

1. alknet-tls provides TlsClientConfig

alknet-tls grows a client-side config type alongside TlsServerConfig:

pub struct TlsClientConfig {
    config: rustls::ClientConfig,
}

impl TlsClientConfig {
    /// Build a client TLS config for the given remote identity context.
    /// Applies ADR-034 verifier selection:
    ///   - known peer (PeerEntry present) + raw key → fingerprint pin
    ///   - known peer (PeerEntry present) + X.509 → fingerprint pin
    ///   - unknown remote + X.509 → CA verification (WebPkiServerVerifier)
    ///   - unknown remote + raw key → fail closed
    /// Applies ADR-084 crypto provider (aws_lc_rs::default_provider()).
    pub fn new(verifier_context: ClientVerifierContext) -> Result<Self, TlsError>;
}

The ClientVerifierContext carries the inputs to ADR-034's verifier selection: whether a PeerEntry exists for the remote, the expected fingerprint (if known), and the remote cert type (if known). The exact shape of this context is an implementation detail (the decisions are in ADR-034; the struct is a bag of already-decided inputs). It is sketched lightly here; the full variant-granularity of TlsError is OQ-63 (the next session).

Amendment 2026-07-16 (ADR-091): ClientVerifierContext is derived from ConnectionCredentials.remote_identity (the transport-level credential bundle), not CallCredentials.remote_identity. The dial (AlknetClient::dial_quic / dial_tcp_tls) extracts creds.remote_identity from ConnectionCredentials and builds ClientVerifierContext from it. CallCredentials is no longer in the dial's path — its auth_token dimension is a call-protocol concept, not a transport credential. See ADR-091.

This is not the dial. TlsClientConfig produces a rustls::ClientConfig; the caller (the transport-specific dial helper — now AlknetClient::dial_quic / dial_tcp_tls per ADR-089; the per-protocol CallClient::connect / ChannelClient::connect_quic convenience constructors are removed per ADR-089 §5) passes it to the transport's connector. The config is transport-agnostic; the dial is not.

2. The dial seam (OQ-55) — subsequently resolved by ADR-089

Update (2026-07-16): OQ-55 is now resolved by ADR-089. The text below is the original (pre-ADR-089) framing, preserved for context. AlknetClient is the extracted dial seam; the per-protocol convenience constructors are removed, not retained as wrappers.

OQ-55 (the AlknetClient transport-polymorphic dial extraction) remains deferred is resolved by ADR-089. The deferral was about the dial — transport-specific connection establishment — not about the TLS config. With TlsClientConfig in alknet-tls, each transport-specific dial helper (now AlknetClient::dial_quic / dial_tcp_tls / dial_iroh, ADR-089) builds its TlsClientConfig and passes it to its transport's connector. The friction (each dial helper calls TlsClientConfig::new + its transport's connect) is real but narrow — it's duplicated TlsClientConfig::new calls, not duplicated verifier selection logic. When a second transport's dial exists, the dial seam is extractable (OQ-55 unblocked); the TLS config is already shared by then.

3. Iroh is the one exception (shares the key, not the config)

Iroh's client side, like its server side (ADR-082 §"Iroh: shares the key, not the rustls config"), does not consume a rustls::ClientConfig — it takes an iroh::SecretKey and handles TLS internally. The iroh client dial does not use TlsClientConfig. The Ed25519SecretKey (from StaticConfig, in core) feeds iroh::SecretKey::from_bytes directly, same as the server side.

The verifier selection for iroh is also different: iroh's built-in TLS verifies the remote's NodeId (Ed25519 public key) against the expected NodeId. This is fingerprint-pinning by another name — the NodeId IS the fingerprint. An unknown iroh remote fails closed (no CA to fall back to — ADR-034 §3, Assumption 1). TlsClientConfig does not cover the iroh path; the iroh dial helper applies the same ADR-034 rule (known peer → pin, unknown → fail closed) via iroh's own API.

4. alknet-tls is no longer "server-only"

The TLS README's "Server-only (for now)" section is removed. alknet-tls provides both TlsServerConfig (inbound) and TlsClientConfig (outbound). The server side is unchanged (ADR-082); the client side is added by this ADR.

The provider-consistency convention (ADR-084: aws_lc_rs on all paths) moves from "enforced by convention" to "enforced by TlsClientConfig::new" for the rustls-consuming transports (quinn, TCP+TLS). The iroh path uses iroh's built-in tls-aws-lc-rs feature (already consistent).

5. The hub-as-client requirement is a first-class use case

The hub's dial_worker_connection / supervise_worker (hub README §"Dial (outbound workers)") are client operations. They need a TlsClientConfig for the outbound dial. The hub-as-client case is not a "future use case the resolved helper must cover" — it is a present requirement that drives the resolution. A hub that supervises workers dials them over the native endpoint (QUIC, raw key) using a TlsClientConfig with the worker's fingerprint pinned (ADR-034 §3, known peer + raw key). A hub that connects to another hub dials it the same way.

What this does NOT change

  • TlsServerConfig (ADR-082) — the server-side config is unchanged. TlsClientConfig is a separate type, same crate.
  • The dial seam (OQ-55) — the transport-polymorphic dial extraction remains deferred. This ADR extracts the TLS config, not the dial. When OQ-55 resolves, AlknetClient::dial() will call TlsClientConfig::new + the transport-specific connector; the config is already shared by then.
  • ADR-034 (verifier selection) — the rule is unchanged. This ADR centralizes its implementation in TlsClientConfig::new instead of each client rebuilding it.
  • ADR-084 (crypto provider) — the provider is unchanged. This ADR moves enforcement from convention to code for the client side.
  • CallClient / ChannelClient take-over APIs — spawn_dispatch / from_connection are transport-agnostic and decided (ADR-017, ADR-080). They take a pre-established Connection. This ADR is about how the caller builds the TLS config before establishing that Connection, not about the take-over.
  • FingerprintPinVerifier — the existing verifier in alknet-call is the current implementation of ADR-034's fingerprint-pin path. It moves to alknet-tls (amended by ADR-089 §5): with CallClient::connect removed, it has no remaining home in alknet-call, and it is a TLS concern (implements rustls::client::danger::ServerCertVerifier). TlsClientConfig::new constructs it. Moving it lets alknet-call shed its direct rustls dep entirely — CallClient becomes a pure protocol crate.

Consequences

Positive:

  • The circular dependency is broken. TlsClientConfig is buildable today; the dial seam (OQ-55) is no longer blocking it. A second transport's dial can be built using TlsClientConfig + the transport's connector, without waiting for the dial-seam extraction.
  • The hub-as-client requirement is met. The hub's dial_worker_connection / supervise_worker use TlsClientConfig::new for the outbound dial's TLS config. The first hub deployment (web + native) can dial workers over QUIC with the worker's fingerprint pinned.
  • alknet-worker is unblocked on the TLS front. A worker dials a hub using TlsClientConfig::new + the transport-specific connector. The dial seam (OQ-55) is about extracting the shared dial, not about blocking the worker from dialing.
  • Provider consistency (ADR-084) is enforced by code, not convention, for the client side. TlsClientConfig::new uses aws_lc_rs::default_provider(); every client that uses it gets the right provider. The convention-based risk (one crate drifting to ring) is removed.
  • The duplicated boilerplate (each client rebuilding verifier selection + provider wiring) is centralized. TlsClientConfig::new is the single point where ADR-034's rule and ADR-084's provider are applied.

Negative:

  • alknet-tls grows a client-side type. The crate is no longer "server-only." This is correct — the crate's purpose is shared TLS config, and the client side is shared across all outbound-dialing crates (hub, worker, CallClient, ChannelClient).
  • The iroh client path does not use TlsClientConfig. This is unavoidable — iroh has its own TLS. The iroh dial helper applies the same ADR-034 rule via iroh's API. The consistency is in the rule, not in the type.
  • TlsError (OQ-63) now covers both server and client errors. The variant granularity is slightly larger (client-side variants: verifier construction, provider init, unknown-remote fail-closed). OQ-63 is the next session and will account for both.

Door type

One-way. TlsClientConfig as the shared client-side TLS config in alknet-tls is structural — every outbound-dialing crate depends on it. Reversing would mean re-distributing verifier selection + provider wiring across crates, reintroducing the convention-based consistency risk. The TlsClientConfig::new signature (takes a verifier context, returns a rustls::ClientConfig) is one-way — changing it after consumers exist is a rewrite. The internal implementation (how the verifier context struct is shaped, how FingerprintPinVerifier relates to the CA-verify path) is two-way.

References

  • OQ-64 (resolved by this ADR) — should alknet-tls provide a client-side TLS config helper?
  • OQ-55 (unaffected — the dial seam remains deferred; this ADR extracts the TLS config, not the dial)
  • ADR-034 §3 — verifier selection rule (known peer → fingerprint pin; unknown X.509 → CA verify; unknown raw-key → fail closed)
  • ADR-084 — aws-lc-rs crypto provider on all paths
  • ADR-082 — TlsServerConfig (server-side; this ADR adds the client-side analogue)
  • ADR-065 — the server-side precedent: separate the take-over (transport-agnostic, built now) from the dial (transport-specific, per-transport). This ADR is the client-side analogue.
  • ADR-086 — the hub composes endpoint types and dials workers (hub-as-client)
  • OQ-63 — TlsError shape (next session; now covers both server and client variants)
  • docs/architecture/crates/hub/README.md §"Dial (outbound workers)" — the hub-as-client operations that need TlsClientConfig