Resolve the cert-type negotiation gap (review 001 §U-3, OQ-TLS-10) by deviation from alknet: the gap was a defect in the prior art (alknet's code never delivered its spec's raw-key-over-TCP promise — ADR-082 "works for both QUIC and TCP+TLS"), not behavior to preserve. - FingerprintPinVerifier::requires_raw_public_keys() derives from the pin format: ed25519: -> true (offer [RawPublicKey]), SHA256: -> false (X.509 offer). Crate pin client now completes against the crate raw-key server; SHA256: pins negotiate unchanged. - RawKeyClientCertResolver presents the SPKI under the X.509 offer (only_raw_public_keys() == false): a raw-only client offer can only negotiate against a requires_raw server verifier, and AcceptAnyCertVerifier correctly stays on the default (accepts both cert types). The server extracts the ed25519: fingerprint from the SPKI bytes either way. - Fail-closed preserved and strengthened: an ed25519: pin against an X.509 server now aborts at negotiation (suite 2b), never a downgrade; no API change (no public signature affected; the fix is invisible to consumers apart from working handshakes). - tests/handshake_behavior.rs: suite 3 now runs crate-native (no custom iroh-shaped verifier), new negotiation fail-closed suite, suite 3b inverted to end-to-end success; invariant_pins.rs resolver-offer assertions flipped; unused imports dropped. - Docs: ADR-007 written; OQ-TLS-10 -> resolved-by-deviation; client.md/server.md/overview/README/task postscript synced (incl. the strict-foreign-server limit in ADR-007 §Limits). Verification: cargo test 81 / --features tcp 94 / --all-features 105 green; clippy -D warnings clean (default + all-features); fmt clean; cargo doc warning-free.
175 lines
8.1 KiB
Markdown
175 lines
8.1 KiB
Markdown
---
|
|
id: handshake-tests
|
|
name: Handshake-level test suite — pin, fail-closed, raw-key path executed (U-3)
|
|
status: completed
|
|
depends_on: []
|
|
scope: moderate
|
|
risk: medium
|
|
impact: component
|
|
level: implementation
|
|
tags: [tests, tcp, review-001, u3]
|
|
---
|
|
|
|
## Description
|
|
|
|
Every existing test asserts config *construction*; the crate's actual
|
|
TLS behavior (server accepts, pin verifies, fail-closed manifests) is
|
|
never executed — no handshake-level test exists. These are the
|
|
highest-value missing tests in the crate: they turn "fail closed" and
|
|
"pin" from documentation into executed behavior. The ADR-006 boundary
|
|
(handshakes belong to transport crates) is not violated by a
|
|
duplex-pair handshake: no external transport dep, just tokio +
|
|
tokio-rustls under the existing `tcp` feature (dev/test only — the
|
|
feature gate already exists for `for_tcp_tls`).
|
|
|
|
The four handshake-level gaps (review 001 §U-3):
|
|
|
|
1. **The pin path end-to-end**: client config with
|
|
`FingerprintPinVerifier` handshaking a server whose presentation
|
|
matches the pin → ok; mismatched pin → fails.
|
|
2. **The fail-closed path**: `remote_identity: None` + raw-key server
|
|
→ handshake fails (the structural claim, executed).
|
|
3. **The RFC 7250 raw-key server path end-to-end**: `RawKeyCertResolver`
|
|
+ client → handshake completes with the raw-key cert type negotiated
|
|
(currently only `only_raw_public_keys()` is asserted).
|
|
4. **S-1's impersonation probe made permanent** — covered by
|
|
`fix-accept-any-cert-verifier-posture` (kept separate: different
|
|
remediation owner, doc+ADR+probe).
|
|
|
|
## Work
|
|
|
|
1. Add `tests/handshake_behavior.rs` (`#![cfg(feature = "tcp")]`):
|
|
tokio duplex pair + `for_tcp_tls()` acceptor / `into_rustls_config()`
|
|
connector — or raw `ServerConnection`/`ClientConnection` with
|
|
`complete_io` (the review-probe shape; fewer moving parts).
|
|
2. Suite (1): server = `TlsIdentity::RawKey(sk)`, client pin =
|
|
`fingerprint_from_cert_der(spki)` → handshake ok + app-data
|
|
round-trip; wrong pin → error.
|
|
3. Suite (2): server = RawKey, credentials without `remote_identity` →
|
|
handshake fails (`UnknownCertificateType`/alert — assert failure,
|
|
not the specific error text unless rustls pins it).
|
|
4. Suite (3): raw-key server + raw-key client (RFC 7250 both sides):
|
|
handshake ok; server `peer_certificates()` is an SPKI; client
|
|
presented X509+RawPublicKey cert types per N-4's correction (the
|
|
client resolver already offers the right shape — see N-4).
|
|
5. Keep every test within the crate's public API + rustls types; no new
|
|
dependencies.
|
|
|
|
## Verification
|
|
|
|
- [ ] All three executed handshake tests pass under
|
|
`cargo test --features tcp` (and `--all-features`)
|
|
- [ ] The fail-closed test actually fails the handshake (assert error,
|
|
not success)
|
|
- [ ] `cargo test` (default) still green — the file is feature-gated
|
|
and contributes nothing without `tcp`
|
|
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`,
|
|
`cargo fmt --check` green
|
|
|
|
## Acceptance Criteria
|
|
|
|
- [ ] Pin / fail-closed / raw-key are executed behaviors in the suite,
|
|
not just structural assertions
|
|
- [ ] No new dependencies; the default build stays lean
|
|
|
|
## References
|
|
|
|
- docs/reviews/001-implementation-review.md §U-3, §N-4 (the cert-type
|
|
negotiation correction — read before writing suite (3))
|
|
- src/client.rs (`FingerprintPinVerifier`, `select_server_verifier`),
|
|
src/server.rs (`RawKeyCertResolver`)
|
|
- ADR-006 (the handshake scope boundary — and why the duplex-pair shape
|
|
stays inside it)
|
|
- tasks/integration-suite.md (the existing suite this extends)
|
|
|
|
## Postscript (2026-09-11 — the OQ-TLS-10 resolution)
|
|
|
|
The open gap above is now **resolved by ADR-007** (deviation from
|
|
alknet; OQ-TLS-10 → resolved). The two gap premises this task pinned
|
|
are no longer true:
|
|
|
|
- Suite 1's original premise (crate pin client ↔ raw-key server → ok)
|
|
is now **true**: `FingerprintPinVerifier::requires_raw_public_keys()`
|
|
derives from the pin format (`ed25519:` → `true`), so the crate's
|
|
own pin client completes against the crate's raw-key server —
|
|
`raw_key_server_path_completes_with_crate_pin_client` (no custom
|
|
verifier needed anymore).
|
|
- Suite 3b's premise is now **inverted**: 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`).
|
|
- New pin: `ed25519_pin_against_x509_server_fails_closed_at_negotiation`
|
|
— a pin-format/cert-kind mismatch aborts at negotiation, never a
|
|
downgrade.
|
|
|
|
Rationale and limits (incl. the strict-foreign-server caveat) in
|
|
ADR-007; client.md/server.md notes flipped accordingly.
|
|
|
|
## Notes
|
|
|
|
> **Major finding during implementation (recorded as OQ-TLS-10):** the
|
|
> task's suite premises 1 and 3 did not hold as written. A temporary
|
|
> probe (the review's methodology, deleted after the run) found:
|
|
>
|
|
> - **A crate-built pin client cannot complete a handshake against a
|
|
> crate-built raw-key server** (HandshakeFailure). Mechanism,
|
|
> verified in the rustls 0.23.44 sources
|
|
> (`server/hs.rs::process_cert_type_extension`,
|
|
> `client/hs.rs::process_cert_type_extension`): the raw-key server
|
|
> resolver requires the client to offer
|
|
> `server_certificate_types = [RawPublicKey]`, which rustls sends
|
|
> only when the client verifier overrides
|
|
> `requires_raw_public_keys() == true`. `FingerprintPinVerifier`
|
|
> keeps the trait default `false` (as does
|
|
> `AcceptAnyCertVerifier`). iroh's verifier overrides `true` on both
|
|
> sides (`iroh/src/tls/verifier.rs`) — the working prior art. The
|
|
> gap is inherited from alknet (no override there either) —
|
|
> behavior-preserving, but the raw-key-over-TCP interop the
|
|
> extraction implies does not exist yet.
|
|
> - **Suite 3's premise adjusted**: the raw-key server path completes
|
|
> only with an iroh-shaped verifier (`requires_raw_public_keys()
|
|
> == true`); the suite pins that working shape (with
|
|
> `NoClientCertResolver` client auth) and asserts the presented
|
|
> SPKI carries the raw Ed25519 key.
|
|
> - **Suite 1's premise adjusted**: the crate pin client ↔ raw-key
|
|
> server shape was replaced by the pin client ↔ X.509 server
|
|
> (`SHA256:` pin, which the crate's verifier can negotiate), pin
|
|
> match + mismatch. The executed fail-closed (suite 2) is
|
|
> mechanically the same HandshakeFailure (raw-key server needs a
|
|
> raw-key offer the CA-path client never makes) — the alert is
|
|
> asserted, the no-downgrade outcome is what's pinned.
|
|
> - **N-4's trap executed as suite 3b**: raw-key client resolver ↔
|
|
> `AcceptAnyCertVerifier` fails (client offers
|
|
> `[RawPublicKey]` → `IncorrectCertificateTypeExtension`).
|
|
>
|
|
> OQ-TLS-10 recorded (open, options a/b/c, deferral rationale);
|
|
> client.md and server.md carry the interop notes.
|
|
|
|
## Summary
|
|
|
|
**Landed as `tests/handshake_behavior.rs`** (`tcp`-gated, 5 suites;
|
|
no new deps — tokio duplex + tokio-rustls under the existing feature):
|
|
|
|
1. `pin_match_completes_and_server_extracts_client_fingerprint` —
|
|
X.509 server, client pins the matching `SHA256:` fingerprint →
|
|
handshake completes, app data round-trips, server extracts the
|
|
client cert's fingerprint (request-but-don't-require, executed).
|
|
2. `pin_mismatch_fails_the_handshake` — wrong pin → handshake error
|
|
carrying the pin verifier's mismatch message.
|
|
3. `unknown_raw_key_remote_fails_closed` — `remote_identity: None` +
|
|
raw-key server → HandshakeFailure (fail closed, executed).
|
|
4. `raw_key_server_path_completes_with_requires_raw_verifier` — the
|
|
RFC 7250 server path end-to-end with the iroh-shaped client
|
|
verifier: handshake completes, no client cert presented, and the
|
|
presented server cert is verified to be the SPKI carrying the raw
|
|
Ed25519 public key.
|
|
5. `raw_key_client_resolver_fails_against_accept_any_cert_verifier` —
|
|
N-4's interop trap executed (raw-key client cert types rejected).
|
|
|
|
Plus **OQ-TLS-10** (open — the cert-type negotiation gap above),
|
|
server.md/client.md synced, frontmatter → completed.
|
|
|
|
**Verification:** 81 default / 91 tcp / 99 all-features tests green
|
|
(+5 new handshake suites), clippy `-D warnings` clean (default +
|
|
all-features), fmt clean, `cargo doc` warning-free, `taskgraph
|
|
validate` 14 tasks. |