--- status: reviewed last_updated: 2026-09-11 --- # alktls — Client side `TlsClientConfig` and its verifiers: the client-side TLS setup, extracted from alknet (`crates/alknet-tls/src/client.rs`) and ported per the ADRs. Decisions referenced here: see [open-questions.md](open-questions.md) and the index in [overview.md](overview.md). ## `TlsClientConfig` Built **per dial** from a `ConnectionCredentials` + ALPN (the dial-seam pattern, alknet ADR-089). Consumed by its accessors — a `TlsClientConfig` is not reused across dials (ADR-004). ```rust pub struct TlsClientConfig { rustls_config: rustls::ClientConfig, } impl TlsClientConfig { pub fn new(credentials: &ConnectionCredentials, alpn: &[u8]) -> Result; #[cfg(feature = "noq")] pub fn for_noq(self) -> Result; pub fn into_rustls_config(self) -> rustls::ClientConfig; } ``` `new` is sync and infallible-free aside from config construction (cert file loading for an X.509 local identity is the only I/O). Every config sets `enable_early_data = true` (the client half of the 0-RTT invariant) and carries the aws-lc-rs provider (alknet ADR-084). ## The two credential dimensions `ConnectionCredentials` (ADR-005, moved from alknet ADR-091) carries exactly the two inputs the client config consumes: 1. **`local_identity: Option`** — the client-auth cert presentation: | Local identity | Presented | |----------------|-----------| | `RawKey` | the Ed25519 SPKI as the client cert, under the X.509 offer (ADR-007 — the cert-type extension is an offer format, not an identity statement; the server extracts the `ed25519:` fingerprint either way) | | `X509` | the cert chain + key, loaded from disk | | `SelfSigned` / `None` | nothing (`NoClientCertResolver`) — documented, resolved in OQ-TLS-02 | | `Acme` | **config error** (`TlsError::AcmeConfig`) — server-only identity | 2. **`remote_identity: Option`** — the verifier selection matrix (below). Both `Option`s are load-bearing, not cosmetic: `Some` means "pin this", `None` means "trust the CA or fail" — never a placeholder default. ## Verifier selection (alknet ADR-034 §3) Exactly three outcomes, driven by `remote_identity`: | `remote_identity` | Remote cert | Verifier | Outcome | |-------------------|-------------|----------|---------| | `Some(fingerprint)` | any | `FingerprintPinVerifier` | pin match required | | `None` | X.509 | `WebPkiServerVerifier` (CA) | CA verification against the platform root store | | `None` | Ed25519 raw key | (the CA verifier fails) | **fail closed at handshake** | `None` is the public-X.509-endpoint state, **not** "skip verification". An unknown raw-key remote fails at handshake — it must never silently downgrade to CA verification. Verifier selection happens at config construction; the fail-closed *manifests* at handshake time (the config carries the CA verifier; the raw-key remote simply cannot satisfy it) — this is the ADR-002 scope boundary in action. **Fail-closed is structural**: known peer + fingerprint → pin; unknown + X.509 → CA; unknown + raw key → fail. No fourth path. All three outcomes are **executed** in `tests/handshake_behavior.rs` (real rustls handshakes over a duplex pair, `tcp`-gated). **RFC 7250 over TCP is the crate's own composition now** (ADR-007, resolving OQ-TLS-10): `FingerprintPinVerifier` derives its cert-type offer from the pin format — an `ed25519:` pin offers `server_certificate_types = [RawPublicKey]` and completes against a crate-built raw-key server (`raw_key_server_path_completes_with_crate_pin_client`); a `SHA256:` pin keeps the X.509 offer. A raw-key *client* identity presents its SPKI under the X.509 offer and the server's default verifier possession-verifies it end-to-end (`raw_key_client_presents_spki_and_server_extracts_fingerprint`, `raw_key_client_vs_raw_key_server_default_verifier_checks_possession`). A pin-format/cert-kind mismatch fails closed at negotiation, never a downgrade (`ed25519_pin_against_x509_server_fails_closed_at_negotiation`). Raw-key peers that ride iroh/noq use those transports' own TLS and are unaffected. ## `FingerprintPinVerifier` The known-peer path. The fingerprint IS the trust anchor: for `ed25519:` remotes the raw Ed25519 key is extracted from the presented cert (SPKI) and matched; for `SHA256:` remotes the full cert DER is hashed and matched. No CA verification, no name verification — only the pin. Handshake signatures are still verified (TLS 1.2/1.3, aws-lc-rs algorithms; Ed25519 SPKI certs route through `verify_tls13_signature_with_raw_key`): the presenter must prove possession of the corresponding private key, so a stolen or observed certificate cannot be used by a party that does not hold the matching key. The same possession rule now holds server-side: `VerifyPresentedCertVerifier` is the default client-cert verifier on every server path (ADR-008, OQ-TLS-09); `AcceptAnyCertVerifier` remains the explicit no-pop escape hatch (see [server.md](server.md)). The cert-type offer follows the pin format (ADR-007): `requires_raw_public_keys()` returns `true` for `ed25519:` pins (the peer presents an RFC 7250 raw key), `false` for `SHA256:` pins (the peer presents an X.509 cert) — a mismatched pairing fails closed at negotiation. ## The root-store fallback (alknet ADR-088 §5) The `None` + X.509 CA path loads the platform's native root certs (`rustls-native-certs`). If the platform store is empty — a containerized deployment with no system CA bundle — the built-in `webpki-roots` are merged in so the store is **never empty**. Native-certs *load* errors are logged, not returned; the fallback guarantees non-emptiness regardless. This makes `NoRootAnchors` unreachable in practice (the `VerifierBuild` variant exists for the builder API's completeness, not a reachable path — ADR-002). The root store is built unconditionally for the CA path — not feature-gated on `tcp` or `noq` — because any client dialing a public X.509 endpoint needs it regardless of transport. ## What the client side does NOT do - No dial: the dial seam (`AlknetClient` in alknet; the rewrite's dial crate) consumes the config via `for_noq()` / `into_rustls_config()` and owns `TcpStream::connect`, `Endpoint::connect`, SNI, and SOCKS5 proxying (alknet ADR-089; SOCKS5 proxy seam per alknet ADR-090). Handshake failures are the dial's errors (`ClientDialError`), not `TlsError`. - No iroh: the iroh dial takes the 32-byte Ed25519 key directly (key-not-config; ADR-003). iroh's built-in verifier (NodeId check) is iroh's own application of the same fail-closed rule. - No per-request auth: a call-protocol `auth_token` is a call-layer concept; it never reaches `TlsClientConfig`. ## References - [overview.md](overview.md) — the index; [server.md](server.md) — the server side - alknet `crates/tls/README.md` §Client side — the full client-side spec this doc mirrors - alknet ADR-034 (verifier selection), ADR-088 §5 (root-store fallback), ADR-091 (`ConnectionCredentials`) - ADR-002 (`TlsError`), ADR-004 (accessors), ADR-005 (credential types), ADR-006 (tests — the verifier-selection matrix), ADR-007 (the cert-type negotiation / offer-follows-identity rule)