Files
alknet/docs/architecture/decisions/082-alknet-tls-extraction.md
T
glm-5.2 bf6ce957c0 docs(arch): tighten tls/endpoint/client specs — remove connect/connect_quic, move CallCredentials to core, shed alknet-call TLS deps
Review of the three new crates (alknet-tls, alknet-endpoint, alknet-client)
+ revised core found compile-blocking inconsistencies, stale claims, and
dep-graph contradictions. All resolved:

Critical:
- C1: CallClient::connect / ChannelClient::connect_quic REMOVED (not
  delegated) — keeping them as thin wrappers over AlknetClient::dial_quic
  would make protocol crates depend on alknet-client, contradicting the
  dep graph. Callers compose dial + take-over (2 lines).
- C2: alknet-client feature gates now pull alknet-core/quinn +
  alknet-core/iroh (for Connection::from_quinn_with_alpn / from_iroh).
- C3: rustls-native-certs + webpki-roots added to alknet-tls deps
  (always-present, not feature-gated — CA-verify path is transport-agnostic).

Warning:
- W1: CallCredentials/RemoteIdentity moved to alknet-core (from
  alknet-call) — the dial must not depend on the call protocol; not a
  two-way-door, it determines the dep graph.
- W2: webpki-roots fallback implemented in spec (ADR-088 §5 added) —
  the code claimed a fallback that never existed; now the store is never
  empty, NoRootAnchors unreachable, containerized deployments work.
- W3: EndpointError removed entirely (BindFailed + HandlerNotFound both
  vestigial after ADR-083); shutdown() is now infallible.
- W4: FingerprintPinVerifier moved to alknet-tls (from alknet-call) —
  alknet-call sheds quinn/rustls/rustls-pemfile/rustls-native-certs
  entirely; CallClient becomes a pure protocol crate.

Plus: ClientError removed (only produced by removed connect); S1
(CallCredentials → ClientVerifierContext mapping + auth_token stripped
at TLS boundary documented); amendment notes on ADR-017, ADR-069,
ADR-080, ADR-082, ADR-087, ADR-090; overview crate graph + README index
updated.

29 files, consistency-reviewed.
2026-07-16 11:33:18 +00:00

14 KiB

ADR-082: alknet-tls Crate Extraction

Status

Accepted (amended 2026-07-14: the AlknetEndpoint::new signature referenced here was superseded by ADR-083 — the endpoint takes no TLS config; the assembly layer builds transports from TlsServerConfigs. All TLS-scope OQs resolved; advanced to Accepted ahead of the task-decomposition session.)

Context

The TLS setup in alknet-core is welded to quinn. The current flow:

  1. StaticConfig.tls_identity: Option<TlsIdentity> carries the identity (X509 / RawKey / SelfSigned / Acme).
  2. build_rustls_server_config(tls_identity, alpns) produces a rustls::ServerConfig. This function is transport-agnostic in principle — it returns a rustls::ServerConfig, which is what both quinn and tokio-rustls consume.
  3. build_quinn_server_config_from_rustls(rustls_config) consumes the rustls::ServerConfig into a quinn::ServerConfig. The rustls config is moved — it cannot be reused for a TCP+TLS listener.
  4. TlsSetup (which owns the ACME state machine handle) is #[cfg(feature = "quinn")] — it only exists when quinn is enabled. ACME is structurally a quinn-only path today.
  5. The AcmeState task is spawned inside TlsSetup::new_acme, and its JoinHandle is stored on AlknetEndpoint. If you wanted to run ACME for a TCP+TLS listener, you'd have to duplicate the ACME setup or restructure.

The cert-reuse problem

A hub that serves HTTP (TCP+TLS on 443) and channels (QUIC on 4433) with the same X.509 cert cannot do it with the current code. The rustls::ServerConfig is moved into quinn::ServerConfig and consumed. A TCP+TLS listener would have to build its own rustls::ServerConfig from the same TlsIdentity — re-loading the cert file, or re-deriving the raw key cert, or running a second ACME state machine for the same domains.

