Files
alktls/tasks/fix-accept-any-cert-verifier-posture.md
T
glm-5.3-flash d82956385b record S-1/N-1 remediation status + the N-4 parenthetical correction
- review 001: §Status carries the remediation status (S-1 + N-1 landed
  in e86b8ba, OQ-TLS-09 open, probe permanent) and the N-4 correction
  summary; §S-1 remediation-task line updated; §N-1 marked remediated;
  §N-4 gains the corrected mechanism chain (rustls 0.23.41/0.23.44 send
  [RawPublicKey] iff the resolver's only_raw_public_keys() is true —
  no "offers both types" behavior exists; a raw-key client fails
  IncorrectCertificateTypeExtension against this verifier; the S-1
  probe presents the SPKI under the default X.509 offer instead)
- task fix-accept-any-cert-verifier-posture: drop the placeholder
  fill-in lines (Notes/Summary carry the actual record)
- task docs-pin-c1-c4-n3-n4: N-4 work item rewritten from the
  corrected chain so the implementing agent does not re-derive it;
  references point at the rustls source lines and the probe's
  resolver choice
2026-09-11 07:38:21 +00:00

8.2 KiB

id, name, status, depends_on, scope, risk, impact, level, tags
id name status depends_on scope risk impact level tags
fix-accept-any-cert-verifier-posture S-1 remediation — AcceptAnyCertVerifier no-pop posture (doc + ADR/OQ + permanent probe) completed
narrow medium project implementation
security
docs
review-001
s1

Description

The AcceptAnyCertVerifier (src/server.rs:257-318) never checks the client's CertificateVerify signature — no proof-of-possession — so any party that sees a peer's public cert/SPKI can complete a handshake as that peer, and the server hands the auth layer the victim's fingerprint. Confirmed by executable probe during review 001 and re-verified by a fresh probe in the 001-decomposition session (both X.509 and RFC 7250 raw-key variants complete the handshake with the victim's cert + the attacker's signing key; peer_certificates() yields the victim's cert and the server-extracted fingerprint equals the victim's).

The verifier behavior itself is the alknet-inherited design (alknet ADR-034) and is behavior-preserving — this is a documentation/posture finding, not a code-change mandate. What's missing:

  1. The load-bearing doc on AcceptAnyCertVerifier (src/server.rs:242-256) states "does not verify the presented cert against a CA" but nowhere states the signature is unverified. To a consumer that reads as the only gap, when the actual consequence is: presented identity is spoofable by anyone with the public bytes.
  2. No ADR/OQ records which layer owns proof-of-possession on the server path.
  3. The spoofable posture has no permanent test pinning it — a refactor that adds pop (or upstream changes the verifier contract) would land green, silently changing the documented behavior.

Work

  1. Doc on AcceptAnyCertVerifier: state explicitly that the client's CertificateVerify signature is not verified (no proof-of-possession), that consequently the fingerprint the server extracts is attacker-suppliable from observed peer fingerprints, and point at the two safe patterns the auth layer can own:
    • challenge-response over the established channel bound to the cert's public key, or
    • a server-side verifier that does verify the CertificateVerify (the same routing the client-side FingerprintPinVerifier implements — the crate has the code, just not on this type).
  2. N-1 companion line on FingerprintPinVerifier (src/client.rs:232-236): fix the "stolen-but-stale fingerprint" phrasing (the cert is presented fresh each time — the real threat is a stolen private key) and add the cross-reference: this verifier checks proof-of-possession; the server-side AcceptAnyCertVerifier does not.
  3. Record OQ-TLS-09 in docs/architecture/open-questions.md: which layer owns proof-of-possession on the server path? Options: (a) keep request-but-don't-require + auth-layer challenge-response; (b) additive VerifyPresentedCertVerifier sibling (request-and-verify — same nine-scheme list, permissive verify_client_cert, signature methods delegate to rustls::crypto::verify_tls{12,13}_signature(_with_raw_key)); (c) make the auth layer's fingerprint resolution possession-checked. Until decided, the S-1 doc note is the honest description. If option (b) is chosen, it must land before the first consumer.
  4. Permanent behavior-pin test (tests/, tcp-feature-gated): the impersonation probe as a documented test — server config via build_rustls_server_config, a client presenting the victim's cert bytes with an attacker signer completes the handshake and peer_certificates() yields the victim's fingerprint. Assert the spoofable posture in both directions (X.509 + raw-key SPKI). If a future change adds pop, this test fails and forces the doc/ADR update. Probe shape: rustls ServerConnection/ClientConnection pair over a duplex, complete_io on each — the 001-decomposition session's probe (deleted after its run) is the reference; a client-side test-local accept-anything verifier keeps the probe independent of the pin path.

