C1: Correct dep-change claim — only rustls-pemfile, rcgen, rustls-acme leave core; quinn, iroh, ed25519-dalek stay (endpoint struct, accept loops, and Ed25519SecretKey remain in core). W1: Trim duplicated 'Why' section in README to summary + ADR-082 link; keep the three-use-cases table as reference. W2: Clarify TlsServerConfig is not Clone (holds JoinHandle); share via Arc, accessors clone the inner rustls::ServerConfig. Fix in README and ADR-082. W6: Resolve futures dep inconsistency — acme-gated in both README and ADR. S1: Behavior-preservation invariants now reference ADR-027 as their origin. S2: Document for_quinn fallibility (QuicServerConfig::try_from can fail) vs for_tcp_tls infallibility (TlsAcceptor::new cannot fail).
17 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 2026-07-13 |
alknet-tls
Shared TLS configuration and certificate management. Builds a
rustls::ServerConfig (or an ACME state machine + cert resolver) once,
and shares it across 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:
pub struct TlsServerConfig {
config: rustls::ServerConfig, // Clone-safe — Arc internally
acme_handle: Option<JoinHandle<()>>, // one ACME task (see lifecycle below)
}
impl TlsServerConfig {
pub async fn new(identity: &TlsIdentity, alpns: &[Vec<u8>]) -> Result<Self, TlsError>;
/// Produce a quinn server config. Clones the inner rustls config
/// (cheap — Arc-shared cert resolver) and wraps it for quinn.
/// Feature-gated on `quinn`.
#[cfg(feature = "quinn")]
pub fn for_quinn(&self) -> Result<quinn::ServerConfig, TlsError>;
/// Produce a tokio-rustls acceptor for TCP+TLS. Clones the inner
/// rustls config. Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor;
/// Borrow the underlying rustls config, for any other consumer.
pub fn rustls_config(&self) -> &rustls::ServerConfig;
}
TlsServerConfig is not Clone — it holds a JoinHandle for the
ACME task, which is not cloneable. Share it via Arc<TlsServerConfig>.
Each accessor (for_quinn, for_tcp_tls) clones the inner
rustls::ServerConfig, which is cheap (it holds Arcs to the cert
resolver and verifier, not the raw key material). The assembly layer
builds one TlsServerConfig, wraps it in Arc, and hands Arc::clone()
to each transport consumer. One cert, one ACME state machine, N
transports.
Why
alknet-core builds the rustls::ServerConfig once, then consumes it
into a quinn::ServerConfig — making the cert unreusable for a TCP+TLS
listener. For ACME the problem is worse: the AcmeState task is spawned
inside the quinn endpoint, so a TCP+TLS listener would need a second ACME
state machine for the same domain (duplicate orders, divergent cert
caches, Let's Encrypt rate-limit risk). The full rationale, including
the cert-reuse problem, the ACME worst case, and the three reasons a
separate crate is the right shape (dependency isolation, ACME weight,
quinn/iroh having their own TLS), is in
ADR-082.
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.
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.
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:
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. They
originate from ADR-027,
which established the TlsIdentity model, the Acme variant, and the
acme-tls/1 ALPN challenge handling. 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::MAXon 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'stls-aws-lc-rsfeature. Do not switch toringor the process-default provider without an ADR — different FIPS status, different platform support.AcceptAnyCertVerifier'ssupported_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/1ALPN append for the ACME path only (ADR-027 §7). The TLS-ALPN-01 challenge requires the server to advertiseacme-tls/1in its ALPN list. Appended inTlsServerConfig::new's ACME branch, not by the caller.
Transport-specific accessors:
impl TlsServerConfig {
/// Produce a `quinn::ServerConfig` for a QUIC listener. Clones the
/// rustls config (cheap — Arc-shared cert resolver), wraps it in
/// `QuicServerConfig`. Returns `Result` because
/// `QuicServerConfig::try_from(rustls::ServerConfig)` can fail if
/// the rustls config contains quinn-incompatible settings.
/// 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. Infallible —
/// `TlsAcceptor::new(rustls::ServerConfig)` cannot fail.
/// 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
[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 — acme-gated)
├── tracing (logging)
├── quinn (optional — for_quinn())
├── tokio-rustls (optional — for_tcp_tls())
└── rustls-acme (optional — ACME state machine)
alknet-core loses rustls-pemfile, rcgen, and rustls-acme from
its dependencies — the cert-loading, self-signed generation, and ACME
machinery move to alknet-tls. Core keeps quinn and iroh (the
endpoint struct and accept loops remain in core), ed25519-dalek
(Ed25519SecretKey stays in config.rs), and rustls /
rustls-pki-types (fingerprint.rs uses rustls::pki_types in
production and rustls::sign in the test helper build_ed25519_spki_der
— see OQ-59).
What AlknetEndpoint does after the refactor
AlknetEndpoint::new() currently builds TlsSetup internally. After
the refactor, it takes an Arc<TlsServerConfig>:
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/.
| ADR | Decision | Summary |
|---|---|---|
| 082 | 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 for full details.
- OQ-59 (open): Should
fingerprint.rsstay inalknet-coreor move toalknet-tls? It usesrustls::pki_typesandrustls::signtypes, which creates arustlsdep in core. If it moves toalknet-tls, the client-sideFingerprintPinVerifier(inalknet-call) would depend onalknet-tls— a new dep edge. If it stays, core keeps a narrowrustlsdep. Decision-ready — the answer depends on whether we want core to berustls-free.
References
docs/architecture/decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md—TlsIdentity(RawKey / X509 / Acme), RFC 7250, browser limitationdocs/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.mdAmendment 1 — TCP+TLS dispatch viafrom_stream(accept loop outside the endpoint struct)docs/architecture/crates/core/endpoint.md— current endpoint design (TLS section will be amended to point toalknet-tls)docs/architecture/crates/core/config.md—TlsIdentity,StaticConfigcrates/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)