Files
alktls/docs/architecture/client.md
T
glm-5.3-flash ac440f3a9a ADR-008: server-path possession verification — the verifying verifier is the default (OQ-TLS-09 resolved)
Close review 001 §S-1: the default client-cert verifier never checked
the client's CertificateVerify, so anyone holding a peer's *public*
cert/SPKI bytes (public by design — peers publish them to be dialable)
could complete a handshake as that peer, and the auth layer could not
detect it. The consumer designs are known (X.509 and raw-key TCP/QUIC
endpoints with identity-bearing clients), so implementing now — the
zero-consumer moment — avoids the guaranteed breaking republish of
flipping the default later.

- VerifyPresentedCertVerifier (new): request, don't require, verify
  possession — permissive verify_client_cert (self-signed chains and
  bare SPKIs stay valid presentation) + CertificateVerify routing by
  presented cert kind (Ed25519 SPKI -> verify_tls13_signature_with_
  raw_key both TLS versions; X.509 -> standard route), the same
  routing FingerprintPinVerifier implements. Nine-scheme list
  verbatim (shared fn, exact-list pin covers both).
- Default on every TlsServerConfig path — X509 / RawKey / SelfSigned
  / ACME (the verifier install is crate-side rustls in new_acme, not
  rustls-acme's).
- AcceptAnyCertVerifier stays public as the explicit no-pop escape
  hatch, no longer installed by any crate path.
- tests/impersonation_posture.rs: four pins — default rejects the
  attacker (X.509: UnsupportedSignatureAlgorithmForPublicKeyContext;
  raw-key: BadSignature), escape hatch still accepts + extracts the
  victim's fingerprint (both cert types).
- tests/handshake_behavior.rs: suites 4/4b — possession-checked legit
  clients (raw-key pin vs raw-key server; X.509 client vs X.509
  server) complete and the server extracts the fingerprint; suite 3b
  doc updated.
- Docs: ADR-008 written; OQ-TLS-09 -> resolved (option (b));
  ADR-007 §Limits deferral retired to not-planned; server.md/client.md
  invariants/README/overview synced.

Verification: cargo test 81 / --features tcp 95 / --all-features 104
green; clippy -D warnings clean (default + all-features); fmt clean;
cargo doc warning-free.
2026-09-11 11:12:48 +00:00

7.2 KiB

status, last_updated
status last_updated
reviewed 2026-09-11

alktls — Client side

TlsClientConfig and its verifiers: the client-side TLS setup, extracted from alknet (crates/alknet-tls/src/client.rs) and ported per the ADRs. Decisions referenced here: see open-questions.md and the index in overview.md.

TlsClientConfig

Built per dial from a ConnectionCredentials + ALPN (the dial-seam pattern, alknet ADR-089). Consumed by its accessors — a TlsClientConfig is not reused across dials (ADR-004).

pub struct TlsClientConfig {
    rustls_config: rustls::ClientConfig,
}

impl TlsClientConfig {
    pub fn new(credentials: &ConnectionCredentials, alpn: &[u8])
        -> Result<Self, TlsError>;

    #[cfg(feature = "noq")]
    pub fn for_noq(self) -> Result<noq::ClientConfig, TlsError>;

    pub fn into_rustls_config(self) -> rustls::ClientConfig;
}

new is sync and infallible-free aside from config construction (cert file loading for an X.509 local identity is the only I/O). Every config sets enable_early_data = true (the client half of the 0-RTT invariant) and carries the aws-lc-rs provider (alknet ADR-084).

The two credential dimensions

ConnectionCredentials (ADR-005, moved from alknet ADR-091) carries exactly the two inputs the client config consumes:

  1. local_identity: Option<TlsIdentity> — the client-auth cert presentation:

    Local identity Presented
    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
  2. remote_identity: Option<RemoteIdentity> — the verifier selection matrix (below). Both Options are load-bearing, not cosmetic: Some means "pin this", None means "trust the CA or fail" — never a placeholder default.

Verifier selection (alknet ADR-034 §3)

Exactly three outcomes, driven by remote_identity:

remote_identity Remote cert Verifier Outcome
Some(fingerprint) any FingerprintPinVerifier pin match required
None X.509 WebPkiServerVerifier (CA) CA verification against the platform root store
None Ed25519 raw key (the CA verifier fails) fail closed at handshake

None is the public-X.509-endpoint state, not "skip verification". An unknown raw-key remote fails at handshake — it must never silently downgrade to CA verification. Verifier selection happens at config construction; the fail-closed manifests at handshake time (the config carries the CA verifier; the raw-key remote simply cannot satisfy it) — this is the ADR-002 scope boundary in action.

Fail-closed is structural: known peer + fingerprint → pin; unknown + X.509 → CA; unknown + raw key → fail. No fourth path. All three outcomes are executed in tests/handshake_behavior.rs (real rustls handshakes over a duplex pair, tcp-gated).

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 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.

FingerprintPinVerifier

The known-peer path. The fingerprint IS the trust anchor: for ed25519:<hex> remotes the raw Ed25519 key is extracted from the presented cert (SPKI) and matched; for SHA256:<hex> remotes the full cert DER is hashed and matched. No CA verification, no name verification — only the pin.

Handshake signatures are still verified (TLS 1.2/1.3, aws-lc-rs 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. 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).

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 (rustls-native-certs). If the platform store is empty — a containerized deployment with no system CA bundle — the built-in webpki-roots are merged in so the store is never empty. Native-certs load errors are logged, not returned; the fallback guarantees non-emptiness regardless. This makes NoRootAnchors unreachable in practice (the VerifierBuild variant exists for the builder API's completeness, not a reachable path — ADR-002).

The root store is built unconditionally for the CA path — not feature-gated on tcp or noq — because any client dialing a public X.509 endpoint needs it regardless of transport.

What the client side does NOT do

  • No dial: the dial seam (AlknetClient in alknet; the rewrite's dial crate) consumes the config via for_noq() / into_rustls_config() and owns TcpStream::connect, Endpoint::connect, SNI, and SOCKS5 proxying (alknet ADR-089; SOCKS5 proxy seam per alknet ADR-090). Handshake failures are the dial's errors (ClientDialError), not TlsError.
  • No iroh: the iroh dial takes the 32-byte Ed25519 key directly (key-not-config; ADR-003). iroh's built-in verifier (NodeId check) is iroh's own application of the same fail-closed rule.
  • No per-request auth: a call-protocol auth_token is a call-layer concept; it never reaches TlsClientConfig.

References

  • overview.md — the index; server.md — the server side
  • alknet crates/tls/README.md §Client side — the full client-side spec this doc mirrors
  • 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), ADR-007 (the cert-type negotiation / offer-follows-identity rule)