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
This commit is contained in:
2026-09-10 05:37:55 +00:00
parent e93238cb4a
commit d74a27f764
15 changed files with 1570 additions and 246 deletions
+53
View File
@@ -0,0 +1,53 @@
---
status: draft
last_updated: 2026-09-10
---
# alktls — Architecture
The authoritative architecture spec for the alktls crate (Phase 1 of
the SDD process). All docs are **Draft** pending the architecture
review pass; statuses update here as docs advance.
All docs follow the SDD process conventions: specs reference ADRs and
OQs by number, ADRs explain WHY, `open-questions.md` tracks what is
unresolved.
## Documents
| Doc | Status | Scope |
|-----|--------|-------|
| [overview.md](overview.md) | Draft | Purpose, transport picture, API surface, ADR/OQ index |
| [server.md](server.md) | Draft | `TlsServerConfig`, resolvers, ACME path, server invariants |
| [client.md](client.md) | Draft | `TlsClientConfig`, verifier selection, client auth, root-store fallback |
| [open-questions.md](open-questions.md) | live | The authoritative OQ tracker (all Phase 0 OQs resolved at entry) |
## ADRs
| ADR | Status | Decision |
|-----|--------|----------|
| [001](decisions/001-inherit-alknet-tls-design.md) | Accepted | Inherit the alknet TLS design as the baseline; deviations recorded as alktls ADRs |
| [002](decisions/002-tlserror-shape.md) | Accepted | `TlsError`: the ADR-088 six-variant shape from day one; config-construction scope boundary |
| [003](decisions/003-noq-replaces-quinn.md) | Accepted | The QUIC feature is `noq` (iroh's extracted fork), not `quinn`; iroh stays key-not-config |
| [004](decisions/004-accessor-surface.md) | Accepted | Complete accessors: `for_tcp_tls()` adopted; server borrows, client consumes |
| [005](decisions/005-config-types-move-into-alktls.md) | Accepted | Identity + credentials + fingerprint types move into alktls; auth layer stays out |
| [006](decisions/006-module-layout-and-tests.md) | Accepted | Eight-module layout; seed tests + integration invariant pins |
## Lifecycle
Docs move `Draft``Reviewed` when their open questions are resolved
and the architecture review reports zero critical issues; ADRs are
Accepted at write time and never revert (supersede instead).
`open-questions.md` is the authoritative tracker; the Phase 0 doc's
OQ statuses are the historical record.
## Phase status
- **Phase 0** (complete, 2026-09-10): `docs/research/phase-0.md`
extraction inventory, verified invariants, gaps, noq investigation,
OQ-TLS-01..08.
- **Phase 1** (this directory): all Phase 0 OQs resolved at entry —
six via ADR-001..006, two as documented behavior (OQ-TLS-02,
OQ-TLS-06); review pass pending.
- **Phase 2** (next): decomposition into `tasks/` — the port guided by
ADR-006's module map and seed tests.
+135
View File
@@ -0,0 +1,135 @@
---
status: draft
last_updated: 2026-09-10
---
# alktls — Client side
`TlsClientConfig` and its verifiers: the client-side TLS setup,
extracted from alknet (`crates/alknet-tls/src/client.rs`) and ported
per the ADRs. Decisions referenced here: see
[open-questions.md](open-questions.md) and the index in
[overview.md](overview.md).
## `TlsClientConfig`
Built **per dial** from a `ConnectionCredentials` + ALPN (the
dial-seam pattern, alknet ADR-089). Consumed by its accessors — a
`TlsClientConfig` is not reused across dials (ADR-004).
```rust
pub struct TlsClientConfig {
rustls_config: rustls::ClientConfig,
}
impl TlsClientConfig {
pub fn new(credentials: &ConnectionCredentials, alpn: &[u8])
-> Result<Self, TlsError>;
#[cfg(feature = "noq")]
pub fn for_noq(self) -> Result<noq::ClientConfig, TlsError>;
pub fn into_rustls_config(self) -> rustls::ClientConfig;
}
```
`new` is sync and infallible-free aside from config construction
(cert file loading for an X.509 local identity is the only I/O).
Every config sets `enable_early_data = true` (the client half of the
0-RTT invariant) and carries the aws-lc-rs provider (alknet ADR-084).
## The two credential dimensions
`ConnectionCredentials` (ADR-005, moved from alknet ADR-091) carries
exactly the two inputs the client config consumes:
1. **`local_identity: Option<TlsIdentity>`** — the client-auth cert
presentation:
| Local identity | Presented |
|----------------|-----------|
| `RawKey` | RFC 7250 raw public key (SPKI DER; `only_raw_public_keys()` auto-detected from the DER) |
| `X509` | the cert chain + key, loaded from disk |
| `SelfSigned` / `None` | nothing (`NoClientCertResolver`) — documented, resolved in OQ-TLS-02 |
| `Acme` | **config error** (`TlsError::AcmeConfig`) — server-only identity |
2. **`remote_identity: Option<RemoteIdentity>`** — the verifier
selection matrix (below). Both `Option`s are load-bearing, not
cosmetic: `Some` means "pin this", `None` means "trust the CA or
fail" — never a placeholder default.
## Verifier selection (alknet ADR-034 §3)
Exactly three outcomes, driven by `remote_identity`:
| `remote_identity` | Remote cert | Verifier | Outcome |
|-------------------|-------------|----------|---------|
| `Some(fingerprint)` | any | `FingerprintPinVerifier` | pin match required |
| `None` | X.509 | `WebPkiServerVerifier` (CA) | CA verification against the platform root store |
| `None` | Ed25519 raw key | (the CA verifier fails) | **fail closed at handshake** |
`None` is the public-X.509-endpoint state, **not** "skip
verification". An unknown raw-key remote fails at handshake — it must
never silently downgrade to CA verification. Verifier selection
happens at config construction; the fail-closed *manifests* at
handshake time (the config carries the CA verifier; the raw-key
remote simply cannot satisfy it) — this is the ADR-002 scope
boundary in action.
**Fail-closed is structural**: known peer + fingerprint → pin;
unknown + X.509 → CA; unknown + raw key → fail. No fourth path.
## `FingerprintPinVerifier`
The known-peer path. The fingerprint IS the trust anchor: for
`ed25519:<hex>` remotes the raw Ed25519 key is extracted from the
presented cert (SPKI) and matched; for `SHA256:<hex>` remotes the
full cert DER is hashed and matched. No CA verification, no name
verification — only the pin.
Handshake signatures are still verified (TLS 1.2/1.3, aws-lc-rs
algorithms; Ed25519 SPKI certs route through
`verify_tls13_signature_with_raw_key`): a stolen fingerprint cannot
be replayed with a forged signature — the presenter must prove
possession of the corresponding private key.
## The root-store fallback (alknet ADR-088 §5)
The `None` + X.509 CA path loads the platform's native root certs
(`rustls-native-certs`). If the platform store is empty — a
containerized deployment with no system CA bundle — the built-in
`webpki-roots` are merged in so the store is **never empty**.
Native-certs *load* errors are logged, not returned; the fallback
guarantees non-emptiness regardless. This makes `NoRootAnchors`
unreachable in practice (the `VerifierBuild` variant exists for the
builder API's completeness, not a reachable path — ADR-002).
The root store is built unconditionally for the CA path — not
feature-gated on `tcp` or `noq` — because any client dialing a public
X.509 endpoint needs it regardless of transport.
## What the client side does NOT do
- No dial: the dial seam (`AlknetClient` in alknet; the rewrite's
dial crate) consumes the config via `for_noq()` /
`into_rustls_config()` and owns `TcpStream::connect`,
`Endpoint::connect`, SNI, and SOCKS5 proxying (alknet ADR-089;
SOCKS5 proxy seam per alknet ADR-090).
Handshake failures are the dial's errors (`ClientDialError`),
not `TlsError`.
- No iroh: the iroh dial takes the 32-byte Ed25519 key directly
(key-not-config; ADR-003). iroh's built-in verifier (NodeId check)
is iroh's own application of the same fail-closed rule.
- No per-request auth: a call-protocol `auth_token` is a call-layer
concept; it never reaches `TlsClientConfig`.
## References
- [overview.md](overview.md) — the index; [server.md](server.md) —
the server side
- alknet `crates/tls/README.md` §Client side — the full client-side
spec this doc mirrors
- alknet ADR-034 (verifier selection), ADR-088 §5 (root-store
fallback), ADR-091 (`ConnectionCredentials`)
- ADR-002 (`TlsError`), ADR-004 (accessors), ADR-005 (credential
types), ADR-006 (tests — the verifier-selection matrix)
@@ -0,0 +1,108 @@
---
status: accepted
last_updated: 2026-09-10
---
# ADR-001: Inherit the alknet TLS design; alktls as the rewrite's TLS crate
## Status
Accepted (2026-09-10)
## Context
alktls is the extraction of the TLS handling from alknet
(`crates/alknet-tls` + the config types in `crates/alknet-core`), the
last crate extracted before the alknet rewrite begins. alknet already
recorded the full TLS design as ADRs (082/083/084/086/087/088/089/
091, plus ADR-027's identity model and ADR-034's verifier selection)
and a complete crate spec (`docs/architecture/crates/tls/README.md`).
The extracted code runs in production today and every
behavior-preservation invariant was verified present in Phase 0
(`docs/research/phase-0.md` §Prior art).
Phase 1 must decide how this crate relates to that body of decisions:
re-litigate, inherit wholesale, or inherit with recorded deviations.
## Decision
alktls **inherits** the alknet TLS design. The alknet ADRs and the
`crates/tls` README are adopted as this crate's authoritative prior
decisions; Phase 1 does not re-litigate them. This ADR records that
inheritance as alktls' own baseline, so this repo's ADR numbering
starts from a coherent baseline instead of dangling references into
another repo's architecture.
Concretely, alktls adopts:
- `TlsServerConfig` / `TlsClientConfig` as the central types, built
once, shared across transports via `Arc` (alknet ADR-082). The inner
rustls config is `Clone` (Arc-shared resolvers); `TlsServerConfig`
is not `Clone` (it holds the ACME task's `JoinHandle`).
- The config-construction scope boundary: this crate builds configs;
handshake outcomes flow through the transport's connector; ACME
runtime errors are stream events logged in the spawned task
(alknet ADR-088 §6).
- The behavior-preservation invariants verbatim: `max_early_data_size
= u32::MAX` on all server paths **plus `enable_early_data = true`
on every client config** (the client half of the 0-RTT invariant),
`aws_lc_rs::default_provider()` on
all paths, `AcceptAnyCertVerifier`'s nine-scheme
`supported_verify_schemes()`, crate-side `acme-tls/1` append for the
ACME path only, non-empty root store via `webpki-roots` fallback
(alknet ADR-082, ADR-084, ADR-088 §5).
- The identity model: `TlsIdentity` four variants, `Acme` server-only,
client-auth presentation follows the local identity, verifier
selection fail-closed (alknet ADR-027, ADR-034, ADR-091).
- One ACME state machine per domain, shared across transports; never a
second order for a served domain (alknet ADR-082 §The cert-reuse
problem).
- iroh stays key-not-config: no `for_iroh()`; the identity type
exposes 32-byte Ed25519 access for `iroh_base::SecretKey`
(alknet ADR-082 §Iroh is different).
Where alktls deviates from or refines the inherited design, the
deviation is recorded as an alktls ADR (001.. this series) rather than
silently diverging. The known deviations/corrections at time of
writing: the `TlsError` shape (ADR-002), the QUIC feature (ADR-003,
which also corrects the extracted code's `default = ["quinn"]` back
to the spec's `default = []`), the API surface completion (ADR-004),
and config-type ownership (ADR-005).
## Consequences
**Positive:**
- The crate implements proven code, not a new design; Phase 2 is a
port with a verified behavior checklist, not an invention.
- Design rationale is already written and battle-tested; this repo's
ADR series stays small and points at the alknet reasoning where the
full argument lives.
- The alknet rewrite consumes one crate with one consistent ADR
lineage instead of reverse-engineering decisions from scattered
alknet ADRs.
**Negative:**
- alktls ADRs are derivative — a reader must consult alknet's ADRs for
the full rationale of the inherited posture (mitigated: each
alktls ADR restates the decision it pins, and the Phase 0 doc
carries the verified line references).
- If alknet's rewrite later re-decides an inherited posture, both
series need a superseding note.
## References
- `docs/research/phase-0.md` — the Phase 0 inventory (verified
invariants, gaps, noq investigation)
- alknet ADR-082 (extraction), ADR-083 (endpoint takes no TLS config),
ADR-084 (aws-lc-rs), ADR-027 (identity model), ADR-034 (verifier
selection), ADR-086 §3 (split ALPN lists), ADR-087
(`TlsClientConfig`), ADR-088 (`TlsError`, root-store fallback), 089
(dial seam), 091 (`ConnectionCredentials`)
- alknet `docs/architecture/crates/tls/README.md` — the full crate
spec this crate implements
- ADR-002 — the `TlsError` shape (gap #1)
- ADR-003 — the QUIC feature: noq replaces quinn
- ADR-004 — the API surface (gap #2, accessor shapes)
- ADR-005 — config-type ownership (gap #3)
@@ -0,0 +1,149 @@
---
status: accepted
last_updated: 2026-09-10
---
# ADR-002: `TlsError` — the ADR-088 six-variant shape from day one
## Status
Accepted (2026-09-10)
## Context
The extracted code (`crates/alknet-tls/src/lib.rs`) carries a
simplified 3-variant `TlsError`: `Config(String)`, `Io(io::Error)`,
`Cert(String)`. alknet ADR-088 recorded the target shape — a single
`#[non_exhaustive]` enum, one variant per failure category, typed
`#[from]` sources — but it was never implemented; the extracted code
folds every typed failure into `Config(e.to_string())` strings, losing
the `#[source]` chain and the category distinction.
Phase 0 identified this as gap #1, the highest-value gap closure: this
crate is pre-consumer, so the enum shape is still a two-way door; once
the alknet rewrite consumes it, it is one-way.
Two mechanics from ADR-088's "Gotchas" needed verification before
pinning the shape:
- `CertLoad`'s `#[from] io::Error` relies on `rustls_pemfile`
funnelling its own non-`std::error::Error` error type into
`io::Error`. Confirmed at the pinned versions: `rustls_pemfile`
2.x returns `Result<T, io::Error>` from its iterator and read APIs
(`certs`, `private_key`), so PEM parse failures surface as
`io::Error` — the `CertLoad(#[from] io::Error)` variant is sound.
- `VerifierBuild` wraps `rustls::webpki::VerifierBuilderError`
(confirmed present in rustls 0.23.44: `NoRootAnchors` +
`InvalidCrl(CertRevocationListError)`, `#[non_exhaustive]`).
With the ADR-088 §5 root-store fallback in place,
`NoRootAnchors` is unreachable in practice — the variant exists for
the builder API's completeness, not a reachable failure path.
## Decision
`TlsError` ships as the alknet ADR-088 shape, unmodified:
```rust
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TlsError {
/// Cert or key file read / PEM parse. `rustls_pemfile` funnels its
/// own error type into `io::Error`, so one `io::Error` source covers
/// the whole loading path.
#[error("loading cert/key material: {0}")]
CertLoad(#[from] std::io::Error),
/// Self-signed cert generation (rcgen). Server `SelfSigned` path.
#[error("generating self-signed cert: {0}")]
SelfSigned(#[from] rcgen::Error),
/// rustls server or client config construction
/// (`with_safe_default_protocol_versions`, `with_single_cert`,
/// `CertifiedKey::from_der`, `RootCertStore::add`).
#[error("building rustls config: {0}")]
Rustls(#[from] rustls::Error),
/// `WebPkiServerVerifier::builder(_with_provider)..build()` — the
/// unknown-X.509-remote client path.
#[error("building webpki verifier: {0}")]
VerifierBuild(#[from] rustls::webpki::VerifierBuilderError),
/// QUIC config wrapping — the one path where `for_noq()` fails
/// (`NoInitialCipherSuite`, not a `rustls::Error`). noq-gated.
#[cfg(feature = "noq")]
#[error("wrapping rustls config for noq: {0}")]
NoqWrap(#[from] noq_proto::crypto::rustls::NoInitialCipherSuite),
/// Config-mismatch errors that are not wrapped third-party
/// errors: ACME feature not enabled but `Acme` configured
/// (server), or `Acme` identity used for client auth. A config
/// error, not a wrapped third-party error.
#[error("TLS config error: {0}")]
AcmeConfig(String),
}
```
Notes on the shape, per ADR-088's rationale:
- **Single enum, not a thin wrapper.** ADR-088's three findings stand:
the QUIC-wrap failure is `NoInitialCipherSuite` (not a
`rustls::Error`), `rustls_pemfile`'s error is not a
`std::error::Error`, and `WebPkiServerVerifier::build()` returns its
own `VerifierBuilderError`. One enum with `#[from]` sources models
the actual call sites.
- **The quinn-gated variant becomes `NoqWrap`** (noq-gated), renaming
ADR-088's `QuinnWrap` per ADR-003. Same failure type name
(`NoInitialCipherSuite`), different crate path.
- **`AcmeConfig(String)` keeps string payloads.** The two Acme
mismatches are genuinely configuration mistakes, not wrapped
upstream errors; there is no third-party error type to preserve.
The extracted code's residual `Config(String)` call sites
(e.g. `CertifiedKey::from_der` string fallbacks in the client-auth
builder) map to this variant — see the refinement below.
- **Scope boundary holds (alknet ADR-088 §6).** `TlsError` is the
config-construction error type. Handshake outcomes (a rejected
cert, the unknown-raw-key fail-closed) flow through the transport's
connector; ACME state-machine runtime errors are stream events
logged in the spawned task. No handshake variants, ever.
- **`#[non_exhaustive]` from day one.** The enum is crate-local
today, but the rewrite compiles against it; new variants are
additive for consumers that match with a wildcard arm.
**Refinement over the extracted code:** the extracted code uses
`TlsError::Config(e.to_string())` at `rustls::Error` call sites that
ADR-088 assigns to `Rustls` (e.g. `with_single_cert`,
`CertifiedKey::from_der` — both return `rustls::Error`). This crate
maps those call sites to the typed variants. The only genuinely
stringy sites are the Acme mismatches, which land in `AcmeConfig`
(matching the sketch above — `AcmeConfig` holds exactly the two
config-mismatch cases, nothing else). No `Config(String)` catch-all
survives — a variant-per-category enum with a string catch-all would
reintroduce the fold this ADR removes.
## Consequences
**Positive:**
- `#[source]` chains survive for programmatic inspection (the
rewrite's config plumbing can distinguish a missing file from a bad
PEM from a bad config without string parsing).
- The crate ships the recorded target shape — no later migration.
- The noq rename rides along; `TlsError` never has a `QuinnWrap`
variant to deprecate.
**Negative:**
- Six variants is more surface than three; consumers must match
more arms (mitigated by `#[non_exhaustive]` + wildcard arms).
- `rcgen::Error` in the public API couples the error type to rcgen's
error churn (accepted — rcgen 0.13 is stable and the coupling is
what ADR-088 chose).
## References
- alknet ADR-088 — the recorded target shape and its rationale
- alknet ADR-088 §5 — the root-store fallback that keeps
`NoRootAnchors` unreachable
- `docs/research/phase-0.md` §Gaps #1 — the gap analysis
- ADR-001 — the inheritance baseline
- ADR-003 — the noq feature (the `NoqWrap` rename)
@@ -0,0 +1,119 @@
---
status: accepted
last_updated: 2026-09-10
---
# ADR-003: The QUIC feature is `noq`, not `quinn`
## Status
Accepted (2026-09-10)
## Context
The extracted crate's `quinn` feature (`for_quinn()` accessors,
quinn-gated wrap) predates the noq split: iroh has extracted its
internal quinn fork into the standalone, published `noq` project
(`noq` / `noq-proto` / `noq-udp`; noq 1.2.0 on crates.io), and iroh
1.1.0 is built on noq 1.2.0, re-exporting `noq::*` as public API.
The alknet rewrite will standardize on noq for QUIC — both the bare
QUIC transport and iroh's internal one — eliminating the duplicate
QUIC implementations.
Phase 0 verified the seam is API-compatible with quinn 0.11
(`docs/research/phase-0.md` §Prior art: noq):
- `noq_proto::crypto::rustls::QuicServerConfig::try_from(
rustls::ServerConfig)` / `QuicClientConfig::try_from(
rustls::ClientConfig)` — same constructor shape, same
`NoInitialCipherSuite` failure type, different crate path.
- noq 1.2 pins `rustls ^0.23.33`; iroh 1.1 pins `rustls 0.23.33` —
the same 0.23 line this crate pins. One rustls tree across all
paths.
- noq's `rustls-aws-lc-rs` feature selects the aws-lc-rs provider;
iroh's `tls-aws-lc-rs` does the same on the iroh side. The ADR-084
posture composes unchanged.
- noq's MSRV is 1.88 — the ecosystem floor (adopted 2026-09-10),
so no MSRV tension remains.
The `quinn` feature has no published consumer: alktls is
pre-consumer, and the alknet rewrite (its only planned consumer)
targets noq. Carrying both features would double the accessor surface
with nothing to transition.
## Decision
**The QUIC feature is `noq`.** The `quinn` feature never existed in a
published alktls, so the rename is pre-consumer and free:
```toml
[features]
default = []
noq = ["dep:noq", "dep:noq-proto"]
tcp = ["dep:tokio-rustls"]
acme = ["dep:rustls-acme"]
[dependencies]
noq = { version = "1.2", optional = true, default-features = false, features = ["rustls"] }
noq-proto = { version = "1.2", optional = true, default-features = false }
```
`default = []` — all transport features opt-in, matching the
inherited alknet spec's feature-gate shape and AGENTS.md convention 9
(the default crate compiles lean; a consumer that never runs QUIC
should not pull `noq` + `noq-proto`). Note the extracted code's
`Cargo.toml` carries `default = ["quinn"]` — a silent deviation from
the alknet spec that this port corrects rather than carries forward.
The `noq` dep is enabled with `default-features = false,
features = ["rustls"]` so noq's provider-selection features do not
fight the crate's explicit provider posture; `noq-proto` rides along
for the `NoqWrap` error source.
- `TlsServerConfig::for_noq(&self) -> Result<noq::ServerConfig,
TlsError>` (feature-gated on `noq`) —
`noq::ServerConfig::with_crypto(Arc::new(
noq::crypto::rustls::QuicServerConfig::try_from(inner)?))`.
- `TlsClientConfig::for_noq(self) -> Result<noq::ClientConfig,
TlsError>` (feature-gated on `noq`) — the client-side mirror.
- `TlsError::NoqWrap(#[from]
noq_proto::crypto::rustls::NoInitialCipherSuite)` (ADR-002).
- There is no `for_quinn()`, no `quinn` dependency, no quinn feature.
- iroh remains key-not-config (ADR-001): iroh's endpoint is fed the
Ed25519 secret key; the `noq` feature does not attempt to serve
iroh's TLS, and iroh does not appear in this crate's dependency
tree.
- The `tcp` feature (`tokio-rustls`) and `acme` feature are
unaffected.
Provider wiring on the QUIC path: the rustls configs this crate builds
already carry the aws-lc-rs provider (every path constructs its own
provider via `builder_with_provider`); noq's `rustls` feature
consumes that provider from the config.
## Consequences
**Positive:**
- One QUIC implementation across the bare-QUIC and iroh transports;
no duplicate quinn/noq stacks in the rewrite's binaries.
- `TlsError` never has a dead `QuinnWrap` variant.
- The crate's MSRV floor (1.88) is honest for the whole tree rather
than feature-dependent.
**Negative:**
- If a future consumer needs quinn (none is known), that is a new
feature alongside `noq` — additive, cheap, and only paid if real.
- noq is young (1.2.0, first release Feb 2026); its API may churn
faster than quinn 0.11's frozen line (accepted — n0-computer owns
both noq and iroh, and the rewrite rides their stack; pin `noq =
"1.2"` and bump deliberately).
## References
- `docs/research/phase-0.md` §Prior art: noq — the verified seam facts
- `/workspace/noq`, `/workspace/iroh` — the upstream clones inspected
- ADR-001 — the inheritance baseline (quinn-era accessors inherited
with deviations recorded here)
- ADR-002 — `TlsError` (`NoqWrap`)
- ADR-004 — accessor shapes (`&self` vs `self`)
@@ -0,0 +1,129 @@
---
status: accepted
last_updated: 2026-09-10
---
# ADR-004: Complete the accessors — `for_tcp_tls()`, `rustls_config()`, and borrow-vs-consume
## Status
Accepted (2026-09-10)
## Context
The alknet spec (ADR-082's API table and the `crates/tls` README)
pins four server-side accessors and three client-side accessors. The
extracted code implements a subset and diverges in shape:
| Accessor | Spec | Extracted code |
|----------|------|----------------|
| `TlsServerConfig::new` | `async`, `(&TlsIdentity, &[Vec<u8>])` | same |
| `TlsServerConfig::for_quinn` | `&self` | `self` (consumes) |
| `TlsServerConfig::for_tcp_tls` | `-> TlsAcceptor`, infallible | **missing** — callers wrap `tokio_rustls::TlsAcceptor::from(Arc::new(cfg.rustls_config.clone()))` themselves |
| `TlsServerConfig::rustls_config` | `&self -> &ServerConfig` | **missing** — the field is `pub(crate)` |
| `TlsClientConfig::new` | sync, `(&ConnectionCredentials, &[u8])` | same |
| `TlsClientConfig::for_quinn` | `self` (consumes) | `self` |
| `TlsClientConfig::into_rustls_config` | `self` (consumes) | same |
The missing server accessors force the assembly layer to reach into
crate internals (`pub(crate)` field access is impossible for external
consumers) or re-derive the acceptor wrap. The `self`-consuming
server accessor prevents the literal ADR-082 story — one
`TlsServerConfig` feeding both a QUIC endpoint and a TCP+TLS acceptor —
without contortions.
## Decision
The public API surface is the spec's surface, with borrow-vs-consume
decided per accessor:
```rust
impl TlsServerConfig {
pub async fn new(identity: &TlsIdentity, alpns: &[Vec<u8>])
-> Result<Self, TlsError>;
/// `&self` — the inner rustls config is Clone (Arc-shared
/// resolvers); one TlsServerConfig can feed a noq endpoint AND a
/// TCP+TLS acceptor without contortions.
#[cfg(feature = "noq")]
pub fn for_noq(&self) -> Result<noq::ServerConfig, TlsError>;
/// Infallible — `TlsAcceptor::from(Arc<ServerConfig>)` cannot
/// fail. Feature-gated on `tcp`.
#[cfg(feature = "tcp")]
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor;
/// Borrow the inner config for transport wrappers the crate does
/// not cover.
pub fn rustls_config(&self) -> &rustls::ServerConfig;
}
impl TlsClientConfig {
pub fn new(credentials: &ConnectionCredentials, alpn: &[u8])
-> Result<Self, TlsError>;
/// Consumes — one dial = one config build; a `TlsClientConfig` is
/// not reused across dials.
#[cfg(feature = "noq")]
pub fn for_noq(self) -> Result<noq::ClientConfig, TlsError>;
/// Consumes — the TCP+TLS dial wraps the returned config in a
/// `TlsConnector` itself.
pub fn into_rustls_config(self) -> rustls::ClientConfig;
}
```
Rationale per accessor:
- **Server accessors take `&self`.** The ADR-082 story is "one
identity, N transports": the assembly layer builds one
`TlsServerConfig` per endpoint type and hands it to every transport
that endpoint serves. `&self` plus the Clone inner config makes the
multi-transport sharing direct; the extracted `self`-consuming shape
is a vestige of the quinn-only extraction era (the TCP path was
re-derivable only because the field happened to be `pub(crate)` in
the same workspace).
- **Client accessors consume `self`.** A `TlsClientConfig` is built
per dial (`dial_quic`, `dial_tcp_tls` build fresh configs per
ADR-089's pattern); nothing reuses it. Consuming makes
`into_rustls_config` zero-cost (no clone behind the scenes) and
keeps the API honest about reuse.
- **`for_tcp_tls()` is adopted** (Phase 0 gap #2). It is one line,
infallible, and the spec is unambiguous; leaving it out would keep
the de facto "callers wrap the acceptor" shape and force the rewrite
to duplicate it.
- **`rustls_config()` is adopted** for any transport wrapper beyond
`for_noq` / `for_tcp_tls` (iroh does not need it — key-not-config —
but the escape hatch costs nothing and stays true to the spec).
- **`new` stays `async fn`** for API uniformity with the ACME path
(which spawns the state-machine task); the non-ACME paths have no
await point (sync file I/O) — this is the spec's recorded posture,
and the uniform signature is worth more than the await-free purity.
## Consequences
**Positive:**
- The assembly layer builds configs and transports without reaching
into crate internals or hand-rolling acceptor wraps.
- The API freeze matches the spec the rewrite was specced against —
zero translation for the rewrite's consumers.
- The multi-transport story (one config → noq + TCP+TLS) is directly
expressible.
**Negative:**
- `&self` server accessors require the inner rustls config to stay
`Clone`-able — already true and load-bearing (Arc-shared
resolvers); a future config shape that is not Clone would break the
accessor contract (acceptable: Clone is structural to the
sharing story).
## References
- alknet ADR-082 API table and the `crates/tls` README §Architecture —
the spec surface this ADR adopts
- `docs/research/phase-0.md` §Gaps #2, OQ-TLS-03/OQ-TLS-04 — the gap
and the accessor-shape question
- ADR-002 — `TlsError` (the `for_noq` failure variant)
- ADR-003 — the `noq` feature
@@ -0,0 +1,109 @@
---
status: accepted
last_updated: 2026-09-10
---
# ADR-005: Config types move into alktls; alktls owns the identity, credentials, and fingerprint
## Status
Accepted (2026-09-10)
## Context
The extracted code imports four type groups from `alknet-core`:
| Group | Types | Used by |
|-------|-------|---------|
| Identity | `TlsIdentity`, `Ed25519SecretKey`, `AcmeDirectory` | server + client config construction |
| Credentials | `ConnectionCredentials`, `RemoteIdentity` | `TlsClientConfig::new` |
| Fingerprint | `fingerprint_from_cert_der`, `extract_ed25519_raw_key_from_spki` | `FingerprintPinVerifier`, server-side extraction (endpoint) |
| Auth (not TLS) | `PeerEntry`, `AuthPolicy`, `Identity`, `IdentityProvider` | fingerprint → peer-id resolution (endpoint/auth layer) |
AGENTS.md convention 10 says this crate owns the config types after
the rewrite; but they are *config* types that `StaticConfig`, the
vault, and the auth layer also consume. Phase 0's OQ-TLS-01 framed
three options: move everything (A), move identity + fingerprint and
leave credentials at the dial seam (B), or keep in core with
re-exports (C).
The structural constraint: **alktls must compile without alknet**. It
is the rewrite's TLS crate; a dependency on the rewrite's core would
invert the layering (core always depends on TLS types, never the
reverse). Re-exports (C) would leave alktls importing from a crate
that is itself being rewritten — the coupling this extraction exists
to break.
## Decision
**Option A with one carve-out: alktls owns the identity, credentials,
and fingerprint types; the auth layer stays out.**
Moving into alktls (`src/`):
- `identity.rs``TlsIdentity`, `Ed25519SecretKey`, `AcmeDirectory`
(from alknet-core `config.rs`). `Ed25519SecretKey` keeps its exact
surface — `generate()`, `from_bytes(&[u8; 32])`, `as_bytes() ->
[u8; 32]`, `public()`, `sign()` — because iroh's
`iroh_base::SecretKey` consumes the 32 raw bytes (OQ-TLS-07's
requirement, verified against iroh 1.1 in Phase 0) and the
`Ed25519SigningKey` helper signs through it.
- `credentials.rs``ConnectionCredentials`, `RemoteIdentity` (from
alknet-core `credentials.rs`), including the load-bearing `Option`
semantics in their doc comments: `Some(fingerprint)` = pin,
`None` = CA-or-fail-closed, never a placeholder default.
- `fingerprint.rs``fingerprint_from_cert_der`,
`extract_ed25519_raw_key_from_spki`, and the manual DER parser
(from alknet-core). Production code stays `sha2` + manual DER —
no rustls dep needed by the module itself (the crate has rustls
anyway, but the module's purity keeps it testable standalone).
Staying out of alktls:
- `PeerEntry`, `AuthPolicy`, `Identity`, `IdentityProvider`,
fingerprint → peer-id resolution — these are the auth/identity
layer (peer records, tokens, scopes), not TLS. The rewrite's core
(or its auth crate) owns them. alktls' server side hands the
extracted fingerprint string to the caller; the caller resolves it.
This keeps the TLS-crate scope boundary clean: cert-level identity
in, peer-level identity out.
`StaticConfig`'s `tls_identity` field follows the type: the rewrite's
config struct depends on alktls for `TlsIdentity` (one-way: config →
alktls, never alktls → config). The vault derives `Ed25519SecretKey`
from key material and hands it in — alktls has no vault dep.
## Consequences
**Positive:**
- alktls compiles standalone; the rewrite's core depends on alktls
for TLS types in one direction only. No cycle risk when the
rewrite's module map settles.
- The credential bundle (`ConnectionCredentials`) and the verifier
selection it drives (ADR-034) live in the same crate — the
semantics are coupled (the `Option` dance is meaningless outside
the verifier contract), so co-locating them prevents semantic
drift between the bundle and the verifier.
- The fingerprint helpers move with their consumers
(`FingerprintPinVerifier`); the normalized `ed25519:<hex>` /
`SHA256:<hex>` formats stay with the code that computes them.
**Negative:**
- alktls' public API grows by the moved types (identity +
credentials + fingerprint). This is the point — the types are TLS
types — but it widens the API-freeze review surface.
- `alknet-core` (in the rewrite's form) must take the alktls dep for
`TlsIdentity` in its config struct. If the rewrite's core wants to
stay dependency-light, the type could be re-exported rather than
consumed deeply (implementation detail of the rewrite, not this
crate's concern).
## References
- AGENTS.md convention 10 — the recorded ownership expectation
- `docs/research/phase-0.md` §Gaps #3, OQ-TLS-01 — the framing
- alknet ADR-091 — `ConnectionCredentials` semantics
- alknet ADR-030 §6 — fingerprint normalization
- ADR-001 — the inheritance baseline
@@ -0,0 +1,103 @@
---
status: accepted
last_updated: 2026-09-10
---
# ADR-006: Module layout and test surface
## Status
Accepted (2026-09-10)
## Context
The extracted crate is organized as four modules plus the error
(`lib.rs`, `server.rs`, `client.rs`, `pem.rs`, `signing.rs`) with all
tests as in-module `#[cfg(test)]` blocks. alktls adds three modules of
moved types (ADR-005) and, per the repo's conventions, needs a
decided module map and test layout before the port starts.
Two shape questions need answers: where the moved types live
relative to the config constructors, and how the seed tests port
(no external workspace exists here, so in-module tests are the only
cross-module surface a full-crate crate has).
## Decision
**Module layout** (one module per file, re-exported from `lib.rs`):
```
src/
├── lib.rs — crate docs, TlsError (ADR-002), re-exports
├── identity.rs — TlsIdentity, Ed25519SecretKey, AcmeDirectory (ADR-005)
├── credentials.rs — ConnectionCredentials, RemoteIdentity (ADR-005)
├── fingerprint.rs — fingerprint_from_cert_der, extract_ed25519_raw_key_from_spki,
│ DER parser (ADR-005)
├── server.rs — TlsServerConfig, build_rustls_server_config,
│ RawKeyCertResolver, AcceptAnyCertVerifier,
│ SelfSignedCert / generate_self_signed_cert
├── client.rs — TlsClientConfig, select_server_verifier,
│ build_client_auth, RawKeyClientCertResolver,
│ NoClientCertResolver, FingerprintPinVerifier,
│ load_platform_root_cert_store
├── pem.rs — load_cert_chain, load_private_key
└── signing.rs — Ed25519SigningKey (rustls SigningKey + Signer)
```
Public API surface is `lib.rs` re-exports only (AGENTS.md convention
12); modules are `pub` for discoverability but the re-export block is
the documented surface.
**Feature gates stay on types and accessors, not modules.** The
`noq` feature gates `for_noq()` + `NoqWrap`; the `tcp` feature gates
`for_tcp_tls()`; the `acme` feature gates the ACME branch of
`TlsServerConfig::new` + the spawned task + the `futures` dep. Unlike
the alktunnels/alktty backends, there are no substrate modules to
gate — the transport-specific code is accessor-shaped.
**Test surface:**
- Port the extracted crate's in-module tests as the seed (they assert
the behavior-preservation invariants: `max_early_data_size` on the
server paths, `enable_early_data` on the client config, the
nine-scheme list, resolver behavior, PEM error paths, signing).
- Add `tests/` integration tests for the cross-module surfaces a
single crate has: `TlsServerConfig::new``for_noq()` /
`for_tcp_tls()` round-trips per identity variant;
`TlsClientConfig` verifier selection matrix (`Some`/`None` ×
raw-key/X.509); the root-store fallback against an empty platform
store; `acme-tls/1` append on the ACME path only.
- Pin the exact nine-scheme list in an integration test
(Phase 0 OQ-TLS-05's regression-proof shape — the extracted
in-module test only checks membership of two schemes), and pin
`enable_early_data = true` on the client config (the client half
of the 0-RTT invariant).
- `TlsIdentity::Acme`'s `unreachable!` in
`build_rustls_server_config` becomes a config error (the extracted
code relies on an internal dispatch invariant; the port replaces
the `unreachable!` with the `AcmeConfig` variant, keeping
no-panics-in-library-code).
## Consequences
**Positive:**
- The port is mechanically guided: module map + seed tests make the
Phase 2 port a diff against the extracted source.
- The invariant assertions move from "documented" to "tested" at both
unit and integration level.
**Negative:**
- Integration tests that wrap real QUIC endpoints are out of scope
(they belong to transport crates); the seam tests assert config
construction, not handshakes — the scope boundary holds.
## References
- AGENTS.md conventions 1, 9, 12 — comments, feature gates, module
structure
- `docs/research/phase-0.md` §Gaps #5, OQ-TLS-05 — the test-surface
question
- ADR-002 — `TlsError` (no `Config(String)` catch-all)
- ADR-005 — the moved modules
+137
View File
@@ -0,0 +1,137 @@
---
status: draft
last_updated: 2026-09-10
---
# Open Questions
Centralized tracker for alktls. Promoted from Phase 0
(`docs/research/phase-0.md` OQ-TLS-01..08) on 2026-09-10. Statuses here
are authoritative; the Phase 0 doc's statuses are the historical record.
## Statuses at a glance
| OQ | Topic | Status | Priority |
|----|-------|--------|----------|
| OQ-TLS-01 | Config-type ownership | **resolved** (ADR-005) | high |
| OQ-TLS-02 | `SelfSigned` on the client path | resolved (documented behavior kept) | low |
| OQ-TLS-03 | `for_tcp_tls()` adoption | **resolved** (ADR-004) | medium |
| OQ-TLS-04 | Accessor borrow-vs-consume | **resolved** (ADR-004) | medium |
| OQ-TLS-05 | Test surface | **resolved** (ADR-006) | low |
| OQ-TLS-06 | ACME task shutdown surface | resolved (detached-only for v1) | low |
| OQ-TLS-07 | iroh key surface | **resolved** (ADR-005, byte access pinned) | low |
| OQ-TLS-08 | `quinn``noq` feature rename | **resolved** (ADR-003) | high |
## Identity & types
### OQ-TLS-01: Where do the config types live, and what moves?
- **Origin**: docs/research/phase-0.md §Gaps #3
- **Status**: resolved (2026-09-10)
- **Priority**: high
- **Resolution**: alktls owns the identity types (`TlsIdentity`,
`Ed25519SecretKey`, `AcmeDirectory`), the credential bundle
(`ConnectionCredentials`, `RemoteIdentity`), and the fingerprint
helpers. The auth layer (`PeerEntry`, `AuthPolicy`,
`IdentityProvider`) stays out — peer-level identity is not TLS.
Decision recorded in [ADR-005](decisions/005-config-types-move-into-alktls.md).
- **Cross-references**: ADR-001, ADR-005
### OQ-TLS-02: `SelfSigned` on the client path — encode or document?
- **Origin**: docs/research/phase-0.md §Gaps #4
- **Status**: resolved (2026-09-10)
- **Priority**: low
- **Resolution**: keep the current behavior (present nothing via
`NoClientCertResolver`) and document it on the identity type and in
the [client spec](client.md). Type-level enforcement (config error,
like `Acme`) was rejected: `SelfSigned` as a *local* identity
meaning "present nothing" is coherent — the dev cert exists for the
server side, and presenting a self-signed client cert would add
nothing the fingerprint path uses.
- **Consequences**: a future dev client-auth use case would need a
type-level change (additive, not a one-way door).
- **Cross-references**: ADR-001 (identity model), client spec
### OQ-TLS-07: iroh relationship in the rewrite
- **Origin**: docs/research/phase-0.md OQ-TLS-07
- **Status**: resolved (2026-09-10)
- **Priority**: low
- **Resolution**: the requirement is pinned and verified —
`Ed25519SecretKey` keeps 32-byte raw access (`from_bytes` /
`as_bytes`) with the same byte-level surface iroh's
`iroh_base::SecretKey` consumes (`from_bytes` / `to_bytes`; 32 raw
bytes in/out — verified against iroh 1.1 in Phase 0). iroh stays
key-not-config; no `for_iroh()`. The rewrite's iroh dial consumes
the type alktls owns (ADR-005); no alktls-side work remains.
- **Cross-references**: ADR-003, ADR-005
## API surface
### OQ-TLS-03: `for_tcp_tls()` — adopt the spec accessor?
- **Origin**: docs/research/phase-0.md §Gaps #2
- **Status**: resolved (2026-09-10)
- **Priority**: medium
- **Resolution**: adopted — `for_tcp_tls(&self) ->
tokio_rustls::TlsAcceptor`, feature-gated on `tcp`, infallible.
Decision and rationale in [ADR-004](decisions/004-accessor-surface.md).
- **Cross-references**: ADR-004
### OQ-TLS-04: `for_quinn()` — `self` or `&self`?
- **Origin**: docs/research/phase-0.md OQ-TLS-04
- **Status**: resolved (2026-09-10)
- **Priority**: medium
- **Resolution**: server accessors take `&self` (one config feeds N
transports — the ADR-082 story, directly expressible); client
accessors consume `self` (per-dial build, zero-cost
`into_rustls_config`). Full rationale in
[ADR-004](decisions/004-accessor-surface.md).
- **Cross-references**: ADR-003, ADR-004
### OQ-TLS-08: `quinn` feature vs `noq` feature — and the MSRV floor
- **Origin**: docs/research/phase-0.md §Prior art: noq
- **Status**: resolved (2026-09-10)
- **Priority**: high
- **Resolution**: the feature is `noq` (no `quinn` feature ever
published; rename is pre-consumer and free). noq 1.2 pinned
(`"1.2"`, default-features off, `rustls` feature), bump
deliberately. The MSRV half of the OQ was separately resolved the
same day (ecosystem floor 1.88; `time` pin dropped). Decision in
[ADR-003](decisions/003-noq-replaces-quinn.md).
- **Cross-references**: ADR-002, ADR-003, ADR-004
## Quality / process
### OQ-TLS-05: Test surface for the invariants
- **Origin**: docs/research/phase-0.md §Gaps #5
- **Status**: resolved (2026-09-10)
- **Priority**: low
- **Resolution**: port the in-module seed tests AND add `tests/`
integration tests for the cross-module surfaces; pin the exact
nine-scheme list (regression-proof). Decision in
[ADR-006](decisions/006-module-layout-and-tests.md).
- **Cross-references**: ADR-006
### OQ-TLS-06: Does the ACME state machine need a shutdown surface?
- **Origin**: docs/research/phase-0.md OQ-TLS-06
- **Status**: resolved (2026-09-10)
- **Priority**: low
- **Resolution**: detached-only — the handle is stored (keeping
`TlsServerConfig` non-Clone) and never aborted; the ACME task runs
for the process lifetime. Documented on the type.
- **Consequences**: a `shutdown()` (abort + await) surface would be
additive and cheap if the rewrite's graceful-shutdown design wants
one later — not a one-way door.
- **Cross-references**: ADR-001 (one-ACME-machine rule),
[server.md](server.md)
## Deferred / Blocked
(none — all promoted OQs are resolved; new OQs added during review
land here with their deferral half per `docs/sdd_process.md`)
+108
View File
@@ -0,0 +1,108 @@
---
status: draft
last_updated: 2026-09-10
---
# alktls — Overview
## Purpose
alktls 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. It is the extraction of the TLS handling from alknet
(`crates/alknet-tls` + the identity/credential/fingerprint types in
`crates/alknet-core`), and it is the TLS crate the alknet rewrite
consumes.
The crate owns **config construction**: given an identity and an ALPN
list, produce a `rustls::ServerConfig` or `rustls::ClientConfig`, and
hand it to whichever transport wrapper the deployment runs. It does
not dial, accept, dispatch, or resolve peer identities — those are
the dial seam's, the accept loop's, and the auth layer's jobs
(alknet ADR-083/089; the scope boundary in ADR-001).
**Terminology.** The **assembly layer** is the deployment binary that
builds the `TlsServerConfig`s / `TlsClientConfig`s, builds the
transports from them, and wires the results together (alknet
ADR-014's term — in practice, the hub/worker/endpoint binary). The
dial seam is the outbound connection point the assembly layer (or a
client crate) consumes; the accept loop is the inbound counterpart.
alktls is the cert provider for both, never the loops themselves.
## The transport picture
A deployment (endpoint / hub) assembles a subset of three transports;
alktls serves the first two directly and feeds the third a key:
| Transport | Stack | What alktls provides |
|-----------|-------|----------------------|
| TCP+TLS | `tokio-rustls` | `TlsServerConfig::for_tcp_tls()``TlsAcceptor` (feature `tcp`); `TlsClientConfig::into_rustls_config()``TlsConnector` (ungated) |
| QUIC | `noq` (iroh's extracted quinn fork) | `for_noq()` on both configs (feature `noq`) |
| iroh | iroh's own TLS | nothing — `Ed25519SecretKey` 32-byte access feeds `iroh_base::SecretKey` (key-not-config) |
One identity, N transports: the inner rustls config is Clone
(Arc-shared resolvers); one `TlsServerConfig` feeds every transport an
endpoint runs. One ACME state machine per domain, shared across
transports — duplicate orders risk Let's Encrypt rate limits and
cert-cache divergence (ADR-001).
## What the crate is
The public API surface (ADR-004, ADR-005):
- `TlsServerConfig` — built once from a `TlsIdentity` + ALPN list;
accessors `for_noq()`, `for_tcp_tls()`, `rustls_config()`. Not
`Clone` (holds the ACME task's `JoinHandle`); share via `Arc`.
- `TlsClientConfig` — built per dial from `ConnectionCredentials` +
ALPN; accessors `for_noq()`, `into_rustls_config()`.
- `TlsError` — the ADR-088 six-variant `#[non_exhaustive]`
config-construction error type (ADR-002).
- Identity types — `TlsIdentity` (`X509` / `RawKey` / `SelfSigned` /
`Acme`), `Ed25519SecretKey`, `AcmeDirectory` (ADR-005).
- Credential bundle — `ConnectionCredentials`, `RemoteIdentity`
(ADR-005); drives the verifier selection matrix (alknet ADR-034).
- Fingerprint helpers — `fingerprint_from_cert_der`,
`extract_ed25519_raw_key_from_spki` (ADR-005); normalized
`ed25519:<hex>` / `SHA256:<hex>` formats.
## Design Decisions
All design decisions are documented as ADRs in [decisions/](decisions/).
| ADR | Decision | Summary |
|-----|----------|---------|
| [001](decisions/001-inherit-alknet-tls-design.md) | Inherit the alknet TLS design | The alknet ADRs + crate spec are the baseline; deviations recorded as alktls ADRs |
| [002](decisions/002-tlserror-shape.md) | `TlsError` — ADR-088 shape from day one | Six typed variants, `#[non_exhaustive]`, no string catch-all; config-construction scope boundary |
| [003](decisions/003-noq-replaces-quinn.md) | The QUIC feature is `noq` | noq 1.2 (iroh's extracted fork) replaces quinn pre-consumer; iroh stays key-not-config |
| [004](decisions/004-accessor-surface.md) | Complete the accessors | `for_tcp_tls()` adopted; server accessors borrow (`&self`), client accessors consume |
| [005](decisions/005-config-types-move-into-alktls.md) | Config types move into alktls | Identity + credentials + fingerprint move in; auth layer stays out |
| [006](decisions/006-module-layout-and-tests.md) | Module layout and test surface | Eight modules; in-module seed tests + integration invariant pins |
## Open Questions
Open questions are tracked in [open-questions.md](open-questions.md).
All Phase 0 questions (OQ-TLS-01..08) were resolved at Phase 1 entry
(2026-09-10) — six via ADR-001..006, two resolved as documented
behavior (OQ-TLS-02, OQ-TLS-06); the tracker records the resolutions
with pointers.
## Components
- [server.md](server.md) — `TlsServerConfig`, resolvers, the ACME
path, and the server-side invariants
- [client.md](client.md) — `TlsClientConfig`, verifier selection,
client-auth presentation, the root-store fallback
## Relationship to the alknet docs
The full design rationale lives in alknet's architecture docs
(`/workspace/@alkdev/alknet/docs/architecture/`): ADR-082 (why a
standalone TLS crate — the cert-reuse problem), ADR-083 (why the
endpoint takes no TLS config), ADR-084 (why aws-lc-rs), ADR-027 (the
identity model), ADR-034 (verifier selection), ADR-086 §3 (split ALPN
lists), ADR-087 (`TlsClientConfig`), ADR-088 (`TlsError`, root-store
fallback), ADR-089 (the dial seam), ADR-091 (`ConnectionCredentials`),
and `crates/tls/README.md` (the full crate spec). alktls' ADR series
records its own posture and points at the alknet reasoning rather
than duplicating it.
+152
View File
@@ -0,0 +1,152 @@
---
status: draft
last_updated: 2026-09-10
---
# alktls — Server side
`TlsServerConfig` and its resolvers: the server-side TLS setup,
extracted from alknet (`crates/alknet-tls/src/server.rs`) and ported
per the ADRs. Statuses of the decisions referenced here: see
[open-questions.md](open-questions.md) and the ADR index in
[overview.md](overview.md).
## `TlsServerConfig`
The central server-side type. Built once from a `TlsIdentity` + ALPN
list, shared across transports via `Arc` (not `Clone` — it holds the
ACME task's `JoinHandle`).
```rust
pub struct TlsServerConfig {
rustls_config: rustls::ServerConfig, // Clone-safe — Arc internally
acme_handle: Option<tokio::task::JoinHandle<()>>, // acme-gated
}
impl TlsServerConfig {
pub async fn new(identity: &TlsIdentity, alpns: &[Vec<u8>])
-> Result<Self, TlsError>;
#[cfg(feature = "noq")]
pub fn for_noq(&self) -> Result<noq::ServerConfig, TlsError>;
#[cfg(feature = "tcp")]
pub fn for_tcp_tls(&self) -> tokio_rustls::TlsAcceptor;
pub fn rustls_config(&self) -> &rustls::ServerConfig;
}
```
Construction dispatch (ADR-001's identity model):
- `X509 { cert, key }` — loads the chain + key from disk (`pem.rs`),
`with_single_cert`.
- `RawKey(Ed25519SecretKey)``RawKeyCertResolver` presents the
Ed25519 key as an RFC 7250 raw public key server cert.
- `SelfSigned``generate_self_signed_cert()` (rcgen), in-memory.
- `Acme { domains, cache_dir, directory, contact }` — the ACME path
(below). **Server-only**: on the client path it is a config error.
## Behavior-preservation invariants
These are load-bearing (ADR-001); an implementation that omits any of
them compiles but silently changes TLS behavior. Each is asserted by
test (ADR-006):
- **`max_early_data_size = u32::MAX`** on every server config path —
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 (alknet ADR-084). Never `ring`, never the
process-default provider, without a new ADR.
- **`AcceptAnyCertVerifier::supported_verify_schemes()`** returns
ED25519 + ECDSA P-256/P-384 + RSA PSS (SHA256/384/512) + RSA PKCS1
(SHA256/384/512) — nine schemes, verbatim, pinned by an exact-list
integration test.
- **`acme-tls/1` ALPN append** for the ACME path only, done by the
crate, not the caller (alknet ADR-027 §7).
- **Non-empty root store** — the client CA path merges `webpki-roots`
when the platform store is empty (see [client.md](client.md)).
## `AcceptAnyCertVerifier`
The server-side client-cert verifier: **request-but-don't-require**.
It asks for a client cert (X.509 or RFC 7250 raw key) so the caller
can extract the fingerprint via `peer_identity()`, but does not
require one and does not verify the presented cert against a CA. The
fingerprint is matched against peer records by the auth layer
(`IdentityProvider::resolve_from_fingerprint`) *outside* this crate —
the TLS crate hands over the fingerprint string; peer resolution is
not a TLS concern (ADR-005).
Server-side only: this must not be reused as a client-side
`ServerCertVerifier` — client-side verification is alknet ADR-034's
selection matrix (see [client.md](client.md)).
## `RawKeyCertResolver`
Presents an `Ed25519SecretKey` as an RFC 7250 raw public key server
certificate: the SPKI DER (Ed25519 OID + 32-byte key) is the "cert",
`only_raw_public_keys() == true`, and the signing key is the shared
`Ed25519SigningKey` helper (`signing.rs`).
## The ACME path
For `TlsIdentity::Acme`, `new` (feature `acme`):
1. Builds `rustls_acme::AcmeConfig` — the upstream builder, distinct
from `TlsError::AcmeConfig` (domains, `DirCache` cache dir,
directory URL from `AcmeDirectory`, contacts).
2. Wires `state.resolver()` as the cert resolver into the server
config.
3. Appends `acme-tls/1` to the ALPN list (TLS-ALPN-01 challenge).
4. Spawns the event-loop task (`tokio::spawn`) matching
`EventOk`/`EventError` variants to `tracing` logs, and returns
**immediately** — it does not await the first certificate.
Lifecycle semantics:
- The returned config is usable right away; handshakes fail
transiently until the first order completes (or a cached cert
deploys).
- Order errors log at `warn!` and retry inside `rustls-acme`; the
task exits only when the event stream ends.
- The task is detached: the stored `JoinHandle` is never aborted;
ACME runs for the process lifetime (OQ-TLS-06 resolved
detached-only; a `shutdown()` surface would be additive later if
the rewrite's graceful-shutdown design wants one).
- **One state machine per domain** — never spawn a second ACME task
for an already-served domain (duplicate orders = Let's Encrypt
rate-limit risk + cert-cache divergence). This is why
`TlsServerConfig` is not `Clone`.
- Runtime errors are stream events, not `TlsError` variants
(ADR-002's scope boundary). `TlsError::AcmeConfig` covers
config-mismatch mistakes ("ACME feature not enabled but `Acme`
configured" — and on the client path, `Acme` used for client
auth), not runtime failures.
## What the server side does NOT do
- No accept loop: `for_tcp_tls()` yields a `TlsAcceptor`; the
`TcpListener::accept``TlsAcceptor::accept` → dispatch loop
belongs to the caller (alknet ADR-083 — the endpoint takes no TLS
config; the assembly layer builds configs and transports).
- No ALPN policy: the ALPN list is a parameter (the assembly layer
filters per endpoint type — alknet ADR-086 §3); the crate appends
only `acme-tls/1` on the ACME path.
- No handshake: verifier selection and handshake outcomes on the
*server* side are `AcceptAnyCertVerifier` + the caller's
fingerprint extraction; a rejected handshake is the transport's
error, not `TlsError`.
- No peer resolution: the extracted fingerprint string goes to the
caller; `PeerEntry`/`AuthPolicy` live in the auth layer.
## References
- [overview.md](overview.md) — the index; [client.md](client.md) —
the client side
- alknet `crates/tls/README.md` §Architecture — the full server-side
spec this doc mirrors
- ADR-001 (invariants), ADR-002 (`TlsError`), ADR-003 (`for_noq`),
ADR-004 (accessors), ADR-005 (identity types), ADR-006 (modules,
tests)
+4 -2
View File
@@ -614,8 +614,10 @@ All internal; this crate has no external research debt:
- [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)
- [ ] Open questions promoted to Phase 1
`docs/architecture/open-questions.md` (Phase 1 work)
- [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