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.subtleES256 API.crypto.subtleis only exposed in a https://[secure context] (https +localhost). The e2e topology reaches the standalone-SHINES instance athttp://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
opensslcrate 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_ipoverConnectInfo<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: booltoggle, whentrue, generates an ephemeral self-signed cert at boot viarcgen. The generation path AND thercgendependency are behind theself-signed-tlsCargo feature (devstack image only), so neither enters a release build. With the feature off,validate()REFUSESself_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 aninto_make_service_with_connect_info::<SocketAddr>()(soConnectInfosurvives) and supports graceful shutdown via a clonedHandle, 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’srustls-tls), and axum-server’stls-rustlsfeature selectsrustls/aws-lc-rs— the same provider, no new subtree. craig-intake installs it idempotently as the process default before resolving TLS (anErrmeans another component already installed it). -
Separate HTTPS e2e instance. MR-5 added a new
craig-intake-standalone-shines-tlscompose service (default host port 8013, built--features self-signed-tlsvia theCRAIG_INTAKE_FEATURESbuild arg,CRAIG_INTAKE__SELF_SIGNED_TLS=true); the existing plain-HTTP:8011instance,.ports.env, and every current Rust/e2e test are left untouched. The secure-context flows run only against the new instance, under the Playwrightintake-ui-shines-tlsproject (HTTPS baseURLCRAIG_INTAKE_SHINES_TLS_URL+ignoreHTTPSErrors):intake-edge-crypto.spec.ts(the real browser keygen+sign chain, COMP-05) andintake-edge-visual.spec.ts(visual baselines, COMP-06).
Alternatives considered
-
axum-server(chosen). PreservesConnectInfo+ graceful shutdown out of the box; pins the same rustls/tokio-rustls already in the lock; small, widely-adopted, actively maintained. -
Hand-rolled
tokio_rustls::TlsAcceptoraccept loop (the sibling IMTN project’s approach). Rejected for craig-intake: a bare accept loop yields aTlsStreamwith noConnectInfo, so the rate-limit/IP-hash layer would silently key every request on a missing/zero address unlesspeer_addr()is threaded into a per-connectionConnectInfoextension by hand — exactly the silent rate-limit breakage the constraints forbid. More code, more footgun, no benefit here. -
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.
-
Chrome
--unsafely-treat-insecure-origin-as-secureflag. 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-serveris a new direct dependency (justified above;cargo deny/macheteclean). -
−
rcgen+ the self-signed path are compiled only under the devself-signed-tlsfeature, so the feature-gated in-process TLS unit test (serve_tls→ HTTPS/healthz200 + rate-limit-over-TLS 429, provingConnectInfosurvives) is NOT part of the default pre-push battery (cargo nextest --workspaceruns default features). It is run withcargo 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_tlsis 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.subtleavailability).