Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Certificates / TLS Backlog

Bounded implementation slice chain for the certificates/TLS track. It decomposes Certificates and TLS into dispatchable slices and is owned by the Certificates / TLS track on the loopyard task board. The dispatchable records live in loopyard; this file is the long-form decomposition and sequencing rationale.

Grounding

  • Certificates and TLS – the schema surface and Phase 1-9 ordering. Phases 1-2 are the near-term target; Phase 1 is Certificate / CertificateChain / TrustStore / CertVerifier over a RAM-only webpki-roots store and a rustls-webpki verifier. The Phase 2 client local proof completes a TLS 1.3 handshake over a userspace-served TcpSocket cap with embedded-tls, and the Phase 2 server core now terminates real traffic in the local QEMU Web UI proof (slice 4). The capnp TlsServerConfig / cert-source service surface remains future Phase 2 work: the landed consumer composes its chain and key in-process rather than through a served config cap.
  • Cryptography and Key Management – partial implementation. The minimal SymmetricKey, PrivateKey, and PublicKey ABI, RAM-only XChaCha20+HMAC/P-256 key cores, RAM-only KeyVault handle custody, and development-only software KeySource bootstrap exist for local proofs. There is still no persistence or production custody source, so production/public TLS and ACME remain blocked on a reviewed source that can mint key handles without exposing raw private-key material.
  • Time and Clock AuthorityWallClock Phase 1 landed (88cf4b5d): a read cap with wallTime and a ClockProvenance label, but the fixed-boot-base source reports Untrusted. Cert-validity (notBefore/notAfter) and OIDC exp/iat compare against it. Host-tested verify logic passes an explicit atEpochSeconds and needs no live clock; security-grade validity against an adversarial clock wants the trusted-provenance upgrade (WallClock Phase 2). Recorded as a sequencing dependency on the live consumer slices, not on the host verifier slice.
  • Phase C Userspace NIC Driver Relocation – the userspace TcpSocket cap the TLS stack wraps arrives via Phase C slice-7 (cloud-prod-userspace-network-stack-smoltcp-local-proof). The TLS stack is a userspace consumer of that cap and must not move into the kernel.

Sequencing Rationale

The proposal’s suggested shape (library -> handshake -> cert caps -> consumer) is reordered to land the lowest-risk real logic first, grounded in what exists:

  • The verifier path (TrustStore + CertVerifier over webpki-roots) needs no socket and no private key – it is pure no_std + alloc host-testable logic. It lands before the handshake.
  • A TLS client handshake needs a TcpSocket cap but no private key.
  • A TLS server needs a KeyVault-issued PrivateKey handle and a server cert source. The 2026-07-16 audit split this: the server handshake core is pure host-testable logic that needs no socket and no production custody (it reaches the key only through a signer seam), so it landed host-first like the verifier. The local QEMU consumer now terminates real traffic; the public direct-termination consumer remains dependency-blocked by the on-hold provider-terminated public proof, with production key custody and a real certificate source also required.

Slice Chain

