--- status: accepted last_updated: 2026-09-11 --- # ADR-008: Server-path proof-of-possession — the verifying verifier is the default ## Status Accepted (2026-09-11). Resolves OQ-TLS-09. ## Context Review 001 §S-1 (executed): `AcceptAnyCertVerifier` — then the default client-cert verifier on every `TlsServerConfig` path — never checked the client's CertificateVerify signature. Any party that observes a peer's public cert bytes (X.509) or SPKI (RFC 7250) could complete a handshake *as that peer*: the handshake completed, `peer_certificates()` yielded the victim's cert, and the extracted fingerprint — the identity every downstream auth decision consumes — was the victim's. The auth layer could not detect the spoofing: the fingerprint it was handed *was* the victim's. The public cert/SPKI bytes are public **by design** — a peer must publish its identity to be dialable (branch-2 endpoints publish the same Ed25519 public key an SSH server would). So the observed-by-anyone attack surface was every peer identity in the stack, and the only proposed mitigation (challenge-response owned by the auth layer, the rewrite's option (a)) did not exist anywhere in the alk* codebase. The spoil was fully known and the fix fully specified — mirroring the signature routing the client-side `FingerprintPinVerifier` already implements — so "wait for a consumer" (the OQ's deferral framing) repeated the circular reasoning OQ-TLS-10 fell into: the component that must *enable* the consumer was deferring to the consumer it enables. The consumer designs are known (TCP/QUIC servers with X.509 or raw-key identities and identity-bearing clients — alkhttp, webtransport, the rewrite's endpoints); possession verification is needed by every one of them that authenticates by fingerprint. ## Decision `VerifyPresentedCertVerifier` is the **default** client-cert verifier on every `TlsServerConfig` path — X509, RawKey, SelfSigned, and ACME alike (the verifier install is crate-side rustls in `new_acme`, not rustls-acme's, so the ACME path flips identically). Its posture: **request, don't require, verify possession.** - `offer_client_auth() == true`, `client_auth_mandatory() == false`, `root_hint_subjects() == &[]` — identical request-not-require shape to `AcceptAnyCertVerifier` (no-cert clients unaffected). - `verify_client_cert` accepts any presented bytes — possession is proven by the CertificateVerify, not the cert's provenance; self-signed X.509 chains and bare RFC 7250 SPKIs remain valid presentation. - `verify_tls13_signature` / `verify_tls12_signature` route by presented cert kind: an Ed25519 SPKI goes through `verify_tls13_signature_with_raw_key` (both TLS versions), an X.509 chain through the standard `verify_tls{12,13}_signature` — the same routing the client-side `FingerprintPinVerifier` implements, and the same functions rustls' webpki verifiers use. - `supported_verify_schemes()` returns the nine-scheme list verbatim (shared with `AcceptAnyCertVerifier`; the exact-list pin covers both). - `requires_raw_public_keys()` stays `false` — both cert types negotiate (ADR-007). `AcceptAnyCertVerifier` stays public as the explicit no-pop escape hatch for a deployment that deliberately wants the old posture (handshake-speed over strictness, an auth layer that owns challenge-response). It is no longer installed by any crate path. Mechanism note (rustls 0.23.44, `server/tls13.rs`): when a client presents a cert, rustls calls `verify_client_cert` then `verify_tls13_signature(construct_client_verify_message(..), cert[0], sig)` — the signature covers the transcript hash bound to the `"TLS 1.3, client CertificateVerify"` constant, so the check is a real proof-of-possession of the presented public key, not a replayable blob. A client presenting no cert skips both (the `!mandatory` branch). The attacker's failure mode is a signature under the attacker's key against the victim's public key: `BadSignature` (raw-key) or `UnsupportedSignatureAlgorithmForPublicKeyContext` (X.509 kind mismatch) — pinned by `tests/impersonation_posture.rs`. ## What this buys — and what it deliberately does not **Bought:** the extracted fingerprint is now authenticated as *"the connecting party holds the private key for the presented public identity."* The impersonation attack (victim's public bytes + attacker signer) fails the handshake on every default path, both cert types. This is the S-1 resolution for branches 1 and 2 (X.509 and raw-key TCP/QUIC endpoints with identity-bearing clients). **Not bought (deliberately, unchanged):** no CA verification and no name verification — a self-signed chain remains valid presentation, and who a fingerprint maps to (the peer table, scopes, tokens) remains the auth layer's concern (ADR-005). The verifier proves *possession*; the auth layer still decides *trust*. This is exactly the split the crate's scope boundary prescribes (ADR-002). ## Behavior-preservation invariants under the change - The nine-scheme list, request-not-require shape, non-empty root store, 0-RTT, provider, and ALPN handling are untouched. - Fail-closed: unchanged — and the new default adds a rejection where there was silent acceptance (the spoof), the direction that never breaks a legitimate composition. - Legit compositions verified end-to-end (duplex handshakes, `tests/handshake_behavior.rs`): raw-key client pin ↔ raw-key server (suite 4), X.509 client ↔ X.509 server (suite 4b), raw-key client ↔ X.509 server (suite 3b) — all extract the client fingerprint under the verifying default. No-cert clients (browsers) unaffected. (Mapping note: the suite-number labels are the test-file's `/// Suite N` doc headers — the canonical handles are the test fns: suite 4 = `raw_key_client_vs_raw_key_server_default_verifier_ checks_possession`, 4b = `x509_client_vs_default_verifier_ possession_checked`, 3b = `raw_key_client_presents_spki_and_ server_extracts_fingerprint`.) - QUIC parity: the CertificateVerify mechanism is handshake-level, identical under `for_noq()`. ## Consequences **Positive:** - S-1 closed out of the box: no deployment can accidentally run the spoofable posture; the escape hatch must be explicitly installed and its posture is test-pinned. - The auth layer's fingerprint resolution can now treat the fingerprint as a possession-authenticated claim — the option-(c) challenge-response design becomes optional defense-in-depth rather than a prerequisite. - Zero-consumer moment used correctly: flipping the default *after* first consumers would be a guaranteed breaking republish. **Negative:** - A legitimate client whose signer does not match its presented cert (a misconfigured `CertifiedKey`) now fails the handshake where it previously succeeded silently — the failure is the correct diagnosis (there is no such legitimate composition). - One more public type on the API surface (additive; the freeze allows additions). ## References - OQ-TLS-09 (`docs/architecture/open-questions.md`) — resolved by this ADR - `tests/impersonation_posture.rs` — both postures pinned (default rejects, escape hatch accepts), both cert types - `tests/handshake_behavior.rs` — legit-composition pins (suites 1, 3b, 4, 4b) - review 001 §S-1 — the discovery probe - ADR-007 (cert-type negotiation), ADR-002 (scope boundary), ADR-005 (auth layer owns peer resolution) - alknet ADR-034 — the request-but-don't-require shape this ADR refines (deviation recorded per AGENTS.md convention 10) - rustls 0.23.44 `server/tls13.rs` (the CertificateVerify flow), `webpki/verify.rs` (`verify_tls13_signature_with_raw_key`)