Files
alktls/docs/research/phase-0.md
T
glm-5.3-flash d74a27f764 phase 1: architecture spec — overview, server/client, ADR-001..006
- ADR-001: inherit the alknet TLS design as the baseline; deviations
  recorded as alktls ADRs
- ADR-002: TlsError ships the ADR-088 six-variant shape from day one
  (typed #[from] sources; NoqWrap; no string catch-all)
- ADR-003: the QUIC feature is noq (iroh's extracted fork), pre-
  consumer rename; default = [] per the lean-crate convention
  (corrects the extracted code's default = ["quinn"])
- ADR-004: complete accessors — for_tcp_tls() adopted, rustls_config()
  adopted; server accessors borrow (&self), client accessors consume
- ADR-005: identity + credentials + fingerprint types move into
  alktls; auth layer stays out
- ADR-006: eight-module layout; seed tests + integration invariant
  pins (exact nine-scheme list, client enable_early_data)
- specs: overview (transport picture, terminology), server.md (ACME
  lifecycle, invariants), client.md (verifier selection matrix, root-
  store fallback); open-questions.md promotes OQ-TLS-01..08 (all
  resolved at entry)
- Cargo.toml: quinn feature -> noq (per ADR-003); AGENTS.md aligned

Architecture review pass done: 0 critical, 2 major (ADR-002 AcmeConfig
doc comment contradiction; ADR-003 unrecorded default deviation) and
8 minors all addressed; cross-references verified against alknet ADRs,
rustls/noq/iroh sources.

Verified: cargo test, test --all-features, clippy -D warnings,
fmt --check, doc --no-deps
2026-09-10 05:37:55 +00:00

623 lines
34 KiB
Markdown

---
status: draft
last_updated: 2026-09-09
---
# alktls — Phase 0 Research Findings
This document captures Phase 0 (Exploration) findings and open design
questions for the `alktls` crate. The objective of Phase 0 per
`docs/sdd_process.md` is: *"Capture vision and guiding principles; research
options; validate approaches; converge on a recommended approach."* It is the
input to Phase 1 (Architecture), where the Architect will produce
`docs/architecture/` specs, ADRs, and open questions.
Drafted 2026-09-09. The crate is the TLS layer of the alk* stack: shared TLS
setup types — server and client `rustls` configs, cert resolvers, verifiers,
and ACME state-machine wiring — transport-agnostic and shareable across
transports (noq QUIC below, `tokio-rustls` TCP+TLS, and anything else
that consumes a `rustls` config).
The 2026-09-09 same-day revision adds the noq investigation
(§Prior art: noq and the quinn→noq shift): the alknet rewrite will move
QUIC from quinn to noq (iroh's extracted internal quinn fork, published
as `noq 1.2.0`, with iroh 1.1.0 built on it), which adds a `for_noq()`
accessor question (OQ-TLS-08) and an MSRV-floor decision. The
transport picture for the rewrite is three paths — TCP+TLS, QUIC+TLS,
and iroh (relay-assisted p2p QUIC) — sharing one rustls 0.23 +
aws-lc-rs stack.
Unlike alktunnels (which was entirely new protocol code), this crate is an
**extraction** of working, battle-tested TLS code from alknet. The prior art
is unusually strong: `crates/alknet-tls` exists and runs in production today,
and the alknet architecture docs (ADR-082/083/084/086/087/088/089 plus the
`crates/tls` README spec) already record most of the design rationale. Phase
0's job is therefore different in character from alktunnels': mostly
**inventory and gap analysis** — what already works, what the spec pinned but
the code never implemented, what must change when the config types move from
`alknet-core` into this crate, and what the alknet rewrite needs this crate
to own. Expect this document to be shorter on open questions than
alktunnels' Phase 0 was.
## What is already settled
The design is ADR-pinned in alknet; this crate inherits it. It does not
re-litigate these decisions — it implements them. The summaries below are
pointers; read the referenced ADRs for the full rationale.
- **The extraction shape (alknet ADR-082).** TLS setup is a standalone
crate because a `rustls::ServerConfig` built for one transport gets
*consumed* into that transport's wrapper (e.g. `quinn::ServerConfig`),
making the cert unreusable for a TCP+TLS listener. The fix: build the
config once (`TlsServerConfig`), share it via `Arc`, clone the cheap
inner rustls config per transport. One cert, one ACME state machine, N
transports.
- **The cert-reuse problem and the ACME worst case (ADR-082 §The
cert-reuse problem).** Two ACME state machines for the same domain =
duplicate Let's Encrypt orders (rate-limit risk), divergent cert
caches, duplicate resolvers. One ACME state machine shared across
transports is the only correct design; `TlsServerConfig` is not
`Clone` (it holds the ACME task's `JoinHandle`) precisely so callers
must share via `Arc`.
- **The endpoint takes no TLS config (alknet ADR-083).** A hub serving
native clients (raw key) and browsers (X.509/ACME) holds *two*
`TlsServerConfig`s — there is no single "the TLS config" for an
endpoint to take. The assembly layer builds the configs and the
transports; the endpoint just dispatches. This crate is the cert
provider, not the accept loop.
- **aws-lc-rs as the crypto provider (alknet ADR-084).**
`rustls::crypto::aws_lc_rs::default_provider()` on all server and
client config paths. Matches iroh's `tls-aws-lc-rs` feature; FIPS-
capable. Do not switch to `ring` or the process-default provider
without a new ADR.
- **The identity model (alknet ADR-027).** `TlsIdentity` is a four-
variant enum: `X509 { cert, key }` (paths, loaded from disk),
`RawKey(Ed25519SecretKey)` (RFC 7250 raw public key), `SelfSigned`
(dev), and `Acme { domains, cache_dir, directory, contact }`
(server-only). `Acme` on the client path is a config error.
- **`TlsClientConfig` is a first-class deliverable (alknet ADR-087).**
Not deferred behind the dial-seam extraction: the TLS config is a
*prerequisite* for the dial, not a consequence of it. Centralizes
ADR-034 verifier selection and ADR-084 provider wiring for all
outbound dials.
- **Verifier selection (alknet ADR-034 §3).** Exactly three outcomes,
driven by `ConnectionCredentials.remote_identity`: `Some(fingerprint)`
→ known peer → `FingerprintPinVerifier` (the fingerprint IS the trust
anchor; handshake signatures still verified so a stolen fingerprint
can't be replayed with a forged signature); `None` + X.509 → CA
verification (`WebPkiServerVerifier`); `None` + raw key → fail closed
at handshake (raw-key remotes are always known peers — there is no CA
to fall back to). `None` is the public-X.509-endpoint state, **not**
"skip verification."
- **The `TlsError` shape (alknet ADR-088).** Single `#[non_exhaustive]`
enum, one variant per failure category: `CertLoad`, `SelfSigned`,
`Rustls`, `VerifierBuild`, `QuinnWrap` (quinn-gated), `AcmeConfig`.
Scope boundary (§6): config-construction errors only — handshake-time
outcomes flow through the transport's connector, ACME runtime errors
are stream events logged in the spawned task.
- **Root-store fallback (alknet ADR-088 §5).** The unknown-X.509-remote
CA path loads the platform's native root certs; if that store is empty
(containerized deployment, no system CA bundle), `webpki-roots` is
merged in so the store is never empty. Native-certs *load* errors are
logged, not returned.
- **Split ALPN lists per endpoint type (alknet ADR-086 §3).** The native
config advertises native ALPNs (`alknet/channels`, `alknet/call`); the
web config advertises entry-point ALPNs (`h2`, `http/1.1`) +
`alknet/channels`; `acme-tls/1` is appended automatically for the ACME
path. The assembly layer filters per config — this crate just takes
the list.
- **The dial seam is elsewhere (alknet ADR-089).** `AlknetClient`
(transport-polymorphic dial) consumes `TlsClientConfig` via
`for_quinn()` / `into_rustls_config()`; this crate does not dial. The
iroh transport is the key-not-config exception on both sides — iroh
has its own TLS and takes an `iroh::SecretKey`; there is no
`for_iroh()` here. **Updated 2026-09-09:** the noq investigation
(§Prior art: noq and the quinn→noq shift) adds a same-shaped
`for_noq()` accessor next to `for_quinn()` and confirms the iroh
exception still holds on iroh 1.1/noq 1.2.
- **Fingerprint normalization (alknet ADR-030 §6).** `ed25519:<hex>` for
RFC 7250 raw keys, `SHA256:<hex>` for cert DER — identical across
quinn, iroh, and TCP+TLS paths, so a `PeerEntry.fingerprints` entry
matches regardless of transport.
- **`ConnectionCredentials` (alknet ADR-091).** The dial's credential
bundle: `local_identity: Option<TlsIdentity>` (client-auth
presentation) + `remote_identity: Option<RemoteIdentity>` (verifier
selection). Both `Option`s are load-bearing, not cosmetic — see the
verifier-selection bullet above.
## Prior art: the extracted code (`crates/alknet-tls`)
The working implementation lives at
`/workspace/@alkdev/alknet/crates/alknet-tls/` (~1.2k lines across four
modules + tests). Module inventory, current state, and known deltas
against the ADR-088 spec:
| Module | Contents | State |
|--------|----------|-------|
| `lib.rs` | `TlsError` — simplified 3-variant enum (`Config(String)`, `Io`, `Cert(String)`) | **Diverges from ADR-088** — see the gaps section |
| `server.rs` | `TlsServerConfig` (`new`, `new_acme`, `for_quinn`), `build_rustls_server_config`, `RawKeyCertResolver`, `AcceptAnyCertVerifier`, `SelfSignedCert`/`generate_self_signed_cert` | Working; all behavior-preservation invariants present (verified below) |
| `client.rs` | `TlsClientConfig` (`new`, `for_quinn`, `into_rustls_config`), `select_server_verifier`, `build_client_auth`, `RawKeyClientCertResolver`, `NoClientCertResolver`, `FingerprintPinVerifier`, `load_platform_root_cert_store` | Working; verifier selection and root-store fallback match the spec |
| `pem.rs` | `load_cert_chain`, `load_private_key` | Working; shared by server + client |
| `signing.rs` | `Ed25519SigningKey` (rustls `SigningKey` + `Signer` over `ed25519_dalek`) | Working; one copy shared by both sides |
Consumers in alknet today: `alknet-client` (the QUIC and TCP+TLS dials
build a `TlsClientConfig` per dial), `alknet-endpoint` (TCP+TLS accept
loop takes a `TlsAcceptor`), and the iroh dial (error-type reuse only).
Server-side, the assembly wiring consumes `TlsServerConfig` and its
`for_quinn()`; the spec's `for_tcp_tls()` accessor is **not implemented**
in the extracted code — the TCP+TLS accept path currently receives a
`TlsAcceptor` built elsewhere (the caller wraps
`tokio_rustls::TlsAcceptor::from(Arc<rustls::ServerConfig>)` itself).
That accessor belongs in this crate (spec §Architecture, ADR-082's API
table) — see the gaps section.
### Behavior-preservation invariants — verified present in the code
All five load-bearing invariants (ADR-082 §Behavior-preservation
invariants, ADR-088 §5) are confirmed in the extracted source:
- `config.max_early_data_size = u32::MAX` on every server config path —
X509 (`server.rs:168`), RawKey (`server.rs:179`), SelfSigned
(`server.rs:191`), and the ACME branch (`server.rs:83`). The client
sets `config.enable_early_data = true` (`client.rs:36`).
- `rustls::crypto::aws_lc_rs::default_provider()` on all paths — server
(`server.rs:77`, `server.rs:155`) and client (`client.rs:26`).
- `AcceptAnyCertVerifier::supported_verify_schemes()` returns ED25519 +
ECDSA P-256/P-384 + RSA PSS (SHA256/384/512) + RSA PKCS1
(SHA256/384/512) — nine schemes verbatim (`server.rs:284`).
- `acme-tls/1` ALPN appended inside `new_acme` (`server.rs:86`) — the
crate appends it, not the caller; only on the ACME path.
- Non-empty root store — `load_platform_root_cert_store()` merges
`webpki-roots` when the platform store is empty (`client.rs:128-149`);
native-certs load errors are logged, not returned.
Phase 1's port must carry these over unchanged, and the port's test
suite should assert them directly (the extracted code's tests already do:
`max_early_data_size` assertions, the nine-scheme list, the empty-store
fallback).
### Implementation notes worth carrying forward
Details discovered in the code that the spec glosses over but any
implementation will trip on:
- **The ACME path is `rustls-acme`'s event stream**, not a bespoke state
machine: `AcmeConfig::new(domains).cache(DirCache).directory(url).contact(...)`
`state.resolver()` wired into the server config →
`tokio::spawn`ed loop over `state.next()` matching
`EventOk`/`EventError` variants to `tracing` log lines. `new` spawns
and returns immediately (does not await the first cert) — handshakes
fail transiently until the first order completes. Spec-consistent.
- **`new_acme` swallows the resolver's cert absence by design.** The
spawned task logs `Order` errors at `warn!` ("will retry") — ACME
retry semantics live inside `rustls-acme`; the task exits only when
the stream ends.
- **`for_quinn()` consumes `self`, not `&self`** in the extracted code
(both server and client). The spec sketches `&self` for the server
accessor. Minor API question for Phase 1 (see OQ-TLS-04) — consuming
`self` is safe for the one-config-per-endpoint-type assembly pattern
but prevents building two transports from one `TlsServerConfig`
without going through `rustls_config()` manually.
- **`FingerprintPinVerifier` verifies handshake signatures.** It is not
a "trust any cert with the right fingerprint" bypass: TLS 1.2/1.3
signature verification still runs (`verify_tls12_signature` /
`verify_tls13_signature`), routing Ed25519 SPKI certs through
`verify_tls13_signature_with_raw_key` and everything else through the
standard aws-lc-rs-backed verification. A pinned fingerprint without
the private key fails the handshake.
- **Client-auth resolver selection is identity-driven.**
`RawKeyClientCertResolver` auto-detects the raw-key case from the
cert DER (an Ed25519 SPKI ⇒ `only_raw_public_keys() == true`), the
same resolver type serves X.509 chains (raw-keys flag false),
`SelfSigned`/`None` present nothing (`NoClientCertResolver`), and
`Acme` is rejected at config time.
- **The 3-variant `TlsError` folds typed sources into strings.** Every
`map_err(|e| TlsError::Config(e.to_string()))` in the extracted code
is a place where ADR-088's typed variants (`Rustls`, `SelfSigned`,
`VerifierBuild`, `QuinnWrap`) would preserve the `#[source]` chain.
This is the single largest code delta against the spec.
- **Tests are in-module `#[cfg(test)]` blocks** covering resolvers,
verifier behavior, PEM error paths, signing, and the invariants —
worth porting as the seed of this crate's `tests/`, plus integration
tests here (a full-crate crate has no workspace to lean on for
cross-crate coverage).
## Gaps: spec-pinned but never implemented
These are recorded decisions without code, or code diverging from the
recorded decision. Phase 1 must close or explicitly re-decide each.
1. **`TlsError` is the 3-variant simplified enum, not the ADR-088
shape.** The six-variant `#[non_exhaustive]` shape (`CertLoad`,
`SelfSigned`, `Rustls`, `VerifierBuild`, `QuinnWrap`, `AcmeConfig`)
with `#[from]` sources was specced in alknet ADR-088 and never
implemented. The ADR-088 README notes the rewrite is a two-way-door
change (no external match arms yet). This crate should ship the
ADR-088 shape from day one — it is the single highest-value gap
closure. Caveat: `CertLoad`'s `#[from] io::Error` relies on
`rustls_pemfile` funnelling its non-`std::error::Error` error type
into `io::Error` (ADR-088 §Gotchas #2) — verify that holds at the
pinned rustls/pemfile versions.
2. **`for_tcp_tls()` does not exist.** Both ADR-082's API table and the
`crates/tls` README spec `for_tcp_tls() -> tokio_rustls::TlsAcceptor`
(feature-gated on `tcp`); the extracted code never added it — the
assembly layer wraps the acceptor itself. Decide in Phase 1 whether
alktls owns the accessor (spec-conforming) or leaves acceptor
wrapping to callers (the current de facto shape). Leaning: own it —
it is infallible, tiny, and the spec is unambiguous.
3. **The config types still live in `alknet-core`.** `TlsIdentity`,
`Ed25519SecretKey` (`config.rs`), `ConnectionCredentials`/
`RemoteIdentity` (`credentials.rs`), and `fingerprint.rs` are all in
core. This crate is expected to own them after the rewrite
(AGENTS.md convention 10) — but they are *config* types that
`StaticConfig` and the vault integration also consume, so the
ownership boundary needs a deliberate decision, not a mechanical
move (OQ-TLS-01). The fingerprint helpers (SHA-256 + manual DER
parsing, no rustls dep in production) are a candidate to move
wholesale; `PeerEntry`/`AuthPolicy` are not TLS concerns and stay
wherever identity resolution lands.
4. **`SelfSigned` is server-only in practice.** The extracted client
path maps `Some(TlsIdentity::SelfSigned)` to `NoClientCertResolver`
(present nothing) — reasonable, but the identity enum doesn't encode
the constraint the way `Acme` is encoded (client-path config error).
Minor semantic wobble to resolve when the types move (OQ-TLS-02).
5. **`tokio` features.** The extracted crate uses `features = ["full"]`
(workspace style); this crate pins the lean subset (`rt`, `sync`,
`macros`) per AGENTS.md convention 3. Verify `rustls-acme`'s
transitive tokio needs don't force a wider set (it brings its own
features regardless — the constraint is only on *this* crate's
direct dep declaration).
## What the alknet rewrite needs from this crate
Context for scoping: alktls is one of the last extractions before the
alknet rewrite begins. The rewrite's consumers (new endpoint, new
client, assembly layer) will consume this crate instead of
`alknet-tls`. Derived requirements:
- **The public API surface should be the spec's surface**: `new`,
`for_quinn`, `for_tcp_tls` (gap #2), `rustls_config` on the server;
`new`, `for_quinn`, `into_rustls_config` on the client; `TlsError` in
the ADR-088 shape (gap #1). The rewrite is the consumer cutoff — get
the surface right *before* it exists.
- **No alknet imports.** The crate must compile without `alknet-core`;
everything it needs (identity, credentials, fingerprint) either moves
in or is re-decided (OQ-TLS-01). This is the structural difference
from `alknet-tls`, which imports `alknet-core` throughout.
- **Noalknet-net assumptions.** The extracted code is transport-agnostic
already (the whole point of ADR-082), so no de-welding work is
expected — but the rewrite should confirm nothing in the ACME path or
PEM loading reaches for alknet types.
- **ALPN naming is the caller's problem.** `alknet/channels`,
`alknet/call`, `h2`, `http/1.1` are strings passed in by the assembly
layer (ADR-086 §3 split lists); this crate owns only `acme-tls/1`
(append-for-ACME). The rewrite's ALPN renames (`alknet/``alk/` or
otherwise) do not touch this crate.
- **Iroh stays key-not-config.** The rewrite's iroh transport reads the
Ed25519 secret key directly; `for_iroh()` must not appear here
(ADR-082 §Iroh is different).
## Version and dependency posture
The extracted crate pins: `rustls 0.23` (aws_lc_rs feature), `quinn
0.11`, `tokio-rustls 0.26`, `rustls-acme 0.12` (aws-lc-rs feature),
`rustls-native-certs 0.8`, `webpki-roots 0.26`, `rcgen 0.13`,
`ed25519-dalek 2`, `thiserror 2`, `tokio 1`. As of 2026-09 the rustls
0.23 line is still current (0.23.44 stable; iroh and noq pin
`rustls 0.23.33` — same 0.23 major, so one unified 0.23 tree across
quinn/noq/iroh paths is achievable). Phase 1 should re-verify at
packaging time and record any bump as a line in the dependency ADR.
Dependency shape per the spec: `rustls` always present (the core
library); `quinn`/`tcp`/`acme` feature-gated;
`rustls-native-certs` + `webpki-roots` always present (the CA path is
needed by any client dialing public X.509 endpoints regardless of
transport); `futures` acme-gated (the event loop's `StreamExt`).
**MSRV verification (2026-09-09).** The sibling crates declared
`rust-version = "1.85"`, but that claim was never compile-checked
against the TLS dependency tree: with default resolution, `rcgen →
yasna/time` pulls `time 0.3.5x` which requires rustc 1.88, and both
this crate's fresh lock *and alknet's own `alknet-tls`* fail
`rustup run 1.85 cargo check` for exactly that reason. Interim
resolution: pin `time` to `0.3.36` and verify under the real 1.85
toolchain.
**RESOLVED 2026-09-10 — ecosystem floor raised to 1.88.** The finding
was audited across the whole ecosystem (3 published + 1 unpublished
crate, dedicated audit sessions): the 1.85 claim failed in all of
them; every crate has now adopted `rust-version = "1.88"` with the
lock re-floated. alktls follows: `rust-version = "1.88"`, `time` pin
dropped (it existed only to keep the 1.85 claim satisfiable),
`rustup run 1.88 cargo check` verified. The lesson recorded for the
ecosystem: `rust-version` claims are passive metadata — nothing fails
at build time on modern toolchains, so the claim must be
*compile-checked* against the resolved tree, not assumed; a committed
`Cargo.lock` is what makes an MSRV claim reproducible either way.
**MSRV pressure from the noq/iroh direction — subsumed.** noq 1.2
declares rust-version 1.88 and iroh 1.1 declares 1.91 (both edition
2024). The 2026-09-10 ecosystem floor of 1.88 (above) matches noq's
exactly and sits below iroh's, so the noq/iroh MSRV pressure that
motivated the Phase 1 lean is already absorbed — what remains of
OQ-TLS-08 is only the feature-rename half (`quinn``noq`).
## Prior art: noq and the quinn→noq shift (2026-09-09)
The alknet rewrite will move QUIC from `quinn` to `noq` — iroh's
extracted internal quinn fork, now a standalone published project.
Facts verified against `/workspace/noq` and `/workspace/iroh` (both
clean clones of upstream):
- **noq is published and current**: crates.io `noq 1.2.0` (Feb 2026
created, Aug 2026 updated, ~1.6M downloads; `noq-proto`/`noq-udp`
siblings). Workspace layout mirrors quinn's (`noq` async facade +
sans-io `noq-proto`). rust-version **1.88**, edition 2024.
- **iroh 1.1.0 (crates.io, Aug 2026) is built on noq 1.2.0** and
re-exports `noq::*`, `noq_proto::*`, `noq_udp::*` as part of its
public API. The QUIC + iroh transports in the rewrite will share one
QUIC implementation by construction.
- **The TLS seam is API-compatible with quinn 0.11** — this is the
load-bearing fact for this crate:
`noq_proto::crypto::rustls::QuicServerConfig::try_from(
rustls::ServerConfig)` / `QuicClientConfig::try_from(
rustls::ClientConfig)`, failing with
`noq_proto::crypto::rustls::NoInitialCipherSuite` — the same
constructor shape and the same failure type ADR-088's `QuinnWrap`
variant wraps. Porting `for_quinn()``for_noq()` is mechanical
(type renames in one module; no config-construction change).
- **One rustls, one provider, both QUIC paths.** iroh 1.1 depends on
`rustls 0.23.33` (default-features off) and noq 1.2 on
`rustls ^0.23.33` — the same rustls 0.23 line this crate pins. The
`tls-aws-lc-rs` / `tls-ring` feature split maps onto noq's
`aws-lc-rs` / `ring` features; our aws-lc-rs-only posture (ADR-084)
composes cleanly (`noq` feature `rustls-aws-lc-rs`).
- **The iroh key surface is unchanged for us.**
`iroh_base::SecretKey` is still 32 raw Ed25519 bytes
(`from_bytes`/`to_bytes`), the key-not-config exception still holds
(no `for_iroh()`, no rustls config handed to iroh), and the
fingerprint normalization story (`ed25519:<hex>`) is unaffected.
Implications for this crate:
- **The `quinn` feature becomes (or is joined by) a `noq` feature.**
The transport-gate story stays the same — feature-gated wrapping —
but the accessor name and dep change. Phase 1 decides: rename
`for_quinn``for_noq` outright (clean, pre-consumer window) or
carry both features during a transition (alknet's rewrite is the
only consumer; there is nothing to transition with — leaning one
accessor, see OQ-TLS-08).
- **`QuinnWrap`'s error source is the same shape under a new name.**
ADR-088's variant wraps `NoInitialCipherSuite`; under noq it wraps
`noq_proto::crypto::rustls::NoInitialCipherSuite` — same type name,
different crate path. The ADR-088 shape survives the rename.
- **Consolidation win for ADR-084.** With quinn and iroh on one QUIC
stack (noq) and one rustls version (0.23), the "one provider, all
paths" invariant gets *stronger* — there is no longer a risk of
quinn's ring-default and iroh's aws-lc-rs-default diverging inside
one binary.
- **tokio-rustls (the `tcp` feature) is unaffected** — noq does not
touch the TCP+TLS path.
## Prior art: the three-transport picture
The transport story the rewrite assembles: **TCP+TLS** (tokio-rustls,
this crate's `tcp` feature), **QUIC+TLS** (noq, this crate's quic
accessor), and **iroh** (relay-assisted p2p QUIC — iroh's own TLS
inside, fed the Ed25519 key). A hub/endpoint may have public
ips/ports or not; iroh is the "or not" half. For this crate the
consequences are: two `TlsServerConfig`s per endpoint-type split
(ADR-086 §3) unchanged; the quic accessor feeds either a bare noq
endpoint or (via iroh's own builder) nothing at all; and the identity
type must keep `iroh_base::SecretKey`-shaped byte access (OQ-TLS-07).
## Open Questions
Numbered OQ-TLS-01.. so they can be promoted into
`docs/architecture/open-questions.md` in Phase 1. Most are small — this
is the gap list, not a research agenda.
### OQ-TLS-01: Where do the config types live, and what moves?
`TlsIdentity`, `Ed25519SecretKey`, `ConnectionCredentials`,
`RemoteIdentity`, and the fingerprint helpers live in `alknet-core`
today. AGENTS.md convention 10 says this crate owns them after the
rewrite — but `StaticConfig` (core's config struct) holds a
`TlsIdentity`, `PeerEntry` fingerprint resolution is auth-layer (not
TLS), and the vault derives the local identity at startup.
Options:
- **A: move all five into alktls** — `StaticConfig` and the vault
depend on alktls for the types. alknet-core grows a dep on alktls (or
the rewrite's equivalent of core does). Cleanest ownership; couples
core config to the TLS crate.
- **B: move identity + fingerprint, keep credentials in core** —
`ConnectionCredentials` is a dial-layer bundle (ADR-091); it may
belong to the rewrite's dial seam instead.
- **C: keep types in core-equivalent, alktls re-exports** — the
`alknet-tls` shape exactly; minimal change but leaves the ownership
question open forever.
Considerations: the rewrite is the window to decide this — after
consumers exist, moving types is a breaking change. The fingerprint
helpers are pure (sha2 + manual DER, no rustls dep) so they can live
anywhere; `Ed25519SigningKey` (signing.rs) needs whichever type
`Ed25519SecretKey` lands in.
**Status:** open — lean A (full move) for the identity types +
fingerprint, with `ConnectionCredentials` decided alongside the
rewrite's dial-seam design (B). Needs the rewrite's core-shape decision
to finalize; do not let it block the rest of Phase 1.
### OQ-TLS-02: `SelfSigned` on the client path — encode or document?
The extracted code maps `Some(SelfSigned)` client-auth to "present
nothing" (`NoClientCertResolver`). `Acme` is a client-path config
*error* (encoded), but `SelfSigned`'s client-path meaning is implicit.
Options: encode it (`SelfSigned` → config error on client, like Acme),
keep present-nothing (documented), or make the dev path present the
self-signed cert. Present-nothing matches current behavior and needs no
code; encoding the constraint is a type-level nicety.
**Status:** open, small — lean "keep behavior, document it" unless
Phase 1 wants the type-level enforcement.
### OQ-TLS-03: `for_tcp_tls()` — adopt the spec accessor?
Gap #2 above. The spec (ADR-082 API table, `crates/tls` README) pins
`for_tcp_tls() -> tokio_rustls::TlsAcceptor` feature-gated on `tcp`; the
extracted code never implemented it (callers wrap the acceptor
themselves from `rustls_config()`).
**Status:** open, lean adopt (infallible, spec-conforming, one-line).
Decide in Phase 1 before the API freezes.
### OQ-TLS-04: `for_quinn()` — `self` or `&self`?
The extracted code consumes `self` (server and client); the spec
sketches `&self` for the server accessor. `&self` (plus the inner rustls
config being `Clone`) allows one `TlsServerConfig` to feed both a quinn
endpoint and a TCP+TLS acceptor directly — the ADR-082 story's most
literal form. `self` forces callers through `rustls_config()` for the
second transport. Note `TlsClientConfig::for_quinn(self)` is natural
(one dial = one config build), so the two accessors need not agree.
This question carries over unchanged to `for_noq()` (OQ-TLS-08).
**Status:** open, small — lean `&self` on the server accessor to match
the spec sketch, `self` on the client (consumed by `into_rustls_config`
pattern anyway).
### OQ-TLS-05: Test surface for the invariants
The behavior-preservation invariants deserve direct tests (the extracted
code has them in-module). Questions: port the in-module tests as-is,
split into `tests/` integration tests (no workspace consumers to cover
here, so integration tests are the only cross-module surface), or both?
Also: does the invariant test for `supported_verify_schemes` pin the
exact nine-scheme list (regression-proof) or just non-empty + ED25519
present (the current extracted test)?
**Status:** open, small — the alktunnels precedent (coverage
weak-spot test commits late in the cycle) suggests porting in-module
tests early and adding exact-list pins; not a blocker.
### OQ-TLS-06: Does the ACME state machine need a shutdown surface?
`TlsServerConfig` holds the ACME task's `JoinHandle` (that is why it is
not `Clone`), but nothing in the extracted code ever aborts the task —
the handle is stored and dropped (dropping a `JoinHandle` detaches).
For a long-lived hub that is fine (the state machine should run for the
process lifetime); for tests and graceful-shutdown stories, a
`shutdown()` (abort + await) might be wanted. Spec is silent.
**Status:** open, small — decide whether v1 ships detached-only
(document) or a shutdown surface (code). Lean detached-only for v1;
revisit with the rewrite's graceful-shutdown design.
### OQ-TLS-07: iroh relationship in the rewrite
The extracted client's iroh dial only reuses `TlsError` for error
shaping; the server-side iroh path reads `Ed25519SecretKey` directly.
The rewrite may re-shape how iroh consumes the identity (especially if
OQ-TLS-01 moves the types). Verified 2026-09-09 against iroh 1.1:
`iroh_base::SecretKey` is still 32 raw Ed25519 bytes
(`from_bytes`/`to_bytes`), so the requirement is unchanged — keep
`Ed25519SecretKey` (or its successor) constructible from raw bytes and
`Clone`. No code needed here beyond that; the rewrite's iroh dial
design should be checked against whatever OQ-TLS-01 decides.
**Status:** open, deferred-shaped — blocked on the rewrite's dial/iroh
design landing; track so the identity type keeps iroh's needs
(`to_bytes()`-style access) in view.
### OQ-TLS-08: `quinn` feature vs `noq` feature — and the MSRV floor
The rewrite moves QUIC to noq 1.2 (iroh's extracted fork; §Prior art).
The extracted `quinn` feature + `for_quinn()` accessors face a decision:
- **A: rename outright.** Drop `quinn` before any consumer exists —
feature `noq = ["dep:noq"]`, accessors `for_noq()`, `TlsError`
variant wrapping `noq_proto::crypto::rustls::NoInitialCipherSuite`.
Clean; the pre-consumer window (this crate, pre-rewrite) is exactly
when renames are free. No published alk* consumer uses `for_quinn`.
- **B: both features during a transition.** `quinn` and `noq` side by
side. There is no third consumer to transition with — alknet's
rewrite is the only one — so this doubles the accessor surface for
nothing.
Leaning A. MSRV sub-decision RESOLVED 2026-09-10: the ecosystem floor
is 1.88 crate-wide (matching noq's; `time` unpinned — see §Version and
dependency posture), so the only open half of this OQ is the feature
rename itself.
Also record in the Phase 1 ADR: noq 1.2's `rustls-aws-lc-rs` feature is
the wiring for ADR-084's provider posture on the QUIC path, and iroh
1.1's `tls-aws-lc-rs` feature is the matching iroh-side wiring — both
feature-select the *same* rustls 0.23 + aws-lc-rs stack this crate
already pins, so no new provider decision is needed.
**Status:** open (feature-rename half only) — needs a Phase 1 ADR
(feature-gate + accessor naming is one-way-door API surface). Lean A;
final call belongs to the Phase 1 ADR alongside OQ-TLS-04's
accessor-shape decision.
## Survey / prior-art list
All internal; this crate has no external research debt:
- `crates/alknet-tls/` — the extraction source (module inventory §Prior
art). Authoritative for behavior; its tests are the seed test suite.
- `crates/alknet-core/src/config.rs``TlsIdentity`,
`Ed25519SecretKey`, `AcmeDirectory` (OQ-TLS-01 scope).
- `crates/alknet-core/src/credentials.rs``ConnectionCredentials`,
`RemoteIdentity` (ADR-091).
- `crates/alknet-core/src/fingerprint.rs` — fingerprint computation +
manual DER parsing (pure; moves cleanly).
- `crates/alknet-client/src/dial/{quinn,tcp_tls,iroh}.rs` — the client
consumption patterns (`TlsClientConfig` per dial; the iroh
key-not-config exception).
- `crates/alknet-endpoint/src/accept/tcp_tls.rs` — the server-side
TCP+TLS accept loop (this crate provides the acceptor, not the loop).
- `/workspace/noq` — the noq clone (upstream n0-computer/noq, 1.2.0):
`noq-proto/src/crypto/rustls.rs` for the `QuicServerConfig`/
`QuicClientConfig` seam and `NoInitialCipherSuite` (§Prior art: noq).
- `/workspace/iroh` — the iroh clone (upstream, 1.1.0): `Cargo.toml`
noq/rustls pinning and the `tls-aws-lc-rs` feature; `iroh-base/src/
key.rs` for the SecretKey byte surface (§Prior art).
- alknet ADRs: 082 (extraction), 083 (endpoint takes no TLS config),
084 (aws-lc-rs), 027 (identity model, `acme-tls/1`), 030 §6
(fingerprint normalization), 086 §3 (split ALPN lists), 087
(`TlsClientConfig`), 088 (`TlsError` shape, root-store fallback), 089
(dial seam), 091 (`ConnectionCredentials`), 034 (verifier selection).
- `docs/architecture/crates/tls/README.md` — the full crate spec; the
closest thing to this crate's Phase 1 target document.
## Convergence checklist (what Phase 0 must produce)
- [x] Inventory of the extracted code with verified
behavior-preservation invariants (§Prior art) — all five present,
with line references
- [x] Gap list: spec-pinned but unimplemented (§Gaps) — `TlsError`
shape, `for_tcp_tls()`, config-type ownership, `SelfSigned`
client semantics, tokio feature subset
- [x] Rewrite-derived requirements (§What the alknet rewrite needs) —
API surface freeze before consumers, no alknet imports,
caller-owned ALPNs, iroh key-not-config
- [x] Version/dependency posture recorded (§Version and dependency
posture) — rustls 0.23 line current; feature-gate shape per spec;
MSRV verified and resolved 2026-09-10 (ecosystem floor 1.88,
`time` pin dropped, verified under the real 1.88 toolchain)
- [x] noq/iroh investigation (2026-09-09) — noq 1.2 published, iroh 1.1
built on it; TLS seam API-compatible with quinn 0.11 (mechanical
port); one rustls 0.23 tree across all QUIC paths; key-not-config
exception intact; OQ-TLS-08 opened (feature rename + MSRV floor)
- [x] Open questions enumerated (OQ-TLS-01..08) — all small; two are
deferred-shaped (OQ-TLS-01 finalize, OQ-TLS-07); none require a
POC (the code exists and runs; there is nothing to de-risk)
- [x] Open questions promoted to Phase 1
`docs/architecture/open-questions.md` with statuses — DONE
2026-09-10 (OQ-TLS-01..08 promoted; all resolved at Phase 1
entry via ADR-001..006 or documented behavior)
- [ ] POC decision: none planned — extraction of working code does not
need a POC; the OQ-TN-10-style validation step from alktunnels
does not apply here