#TaskProposal phaseStatusDepends on
1cloud-tls-vendor-rustls-webpki-roots-no-std-provenancePhase 1 depsdone
2cloud-tls-cert-truststore-certverifier-phase1-host-proofPhase 1doneslice 1
3cloud-tls-client-handshake-over-tcpsocket-local-proofPhase 2 (client)doneslice 2 + Phase C slice-7 socket cap
3scloud-tls-server-handshake-core-host-proofPhase 2 (server core)doneslice 3 + K3
4cloud-tls-webui-terminated-endpoint-local-qemu-proofPhase 2 (server) local consumerdoneslice 3s + Phase C slice-7 socket cap
4pcloud-tls-self-hosted-webui-terminated-endpointPhase 2 (server) public consumerblockedslice 4 + key-cap surface + provider-terminated GCE Web UI proof
K0crypto-key-custody-tls-acme-decompositionkey-management precursordone
K1crypto-privatekey-publickey-ram-signing-local-proofkey Phase 1 subsetdoneslice 2
K2crypto-keyvault-ram-privatekey-custody-local-proofkey Phase 2 subsetdoneK1
K3crypto-development-keysource-tls-acme-bootstrap-local-proofkey source local bootstrapdoneK2
5cloud-tls-acme-account-order-local-proofPhase 3 (ACME core)doneslice 3 + K3
6cloud-tls-acme-http01-challenge-solver-local-proofPhase 3 (http-01)doneslice 5 + Web UI L4 path
7cloud-tls-acme-renewal-certstore-rotation-local-proofPhase 3 (renewal)doneslices 4-6
8cloud-gce-public-webui-letsencrypt-direct-termination-proofpublic GCE successorblockedprovider proof + slice 7 + public DNS/authorization
  1. Vendor the Phase-1 verifier crates. rustls-webpki + webpki-roots as static-pinned no_std+alloc snapshots with VENDORED_FROM.md provenance, recorded under docs/trusted-build-inputs.md, proved to build for the bare-metal target. Slice 3 later selected embedded-tls for the local client proof’s no_std TLS state machine; the broader server/config service stack remains future work.

  2. Certificate / TrustStore / CertVerifier (Phase 1). Schema additions plus host-tested verify logic over rustls-webpki seeded by webpki-roots, with chain verification proved against committed good/bad vectors and explicit atEpochSeconds. No running cap service, no socket, no key – the lowest-risk real cert logic.

  3. Client TLS handshake over TcpSocket (Phase 2, client-only). Done 2026-06-08. A userspace process completes one TLS 1.3 client handshake over the Phase C userspace TcpSocket cap, validating the peer chain with the slice-2 verifier, with an observable local QEMU proof. The no_std determination selected a vendored embedded-tls 0.19.0 client state machine for this local proof rather than full rustls. 3s. Server-side handshake core (host proof). Done 2026-07-16. Split out of slice 4 by the 2026-07-16 backlog audit, which found that the local server-side path had no remaining technical prerequisite: only the server-side TLS state machine itself was missing, because the vendored embedded-tls is client-only. capos-tls now carries a no_std + alloc TLS 1.3 server handshake core (tls13/), bounded to TLS 1.3, TLS_AES_128_GCM_SHA256, secp256r1, and ecdsa_secp256r1_sha256, with no mTLS, resumption, 0-RTT, or HelloRetryRequest. Server key authority stays behind a TlsServerSigner seam whose in-tree implementations are the landed PrivateKey cap types; the core has no raw-key path. The host proof completes a full handshake and application-data round trip against the vendored embedded-tls client over an in-memory transport (cargo test-tls). It owns no socket and no served cap, so it grants no production or public TLS authority.

    The RFC 8446 §6 alert path landed 2026-07-17 10:38 UTC as a follow-up (cloud-tls-server-alert-path): the core sent no alerts, so a peer saw a bare TCP close where a handshake_failure or a close_notify was owed. The core now builds the fatal alert a failed feed owes and emits close_notify on clean shutdown, protected under whatever epoch the server currently writes and plaintext only before the ServerHello reaches the peer. The error-to-description map is a disclosure decision, not a formatting one, so the bounded-surface refusals collapse onto one handshake_failure and deprotection failure says only bad_record_mac (RFC 8446 §5.2). Sending the bytes is the consumer’s move – the core owns no transport – so a consumer that drops them still shows a peer the old bare close.

    The in-tree consumer was wired to that path 2026-07-17 (cloud-tls-webui-alert-path-consumer-wiring), which is what carries it to the wire: remote-session-web-ui writes take_alert() before abandoning a failed handshake, and close_notify() when it closes. Both writes are best-effort and share the connection deadline, so neither can extend a connection past the bound it already had or turn an abort into another outcome. make run-cloud-tls-webui-terminated proves both from the host client – a refused hello reports a fatal handshake_failure, and a completed exchange closes cleanly – each verified against the unwired code.

    close_notify is owed wider than the served response: RFC 8446 §6.1 requires it before every close of the write side except after an error alert, so a connection abandoned mid-request (read deadline expired, oversized or unparseable request) is owed one too – it sent no alert. The consumer’s abort path therefore offers it and the core answers, refusing for any connection that never completed a handshake or already sent its fatal alert, which is precisely §6.1’s exception. The attempt is one bounded write, never a retry loop: an abandoned connection’s expired deadline discards whatever of it did not leave, so the alert cannot extend the bound that deadline already put on the slot. Transport and ring faults do not attempt it at all – there the write is already known to be impossible. What the QEMU proof (make run-cloud-tls-webui-terminated) checks is that the peer receives it on the refused-request and read-deadline aborts, both verified against the unwired code; delivery after a peer has already stopped reading is attempted, not claimed.

    The ClientHello parser was made to enforce supported_groups 2026-07-18 13:05 UTC (cloud-tls-client-hello-supported-groups-conformance), closing the one case the alert-path slice recorded that did not abort at all: the extension was never parsed, so a hello that omitted it entirely – which RFC 8446 §9.2 forbids of any client offering ECDHE, and requires a server to abort on – silently completed a handshake off the key_share alone. The parser now requires the extension to name secp256r1, treating “absent” and “names only groups we do not implement” as one verdict, since the bounded surface has no HelloRetryRequest to answer either with. The refusal joins the existing collapse onto handshake_failure rather than sending the missing_extension §9.2 names, which would report which bound refused the hello; that trade was already made and reviewed for the key_share and signature_algorithms absent cases. Those cases, and now this one, still abort under a description §9.2 does not name, so the parser is not §9.2-alert conformant and is not claimed to be – the disclosure collapse is the deliberate reason. Both real clients the repo proves against – vendored embedded-tls and OpenSSL – send the extension, so enforcement cost no interop.

  4. capOS-terminated Web UI endpoint (local QEMU consumer). Done 2026-07-16. remote-session-web-ui serves the Web UI over a TLS 1.3 endpoint it terminates itself, wiring slice 3s’s core to the Phase C userspace TcpSocket cap. Termination is a boot-manifest decision: a tls_terminated_endpoint marker makes the network stack forward the marker and grant an EntropySource. The service refuses to boot if it ever sees one without the other, or a request for termination in a build that links no TLS stack. Those three refusals, and the cap-id ordering contract they rest on (entropy at the slot immediately after the required service caps, every policy marker strictly after it), are decided by evaluate_service_capset_policy in the web-ui’s host-testable policy library and covered by the refusal matrix in make webui-login-peer-logic-test. The spawning network stack asserts the grant order it builds against the same shared constants, so a reorder there fails at its source rather than only as a guest boot refusal under whichever posture a smoke happens to boot. Proof make run-cloud-tls-webui-terminated: a host OpenSSL self-test client over user-net hostfwd completes the handshake, pins the served leaf against the fingerprint the guest logged, fetches a Web UI response over the terminated connection, and confirms plaintext HTTP is refused on the same port. The plain-HTTP L4 path and its proofs are unchanged; the TLS stack is a default-off tls-endpoint feature, so that build links none of it.

    Two bounds this proof does not clear. The chain is a boot-provisioned self-signed development certificate over a runtime-minted key (issue_development_self_signed_certificate): a vault-minted PrivateKey refuses export, so no certificate provisioned ahead of time could match it, and nothing here chains to a public root. Its validity window is a fixed constant rather than a trusted-clock read, because WallClock provenance is still Phase 1 Untrusted. Public trust and real validity come from ACME issuance (slices 5-7), not from this path.

