ADR-007: RFC 7250 cert-type negotiation — the offer follows the identity (OQ-TLS-10 resolved)
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.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
---
|
||||
status: accepted
|
||||
last_updated: 2026-09-11
|
||||
---
|
||||
|
||||
# ADR-007: RFC 7250 cert-type negotiation — the offer follows the identity (deviation from alknet)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-09-11). Resolves OQ-TLS-10.
|
||||
|
||||
## Context
|
||||
|
||||
Review 001 §U-3's executed handshake suites found that the extracted
|
||||
code could not negotiate RFC 7250 (raw public key) peers over
|
||||
rustls-driven TCP+TLS at all:
|
||||
|
||||
- The crate's `FingerprintPinVerifier` kept rustls' trait-default
|
||||
`requires_raw_public_keys() == false`, so a client pinning an
|
||||
`ed25519:<hex>` fingerprint never offered
|
||||
`server_certificate_types = [RawPublicKey]` — the raw-key server
|
||||
(whose resolver has `only_raw_public_keys() == true`) requires that
|
||||
offer and aborted every handshake with `HandshakeFailure`.
|
||||
- A raw-key *client* identity (`RawKeyClientCertResolver` with
|
||||
`only_raw_public_keys() == true`) made rustls offer
|
||||
`client_certificate_types = [RawPublicKey]` (rustls 0.23.44 sends
|
||||
that one-element list iff the resolver's
|
||||
`only_raw_public_keys()` is true — never a mixed list), which the
|
||||
crate's `AcceptAnyCertVerifier` (`requires_raw_public_keys() ==
|
||||
false`) rejected with `IncorrectCertificateTypeExtension`.
|
||||
|
||||
Mechanism verified empirically (duplex-pair handshakes) and against
|
||||
the rustls 0.23.44 sources
|
||||
(`server/hs.rs::process_cert_type_extension`,
|
||||
`client/hs.rs::process_cert_type_extension`); pinned by
|
||||
`tests/handshake_behavior.rs`. Recorded as OQ-TLS-10, initially
|
||||
**open** with a "the gap is inherited from alknet, behavior
|
||||
preservation holds" framing and options (a) document-the-gap /
|
||||
(b) pin-format-driven offer / (c) raw-key-only server verifier,
|
||||
deferred as "API-shape decisions before the first consumer".
|
||||
|
||||
That framing was wrong in two ways:
|
||||
|
||||
1. **The gap contradicts the inherited spec, not just an ideal.**
|
||||
alknet ADR-082 states "the raw-key path (RFC 7250) works for both
|
||||
QUIC and TCP+TLS", and the alknet `crates/tls` README lists
|
||||
TCP+TLS as the raw-key identity's fallback transport. The alknet
|
||||
*code* never delivered this (no `requires_raw_public_keys()`
|
||||
override exists anywhere in alknet-tls; raw-key traffic rode iroh's
|
||||
own TLS, which overrides the knob on both verifier sides). The
|
||||
extraction faithfully ported the bug. Behavior-preservation in this
|
||||
crate means the load-bearing TLS postures (0-RTT, provider,
|
||||
nine-scheme list, fail-closed, root-store fallback) — not
|
||||
"preserve defects the prior art's spec already forbids".
|
||||
2. **The deferral rationale was circular.** "Wait for the first
|
||||
raw-key-over-TCP consumer" — but this crate is the component that
|
||||
must *enable* that consumer; a consumer cannot appear first. And
|
||||
the fix required no API change at all (a trait override + one
|
||||
boolean; every public signature unchanged).
|
||||
|
||||
## Decision
|
||||
|
||||
The cert-type offer derives from the configured identity, per
|
||||
connection. Two changes, both in `src/client.rs`, no API-shape change:
|
||||
|
||||
1. **`FingerprintPinVerifier::requires_raw_public_keys()`** is
|
||||
overridden to derive from the pin format: `ed25519:<hex>` → `true`
|
||||
(offer `[RawPublicKey]` — the known peer presents an RFC 7250 raw
|
||||
key), `SHA256:<hex>` → `false` (the default X.509 offer). This is
|
||||
iroh's shape generalized: iroh's verifiers hardcode raw keys for
|
||||
its NodeId identity; here the *pin format* — the same string that
|
||||
selects the verification algorithm — also selects the offer
|
||||
format, so a peer's identity kind cannot be mis-negotiated.
|
||||
2. **`RawKeyClientCertResolver` presents the SPKI under the X.509
|
||||
offer** (`only_raw_public_keys() == false` unconditionally). rustls
|
||||
sends `client_certificate_types = [RawPublicKey]` only when the
|
||||
resolver demands raw-only, and a raw-only client offer can only
|
||||
negotiate against a server verifier with
|
||||
`requires_raw_public_keys() == true` — the crate's
|
||||
request-but-don't-require `AcceptAnyCertVerifier` correctly keeps
|
||||
`false` (it accepts both cert types and must not demand raw keys
|
||||
from X.509 clients). Under the X.509 offer the SPKI DER goes out as
|
||||
opaque cert bytes; the server-side fingerprint extraction
|
||||
(`fingerprint_from_cert_der`) reads the Ed25519 SPKI either way,
|
||||
and the CertificateVerify is signed by the real Ed25519 key. The
|
||||
extension is a transport-level offer format, not an identity
|
||||
statement — the identity is what is presented and how it verifies.
|
||||
|
||||
`AcceptAnyCertVerifier` stays on the trait default (`false`) — it
|
||||
accepts both cert types, which is the request-but-don't-require shape
|
||||
(N-4's correction in review 001 stands).
|
||||
|
||||
## Why this is recorded as a deviation, not behavior preservation
|
||||
|
||||
alknet's code never negotiated raw-key over TCP — but the deviation is
|
||||
from the **extracted code's executed behavior**, restoring what the
|
||||
inherited **spec** (alknet ADR-082, the `crates/tls` README) always
|
||||
promised and what the crate's purpose (`TlsIdentity::RawKey` exists as
|
||||
a variant) implies. Per AGENTS.md convention 10, deviations are
|
||||
recorded rather than silent; this ADR is the record.
|
||||
|
||||
## Behavior-preservation invariants under the change
|
||||
|
||||
- **Fail-closed, preserved and strengthened.** Unknown raw-key remote
|
||||
+ CA path: unchanged (`HandshakeFailure`, suite 2). New and
|
||||
deliberate: an `ed25519:` pin against an X.509 server now aborts at
|
||||
cert-type negotiation (suite 2b) — previously it failed later at pin
|
||||
verification. Same verdict, earlier, no downgrade path in either
|
||||
direction; the pin format is a promise about the peer's cert kind
|
||||
and a mismatch is a configuration error, not something to paper
|
||||
over. A `SHA256:` pin against a raw-key server likewise fails at
|
||||
negotiation (the client never offers the raw-key server cert type
|
||||
the raw-key resolver requires).
|
||||
- **No cross-pin interference.** The two pin formats cannot share one
|
||||
*config's* offer — but they never needed to: verifier selection is
|
||||
per-connection-credentials (ADR-004/ADR-005), each dial builds its
|
||||
config from its own `remote_identity`. The option-(b) objection in
|
||||
OQ-TLS-10 ("the two pin formats cannot share one client config")
|
||||
described a configuration that does not exist in the API.
|
||||
- **0-RTT, provider, nine-scheme list, root-store fallback, ACME
|
||||
append**: untouched by this change.
|
||||
- **QUIC parity.** The cert-type extension is a ClientHello-level
|
||||
mechanism; `for_noq()` wraps the same `rustls::ClientConfig`, so
|
||||
both transports negotiate identically. (iroh remains its own TLS
|
||||
stack, outside this crate.)
|
||||
|
||||
## 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.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- The crate's own compositions now work: pin client ↔ raw-key server,
|
||||
raw-key client ↔ crate server — the interop `TlsIdentity::RawKey`
|
||||
implies, on both transports (tcp feature and noq).
|
||||
- No API change: every public signature, type, and feature gate is
|
||||
unchanged; consumers see only working handshakes.
|
||||
- The pin-format-driven offer is self-consistent: the same string
|
||||
selects the verifier algorithm and the cert-type offer.
|
||||
|
||||
**Negative:**
|
||||
|
||||
- A misconfigured `ed25519:` pin against an X.509 peer (or vice versa)
|
||||
fails one step earlier with a negotiation alert rather than a pin
|
||||
mismatch — operators must match the pin format to the peer's actual
|
||||
cert kind (which they must do anyway for verification to pass).
|
||||
- The resolver type name (`RawKeyClientCertResolver`) is now
|
||||
slightly historical — it serves both identity kinds' cert material.
|
||||
Renaming is free before the first consumer; deferred as cosmetic.
|
||||
|
||||
## References
|
||||
|
||||
- OQ-TLS-10 (`docs/architecture/open-questions.md`) — resolved by
|
||||
this ADR
|
||||
- `tests/handshake_behavior.rs` — the executed pins, including the
|
||||
crate-native suite 3 and the new negotiation fail-closed suite
|
||||
- `tests/invariant_pins.rs` — the resolver-offer pins (updated)
|
||||
- review 001 §U-3, §N-4 (with its correction) — the discovery chain
|
||||
- `tasks/handshake-tests.md` — the task whose premises exposed the gap
|
||||
- alknet ADR-082 ("works for both QUIC and TCP+TLS" — the spec
|
||||
promise), alknet ADR-034, ADR-001 (inheritance + deviations rule)
|
||||
- iroh `iroh/src/tls/verifier.rs` — the working prior art for
|
||||
raw-key verifiers
|
||||
- rustls 0.23.44 `server/hs.rs::process_cert_type_extension`,
|
||||
`client/hs.rs::process_cert_type_extension` — the negotiation
|
||||
mechanism
|
||||
Reference in New Issue
Block a user