docs(arch): spec alknet-tls crate — shared TLS config across quinn + TCP+TLS + iroh (ADR-082, OQ-59)

The TLS setup in alknet-core is welded to quinn: build_rustls_server_config
produces a rustls::ServerConfig, then build_quinn_server_config_from_rustls
consumes it into quinn::ServerConfig — the rustls config is moved, not
shareable. ACME is worse: the AcmeState task is spawned inside the quinn
endpoint, so a TCP+TLS listener would need a second ACME state machine
(two orders for the same domain, two cert caches, Let's Encrypt
rate-limit risk).

alknet-tls extracts the TLS setup into a shareable TlsServerConfig:
- TlsServerConfig::new(identity, alpns) builds the rustls::ServerConfig
  once (including the ACME state machine if ACME)
- for_quinn() clones it for quinn (QuicServerConfig::try_from)
- for_tcp_tls() clones it for tokio-rustls (TlsAcceptor::from)
- rustls_config() borrows it for any other consumer
- One cert, one ACME state machine, N transports

TlsIdentity and Ed25519SecretKey stay in core (config types).
fingerprint.rs stays in core (shared by server endpoint + client
FingerprintPinVerifier in alknet-call — OQ-59 tracks whether it should
move; likely stays because moving would force alknet-call to depend on
alknet-tls, pulling TLS infra into client-only deployments).

iroh shares the key, not the rustls config — iroh has its own TLS built
into the Endpoint (takes iroh::SecretKey, not rustls::ServerConfig).
alknet-tls has no for_iroh() method; the assembly layer passes
Ed25519SecretKey to iroh directly.

Feature gates: quinn / tcp / acme as independent opt-ins. rustls always
present. tokio-rustls only when tcp. quinn only when quinn. rustls-acme
only when acme.

Files:
- crates/tls/README.md: crate spec (TlsServerConfig API, what moves/
  stays, feature gates, deps, behavior-preservation invariants)
- decisions/082-alknet-tls-extraction.md: the ADR (Proposed)
- questions/059-fingerprint-module-location.md: OQ-59 (should
  fingerprint.rs stay in core or move to alknet-tls? open, two-way,
  likely stays)
- open-questions.md: OQ-59 added to alknet-core table
- README.md: tls doc table row + ADR-082 row added

Review (architecture-reviewer): 3 critical (missing
build_quinn_server_config_from_rustls in README table, missing futures
dep, two TlsServerConfig code blocks disagreed on feature gates), 7
warnings (behavior-preservation invariants: max_early_data_size,
aws_lc_rs provider, verifier scheme list, acme-tls/1 ALPN;
rustls-pki-types dep; fingerprint.rs rustls-usage imprecision; ADR
missing acme-tls/1), 4 suggestions. All criticals and warnings addressed.
This commit is contained in:
glm-5.2 committed 2026-07-13 10:58:45 +00:00
1 parent b0cc0a01b7
commit 192bd0a5a2
5 files changed
+722

No files matched your search

