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.
344 lines
16 KiB
Markdown
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
|