4p. Public direct-termination Web UI endpoint (terminal consumer). Serves the Web UI over capOS-held TLS as a direct-termination successor after the first GCE public Web UI proof closes through provider-terminated HTTPS. Deeply blocked: needs the local proof above plus a production key-custody source and a real cert source (ACME / provisioned). 5. Minimal key-custody decomposition. Done. It decomposes the missing PrivateKey / KeyVault / KeySource subset into the three implementation records below, keeping production hardware/cloud custody out of the local TLS/ACME bootstrap. 6. PrivateKey / PublicKey RAM signing proof. Done 2026-06-04. Adds the minimal asymmetric-key ABI and host-tested RAM signing core: sign/public/info, public verify/export/info, purpose metadata, and no raw private-key export. 7. RAM KeyVault custody. Done 2026-06-05. Adds handle-based key generation/open/list/destroy and a local QEMU proof for TLS and ACME account key handles, still RAM-only and not production custody. 8. Development-only KeySource bootstrap. Done 2026-06-05. Grants local proofs a development software key source that mints key handles without putting raw private keys in manifests, images, logs, task records, or evidence, and is rejected for production/public profiles. 9. ACME account/order local proof. Done 2026-06-08. capos-tls now has a no_std+alloc ACME account/new-order/CSR-finalize/certificate-retrieval core, with ES256 JWS signing through AcmeAccount PrivateKey caps and CSR signing through a separate TLS-purpose PrivateKey cap. Challenge validation stays fake or pre-authorized here; the proof does not call Let’s Encrypt staging or production. 10. Scoped http-01 solver. Done 2026-06-09. capos-tls adds a bounded, token-scoped Http01ChallengeSolver and the RFC 8555 http-01 authorization flow (pending order, authorization fetch, key-authorization derivation via the RFC 7638 account-key thumbprint, challenge response, out-of-band validation, and cleanup). remote-session-web-ui serves only /.well-known/acme-challenge/<token> for currently-published tokens through that same solver; retired, unknown, sub-path, and traversal tokens fail closed (404). The host ACME http-01 test proves the protocol and cleanup; the Web UI L4 QEMU proof fetches the challenge through the served origin. It grants no generic route, static-file, DNS, or Web UI authority and adds no public CA call. 11. CertificateStore.watch renewal and rotation. Done 2026-07-17. capos-tls adds the bounded RAM CertificateStore (a stable handle names the current chain; put replaces it in one step and notifies watch subscribers; get resolves only inside the stored chain’s validity window, so unknown, deleted, expired, and not-yet-valid handles fail closed) and RenewalPolicy, the pure “is it due” decision that fires a lead before notAfter and distinguishes Due from Expired. A store entry holds public certificate bytes only, which is what keeps a chain rotation from being a key rotation. The host proof (cargo test-tls) drives a full local ACME order into the store, reaches the renewal threshold, orders a second distinct chain, and resolves it back out with a watcher observing the rotation and the same key still signing. The Web UI TLS endpoint serves in epochs – one resolution per epoch, re-resolved periodically and again at each admission, latched into draining by the first check that finds the chain no longer current or the clock unreadable – and renews at the halfway mark of a short-lived window once the table has drained. The QEMU proof (make run-cloud-tls-webui-terminated) pins the provisioned leaf before the threshold and a different leaf after it, on a service whose provisioning lines occur exactly once, drives that rotation under overlapping load (two staggered holder connections plus the polling client), and requires the endpoint to settle on the one renewed chain over further polls rather than stopping at the first leaf that differs.

