Close review 001 §S-1: the default client-cert verifier never checked the client's CertificateVerify, so anyone holding a peer's *public* cert/SPKI bytes (public by design — peers publish them to be dialable) could complete a handshake as that peer, and the auth layer could not detect it. The consumer designs are known (X.509 and raw-key TCP/QUIC endpoints with identity-bearing clients), so implementing now — the zero-consumer moment — avoids the guaranteed breaking republish of flipping the default later. - VerifyPresentedCertVerifier (new): request, don't require, verify possession — permissive verify_client_cert (self-signed chains and bare SPKIs stay valid presentation) + CertificateVerify routing by presented cert kind (Ed25519 SPKI -> verify_tls13_signature_with_ raw_key both TLS versions; X.509 -> standard route), the same routing FingerprintPinVerifier implements. Nine-scheme list verbatim (shared fn, exact-list pin covers both). - Default on every TlsServerConfig path — X509 / RawKey / SelfSigned / ACME (the verifier install is crate-side rustls in new_acme, not rustls-acme's). - AcceptAnyCertVerifier stays public as the explicit no-pop escape hatch, no longer installed by any crate path. - tests/impersonation_posture.rs: four pins — default rejects the attacker (X.509: UnsupportedSignatureAlgorithmForPublicKeyContext; raw-key: BadSignature), escape hatch still accepts + extracts the victim's fingerprint (both cert types). - tests/handshake_behavior.rs: suites 4/4b — possession-checked legit clients (raw-key pin vs raw-key server; X.509 client vs X.509 server) complete and the server extracts the fingerprint; suite 3b doc updated. - Docs: ADR-008 written; OQ-TLS-09 -> resolved (option (b)); ADR-007 §Limits deferral retired to not-planned; server.md/client.md invariants/README/overview synced. Verification: cargo test 81 / --features tcp 95 / --all-features 104 green; clippy -D warnings clean (default + all-features); fmt clean; cargo doc warning-free.
165 lines
7.2 KiB
Markdown
165 lines
7.2 KiB
Markdown
---
|
|
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<Self, TlsError>;
|
|
|
|
#[cfg(feature = "noq")]
|
|
pub fn for_noq(self) -> Result<noq::ClientConfig, TlsError>;
|
|
|
|
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<TlsIdentity>`** — 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<RemoteIdentity>`** — 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:<hex>` 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:<hex>` 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:<hex>` remotes the raw Ed25519 key is extracted from the
|
|
presented cert (SPKI) and matched; for `SHA256:<hex>` 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) |