diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4c41a74..eb902ef 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -34,6 +34,7 @@ unresolved. | [005](decisions/005-config-types-move-into-alktls.md) | Accepted | Identity + credentials + fingerprint types move into alktls; auth layer stays out | | [006](decisions/006-module-layout-and-tests.md) | Accepted | Eight-module layout; seed tests + integration invariant pins | | [007](decisions/007-cert-type-negotiation.md) | Accepted | RFC 7250 cert-type negotiation: the offer follows the identity (deviation from alknet; resolves OQ-TLS-10) | +| [008](decisions/008-server-path-possession-verification.md) | Accepted | Server-path proof-of-possession: the verifying verifier is the default on every path (resolves OQ-TLS-09) | ## Lifecycle diff --git a/docs/architecture/client.md b/docs/architecture/client.md index 3d26dca..fb949ab 100644 --- a/docs/architecture/client.md +++ b/docs/architecture/client.md @@ -88,15 +88,14 @@ 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 -`AcceptAnyCertVerifier` accepts it end-to-end -(`raw_key_client_presents_spki_and_server_extracts_fingerprint`). A -pin-format/cert-kind mismatch fails closed at negotiation, never a +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. The strict-server limit (a foreign server demanding -raw-only client certs still rejects an X.509-offer presentation) is -recorded in ADR-007 §Limits. +unaffected. ## `FingerprintPinVerifier` @@ -111,9 +110,11 @@ 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. This verifier checks proof-of-possession; the server-side -`AcceptAnyCertVerifier` does not (see -[server.md](server.md), OQ-TLS-09). +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 diff --git a/docs/architecture/decisions/007-cert-type-negotiation.md b/docs/architecture/decisions/007-cert-type-negotiation.md index e7a7655..fcec217 100644 --- a/docs/architecture/decisions/007-cert-type-negotiation.md +++ b/docs/architecture/decisions/007-cert-type-negotiation.md @@ -127,13 +127,16 @@ recorded rather than silent; this ADR is the record. ## Limits (honest scope) The SPKI-under-X.509 presentation makes a raw-key *client* presentable -to **this crate's** `AcceptAnyCertVerifier` (and to any permissive -verifier). A foreign strict RFC 7250 server that *demands* -`client_certificate_types = [RawPublicKey]` (`requires_raw_public_keys() == true` server-side) will still reject an X.509-offer -presentation. If the rewrite needs that, the additive route is a -raw-key-only server-verifier sibling (OQ-TLS-10 option (c)), which -pairs with OQ-TLS-09's verify-presenting verifier (option (b)) — both -remain additive and are not needed for the crate's own compositions. +to **this crate's** server verifiers (both the default +`VerifyPresentedCertVerifier` and the `AcceptAnyCertVerifier` escape +hatch) and to any permissive verifier. A foreign strict RFC 7250 +server that *demands* `client_certificate_types = [RawPublicKey]` +(`requires_raw_public_keys() == true` server-side) will still reject +an X.509-offer presentation. No known alk* deployment needs that (all +branch designs use permissive servers — clients may present X.509, an +SPKI, or nothing); the additive route if one ever appears is a +raw-key-only server-verifier sibling. **Not planned** — recorded here +so the next reader doesn't mistake it for pending work. ## Consequences diff --git a/docs/architecture/decisions/008-server-path-possession-verification.md b/docs/architecture/decisions/008-server-path-possession-verification.md new file mode 100644 index 0000000..8656010 --- /dev/null +++ b/docs/architecture/decisions/008-server-path-possession-verification.md @@ -0,0 +1,152 @@ +--- +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. +- 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`) \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index a39c8d9..582c9de 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -21,7 +21,7 @@ are authoritative; the Phase 0 doc's statuses are the historical record. | OQ-TLS-06 | ACME task shutdown surface | resolved (detached-only for v1) | low | | OQ-TLS-07 | iroh key surface | **resolved** (ADR-005, byte access pinned) | low | | OQ-TLS-08 | `quinn` → `noq` feature rename | **resolved** (ADR-003) | high | -| OQ-TLS-09 | Server-path proof-of-possession | **open** | high | +| OQ-TLS-09 | Server-path proof-of-possession | **resolved** (ADR-008) | high | | OQ-TLS-10 | RFC 7250 over TCP: cert-type negotiation gap | **resolved** (ADR-007) | high | ## Identity & types @@ -112,37 +112,23 @@ are authoritative; the Phase 0 doc's statuses are the historical record. impersonation probe: a handshake with the victim's cert bytes + an attacker signer completes, and the server extracts the victim's fingerprint) -- **Status**: open (recorded 2026-09-10) -- **Priority**: high -- **Question**: `AcceptAnyCertVerifier` never checks the client's - CertificateVerify signature — the presented identity is spoofable by - anyone holding the public cert/SPKI bytes, and the auth layer cannot - detect it (the fingerprint it is handed *is* the victim's). alknet - ADR-034 inherited the request-but-don't-require shape without - recording this property. Who enforces possession? -- **Options**: - - **(a) Keep request-but-don't-require; the auth layer owns - challenge-response** over the established channel, bound to the - presented public key. No crate change; the S-1 doc note on - `AcceptAnyCertVerifier` is the honest description until then. - - **(b) Add a `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)` — the - routing the client-side `FingerprintPinVerifier` already - implements. Additive; must land before the first consumer (an API - shape decision). - - **(c) Make the auth layer's fingerprint resolution - possession-checked** (a possession proof accompanies each - fingerprint resolution). Cross-crate; the auth layer does not exist - yet. -- **Constraints**: the spoofable posture is pinned by - `tests/impersonation_posture.rs` (both cert types) — any decision - must fail or update that test together with the - `AcceptAnyCertVerifier` doc note. -- **Cross-references**: src/server.rs (`AcceptAnyCertVerifier`), - src/client.rs (`FingerprintPinVerifier`), - docs/reviews/001-implementation-review.md §S-1, alknet ADR-034 +- **Status**: resolved (2026-09-11) — option (b), + [ADR-008](decisions/008-server-path-possession-verification.md). + [`VerifyPresentedCertVerifier`](decisions/008-server-path-possession-verification.md) + is the default client-cert verifier on every `TlsServerConfig` path + (including the ACME path — the verifier install is crate-side + rustls, not rustls-acme's): request, don't require, **verify the + CertificateVerify against the presented cert's public key**. The + extracted fingerprint is possession-checked (attacker-supplied + presentations fail the handshake — + `tests/impersonation_posture.rs` pins both directions); who a + fingerprint maps to remains the auth layer's concern (ADR-005). + `AcceptAnyCertVerifier` stays public as the explicit no-pop escape + hatch, its posture pinned by the same test file. +- **Cross-references**: src/server.rs (`VerifyPresentedCertVerifier`, + `AcceptAnyCertVerifier`), src/client.rs (`FingerprintPinVerifier` — + the routing being mirrored), ADR-008, review 001 §S-1, alknet + ADR-034 ### OQ-TLS-10: How do RFC 7250 raw-key peers negotiate over rustls-driven TCP+TLS? @@ -205,6 +191,7 @@ are authoritative; the Phase 0 doc's statuses are the historical record. ## Deferred / Blocked -- OQ-TLS-09 (server-path proof-of-possession): open by design — the - decision needs the rewrite's auth-layer design in hand (option (c)) - or an API-shape call before the first consumer (option (b)). \ No newline at end of file +None — all recorded OQs are resolved. Future questions (e.g. the +raw-key-only server-verifier sibling if a foreign strict RFC 7250 +server interop ever appears — see ADR-007 §Limits) get new OQ entries +rather than reopening these. \ No newline at end of file diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 4483ad6..f41c38a 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -79,6 +79,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/). | [005](decisions/005-config-types-move-into-alktls.md) | Config types move into alktls | Identity + credentials + fingerprint move in; auth layer stays out | | [006](decisions/006-module-layout-and-tests.md) | Module layout and test surface | Eight modules; in-module seed tests + integration invariant pins | | [007](decisions/007-cert-type-negotiation.md) | RFC 7250 cert-type negotiation | The cert-type offer follows the identity/pin format — raw-key-over-TCP works (deviation from alknet; resolves OQ-TLS-10) | +| [008](decisions/008-server-path-possession-verification.md) | Server-path possession verification | The verifying verifier is the default on every server path; `AcceptAnyCertVerifier` is the explicit escape hatch (resolves OQ-TLS-09) | ## Open Questions diff --git a/docs/architecture/server.md b/docs/architecture/server.md index 07b812c..4c408f2 100644 --- a/docs/architecture/server.md +++ b/docs/architecture/server.md @@ -59,47 +59,46 @@ test (ADR-006): - **`rustls::crypto::aws_lc_rs::default_provider()`** as the crypto provider on all paths (alknet ADR-084). Never `ring`, never the process-default provider, without a new ADR. -- **`AcceptAnyCertVerifier::supported_verify_schemes()`** returns +- **The nine-scheme `supported_verify_schemes()`** (shared by + `VerifyPresentedCertVerifier` and `AcceptAnyCertVerifier`) returns ED25519 + ECDSA P-256/P-384 + RSA PSS (SHA256/384/512) + RSA PKCS1 (SHA256/384/512) — nine schemes, verbatim, pinned by an exact-list integration test. +- **The default client-cert verifier verifies possession** + (ADR-008): `VerifyPresentedCertVerifier` is installed on every + path; the CertificateVerify is checked against the presented + cert's public key. `AcceptAnyCertVerifier` exists only as the + explicit escape hatch. - **`acme-tls/1` ALPN append** for the ACME path only, done by the crate, not the caller (alknet ADR-027 §7). - **Non-empty root store** — the client CA path merges `webpki-roots` when the platform store is empty (see [client.md](client.md)). -## `AcceptAnyCertVerifier` +## `VerifyPresentedCertVerifier` (the default) and `AcceptAnyCertVerifier` (the escape hatch) -The server-side client-cert verifier: **request-but-don't-require**. -It asks for a client cert (X.509 or RFC 7250 raw key) so the caller -can extract the fingerprint via `peer_identity()`, but does not -require one and does not verify the presented cert against a CA. The -fingerprint is matched against peer records by the auth layer -(`IdentityProvider::resolve_from_fingerprint`) *outside* this crate — -the TLS crate hands over the fingerprint string; peer resolution is -not a TLS concern (ADR-005). +The server-side client-cert verifier: **request, don't require, verify +possession** (ADR-008, resolving OQ-TLS-09). +[`VerifyPresentedCertVerifier`](decisions/008-server-path-possession-verification.md) +is the default on every `TlsServerConfig` path (X509 / RawKey / +SelfSigned / ACME). It asks for a client cert (X.509 or RFC 7250 raw +key) so the caller can extract the fingerprint via `peer_identity()`, +does not require one, and does not verify the presented cert against a +CA — self-signed chains and bare SPKIs are valid presentation. The +client's **CertificateVerify signature is verified** against the +presented cert's public key (Ed25519 SPKIs route through +`verify_tls13_signature_with_raw_key`; X.509 through the standard +route): the extracted fingerprint is possession-checked — presenting a +victim's public bytes under an attacker's key fails the handshake. +Who the fingerprint maps to remains the auth layer's concern +(ADR-005). -**The presented signature is not verified (no proof-of-possession)** — -review 001's S-1: the client's CertificateVerify signature is never -checked against the presented cert's public key, so any party holding -a peer's public cert bytes (X.509) or SPKI (RFC 7250) can complete a -handshake *as that peer*, and the server hands the auth layer the -victim's fingerprint. The auth layer cannot detect this — the -fingerprint it is handed *is* the victim's. Until the caller enforces -possession, treat the extracted fingerprint as an unauthenticated -claim, not proof of identity. Two patterns make it safe (the auth -layer owns either): challenge-response over the established channel -bound to the presented public key, or a verifier that verifies the -CertificateVerify (the routing `FingerprintPinVerifier` implements — -tracked as OQ-TLS-09; option (b) there is additive but must land -before the first consumer). The spoofable posture is pinned by -`tests/impersonation_posture.rs` (both cert types) — a change here -must fail or update that test together with this doc. - -Server-side only: this must not be reused as a client-side -`ServerCertVerifier` — client-side verification is alknet ADR-034's -selection matrix (see [client.md](client.md)), and unlike the -client-side pin verifier this type has no proof-of-possession check. +**The escape hatch**: `AcceptAnyCertVerifier` is the documented no-pop +verifier — same request-not-require shape, no CertificateVerify check, +so the fingerprint it extracts is attacker-suppliable (S-1). Install +it explicitly only when a deployment deliberately wants that posture. +Both postures are pinned by `tests/impersonation_posture.rs` (default +rejects the attacker, escape hatch accepts — both cert types); a +change must update that test together with this doc. ## `RawKeyCertResolver` @@ -117,10 +116,12 @@ sends only when the client verifier overrides (the offer follows the pin format), so a crate pin client completes against this raw-key server (`raw_key_server_path_completes_with_crate_pin_client`). A raw-key -*client* identity presents its SPKI under the X.509 offer, which -`AcceptAnyCertVerifier` (`requires_raw_public_keys() == false` — -correctly; it accepts both cert types) accepts end-to-end -(`raw_key_client_presents_spki_and_server_extracts_fingerprint`). +*client* identity presents its SPKI under the X.509 offer, which the +verifiers (`requires_raw_public_keys() == false` — correctly; both +accept X.509-or-raw cert types) accept end-to-end, the default +possession-verifying the presentation +(`raw_key_client_presents_spki_and_server_extracts_fingerprint`, +`raw_key_client_vs_raw_key_server_default_verifier_checks_possession`). Raw-key peers riding iroh/noq are unaffected (their TLS stacks own their negotiation). @@ -169,9 +170,10 @@ Lifecycle semantics: filters per endpoint type — alknet ADR-086 §3); the crate appends only `acme-tls/1` on the ACME path. - No handshake: verifier selection and handshake outcomes on the - *server* side are `AcceptAnyCertVerifier` + the caller's - fingerprint extraction; a rejected handshake is the transport's - error, not `TlsError`. + *server* side are the default `VerifyPresentedCertVerifier` (or the + explicitly-installed escape hatch) + the caller's fingerprint + extraction; a rejected handshake is the transport's error, not + `TlsError`. - No peer resolution: the extracted fingerprint string goes to the caller; `PeerEntry`/`AuthPolicy` live in the auth layer. @@ -183,4 +185,5 @@ Lifecycle semantics: spec this doc mirrors - ADR-001 (invariants), ADR-002 (`TlsError`), ADR-003 (`for_noq`), ADR-004 (accessors), ADR-005 (identity types), ADR-006 (modules, - tests) \ No newline at end of file + tests), ADR-007 (cert-type negotiation), ADR-008 (possession + verification) \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 68cc9f4..a3f4883 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,7 +45,7 @@ pub use credentials::{ConnectionCredentials, RemoteIdentity}; pub use server::{ build_rustls_server_config, generate_self_signed_cert, AcceptAnyCertVerifier, - RawKeyCertResolver, SelfSignedCert, TlsServerConfig, + RawKeyCertResolver, SelfSignedCert, TlsServerConfig, VerifyPresentedCertVerifier, }; #[derive(Debug, thiserror::Error)] diff --git a/src/server.rs b/src/server.rs index cd54906..98a78b8 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,7 +1,8 @@ //! Server-side TLS configuration: [`TlsServerConfig`], //! [`build_rustls_server_config`], [`RawKeyCertResolver`], -//! [`AcceptAnyCertVerifier`], [`SelfSignedCert`] / -//! [`generate_self_signed_cert`], and the ACME path (feature `acme`). +//! [`VerifyPresentedCertVerifier`], [`AcceptAnyCertVerifier`], +//! [`SelfSignedCert`] / [`generate_self_signed_cert`], and the ACME path +//! (feature `acme`). use std::sync::Arc; @@ -12,6 +13,8 @@ use tracing::{debug, error, warn}; use crate::identity::AcmeDirectory; use crate::identity::{Ed25519SecretKey, TlsIdentity}; use crate::signing::Ed25519SigningKey; + +use crate::fingerprint::extract_ed25519_raw_key_from_spki; use crate::TlsError; /// Server-side TLS configuration, transport-agnostic. Built once from a @@ -78,9 +81,9 @@ impl TlsServerConfig { let resolver = state.resolver(); let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); - let mut config = rustls::ServerConfig::builder_with_provider(provider) + let mut config = rustls::ServerConfig::builder_with_provider(provider.clone()) .with_safe_default_protocol_versions()? - .with_client_cert_verifier(Arc::new(AcceptAnyCertVerifier)) + .with_client_cert_verifier(Arc::new(VerifyPresentedCertVerifier::new(&provider))) .with_cert_resolver(resolver); config.max_early_data_size = u32::MAX; @@ -173,7 +176,8 @@ pub fn build_rustls_server_config( alpns: &[Vec], ) -> Result { let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); - let client_verifier = Arc::new(AcceptAnyCertVerifier); + let client_verifier: Arc = + Arc::new(VerifyPresentedCertVerifier::new(&provider)); match tls_identity { TlsIdentity::X509 { cert, key } => { let cert_chain = crate::pem::load_cert_chain(cert)?; @@ -239,6 +243,147 @@ pub fn generate_self_signed_cert() -> Result { }) } +/// Server-side client-cert verifier that verifies the presented +/// identity's proof-of-possession: **request, don't require, verify +/// possession**. The default client-cert verifier on every +/// [`TlsServerConfig`] path (ADR-008, resolving OQ-TLS-09). +/// +/// Like [`AcceptAnyCertVerifier`], it asks for a client cert (X.509 or +/// RFC 7250 raw key) so the endpoint can extract the fingerprint via +/// `peer_identity()`, does not require one, and does not verify the +/// presented cert against a CA — a self-signed X.509 chain or a bare +/// RFC 7250 SPKI is acceptable presentation. Unlike +/// `AcceptAnyCertVerifier`, the client's **CertificateVerify signature +/// is verified** against the presented cert's public key: +/// +/// - An Ed25519 SPKI presentation routes to +/// `verify_tls13_signature_with_raw_key` (and its TLS 1.2 +/// equivalent routing) — the same routing the client-side +/// `FingerprintPinVerifier` uses. +/// - An X.509 presentation routes to the standard +/// `verify_tls12_signature` / `verify_tls13_signature`. +/// +/// A presented cert is therefore only usable by the party holding the +/// matching private key: presenting a victim's public cert/SPKI bytes +/// under an attacker's key fails the CertificateVerify check (the +/// impersonation posture pinned by `tests/impersonation_posture.rs` +/// under the permissive escape hatch). The extracted fingerprint is +/// authenticated as "this party holds the private key for the +/// presented public identity" — a real proof-of-possession, though +/// still *not* a name/CA check (self-signed chains remain valid +/// presentation; who a fingerprint maps to stays the auth layer's +/// concern, ADR-005). +/// +/// Clients that present no cert are unaffected — there is nothing to +/// verify, and `client_auth_mandatory() == false`. +/// +/// `supported_verify_schemes()` returns the same nine-scheme list as +/// `AcceptAnyCertVerifier` (the load-bearing list, pinned by an +/// exact-list test). `requires_raw_public_keys()` stays `false` — the +/// verifier accepts both cert types (ADR-007). +/// +/// Use [`AcceptAnyCertVerifier`] only if a deployment deliberately +/// needs the no-pop posture (e.g. an auth layer that owns +/// challenge-response and wants handshake-speed over strictness); the +/// permissive posture is still pinned by `tests/impersonation_posture.rs` +/// when this verifier is explicitly installed. +pub struct VerifyPresentedCertVerifier { + supported: rustls::crypto::WebPkiSupportedAlgorithms, +} + +impl VerifyPresentedCertVerifier { + pub fn new(provider: &Arc) -> Self { + Self { + supported: provider.signature_verification_algorithms, + } + } +} + +impl std::fmt::Debug for VerifyPresentedCertVerifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VerifyPresentedCertVerifier").finish() + } +} + +impl rustls::server::danger::ClientCertVerifier for VerifyPresentedCertVerifier { + fn offer_client_auth(&self) -> bool { + true + } + + fn client_auth_mandatory(&self) -> bool { + false + } + + fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] { + &[] + } + + fn verify_client_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::server::danger::ClientCertVerified::assertion()) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + if extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() { + let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec()); + rustls::crypto::verify_tls13_signature_with_raw_key( + message, + &spki, + dss, + &self.supported, + ) + } else { + rustls::crypto::verify_tls13_signature(message, cert, dss, &self.supported) + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + if extract_ed25519_raw_key_from_spki(cert.as_ref()).is_some() { + let spki = rustls::pki_types::SubjectPublicKeyInfoDer::from(cert.as_ref().to_vec()); + rustls::crypto::verify_tls13_signature_with_raw_key( + message, + &spki, + dss, + &self.supported, + ) + } else { + rustls::crypto::verify_tls12_signature(message, cert, dss, &self.supported) + } + } + + fn supported_verify_schemes(&self) -> Vec { + nine_supported_verify_schemes() + } +} + +fn nine_supported_verify_schemes() -> Vec { + vec![ + rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + ] +} + /// Server-side "request-but-don't-require" client cert verifier /// (alknet ADR-034). /// @@ -262,27 +407,18 @@ pub fn generate_self_signed_cert() -> Result { /// fingerprint decision (scopes, tokens, resumption) is then made against /// a spoofed identity. The auth layer's peer table cannot detect this: the /// fingerprint it is handed *is* the victim's. Until the caller enforces -/// possession, treat the extracted fingerprint as an unauthenticated -/// claim, not proof of identity. +/// possession (challenge-response over the established channel is the +/// pattern), treat the extracted fingerprint as an unauthenticated claim, +/// not proof of identity. /// -/// Two patterns make the identity safe — the auth layer owns either: -/// -/// 1. **Challenge-response over the established channel**, bound to the -/// presented public key: possession is proven post-handshake, so a -/// stolen/observed fingerprint cannot be replayed without the key. -/// 2. **A verifier that does verify the CertificateVerify** against the -/// presented cert — the same -/// `verify_tls13_signature_with_raw_key` / `verify_tls12_signature` -/// routing the client-side `FingerprintPinVerifier` implements. The -/// crate has the routing code, just not on this type (a tracked -/// question — see OQ-TLS-09 in `docs/architecture/open-questions.md`). -/// -/// This behavior is pinned end-to-end by -/// `tests/impersonation_posture.rs`: a handshake with the victim's cert -/// bytes + an attacker signer completes and the server extracts the -/// victim's fingerprint. A change that adds proof-of-possession (or an -/// upstream rustls change that enforces it) must fail that test and -/// update this doc + OQ-TLS-09 together. +/// **Not the default** (ADR-008): [`VerifyPresentedCertVerifier`] is the +/// default client-cert verifier on every [`TlsServerConfig`] path — it +/// has the same request-not-require shape plus the CertificateVerify +/// possession check. Install this type explicitly only when a +/// deployment deliberately wants the no-pop posture. The spoofable +/// behavior is pinned by `tests/impersonation_posture.rs` (both cert +/// types, against a server configured with this verifier) — a change +/// must update that test together with this doc. /// /// **Server-side only.** This must not be reused as a client-side /// `ServerCertVerifier` — client-side verification is alknet ADR-034's @@ -339,17 +475,7 @@ impl rustls::server::danger::ClientCertVerifier for AcceptAnyCertVerifier { } fn supported_verify_schemes(&self) -> Vec { - vec![ - rustls::SignatureScheme::ED25519, - rustls::SignatureScheme::ECDSA_NISTP256_SHA256, - rustls::SignatureScheme::ECDSA_NISTP384_SHA384, - rustls::SignatureScheme::RSA_PSS_SHA256, - rustls::SignatureScheme::RSA_PSS_SHA384, - rustls::SignatureScheme::RSA_PSS_SHA512, - rustls::SignatureScheme::RSA_PKCS1_SHA256, - rustls::SignatureScheme::RSA_PKCS1_SHA384, - rustls::SignatureScheme::RSA_PKCS1_SHA512, - ] + nine_supported_verify_schemes() } } diff --git a/tests/handshake_behavior.rs b/tests/handshake_behavior.rs index c52873c..571a07f 100644 --- a/tests/handshake_behavior.rs +++ b/tests/handshake_behavior.rs @@ -14,8 +14,9 @@ //! verification). //! - A raw-key *client* identity presents its SPKI under the X.509 //! offer (`only_raw_public_keys() == false` — the extension is an -//! offer format, not an identity statement); `AcceptAnyCertVerifier` -//! accepts it and the server extracts the `ed25519:` fingerprint. +//! offer format, not an identity statement); the server's default +//! `VerifyPresentedCertVerifier` possession-verifies it (ADR-008) +//! and the server extracts the `ed25519:` fingerprint. //! //! Mechanism reference: rustls 0.23.44's cert-type negotiation //! (`server/hs.rs::process_cert_type_extension`, @@ -262,8 +263,10 @@ async fn raw_key_server_path_completes_with_crate_pin_client() { /// Suite 3b — N-4's interop trap, resolved (ADR-007): a raw-key client /// identity presents its SPKI under the X.509 offer /// (`only_raw_public_keys() == false` — the extension is an offer format, -/// not an identity statement), which `AcceptAnyCertVerifier` accepts; the -/// server extracts the client's `ed25519:` fingerprint end-to-end. +/// not an identity statement), which the server's default +/// `VerifyPresentedCertVerifier` accepts *and possession-verifies* +/// (ADR-008); the server extracts the client's `ed25519:` fingerprint +/// end-to-end. #[tokio::test] async fn raw_key_client_presents_spki_and_server_extracts_fingerprint() { let dir = tempfile::tempdir().expect("tempdir"); @@ -297,3 +300,87 @@ async fn raw_key_client_presents_spki_and_server_extracts_fingerprint() { "the server must extract the raw-key client's ed25519 fingerprint" ); } + +/// Suite 4 (ADR-008) — the possession-verifying default end-to-end: a +/// raw-key client pins the raw-key server's `ed25519:` fingerprint +/// (branch-2 shape), presents its own SPKI under the X.509 offer, and +/// the default `VerifyPresentedCertVerifier` verifies the client's +/// CertificateVerify against that SPKI's public key — the server +/// extracts the client's `ed25519:` fingerprint, possession-checked, +/// on the crate's default configuration. (A raw-key *client* without +/// the pin stays fail-closed against a raw-key server — the server +/// cert-type offer comes from the verifier, suite 2's rule.) +#[tokio::test] +async fn raw_key_client_vs_raw_key_server_default_verifier_checks_possession() { + let server_key = Ed25519SecretKey::generate(); + let server_public: [u8; 32] = server_key.public().to_bytes(); + let server_spki = + rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, server_public) + .to_vec(); + let server_fp = fingerprint_from_cert_der(&server_spki).expect("server fp"); + + let acceptor = server_config(&TlsIdentity::RawKey(server_key.clone())) + .await + .for_tcp_tls(); + + let client_key = Ed25519SecretKey::generate(); + let client_public: [u8; 32] = client_key.public().to_bytes(); + let credentials = ConnectionCredentials::new() + .with_local_identity(TlsIdentity::RawKey(client_key)) + .with_remote_identity(RemoteIdentity { + fingerprint: server_fp, + }); + + let server_seen = round_trip(acceptor, connector(&credentials)).await.expect( + "a pinning raw-key client must complete against a raw-key server \ + whose default verifier possession-checks the presented SPKI", + ); + + let expected = format!("ed25519:{}", hex::encode(client_public)); + assert_eq!( + server_seen.as_deref(), + Some(expected.as_str()), + "the possession-verifying default must accept the legit raw-key \ + client and extract its fingerprint" + ); +} + +/// Suite 4b (ADR-008) — an X.509 client (self-signed chain, the +/// documented presentation) completes against the verifying default: +/// the CertificateVerify is verified through the standard X.509 route. +#[tokio::test] +async fn x509_client_vs_default_verifier_possession_checked() { + let dir = tempfile::tempdir().expect("tempdir"); + let (server_cert, server_key, server_der) = write_x509_pair(dir.path()); + let server_fp = fingerprint_from_cert_der(server_der.as_ref()).expect("server fp"); + + let acceptor = server_config(&TlsIdentity::X509 { + cert: server_cert.clone(), + key: server_key.clone(), + }) + .await + .for_tcp_tls(); + + let (client_cert, client_key, client_der) = write_x509_pair(dir.path()); + let client_fp = fingerprint_from_cert_der(client_der.as_ref()).expect("client fp"); + + let credentials = ConnectionCredentials::new() + .with_local_identity(TlsIdentity::X509 { + cert: client_cert, + key: client_key, + }) + .with_remote_identity(RemoteIdentity { + fingerprint: server_fp, + }); + + let server_seen = round_trip(acceptor, connector(&credentials)) + .await + .expect("an X.509 client must complete against the verifying default"); + + assert_eq!( + server_seen.as_deref(), + Some(client_fp.as_str()), + "the verifying default must accept the legit X.509 client and \ + extract its fingerprint" + ); +} diff --git a/tests/impersonation_posture.rs b/tests/impersonation_posture.rs index ac17e5e..78291d5 100644 --- a/tests/impersonation_posture.rs +++ b/tests/impersonation_posture.rs @@ -1,12 +1,16 @@ -//! S-1 behavior pin (review 001): `AcceptAnyCertVerifier` performs no -//! proof-of-possession check — a handshake presenting the victim's public -//! cert bytes (X.509) or SPKI (RFC 7250) under an attacker-owned signer -//! completes, and the server extracts the victim's fingerprint. +//! S-1 posture pins (review 001 §S-1, resolved by ADR-008) — both +//! cert-type verifiers, both cert types: //! -//! This test PINS the spoofable posture in both directions. If a future -//! change adds proof-of-possession (OQ-TLS-09 option (b)) or an upstream -//! rustls change enforces the signature, these tests fail and force the -//! `AcceptAnyCertVerifier` doc + OQ-TLS-09 update together. +//! 1. **Default** (`VerifyPresentedCertVerifier`, ADR-008): a handshake +//! presenting the victim's public cert bytes (X.509) or SPKI +//! (RFC 7250) under an attacker-owned signer **fails** the +//! CertificateVerify possession check — the attacker cannot +//! complete the handshake as the victim. +//! 2. **Escape hatch** (`AcceptAnyCertVerifier`, explicitly +//! installed): the same handshake completes and the server extracts +//! the victim's fingerprint — the documented no-pop posture of the +//! escape hatch, pinned so it cannot silently change either +//! direction. //! //! Gated on `tcp` (tokio-rustls provides the duplex-driven handshake; //! no external transport is involved). @@ -25,12 +29,6 @@ use alktls::{Ed25519SecretKey, TlsServerConfig}; const ALPN: &[u8] = b"alk/impersonation"; -async fn server_config() -> alktls::TlsServerConfig { - TlsServerConfig::new(&alktls::TlsIdentity::SelfSigned, &[ALPN.to_vec()]) - .await - .expect("server config must construct") -} - /// The attacker client: presents `presented_cert` (the victim's public /// bytes) with the attacker's own Ed25519 signer, and accepts any server /// cert (the probe stays independent of the crate's pin path). @@ -134,13 +132,15 @@ fn attacker_client_config( config } +/// Drive the attacker handshake against `acceptor`. Returns +/// `Ok(Option)` when the handshake completed (the server +/// side's extracted fingerprint), `Err(message)` when either side +/// aborted. async fn run_impersonation( + acceptor: tokio_rustls::TlsAcceptor, presented_cert: CertificateDer<'static>, attacker_signer: &Ed25519SecretKey, -) -> Option { - let server_config = server_config().await; - let acceptor = server_config.for_tcp_tls(); - +) -> Result, String> { let client_config = attacker_client_config(presented_cert, attacker_signer); let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config)); @@ -155,30 +155,53 @@ async fn run_impersonation( acceptor.accept(server_io), ); - let mut client_stream = client.expect("attacker handshake must complete (the S-1 posture)"); - let mut server_stream = server.expect("server side of the handshake must complete"); + let mut client_stream = client.map_err(|e| format!("client: {e}"))?; + let mut server_stream = server.map_err(|e| format!("server: {e}"))?; use tokio::io::{AsyncReadExt, AsyncWriteExt}; client_stream .write_all(b"spoof") .await - .expect("application data must flow after the spoofed handshake"); + .map_err(|e| format!("client write: {e}"))?; let mut buf = [0u8; 5]; server_stream .read_exact(&mut buf) .await - .expect("the server must read the attacker's application data"); + .map_err(|e| format!("server read: {e}"))?; let (_, server_conn) = server_stream.get_ref(); - server_conn + Ok(server_conn .peer_certificates() .and_then(|certs| certs.first().map(|c| fingerprint_from_cert_der(c.as_ref()))) - .flatten() + .flatten()) +} + +async fn default_acceptor() -> tokio_rustls::TlsAcceptor { + TlsServerConfig::new(&alktls::TlsIdentity::SelfSigned, &[ALPN.to_vec()]) + .await + .expect("server config must construct") + .for_tcp_tls() +} + +/// An explicit escape-hatch server: the crate's SelfSigned identity with +/// `AcceptAnyCertVerifier` installed in place of the default (the +/// documented no-pop opt-out, built through the public config builder). +fn escape_hatch_acceptor() -> tokio_rustls::TlsAcceptor { + let cert = alktls::generate_self_signed_cert().expect("self-signed cert generates"); + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let mut config = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .expect("protocol versions") + .with_client_cert_verifier(Arc::new(alktls::AcceptAnyCertVerifier)) + .with_single_cert(cert.cert_chain, cert.private_key) + .expect("self-signed server cert installs"); + config.alpn_protocols = vec![ALPN.to_vec()]; + tokio_rustls::TlsAcceptor::from(Arc::new(config)) } #[tokio::test] -async fn x509_victim_cert_with_attacker_key_completes_and_fingerprint_is_victims() { +async fn default_verifier_rejects_x509_victim_cert_with_attacker_key() { let victim_key_pair = rcgen::KeyPair::generate().expect("victim key gen"); let victim_cert = rcgen::CertificateParams::default() .self_signed(&victim_key_pair) @@ -191,19 +214,22 @@ async fn x509_victim_cert_with_attacker_key_completes_and_fingerprint_is_victims let attacker_signer = Ed25519SecretKey::generate(); - let server_seen = run_impersonation(victim_cert_der, &attacker_signer).await; + let result = + run_impersonation(default_acceptor().await, victim_cert_der, &attacker_signer).await; - assert_eq!( - server_seen.as_deref(), - Some(victim_fingerprint.as_str()), - "S-1: the server must extract the VICTIM's fingerprint from a \ - handshake the attacker completed with its own key — the no-pop \ - posture is the documented behavior (OQ-TLS-09)" + let err = result.expect_err( + "ADR-008: the default verifier must reject a handshake that presents \ + the victim's X.509 cert under the attacker's key", + ); + assert!( + err.contains("invalid peer certificate"), + "the possession check must fail the handshake with a certificate \ + error, got: {err}" ); } #[tokio::test] -async fn raw_key_victim_spki_with_attacker_key_completes_and_fingerprint_is_victims() { +async fn default_verifier_rejects_raw_key_victim_spki_with_attacker_key() { let victim_key = Ed25519SecretKey::generate(); let victim_public_bytes: [u8; 32] = victim_key.public().to_bytes(); let victim_spki_der = @@ -221,15 +247,78 @@ async fn raw_key_victim_spki_with_attacker_key_completes_and_fingerprint_is_vict "the attacker must hold a different key than the victim" ); - let server_seen = - run_impersonation(CertificateDer::from(victim_spki_der), &attacker_signer).await; + let result = run_impersonation( + default_acceptor().await, + CertificateDer::from(victim_spki_der), + &attacker_signer, + ) + .await; + + let err = result.expect_err( + "ADR-008: the default verifier must reject a handshake that presents \ + the victim's SPKI under the attacker's key", + ); + assert!( + err.contains("invalid peer certificate"), + "the possession check must fail the handshake with a certificate \ + error, got: {err}" + ); +} + +#[tokio::test] +async fn escape_hatch_x509_victim_cert_completes_and_fingerprint_is_victims() { + let victim_key_pair = rcgen::KeyPair::generate().expect("victim key gen"); + let victim_cert = rcgen::CertificateParams::default() + .self_signed(&victim_key_pair) + .expect("victim cert"); + let victim_cert_der = victim_cert.der().clone(); + + let victim_fingerprint = fingerprint_from_cert_der(victim_cert_der.as_ref()) + .expect("fingerprint of the victim cert"); + + let attacker_signer = Ed25519SecretKey::generate(); + + let server_seen = run_impersonation(escape_hatch_acceptor(), victim_cert_der, &attacker_signer) + .await + .expect("the escape-hatch verifier must accept the no-pop handshake"); assert_eq!( server_seen.as_deref(), Some(victim_fingerprint.as_str()), - "S-1 (RFC 7250 variant): the server must extract the VICTIM's \ - ed25519 fingerprint from a handshake the attacker completed with \ - its own signer — the spoofable posture is the documented behavior \ - (OQ-TLS-09)" + "S-1 (escape hatch): AcceptAnyCertVerifier performs no \ + proof-of-possession check — the server extracts the VICTIM's \ + fingerprint from a handshake the attacker completed with its own \ + key. This is the documented, deliberately-installed posture." + ); +} + +#[tokio::test] +async fn escape_hatch_raw_key_victim_spki_completes_and_fingerprint_is_victims() { + let victim_key = Ed25519SecretKey::generate(); + let victim_public_bytes: [u8; 32] = victim_key.public().to_bytes(); + let victim_spki_der = + rustls::sign::public_key_to_spki(&rustls::pki_types::alg_id::ED25519, victim_public_bytes) + .to_vec(); + + let victim_fingerprint = + fingerprint_from_cert_der(&victim_spki_der).expect("fingerprint of the victim SPKI"); + + let attacker_signer = Ed25519SecretKey::generate(); + + let server_seen = run_impersonation( + escape_hatch_acceptor(), + CertificateDer::from(victim_spki_der), + &attacker_signer, + ) + .await + .expect("the escape-hatch verifier must accept the no-pop handshake"); + + assert_eq!( + server_seen.as_deref(), + Some(victim_fingerprint.as_str()), + "S-1 (escape hatch, RFC 7250 variant): the server extracts the \ + VICTIM's ed25519 fingerprint from a handshake the attacker \ + completed with its own signer — the no-pop posture only exists \ + where AcceptAnyCertVerifier is explicitly installed." ); } diff --git a/tests/invariant_pins.rs b/tests/invariant_pins.rs index a16d0a3..33e59a7 100644 --- a/tests/invariant_pins.rs +++ b/tests/invariant_pins.rs @@ -188,7 +188,7 @@ fn server_paths_carry_max_early_data_and_alpn() { assert!( !format!("{config:?}").is_empty(), "the config carries its verifier through the derived Debug (request-but-don't-require \ - shape asserted at unit level on AcceptAnyCertVerifier itself)" + shape asserted at unit level on the verifiers themselves)" ); } }