diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8b18d94..312a062 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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 diff --git a/docs/architecture/crates/tls/README.md b/docs/architecture/crates/tls/README.md new file mode 100644 index 0000000..63d34bf --- /dev/null +++ b/docs/architecture/crates/tls/README.md @@ -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>, // one ACME task, shared +} + +impl TlsServerConfig { + pub async fn new(identity: &TlsIdentity, alpns: &[Vec]) -> Result; + + /// Clone for quinn. Feature-gated on `quinn`. + #[cfg(feature = "quinn")] + pub fn for_quinn(&self) -> Result; + + /// 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>, +} +``` + +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], + ) -> Result; +} +``` + +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; + + /// 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:` 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`: + +```rust +impl AlknetEndpoint { + pub async fn new( + static_config: &StaticConfig, + tls_config: Arc, // ← new param + handlers: HandlerRegistry, + dynamic: Arc>, + identity_provider: Arc, + ) -> Result; +} +``` + +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:` 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) \ No newline at end of file diff --git a/docs/architecture/decisions/082-alknet-tls-extraction.md b/docs/architecture/decisions/082-alknet-tls-extraction.md new file mode 100644 index 0000000..cea5dd2 --- /dev/null +++ b/docs/architecture/decisions/082-alknet-tls-extraction.md @@ -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` 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>, +} + +impl TlsServerConfig { + pub async fn new(identity: &TlsIdentity, alpns: &[Vec]) + -> Result; + + #[cfg(feature = "quinn")] + pub fn for_quinn(&self) -> Result; + + #[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` 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` + +```rust +impl AlknetEndpoint { + pub async fn new( + static_config: &StaticConfig, + tls_config: Arc, // ← new param + handlers: HandlerRegistry, + dynamic: Arc>, + identity_provider: Arc, + ) -> Result; +} +``` + +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` (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`), + 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:` 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 \ No newline at end of file diff --git a/docs/architecture/open-questions.md b/docs/architecture/open-questions.md index a92124c..de014ce 100644 --- a/docs/architecture/open-questions.md +++ b/docs/architecture/open-questions.md @@ -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 diff --git a/docs/architecture/questions/059-fingerprint-module-location.md b/docs/architecture/questions/059-fingerprint-module-location.md new file mode 100644 index 0000000..1f0007a --- /dev/null +++ b/docs/architecture/questions/059-fingerprint-module-location.md @@ -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). \ No newline at end of file