tls: client certificates (mutual TLS) #117
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal
A
std/tlsclient can present a certificate and private key (mutual TLS), and astd/tlsserver can require and verify one. This is FIRST-PARTY.md gap 19 (Postgressslcert/sslkey, MySQL, MongoDB X.509 authentication, AMQPEXTERNAL). Design record:docs/design/NETWORK.mdsection 5 ("TLS, as built"); same shape as ALPN (#118) and the peer certificate (#116, #463).Decided by the loop
[loop:torbscript] decided by the loop: internal, additive API that follows the ALPN shape; no owner question.
tlsHandshake, answering a packedTORB_TLS_FAILED(isTlsFailure()) on misuse, liketorb_tls_offer:int64_t torb_tls_present(int64_t session, int64_t identity): client sessions only;mbedtls_ssl_conf_own_certwith the identity record (the session takes its own share of the identity's count, astorb_tls_serverdoes). Unknown handle, a server session, or a call after the handshake began: failure.int64_t torb_tls_require_client(int64_t session, torb_text roots): server sessions only; parsesroots(PEM, newline-joined liketrusted) into the session's trust list,mbedtls_ssl_conf_ca_chain,mbedtls_ssl_conf_authmode(..., MBEDTLS_SSL_VERIFY_REQUIRED). Empty or unparsable roots (any block unreadable): failure, never "required but trusts nothing". Client certificates are always verified against these roots, never the platform's store (a client certificate is issued by the server operator's own CA).tlsIdentity,tlsClose) are reused: an identity record is neutral.std/tls:public shared type ClientIdentity with Closewithstatic fn of(certificates: String, privateKey: String): Result<Self, NetworkError>, same contract asServerIdentity.of(overtlsIdentity),close(). A separate type, soServerIdentitykeeps its name and no published API breaks.TlsSettings.identity: ClientIdentity? = None:connectcallstlsPresentright aftertlsClient(andtlsOffer), before the handshake; the certificate is sent only if the server asks for one.TlsStream.accept(stream, identity, protocols: List<String> = [], clientRoots: List<String> = []): a non-emptyclientRootsmakes the client certificate mandatory and verifies it against exactly these roots. Empty is today's behaviour (no certificate requested). No "optional" mode: nobody in the gap list needs it; add it when a driver does.peerCertificate()(#463): on a server stream that required a client certificate it answersSome(der)of the client's leaf (the C function already returns the peer's certificate on either side). Documented here; no new API.acceptwith aNetworkErrorthatisTlsFailure()orisCertificateRejected()(whichever the existing mapping yields for mbedTLS's verify failure; the test pins it). The client sees the alert onconnect(TLS 1.2) or on its firstreceive(TLS 1.3, where the client's handshake completes first): an error, never a hang.compiler/srcimportsstd/tls(the ALPN precedent, #118), so stage 1 is built from the PR's own manifest and no seed refresh is needed between the commits. The seed itself is not touched. Seed impact: none (no syntax, no driver flag, no string-looked-up std name).Requirements
runtime/tls/tls.c,runtime/include/torb_tls.h): the two functions above. C tests inruntime/tests/tls_test.c: loopback client and server sessions with a client chain signed by the test root; handshake succeeds withpresent+require_client; fails withoutpresent; fails with a client certificate of another root;presenton a server session, on an unknown handle, after the first handshake step, andrequire_clientwith garbage roots all answer failure.tlsPresent,tlsRequire(parttls) incompiler/src/backend/c/natives.trb;runtime/include/torb_natives.handruntime/machine_natives.cregenerated withtorb natives --header(never by hand); two rows indocs/design/NATIVES.mdso the drift test stays green.std/tls/src/lib.trb: the natives,ClientIdentity,TlsSettings.identity,accept(..., clientRoots).tests/conformance/tls-loopback.trb, as PEM constants in the test, long validity (decades, as the existing ones).docs/standard-library/tls.md; FIRST-PARTY.md gap 19 marked done with the names, "Until then" column updated like gaps 13/14/20; regenerateskills/(torb docs skill docs skills). If #463 has landed, update itspeerCertificate()doc ("no client certificate is requested until #117") and its server-sideNonetest; if not, #463's server-sideNonecase stays valid for streams accepted withoutclientRoots.std/httpor the database drivers, an optional-client-certificate mode, certificate revocation, PKCS#12 / encrypted keys, SNI-based identity selection.Acceptance criteria
ClientIdentity.ofwith a valid chain and key succeeds; a key that is not the certificate's, or garbage, fails withisTlsFailure().TlsSettings(trusted: [root], identity: id)againstaccept(stream, serverIdentity, clientRoots: [root]): both handshakes succeed, data flows both ways.acceptfails (isTlsFailure()orisCertificateRejected()), and the client'sconnector firstreceiveerrors; no hang.clientRootsand a client with an identity: handshake succeeds and the certificate is ignored (today's behaviour); a server with noclientRootsand a client with none: unchanged (tls-loopback,dns-over-tls,https-exchangeunchanged and green).clientRootswith an unreadable PEM block:acceptfails before the handshake withisTlsFailure().--native(tier B conformance); the allocation budget of the new conformancetests/conformance/tls-client-certificate.trbis set from the first CI run, as fortls-loopback.skills/(--checkgreen) and FIRST-PARTY.md gap 19 updated as listed.Size
One PR, two Conventional Commits:
feat(tls): present a client certificate and require one on a server(runtime, natives, manifest, C tests) andfeat(tls): client certificates in std/tls(std API, conformance test, docs, gap 19). Not an epic.Dependencies
None blocking. Related, independent: #115 (upgradable transport), #463 (peer certificate; touches the same files, so whichever merges second rebases; see requirement 5). Duplicate search of the open issues (tls, certificate, mutual, X.509): none.
[loop:torbscript] intake: in scope (author orion, label loop), source FIRST-PARTY.md gap 19, not a duplicate. No dependencies. next: waiting (refine queued, ready buffer full) until 2026-10-10T04:00:00Z
TlsStream#116[loop:torbscript] claim refine by session:loop-torbscript-20261026 until 2026-10-26T02:00:00Z
[loop:torbscript] refine: ready, one PR in two commits (runtime functions
tlsPresent/tlsRequire+ std/tlsClientIdentity,TlsSettings.identity,accept(clientRoots:), tests, docs), decided by the loop (design in the body, no owner question), no seed refresh needed (ALPN precedent #118), no blocking dependency (#463 touches the same files; rebase note in the body). Tokens: ~0.25M. next: ready[loop:torbscript] claim implement by session:loop-torbscript-20261026 until 2026-10-26T07:00:00Z
[loop:torbscript] implement: checkpoint pushed to
loop/117-tls-client-certificates(commit7627213c, no PR yet), next: ready (continue implement; claim by session:loop-torbscript-20261026 released)Done (commit 1, runtime part, not yet formatted/regenerated):
runtime/tls/tls.c:torb_tls_session.is_server(set intorb_tls_server),torb_tls_present(client only, one identity per session, before the handshake; takes its own share of the identity count),torb_tls_require_client(server only, before the handshake; empty or partly unreadable roots drop the list and fail;ca_chain+VERIFY_REQUIRED).runtime/include/torb_tls.h: both declared and documented.compiler/src/backend/c/natives.trb: rowstlsPresent,tlsRequire.Remaining steps:
runtime/include/torb_natives.handruntime/machine_natives.cwithtorb natives --header runtime(alphabetical by C name, so every later thunk renumbers;compiler/tests/bytecode.test.trbandnatives.test.trbassert the files). No C compiler on the worker host and builds are CI-only: use the publishedtorb-nightly-*-linux-x64.tar.gz(unpack in its own fresh dir) and run it from the worktree astorb run compiler/src/main.trb natives --header runtime, or let CI show the drift.docs/design/NATIVES.md: two rows aftertlsOffer(line ~822),| tlsPresent | Runtime | keep | tls | - | - | - |andtlsRequire.runtime/tests/tls_test.c: loopback cases listed in requirement 1 (model on the ALPN case around lines 300-440).feat(tls): present a client certificate and require one on a server(the checkpoint commitwip(tls)is to be squashed/amended).std/tls/src/lib.trb(tlsPresent,tlsRequirenatives,ClientIdentity,TlsSettings.identity,accept(..., clientRoots)),tests/conformance/tls-client-certificate.trb(certs as PEM constants signed by the root oftls-loopback.trb), docs (NETWORK.md section 5,docs/standard-library/tls.md, FIRST-PARTY.md gap 19,torb docs skill docs skills). #463 has no PR and is not on main (peerCertificate()exists inruntime/from #116;std/tlsdoes not declare it yet).Closes #117),ci-wait.sh, fix red CI, request Argus.Worktree:
code/wt/torbscript-language-117(clean, pushed). Git push needs-c "http.extraHeader=Authorization: token $(cat ~/.secrets/torbdev-token)". Tokens: ~0.1M.[loop:torbscript] implement: checkpoint 2 pushed to
loop/117-tls-client-certificates(commit53389b7c, no PR yet), next: ready (continue implement; commit 2 and the PR remain)Done (commit 1 is complete and squashed,
feat(tls): present a client certificate and require one on a server):torb_tls_present/torb_tls_require_client(tls.c, torb_tls.h), manifest rowstlsPresent/tlsRequire.runtime/include/torb_natives.handruntime/machine_natives.cregenerated with the nightly-20261025 linux-x64torb run compiler/src/main.trb natives --header runtime(works without a C compiler, ~1 min; nightly unpacked intmp/torb-nightly-117/x/torb-nightly-20261025-linux-x64/bin/torb).docs/design/NATIVES.mdrows aftertlsOffer.runtime/tests/tls_test.c: mutual success (+ data both ways, server sees the client's leaf DER), no certificate, rogue root, server withoutrequire_clientignores a presented certificate, misuse (serverpresent, unknown handles, secondpresent,require_clienton a client / garbage / empty / partly unreadable roots / twice / after the handshake). Not compiled (CI only): the kind asserts for the refused handshakes accept 12..14 because the mbedTLS mapping is unverified.tls-loopback.trb) was not kept, so client certificates cannot be signed by it. New fixtures inruntime/tests/tls_certificates.h(made with openssl, P-256, valid until 2126, root keys destroyed):test_client_root+test_client_certificate/test_client_key(clientAuth EKU) andtest_rogue_root+test_rogue_certificate/test_rogue_key. The conformance test must reuse these PEMs (copy from that header asconsttexts) and the server side oftls-loopback.trb(testRoot, server cert/key) for the server identity.Remaining:
feat(tls): client certificates in std/tls:std/tls/src/lib.trb(tlsPresent,tlsRequirenatives next totlsOffer,ClientIdentityovertlsIdentity,TlsSettings.identity,accept(..., clientRoots)),tests/conformance/tls-client-certificate.trb(+ expected output, allocation budget from the first CI run, TLS 1.3 and 1.2 cases, no-identity / rogue / unreadable-roots cases), docs (NETWORK.md section 5,docs/standard-library/tls.md, FIRST-PARTY.md gap 19 done 2026-10-26,torb docs skill docs skillswith the nightly torb).Closes #117; mention the fixture deviation),ci-wait.sh, fix red CI (first real compile of the C code), request Argus. Never merge.Worktree
code/wt/torbscript-language-117(clean, pushed). Push:git -c "http.extraHeader=Authorization: token $(cat ~/.secrets/torbdev-token)" push. Tokens: ~0.1M (this worker).[loop:torbscript] implement: checkpoint 3 (final worker checkpoint) pushed to
loop/117-tls-client-certificates(commitwip(tls)on top of53389b7c, no PR yet), next: ready (continue implement; docs, squash, PR and CI remain)Done in the wip commit (to be squashed into
feat(tls): client certificates in std/tls):std/tls/src/lib.trb: nativestlsPresent/tlsRequire,ClientIdentity(overtlsIdentity),TlsSettings.identity,accept(..., clientRoots)(tlsRequire before the handshake, closes the session on failure).torb format --checkclean.tests/conformance/tls-client-certificate.trb(+.expected,.exit,.allocations): cases mutual, no identity, rogue root, two roots, not required (offered / none), unreadable / partly unreadable / empty roots,ClientIdentity.ofwith wrong key and garbage. PEMs copied fromruntime/tests/tls_certificates.h; server side fromtls-loopback.trb.torb check(nightly-20261025) is green; not run (nightly has notlsPresentthunk; builds are CI-only)..expectedis written from reasoning, not from a run: fix it from the first CI run..allocationsis a copy of theunbudgeted:text oftls-alpn; set the figure from the first CI run if it is stable, otherwise keep the text.std/tlshas no switch for the version, so a TLS 1.2 case is not reachable from a conformance test (the issue allowed this: alert path shown by the 1.3 refusals; the client treats a failingconnect,sendorreceivealike, so a 1.2 peer would print the same lines).Remaining:
docs/design/NETWORK.mdsection 5 "as built" paragraph (API, roots rule, failure behaviour, TLS 1.3 vs 1.2 alert timing, fixture deviation),docs/standard-library/tls.md,docs/design/FIRST-PARTY.mdgap 19 done 2026-10-26 (+ "Until then" column like gaps 13/14/20), thentorb docs skill docs skillswith the nightly torb (tmp/torb-nightly-117/x/torb-nightly-20261025-linux-x64/bin/torb) andtorb docs check.feat(tls): client certificates in std/tls, force-push with lease.Closes #117; mention the fixture deviation: old test root key not kept, new P-256 fixtures inruntime/tests/tls_certificates.h, and that TLS 1.2 is not exercised),ci-wait.sh, fix red CI (first real compile of the C code; the kind asserts for refused handshakes accept 12..14 intls_test.cand can be tightened once CI shows the mapping), request Argus. Never merge.Worktree
code/wt/torbscript-language-117(clean, pushed). Push:git -c "http.extraHeader=Authorization: token $(cat ~/.secrets/torbdev-token)" push.[loop:torbscript] claim implement (continuation 3) by session:loop-torbscript-20261026 until 2026-10-26T08:00:00Z
[loop:torbscript] release implement: the worker tool was unavailable in this tick, so continuation 3 did not start; branch loop/117-tls-client-certificates is pushed (checkpoint 3), next: ready (continue implement, docs, squash, PR and CI remain)
[loop:torbscript] claim implement (continuation 3) by session:loop-torbscript-20261026 until 2026-10-26T08:00:00Z
[loop:torbscript] implement: docs done (NETWORK.md section 5, std/tls page,
skills/, FIRST-PARTY.md gap 19 done 2026-10-26), wip squashed intofeat(tls): client certificates in std/tls, PR opened: #490 (Closes #117). First CI run (e77eb326): bootstrap, commit messages, pdisc green; tier B red on one thing only:tls-loopbackallocated 517 blocks over its budget of 505 (the newTlsSettings.identityfield);tls-client-certificatepassed in the VM suite with its.expectedas written, so no fix needed there. Fixed in9c353e8e(test(conformance): raise tls-loopback allocation budget to 517), pushed. Tier A,docs check, gates and the remaining jobs of the first run did not finish before that push (it restarts CI).docs checkwith the previous nightly reports the new natives as unknown (expected, the nightly predates them); CI decides. Not done: tightening the kind asserts inruntime/tests/tls_test.c(no CI output shows the mapping yet) and a figure fortls-client-certificate.allocations(still theunbudgeted:text, CI does not print a figure for it). ~300k tokens this continuation.Remainder for the next tick: (1)
ci-wait.sh -C code/wt/torbscript-language-117on9c353e8e, fix whatever is red on the same branch (tier A / gates /docs checkare the unseen ones); (2) tighten the kind asserts (12..14) intls_test.cif the logs show the mapping; (3) request Argus's review on PR 490 once CI is green. Never merge.next: waiting (CI on PR 490, commit
9c353e8e), then review[loop:torbscript] release implement (continuation 3): worker finished; PR 490 head
9c353e8eis in the forge CI queue, next: waiting (CI on PR 490; then fix findings if red, tighten tls_test.c kind asserts if logs show the mapping, request Argus review)