← board

TLS 1.3 from scratch — syscall-only (Pascal handshake + kTLS bulk)

STATUS (2026-06-25) — a working from-scratch TLS 1.3 client exists

M1–M7 are implemented and tested. A from-scratch, library-free Pascal client does a real, server-authenticated https GET against openssl s_server -tls1_3 (make tls13-handshake-devtest): ClientHello → ServerHello → X25519 ECDHE → key schedule → decrypt the server flight → verify CertificateVerify + the cert chain (issuer/sig/validity/hostname) + the server Finished → client Finished → app keys → GET → decrypt 200. Both the kTLS-TX offload (when the tls module is loaded) and the Pascal record layer baseline are exercised.

Library units (all in lib/rtl, all gated in make lib-test unless noted): sha256 (+HMAC/HKDF), sha512, chacha20poly1305, aesgcm, x25519, rsa, ed25519, ecdsa_p256 (slow, gated), x509, tls13_keys, tls13_record, tls13_hs, tls13_ktls. Handshake driver: test/devtest_tls13_handshake.pas (non-hermetic devtest, needs openssl; not in the gate).

Next steps (each its own slice): [[feature-tls-system-trust-store]] (/etc/ssl/certs anchoring) · RSA-PSS + ECDSA CertificateVerify scheme dispatch · kTLS RX (control-record recvmsg) · ciphersuite negotiation / HelloRetryRequest / session tickets · ESP not-on-int Boolean variant (sis's bug-esp-not-always-boolean — the x86-64 not reverts don't apply on ESP). Workaround/undo state for the codegen bugs hit along the way is in [[track-b-workarounds]].

Scope decision (2026-06-24)

Target: POSIX/Linux only. This from-scratch stack is our TLS for the desktop/server targets. ESP32 is out of scope — ESP-IDF already ships mbedTLS + HW crypto; link those there, don't run our software stack. BSD/kTLS (FreeBSD) is deferred to when BSD support proper begins. So: don't pre-build multi-platform crypto seams now — keep the primitives portable Pascal targeting Linux/POSIX; the per-platform notes below are forward-looking context, not work to do up front. Whole feature is deferred until we pick up BSD anyway.

Why / stance

TLS is not a kernel feature. Linux kTLS only does the record layer (bulk AEAD of application data) once you hand it negotiated keys — it does not do the handshake. The handshake (the actual difficulty) is pure userspace computation over our socket syscalls: key exchange, cert-chain signature verify, key schedule. So a syscall-only TLS = we write the TLS stack in Pascal; the kernel just moves bytes (and, via kTLS, can do the bulk symmetric crypto so our Pascal AEAD only ever touches the low-volume handshake — no throughput concern).

This is squarely our lane and a superb real-world compiler stress test: big-int math, bit-twiddling, AEAD state machines, ASN.1 parsing, a multi-state protocol — "platonic cryptographic code."

Security stance (explicit): this is educational / platonic / a compiler exercise. It will NOT be constant-time-audited or hardened against timing/padding side channels initially. For production / hostile networks, recommend the OpenSSL-dlopen path. Document this loudly at the unit head and in any API doc. That neatly sidesteps the real risk (subtle crypto bugs) without giving up the build.

Architecture

  1. Handshake in Pascal (once per connection, low volume — speed irrelevant): ClientHello → ServerHello, derive shared secret, key schedule, decrypt+verify the (encrypted) EncryptedExtensions / Certificate / CertVerify / Finished, send our Finished. Our AEAD runs here on a few KB only.
  2. Bulk record layer: two options, build (a) first.
    • (a) Pascal record layer — AEAD each app-data record in Pascal. Works everywhere (other arches too), simplest, no kTLS dependency. Throughput is Pascal-AES-bound but correct.
    • (b) kTLS offload — after the handshake, install the app traffic keys via setsockopt(TCP_ULP,"tls") + setsockopt(SOL_TLS, TLS_TX/TLS_RX, crypto_info); the kernel then encrypts/decrypts bulk data at line rate and read/write look plaintext. Needs PalSetSockOpt + the SOL_TLS crypto-info structs. The perf path; per-platform behind PAL (Linux and FreeBSD have kTLS with different APIs; OpenBSD/NetBSD/macOS/ESP do not — see the platform table below). Never the baseline.

Platform abstraction (get the seam at the right level)

The protocol is portable; the crypto and the offloads are not. Three layers:

kTLS availability (offload only — the handshake is always ours)

OS kTLS API
Linux yes (TX 4.13+, RX 4.17+) setsockopt(TCP_ULP,"tls") + SOL_TLS crypto_info
FreeBSD yes (pioneered) setsockopt(TCP_TXTLS_ENABLE,...)different API/structs
OpenBSD / NetBSD / macOS no (userspace TLS)
ESP32 no FreeRTOS/lwIP; use HW accel + Pascal record layer

So kTLS glue is per-platform behind PAL (separate Linux and FreeBSD backends), absent elsewhere — which is exactly why the Pascal record layer must be the baseline. The from-scratch TLS is therefore never wasted: the SW path always works; kTLS/HW-accel plug in where present.

Target ciphersuite (TLS 1.3 only — far simpler than 1.2)

Mandatory + modern, minimal set:

No 1.2, no RC4/CBC/renegotiation/compression. Client-side only first (we are an HTTP client); server-side later if wanted.

Building blocks

Have: lib/rtl/bignum.pasBigModPow (RSA verify), BigDivMod/BigMul/… (EC field arithmetic). Big head start.