+2
View File
@@ -172,6 +172,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [crates/vault/service.md](crates/vault/service.md) | stable | VaultServiceHandle lifecycle, direct dispatch, cache, error model |
| [crates/vault/protocol.md](crates/vault/protocol.md) | stable | DerivedKey redaction, KeyType, serialization behavior |
| [crates/hub/README.md](crates/hub/README.md) | draft | alknet-hub crate — multi-transport endpoint (TCP+TLS + QUIC), channels substrate (ADR-079 relay), worker registration flow (OQ-58), identity over transports, aggregated peer env, connection lifecycle, service discovery |
| [crates/tls/README.md](crates/tls/README.md) | draft | alknet-tls crate — shared TLS config (`TlsServerConfig`) extractable across quinn + TCP+TLS + iroh; one cert, one ACME state machine, N transports; fixes cert-reuse welding in `alknet-core/endpoint.rs` (ADR-082) |
| [crates/channels/README.md](crates/channels/README.md) | draft | alknet-channels crate — multiplexing proxy, 9-byte chunk format, N channels over one transport stream |
| [crates/channels/overview.md](crates/channels/overview.md) | draft | Crate purpose, the multiplexing collapse, dependencies, transport agnosticism, WASM, relationship to existing crates |
| [crates/channels/channels-wire.md](crates/channels/channels-wire.md) | draft | 9-byte chunk format, stream types, sentinels, framing disambiguation, wire-level invariants (REQ-CH-01..05) |
@@ -265,6 +266,7 @@ adapter location map is now consistent: all HTTP-backed adapters
| [079](decisions/079-hub-relay-translate-not-forward.md) | Hub Relay — Translate, Not Transparently Forward | Accepted |
| [080](decisions/080-channelclient.md) | ChannelClient — the Client Side of a Channels Connection | Accepted |
| [081](decisions/081-channels-subcrate-decomposition.md) | channels Sub-Crate Decomposition | Accepted |
| [082](decisions/082-alknet-tls-extraction.md) | alknet-tls Crate Extraction | Proposed |
## Open Questions
+389
View File
@@ -0,0 +1,389 @@
---
status: draft
last_updated: 2026-07-13
---
# alknet-tls
Shared TLS configuration and certificate management. Builds a
`rustls::ServerConfig` (or an ACME state machine + cert resolver) once,
and hands clones to multiple transports — quinn, `tokio-rustls` (TCP+TLS),
and iroh — so one certificate identity serves QUIC and TCP endpoints
simultaneously. One ACME state machine, one cert, N transports.
## What
`alknet-tls` extracts the TLS setup that was welded to the quinn endpoint
in `alknet-core`. The existing code (`endpoint.rs`) builds a
`rustls::ServerConfig` from a `TlsIdentity`, then **consumes** it into a
`quinn::ServerConfig` — making it impossible to reuse the same cert for a
TCP+TLS listener. ACME is worse: the `AcmeState` task is spawned inside
the quinn endpoint, so a TCP+TLS listener would need its own ACME state
machine (two orders for the same domain, two cert caches, potential
Let's Encrypt rate-limiting).
`alknet-tls` fixes this by making the TLS config **shareable**:
```rust
pub struct TlsServerConfig {
config: rustls::ServerConfig, // Clone — Arc internally
acme_handle: Option<JoinHandle<()>>, // one ACME task, shared
}
impl TlsServerConfig {
pub async fn new(identity: &TlsIdentity, alpns: &[Vec<u8>]) -> Result<Self, TlsError>;
/// Clone for quinn. Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
pub fn for_quinn(&self) -> Result<quinn::ServerConfig, TlsError>;
/// Clone for tokio-rustls (TCP+TLS). Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor;
/// The underlying rustls config, for any other consumer.
pub fn rustls_config(&self) -> &rustls::ServerConfig;
}
```
The key property: `rustls::ServerConfig` is `Clone` (it holds `Arc`s to
the cert resolver and verifier, not the raw key material). So one
`TlsServerConfig` can feed quinn and TCP+TLS simultaneously — one cert,
one ACME state machine, two transports.
## Why
### The cert-reuse problem is concrete
A hub that serves HTTP (TCP+TLS on 443) and channels (QUIC on 4433) with
the same X.509 cert cannot do it with the current code. The
`rustls::ServerConfig` is moved into `quinn::ServerConfig` and consumed.
A TCP+TLS listener would have to build its own `rustls::ServerConfig`
from the same `TlsIdentity` — re-loading the cert file, or re-deriving the
raw key cert, or running a second ACME state machine for the same
domains.
### ACME is the worst case
Two ACME state machines for the same domain:
- Two cert-order attempts (race condition on Let's Encrypt's rate
limiter).
- Two cert caches (divergent cache dirs or a shared dir with no
coordination).
- Two `AcmeState` tasks spawning duplicate `resolver()` instances.
One `TlsServerConfig` with one `AcmeState` task solves this: the ACME
state machine runs once, the `resolver()` is `Arc`-shared across
transports, and both quinn and TCP+TLS get the same cert from the same
order.
### The three use cases
| Use case | Identity | Transports | Browsers? |
|----------|----------|-----------|-----------|
| P2P / native clients | RFC 7250 raw key (Ed25519) | QUIC + TCP (fallback when UDP blocked) | No (browsers can't do raw keys) |
| Domain-hosted / public service | X.509 (manual or ACME) | QUIC + TCP+TLS (same cert) | Yes (via HTTPS / WebSocket; WebTransport when revived) |
| Development | Self-signed | Any | No (untrusted) |
In all cases, TLS + ALPNs "just works" — the TLS handshake negotiates the
ALPN, the `HandlerRegistry` dispatches by ALPN, the transport is a
parameter. The TLS crate's job is to make the cert available to whichever
transports the deployment runs.
### Why a separate crate
1. **Dependency isolation.** `tokio-rustls` is a real dependency that
shouldn't be forced on every `alknet-core` consumer. A node that only
uses quinn doesn't need `tokio-rustls`. A node that uses TCP+TLS
needs it. Feature-gating in core mixes concerns — core is types and
traits, not transport-specific TLS setup.
2. **ACME is heavy.** `rustls-acme` spawns a long-running async task,
manages a cert cache, talks to Let's Encrypt. That's transport infra,
not core types. It belongs in a crate the assembly layer pulls in when
it needs real TLS setup, not in core.
3. **Quinn and iroh have their own TLS.** Quinn wraps
`rustls::ServerConfig` into `quinn::ServerConfig`. Iroh uses its own
raw-key TLS built into the `Endpoint`. `alknet-tls` is the "I have a
cert and I want to share it across transports" layer — it doesn't
replace quinn's or iroh's TLS, it provides the shared cert source.
## Architecture
### What moves from `alknet-core` to `alknet-tls`
| Component | Current location | New location |
|-----------|-----------------|-------------|
| `TlsIdentity` enum | `alknet-core/config.rs` | **stays in core** (it's a config type) |
| `Ed25519SecretKey` | `alknet-core/config.rs` | **stays in core** (config type) |
| `build_rustls_server_config()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (unconditional) |
| `build_quinn_server_config_from_rustls()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (`for_quinn()` — wraps rustls config in `QuicServerConfig`) |
| `TlsSetup` (ACME state machine) | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` (the `TlsServerConfig::new` ACME path) |
| `RawKeyCertResolver` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `Ed25519SigningKey` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `AcceptAnyCertVerifier` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `SelfSignedCert` / `generate_self_signed_cert()` | `alknet-core/endpoint.rs` (`#[cfg(feature = "quinn")]`) | `alknet-tls` |
| `load_cert_chain()` / `load_private_key()` | `alknet-core/endpoint.rs` | `alknet-tls` |
| `fingerprint.rs` | `alknet-core/fingerprint.rs` | **stays in core** (shared by server + client; client is in `alknet-call`; production code uses `sha2` + manual DER only — `rustls` is test-only. See OQ-59.) |
`TlsIdentity` and `Ed25519SecretKey` stay in core because they're config
types — `StaticConfig` holds a `TlsIdentity`, and config types belong in
core. `alknet-tls` re-exports them for convenience. `fingerprint.rs` stays
in core because it's shared by both the server path (endpoint extracts
fingerprint from the client cert) and the client path (`FingerprintPinVerifier`
in `alknet-call` matches the server's cert against a pinned fingerprint).
The production code in `fingerprint.rs` uses only `sha2` and manual DER
parsing — the `rustls::sign` usage is in the test helper only. See OQ-59
for the full trade-off.
### `TlsServerConfig`
The central type. Built once from a `TlsIdentity` + ALPN list, shared
across transports.
```rust
pub struct TlsServerConfig {
/// The rustls server config. Clone-safe (holds Arcs to cert resolver
/// and verifier, not raw key material).
config: rustls::ServerConfig,
/// The ACME state machine task, if ACME is active. One task, shared
/// — dropping this handle does NOT stop the ACME state machine
/// (it's owned by the config, not the endpoint).
acme_handle: Option<tokio::task::JoinHandle<()>>,
}
```
Construction:
```rust
impl TlsServerConfig {
/// Build a TLS server config from the given identity and ALPN list.
/// For ACME, spawns the ACME state machine task and wires its
/// resolver into the rustls config. For X509/RawKey/SelfSigned,
/// loads the cert and builds the resolver directly.
pub async fn new(
identity: &TlsIdentity,
alpns: &[Vec<u8>],
) -> Result<Self, TlsError>;
}
```
The ALPN list is the set of ALPNs the deployment wants to advertise
(`alknet/call`, `alknet/channels`, `h2`, `http/1.1`, etc.). For ACME, the
`acme-tls/1` ALPN is appended automatically (for the TLS-ALPN-01
challenge, ADR-027 §7).
### Behavior-preservation invariants
The extraction must preserve these load-bearing TLS behaviors from the
current code. An implementer who omits any of these produces a crate
that compiles and passes type-checks but silently changes TLS behavior:
- **`max_early_data_size = u32::MAX`** on all server config paths (X509,
RawKey, SelfSigned, ACME). Enables 0-RTT / early data. Omitting it
disables 0-RTT, silently breaking clients that use it.
- **`rustls::crypto::aws_lc_rs::default_provider()`** as the crypto
provider on all paths. Matches iroh's `tls-aws-lc-rs` feature. Do not
switch to `ring` or the process-default provider without an ADR —
different FIPS status, different platform support.
- **`AcceptAnyCertVerifier`'s `supported_verify_schemes()`** returns
ED25519 + ECDSA P-256/P-384 + RSA PSS/PKCS1 (SHA256/384/512). This
list determines which client cert signature algorithms the server
accepts. Must be preserved verbatim.
- **`acme-tls/1` ALPN append** for the ACME path only. The TLS-ALPN-01
challenge requires the server to advertise `acme-tls/1` in its ALPN
list. Appended in `TlsServerConfig::new`'s ACME branch, not by the
caller.
Transport-specific accessors:
```rust
impl TlsServerConfig {
/// Produce a `quinn::ServerConfig` for a QUIC listener. Clones the
/// rustls config (cheap — Arc-shared cert resolver), wraps it in
/// `QuicServerConfig`. Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
pub fn for_quinn(&self) -> Result<quinn::ServerConfig, TlsError>;
/// Produce a `tokio_rustls::TlsAcceptor` for a TCP+TLS listener.
/// Clones the rustls config. Feature-gated on `tcp` (pulls
/// `tokio-rustls`).
#[cfg(feature = "tcp")]
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor;
/// Borrow the underlying rustls config, for consumers that need to
/// build their own transport-specific wrapper (e.g. iroh, which
/// has its own TLS built in but shares the Ed25519 key).
pub fn rustls_config(&self) -> &rustls::ServerConfig;
}
```
### Iroh: shares the key, not the rustls config
Iroh is different from quinn and TCP+TLS: it has its own TLS built into
the `Endpoint`, using RFC 7250 raw keys. It does not consume a
`rustls::ServerConfig` — it takes an `iroh::SecretKey` and handles TLS
internally. So `alknet-tls` does not have a `for_iroh()` method. Instead,
the assembly layer reads the `Ed25519SecretKey` from `StaticConfig`
(stays in core) and passes it to iroh's `Endpoint::builder().secret_key()`
directly. `alknet-tls` is involved only when iroh is not the sole
transport — in that case, the same `Ed25519SecretKey` feeds both
`TlsServerConfig::new(TlsIdentity::RawKey(key), ...)` (for quinn/TCP) and
`iroh::SecretKey::from_bytes(key.as_bytes())` (for iroh).
The fingerprint is normalized across all three paths (ADR-030 §6):
`ed25519:<hex>` for raw keys, whether the cert came from quinn's
`RawKeyCertResolver`, iroh's built-in TLS, or a future TCP+TLS raw-key
path. `fingerprint.rs` (in core) handles this.
### Feature gates
```toml
[features]
default = []
quinn = ["dep:quinn"] # for_quinn() — wraps rustls config for quinn
tcp = ["dep:tokio-rustls"] # for_tcp_tls() — wraps rustls config for TCP+TLS
acme = ["dep:rustls-acme"] # ACME state machine
```
A deployment that only uses quinn enables `quinn`. A deployment that
uses TCP+TLS enables `tcp`. A deployment that uses both enables both.
ACME is opt-in (heavy dep, long-running task). The `rustls` dep is always
present (it's the core TLS library).
### Dependencies
```
alknet-tls
├── alknet-core (TlsIdentity, Ed25519SecretKey, fingerprint — re-exported)
├── rustls (ServerConfig, cert types — always present)
├── rustls-pki-types (CertificateDer, PrivateKeyDer, etc. — via rustls re-export
│ or direct dep; core lists it directly)
├── rustls-pemfile (cert/key file loading — always present)
├── rcgen (self-signed cert generation — always present)
├── ed25519-dalek (Ed25519 signing key — always present, via core)
├── sha2 (fingerprint computation — always present, via core)
├── tokio (spawn for ACME task — always present)
├── futures (StreamExt for ACME event loop — present; only used
│ in the ACME path, can be acme-gated or always present)
├── tracing (logging)
├── quinn (optional — for_quinn())
├── tokio-rustls (optional — for_tcp_tls())
└── rustls-acme (optional — ACME state machine)
```
`alknet-core` loses the `quinn`, `iroh`, `rustls-pemfile`, `rcgen`,
`ed25519-dalek`, and `rustls-acme` deps from its `[features]`
section — they move to `alknet-tls`. Core keeps a narrow `rustls` /
`rustls-pki-types` dep only if `fingerprint.rs` stays (OQ-59): the
production fingerprint code uses `sha2` + manual DER only, but the test
helper `build_ed25519_spki_der` uses `rustls::sign::public_key_to_spki`.
If `fingerprint.rs` moves to `alknet-tls`, core becomes `rustls`-free.
### What `AlknetEndpoint` does after the refactor
`AlknetEndpoint::new()` currently builds `TlsSetup` internally. After
the refactor, it takes an `Arc<TlsServerConfig>`:
```rust
impl AlknetEndpoint {
pub async fn new(
static_config: &StaticConfig,
tls_config: Arc<TlsServerConfig>, // ← new param
handlers: HandlerRegistry,
dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Result<Self, EndpointError>;
}
```
The endpoint calls `tls_config.for_quinn()` to get its quinn server
config. The ACME handle is on the `TlsServerConfig`, not the endpoint —
the endpoint doesn't own the ACME task. The assembly layer builds the
`TlsServerConfig` once, passes `Arc::clone()` to the endpoint, and
passes another `Arc::clone()` to the TCP+TLS accept loop (which lives in
the hub or a future `TcpTlsAcceptor`).
### The TCP+TLS accept loop (out of scope for this crate)
`alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP
accept loop (`TcpListener::accept` → `TlsAcceptor::accept` →
`Connection::from_bidi` → `HandlerRegistry::dispatch`) lives elsewhere —
in `alknet-hub` (the hub is the primary multi-transport consumer) or in
a future `alknet-core` `TcpTlsAcceptor` module. `alknet-tls` is the cert
provider, not the accept loop. This keeps `alknet-tls` focused on TLS
setup and cert sharing, not transport accept logic.
## Crate dependencies (in the dep graph)
```
alknet-tls
├── alknet-core (TlsIdentity, Ed25519SecretKey, fingerprint)
alknet-core (loses TLS setup code)
├── (rustls — only for fingerprint.rs types, if kept)
alknet-call (client-side verifier — unchanged)
├── alknet-core (fingerprint.rs)
alknet-http (future: TCP+TLS accept loop)
├── alknet-tls (TlsServerConfig::for_tcp_tls())
├── alknet-core (Connection::from_bidi, HandlerRegistry)
alknet-hub (multi-transport endpoint)
├── alknet-tls (TlsServerConfig — shared across quinn + TCP)
├── alknet-channels-call (ChannelClient)
├── alknet-call (CallAdapter, Dispatcher)
├── alknet-http (HttpAdapter)
├── alknet-core (AlknetEndpoint, HandlerRegistry, Connection)
```
`alknet-tls` depends on `alknet-core` only. No handler crate depends on
`alknet-tls` — they depend on `alknet-core` for types and on
`alknet-tls` only indirectly through the assembly layer. The assembly
layer (the deployment binary) builds the `TlsServerConfig` and passes it
to the endpoint and the TCP+TLS accept loop.
## Design Decisions
All design decisions are documented as ADRs in
[decisions/](../../decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [082](../../decisions/082-alknet-tls-extraction.md) | alknet-tls crate extraction | Extract TLS setup from alknet-core/endpoint.rs; `TlsServerConfig` shareable across quinn + TCP+TLS + iroh; one ACME state machine |
## Open Questions
See [open-questions.md](../../open-questions.md) for full details.
- **OQ-59** (open): Should `fingerprint.rs` stay in `alknet-core` or move
to `alknet-tls`? It uses `rustls::pki_types` and `rustls::sign` types,
which creates a `rustls` dep in core. If it moves to `alknet-tls`, the
client-side `FingerprintPinVerifier` (in `alknet-call`) would depend
on `alknet-tls` — a new dep edge. If it stays, core keeps a narrow
`rustls` dep. Decision-ready — the answer depends on whether we want
core to be `rustls`-free.
## References
- `docs/architecture/decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md`
— `TlsIdentity` (RawKey / X509 / Acme), RFC 7250, browser limitation
- `docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md` §6
— fingerprint normalization (`ed25519:<hex>` across quinn/iroh)
- `docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md`
— client-side verifier selection (CA vs fingerprint pin)
- `docs/architecture/decisions/065-connection-from-stream-generic-single-stream.md`
— `Connection::from_stream`/`from_bidi` (TCP+TLS path)
- `docs/architecture/decisions/010-alpn-router-and-endpoint.md` Amendment 1
— TCP+TLS dispatch via `from_stream` (accept loop outside the endpoint struct)
- `docs/architecture/crates/core/endpoint.md` — current endpoint design
(TLS section will be amended to point to `alknet-tls`)
- `docs/architecture/crates/core/config.md` — `TlsIdentity`, `StaticConfig`
- `crates/alknet-core/src/endpoint.rs` — the code being extracted
(`build_rustls_server_config`, `TlsSetup`, `RawKeyCertResolver`,
`Ed25519SigningKey`, `AcceptAnyCertVerifier`, `generate_self_signed_cert`,
`load_cert_chain`, `load_private_key`)
- `crates/alknet-core/src/fingerprint.rs` — fingerprint extraction
(shared by server endpoint and client verifier)
@@ -0,0 +1,274 @@
# ADR-082: alknet-tls Crate Extraction
## Status
Proposed
## Context
The TLS setup in `alknet-core` is welded to quinn. The current flow:
1. `StaticConfig.tls_identity: Option<TlsIdentity>` carries the
identity (X509 / RawKey / SelfSigned / Acme).
2. `build_rustls_server_config(tls_identity, alpns)` produces a
`rustls::ServerConfig`. This function is transport-agnostic in
principle — it returns a `rustls::ServerConfig`, which is what both
quinn and `tokio-rustls` consume.
3. `build_quinn_server_config_from_rustls(rustls_config)` **consumes**
the `rustls::ServerConfig` into a `quinn::ServerConfig`. The rustls
config is moved — it cannot be reused for a TCP+TLS listener.
4. `TlsSetup` (which owns the ACME state machine handle) is
`#[cfg(feature = "quinn")]` — it only exists when quinn is enabled.
ACME is structurally a quinn-only path today.
5. The `AcmeState` task is spawned inside `TlsSetup::new_acme`, and its
`JoinHandle` is stored on `AlknetEndpoint`. If you wanted to run ACME
for a TCP+TLS listener, you'd have to duplicate the ACME setup or
restructure.
### The cert-reuse problem
A hub that serves HTTP (TCP+TLS on 443) and channels (QUIC on 4433)
with the same X.509 cert cannot do it with the current code. The
`rustls::ServerConfig` is moved into `quinn::ServerConfig` and
consumed. A TCP+TLS listener would have to build its own
`rustls::ServerConfig` from the same `TlsIdentity` — re-loading the cert
file, or re-deriving the raw key cert, or running a second ACME state
machine for the same domains.
ACME is the worst case: two ACME state machines for the same domain means
two cert-order attempts (race condition on Let's Encrypt's rate limiter),
two cert caches, two `AcmeState` tasks spawning duplicate `resolver()`
instances. One ACME state machine with one resolver, shared across
transports, is the only correct design.
### The `rustls::ServerConfig` is already shareable
`rustls::ServerConfig` is `Clone` — it holds `Arc`s to the cert resolver
and verifier, not the raw key material. So the fix is structural, not
algorithmic: build the config once, clone it for each transport. The
existing `build_rustls_server_config` function already produces the
right type; the welding is in the layer above it (the `#[cfg(feature =
"quinn")]` gates, the `TlsSetup` struct, the consumption into
`quinn::ServerConfig`).
### The three use cases
| Use case | Identity | Transports | Browsers? |
|----------|----------|-----------|-----------|
| P2P / native clients | RFC 7250 raw key (Ed25519) | QUIC + TCP (fallback when UDP blocked) | No |
| Domain-hosted / public service | X.509 (manual or ACME) | QUIC + TCP+TLS (same cert) | Yes |
| Development | Self-signed | Any | No |
In all cases, TLS + ALPNs "just works" — the TLS handshake negotiates the
ALPN, the `HandlerRegistry` dispatches by ALPN. The TLS crate's job is
to make the cert available to whichever transports the deployment runs,
without duplicating the cert or the ACME state machine.
### Iroh is different
Iroh has its own TLS built into the `Endpoint`, using RFC 7250 raw keys.
It does not consume a `rustls::ServerConfig` — it takes an
`iroh::SecretKey` and handles TLS internally. So `alknet-tls` does not
have a `for_iroh()` method. The assembly layer reads the
`Ed25519SecretKey` from `StaticConfig` and passes it to iroh directly.
`alknet-tls` is involved only when iroh is not the sole transport — in
that case, the same `Ed25519SecretKey` feeds both `TlsServerConfig::new`
(for quinn/TCP) and `iroh::SecretKey::from_bytes` (for iroh).
## Decision
### Extract `alknet-tls` as a new crate
A new crate `alknet-tls` holds the TLS setup code extracted from
`alknet-core/endpoint.rs`. The central type is `TlsServerConfig`, built
once from a `TlsIdentity` + ALPN list, shared across transports via
cheap clones.
```rust
pub struct TlsServerConfig {
config: rustls::ServerConfig,
acme_handle: Option<tokio::task::JoinHandle<()>>,
}
impl TlsServerConfig {
pub async fn new(identity: &TlsIdentity, alpns: &[Vec<u8>])
-> Result<Self, TlsError>;
#[cfg(feature = "quinn")]
pub fn for_quinn(&self) -> Result<quinn::ServerConfig, TlsError>;
#[cfg(feature = "tcp")]
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor;
pub fn rustls_config(&self) -> &rustls::ServerConfig;
}
```
### What moves
| Component | From | To |
|-----------|------|-----|
| `build_rustls_server_config()` | `alknet-core/endpoint.rs` | `alknet-tls` |
| `TlsSetup` / ACME state machine | `alknet-core/endpoint.rs` | `alknet-tls` (`TlsServerConfig::new` ACME path) |
| `RawKeyCertResolver` | `alknet-core/endpoint.rs` | `alknet-tls` |
| `Ed25519SigningKey` | `alknet-core/endpoint.rs` | `alknet-tls` |
| `AcceptAnyCertVerifier` | `alknet-core/endpoint.rs` | `alknet-tls` |
| `SelfSignedCert` / `generate_self_signed_cert()` | `alknet-core/endpoint.rs` | `alknet-tls` |
| `load_cert_chain()` / `load_private_key()` | `alknet-core/endpoint.rs` | `alknet-tls` |
| `build_quinn_server_config_from_rustls()` | `alknet-core/endpoint.rs` | `alknet-tls` (`for_quinn()`) |
### What stays
| Component | Location | Why |
|-----------|----------|-----|
| `TlsIdentity` enum | `alknet-core/config.rs` | Config type — `StaticConfig` holds it |
| `Ed25519SecretKey` | `alknet-core/config.rs` | Config type — iroh reads it directly |
| `AcmeDirectory` | `alknet-core/config.rs` | Config type |
| `fingerprint.rs` | `alknet-core` | Shared by server (endpoint) and client (`alknet-call`'s `FingerprintPinVerifier`) — moving it would create a dep edge from `alknet-call` to `alknet-tls`. Production code uses `sha2` + manual DER only; `rustls` is test-only. See OQ-59. |
| `AlknetEndpoint` | `alknet-core` | The endpoint struct stays; it takes `Arc<TlsServerConfig>` instead of building it internally |
### Feature gates
```toml
[features]
default = []
quinn = ["dep:quinn"] # for_quinn()
tcp = ["dep:tokio-rustls"] # for_tcp_tls()
acme = ["dep:rustls-acme"] # ACME state machine
```
A deployment enables the features for the transports it runs. The
`rustls` dep is always present (core TLS library). `tokio-rustls` is
only pulled in when `tcp` is enabled. `quinn` is only pulled in when
`quinn` is enabled. `rustls-acme` is only pulled in when `acme` is
enabled. `futures` (for `StreamExt` in the ACME event loop) is present
when `acme` is enabled. `rustls-pki-types` is available via `rustls`'s
re-export (core lists it directly; the new crate can rely on the
re-export or list it directly — implementation detail).
### `AlknetEndpoint` takes `Arc<TlsServerConfig>`
```rust
impl AlknetEndpoint {
pub async fn new(
static_config: &StaticConfig,
tls_config: Arc<TlsServerConfig>, // ← new param
handlers: HandlerRegistry,
dynamic: Arc<ArcSwap<DynamicConfig>>,
identity_provider: Arc<dyn IdentityProvider>,
) -> Result<Self, EndpointError>;
}
```
The endpoint calls `tls_config.for_quinn()` to get its quinn server
config. The ACME handle lives on the `TlsServerConfig`, not the
endpoint — the endpoint doesn't own the ACME task. The assembly layer
builds the `TlsServerConfig` once, passes `Arc::clone()` to the
endpoint, and passes another `Arc::clone()` to the TCP+TLS accept loop.
### The TCP+TLS accept loop lives outside `alknet-tls`
`alknet-tls` provides `for_tcp_tls() -> TlsAcceptor`. The actual TCP
accept loop (`TcpListener::accept` → `TlsAcceptor::accept` →
`Connection::from_bidi` → `HandlerRegistry::dispatch`) lives elsewhere —
in `alknet-hub` (the primary multi-transport consumer) or in a future
`alknet-core` module. `alknet-tls` is the cert provider, not the
accept loop. This keeps `alknet-tls` focused on TLS setup and cert
sharing, not transport accept logic.
### One ACME state machine, shared
For ACME, `TlsServerConfig::new` spawns the `AcmeState` task once and
wires its `resolver()` into the `rustls::ServerConfig`. Both
`for_quinn()` and `for_tcp_tls()` clone the same `rustls::ServerConfig`,
which shares the same `Arc<dyn ResolvesServerCert>` (the ACME resolver).
One ACME order, one cert cache, one resolver, two transports.
The `acme-tls/1` ALPN is appended to the server's `alpn_protocols` only
when ACME is active (ADR-027 §7); this behavior moves to
`TlsServerConfig::new`'s ACME branch unchanged. The caller does not
append `acme-tls/1` — the TLS crate does.
### Behavior-preservation invariants
The extraction must preserve these load-bearing TLS behaviors from the
current code. An implementer who omits any of these produces a crate
that compiles and passes type-checks but silently changes TLS behavior:
- **`max_early_data_size = u32::MAX`** on all server config paths.
Enables 0-RTT / early data. Omitting it silently breaks 0-RTT clients.
- **`rustls::crypto::aws_lc_rs::default_provider()`** as the crypto
provider on all paths. Matches iroh's `tls-aws-lc-rs` feature. Do not
switch to `ring` or the process-default provider without an ADR.
- **`AcceptAnyCertVerifier`'s `supported_verify_schemes()`** returns
ED25519 + ECDSA P-256/P-384 + RSA PSS/PKCS1 (SHA256/384/512). This
list determines which client cert signature algorithms the server
accepts. Must be preserved verbatim.
- **`acme-tls/1` ALPN append** for the ACME path only (above).
## Consequences
**Positive:**
- One certificate identity serves QUIC and TCP+TLS simultaneously. A
hub can serve HTTP on 443 (TCP+TLS) and channels on 4433 (QUIC) with
the same X.509 cert or the same ACME-issued cert.
- One ACME state machine per domain, shared across transports. No
duplicate orders, no cert cache divergence, no Let's Encrypt
rate-limit risk from duplicate orders.
- `alknet-core` loses the quinn-specific TLS setup code and the
`#[cfg(feature = "quinn")]` gates on `TlsSetup`, `RawKeyCertResolver`,
`AcceptAnyCertVerifier`, etc. Core becomes leaner — it holds config
types and the endpoint struct, not TLS setup machinery.
- `tokio-rustls` is an opt-in dep (behind `alknet-tls`'s `tcp` feature),
not forced on every `alknet-core` consumer.
- The raw-key path (RFC 7250) works for both QUIC and TCP+TLS — native
clients can use raw keys over either transport. The browser case
(requires X.509) is the exception, not the constraint.
**Negative:**
- `AlknetEndpoint::new` gains a new parameter (`Arc<TlsServerConfig>`),
which is a breaking change for existing callers. The assembly layer
(CLI binary, hub construction) must build the `TlsServerConfig` before
constructing the endpoint. This is expected — it's the point of the
extraction.
- `alknet-core` may keep a narrow `rustls` dep if `fingerprint.rs` stays
(OQ-59). If `fingerprint.rs` moves to `alknet-tls`, `alknet-call`'s
client-side `FingerprintPinVerifier` gains a dep on `alknet-tls`. The
trade-off is documented in OQ-59.
- A new crate in the dep graph. Small, focused, one clear
responsibility.
## Door type
**One-way.** Extracting TLS setup into a shareable config is a
structural change: `AlknetEndpoint::new` signature changes, the assembly
layer must build `TlsServerConfig` separately, and the cert-sharing
contract (one config, N transports) becomes the architecture. Reversing
would mean re-welding TLS to the endpoint and losing the multi-transport
cert-reuse capability — the exact capability the hub needs.
The `TlsServerConfig` API surface (`new`, `for_quinn`, `for_tcp_tls`,
`rustls_config`) is one-way — changing it after consumers exist is a
rewrite. The internal implementation (how the ACME state machine is
spawned, how the cert resolver works) is two-way — implementation
details that can change without breaking the contract.
## References
- ADR-010 Amendment 1 — TCP+TLS dispatch via `from_stream` (the accept
loop that consumes `TlsServerConfig::for_tcp_tls()`)
- ADR-027 — `TlsIdentity` (RawKey / X509 / Acme), RFC 7250, browser
limitation
- ADR-030 §6 — fingerprint normalization (`ed25519:<hex>` across
quinn/iroh)
- ADR-034 — client-side verifier selection (CA vs fingerprint pin)
- ADR-065 — `Connection::from_stream`/`from_bidi` (TCP+TLS path)
- ADR-080 — `ChannelClient::from_connection` (transport-agnostic
client; the pattern this ADR mirrors on the TLS side)
- OQ-59 — should `fingerprint.rs` stay in core or move to `alknet-tls`?
- `crates/alknet-core/src/endpoint.rs` — the code being extracted
- `crates/alknet-core/src/config.rs` — `TlsIdentity`, `Ed25519SecretKey`
(staying in core)
- `crates/alknet-core/src/fingerprint.rs` — fingerprint extraction
(OQ-59)
- `docs/architecture/crates/tls/README.md` — the crate spec
+1
View File
@@ -76,6 +76,7 @@ Door type is separate from whether a decision is made. A two-way door is a decis
| [OQ-13](questions/013-operation-path-format-and-routing-scope.md) | Operation Path Format and Routing Scope | resolved | two | med |
| [OQ-14](questions/014-batch-operation-semantics.md) | Batch Operation Semantics | resolved | two | low |
| [OQ-55](questions/055-alknetclient-establishment-extraction.md) | AlknetClient / Client Establishment Extraction | deferred(scope) | two | med |
| [OQ-59](questions/059-fingerprint-module-location.md) | Should `fingerprint.rs` Stay in Core or Move to `alknet-tls`? | open | two | med |
### alknet-call
@@ -0,0 +1,56 @@
# OQ-59: Should `fingerprint.rs` Stay in `alknet-core` or Move to `alknet-tls`?
- **Origin**: `docs/architecture/crates/tls/README.md` (the crate spec),
`docs/architecture/decisions/082-alknet-tls-extraction.md` (the ADR).
- **Status**: open
- **Door type**: two-way (moving a module between crates is a refactor,
not a wire-format change; the function signatures and types are
unchanged)
- **Priority**: medium
- **Blocked on**: nothing structural — the question is a dependency-edge
trade-off, not a missing capability. The existing code works in either
location. The decision is about which dep edge is cleaner.
- **Resolution**: Not yet decided. The trade-off:
**Option A: `fingerprint.rs` stays in `alknet-core`.**
- `alknet-core` keeps a narrow `rustls` dep (uses
`rustls::pki_types::CertificateDer` and `rustls::sign::public_key_to_spki`
in tests; the production code uses only `sha2` and manual DER parsing,
but the test helper `build_ed25519_spki_der` uses `rustls::sign`).
- `alknet-call`'s client-side `FingerprintPinVerifier` depends on
`alknet-core` (existing edge, no new dep).
- `alknet-tls` depends on `alknet-core` and re-exports the fingerprint
functions for convenience.
- Core is not `rustls`-free, but the dep is narrow (pki-types + sign
types, not the full TLS stack).
- This is the lower-friction option — no dep edges change.
**Option B: `fingerprint.rs` moves to `alknet-tls`.**
- `alknet-core` becomes `rustls`-free (loses the `rustls`,
`rustls-pki-types` deps for fingerprint).
- `alknet-call`'s `FingerprintPinVerifier` gains a dep on `alknet-tls`
— a new dep edge from a handler crate to a transport-infra crate.
This may or may not violate ADR-003's "no handler-depends-on-handler"
rule (alknet-tls is not a handler, but it is transport infra that
handler crates didn't previously depend on).
- The dep edge is the main concern: `alknet-call` depending on
`alknet-tls` means every call-protocol consumer transitively pulls
`rustls` + `tokio` + (optionally) `quinn`/`tokio-rustls`/`rustls-acme`.
That's heavy for a client-only deployment that just needs
fingerprint matching, not TLS setup.
**Likely resolution**: Option A (stay in core). The `rustls` dep in
core is narrow (type usage, not the TLS stack), and moving
`fingerprint.rs` to `alknet-tls` creates a heavy dep edge from
`alknet-call` to `alknet-tls` that doesn't serve the client-only case.
The fingerprint functions are used by both the server path (endpoint,
which does depend on `alknet-tls`) and the client path (call-client,
which does not want to depend on `alknet-tls`). Keeping them in core
serves both paths without forcing the client path to pull TLS infra.
- **What does NOT block on this**: the `alknet-tls` extraction itself
(ADR-082). The crate can be created with `fingerprint.rs` staying in
core, and the question can be resolved later without blocking the
extraction.
- **Cross-references**: ADR-082 (alknet-tls extraction), ADR-003 (crate
decomposition — handler dep rules), ADR-030 §6 (fingerprint
normalization), `crates/alknet-core/src/fingerprint.rs` (the code).