An accept that completes after its epoch has stopped being fit to serve it is carried into the next epoch rather than refused inside the stale one. The epoch that latched into draining – or that found its chain no longer current at the moment of admission – hands the completed socket to a boot-scoped holder outside the epoch loop, keeping one stable accept id and the original absolute read deadline, and stops submitting accepts while it is occupied, so at most one is ever held. The next epoch renews and re-resolves first, then admits that same socket, or closes it fail-closed if its original deadline has expired, the newly resolved chain is still not servable, or – the case re-resolution alone does not catch – the newly resolved generation is not strictly greater than the one the socket was deferred under. An unreadable clock is owned by the first of those paths: without a clock the loop cannot prove any deadline remains, so it consumes the carried socket through the bounded expiry close before asking a chain-freshness question. That last generation condition is the one that makes the crossing mean something: a renewal that fails leaves the old generation still resolvable and still current, so a rule that asked only “is this chain the one the store would hand out” would readmit the socket onto the very chain it was carried away from. The deferral generation is therefore stored on the carried socket and compared at readmission, in runtime code rather than in an after-the-fact log assertion. This is deliberately availability-conservative for every deferral reason: an EpochDraining carry that reaches the next epoch without a renewal is closed for an unadvanced generation even when that generation remains otherwise current. A carried socket is never carried twice: the second unservable epoch closes it once, through the same bounded refusal path every other refusal takes, which is what makes this a single boundary crossing rather than a retry loop. No TLS state is created under the old chain, since the decision happens before any handshake starts.

