Review of the three new crates (alknet-tls, alknet-endpoint, alknet-client) + revised core found compile-blocking inconsistencies, stale claims, and dep-graph contradictions. All resolved: Critical: - C1: CallClient::connect / ChannelClient::connect_quic REMOVED (not delegated) — keeping them as thin wrappers over AlknetClient::dial_quic would make protocol crates depend on alknet-client, contradicting the dep graph. Callers compose dial + take-over (2 lines). - C2: alknet-client feature gates now pull alknet-core/quinn + alknet-core/iroh (for Connection::from_quinn_with_alpn / from_iroh). - C3: rustls-native-certs + webpki-roots added to alknet-tls deps (always-present, not feature-gated — CA-verify path is transport-agnostic). Warning: - W1: CallCredentials/RemoteIdentity moved to alknet-core (from alknet-call) — the dial must not depend on the call protocol; not a two-way-door, it determines the dep graph. - W2: webpki-roots fallback implemented in spec (ADR-088 §5 added) — the code claimed a fallback that never existed; now the store is never empty, NoRootAnchors unreachable, containerized deployments work. - W3: EndpointError removed entirely (BindFailed + HandlerNotFound both vestigial after ADR-083); shutdown() is now infallible. - W4: FingerprintPinVerifier moved to alknet-tls (from alknet-call) — alknet-call sheds quinn/rustls/rustls-pemfile/rustls-native-certs entirely; CallClient becomes a pure protocol crate. Plus: ClientError removed (only produced by removed connect); S1 (CallCredentials → ClientVerifierContext mapping + auth_token stripped at TLS boundary documented); amendment notes on ADR-017, ADR-069, ADR-080, ADR-082, ADR-087, ADR-090; overview crate graph + README index updated. 29 files, consistency-reviewed.
469 lines
26 KiB
Markdown
469 lines
26 KiB
Markdown
# ADR-088: `TlsError` Shape — Single Enum, Owned by `alknet-tls`
|
|
|
|
## Status
|
|
|
|
Accepted (resolves OQ-63)
|
|
|
|
## Context
|
|
|
|
OQ-63 flagged that `TlsError` is referenced in the public signatures of
|
|
`TlsServerConfig::new`, `TlsClientConfig::new`, and `for_quinn()` (ADR-082,
|
|
ADR-087) but was never sketched. An implementer cannot write the crate
|
|
without deciding the shape: a thin wrapper around `rustls::Error` /
|
|
`io::Error` (less code, no matchable context) or a single enum with variants
|
|
per failure category (more code, the assembly layer can distinguish
|
|
"cert file missing" from "quinn rejected the rustls config" from
|
|
"verifier build failed because the root store is empty"). This ADR makes
|
|
that decision, grounded in the actual error-producing call sites and the
|
|
actual error types the dependency crates return.
|
|
|
|
### What was verified (the grounding)
|
|
|
|
The decision is grounded in two sources, not memory:
|
|
|
|
1. **The current code** — `crates/alknet-core/src/endpoint.rs`
|
|
(server-side: `build_rustls_server_config`, `TlsSetup::new`,
|
|
`TlsSetup::new_acme`, `build_quinn_server_config_from_rustls`,
|
|
`load_cert_chain`, `load_private_key`, `generate_self_signed_cert`)
|
|
and `crates/alknet-call/src/client/call_client.rs` (client-side:
|
|
`build_quinn_client_config`, `build_client_auth`,
|
|
`select_server_verifier`, `load_platform_root_cert_store`). Both
|
|
currently funnel every error into `io::Error` (server) or `String`
|
|
(client), losing all context.
|
|
2. **The dependency-crate sources** (rustls 0.23.41, rustls-pemfile 2.2.0,
|
|
rcgen 0.13.2, quinn-proto 0.11.15, rustls-acme 0.12.1, rustls-pki-types
|
|
1.14.1) — read from the cargo cache to confirm the exact error type
|
|
each call site returns, whether it implements `std::error::Error`, and
|
|
whether it is `Send + Sync + 'static`. Also confirmed: `rustls::Error`'s
|
|
`std::error::Error` impl is empty (no `source()` — the detail is in
|
|
`Display`, not a source chain); `rustls_pemfile::Error` has no
|
|
`Display` and no `Error` impl (only `From<Error> for io::Error`); the
|
|
`quinn` crate re-exports `quinn_proto::crypto` as `quinn::crypto`, so
|
|
`quinn::crypto::rustls::NoInitialCipherSuite` is the public path. The
|
|
full mapping is below.
|
|
|
|
### The actual error-producing call sites
|
|
|
|
**Server side (`TlsServerConfig::new` + `for_quinn`):**
|
|
|
|
| Call site | Error type | Notes |
|
|
|-----------|-----------|-------|
|
|
| `std::fs::read(cert_path)` / `read(key_path)` | `io::Error` | file read; cert/key load |
|
|
| `rustls_pemfile::certs()` / `private_key()` | `io::Error` | pemfile funnels its own `pemfile::Error` into `io::Error` (it does not impl `std::error::Error` — see "Gotchas") |
|
|
| "no private key found in file" | `io::Error(InvalidData)` | the `Ok(None)` case from `private_key()` |
|
|
| `rcgen::KeyPair::generate()` / `CertificateParams::self_signed()` | `rcgen::Error` | self-signed cert generation |
|
|
| `ServerConfig::builder_with_provider(...).with_safe_default_protocol_versions()` | `rustls::Error` | protocol-version / cipher-suite selection |
|
|
| `.with_single_cert(cert_chain, key)` | `rustls::Error` | key load via provider + `keys_match` |
|
|
| `RawKeyCertResolver::new(...)` | **infallible** | builds `CertifiedKey` in-memory; `ResolvesServerCert` has no construction error |
|
|
| `QuicServerConfig::try_from(rustls::ServerConfig)` (in `for_quinn`) | **`NoInitialCipherSuite`** | NOT `rustls::Error` — a distinct struct (re-exported at `quinn::crypto::rustls::NoInitialCipherSuite`, defined in `quinn_proto::crypto::rustls`); the one path where `for_quinn()` fails |
|
|
| "ACME feature not enabled but `TlsIdentity::Acme` configured" | `io::Error(Unsupported)` | the `#[cfg(not(feature = "acme"))]` guard |
|
|
| `AcmeConfig::new()` / `AcmeConfig::state()` | **infallible** | ACME state-machine construction does not fail; errors are stream events (`EventError`), logged not returned (see "ACME") |
|
|
|
|
**Client side (`TlsClientConfig::new`):**
|
|
|
|
| Call site | Error type | Notes |
|
|
|-----------|-----------|-------|
|
|
| `ClientConfig::builder_with_provider(...).with_safe_default_protocol_versions()` | `rustls::Error` | same as server-side |
|
|
| `FingerprintPinVerifier::new(...)` | **infallible** | stores the fingerprint + supported algorithms; the known-peer path |
|
|
| `WebPkiServerVerifier::builder_with_provider(...).build()` | **`VerifierBuilderError`** | `rustls::webpki::VerifierBuilderError` — `NoRootAnchors`, `InvalidCrl`; the unknown-X.509-remote path |
|
|
| `rustls::RootCertStore::add(cert)` | `rustls::Error` | adding a native root cert (maps `webpki::Error` → `InvalidCertificate(...)`) |
|
|
| `rustls-native-certs::load_native_certs()` errors | logged, not returned | native-certs load errors are logged and the load continues; the built-in `webpki-roots` are merged into the store when the platform store is empty (see §5), so the store is never empty and `NoRootAnchors` is unreachable in practice |
|
|
| Client-auth cert resolver — RawKey | **infallible** | builds `CertifiedKey` in-memory |
|
|
| Client-auth cert resolver — X.509 | `io::Error` (load) + `rustls::Error` (`CertifiedKey::from_der`) | cert/key file load + key parse |
|
|
| "ACME TLS identity is server-only; cannot be used for client auth" | `io::Error`-shape | the `TlsIdentity::Acme` as client-identity guard |
|
|
| `aws_lc_rs::default_provider()` | **infallible** | returns `CryptoProvider` by value; provider init cannot fail |
|
|
|
|
### Gotchas that drive the enum-not-wrapper decision
|
|
|
|
Three findings from the dependency-crate source make a thin wrapper
|
|
**actively wrong** for this crate:
|
|
|
|
1. **`for_quinn()` fails with `NoInitialCipherSuite`, not `rustls::Error`.**
|
|
These are two distinct, non-overlapping types from two different crates.
|
|
A thin wrapper around `rustls::Error` cannot represent the `for_quinn()`
|
|
failure at all; a thin wrapper around `io::Error` (the current
|
|
server-side pattern) erases it into "other I/O error." The assembly
|
|
layer that calls `for_quinn()` needs to distinguish "the rustls config
|
|
was built fine but quinn rejected it for lacking a TLS 1.3 AES-128-GCM
|
|
initial cipher suite" from "the rustls config build failed" — those
|
|
have different remediations (the first is a provider/cipher-suite
|
|
config issue; the second is a cert/key issue).
|
|
|
|
2. **`rustls_pemfile::Error` is not a `std::error::Error`.** It has no
|
|
`Display` impl and no `Error` trait impl — it only has
|
|
`From<Error> for io::Error`. This is why every BufRead-based pemfile
|
|
API returns `io::Error`, not `pemfile::Error`. A `TlsError` that tried
|
|
to `#[from] rustls_pemfile::Error` would not compile. The crate's
|
|
`io::Error`-returning APIs are the correct surface to wrap.
|
|
|
|
3. **`WebPkiServerVerifier::build()` returns `VerifierBuilderError`,
|
|
not `rustls::Error`.** This is the client-side unknown-X.509-remote
|
|
path (ADR-034 §3). Its variants (`NoRootAnchors`, `InvalidCrl`) are
|
|
distinct from `rustls::Error::InvalidCertificate(...)`. A thin
|
|
`rustls::Error` wrapper cannot represent "the CA root store was
|
|
empty" as a first-class failure; the enum can.
|
|
|
|
A single enum with one variant per failure category gives the assembly
|
|
layer matchable context — the one thing the current `io::Error` /
|
|
`String` funneling destroys — and correctly represents the four
|
|
distinct error types (`rustls::Error`, `NoInitialCipherSuite`,
|
|
`VerifierBuilderError`, `rcgen::Error`, plus `io::Error` for file I/O
|
|
and the ACME-disabled guard) as separate variants instead of erasing
|
|
them into one.
|
|
|
|
### What the thin wrapper would lose
|
|
|
|
The current server-side code (`endpoint.rs`) wraps everything in
|
|
`EndpointError::TlsConfig(io::Error::other(e))`. The current client-side
|
|
code (`call_client.rs`) converts everything to `String`. Both lose the
|
|
matchable context. Concretely, an operator who sees
|
|
"tls config error: No such file or directory" cannot tell from the
|
|
error alone whether the cert path was wrong, the key path was wrong, or
|
|
the ACME cache dir was wrong — and an operator who sees
|
|
"tls config error: invalid cipher suite specified" cannot tell whether
|
|
that came from `with_safe_default_protocol_versions()` (a rustls
|
|
config issue) or from `QuicServerConfig::try_from` (a quinn-wrap issue).
|
|
The enum names the category; the wrapped source keeps the detail.
|
|
|
|
## Decision
|
|
|
|
### 1. `TlsError` is a single enum with variants per failure category
|
|
|
|
`TlsError` is a `#[non_exhaustive]` enum, one variant per failure
|
|
category, each variant wrapping the underlying error as its `#[source]`.
|
|
The decision is the **variant names and their wrapped types**; the
|
|
`#[from]` vs. explicit-field style and the `AcmeConfig(String)` vs.
|
|
structured-enum choice are two-way implementation details (see "Door
|
|
type"). The shape:
|
|
|
|
```rust
|
|
/// Errors produced by `TlsServerConfig::new`, `TlsClientConfig::new`,
|
|
/// and the transport accessors (`for_quinn`, `for_tcp_tls` is infallible).
|
|
///
|
|
/// One variant per failure category. The wrapped error is the
|
|
/// `#[source]` — match on the variant for the category, inspect the
|
|
/// source for the detail.
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[non_exhaustive]
|
|
pub enum TlsError {
|
|
/// Cert or key file could not be read or parsed.
|
|
/// Source: `io::Error` (file read + pemfile parse, which pemfile
|
|
/// funnels into `io::Error`; see ADR-088 §"Gotchas" #2).
|
|
#[error("loading cert/key material: {0}")]
|
|
CertLoad(#[from] std::io::Error),
|
|
|
|
/// Self-signed certificate generation failed.
|
|
/// Source: `rcgen::Error`.
|
|
#[error("generating self-signed cert: {0}")]
|
|
SelfSigned(#[from] rcgen::Error),
|
|
|
|
/// rustls server or client config construction failed
|
|
/// (`with_safe_default_protocol_versions`, `with_single_cert`,
|
|
/// `CertifiedKey::from_der`, `RootCertStore::add`).
|
|
/// Source: `rustls::Error`.
|
|
#[error("building rustls config: {0}")]
|
|
Rustls(#[from] rustls::Error),
|
|
|
|
/// `WebPkiServerVerifier::build()` failed (the unknown-X.509-remote
|
|
/// client path — empty root store, invalid CRL). Distinct from
|
|
/// `Rustls` because the type is distinct and the remediation is
|
|
/// different (root-store / CRL config, not cert/key config).
|
|
/// Source: `rustls::webpki::VerifierBuilderError`.
|
|
#[error("building webpki verifier: {0}")]
|
|
VerifierBuild(#[from] rustls::webpki::VerifierBuilderError),
|
|
|
|
/// `QuicServerConfig::try_from(rustls::ServerConfig)` failed — the
|
|
/// one path where `for_quinn()` can fail. The rustls config was
|
|
/// built fine; quinn rejected it (no TLS 1.3 AES-128-GCM initial
|
|
/// cipher suite in the provider). Distinct from `Rustls` because
|
|
/// the type is `NoInitialCipherSuite`, not `rustls::Error`, and the
|
|
/// remediation is provider/cipher-suite config, not cert/key.
|
|
/// Source: `quinn_proto::crypto::rustls::NoInitialCipherSuite`.
|
|
#[cfg(feature = "quinn")]
|
|
#[error("wrapping rustls config for quinn: {0}")]
|
|
QuinnWrap(#[from] quinn::crypto::rustls::NoInitialCipherSuite),
|
|
|
|
/// ACME feature not enabled but `TlsIdentity::Acme` configured, or
|
|
/// ACME identity used where a client identity is required.
|
|
/// Not a wrapped error — a configuration mismatch detected by the
|
|
/// crate itself.
|
|
#[error("ACME configuration error: {0}")]
|
|
AcmeConfig(String),
|
|
}
|
|
```
|
|
|
|
**The variant set, in words:**
|
|
|
|
- `CertLoad(io::Error)` — file read + PEM parse (cert, key, and via the
|
|
shared `load_cert_chain`/`load_private_key` helpers). Covers the
|
|
server X.509 path, the client X.509 client-auth path, and the
|
|
"no private key found" case. `#[from] io::Error` because `io::Error` is
|
|
the type the pemfile BufRead APIs actually return.
|
|
- `SelfSigned(rcgen::Error)` — self-signed cert generation (server
|
|
`SelfSigned` identity).
|
|
- `Rustls(rustls::Error)` — rustls server or client config construction
|
|
(`with_safe_default_protocol_versions`, `with_single_cert`,
|
|
`CertifiedKey::from_der`, `RootCertStore::add`). The largest failure
|
|
surface; both server and client paths.
|
|
- `VerifierBuild(VerifierBuilderError)` —
|
|
`WebPkiServerVerifier::build()` (the unknown-X.509-remote client path).
|
|
Distinct from `Rustls` because the type is distinct and the
|
|
remediation is root-store/CRL config, not cert/key config.
|
|
- `QuinnWrap(NoInitialCipherSuite)` — `for_quinn()`'s one failure path.
|
|
Feature-gated on `quinn`. Distinct from `Rustls` because the type is
|
|
distinct and the remediation is provider/cipher-suite config.
|
|
- `AcmeConfig(String)` — "ACME feature not enabled but
|
|
`TlsIdentity::Acme` configured" (server) and "ACME identity is
|
|
server-only; cannot be used for client auth" (client). A config
|
|
mismatch detected by the crate, not a wrapped third-party error.
|
|
|
|
### 2. What is NOT a variant (and why)
|
|
|
|
These are deliberately absent because they cannot be produced by the
|
|
crate's public API:
|
|
|
|
- **`rustls_pemfile::Error`** — not a `std::error::Error` (no `Display`,
|
|
no `Error` impl); pemfile's BufRead APIs return `io::Error`. Wrapped
|
|
as `CertLoad(io::Error)`. See §"Gotchas" #2.
|
|
- **ACME `EventError` / `OrderError` / `CertParseError`** — these are
|
|
stream events from the ACME state machine, not errors returned from
|
|
`TlsServerConfig::new`. `new` spawns the state machine and returns
|
|
immediately (ADR-082 §"`async fn new` — lifecycle semantics"); the
|
|
state machine's errors surface via the event stream and are logged
|
|
(the current code logs them in the spawned task, ADR-082 §"Behavior
|
|
preservation"). They do not flow through `TlsError`. See §"ACME" below.
|
|
- **Provider init error** — `aws_lc_rs::default_provider()` is
|
|
infallible (returns `CryptoProvider` by value). There is no
|
|
`ProviderInit` variant because provider init cannot fail.
|
|
- **`rustls::server::ResolvesServerCert` / `client::ResolvesClientCert`
|
|
construction** — infallible; the resolver traits have no construction
|
|
error. `RawKeyCertResolver::new` and the client-side
|
|
`RawKeyClientCertResolver::new` build `CertifiedKey` in-memory and
|
|
cannot fail.
|
|
- **`FingerprintPinVerifier::new`** — infallible; stores the fingerprint
|
|
+ supported algorithms. The known-peer client path has no construction
|
|
error.
|
|
- **The unknown-raw-key fail-closed** — this is NOT an error returned
|
|
from `TlsClientConfig::new`. It is a `Result::Err` the *caller* gets
|
|
when *trying to connect* to an unknown raw-key remote (the
|
|
`WebPkiServerVerifier` fails the handshake at dial time). It is not in
|
|
`TlsError`'s scope — `TlsError` is for config construction, not
|
|
handshake outcomes. OQ-63's framing listed it as a "client verifier
|
|
construction" failure mode; the research corrects this: the
|
|
fail-closed is a handshake-time rejection, not a config-construction
|
|
error. See §"The fail-closed distinction" below.
|
|
|
|
### 3. `TlsError` lives in `alknet-tls` (owned by the crate that produces it)
|
|
|
|
`TlsError` is defined in `alknet-tls` and is the sole TLS error surface
|
|
for the crate. It is not re-exported from `alknet-core`. Rationale:
|
|
|
|
- `alknet-core`'s `EndpointError` no longer has a `TlsConfig` variant
|
|
after ADR-083 (the endpoint takes no TLS config; the assembly layer
|
|
builds `TlsServerConfig` and hands pre-built transports to the
|
|
endpoint). Core does not need to know about `TlsError`.
|
|
- `alknet-tls` is the crate that produces the errors; it owns the type.
|
|
Re-exporting from core would invert the ownership (core re-exporting a
|
|
type from a crate that depends on it).
|
|
- The assembly layer (hub/worker) depends on `alknet-tls` directly (it
|
|
calls `TlsServerConfig::new` / `TlsClientConfig::new`); it gets
|
|
`TlsError` from that dependency, not from core.
|
|
|
|
This resolves the subsidiary question in OQ-63 ("does `TlsError` live in
|
|
`alknet-tls` or is it re-exported from `alknet-core`?"): **`alknet-tls`,
|
|
owned by the crate that produces it.**
|
|
|
|
### 4. Feature gates
|
|
|
|
`QuinnWrap` is gated on the `quinn` feature (it wraps a quinn type).
|
|
`VerifierBuild` wraps a `rustls::webpki` type that is always present
|
|
when `rustls` is present — it is not feature-gated. The ACME
|
|
config-mismatch variant (`AcmeConfig`) is present regardless of the
|
|
`acme` feature, because the "ACME configured but feature not enabled"
|
|
guard must exist even when `acme` is off (the `#[cfg(not(feature =
|
|
"acme"))]` branch returns `AcmeConfig("ACME feature not enabled...")`).
|
|
|
|
The enum is `#[non_exhaustive]` so adding variants (e.g., a future
|
|
`TcpWrap` if `for_tcp_tls` ever gains a failure path — it does not today)
|
|
is not a breaking change.
|
|
|
|
### 5. Root store fallback — `webpki-roots` when the platform store is empty
|
|
|
|
The unknown-X.509-remote CA-verification path in `TlsClientConfig::new`
|
|
loads the platform's native root certs (`rustls-native-certs`). The
|
|
current code's comment claims a fallback to built-in `webpki-roots` when
|
|
the platform store is empty, but the code does no such thing — an empty
|
|
store produces `NoRootAnchors` from `WebPkiServerVerifier::build()`,
|
|
surfacing as `TlsError::VerifierBuild`. This is a latent bug for
|
|
containerized deployments (no system CA bundle) dialing public X.509
|
|
endpoints — a real deployment shape.
|
|
|
|
The extraction to `alknet-tls` is the moment to make the claim true.
|
|
`TlsClientConfig::new`'s CA-verify path merges the built-in
|
|
`webpki-roots` into the `RootCertStore` when the platform store comes
|
|
back empty (or unconditionally — the built-in roots are a superset of
|
|
trust, and native certs are mostly the same CAs). `webpki-roots` is an
|
|
always-present dep in `alknet-tls` (not feature-gated — the CA-verify
|
|
path is transport-agnostic). Native-certs *load* errors are still
|
|
logged, not returned (preserved behavior); the fallback guarantees the
|
|
store is non-empty regardless, so `NoRootAnchors` is unreachable in
|
|
practice. The `VerifierBuild` variant remains in `TlsError` for the
|
|
`InvalidCrl` case and forward-compatibility, but the empty-store case
|
|
is eliminated.
|
|
|
|
This is a behavior change from the current code (which silently fails on
|
|
empty stores), but it is the behavior the docs already claimed — making
|
|
the claim true, not preserving a latent bug. See ADR-089 §5 for the
|
|
broader principle: preserve behavior only when it makes sense; a claim
|
|
without code is a bug, not a contract.
|
|
|
|
### 6. ACME — errors are stream events, not `TlsError` variants
|
|
|
|
`AcmeConfig::new()` and `AcmeConfig::state()` are infallible (confirmed
|
|
in the rustls-acme 0.12.1 source). `TlsServerConfig::new`'s ACME branch
|
|
spawns the state machine and returns immediately; the state machine's
|
|
errors (`EventError` — `CertCacheLoad`, `AccountCacheLoad`, `Order`,
|
|
`NewCertParse`, etc.) arrive asynchronously through the event stream
|
|
and are logged in the spawned task (the current code's pattern, preserved
|
|
by ADR-082 §"Behavior-preservation invariants"). They do not flow through
|
|
`TlsError`.
|
|
|
|
The only ACME-related `TlsError` is `AcmeConfig(String)` — the
|
|
configuration-mismatch guard ("ACME feature not enabled but
|
|
`TlsIdentity::Acme` configured" on the server; "ACME identity is
|
|
server-only; cannot be used for client auth" on the client). This is a
|
|
synchronous config error detected at `new` time, not an ACME-protocol
|
|
error.
|
|
|
|
If a future requirement wants to surface ACME state-machine errors to
|
|
the caller (rather than log them), that is a new ADR — it changes the
|
|
`TlsServerConfig` API (the ACME handle would need an error channel) and
|
|
is out of scope for the error-shape decision.
|
|
|
|
### 7. The fail-closed distinction
|
|
|
|
OQ-63's framing listed "unknown-remote fail-closed (not an error to
|
|
return — it's a `Result::Err` the caller gets for trying to connect to
|
|
an unknown raw-key remote)" as a client verifier construction failure
|
|
mode. The research corrects this: the fail-closed is **not** a
|
|
`TlsClientConfig::new` error. It is a handshake-time rejection produced
|
|
by the verifier at dial time, not a config-construction error. The
|
|
distinction:
|
|
|
|
- `TlsClientConfig::new` builds the config. If the *inputs* are bad
|
|
(empty root store → `VerifierBuilderError`, bad fingerprint format →
|
|
not currently a construction error because `FingerprintPinVerifier`
|
|
stores the string as-is and rejects at handshake), `new` returns
|
|
`TlsError`.
|
|
- The *handshake* (dial time) can fail with `rustls::Error` (the
|
|
verifier returns `InvalidCertificate(...)`). That error flows through
|
|
the transport's connector (`quinn::Endpoint::connect_with` →
|
|
`quinn::Connection` error; `TlsConnector::connect` → `io::Error`), not
|
|
through `TlsError`.
|
|
|
|
`TlsError` is the **config-construction** error type. Handshake-time
|
|
errors are the transport's error type. This keeps `TlsError` scoped to
|
|
what `new` and `for_quinn` can actually fail on, and avoids pretending
|
|
handshake outcomes are config-construction errors. The dial seam
|
|
(ADR-089, `AlknetClient`) surfaces handshake errors as
|
|
`ClientDialError::Handshake(String)` — see the client spec's
|
|
`ClientDialError` section.
|
|
|
|
## Consequences
|
|
|
|
**Positive:**
|
|
|
|
- The assembly layer can match on `TlsError` to distinguish failure
|
|
categories — "cert file missing" (`CertLoad`) from "quinn rejected the
|
|
rustls config" (`QuinnWrap`) from "empty CA root store"
|
|
(`VerifierBuild`) from "self-signed generation failed" (`SelfSigned`).
|
|
The current `io::Error` / `String` funneling destroys this context; the
|
|
enum restores it.
|
|
- The four distinct underlying error types (`rustls::Error`,
|
|
`NoInitialCipherSuite`, `VerifierBuilderError`, `rcgen::Error`) are
|
|
represented as separate variants instead of erased into one —
|
|
correctly reflecting that they come from different crates, have
|
|
different remediations, and (in the `NoInitialCipherSuite` case) are
|
|
not even the same type as `rustls::Error`.
|
|
- `#[from]` conversions make the implementation terse (`?` propagates
|
|
`io::Error`, `rustls::Error`, `rcgen::Error`,
|
|
`VerifierBuilderError`, `NoInitialCipherSuite` directly) while keeping
|
|
the category explicit at the variant level.
|
|
- `TlsError` lives in `alknet-tls`, owned by the crate that produces it;
|
|
core's `EndpointError` is unaffected (and no longer has a `TlsConfig`
|
|
variant after ADR-083 anyway).
|
|
- `#[non_exhaustive]` lets future variants be added without breaking
|
|
downstream matches.
|
|
- The ACME boundary is clear: `TlsError::AcmeConfig` is the
|
|
config-mismatch guard; ACME state-machine errors are stream events,
|
|
logged, not `TlsError` variants.
|
|
|
|
**Negative:**
|
|
|
|
- Six variants is more code than a thin `rustls::Error` wrapper. The
|
|
trade is matchable context vs. minimal code; for a crate whose entire
|
|
purpose is shared TLS config across multiple assembly-layer call sites,
|
|
the context wins. The assembly layer is the consumer that benefits
|
|
from the distinction.
|
|
- The client-side `TlsError` coverage is slightly asymmetrical with the
|
|
server side: the server has `QuinnWrap` (quinn-specific) and
|
|
`SelfSigned` (rcgen); the client has `VerifierBuild` (webpki). This
|
|
reflects the actual asymmetry — the server wraps for quinn and
|
|
generates self-signed certs; the client builds a CA verifier. The
|
|
shared variants (`CertLoad`, `Rustls`, `AcmeConfig`) cover both. The
|
|
asymmetry is the real shape, not a missing piece.
|
|
- `rustls::Error`'s `std::error::Error` impl is empty (no `source()`),
|
|
so the source chain stops at the variant. The variant name carries the
|
|
category; the `rustls::Error`'s `Display` carries the detail. This is
|
|
the same as the current code's behavior (the detail is in the
|
|
`io::Error::other(e)` string); the enum adds the category on top.
|
|
- `NoInitialCipherSuite` has a private field — it cannot be constructed
|
|
externally (only received from quinn). This is fine for `#[from]`
|
|
(we receive it, we don't construct it), but it means tests cannot
|
|
synthesize a `QuinnWrap` variant without a real quinn rejection. The
|
|
`for_quinn` failure path is tested via integration, not unit.
|
|
|
|
## Door type
|
|
|
|
**One-way.** `TlsError` is the public API surface of `alknet-tls`. Every
|
|
assembly-layer call site (`TlsServerConfig::new`, `TlsClientConfig::new`,
|
|
`for_quinn`) returns it. Changing the variant set after consumers exist
|
|
is a breaking change to every call site's `match`. `#[non_exhaustive]`
|
|
makes *adding* variants non-breaking, but *removing* or *renaming*
|
|
variants is a breaking change. The one-way-ness is why this ADR exists
|
|
before implementation, not after.
|
|
|
|
The internal implementation (whether `CertLoad` uses `#[from] io::Error`
|
|
or an explicit `io::Error` field; whether `AcmeConfig` is a `String` or
|
|
a structured enum) is two-way — those are implementation details that
|
|
can change without breaking the variant names.
|
|
|
|
## References
|
|
|
|
- OQ-63 (resolved by this ADR) — `TlsError` shape
|
|
- [ADR-082](082-alknet-tls-extraction.md) — `TlsServerConfig` extraction
|
|
(introduces `TlsError` in signatures)
|
|
- [ADR-083](083-endpoint-as-accept-loop-runner.md) — endpoint takes no
|
|
TLS config; `EndpointError::TlsConfig` is removed, making `TlsError`
|
|
the sole TLS error surface
|
|
- [ADR-084](084-aws-lc-rs-crypto-provider.md) — `aws_lc_rs` provider
|
|
(infallible; no `ProviderInit` variant)
|
|
- [ADR-087](087-tlsclientconfig-not-blocked-on-dial.md) —
|
|
`TlsClientConfig` (extends `TlsError` to client-side variants)
|
|
- [ADR-034](034-outgoing-only-x509-and-three-peer-roles.md) §3 — verifier
|
|
selection rule (known peer → fingerprint pin; unknown X.509 → CA
|
|
verify; unknown raw-key → fail closed at handshake, not at `new`)
|
|
- `crates/alknet-core/src/endpoint.rs` — the current server-side
|
|
`io::Error`-wrapping pattern (`TlsConfig(io::Error::other(e))`)
|
|
- `crates/alknet-call/src/client/call_client.rs` — the current
|
|
client-side `String`-wrapping pattern (`e.to_string()`)
|
|
- Dependency-crate sources (read from the cargo cache):
|
|
rustls 0.23.41 (`src/error.rs`, `src/builder.rs`, `src/server/builder.rs`,
|
|
`src/client/builder.rs`, `src/webpki/server_verifier.rs`,
|
|
`src/webpki/anchors.rs`), rustls-pemfile 2.2.0 (`src/lib.rs`,
|
|
`src/pemfile.rs`), rcgen 0.13.2 (`src/error.rs`, `src/certificate.rs`,
|
|
`src/key.rs`), quinn-proto 0.11.15 (`src/crypto/rustls.rs` —
|
|
`NoInitialCipherSuite`, `TryFrom<rustls::ServerConfig>`), rustls-acme
|
|
0.12.1 (`src/state.rs`, `src/acme.rs` — `EventError`, `OrderError`,
|
|
infallible `AcmeConfig::new`/`state`), rustls-pki-types 1.14.1
|
|
(`src/lib.rs`, `src/pem.rs`) |