ACME is the worst case: two ACME state machines for the same domain means two cert-order attempts (race condition on Let's Encrypt's rate limiter), two cert caches, two AcmeState tasks spawning duplicate resolver() instances. One ACME state machine with one resolver, shared across transports, is the only correct design.

The rustls::ServerConfig is already shareable

rustls::ServerConfig is Clone — it holds Arcs to the cert resolver and verifier, not the raw key material. So the fix is structural, not algorithmic: build the config once, clone it for each transport. The existing build_rustls_server_config function already produces the right type; the welding is in the layer above it (the #[cfg(feature = "quinn")] gates, the TlsSetup struct, the consumption into quinn::ServerConfig).

The three use cases

Use case Identity Transports Browsers?
P2P / native clients RFC 7250 raw key (Ed25519) QUIC + TCP (fallback when UDP blocked) No
Domain-hosted / public service X.509 (manual or ACME) QUIC + TCP+TLS (same cert) Yes
Development Self-signed Any No

In all cases, TLS + ALPNs "just works" — the TLS handshake negotiates the ALPN, the HandlerRegistry dispatches by ALPN. The TLS crate's job is to make the cert available to whichever transports the deployment runs, without duplicating the cert or the ACME state machine.

Iroh is different

Iroh has its own TLS built into the Endpoint, using RFC 7250 raw keys. It does not consume a rustls::ServerConfig — it takes an iroh::SecretKey and handles TLS internally. So alknet-tls does not have a for_iroh() method. The assembly layer reads the Ed25519SecretKey from StaticConfig and passes it to iroh directly. alknet-tls is involved only when iroh is not the sole transport — in that case, the same Ed25519SecretKey feeds both TlsServerConfig::new (for quinn/TCP) and iroh::SecretKey::from_bytes (for iroh).

Decision

Extract alknet-tls as a new crate

A new crate alknet-tls holds the TLS setup code extracted from alknet-core/endpoint.rs. The central type is TlsServerConfig, built once from a TlsIdentity + ALPN list, shared across transports via Arc<TlsServerConfig>. TlsServerConfig is not Clone (it holds a JoinHandle); each transport accessor clones the inner rustls::ServerConfig, which is cheap (Arc-shared cert resolver).

pub struct TlsServerConfig {
    config: rustls::ServerConfig,
    acme_handle: Option<tokio::task::JoinHandle<()>>,
}

impl TlsServerConfig {
    pub async fn new(identity: &TlsIdentity, alpns: &[Vec<u8>])
        -> Result<Self, TlsError>;

    #[cfg(feature = "quinn")]
    pub fn for_quinn(&self) -> Result<quinn::ServerConfig, TlsError>;

    #[cfg(feature = "tcp")]
    pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor;

    pub fn rustls_config(&self) -> &rustls::ServerConfig;
}

What moves

Component From To
build_rustls_server_config() alknet-core/endpoint.rs alknet-tls
TlsSetup / ACME state machine alknet-core/endpoint.rs alknet-tls (TlsServerConfig::new ACME path)
RawKeyCertResolver alknet-core/endpoint.rs alknet-tls
Ed25519SigningKey alknet-core/endpoint.rs alknet-tls
AcceptAnyCertVerifier alknet-core/endpoint.rs alknet-tls
SelfSignedCert / generate_self_signed_cert() alknet-core/endpoint.rs alknet-tls
load_cert_chain() / load_private_key() alknet-core/endpoint.rs alknet-tls
build_quinn_server_config_from_rustls() alknet-core/endpoint.rs alknet-tls (for_quinn())

What stays

Component Location Why
TlsIdentity enum alknet-core/config.rs Config type — StaticConfig holds it
Ed25519SecretKey alknet-core/config.rs Config type — iroh reads it directly
AcmeDirectory alknet-core/config.rs Config type
fingerprint.rs alknet-core Shared by server (endpoint) and client (FingerprintPinVerifier, now in alknet-tls per ADR-089 §5 — the original dep-edge concern from alknet-call is dissolved) — production code uses sha2 + manual DER only; rustls is test-only. See OQ-59.
AlknetEndpoint alknet-endpoint Extracted from alknet-core per ADR-083 Am. 2026-07-15; takes no TLS config (see ADR-083)

Feature gates

[features]
default = []
quinn = ["dep:quinn"]       # for_quinn()
tcp = ["dep:tokio-rustls"]  # for_tcp_tls()
acme = ["dep:rustls-acme"]  # ACME state machine

A deployment enables the features for the transports it runs. The rustls dep is always present (core TLS library). tokio-rustls is only pulled in when tcp is enabled. quinn is only pulled in when quinn is enabled. rustls-acme is only pulled in when acme is enabled. futures (for StreamExt in the ACME event loop) is present when acme is enabled. rustls-pki-types is available via rustls's re-export (core lists it directly; the new crate can rely on the re-export or list it directly — implementation detail).

AlknetEndpoint takes no TLS config (see ADR-083)

ADR-082's original proposal was that AlknetEndpoint::new would take Arc<TlsServerConfig>. That does not hold: a hub serving both native clients (raw key) and browsers (X.509/ACME) holds two TlsServerConfigs, and the endpoint has no single "the TLS config" to take. The endpoint takes no TLS config at all — it is a pure accept-loop runner with a public dispatch method. The assembly layer builds the TlsServerConfigs and the transports, and hands the pre-built quinn/iroh endpoints to AlknetEndpoint via builder methods. See ADR-083 for the endpoint's new shape and signature.

alknet-tls's job is to make the cert available to whichever transports the deployment runs. The endpoint's job is to dispatch. The two are decoupled — alknet-tls provides TlsServerConfig and its accessors (for_quinn, for_tcp_tls, rustls_config); the assembly layer wires them to transports; the endpoint dispatches connections from those transports.

The TCP+TLS accept loop lives outside alknet-tls

alknet-tls provides for_tcp_tls() -> TlsAcceptor. The actual TCP accept loop (TcpListener::accept → TlsAcceptor::accept → Connection::from_bidi → endpoint.dispatch()) lives in alknet-core behind a tcp feature, as an owned transport on AlknetEndpoint (via with_tcp_tls(listener, acceptor) — see ADR-083). alknet-tls is the cert provider, not the accept loop. This keeps alknet-tls focused on TLS setup and cert sharing, not transport accept logic.

One ACME state machine, shared

For ACME, TlsServerConfig::new spawns the AcmeState task once and wires its resolver() into the rustls::ServerConfig. Both for_quinn() and for_tcp_tls() clone the same rustls::ServerConfig, which shares the same Arc<dyn ResolvesServerCert> (the ACME resolver). One ACME order, one cert cache, one resolver, two transports.

The acme-tls/1 ALPN is appended to the server's alpn_protocols only when ACME is active (ADR-027 §7); this behavior moves to TlsServerConfig::new's ACME branch unchanged. The caller does not append acme-tls/1 — the TLS crate does.

Behavior-preservation invariants

The extraction must preserve these load-bearing TLS behaviors from the current code. An implementer who omits any of these produces a crate that compiles and passes type-checks but silently changes TLS behavior:

  • max_early_data_size = u32::MAX on all server config paths. Enables 0-RTT / early data. Omitting it silently breaks 0-RTT clients.
  • rustls::crypto::aws_lc_rs::default_provider() as the crypto provider on all paths. Matches iroh's tls-aws-lc-rs feature. Do not switch to ring or the process-default provider without an ADR — this decision is now recorded as ADR-084.
  • AcceptAnyCertVerifier's supported_verify_schemes() returns ED25519 + ECDSA P-256/P-384 + RSA PSS/PKCS1 (SHA256/384/512). This list determines which client cert signature algorithms the server accepts. Must be preserved verbatim.
  • acme-tls/1 ALPN append for the ACME path only (above).

Consequences

Positive:

  • One certificate identity serves QUIC and TCP+TLS simultaneously. A hub can serve HTTP on 443 (TCP+TLS) and channels on 4433 (QUIC) with the same X.509 cert or the same ACME-issued cert.
  • One ACME state machine per domain, shared across transports. No duplicate orders, no cert cache divergence, no Let's Encrypt rate-limit risk from duplicate orders.
  • alknet-core loses the quinn-specific TLS setup code and the #[cfg(feature = "quinn")] gates on TlsSetup, RawKeyCertResolver, AcceptAnyCertVerifier, etc. Core becomes leaner — it holds config types and the endpoint struct, not TLS setup machinery.
  • tokio-rustls is an opt-in dep (behind alknet-tls's tcp feature), not forced on every alknet-core consumer.
  • The raw-key path (RFC 7250) works for both QUIC and TCP+TLS — native clients can use raw keys over either transport. The browser case (requires X.509) is the exception, not the constraint.

Negative:

  • AlknetEndpoint::new no longer takes a tls_config parameter, which is a breaking change for existing callers. The assembly layer builds the TlsServerConfigs and the transports and hands pre-built endpoints to AlknetEndpoint via builder methods (see ADR-083). This is expected — it is the point of the extraction.
  • alknet-core may keep a narrow rustls dep if fingerprint.rs stays (OQ-59). (Resolved by ADR-089 §5: FingerprintPinVerifier moved to alknet-tls, so the alknet-call → alknet-tls dep-edge concern that drove this trade-off is moot — alknet-call shed its rustls dep entirely.)
  • A new crate in the dep graph. Small, focused, one clear responsibility.

Door type

One-way. Extracting TLS setup into a shareable config is a structural change: the assembly layer must build TlsServerConfig separately and the cert-sharing contract (one config, N transports) becomes the architecture. Reversing would mean re-welding TLS to the endpoint and losing the multi-transport cert-reuse capability — the exact capability the hub needs. The AlknetEndpoint::new signature change is documented in ADR-083, not here.

The TlsServerConfig API surface (new, for_quinn, for_tcp_tls, rustls_config) is one-way — changing it after consumers exist is a rewrite. The internal implementation (how the ACME state machine is spawned, how the cert resolver works) is two-way — implementation details that can change without breaking the contract.

References

  • ADR-010 Amendment 1 — TCP+TLS dispatch via from_stream (the accept loop that consumes TlsServerConfig::for_tcp_tls())
  • ADR-083 — endpoint as pure accept-loop runner with public dispatch (the endpoint takes no TLS config; the assembly layer builds transports from TlsServerConfigs)
  • ADR-027 — TlsIdentity (RawKey / X509 / Acme), RFC 7250, browser limitation
  • ADR-030 §6 — fingerprint normalization (ed25519:<hex> across quinn/iroh)
  • ADR-034 — client-side verifier selection (CA vs fingerprint pin)
  • ADR-065 — Connection::from_stream/from_bidi (TCP+TLS path)
  • ADR-080 — ChannelClient::from_connection (transport-agnostic client; the pattern this ADR mirrors on the TLS side)
  • OQ-59 — resolved: fingerprint.rs stays in alknet-core (the client-side FingerprintPinVerifier is now in alknet-tls per ADR-089 §5, so the alknet-call → alknet-tls dep-edge concern that drove this resolution is dissolved; the rustls dep in core is test-only).
  • crates/alknet-core/src/endpoint.rs — the code being extracted
  • crates/alknet-core/src/config.rs — TlsIdentity, Ed25519SecretKey (staying in core)
  • crates/alknet-core/src/fingerprint.rs — fingerprint extraction (OQ-59)
  • docs/architecture/crates/tls/README.md — the crate spec