Files
alkhttp/docs/architecture/decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md
T
glm-5.3-flash 4a825d33e7 feat(infra): full-surface integration suite + docs sync + publish prep
Full-surface integration suite (tests/full_surface.rs, mcp feature):
- one HttpAdapter over real TCP (ProtocolHandler::handle path) serving
  gateway endpoints, /openapi.json, /mcp, and the WS channels session
- gateway: search/schema/call/subscribe/batch/publish presence,
  envelope shapes, error fidelity end-to-end
- from_openapi import -> Internal-by-default invisible from the wire ->
  External facade composes it via env.invoke -> upstream HTTP API
  called end-to-end (ADR-015 composition model exercised)
- to_openapi 6-path doc validated against openapiv3 over the wire
- to_mcp: MCP client connects to /mcp on the served adapter, lists the
  4 gateway tools, search returns ACL-filtered ops (Sub excluded)

Production fix: the WS upgrade route was reserved but never wired into
HttpAdapter's router (the ws-upgrade-session tests built their own
router). Now wired with ws_bearer_auth (401 without a resolvable
token) around ws_upgrade_handler.

Docs sync: all 28 'Port notes' sections/blockquotes stripped from
ported ADRs/specs; OQ-01/OQ-02 statuses corrected to resolved in
overview.md, websocket.md, and the README table (open-questions.md was
already current).

Publish prep: cargo publish --dry-run --allow-dirty succeeds;
cargo doc --no-deps warning-free (ADR link targets fixed); feature
combinations (default / test-support / mcp / wss / all) compile
warning-free under clippy -D warnings.

Verified: cargo test (182 lib default), --all-features (227 lib + 29
integration), clippy -D warnings x3 feature sets, fmt, doc,
publish --dry-run.
2026-08-28 16:07:56 +00:00

344 lines
16 KiB
Markdown

# 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<String>,
cache_dir: PathBuf,
directory: AcmeDirectory, // enum: Production, Staging, Custom(String)
contact: Vec<String>, // 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<AcmeStateHandle>, // 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