tls: ALPN #118

Closed
opened 2026-10-05 21:07:22 +00:00 by orion · 8 comments
Collaborator

Context

HTTP/2, gRPC, Postgres 17's direct TLS. Listed among what std is missing for the first-party packages.

Source

Acceptance criteria

  • Built in the package FIRST-PARTY.md names
  • Tests
  • FIRST-PARTY.md gap 20 marked done

Dependencies

None known.

Moved from the planning lists of the docs; the design record stays the specification.

## Context HTTP/2, gRPC, Postgres 17's direct TLS. Listed among what `std` is missing for the first-party packages. ## Source - [FIRST-PARTY.md gap 20](https://git.torb.dev/torbscript/language/src/commit/393ef2608e4a60b8ff76d8b6b408951b822d887d/docs/design/FIRST-PARTY.md#L280) ## Acceptance criteria - [ ] Built in the package FIRST-PARTY.md names - [ ] Tests - [ ] FIRST-PARTY.md gap 20 marked done ## Dependencies None known. _Moved from the planning lists of the docs; the design record stays the specification._
Author
Collaborator

[loop:torbscript] intake: in scope (author orion, label loop), source FIRST-PARTY.md gap 20, not a duplicate. No dependencies. next: waiting (refine queued, ready buffer full) until 2026-10-10T04:00:00Z

[loop:torbscript] intake: in scope (author orion, label loop), source FIRST-PARTY.md gap 20, not a duplicate. No dependencies. next: waiting (refine queued, ready buffer full) until 2026-10-10T04:00:00Z
Author
Collaborator

[loop:torbscript] claim refine by session:loop-torbscript-20261024 until 2026-10-24T07:39:00Z

[loop:torbscript] claim refine by session:loop-torbscript-20261024 until 2026-10-24T07:39:00Z
Author
Collaborator

[loop:torbscript] refine: requirements for #118 (read-only analysis of main 5d7f790; source FIRST-PARTY.md gap 20, NETWORK.md section 5 "TLS, as built")

State today. std/tls/src/lib.trb has TlsSettings(trusted), ServerIdentity, TlsStream.connect/accept over twelve natives of runtime/tls/tls.c (declared in runtime/include/torb_tls.h, manifest in compiler/src/backend/c/natives.trb tlsEntries). mbedTLS 3.6.7 is vendored with MBEDTLS_SSL_ALPN already on, and no ALPN call is made anywhere. Postgres 17 direct TLS, h2 and so gRPC (gaps 20, 25; #72) wait on this.

Decided by the loop (internal design, additive, follows the existing shape; no owner question): ALPN is two new natives, so no existing native or TlsClient/tlsServer signature changes. The offered list is per session and set before the handshake; a list is protocol names in preference order, crossing as text separated by \n like trusted does.

Seed: adds natives, so it is a "new native" in the sense of CLAUDE.md, but nothing in compiler/src imports std/tls (only package/archive.trb names it as a capability string), so the PR is two commits and no seed refresh is needed between them: stage 1 is built from the PR's own manifest.

Requirements

  1. Runtime, runtime/tls/tls.c, torb_tls.h:
    • int64_t torb_tls_offer(int64_t session, torb_text protocols): sets the session's ALPN list (client: offered; server: accepted, in server preference order) via mbedtls_ssl_conf_alpn_protocols. Called before the first tlsHandshake. The session owns a NULL-terminated copy of the names that lives until torb_tls_session_free. A name that is empty or longer than 255 bytes, or a call after the handshake began, answers a packed TORB_TLS_FAILED (isTlsFailure()), never truncates. Empty protocols is a no-op (today's behaviour).
    • torb_text torb_tls_negotiated(int64_t session): mbedtls_ssl_get_alpn_protocol, empty text when none was chosen.
  2. Manifest and natives of compiler/src/backend/c/natives.trb (tlsOffer, tlsNegotiated, both part tls); runtime/include/torb_natives.h and runtime/machine_natives.c regenerated with torb natives --header (never by hand); two rows in docs/design/NATIVES.md (keep, tls) so the drift test stays green.
  3. std/tls:
    • TlsSettings.protocols: List<String> = [] (client offer, preference order).
    • TlsStream.accept(stream, identity, protocols: List<String> = []) (server accepts the first of its own list that the client offers).
    • TlsStream.negotiatedProtocol(): String?: Some("h2") etc. after the handshake, None if neither side offered or the server chose none.
    • Both connect and accept call tlsOffer right after creating the session and fail with the NetworkError of the native's failure. Existing callers (std/http, std/dns) compile unchanged.
  4. Docs: docs/design/NETWORK.md section 5 "as built" gets a short ALPN paragraph (API, no-overlap behaviour); docs/standard-library/tls.md; FIRST-PARTY.md gap 20 marked done with the names, "Until then" column updated like gaps 13/14; regenerate skills/ (torb docs skill docs skills, --check is a tier A gate).
  5. Out of scope (own issues): using ALPN in std/http's client or server, HTTP/2 (#72), HTTPS/SVCB hints (#71), the Postgres sslnegotiation=direct driver (torb/postgres).

