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:
2026-09-11 10:26:28 +00:00
parent 4a4fae64af
commit 49d4432247
10 changed files with 390 additions and 229 deletions
+1
View File
@@ -33,6 +33,7 @@ unresolved.
| [004](decisions/004-accessor-surface.md) | Accepted | Complete accessors: `for_tcp_tls()` adopted; server borrows, client consumes |
| [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) |
## Lifecycle
+24 -11
View File
@@ -48,7 +48,7 @@ exactly the two inputs the client config consumes:
| Local identity | Presented |
|----------------|-----------|
| `RawKey` | RFC 7250 raw public key (SPKI DER; `only_raw_public_keys()` auto-detected from the DER) |
| `RawKey` | the Ed25519 SPKI as the client cert, under the X.509 offer (ADR-007 — the cert-type extension is an offer format, not an identity statement; the server extracts the `ed25519:` fingerprint either way) |
| `X509` | the cert chain + key, loaded from disk |
| `SelfSigned` / `None` | nothing (`NoClientCertResolver`) — documented, resolved in OQ-TLS-02 |
| `Acme` | **config error** (`TlsError::AcmeConfig`) — server-only identity |
@@ -82,15 +82,21 @@ All three outcomes are **executed** in
`tests/handshake_behavior.rs` (real rustls handshakes over a duplex
pair, `tcp`-gated).
**RFC 7250 over TCP is a negotiation gap, not a verified path**
(OQ-TLS-10): `FingerprintPinVerifier` keeps rustls' trait-default
`requires_raw_public_keys() == false`, so a crate-built pin client
cannot reach a crate-built raw-key server — the handshake fails
closed (`HandshakeFailure`). The executed raw-key-over-TCP pin
(`raw_key_server_path_completes_with_requires_raw_verifier`) uses the
iroh-shaped verifier (`requires_raw_public_keys() == true`) any
raw-key-over-TCP consumer must bring. Raw-key peers that ride
iroh/noq use those transports' own TLS and are unaffected.
**RFC 7250 over TCP is the crate's own composition now** (ADR-007,
resolving OQ-TLS-10): `FingerprintPinVerifier` derives its cert-type
offer from the pin format — an `ed25519:<hex>` 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:<hex>` 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
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.
## `FingerprintPinVerifier`
@@ -109,6 +115,12 @@ key. This verifier checks proof-of-possession; the server-side
`AcceptAnyCertVerifier` does not (see
[server.md](server.md), OQ-TLS-09).
The cert-type offer follows the pin format (ADR-007):
`requires_raw_public_keys()` returns `true` for `ed25519:` pins
(the peer presents an RFC 7250 raw key), `false` for `SHA256:` pins
(the peer presents an X.509 cert) — a mismatched pairing fails closed
at negotiation.
## The root-store fallback (alknet ADR-088 §5)
The `None` + X.509 CA path loads the platform's native root certs
@@ -148,4 +160,5 @@ X.509 endpoint needs it regardless of transport.
- alknet ADR-034 (verifier selection), ADR-088 §5 (root-store
fallback), ADR-091 (`ConnectionCredentials`)
- ADR-002 (`TlsError`), ADR-004 (accessors), ADR-005 (credential
types), ADR-006 (tests — the verifier-selection matrix)
types), ADR-006 (tests — the verifier-selection matrix), ADR-007
(the cert-type negotiation / offer-follows-identity rule)
@@ -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
+20 -53
View File
@@ -22,7 +22,7 @@ are authoritative; the Phase 0 doc's statuses are the historical record.
| 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-10 | RFC 7250 over TCP: cert-type negotiation gap | **open** | high |
| OQ-TLS-10 | RFC 7250 over TCP: cert-type negotiation gap | **resolved** (ADR-007) | high |
## Identity & types
@@ -156,52 +156,24 @@ are authoritative; the Phase 0 doc's statuses are the historical record.
(`server/hs.rs::process_cert_type_extension`,
`client/hs.rs::process_cert_type_extension`); pinned by
`tests/handshake_behavior.rs`.
- **Status**: open (recorded 2026-09-11)
- **Priority**: high
- **Mechanism** (rustls 0.23.44, verified):
- A raw-key *server* resolver (`only_raw_public_keys() == true`)
requires the client to offer `server_certificate_types =
[RawPublicKey]`. rustls' client sends that offer only when the
client verifier overrides `requires_raw_public_keys() == true`.
- This crate's `FingerprintPinVerifier` keeps the trait default
(`false`), and `AcceptAnyCertVerifier` never overrides it either.
- Consequently: crate-pin-client ↔ crate-raw-key-server fails; and a
raw-key *client* resolver (offering `[RawPublicKey]` client cert
types) fails against `AcceptAnyCertVerifier` (the
`(false, true, false)` arm → `IncorrectCertificateTypeExtension`).
The fail-closed rule still holds — no path downgrades — but the
raw-key-over-TCP interop the extraction implies does not exist
yet.
- **Context**: the raw-key paths that work today are iroh's (its
built-in TLS overrides `requires_raw_public_keys() == true` on both
verifiers — `iroh/src/tls/verifier.rs`) and the QUIC path
(noq/iroh negotiate cert types differently from rustls' TCP
state machine). alknet's extracted client never negotiated
raw-key-over-TCP either (no override in alknet-tls) — behavior
preservation holds; the gap is inherited, not introduced.
- **Options**:
- **(a) Document the gap** — raw-key peers ride iroh/noq (their own
TLS stacks), not rustls TCP+TLS; TCP+TLS is the X.509 transport.
No code change; the handshake tests pin the executed behavior.
- **(b) Add a `requires_raw_public_keys() == true` override** on
`FingerprintPinVerifier` for `ed25519:` pins (the iroh shape).
Changes the pin verifier's negotiation: a pin client could reach
raw-key servers — but then an X.509 remote pinned by `SHA256:`
could no longer negotiate with the same client config (the offer
would exclude X509), so the two pin formats cannot share one
client config. An API-shape decision before the first consumer.
- **(c) Add a server-side verifier that overrides
`requires_raw_public_keys() == true`** (raw-key-only client auth),
additive like OQ-TLS-09's option (b); pairs with it if mandatory
raw-key client auth is wanted.
- **Constraints**: the executed behavior is pinned both ways by
`tests/handshake_behavior.rs` (`raw_key_server_path_completes_…`
with an iroh-shape verifier;
`raw_key_client_resolver_fails_against_accept_any_cert_verifier`) —
any decision must update those tests together with this OQ.
- **Cross-references**: src/client.rs (`FingerprintPinVerifier` — the
default `false`), src/server.rs (`RawKeyCertResolver`,
`AcceptAnyCertVerifier`), review 001 §N-4 (the client-resolver trap),
- **Status**: resolved (2026-09-11) — by deviation from alknet,
[ADR-007](decisions/007-cert-type-negotiation.md). Option (b) plus a
resolver change: `FingerprintPinVerifier::requires_raw_public_keys()`
derives from the pin format (`ed25519:` → `true`, `SHA256:` →
`false`), and `RawKeyClientCertResolver` presents the SPKI under the
X.509 offer (`only_raw_public_keys() == false`). The gap was a bug in
the prior art (alknet's code never delivered its spec's raw-key-over-
TCP promise), not an alknet behavior to preserve — so the
behavior-preservation invariant does not cover it. The two pin
formats negotiate independently (per-connection verifier, never
shared); fail-closed is preserved and strengthened (an `ed25519:`
pin against an X.509 server aborts at negotiation, never a
downgrade). The original "defer until the first consumer" rationale
was circular — the crate is the component that must enable the
raw-key-over-TCP consumer, and the fix required no API change.
- **Cross-references**: src/client.rs (`FingerprintPinVerifier`,
`RawKeyClientCertResolver`), src/server.rs (`RawKeyCertResolver`,
`AcceptAnyCertVerifier`), review 001 §N-4, ADR-007,
iroh `iroh/src/tls/verifier.rs` (the working prior art)
## Quality / process
@@ -235,9 +207,4 @@ are authoritative; the Phase 0 doc's statuses are the historical record.
- 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)).
- OQ-TLS-10 (RFC 7250 over TCP negotiation gap): open by design —
behavior-preserving (the gap is inherited from alknet); deciding
needs the first raw-key-over-TCP consumer in hand (options (b)/(c)
are API-shape decisions), or the X.509-only TCP posture is
documented as-is (option (a)).
or an API-shape call before the first consumer (option (b)).
+1
View File
@@ -78,6 +78,7 @@ All design decisions are documented as ADRs in [decisions/](decisions/).
| [004](decisions/004-accessor-surface.md) | Complete the accessors | `for_tcp_tls()` adopted; server accessors borrow (`&self`), client accessors consume |
| [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) |
## Open Questions
+13 -9
View File
@@ -108,17 +108,21 @@ certificate: the SPKI DER (Ed25519 OID + 32-byte key) is the "cert",
`only_raw_public_keys() == true`, and the signing key is the shared
`Ed25519SigningKey` helper (`signing.rs`).
**Interop note (OQ-TLS-10, executed in
**Interop note (ADR-007, executed in
`tests/handshake_behavior.rs`)**: a raw-key server resolver requires
the client to offer `[RawPublicKey]` *server* cert types, which
rustls sends only when the client verifier overrides
`requires_raw_public_keys() == true` (iroh's verifier does; this
crate's server-side `AcceptAnyCertVerifier` also keeps the default
`false`, and a raw-key *client* resolver offering `[RawPublicKey]`
client cert types is rejected by it — N-4's trap, also executed).
the client to offer `[RawPublicKey]` *server* cert types, which rustls
sends only when the client verifier overrides
`requires_raw_public_keys() == true`. The crate's own
`FingerprintPinVerifier` now does exactly that for `ed25519:` pins
(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`).
Raw-key peers riding iroh/noq are unaffected (their TLS stacks own
the negotiation); rustls-driven TCP+TLS is the X.509 transport until
OQ-TLS-10 decides otherwise.
their negotiation).
## The ACME path