Checklist (all six PASS): 1. API surface == ADR-004 — every accessor signature verified verbatim 2. TlsError == ADR-002 — six variants, #[non_exhaustive], typed sources, AcmeConfig holds exactly the two config-mismatch cases 3. Invariants: all five server invariants + client 0-RTT half + fail-closed structure, each with a passing behavioral test at unit and integration level 4. Deltas vs extraction: all ADR-pinned; two surfaced divergences recorded as ADR amendments — zero un-pinned divergences remain 5. Feature hygiene: default = [] lean, tokio subset (no full), doc comments on public API, no inline // comments, no panics 6. Docs sync: ADR-002 + ADR-003 amendment notes; overview/server/client Draft → Reviewed; README carries the API-freeze lifecycle note 5 findings, all low severity, all resolved forward (table in task Notes) Verification: cargo test (81), cargo test --all-features (92), clippy -D warnings, fmt --check, doc --no-deps, publish --dry-run — all green. API FROZEN for the alknet rewrite.
159 lines
6.8 KiB
Markdown
159 lines
6.8 KiB
Markdown
---
|
|
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. Re-exported by rustls at
|
|
/// `rustls::client` (the `rustls::webpki` module is private at the
|
|
/// pinned 0.23.44; same type, public path).
|
|
#[error("building webpki verifier: {0}")]
|
|
VerifierBuild(#[from] rustls::client::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).
|
|
|
|
**Amendment (2026-09-10, from the Phase 2 implementation):** the
|
|
`VerifierBuild` source's reachable path at the pinned rustls 0.23.44
|
|
is `rustls::client::VerifierBuilderError`, not
|
|
`rustls::webpki::VerifierBuilderError` — the `rustls::webpki` module
|
|
is private at that version and the type is publicly re-exported at
|
|
`rustls::client` (same type; the implementation carries it that way,
|
|
with the path noted in the variant's doc comment).
|
|
|
|
## 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) |