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

16 KiB

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)

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):

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:

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:

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:

// 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:

[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