# 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 > **Port note (scope):** This ADR is ported because it 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 (alknet ADR-027's alknet-internal > sections describe the alknet endpoint layer, which remains an alknet > concern; see §Port notes at the end of this file). The clauses below > that matter to alkhttp are the browser constraint and the identity > modes it implies; the alknet-internal provisioning mechanics are > retained for provenance and marked as alknet concerns. 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 — see Port notes) - 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 ## Port notes - All TLS provisioning mechanics (`TlsIdentity`, `TlsSetup`, `build_rustls_server_config`, `AcmeState`, the `acme` feature, the `acme-tls/1` dispatch guard) are **alknet concerns** — they live in the alknet endpoint/config layer (alknet ADR-010, ADR-001), not in alkhttp. alkhttp is transport-coupling-free (no TLS, no endpoint, no accept loop); sections describing them are marked *"(alknet concern)"* inline and retained for provenance. The clause that is substantive for alkhttp — **browsers require X.509 for browser-facing TLS** — is restated under "What alkhttp inherits from this decision." - `alknet-core` ownership of `Ed25519SecretKey` is historical: the core types now live in the alkcall crate (vendored there). The decision text is preserved as written; the wrapper type is an alkcall-owned type today. - Reference links rewritten per alkhttp docs conventions: the original relative sibling-ADR links to alknet ADR-010 and alknet ADR-004 are textual references above (neither is ported to alkhttp under those numbers/slug; alkhttp ADR-004 is a different document — auth as shared core). The `crates/core/*.md` spec links are dropped in favor of the textual "alknet doc" annotations because the alknet spec tree is not part of this crate's docs. - The original ADR-083 is an alknet decision (shared `dispatch` guard relocation); it is cited textually as "alknet ADR-083" and is not ported. - Original title preserved: "TLS Identity Redesign — ACME Integration + RawKey Decoupling".