Acceptance criteria

  • Offer ["h2", "http/1.1"] against a server accepting ["http/1.1"]: both sides' negotiatedProtocol() is Some("http/1.1"); server preference wins when both match.
  • Client offers, server has no list: negotiatedProtocol() is None on both sides and data still flows (RFC 7301).
  • No list on either side: None, identical behaviour and handshake to today (existing tls-loopback, dns-over-tls, https-exchange unchanged and green).
  • Both sides have lists without an overlap: the handshake fails with NetworkError that isTlsFailure() (no_application_protocol), on the server at least, and the client's connect fails or sees the alert, no hang.
  • A single "postgresql" offer negotiates Some("postgresql") (the Postgres 17 case).
  • An empty name or a name over 255 bytes is refused by connect/accept with isTlsFailure(), no truncation.
  • Runs the same in the VM and --native (tier B runs the conformance suite in both).
  • FIRST-PARTY.md gap 20 marked done; NETWORK.md, docs/standard-library/tls.md and skills/ updated and --check green.

Tests

  • runtime/tests/tls_test.c: torb_tls_offer/torb_tls_negotiated on a client/server pair (match, no overlap, empty and 256-byte name, offer after handshake start).
  • New conformance tests/conformance/tls-alpn.trb + .expected/.exit/.allocations (the allocation budget set from the first CI run, as for tls-loopback), reusing the test root, certificate and key of tests/conformance/tls-loopback.trb: the five negotiation cases above over loopback, both sides in one program.
  • compiler natives drift test (bfafbd8): green with the two new rows.

Commits (one PR, about 1 h)

  1. feat(runtime): ALPN natives for TLS sessions: tls.c, torb_tls.h, manifest, regenerated torb_natives.h and machine_natives.c, NATIVES.md rows, tls_test.c. Says "new natives, no std change yet" like 5dd2b9a.
  2. feat(tls): ALPN, offered and negotiated protocols: std/tls, conformance test, docs, FIRST-PARTY.md gap 20, regenerated skills/.

