# ADR-027: TLS Identity Redesign — ACME Integration + RawKey Decoupling *Ported from alknet ADR-027 (TLS Identity Redesign — ACME Integration + RawKey Decoupling); re-targeted to alkhttp.* ## Status Accepted (§5 amended by alknet ADR-083 — the `acme-tls/1` guard moves from `dispatch_quinn` to the shared `dispatch` method, since ACME challenges arrive over TCP+TLS, not QUIC; the rationale holds, only the location changes) ## Context **Scope for this crate:** This ADR is the decision of record for the browser-facing TLS constraint this crate inherits: **browsers require X.509** — they cannot present or verify RFC 7250 raw Ed25519 keys, so the browser-reachable surface of alkhttp (`http/1.1`, `h2`, and the WebSocket upgrade path) must be served from an X.509 identity (CA-issued via ACME, or operator-provided). The TLS machinery itself — `TlsIdentity`, `TlsSetup`, ACME provisioning, the rustls server config — is **not implemented in this crate**; alkhttp is transport-coupling-free by design, and the TLS provisioning mechanics described below remain an alknet concern (retained for provenance). The clauses that matter to alkhttp are the browser constraint and the identity modes it implies. OQ-12 marked "resolved" identified two TLS identity use cases: RFC 7250 raw Ed25519 keys (default, P2P) and X.509 certs (domain-hosted, browsers). ACME auto-provisioning was described as "additive — it will be adapted when domain-hosted nodes need it." That deferral created two architectural issues that surface now that ACME is a concrete target. ### Issue 1: `TlsIdentity` cannot represent ACME *(alknet concern)* `TlsIdentity` is `#[derive(Debug, Clone)]` and lives in `StaticConfig` — a static, synchronous config value. ACME requires: - A long-lived async state machine (`AcmeState` event loop, spawned for the endpoint's lifetime) that handles ordering, challenge response, cert renewal, and cache I/O. - TLS-ALPN-01 challenge handling: `acme-tls/1` must be in the server's `alpn_protocols`, and a `ResolvesServerCertAcme` must serve challenge certs during the TLS handshake. - Config fields: domains, cache directory, ACME directory URL, contact email. `AcmeState` is not `Clone`. It cannot be a `TlsIdentity` variant. The current `build_rustls_server_config(&TlsIdentity) -> ServerConfig` is synchronous — there's no room for spawning an async state machine or holding a runtime resolver handle. The reverse-proxy project solved this with a two-phase construction: static config → `TlsMode` (runtime objects) → `ServerConfig`. alknet needs the same split. *(This issue, and the two-phase construction that resolves it, live in the alknet endpoint layer — not in alkhttp, which owns no TLS config.)* ### Issue 2: `RawKey` is coupled to the `iroh` feature *(alknet concern)* `TlsIdentity::RawKey(iroh::SecretKey)` is gated `#[cfg(feature = "iroh")]`. The `RawKeyCertResolver` and `Ed25519SigningKey` impls are gated `#[cfg(all(feature = "quinn", feature = "iroh"))]`. This means a quinn-only build (the default feature set) **cannot use RFC 7250 raw-key identity** — the very mode described as "default for most alknet nodes." The coupling is artificial. `iroh::SecretKey` is a thin newtype over `ed25519_dalek::SigningKey` (`pub struct SecretKey(SigningKey)`). The alknet code uses exactly three APIs: `.public().as_bytes()`, `.sign(msg)`, and `.clone()`. None of these are iroh-specific. The raw-key TLS path needs Ed25519 signing + SPKI encoding — both available from `ed25519-dalek` + `rustls` without iroh. The iroh *transport* (`build_iroh_endpoint`) does need `iroh::SecretKey` for `iroh::Endpoint::builder().secret_key(...)`. If `TlsIdentity::RawKey` no longer carries an `iroh::SecretKey`, the iroh transport must convert from the new key type — trivial since `iroh::SecretKey::from_bytes(&[u8; 32])` accepts raw Ed25519 key bytes. *(This issue is entirely within the alknet transport/config layer; alkhttp has no `TlsIdentity` and no iroh dependency.)* ### ACME challenge handling with quinn (QUIC, not TCP) *(alknet concern)* Research confirmed how TLS-ALPN-01 works with quinn: - The `ResolvesServerCertAcme` resolver intercepts the challenge at the **cert resolution step**, during the TLS handshake, before the handshake result is surfaced to the application. - When an ACME CA connects with ALPN `[acme-tls/1]`, rustls calls the resolver, which returns the challenge cert. The handshake completes. The CA inspects the cert's SAN and validates the challenge — no application-layer data exchange needed. - quinn's `connecting.await` then returns a completed `Connection` with ALPN `acme-tls/1`. alknet's `dispatch_quinn` would find no handler for that ALPN and close the connection. **The challenge already succeeded** — the close is cosmetic. - Unlike the reverse-proxy (TCP + `LazyConfigAcceptor`), quinn gives no "peek at ClientHello" hook. The challenge is fully TLS-layer-handled; the application only needs to close challenge connections gracefully (silent close, not a "no handler" warning). Key constraint: ACME requires `with_cert_resolver(ResolvesServerCertAcme)`, not `with_single_cert`. You cannot just append `acme-tls/1` to an `X509`/`SelfSigned` config — there'd be no resolver to serve the challenge cert. ACME is a distinct `ServerConfig` construction path. *(Challenge handling at this layer is an alknet endpoint concern.)* ## Decision ### 1. Add `TlsIdentity::Acme` variant (static config data only) *(alknet concern)* ```rust pub enum TlsIdentity { X509 { cert: PathBuf, key: PathBuf }, RawKey(Ed25519SecretKey), // see Decision 3 SelfSigned, Acme { // NEW domains: Vec, cache_dir: PathBuf, directory: AcmeDirectory, // enum: Production, Staging, Custom(String) contact: Vec, // e.g. ["mailto:admin@example.com"] }, } ``` `Acme` holds only static, `Clone`/`Debug`-safe config data. No `AcmeState`, no resolver, no runtime objects. The async state machine is constructed at endpoint setup time (Decision 2). ### 2. Split server-config construction into two phases *(alknet concern)* Replace the synchronous `build_rustls_server_config(&TlsIdentity) -> ServerConfig` with a two-phase construction: **Phase 1 — `TlsSetup` (async, at endpoint construction):** ```rust struct TlsSetup { server_config: rustls::ServerConfig, acme_state: Option, // spawned task + handle for shutdown } ``` For `X509`, `SelfSigned`, `RawKey`: construct `ServerConfig` synchronously (current path, unchanged). `acme_state` is `None`. For `Acme`: construct `AcmeConfig`, spawn the `AcmeState` event loop, get `ResolvesServerCertAcme`, build `ServerConfig` with `with_cert_resolver(resolver)`, add `acme-tls/1` to `alpn_protocols`. `acme_state` is `Some(handle)` so the endpoint can abort the ACME task on shutdown. **Phase 2 — use `TlsSetup.server_config` to build `quinn::ServerConfig`:** Same as today: `QuicServerConfig::try_from(rustls_config)` → `quinn::ServerConfig::with_crypto(...)`. The `TlsSetup` is constructed inside `AlknetEndpoint::new()` (or `run_quinn_accept_loop`), not inside `TlsIdentity`. The `TlsIdentity` enum stays a pure data structure. ### 3. Decouple `RawKey` from iroh — use `ed25519-dalek` directly *(alknet concern)* Replace `TlsIdentity::RawKey(iroh::SecretKey)` with `TlsIdentity::RawKey(Ed25519SecretKey)`, where `Ed25519SecretKey` is a thin alknet-core-owned wrapper over `ed25519_dalek::SigningKey`: ```rust pub struct Ed25519SecretKey(ed25519_dalek::SigningKey); ``` This type is `Clone`, `Debug` (redacting), `Zeroize`, and not gated behind any feature flag. `ed25519-dalek` becomes a direct dependency of alknet-core (it's already in the dependency tree transitively via iroh). The `RawKeyCertResolver` and `Ed25519SigningKey` rustls impls move from `#[cfg(all(feature = "quinn", feature = "iroh"))]` to `#[cfg(feature = "quinn")]` — raw-key TLS identity works in quinn-only builds. The `iroh` feature gate on `TlsIdentity::RawKey` is removed. The variant is always available. ### 4. iroh transport converts from `Ed25519SecretKey` *(alknet concern)* `build_iroh_endpoint` currently reads `TlsIdentity::RawKey(iroh::SecretKey)` and passes it to `iroh::Endpoint::builder().secret_key(...)`. After decoupling, it converts: ```rust if let Some(TlsIdentity::RawKey(key)) = static_config.tls_identity.as_ref() { let iroh_key = iroh::SecretKey::from_bytes(key.as_bytes()); builder = builder.secret_key(iroh_key); } ``` `iroh::SecretKey::from_bytes(&[u8; 32])` accepts raw Ed25519 key bytes — no information loss. This conversion is `#[cfg(feature = "iroh")]` only. ### 5. ACME ALPN challenge handling in `dispatch` (moved from `dispatch_quinn` by alknet ADR-083) *(alknet concern)* Add an early-return guard in `dispatch` (the shared dispatch path, moved from `dispatch_quinn` by alknet ADR-083) before the handler lookup: ```rust // In the shared `dispatch` method (moved from `dispatch_quinn` by alknet ADR-083): if alpn == b"acme-tls/1" { debug!("acme-tls/1 challenge connection completed at TLS layer; closing"); connection.close(0u32.into(), b"acme done"); return; } ``` This avoids the misleading "no handler for ALPN" warning. The challenge is already answered at the TLS layer; the application just closes gracefully. No `ProtocolHandler` registration for `acme-tls/1`. The guard is transport-agnostic — it fires for any connection whose TLS handshake negotiated `acme-tls/1`, regardless of which transport delivered it. In practice ACME TLS-ALPN-01 challenges arrive over TCP+TLS (CAs validate via TCP to port 443, not QUIC); advertising `acme-tls/1` on a QUIC listener that shares the ACME config is harmless. See alknet ADR-083 for the full rationale. *(The guard lives in the alknet endpoint's ALPN dispatch loop. alkhttp's `HttpAdapter` — the `ProtocolHandler` for `h2`/`http/1.1` — never sees `acme-tls/1` connections; TLS identity and ALPN dispatch remain alknet concerns per alknet ADR-010/ADR-001.)* ### 6. Feature-gate ACME behind a new `acme` feature *(alknet concern)* Add a `acme` feature to alknet-core: ```toml [features] acme = ["dep:rustls-acme"] ``` `TlsIdentity::Acme` is available regardless of feature (it's just config data), but constructing `TlsSetup` with an `Acme` variant requires the `acme` feature. Without it, `TlsIdentity::Acme` at endpoint construction returns an error ("ACME feature not enabled"). This keeps the footprint down for nodes that don't need ACME — `rustls-acme` and its dependencies are only compiled when the feature is on. ### 7. `acme-tls/1` in ALPN list only when ACME is active *(alknet concern)* When `TlsIdentity::Acme` is configured, `acme-tls/1` is appended to the `alpn_protocols` list alongside the handler ALPNs. When ACME is not configured, `acme-tls/1` is not advertised — no behavior change for non-ACME nodes. ## What alkhttp inherits from this decision - **Browsers require X.509.** Browsers cannot verify or present RFC 7250 raw Ed25519 keys; any deployment that serves browsers (the WebSocket browser bidirectional path — alkhttp ADR-044/ADR-048 — and the gateway endpoints) needs the hub's TLS listener to present an X.509 certificate chain (WebPKI/CA-issued, e.g. via ACME) rather than a raw key. This is the constraint that motivates the ACME work above, and it is why alknet ADR-027 is ported here at all. - **Identity selection is upstream of alkhttp.** The `HttpAdapter` registers on the standard HTTP ALPNs (`h2`, `http/1.1`) per alkhttp ADR-001/ADR-002 and is transport-agnostic: the TLS identity that secures the listener (raw key for P2P, X.509 for browser-facing) is chosen by the consumer's assembly/endpoint layer, not by this crate. ## Consequences - **Breaking change to `TlsIdentity`** *(alknet concern)*: `RawKey(iroh::SecretKey)` → `RawKey(Ed25519SecretKey)`. Pre-1.0 crate, in-repo consumers only. The assembly layer and tests that construct `TlsIdentity::RawKey` must update. - **`ed25519-dalek` becomes a direct dependency** of alknet-core *(alknet concern)*. It's already in the dependency tree (transitive via iroh), so no new compilation cost for `iroh` builds. Quinn-only builds that were not using `RawKey` before will now compile `ed25519-dalek` — it's a small, pure-Rust crate with no C dependencies. - **`rustls-acme` is feature-gated** (`acme` feature) *(alknet concern)*. Nodes not using ACME don't compile it. The feature is compatible with `quinn` (ACME is quinn-only; iroh uses its own TLS). - **`build_rustls_server_config` becomes async** (or is replaced by an async `TlsSetup::new`) *(alknet concern)*. The accept loop already runs in an async context, so this is a local change. - **ACME state machine lifecycle** *(alknet concern)*: the `AcmeState` task is spawned in `AlknetEndpoint::new()` and aborted on shutdown. The `TlsSetup` struct carries the `JoinHandle` so `AlknetEndpoint::shutdown()` can abort it. - **No handler needed for `acme-tls/1`** *(alknet concern)*: the `dispatch_quinn` guard handles it. `HandlerRegistry` is not involved. - **For alkhttp**: no API surface change. The crate gains a documented constraint — browser-facing deployments require X.509 on the listener — which its consumers must satisfy at the endpoint layer. ## Alternatives Considered ### A. ACME as a `ResolvesServerCert` wrapper behind `X509` *(alknet concern)* OQ-12 suggested ACME "fits naturally as an additional `TlsIdentity` variant or as a `rustls::ResolvesServerCert` implementation behind the existing `X509` path." The second option — wrapping `X509` — was rejected because ACME needs async state + config fields (domains, cache, contact) that don't fit behind the static `X509 { cert, key }` variant. A `ResolvesServerCert` that internally does ACME would need to be constructed at config time with those fields, which means `X509` would need to carry them — bloating the variant for non-ACME users. A dedicated `Acme` variant is cleaner. ### B. Keep `RawKey` coupled to iroh, only add ACME *(alknet concern)* Rejected because the coupling is the root cause of quinn-only builds not supporting the "default" identity mode. Fixing only ACME would leave the artificial iroh dependency in place. Since both changes touch `TlsIdentity` and `build_rustls_server_config`, doing them together avoids two breaking changes to the same enum. ### C. Use `iroh::SecretKey` for both, re-export from alknet-core *(alknet concern)* Rejected because it would make `iroh` a non-optional dependency of alknet-core, defeating the feature-gated transport design (alknet ADR-010). `ed25519-dalek` is a lightweight, pure-Rust crate; `iroh` is not. ### D. Register a no-op `ProtocolHandler` for `acme-tls/1` *(alknet concern)* Rejected because it would require the handler registry to know about ACME (a TLS-layer concern), polluting the ALPN dispatch abstraction. The `dispatch_quinn` guard is a one-line check that keeps ACME handling in the endpoint layer where it belongs. ## Cross-References - OQ-12 (TLS identity provisioning) — updated by this ADR *(alknet OQ)* - alknet ADR-010 — multi-connectivity endpoint, feature-gated transports; the ALPN router and endpoint that owns TLS identity and dispatch (an alknet decision; not ported to alkhttp) - alknet ADR-004 — auth as shared core (an alknet decision; ported to alkhttp as alkhttp ADR-004, same number, different scope) - `docs/architecture/crates/core/endpoint.md` *(alknet doc)* — TLS identity use cases - `docs/architecture/crates/core/config.md` *(alknet doc)* — `TlsIdentity` enum - `/workspace/@alkdev/reverse-proxy/src/tls/` — proven ACME implementation pattern - `rustls-acme` crate — ACME state machine + cert resolver - alkhttp ADR-044 — the WebSocket browser path that makes the X.509 requirement load-bearing for this crate - alkhttp ADR-034 — browsers are not peers; the public X.509 endpoint role and the hub role in the peer model