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

314 lines
15 KiB
Markdown

# 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`:
```rust
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](091-connectioncredentials-decouple-dial-from-call.md).
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](034-outgoing-only-x509-and-three-peer-roles.md) §3 —
verifier selection rule (known peer → fingerprint pin; unknown X.509
→ CA verify; unknown raw-key → fail closed)
- [ADR-084](084-aws-lc-rs-crypto-provider.md) — aws-lc-rs crypto
provider on all paths
- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig`
(server-side; this ADR adds the client-side analogue)
- [ADR-065](065-connection-from-stream-generic-single-stream.md) — 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](086-endpoint-types-and-entry-points.md) — 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`