Gates: sh tools/gates.sh a, and b (touches runtime/), in forge CI only. Implementer: sonnet/high (runtime/). Dependencies: none (#115, #116, #117 touch the same files, rebase conflicts in lib.trb/tls.c only).

[loop:torbscript] refine: requirements for #118 (read-only analysis of main 5d7f790; source FIRST-PARTY.md gap 20, NETWORK.md section 5 "TLS, as built") **State today.** `std/tls/src/lib.trb` has `TlsSettings(trusted)`, `ServerIdentity`, `TlsStream.connect/accept` over twelve natives of `runtime/tls/tls.c` (declared in `runtime/include/torb_tls.h`, manifest in `compiler/src/backend/c/natives.trb` `tlsEntries`). mbedTLS 3.6.7 is vendored with `MBEDTLS_SSL_ALPN` already on, and no ALPN call is made anywhere. Postgres 17 direct TLS, `h2` and so gRPC (gaps 20, 25; #72) wait on this. **Decided by the loop** (internal design, additive, follows the existing shape; no owner question): ALPN is two new natives, so no existing native or `TlsClient`/`tlsServer` signature changes. The offered list is per session and set before the handshake; a list is protocol names in preference order, crossing as text separated by `\n` like `trusted` does. **Seed:** adds natives, so it is a "new native" in the sense of CLAUDE.md, but nothing in `compiler/src` imports `std/tls` (only `package/archive.trb` names it as a capability string), so the PR is two commits and no seed refresh is needed between them: stage 1 is built from the PR's own manifest. ### Requirements 1. Runtime, `runtime/tls/tls.c`, `torb_tls.h`: - `int64_t torb_tls_offer(int64_t session, torb_text protocols)`: sets the session's ALPN list (client: offered; server: accepted, in server preference order) via `mbedtls_ssl_conf_alpn_protocols`. Called before the first `tlsHandshake`. The session owns a NULL-terminated copy of the names that lives until `torb_tls_session_free`. A name that is empty or longer than 255 bytes, or a call after the handshake began, answers a packed `TORB_TLS_FAILED` (`isTlsFailure()`), never truncates. Empty `protocols` is a no-op (today's behaviour). - `torb_text torb_tls_negotiated(int64_t session)`: `mbedtls_ssl_get_alpn_protocol`, empty text when none was chosen. 2. Manifest and natives of `compiler/src/backend/c/natives.trb` (`tlsOffer`, `tlsNegotiated`, both part `tls`); `runtime/include/torb_natives.h` and `runtime/machine_natives.c` regenerated with `torb natives --header` (never by hand); two rows in `docs/design/NATIVES.md` (`keep`, tls) so the drift test stays green. 3. `std/tls`: - `TlsSettings.protocols: List<String> = []` (client offer, preference order). - `TlsStream.accept(stream, identity, protocols: List<String> = [])` (server accepts the first of its own list that the client offers). - `TlsStream.negotiatedProtocol(): String?`: `Some("h2")` etc. after the handshake, `None` if neither side offered or the server chose none. - Both `connect` and `accept` call `tlsOffer` right after creating the session and fail with the `NetworkError` of the native's failure. Existing callers (`std/http`, `std/dns`) compile unchanged. 4. Docs: `docs/design/NETWORK.md` section 5 "as built" gets a short ALPN paragraph (API, no-overlap behaviour); `docs/standard-library/tls.md`; FIRST-PARTY.md gap 20 marked **done <date>** with the names, "Until then" column updated like gaps 13/14; regenerate `skills/` (`torb docs skill docs skills`, `--check` is a tier A gate). 5. Out of scope (own issues): using ALPN in `std/http`'s client or server, HTTP/2 (#72), HTTPS/SVCB hints (#71), the Postgres `sslnegotiation=direct` driver (`torb/postgres`). ### Acceptance criteria - [ ] Offer `["h2", "http/1.1"]` against a server accepting `["http/1.1"]`: both sides' `negotiatedProtocol()` is `Some("http/1.1")`; server preference wins when both match. - [ ] Client offers, server has no list: `negotiatedProtocol()` is `None` on both sides and data still flows (RFC 7301). - [ ] No list on either side: `None`, identical behaviour and handshake to today (existing `tls-loopback`, `dns-over-tls`, `https-exchange` unchanged and green). - [ ] Both sides have lists without an overlap: the handshake fails with `NetworkError` that `isTlsFailure()` (`no_application_protocol`), on the server at least, and the client's `connect` fails or sees the alert, no hang. - [ ] A single `"postgresql"` offer negotiates `Some("postgresql")` (the Postgres 17 case). - [ ] An empty name or a name over 255 bytes is refused by `connect`/`accept` with `isTlsFailure()`, no truncation. - [ ] Runs the same in the VM and `--native` (tier B runs the conformance suite in both). - [ ] FIRST-PARTY.md gap 20 marked done; NETWORK.md, `docs/standard-library/tls.md` and `skills/` updated and `--check` green. ### Tests - `runtime/tests/tls_test.c`: `torb_tls_offer`/`torb_tls_negotiated` on a client/server pair (match, no overlap, empty and 256-byte name, offer after handshake start). - New conformance `tests/conformance/tls-alpn.trb` + `.expected`/`.exit`/`.allocations` (the allocation budget set from the first CI run, as for `tls-loopback`), reusing the test root, certificate and key of `tests/conformance/tls-loopback.trb`: the five negotiation cases above over loopback, both sides in one program. - `compiler` natives drift test (`bfafbd8`): green with the two new rows. ### Commits (one PR, about 1 h) 1. `feat(runtime): ALPN natives for TLS sessions`: `tls.c`, `torb_tls.h`, manifest, regenerated `torb_natives.h` and `machine_natives.c`, NATIVES.md rows, `tls_test.c`. Says "new natives, no std change yet" like 5dd2b9a. 2. `feat(tls): ALPN, offered and negotiated protocols`: `std/tls`, conformance test, docs, FIRST-PARTY.md gap 20, regenerated `skills/`. Gates: `sh tools/gates.sh a`, and `b` (touches `runtime/`), in forge CI only. Implementer: sonnet/high (`runtime/`). Dependencies: none (#115, #116, #117 touch the same files, rebase conflicts in `lib.trb`/`tls.c` only).
Author
Collaborator

[loop:torbscript] refine: ready, one PR in two commits (2 natives + std/tls API, tests, docs), decided by the loop, no owner question, next: implement

[loop:torbscript] refine: ready, one PR in two commits (2 natives + std/tls API, tests, docs), decided by the loop, no owner question, next: implement
Author
Collaborator

[loop:torbscript] claim implement by session:loop-torbscript-20261024 until 2026-10-24T08:10:31Z

[loop:torbscript] claim implement by session:loop-torbscript-20261024 until 2026-10-24T08:10:31Z
Author
Collaborator

[loop:torbscript] implement: checkpoint (context guard), CONTINUE. Branch loop/118-tls-alpn pushed (one WIP commit, to be squashed into commit 1), worktree torbscript-language-118-alpn (ws wt).
Done: runtime/include/torb_natives.h (tlsNegotiated, tlsOffer entries) and runtime/machine_natives.c (two thunks, renumbered 300..310, table, count 311) hand-generated in the generator's sorted order (torb_tls_negotiated after _identity, torb_tls_offer after _notify_close), since there is no torb binary here.
Open for commit 1: compiler/src/backend/c/natives.trb tlsEntries (two tlsRuntimeOf rows: int64_t torb_tls_offer(int64_t session, torb_text protocols);, torb_text torb_tls_negotiated(int64_t session);), runtime/include/torb_tls.h prototypes, runtime/tls/tls.c (session gets char *alpn_names + const char **alpn_list, freed in torb_tls_session_free; reject after handshake began via mbedtls_ssl_is_handshake_over/state != HELLO_REQUEST, empty or >255 byte names), NATIVES.md rows (keep, tls; counts keep 178->180? check current, total +2), runtime/tests/tls_test.c.
Open for commit 2: std/tls API, tests/conformance/tls-alpn.trb + expected files, docs (NETWORK.md s5, standard-library/tls.md, FIRST-PARTY.md gap 20), skills/ regeneration (no torb binary here: CI --check will tell; regenerate needs torb), then PR Closes #118.

[loop:torbscript] implement: checkpoint (context guard), CONTINUE. Branch `loop/118-tls-alpn` pushed (one WIP commit, to be squashed into commit 1), worktree `torbscript-language-118-alpn` (ws wt). Done: `runtime/include/torb_natives.h` (tlsNegotiated, tlsOffer entries) and `runtime/machine_natives.c` (two thunks, renumbered 300..310, table, count 311) hand-generated in the generator's sorted order (torb_tls_negotiated after _identity, torb_tls_offer after _notify_close), since there is no torb binary here. Open for commit 1: `compiler/src/backend/c/natives.trb` `tlsEntries` (two `tlsRuntimeOf` rows: `int64_t torb_tls_offer(int64_t session, torb_text protocols);`, `torb_text torb_tls_negotiated(int64_t session);`), `runtime/include/torb_tls.h` prototypes, `runtime/tls/tls.c` (session gets `char *alpn_names` + `const char **alpn_list`, freed in `torb_tls_session_free`; reject after handshake began via `mbedtls_ssl_is_handshake_over`/state != HELLO_REQUEST, empty or >255 byte names), NATIVES.md rows (keep, tls; counts keep 178->180? check current, total +2), `runtime/tests/tls_test.c`. Open for commit 2: std/tls API, `tests/conformance/tls-alpn.trb` + expected files, docs (NETWORK.md s5, standard-library/tls.md, FIRST-PARTY.md gap 20), skills/ regeneration (no torb binary here: CI `--check` will tell; regenerate needs torb), then PR `Closes #118`.
Author
Collaborator

[loop:torbscript] implement: checkpoint 2 (context guard), CONTINUE. Branch loop/118-tls-alpn, worktree torbscript-language-118-alpn.
Done and pushed: commit 1 42c3d8c1 "feat(runtime): ALPN natives for TLS sessions" is complete (tls.c, torb_tls.h, natives.trb rows, hand-generated thunks/header, NATIVES.md rows and counts 180/535, tls_test.c with 4 ALPN tests; names are newline-TERMINATED so [""] is a lone newline and an empty name, "" is no list). On top: one WIP commit with std/tls/src/lib.trb done (TlsSettings.protocols, TlsStream.accept(..., protocols), negotiatedProtocol(), tlsOffer/tlsNegotiated natives, alpnText helper; connect/accept close the session on an offer failure). Squash the WIP into commit 2 feat(tls): ALPN, offered and negotiated protocols.
Open for commit 2: tests/conformance/tls-alpn.trb + .expected/.exit/.allocations (model: tls-loopback.trb, which holds testRoot/serverCertificate/serverKey; the five cases + empty/256-byte name refusals), docs/standard-library/tls.md (declarations in source order: negotiatedProtocol() after protocol(), accept(stream, identity, protocols: List<String> = []), protocols: List<String> = [] after trusted) with the same edits by hand in skills/torbscript-networking/references/standard-library/tls.md (only link lines differ), NETWORK.md section 5 "as built" ALPN bullet (after "No switch turns verification off", ~line 482), FIRST-PARTY.md gap 20 row 280 marked done 2026-10-24 like gap 13. Then PR Closes #118 (CI-only verification, skills --check in CI) and the closing comment + ws wt rm.

[loop:torbscript] implement: checkpoint 2 (context guard), CONTINUE. Branch `loop/118-tls-alpn`, worktree `torbscript-language-118-alpn`. Done and pushed: commit 1 `42c3d8c1` "feat(runtime): ALPN natives for TLS sessions" is complete (tls.c, torb_tls.h, natives.trb rows, hand-generated thunks/header, NATIVES.md rows and counts 180/535, tls_test.c with 4 ALPN tests; names are newline-TERMINATED so `[""]` is a lone newline and an empty name, `""` is no list). On top: one WIP commit with `std/tls/src/lib.trb` done (TlsSettings.protocols, TlsStream.accept(..., protocols), negotiatedProtocol(), tlsOffer/tlsNegotiated natives, `alpnText` helper; connect/accept close the session on an offer failure). Squash the WIP into commit 2 `feat(tls): ALPN, offered and negotiated protocols`. Open for commit 2: `tests/conformance/tls-alpn.trb` + `.expected`/`.exit`/`.allocations` (model: tls-loopback.trb, which holds testRoot/serverCertificate/serverKey; the five cases + empty/256-byte name refusals), `docs/standard-library/tls.md` (declarations in source order: `negotiatedProtocol()` after `protocol()`, `accept(stream, identity, protocols: List<String> = [])`, `protocols: List<String> = []` after `trusted`) with the same edits by hand in `skills/torbscript-networking/references/standard-library/tls.md` (only link lines differ), NETWORK.md section 5 "as built" ALPN bullet (after "No switch turns verification off", ~line 482), FIRST-PARTY.md gap 20 row 280 marked **done 2026-10-24** like gap 13. Then PR `Closes #118` (CI-only verification, skills `--check` in CI) and the closing comment + `ws wt rm`.
Author
Collaborator

[loop:torbscript] implement: PR 466 (cd3738c6), next: waiting (forge CI of cd3738c6)

[loop:torbscript] implement: PR 466 (cd3738c6), next: waiting (forge CI of cd3738c6)
argus closed this issue 2026-10-24 13:33:13 +00:00
Sign in to join this conversation.
No labels
loop
ready
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
torbscript/language#118
No description provided.