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.
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 (
AcmeStateevent 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/1must be in the server'salpn_protocols, and aResolvesServerCertAcmemust 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
ResolvesServerCertAcmeresolver 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.awaitthen returns a completedConnectionwith ALPNacme-tls/1. alknet'sdispatch_quinnwould 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
HttpAdapterregisters 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 constructTlsIdentity::RawKeymust update. ed25519-dalekbecomes a direct dependency of alknet-core (alknet concern). It's already in the dependency tree (transitive via iroh), so no new compilation cost forirohbuilds. Quinn-only builds that were not usingRawKeybefore will now compileed25519-dalek— it's a small, pure-Rust crate with no C dependencies.rustls-acmeis feature-gated (acmefeature) (alknet concern). Nodes not using ACME don't compile it. The feature is compatible withquinn(ACME is quinn-only; iroh uses its own TLS).build_rustls_server_configbecomes async (or is replaced by an asyncTlsSetup::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
AcmeStatetask is spawned inAlknetEndpoint::new()and aborted on shutdown. TheTlsSetupstruct carries theJoinHandlesoAlknetEndpoint::shutdown()can abort it. - No handler needed for
acme-tls/1(alknet concern): thedispatch_quinnguard handles it.HandlerRegistryis 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 casesdocs/architecture/crates/core/config.md(alknet doc) —TlsIdentityenum/workspace/@alkdev/reverse-proxy/src/tls/— proven ACME implementation patternrustls-acmecrate — 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