Files
alkhttp/docs/architecture/decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md
T
glm-5.3-flash 320ea87b08 docs: port architecture specs and ADRs from alknet-http; write new alkhttp ADRs 067-070
Phase 1 (SDD) — architecture documentation:

Ported specs (adapted for alkcall, producer/consumer terms, 6-endpoint
gateway, channels-over-WS, Sub/Pub operation types):
- overview.md, http-server.md, http-adapters.md, http-mcp.md
- README.md index (rewritten for alkhttp)

New ADRs:
- 067: WebSocket carries the channels protocol (8-byte chunk demux,
  channel 0 = alk/call, upgrade path /alk/channels)
- 068: gateway /publish endpoint for Pub operations (NDJSON body)
- 069: WebTransport out of scope in alkhttp (alknet concern)
- 070: from_wss consumer adapter (wss feature, tokio-tungstenite)

Ported ADRs (25, same numbers, port notes + amendments where the
extraction changed facts): 001-004, 010, 014, 015, 017, 022, 023, 027,
034, 036, 037, 039, 041, 042, 044, 045, 046, 047, 048, 049, 051, 066.

websocket.md rewritten for the channels session; open-questions.md
seeded (OQ-01 WS byte-stream adapter, OQ-02 /publish framing,
OQ-03 from_wss reconnect, OQ-04 browser client ownership).

Verified: cargo test, clippy -D warnings, fmt, doc --no-deps.
2026-08-27 14:19:24 +00:00

18 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

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)

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 — 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".