Need (each its own unit + smoke):

  1. Hashes: SHA-256 + HMAC + HKDF-Extract/Expand landed 2026-06-25 (lib/rtl/sha256.pas, RFC-vector smoke test/lib_sha256 in lib-test). SHA-384 still needed for the 384 ciphersuite.
  2. AEAD: ChaCha20 + Poly1305 + AES-128 block + GCM (GHASH) done (lib/rtl/chacha20poly1305.pas RFC 8439, lib/rtl/aesgcm.pas FIPS-197 + GCM TC1–4).
  3. X25519 (Curve25519 ECDH) done (lib/rtl/x25519.pas, RFC 7748; 16-limb radix-2^16 field, not bignum).
  4. Signature verify: RSA (PKCS#1 v1.5) + ECDSA-P256 + Ed25519 done (rsa.pas, ecdsa_p256.pas, ed25519.pas). RSA-PSS still to add if a server needs it.
  5. ASN.1/DER parser + X.509 cert parse; chain build + validation (validity dates, key usage, name match); trust store from /etc/ssl/certs (file syscalls — fine).
  6. TLS 1.3 record + handshake state machine; transcript hash; key schedule; alerts.
  7. Crypto-primitive PAL seam so HW accel can substitute SW (ESP32 AES/SHA/RSA peripherals); + kTLS glue PalSetSockOpt per-platform (Linux SOL_TLS, FreeBSD TCP_TXTLS_ENABLE) — optional offload, absent on OpenBSD/NetBSD/macOS/ESP.
  8. Integration: https:// in http (its isTls branch is already stubbed to refuse); async path too.

Milestones (suggested order — each smoke-tested against known test vectors)

Done when

HttpGet('https://host/...') (sync + async) completes a real TLS 1.3 handshake, validates the cert chain to the system trust store, and transfers application data — Pascal record layer at minimum, kTLS offload optional. Every primitive smoke-tested against published vectors. Unit head + docs state the not-for-hostile-production caveat and point at OpenSSL-dlopen for that.

Moved to rainy-day/ (2026-07-20, Track B sweep)

The ticket's own status line has said "DEFERRED, start alongside BSD support (not now)" since 2026-06-24, yet it kept surfacing at the top of the Track B ready queue at prio 53 — so the queue was advertising as available work something the ticket itself says not to start. rainy-day/ matches the stated intent.

Nothing is lost: M1-M7 are implemented and tested, and a real server-authenticated https GET against openssl s_server works today via make tls13-handshake-devtest. The next slices (RSA-PSS scheme dispatch, kTLS RX, ciphersuite negotiation) are listed in the ticket and can be pulled individually if one becomes urgent — that does not require un-deferring the umbrella.

2026-08-01 (Track B) — CertificateVerify now verifies ALL the schemes it offers

The "next steps" list above named "RSA-PSS + ECDSA CertificateVerify scheme dispatch". Working it turned up that it was not merely missing — it was failing open.

What was wrong

test/devtest_tls13_handshake.pas — which is where the client's handshake logic lives — verified ed25519 only. Every other scheme hit:

else
  writeln('certverify=skip (scheme ', ToHex(cvScheme), ', verifier not wired here)');

and the handshake carried on. The ClientHello advertises four schemes (ed25519, ecdsa_secp256r1_sha256, rsa_pss_rsae_sha256, rsa_pkcs1_sha256), so a server was free to pick any of them — and for three of the four the client accepted the connection without the server ever proving possession of its private key. That is the whole point of CertificateVerify: a valid certificate chain proves the name belongs to a key, and CertificateVerify proves the peer HAS that key. Skipping it means anyone holding a copy of the (public) certificate could complete the handshake.

The devtest was green throughout, because it generates ed25519 keys — the one scheme that worked. A test whose fixture happens to avoid the broken path.

What landed

Proven, not assumed

One harness bug found on the way and fixed here: the script computed NOW once, before the ed25519 runs, so certs minted later for the new runs had a notBefore after it and were correctly rejected as not-yet-valid — which initially looked like a signature failure. The clock is now re-read per scheme.

Follow-on: rsassaPss CERTIFICATES (same day)

Having a PSS verifier raised the mirror question — were we REJECTING legitimate certificates? Measured: yes. X509VerifySig knew sha256WithRSAEncryption, ecdsa-with-SHA256 and Ed25519, so a chain signed with rsassaPss (1.2.840.113549.1.1.10), which is what a modern CA signs with, failed to verify.

That direction was fail-CLOSED — an unknown OID falls through to Result := False — so it was safe, just unusable. Now handled with the same verifier.

Worth stating because the two halves look alike and are not: the rsassaPss AlgorithmIdentifier carries parameters (hash, MGF, salt length) which are NOT parsed; we verify under SHA-256 / MGF1-SHA256 / salt 32. Verifying under assumed parameters fails closed — a signature made with different ones simply does not verify — whereas skipping the check fails open, which is what the CertificateVerify path was doing. A SHA-384 PSS cert is therefore rejected rather than mis-accepted; supporting it means widening the verifier, not loosening this.

test/lib_x509.pas gains five cases: parse, verify, self-verify, a tampered signature rejected, and verification under the WRONG issuer key rejected — the last two because "it returned True once" proves nothing.

Still open from the list above

kTLS RX (control-record recvmsg) · ciphersuite negotiation / HelloRetryRequest / session tickets. The system trust store is done ([[feature-tls-system-trust-store]]) — the list above predates it.