Files
alknet/docs/architecture/decisions/088-tlserror-shape.md
T
glm-5.2 43b8179304 docs(arch): TlsError shape — single enum, owned by alknet-tls (ADR-088, resolves OQ-63)
Grounded in the actual error-producing call sites (endpoint.rs server
side, call_client.rs client side) and the dependency-crate sources read
from the cargo cache (rustls 0.23.41, rustls-pemfile 2.2.0, rcgen 0.13.2,
quinn-proto 0.11.15, rustls-acme 0.12.1).

Decision: single #[non_exhaustive] enum, one variant per failure
category, owned by alknet-tls (not re-exported from core). Six variants:
CertLoad(io::Error), SelfSigned(rcgen::Error), Rustls(rustls::Error),
VerifierBuild(VerifierBuilderError), QuinnWrap(NoInitialCipherSuite)
[quinn-gated], AcmeConfig(String).

Three findings drove single-enum over thin wrapper: (1) for_quinn()
fails with NoInitialCipherSuite, not rustls::Error — a rustls::Error
wrapper cannot represent the for_quinn() failure; (2) rustls_pemfile::Error
is not a std::error::Error (no Display, no Error impl) so #[from] would
not compile — pemfile BufRead APIs return io::Error; (3)
WebPkiServerVerifier::build() returns VerifierBuilderError, not
rustls::Error — a thin wrapper cannot represent empty-CA-root-store as
a first-class failure.

Deliberately NOT variants: ACME EventError/OrderError (stream events,
logged not returned from new); unknown-raw-key fail-closed (handshake-
time rejection at dial time, not a config-construction error —
corrects OQ-63's original framing); provider init (infallible); resolver
construction (infallible).
2026-07-15 09:22:24 +00:00

24 KiB

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 the current client code logs and continues; an empty store falls back to built-in webpki-roots
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:

/// 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. 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.

6. 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. A future ADR that introduces a dial helper (the OQ-55 dial seam) would define how handshake errors are surfaced then; that is not this ADR.

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 — TlsServerConfig extraction (introduces TlsError in signatures)
  • ADR-083 — endpoint takes no TLS config; EndpointError::TlsConfig is removed, making TlsError the sole TLS error surface
  • ADR-084 — aws_lc_rs provider (infallible; no ProviderInit variant)
  • ADR-087 — TlsClientConfig (extends TlsError to client-side variants)
  • ADR-034 §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)