From 77321a7e8446efff9f5a6c5af9af6f7285ef6802 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Wed, 15 Jul 2026 06:05:59 +0000 Subject: [PATCH] =?UTF-8?q?docs(arch):=20break=20the=20AlknetClient=20circ?= =?UTF-8?q?ular=20hedge=20=E2=80=94=20TlsClientConfig=20not=20blocked=20on?= =?UTF-8?q?=20dial=20(ADR-087,=20resolves=20OQ-64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OQ-64 and OQ-55 were linked in a circular dependency: the client-side TLS config was deferred behind the dial seam (OQ-55), but the dial needs the TLS config. No second transport can dial until it has a TLS config; the TLS config was deferred until a second transport dials. Schrödinger's code — required and not required until observed. ADR-087 breaks the circle by separating two concerns that were conflated as 'the same seam': 1. TlsClientConfig — rustls::ClientConfig + ADR-034 verifier selection + ADR-084 crypto provider. Transport-agnostic. All decisions made. Buildable today. A PREREQUISITE for any dial, not a consequence of it. 2. The dial (AlknetClient::dial()) — transport-specific connection establishment. Extracting a transport-polymorphic dial from one shape (QUIC) would bake QUIC in. Legitimate deferral (OQ-55, unchanged). The hub makes this non-optional: a hub dials out to workers it supervises and to other hubs (hub-as-client). The first hub deployment (web + native) dials workers over QUIC with the worker's fingerprint pinned. There is no 'later' for the TLS config — it is on the critical path for the first hub and for alknet-worker. Changes: - ADR-087: TlsClientConfig in alknet-tls, not blocked on OQ-55 - OQ-64: resolved (yes, alknet-tls provides TlsClientConfig) - OQ-55: amended — only the dial seam is deferred; TLS client config is explicitly NOT part of the deferral - TLS README: 'Server-only (for now)' section replaced with TlsClientConfig section; crate is no longer server-only - Hub README: dial/supervision section references TlsClientConfig for outbound connections --- docs/architecture/README.md | 1 + docs/architecture/crates/hub/README.md | 26 +- docs/architecture/crates/tls/README.md | 109 ++++--- ...087-tlsclientconfig-not-blocked-on-dial.md | 293 ++++++++++++++++++ docs/architecture/open-questions.md | 22 +- docs/architecture/overview.md | 3 +- ...5-alknetclient-establishment-extraction.md | 85 +++-- .../questions/064-client-side-tls-helper.md | 103 +++--- 8 files changed, 502 insertions(+), 140 deletions(-) create mode 100644 docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8a4831c..594961b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -286,6 +286,7 @@ adapter location map is now consistent: all HTTP-backed adapters | [084](decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs as the TLS Crypto Provider | Accepted | | [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Accepted | | [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Accepted | +| [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | Accepted | ## Open Questions diff --git a/docs/architecture/crates/hub/README.md b/docs/architecture/crates/hub/README.md index 23aefed..1ac8d8c 100644 --- a/docs/architecture/crates/hub/README.md +++ b/docs/architecture/crates/hub/README.md @@ -282,8 +282,12 @@ all. #### Dial (outbound workers) — transport-agnostic The hub dials outbound workers via `ChannelClient`, not `CallClient`. -The dial path mirrors the `from_connection` / `connect_quic` split -(ADR-080): +The hub is a client when it dials out — a hub (B) that connects to +another hub (A) is a client from A's perspective. The dial needs a +client-side TLS config (`TlsClientConfig`, ADR-087) for the outbound +connection's `rustls::ClientConfig` (verifier selection per ADR-034: +fingerprint pin for the worker's known key). The dial path mirrors the +`from_connection` / `connect_quic` split (ADR-080): ```rust impl Hub { @@ -299,8 +303,9 @@ impl Hub { ) -> Result<(PeerId, ChannelClient), HubError>; /// QUIC convenience: dial a worker over QUIC, then - /// `dial_worker_connection`. Two-way door — additive over - /// `dial_worker_connection`. + /// `dial_worker_connection`. Builds a `TlsClientConfig` (ADR-087) + /// with the worker's fingerprint pinned (ADR-034), dials QUIC, + /// wraps as a channels `Connection`, calls `dial_worker_connection`. pub async fn connect_quic_worker( &self, addr: SocketAddr, @@ -315,11 +320,12 @@ taking over the `Connection`, it runs `from_call` on channel 0 to discover the worker's operations, registers the discovered bundles in the connection's Layer 2 overlay, and attaches the peer to the aggregated env. `connect_quic_worker` is the "I just want QUIC" -convenience — it calls `ChannelClient::connect_quic`, then -`dial_worker_connection`. A future `connect_tcp_tls_worker` dials -TCP+TLS and calls `dial_worker_connection` the same way. The -one-way-door surface is `dial_worker_connection`; the dial helpers -are two-way-door conveniences. +convenience — it builds a `TlsClientConfig` (ADR-087) with the +worker's fingerprint pinned, calls `ChannelClient::connect_quic`, then +`dial_worker_connection`. A future `connect_tcp_tls_worker` builds a +`TlsClientConfig` and dials TCP+TLS the same way. The one-way-door +surface is `dial_worker_connection`; the dial helpers are two-way-door +conveniences. #### Accept (inbound workers and browsers) — transport-agnostic @@ -780,6 +786,7 @@ into `CallAdapter::with_aggregated_env`. | Channel 0 pre-negotiated | [ADR-072](../../decisions/072-channel-0-pre-negotiated-call.md) | Channel 0 = `alknet/call`; the `CallAdapter` runs here | | Channel lifecycle operations | [ADR-073](../../decisions/073-channel-lifecycle-operations.md) | `channel/open`/`close`/`control`/`resources/subscribe` — what the hub translates | | Endpoint types and entry points | [ADR-086](../../decisions/086-endpoint-types-and-entry-points.md) | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type | +| `TlsClientConfig` for outbound dials | [ADR-087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `alknet-tls` provides client-side TLS config; hub-as-client is a first-class use case; not blocked on the dial-seam extraction (OQ-55) | ## Open Questions @@ -843,6 +850,7 @@ See [open-questions.md](../../open-questions.md) for full details. - ADR-082: alknet-tls extraction (`TlsServerConfig` — shared across quinn + TCP+TLS) - ADR-083: Endpoint as multi-transport accept-loop runner (`with_tcp_tls` — TCP+TLS owned by the endpoint; the hub composes transports and handlers) - ADR-086: Endpoint types and entry points (web/native/iroh; entry-point vs. endpoint; split ALPN lists per endpoint type) +- ADR-087: `TlsClientConfig` not blocked on dial seam (client-side TLS config; hub-as-client requirement) - alkapi [hub.md](/workspace/@alkdev/alkapi/docs/architecture/hub.md) — the first hub consumer, the concrete use case that informed this crate diff --git a/docs/architecture/crates/tls/README.md b/docs/architecture/crates/tls/README.md index 784f3dc..f9b3751 100644 --- a/docs/architecture/crates/tls/README.md +++ b/docs/architecture/crates/tls/README.md @@ -5,12 +5,15 @@ last_updated: 2026-07-15 # alknet-tls -Shared TLS configuration and certificate management. Builds a -`rustls::ServerConfig` (or an ACME state machine + cert resolver) once, -and shares it across multiple transports — quinn, `tokio-rustls` -(TCP+TLS), and iroh — so one certificate identity serves QUIC and TCP -endpoints simultaneously. One ACME state machine, one cert, N -transports. +Shared TLS configuration and certificate management — server and +client. Builds a `rustls::ServerConfig` (or an ACME state machine + +cert resolver) once and shares it across multiple transports — quinn, +`tokio-rustls` (TCP+TLS), and iroh — so one certificate identity serves +QUIC and TCP endpoints simultaneously. Builds a `rustls::ClientConfig` +with ADR-034 verifier selection and ADR-084 crypto provider, shared +across all outbound-dialing crates (hub, worker, `CallClient`, +`ChannelClient`). One ACME state machine, one cert, N transports; +one verifier rule, N clients. ## What @@ -388,33 +391,62 @@ behind a `tcp` feature, as an owned transport on `AlknetEndpoint` (via cert provider, not the accept loop. This keeps `alknet-tls` focused on TLS setup and cert sharing, not transport accept logic. -### Server-only (for now) — client-side config is a separate question +### Client-side — `TlsClientConfig` (ADR-087) -`alknet-tls` as specified here is **server-side only**: -`TlsServerConfig` and its accessors (`for_quinn`, `for_tcp_tls`, -`rustls_config`) produce `rustls::ServerConfig`-shaped outputs for -inbound listeners. There is no `TlsClientConfig`, no `for_client()`, -no client-side cert/verifier helper in this crate. +`alknet-tls` provides a client-side config alongside `TlsServerConfig`. +A hub dials out to workers it supervises and to other hubs +(hub-as-client); `alknet-worker` dials a hub. Both need a +`rustls::ClientConfig` with ADR-034's verifier selection and ADR-084's +crypto provider. `TlsClientConfig` centralizes this — it is not a +future extraction deferred behind the dial seam (OQ-55); it is a +present prerequisite for the first hub deployment. -The client side — the `rustls::ClientConfig` construction used by -`CallClient` / `ChannelClient` for outbound dials, including -[ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md)'s -verifier-selection rule (fingerprint pin for known peers, CA-verify for -unknown X.509, fail-closed for unknown raw-key) — currently lives in -`alknet-call`'s `FingerprintPinVerifier`. ADR-084 requires the client -side to use the same `aws_lc_rs` provider; today that is enforced by -**convention** (two crates independently constructing -`aws_lc_rs::default_provider()`), not by shared code. +```rust +pub struct TlsClientConfig { + config: rustls::ClientConfig, +} -Whether `alknet-tls` should grow a client-side helper (a -`TlsClientConfig` that centralizes verifier selection + provider -consistency, so `alknet-call` / a future `AlknetClient` don't each -rebuild it) is **deferred** — see OQ-64. The deferral blocks on the -`AlknetClient` dial-seam extraction (OQ-55): the client-side TLS helper -and the shared dial are the same seam, and extracting either without a -second transport's real client dial would bake QUIC in as the client -shape — the same welding ADR-065 unwound on the server side. The -provider-consistency convention (ADR-084) holds until then. +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; +} +``` + +The `ClientVerifierContext` carries the inputs to ADR-034's verifier +selection (whether a `PeerEntry` exists for the remote, the expected +fingerprint, the remote cert type). The exact struct shape is an +implementation detail; the decisions are in ADR-034. The full +`TlsError` variant granularity (now covering both server and client +errors) is OQ-63 (next session). + +`TlsClientConfig` produces a `rustls::ClientConfig`; the caller (the +transport-specific dial helper — `CallClient::connect_quic`, a future +`connect_tcp_tls`, etc.) passes it to the transport's connector. The +config is transport-agnostic; the dial is not. This is the client-side +analogue of ADR-065's server-side separation: the take-over +(`spawn_dispatch` / `from_connection`, transport-agnostic) is built +now; the dial (transport-specific) is per-transport. The +transport-polymorphic dial extraction (`AlknetClient::dial()`) remains +deferred (OQ-55) — it is about picking the transport and calling the +right connector, not about the TLS config. + +### Iroh — shares the key, not the config (client side too) + +Iroh's client side, like its server side (above), does not consume a +`rustls::ClientConfig` — it takes an `iroh::SecretKey` and handles TLS +internally. The iroh client dial does not use `TlsClientConfig`. The +verifier selection for iroh is fingerprint-pinning by another name: +iroh's built-in TLS verifies the remote's `NodeId` (Ed25519 public key) +against the expected `NodeId`. An unknown iroh remote fails closed +(ADR-034 §3, Assumption 1 — no CA to fall back to). The iroh dial +helper applies the same ADR-034 rule via iroh's own API; the +consistency is in the rule, not in the type. ## Crate dependencies (in the dep graph) @@ -455,6 +487,7 @@ All design decisions are documented as ADRs in | [083](../../decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as multi-transport accept-loop runner | `AlknetEndpoint` takes no TLS config; TCP+TLS is an owned transport (`with_tcp_tls`); `dispatch` public for SSH/WT; `acme-tls/1` guard moves to shared `dispatch` | | [084](../../decisions/084-aws-lc-rs-crypto-provider.md) | aws-lc-rs crypto provider | `rustls::crypto::aws_lc_rs::default_provider()` on all server + client config paths; matches iroh; FIPS-capable; do not switch to `ring` or process-default without a new ADR | | [086](../../decisions/086-endpoint-types-and-entry-points.md) | Endpoint types and entry points | Three endpoint types (web/native/iroh); split ALPN lists per endpoint type (resolves OQ-62); entry-point vs. endpoint ALPN distinction | +| [087](../../decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` not blocked on dial seam | `alknet-tls` provides `TlsClientConfig` (client-side); not deferred behind OQ-55; breaks the circular hedge; hub-as-client is a first-class use case | ## Open Questions @@ -484,12 +517,13 @@ See [open-questions.md](../../open-questions.md) for full details. - **OQ-63** (open): `TlsError` shape — the error type is referenced in public signatures but not sketched. Needs a variant-granularity decision (single enum vs thin wrapper) before implementation. -- **OQ-64** (deferred): Client-side TLS config helper. `alknet-tls` is - server-side only as specified; the client-side `ClientConfig` + - verifier selection lives in `alknet-call`. Blocked on the - `AlknetClient` dial-seam extraction (OQ-55) — extracting the client - helper without a second transport's dial would bake QUIC in as the - client shape. +- **OQ-64** (resolved): `alknet-tls` provides `TlsClientConfig` + (ADR-087). Not blocked on the dial-seam extraction (OQ-55) — the TLS + config is a prerequisite for the dial, not a consequence of it. + Centralizes ADR-034 verifier selection + ADR-084 provider; the + hub-as-client requirement makes it a prerequisite for the first hub + deployment. The dial seam (OQ-55) remains deferred; the TLS config + does not. ## References @@ -507,6 +541,9 @@ See [open-questions.md](../../open-questions.md) for full details. - `docs/architecture/decisions/086-endpoint-types-and-entry-points.md` — three endpoint types (web/native/iroh); split ALPN lists per endpoint type (resolves OQ-62); entry-point vs. endpoint distinction +- `docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md` + — `TlsClientConfig` (client-side); not blocked on the dial seam; + breaks the circular hedge; hub-as-client requirement - `docs/architecture/crates/core/endpoint.md` — current endpoint design (TLS section will be amended to point to `alknet-tls`) - `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig` diff --git a/docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md b/docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md new file mode 100644 index 0000000..38e999b --- /dev/null +++ b/docs/architecture/decisions/087-tlsclientconfig-not-blocked-on-dial.md @@ -0,0 +1,293 @@ +# ADR-087: `TlsClientConfig` Is Not Blocked on the Dial Seam + +## Status + +Accepted (resolves OQ-64) + +## 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:` / `SHA256:`) 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; +} +``` + +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). + +This is **not** the dial. `TlsClientConfig` produces a +`rustls::ClientConfig`; the caller (the transport-specific dial helper, +or `CallClient::connect_quic`, or a future `connect_tcp_tls`) passes +it to the transport's connector. The config is transport-agnostic; the +dial is not. + +### 2. The dial seam (OQ-55) is unaffected + +OQ-55 (the `AlknetClient` transport-polymorphic dial extraction) +remains deferred. The deferral is about the *dial* — +transport-specific connection establishment — not about the TLS config. +With `TlsClientConfig` in `alknet-tls`, each transport-specific dial +helper (`CallClient::connect_quic`, a future `connect_tcp_tls`, a +future `connect_iroh`) 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. `TlsClientConfig::new` centralizes the + verifier construction (including the CA-verify and fail-closed + paths that `FingerprintPinVerifier` does not cover). The + `FingerprintPinVerifier` type may move to `alknet-tls` or stay in + `alknet-call` and be constructed by `TlsClientConfig::new` — an + implementation detail, not an architecture decision. + +## 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` \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index 37db0f4..f37ed30 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -185,7 +185,7 @@ Door type is separate from whether a decision is made. A two-way door is a decis |----|-------|--------|------|-----| | [OQ-62](questions/062-alpn-list-sharing-two-config-hub.md) | Does a Hub Pass the Same ALPN List to Both `TlsServerConfig`s? | resolved | one | high | | [OQ-63](questions/063-tlserror-shape.md) | `TlsError` Shape | open | one | high | -| [OQ-64](questions/064-client-side-tls-helper.md) | Should `alknet-tls` Provide a Client-Side TLS Config Helper? | deferred(scope) | two | med | +| [OQ-64](questions/064-client-side-tls-helper.md) | Should `alknet-tls` Provide a Client-Side TLS Config Helper? | resolved | one | high | | [OQ-65](questions/065-websocket-carrying-channels.md) | Should WebSocket Carry the Channels Protocol (Not Just the Call Protocol)? | open | one | med | ## Deferred / Blocked @@ -294,17 +294,13 @@ filtering the tables above. ### OQ-64: Should `alknet-tls` Provide a Client-Side TLS Config Helper? -- **Blocked on**: the `AlknetClient` dial-seam extraction (OQ-55). The - client-side TLS helper and the shared dial are the same seam — both - answer "how does an outbound connection build its - `rustls::ClientConfig` + select a verifier (ADR-034) + dial." The - blocking condition is the same as OQ-55: a second transport's real - client dial existing (TCP+TLS, SSH raw-TCP, HTTP-wrapped call), so the - transport-polymorphic client+TLS seam is extractable from two - *different* transport implementations, not one QUIC shape. Until then, - `alknet-tls` is server-side only; the client side lives in - `alknet-call`'s `FingerprintPinVerifier`, with provider consistency - (ADR-084) enforced by convention. -- **Priority**: medium +- **Resolved** (ADR-087): `alknet-tls` provides `TlsClientConfig`. Not + blocked on the dial-seam extraction (OQ-55) — the TLS config is a + prerequisite for the dial, not a consequence of it. The circular + hedge (TLS config deferred behind the dial, dial needs the TLS + config) is broken. The hub-as-client requirement makes it a + prerequisite for the first hub deployment. See + [ADR-087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) and + [OQ-64](questions/064-client-side-tls-helper.md). - **Full file**: [OQ-64](questions/064-client-side-tls-helper.md) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index a98bdde..75cecbf 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -97,7 +97,7 @@ alknet-vault (standalone — foundational to ACL: key derivation, identity) │ alknet-core ProtocolHandler, endpoint (multi-transport accept loop), │ │ Connection, BidiStreamSource, AuthContext, IdentityProvider, │ │ StaticConfig, DynamicConfig -│ ├── alknet-tls TlsServerConfig — shared TLS config across quinn + TCP+TLS + iroh (ADR-082) +│ ├── alknet-tls TlsServerConfig + TlsClientConfig — shared TLS config across quinn + TCP+TLS + iroh (ADR-082/087) │ ├── alknet-call CallAdapter on alknet/call, CallClient, OperationRegistry, adapters │ └── alknet-channels │ ├── alknet-channels-core pure multiplexer (wire format, demux/mux) — ADR-081 @@ -370,6 +370,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | [083](decisions/083-endpoint-as-accept-loop-runner.md) | Endpoint as Multi-Transport Accept-Loop Runner | Endpoint takes no TLS config; TCP+TLS is an owned transport; public `dispatch` for SSH/WT | | [085](decisions/085-workspace-scope-core-vs-consumer-repos.md) | Workspace Scope — Core vs. Consumer Repos | Core mono-repo (substrate + deployment shapes + foundational handlers + vault) vs. consumer repos (docker, agent) | | [086](decisions/086-endpoint-types-and-entry-points.md) | Endpoint Types and Entry Points | Three endpoint types (web/native/iroh); entry-point vs. endpoint ALPN distinction; split ALPN lists per endpoint type (resolves OQ-62) | +| [087](decisions/087-tlsclientconfig-not-blocked-on-dial.md) | `TlsClientConfig` Not Blocked on Dial Seam | `alknet-tls` provides client-side TLS config; not deferred behind OQ-55; breaks the circular hedge; hub-as-client is a first-class use case | ## Open Questions diff --git a/docs/architecture/questions/055-alknetclient-establishment-extraction.md b/docs/architecture/questions/055-alknetclient-establishment-extraction.md index 30c3e92..f258115 100644 --- a/docs/architecture/questions/055-alknetclient-establishment-extraction.md +++ b/docs/architecture/questions/055-alknetclient-establishment-extraction.md @@ -19,41 +19,62 @@ and is not the thing being deferred — the shared *dial* is. The deferral is on transport-polymorphism of the dial, not on client count or on the channels protocol's API. +- **What is NOT deferred (amended by ADR-087)**: the client-side TLS + config (`TlsClientConfig`) is **not** part of this deferral. OQ-64 + is resolved — `alknet-tls` provides `TlsClientConfig` now, unblocked + by this OQ. The TLS config is a **prerequisite** for the dial, not a + consequence of it; it is transport-agnostic (ADR-034 verifier + selection + ADR-084 provider, both already decided). Each + transport-specific dial helper builds its `TlsClientConfig` and + passes it to its transport's connector. This OQ defers only the + *transport-polymorphic dial extraction* — the shared `AlknetClient::dial()` + that picks the transport and calls the right connector. When a second + transport's dial exists, the dial seam is extractable; the TLS config + is already shared by then. - **Resolution**: Not yet decidable. The shared substance across TLS-carrying transports is ADR-034's verifier-selection rule (PeerEntry presence → fingerprint pin : CA-verify / fail-closed) and the - `rustls::ClientConfig` construction — ~20 lines, transport-agnostic in - rule. But the *dial* is transport-specific, and we have one of ~5 shapes - implemented (QUIC; the others being HTTP, TCP+TLS, WebTransport, raw - TCP). Extracting a QUIC-shaped connector to core and naming it - `AlknetClient` would bake QUIC in as *the* establishment shape — the - same welding ADR-065 unwound on the server side, repeated on the client - side. The dial is transport-polymorphic; the shared rule is narrow. Until - a second transport's dial exists, the seam between "dial + TLS" (per- - transport) and "spawn the dispatcher" (per-crate) is not extractable from - two real shapes — it's guessable from one. + `rustls::ClientConfig` construction — now centralized in + `TlsClientConfig::new` (ADR-087, OQ-64 resolved). What remains + transport-specific is the *dial* itself — `quinn::Endpoint::connect`, + `TcpStream::connect` + `TlsConnector::connect`, iroh's + `Endpoint::connect`. We have one of ~5 shapes implemented (QUIC; the + others being HTTP, TCP+TLS, WebTransport, raw TCP). Extracting a + QUIC-shaped connector to core and naming it `AlknetClient` would bake + QUIC in as *the* establishment shape — the same welding ADR-065 + unwound on the server side, repeated on the client side. The dial is + transport-polymorphic; the shared TLS config is narrow and now + extracted (ADR-087). Until a second transport's dial exists, the seam + between "dial" (per-transport) and "spawn the dispatcher" (per-crate) + is not extractable from two real shapes — it's guessable from one. - **What does NOT block on this**: each crate building its own client - standalone, and each transport-specific dial helper. `CallClient`'s - transport-agnostic take-over (`spawn_dispatch`) and `ChannelClient`'s - transport-agnostic take-over (`from_connection`, ADR-080) are decided; - `connect_quic` dials QUIC and calls `from_connection`. The SSH crate's - TCP client, the HTTP call client, a `connect_tcp_tls` / `connect_webtransport` helper — each builds its own dial standalone. Core - already permits all of this — `Connection::from_stream` / `from_bidi` - (ADR-065) handles the non-QUIC transport on the server side, and nothing - prevents a client from constructing a `Connection` the same way after - its own transport-specific dial. The friction is duplicated boilerplate - (each dial helper rebuilds verifier selection), not a missing - capability and not a QUIC-welded client API. The bidirectionality - criterion (a crate needs a Client type when (a) the endpoint has - protocol-level authority — e.g., channels' id allocation — or (b) the - protocol needs a reliable establishment interface) is met by each crate - independently; `AlknetClient` is the eventual *shared dial+TLS seam*, - not a prerequisite for any single client to exist. -- **Cross-references**: ADR-034 (verifier selection — currently in - `CallClient`, would move to the extracted seam when this is resolved), - ADR-065 (server-side transport generalization — the client-side analogue - this OQ's deferral avoids preempting), ADR-070 (the `BidiStreamSource` - extension point, which is the *Connection* opening and is orthogonal to - the *client* establishment question), OQ-CH-14 in + standalone with a shared `TlsClientConfig` (ADR-087), and each + transport-specific dial helper. `CallClient`'s transport-agnostic + take-over (`spawn_dispatch`) and `ChannelClient`'s transport-agnostic + take-over (`from_connection`, ADR-080) are decided; `connect_quic` + dials QUIC and calls `from_connection`. The SSH crate's TCP client, + the HTTP call client, a `connect_tcp_tls` / `connect_webtransport` + helper — each builds its `TlsClientConfig` (ADR-087) and its own dial + standalone. Core already permits all of this — + `Connection::from_stream` / `from_bidi` (ADR-065) handles the + non-QUIC transport on the server side, and nothing prevents a client + from constructing a `Connection` the same way after its own + transport-specific dial. The friction is the duplicated dial + boilerplate (each dial helper calls its transport's connector), not + duplicated TLS config (that is shared via ADR-087). The + bidirectionality criterion (a crate needs a Client type when (a) the + endpoint has protocol-level authority — e.g., channels' id allocation + — or (b) the protocol needs a reliable establishment interface) is + met by each crate independently; `AlknetClient` is the eventual + *shared dial seam*, not a prerequisite for any single client to + exist. +- **Cross-references**: ADR-034 (verifier selection — centralized in + `TlsClientConfig::new` per ADR-087), ADR-087 (`TlsClientConfig` is + not blocked on this OQ — the TLS config is extracted; only the dial + remains deferred), ADR-065 (server-side transport generalization — + the client-side analogue this OQ's deferral avoids preempting), + ADR-070 (the `BidiStreamSource` extension point, which is the + *Connection* opening and is orthogonal to the *client* establishment + question), OQ-CH-14 in `docs/research/alknet-channels/phase-0-findings.md` (the research-scope question this core-scope OQ carries forward). \ No newline at end of file diff --git a/docs/architecture/questions/064-client-side-tls-helper.md b/docs/architecture/questions/064-client-side-tls-helper.md index 840ffcc..95a5571 100644 --- a/docs/architecture/questions/064-client-side-tls-helper.md +++ b/docs/architecture/questions/064-client-side-tls-helper.md @@ -1,55 +1,60 @@ # OQ-64: Should `alknet-tls` Provide a Client-Side TLS Config Helper? - **Origin**: `docs/architecture/crates/tls/README.md` (the "Server-only - for now" section flags this as deferred); ADR-084 (requires the + for now" section flagged this as deferred); ADR-084 (requires the client-side `rustls::ClientConfig` to use the same `aws_lc_rs` - provider — currently enforced by convention, not shared code). -- **Status**: deferred(scope) -- **Door type**: two-way (adding a client helper to `alknet-tls` is - additive; the risk is not the addition but *extracting it - prematurely* and baking in a QUIC-shaped client — see Blocking on) -- **Priority**: medium -- **Blocked on**: the `AlknetClient` dial-seam extraction (OQ-55). The - client-side TLS helper and the shared dial are the same seam: both - answer "how does an outbound connection build its - `rustls::ClientConfig` + select a verifier (ADR-034) + dial the - transport." Extracting the TLS helper without a second transport's - real client dial existing would bake the QUIC client's shape into a - shared helper — the same welding ADR-065 unwound on the server side. - The blocking condition is the same as OQ-55: a second transport's - real dial (TCP+TLS, SSH raw-TCP, HTTP-wrapped call) existing, so the - transport-polymorphic client+TLS seam is extractable from two - *different* transport implementations. -- **Resolution**: Not yet decidable. `alknet-tls` is server-side only - as specified. The client side — `rustls::ClientConfig` construction + - ADR-034 verifier selection (fingerprint pin for known peers, CA-verify - for unknown X.509, fail-closed for unknown raw-key) — lives in - `alknet-call`'s `FingerprintPinVerifier` today. The - provider-consistency requirement (ADR-084: `aws_lc_rs` on all paths) - is enforced by convention (two crates independently constructing - `aws_lc_rs::default_provider()`) until this OQ is resolved. + provider — previously enforced by convention, not shared code). +- **Status**: resolved +- **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 re-distribute verifier selection + + provider wiring across crates.) +- **Priority**: high (upgraded from medium — the hub-as-client + requirement makes this a prerequisite for the first hub deployment, + not a future extraction) +- **Resolution**: **Yes. `alknet-tls` provides `TlsClientConfig`.** It + is not blocked on the dial-seam extraction (OQ-55). - What does NOT block on this: each client (`CallClient`, - `ChannelClient`) building its own `ClientConfig` standalone with the - matching provider. The friction is duplicated boilerplate (each - client rebuilds verifier selection + provider wiring), not a missing - capability and not a QUIC-welded client API. The - transport-agnostic take-over (`CallClient::spawn_dispatch`, - `ChannelClient::from_connection` — ADR-080) is decided and is not - the thing being deferred; only the shared *client TLS config helper* - is. + The previous deferral linked OQ-64 and OQ-55 as "the same seam," + creating a circular dependency: the TLS config is deferred behind the + dial, but the dial needs the TLS config. The circle is broken by + separating the two concerns: - Note on the hub-as-client case: a hub (A) that dials another hub (B) - uses a client to do so — from B's perspective A is a worker. The - bidirectionality of the call and channels protocols means both sides - can be both hub and worker within a connection. This does not change - the blocking condition: the shared client TLS helper is still about - the *dial*, regardless of whether the dialer is a hub, a worker, or a - hub-worker. The hub-as-client case is a use case that the resolved - helper must cover, not a reason to resolve it now. -- **Cross-references**: OQ-55 (the `AlknetClient` dial-seam extraction - — this OQ's blocking condition), ADR-034 (verifier selection — the - rule the helper would centralize), ADR-084 (provider consistency — - the convention that holds until this is resolved), ADR-065 (the - server-side transport generalization this OQ's deferral avoids - preempting on the client side) \ No newline at end of file + 1. **`TlsClientConfig`** — the `rustls::ClientConfig` + ADR-034 + verifier selection + ADR-084 crypto provider. Transport-agnostic. + All decisions are made. Buildable today. It is a **prerequisite** + for any dial, not a consequence of it. + 2. **The dial** (`AlknetClient::dial()`) — transport-specific + connection establishment. Extracting a transport-polymorphic dial + from one shape (QUIC) would bake QUIC in. **Legitimate deferral + (OQ-55, unchanged).** + + `TlsClientConfig::new` takes a verifier context (the inputs to + ADR-034's rule: `PeerEntry` presence, expected fingerprint, remote + cert type) and returns a `rustls::ClientConfig`. The caller + (transport-specific dial helper, `CallClient::connect_quic`, a future + `connect_tcp_tls`) passes it to the transport's connector. Iroh is + the exception — it has its own TLS and does not consume a + `rustls::ClientConfig`; the iroh dial helper applies the same + ADR-034 rule via iroh's `NodeId` verification API. + + The hub makes this non-optional: a hub dials out to workers it + supervises and to other hubs (hub-as-client). The hub's + `dial_worker_connection` / `supervise_worker` need a + `TlsClientConfig` for the outbound dial. The first hub deployment + (web + native) dials workers over QUIC with the worker's fingerprint + pinned. There is no "later" — it is on the critical path for the + first hub and for `alknet-worker`. + + See [ADR-087](../decisions/087-tlsclientconfig-not-blocked-on-dial.md) + for the full decision, including the circular-hedge analysis, the + hub-as-client requirement, and the iroh exception. +- **Cross-references**: [ADR-087](../decisions/087-tlsclientconfig-not-blocked-on-dial.md) + (the decision), [ADR-034](../decisions/034-outgoing-only-x509-and-three-peer-roles.md) + §3 (verifier selection — the rule `TlsClientConfig::new` + centralizes), [ADR-084](../decisions/084-aws-lc-rs-crypto-provider.md) + (provider consistency — enforced by code, not convention, for the + client side), [ADR-082](../decisions/082-alknet-tls-extraction.md) + (`TlsServerConfig` — the server-side analogue), OQ-55 (the dial seam + — remains deferred; this OQ's resolution does not affect it), + OQ-63 (`TlsError` shape — now covers both server and client variants) \ No newline at end of file