Files
alktls/docs/architecture/decisions/008-server-path-possession-verification.md
T
glm-5.3-flash 23893d6236 re-baseline the four pending remediation tasks against the ADR-007/008 tree
The tasks were decomposed (d7db6b1) before 49d4432/ac440f3 landed;
both commits touched exactly the areas the tasks reference. Fixes
grounded in verified sources (vendored rustls-acme 0.12.1, rustls-pemfile
2.2.0, rustls-native-certs 0.8.4) and a fresh cargo llvm-cov run:

- acme-event-loop-test: the termination assertion was impossible —
  rustls-acme's Stream for AcmeState never yields None
  (state.rs:407-412, poll_next_infinite + 2^16s backoff); the review's
  U-1 exit-condition premise is withdrawn and corrected in place.
  Replaced with timeout-bounded event collection, a reachable-arm
  inventory (Order warn, AccountCacheStore, Load/Parse error arms,
  DeployedCachedCert/CertCacheStore via deterministic DirCache file
  pre-seeding), and an explicit mark for the full-fake-CA arms.
  server.rs:135 flagged as unreachable dead code (delete or accept).
- coverage-cheap-closes: re-baselined per-line ground truth — original
  groups 1 and 5 are already closed by the handshake suites; group 2's
  TLS 1.3 half is covered, leaving the TLS 1.2 else-arm (client.rs:316);
  new group added for VerifyPresentedCertVerifier::verify_tls12_signature
  (server.rs:349-366, opened by ADR-008; required for the >=98% bar);
  AcceptAnyCertVerifier refs moved to server.rs:468-475 with the stale
  OQ-TLS-09 coordination caveat retired.
- docs-pin-c1-c4-n3-n4: N-4 re-scoped (the mechanism analysis already
  lives in ADR-007 + the resolver doc block; what remains is a short
  server-verifier note covering both verifiers); C-4 updated for
  ADR-007's negotiation-earlier failure point; added the
  FingerprintPinVerifier pop cross-ref update (post-ADR-008 the default
  verifier does verify possession).
- config-validation-and-trivia: added the feature-gate mechanics note
  for the C-3 test (a non-gated test passes vacuously under default
  features); refreshed drifted line refs with a re-grep advisory.
- review 001: Status block records OQ-TLS-09/-10 resolutions; U-1
  carries the termination correction; U-2 carries the supersession
  note. ADR-008 gains the suite-number-to-test-name mapping.

Verification: taskgraph validate 14 tasks; cargo doc --no-deps
warning-free; all edits docs-only (no code paths touched).
2026-09-12 02:42:20 +00:00

7.5 KiB

status, last_updated
status last_updated
accepted 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)