Verification

  • The doc note exists on AcceptAnyCertVerifier and states the no-pop consequence in one reading
  • The N-1 companion line exists on FingerprintPinVerifier
  • OQ-TLS-09 recorded with status open and the three options
  • The permanent probe test passes and asserts the spoofable fingerprint end-to-end (both cert types)
  • cargo test, cargo clippy --all-targets -- -D warnings, cargo fmt --check, cargo doc --no-deps green
  • cargo test --all-features green (the probe is tcp-gated)

Acceptance Criteria

  • A consumer reading only the rustdoc understands that presented identity is spoofable without caller-enforced possession
  • The spoofable posture is pinned by an executed test, not just prose
  • The ownership question is tracked (OQ-TLS-09) — not silently inherited

References

  • docs/reviews/001-implementation-review.md §S-1 (the finding) and §N-1
  • src/server.rs:242-318 (AcceptAnyCertVerifier), src/client.rs:225-323 (FingerprintPinVerifier — the pop-complete routing to mirror)
  • alknet ADR-034 (the inherited request-but-don't-require shape)
  • docs/architecture/open-questions.md (where OQ-TLS-09 lands)

Notes

  • The probe used a tokio duplex pair + TlsConnector/TlsAcceptor (tokio-rustls, tcp-gated) rather than raw complete_io — same shape, cleaner await-based flow. The attacker client uses a test-local accept-anything ServerCertVerifier and a fixed ResolvesClientCert presenting the victim's bytes with the attacker's signer, only_raw_public_keys() == false in both variants (the cert-type negotiation stays X.509-typed, exactly the review-probe shape; verified against rustls 0.23.44's validate_client_cert_type_extension — an offer of only [RawPublicKey] would fail IncorrectCertificateTypeExtension against requires_raw_public_keys() == false, so the SPKI is presented under the default X.509 offer and passes through unparsed).
  • N-4 (the requires_raw_public_keys() doc note) is deliberately NOT in this task — it belongs to docs-pin-c1-c4-n3-n4. While verifying the probe shape, the review's N-4 parenthetical ("alknet's own client resolver offers both types") was found inaccurate: rustls 0.23.41 AND 0.23.44 send client_certificate_types = [RawPublicKey] whenever the resolver's only_raw_public_keys() is true — there is no "offers both types" behavior in either version. Task 6 should write the N-4 note from the rustls sources, not from the review's parenthetical.
  • Docs synced beyond the type docs: docs/architecture/server.md § AcceptAnyCertVerifier and docs/architecture/client.md § FingerprintPinVerifier now carry the S-1 posture + the N-1 cross-reference.

Summary

Landed (this task):

  1. src/server.rsAcceptAnyCertVerifier doc rewritten: a "# The presented signature is NOT verified (no proof-of-possession)" section stating the spoofable-fingerprint consequence, the two safe patterns (challenge-response / a verifying verifier), the OQ-TLS-09 pointer, and the pinning-test reference.
  2. src/client.rsFingerprintPinVerifier doc: the "stolen-but-stale fingerprint" phrasing replaced (the cert is presented fresh each handshake; the signature check defeats use of a stolen/observed cert by a party without the private key) plus the cross-reference: this verifier checks pop; the server-side AcceptAnyCertVerifier does not.
  3. docs/architecture/open-questions.md — OQ-TLS-09 recorded, status open, the three options (a/b/c), the pinning-test constraint, and the deferral note (the decision needs the auth-layer design or an API call before the first consumer).
  4. tests/impersonation_posture.rs (tcp-gated) — the impersonation probe made permanent, both variants: X.509 victim cert + attacker key, and RFC 7250 victim SPKI + attacker key; each asserts the handshake completes, application data flows, and the server-extracted fingerprint equals the victim's.

Verification: 68 default / 77 all-features tests green (+2 new); clippy -D warnings clean (default + all-features); fmt clean; cargo doc --no-deps warning-free.