The QEMU proof for that crossing (make test-cloud-tls-webui-deferred-accept) uses a default-off deferred-accept-proof build with two levers. Each is explicitly classified as an unauthenticated LoginPost, whose Host / Origin / Referer / principal-hint / JSON-content-type chain matches a state-changing request without claiming the handler will call require_active. The client sends the loopback TLS authority it actually dialed – Host with the port and https:// Origin and Referer – and the QEMU SLIRP gateway is accepted only by the peer-gated loopback posture it already holds; no guard is relaxed for the proof. /api/probe/defer-next-accept arms one admission cutoff and forces the paired renewal; the harness then asserts an ordered record tied to one accept id and one carried deadline: deferred under generation N, a renewal strictly between, readmitted under a generation strictly greater than N with the same id, the same deadline, and the same recorded source generation, and a handshake completed by that same carried socket naming all four – with no refusal in between. The handshake proof context owns that original deadline; it never re-reads the connection field that changes to a send deadline once a response begins.

/api/probe/defer-next-accept-no-renew arms the same cutoff and withholds the renewal, which is what makes the strictly-greater comparison a gated invariant rather than a reviewed one. Every other path in the proof advances the generation, so a non-strict comparison would behave identically there and pass; under this lever the next epoch resolves the same generation, and the harness requires exactly one bounded close naming that accept id, that unchanged deadline, and reason=generation-not-advanced, with no admission, no handshake, and no second deferral for it. Mutating the comparison to accept equality fails this assertion.

Neither lever changes a certificate lifetime or a renewal policy; they only choose when an already-implemented cutoff fires and whether the renewal it would have triggered happens. Production builds contain neither route, neither static, nor the per-connection field the handshake marker uses.

The real-expiry trigger is covered separately by make test-cloud-tls-webui-epoch-drain-latch. Its default-off tls-epoch-drain-latch-proof profile leaves the ordinary 120-second lifetime, one-half renewal fraction, and 5-second chain-check interval unchanged. For this focused run it selects a 30-second lifetime, a 1/31 renewal fraction, and a 40-second periodic check. Integer arithmetic makes the renewal lead zero; provisioning also refuses to start unless RenewalPolicy::due_at equals the stored chain’s notAfter. A policy arithmetic change therefore cannot silently turn the proof back into an early-renewal run.

The harness starts after the initial leaf is announced, keeps staggered TLS holders and completed /healthz admissions overlapping through expiry, and waits for the exact chain-not-current-at-admission latch rather than a prefix shared with another drain cause. It then releases the old epoch’s holders and requires one ordered crossing tied to the same accept id, absolute deadline, and source generation: admission-time deferral, one renewal strictly after the latch and before readmission, readmission on a strictly newer generation, and handshake completion by that carried socket. The host observes a different leaf on the carried connection, a 200 health response, and close_notify. A readmission close for that accept, renewal before the latch, an unreadable clock, or a second provisioning fails the proof.

This closes the local trigger-and-consequence gap without claiming a trusted validity clock or public trust. The clock remains the monotonic-offset proof clock. The later in-VM issuance proof executes the local ACME order path at this renewal seam; both the initial and locally issued chains remain development-signed and grant no public trust. 12. Public GCE Let’s Encrypt direct-termination proof. A separately reviewed successor after the provider-managed first public proof. It requires a public DNS name controlled for the run, explicit billable/public-ingress authorization, and explicit authorization before any Let’s Encrypt production call; staging remains the default external CA target.

Let’s Encrypt / ACME Public TLS Decomposition

Let’s Encrypt support is implementable for the public TLS milestone only as the capability-native, capOS-terminated successor path. It is not the already selected closeout path for the first public GCE Web UI proof. That first proof continues to terminate HTTPS at the GCP external load balancer with a provider-managed certificate, no capOS private-key custody, and no raw public HTTP closeout.

