ADR-046: Optional In-Process TLS for craig-intake (axum-server + rustls)

On this page

Status

Accepted (2026-06-30). Epic &61 (craig-intake hardening + total test coverage), plan intake-coverage-hardening.adoc Step 11, the COMP-05 enabler (#756, MR-4 of 5).

Context

craig-intake is the stateless public edge (ADR-017). It serves plain HTTP and assumes an external ingress / reverse-proxy terminates TLS — the standard production topology.

Two forces push for an optional in-process TLS capability:

  • Browser crypto needs a secure context. The mandated-reporter signing UI signs reports in-browser with the WebCrypto crypto.subtle ES256 API. crypto.subtle is only exposed in a https://[secure context] (https + localhost). The e2e topology reaches the standalone-SHINES instance at http://host.docker.internal:8011, which is NOT a secure context, so the real in-browser keygen+sign chain (gap COMP-05) cannot be exercised end-to-end today.

  • A real deployment capability. Some deployments have no ingress terminator (a single-binary standalone-SHINES edge, an air-gapped pilot). In-process TLS is a legitimate production option for them, not only a test affordance.

Constraints:

  • musl ⇒ rustls. All images are Alpine/musl; the openssl crate is banned (deny.toml). TLS must be pure-Rust rustls.

  • ConnectInfo<SocketAddr> MUST survive. The public + signed nests rate-limit and IP-hash on the peer address (api::rate_limit::extract_client_ip over ConnectInfo<SocketAddr>). A TLS serve path that drops connect-info would silently break per-IP rate limiting — a security regression invisible to a smoke test.

  • Graceful shutdown parity with the existing axum::serve(…​).with_graceful_shutdown(…​).

  • No committed key material (Kerckhoffs / security-baseline): a self-signed cert for dev/test must be generated at boot, never checked in.

Decision

Add an optional, default-off, in-process TLS listener. Plain HTTP behind an ingress terminator remains the default and is unchanged.

  • Config (IntakeSettings). tls_cert_file / tls_key_file: Option<String> are unconditional fields (a real production path): when both are set, craig-intake terminates TLS from operator-provisioned PEM files. validate() enforces both-or-neither. They hold filesystem paths, never key bytes.

  • Self-signed (dev/test) is feature-gated. A self_signed_tls: bool toggle, when true, generates an ephemeral self-signed cert at boot via rcgen. The generation path AND the rcgen dependency are behind the self-signed-tls Cargo feature (devstack image only), so neither enters a release build. With the feature off, validate() REFUSES self_signed_tls=true (fail-closed at runtime; the generation code does not exist at compile time). SANs: localhost, 127.0.0.1, host.docker.internal, craig-intake.

  • Serve via axum-server (bind_rustls + RustlsConfig + Handle): it serves an into_make_service_with_connect_info::<SocketAddr>() (so ConnectInfo survives) and supports graceful shutdown via a cloned Handle, in ~20 lines and with no hand-rolled accept loop.

  • Crypto provider. rustls 0.23 needs a process-default CryptoProvider. The dep tree already pulls aws-lc-rs (reqwest’s rustls-tls), and axum-server’s tls-rustls feature selects rustls/aws-lc-rs — the same provider, no new subtree. craig-intake installs it idempotently as the process default before resolving TLS (an Err means another component already installed it).

  • Separate HTTPS e2e instance. MR-5 added a new craig-intake-standalone-shines-tls compose service (default host port 8013, built --features self-signed-tls via the CRAIG_INTAKE_FEATURES build arg, CRAIG_INTAKE__SELF_SIGNED_TLS=true); the existing plain-HTTP :8011 instance, .ports.env, and every current Rust/e2e test are left untouched. The secure-context flows run only against the new instance, under the Playwright intake-ui-shines-tls project (HTTPS baseURL CRAIG_INTAKE_SHINES_TLS_URL + ignoreHTTPSErrors): intake-edge-crypto.spec.ts (the real browser keygen+sign chain, COMP-05) and intake-edge-visual.spec.ts (visual baselines, COMP-06).

Alternatives considered

  1. axum-server (chosen). Preserves ConnectInfo + graceful shutdown out of the box; pins the same rustls/tokio-rustls already in the lock; small, widely-adopted, actively maintained.

  2. Hand-rolled tokio_rustls::TlsAcceptor accept loop (the sibling IMTN project’s approach). Rejected for craig-intake: a bare accept loop yields a TlsStream with no ConnectInfo, so the rate-limit/IP-hash layer would silently key every request on a missing/zero address unless peer_addr() is threaded into a per-connection ConnectInfo extension by hand — exactly the silent rate-limit breakage the constraints forbid. More code, more footgun, no benefit here.

  3. External ingress / reverse-proxy TLS termination (status quo). Remains the default and the recommended production topology — but it cannot give the e2e a secure context without standing up extra proxy infrastructure inside the test harness, and it is unavailable to no-ingress deployments.

  4. Chrome --unsafely-treat-insecure-origin-as-secure flag. Rejected: a browser-specific test hack that does not reflect production, is brittle per-origin, and proves nothing about the real https serving path.

Consequences

  • + The secure-context browser ES256 chain (COMP-05) becomes drivable end-to-end (MR-5), and no-ingress deployments gain a real in-process TLS option.

  • + No new crypto provider or rustls/tokio-rustls version: axum-server reuses the locked rustls 0.23 + tokio-rustls 0.26 + aws-lc-rs, so quality-budget B6 (dep dedup) is unaffected.

  • axum-server is a new direct dependency (justified above; cargo deny/machete clean).

  • rcgen + the self-signed path are compiled only under the dev self-signed-tls feature, so the feature-gated in-process TLS unit test (serve_tls → HTTPS /healthz 200 + rate-limit-over-TLS 429, proving ConnectInfo survives) is NOT part of the default pre-push battery (cargo nextest --workspace runs default features). It is run with cargo nextest -p craig-intake --features self-signed-tls; the DURABLE secure-context coverage is the MR-5 browser-crypto + visual e2e over the HTTPS instance.

  • Operators enabling in-process TLS set CRAIG_INTAKETLS_CERT_FILE + CRAIG_INTAKETLS_KEY_FILE (PEM paths). self_signed_tls is for dev/test only and refuses to run in a release build.

References

  • Plan: craig-intake coverage hardening (Step 11, #756).

  • ADR-017 (stateless edge), ADR-042 §D9 (standalone-SHINES signed API), ADR-043 (intake as the single public portal).

  • MDN — Secure contexts (WebCrypto crypto.subtle availability).

Edit this page · latest