The missing prerequisites are represented as named task records:

Local proofs and public CA/cloud proofs stay distinct. The ACME account/order, challenge, and renewal slices use a local RFC 8555-compatible directory and local QEMU/cloudboot paths. A public GCE/Let’s Encrypt run requires a separately authorized harness mode, a controlled public DNS name, public-ingress teardown evidence, and no private key material in manifests, images, logs, task records, or evidence directories.

Next Gap

Slices 1 and 2 landed on 2026-06-03: rustls-webpki and webpki-roots are vendored as static-pinned no_std+alloc snapshots, and capos-tls contains the Phase 1 Certificate / TrustStore / CertVerifier host verifier proof over those crates. K1 landed on 2026-06-04: capos-tls also contains the minimal RAM-only P-256 PrivateKey / PublicKey signing core. K2 landed on 2026-06-05: RAM-only KeyVault generation/open/list/destroy handle custody for those keys. K3 landed on 2026-06-05: local development software KeySource bootstrap now mints TLS and ACME account key handles without raw private-key material in manifests or evidence and rejects production/public profiles. Capability-infrastructure key-cap reconciliation landed on 2026-06-06: the minimal RAM-only SymmetricKey ABI and local AEAD/MAC proof now exist. Slice 3 landed on 2026-06-08: the local QEMU proof now completes one TLS 1.3 client handshake over a userspace-served TcpSocket cap and validates the peer chain with capos-tls. ACME slice 5 landed on 2026-06-08: capos-tls now proves account registration, order creation, CSR finalize, and returned-chain parsing against a local RFC 8555-style directory using purpose-scoped key caps. ACME slice 6 (proposal item 10) landed on 2026-06-09: the scoped http-01 solver now serves bounded /.well-known/acme-challenge/<token> responses through remote-session-web-ui, with the http-01 authorization/validation/cleanup flow proven host-side and the served route proven in the Web UI L4 QEMU proof. The renewal and certificate-store rotation slice (proposal item 11; table slice 7) landed on 2026-07-17 02:40 UTC with the bounded RAM CertificateStore, RenewalPolicy, epoch-serving Web UI endpoint, deferred-accept crossing, and epoch-drain latch. The in-VM issuance path proven in QEMU on 2026-07-26 12:02 UTC: the Web UI process keeps the boot-time self-signed chain as generation 1, then drives account registration, order creation, account-derived http-01 solver publication and local validation, CSR finalize, returned-chain parsing, and atomic store installation against the local RFC 8555-compatible directory. The scoped HTTP route remains separately proven with its boot fixture; the local directory validates the order authorization directly through the same solver while the single-threaded service is at its quiescent renewal boundary, so this proof does not claim an external CA HTTP fetch. The QEMU proof ties generation 2 to the leaf observed on the wire and, in its default-off failed-order mode, separately drives a complete order whose malformed returned chain fails parsing, observes the generation-1 leaf still served over TLS, and then retries successfully. Its clock remains the monotonic-offset proof clock, both chains remain development-signed, key custody remains RAM-only, and no public CA, DNS, ingress, provider action, or public-trust authority is involved. The next ACME gap is the public GCE Let’s Encrypt direct-termination proof (proposal item 12; table slice 8), which remains on hold behind the provider-terminated public Web UI proof, the public direct-termination consumer, controlled public DNS, and explicit public-ingress and CA authorization.

The local QEMU Web UI TLS consumer (slice 4) is landed. It starts with a development self-signed chain over a runtime-minted key and rotates to the local-directory order result, still over a fixed proof-clock validity window because WallClock provenance remains Phase 1 Untrusted; it grants no production or public-trust authority. The remaining server-side TLS consumer gap is the public direct-termination successor (slice 4p), blocked on cloud-gce-public-self-hosted-webui-ingress-tls closing with provider-terminated HTTPS, production key custody, and a real certificate source. The key-custody local-proof precursor is complete for PrivateKey / PublicKey, RAM KeyVault, and development KeySource; production custody remains future.