TLS 1.3 from scratch — syscall-only (Pascal handshake + kTLS bulk)
- Type: feature (library / crypto / protocol) — flagship
- Status: backlog — DEFERRED, start alongside BSD support (not now)
- Owner: —
- Opened: 2026-06-24
- Relation: the native backend behind [[feature-tls-provider-abstraction]]
(the common TLS seam); the OpenSSL backend is the co-equal default. The
httpshalf of [[feature-own-net-http-lib]].
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
- 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.
- 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 andread/writelook plaintext. NeedsPalSetSockOpt+ theSOL_TLScrypto-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:
- TLS protocol (handshake state machine, key schedule, transcript hash, ASN.1/X.509, alerts) — pure portable Pascal, identical on every target.
- Crypto primitives (AES block, SHA-2 compression, bignum modexp, AEAD) — behind a thin PAL-swappable seam so a target can substitute hardware: software Pascal on Linux/BSD; ESP32 has HW AES/SHA/RSA accelerators (MMIO via the PAL ESP backend, and ESP-IDF mbedTLS) — don't run software AES there. Every implementation is held to the same RFC/NIST test vectors.
- Record layer — pluggable. Portable Pascal AEAD is the baseline (works on EVERY target). kTLS is an optional per-platform offload, not the baseline.
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:
- Cipher:
TLS_AES_128_GCM_SHA256(mandatory) +TLS_CHACHA20_POLY1305_SHA256. - Key exchange:
X25519(mandatory-to-offer); P-256 optional later. - Cert verify (server): ECDSA-P256, Ed25519, RSA-PKCS#1 v1.5 + RSA-PSS.
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.pas — BigModPow (RSA verify), BigDivMod/BigMul/…
(EC field arithmetic). Big head start.
Need (each its own unit + smoke):
- Hashes:
SHA-256+HMAC+HKDF-Extract/Expandlanded 2026-06-25 (lib/rtl/sha256.pas, RFC-vector smoketest/lib_sha256in lib-test). SHA-384 still needed for the 384 ciphersuite. - AEAD:
ChaCha20 + Poly1305+AES-128 block + GCM (GHASH)done (lib/rtl/chacha20poly1305.pasRFC 8439,lib/rtl/aesgcm.pasFIPS-197 + GCM TC1–4). X25519 (Curve25519 ECDH)done (lib/rtl/x25519.pas, RFC 7748; 16-limb radix-2^16 field, not bignum).- Signature verify:
RSA (PKCS#1 v1.5)+ECDSA-P256+Ed25519done (rsa.pas,ecdsa_p256.pas,ed25519.pas). RSA-PSS still to add if a server needs it. - 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). - TLS 1.3 record + handshake state machine; transcript hash; key schedule; alerts.
- Crypto-primitive PAL seam so HW accel can substitute SW (ESP32 AES/SHA/RSA
peripherals); + kTLS glue
PalSetSockOptper-platform (LinuxSOL_TLS, FreeBSDTCP_TXTLS_ENABLE) — optional offload, absent on OpenBSD/NetBSD/macOS/ESP. - Integration:
https://inhttp(itsisTlsbranch is already stubbed to refuse); async path too.
Milestones (suggested order — each smoke-tested against known test vectors)
- M1 hashes + HMAC + HKDF (RFC test vectors). DONE for SHA-256 (2026-06-25) —
lib/rtl/sha256.pas: SHA-256 (FIPS 180-4) + HMAC-SHA256 (RFC 2104) + HKDF-Extract/Expand (RFC 5869), all verified against published vectors intest/lib_sha256(12 checks, gated in lib-test). Library-free, pure integer — the first concrete step of the from-scratch / kTLS path. SHA-384 still to add. - M2 AES-128-GCM + ChaCha20-Poly1305 (RFC 8439 / NIST vectors). DONE
(2026-06-25). ChaCha20-Poly1305:
lib/rtl/chacha20poly1305.pas(ChaCha20 ARX + Poly1305 native limbs), RFC 8439 §2.5.2/§2.8.2,test/lib_chacha20poly1305(7 checks). AES-128-GCM:lib/rtl/aesgcm.pas(AES-128 + GHASH GF(2^128) + GCM), FIPS-197 AES + GCM-spec TC1–4,test/lib_aesgcm(8 checks, gatedaes-gcm). Both library-free. Surfaced Track A bugs: [[bug-managed-record-result-self-arg]], [[bug-fixed-array-assignment-no-copy]], [[bug-string-literal-concat-compare-segfault]]. - M3 X25519 (RFC 7748 vectors). DONE (2026-06-25) —
lib/rtl/x25519.pas: a TweetNaClcrypto_scalarmultport (16-limb radix-2^16 field, Int64),X25519+X25519Base, verified against RFC 7748 §5.2 + §6.1 (Diffie-Hellman incl. ECDH agreement) intest/lib_x25519(6 checks, gated asx25519). Library-free. Surfaced Track A bug [[bug-not-on-int64-is-boolean]] (noton an Int64 expression miscompiles; worked around with-x-1). - M4 signature verify: RSA, ECDSA-P256, Ed25519 (RFC 8032 vectors). DONE
(2026-06-25).
lib/rtl/rsa.pas(PKCS#1 v1.5 SHA-256, over bignum),lib/rtl/ed25519.pas(RFC 8032, TweetNaCl port overlib/rtl/sha512.pas),lib/rtl/ecdsa_p256.pas(secp256r1 SHA-256, Jacobian over bignum). All verify-only, library-free, vector-checked (lib_rsa3,lib_ed255193,lib_ecdsa_p2562; gatedrsa-verify/ed25519-verify/ecdsa-p256-verify). Surfaced Track A bugs [[bug-not-on-int64-is-boolean]], [[bug-aggregate-member-array-as-var-param]]. - M5 ASN.1/X.509 parse + chain validation + trust store. In progress
(2026-06-25):
lib/rtl/x509.pas— a DER (TLV) parser + X.509 field extraction (tbsCertificate, signatureAlgorithm, signatureValue, SubjectPublicKeyInfo) +X509VerifySigwiring the cert signature to the M4 verifiers (RSA/ECDSA-P256/Ed25519 by OID).test/lib_x509: three self-signed certs (one per algorithm) parse and their self-signatures verify (5 checks, gatedx509). Chain validation added 2026-06-25:X509VerifyChain(issuer-name link + signature + validity + hostname),X509ValidAt,X509HostMatch(SAN dNSName, case-insensitive, single*.wildcard).lib_x509now also validates a real CA→leaf chain (issuer link, SAN exact/wildcard/reject, expired-reject, badhost-reject, chain-ok; 12 checks). Parse + chain validation done; the only M5 remainder — loading the system trust store (/etc/ssl/certs) for the trust anchor — is its own ticket [[feature-tls-system-trust-store]]. - M6 key schedule done (2026-06-25):
lib/rtl/tls13_keys.pas— HKDF-Expand-Label, Derive-Secret, the Early/Handshake/Master secret chain, and traffic key/iv derivation (RFC 8446 §7.1), verified byte-for-byte against the RFC 8448 worked example (test/lib_tls13_keys, 5 checks, gatedtls13-keysched). Record layer done (lib/rtl/tls13_record.pas,tls13-record): TLSCiphertext framing + per-record nonce (iv XOR seq) + AEAD wrap/unwrap over both ciphersuites, roundtrip + tamper-reject. Handshake message layer done (lib/rtl/tls13_hs.pas,tls13-hs): ClientHello builder (X25519 key_share, the two SHA-256 suites, supported_versions/groups/sig_algs/SNI), ServerHello parser (cipher + server key_share), HS framing + transcript hash. FULL handshake + real https GET works against a real server (2026-06-25):test/devtest_tls13_handshake.pas(make tls13-handshake-devtest) is a from-scratch TLS 1.3 client that completes a real handshake againstopenssl s_server -tls1_3: ClientHello → ServerHello → X25519 ECDHE → handshake key schedule → decrypt the full server flight (EncryptedExtensions, Certificate, CertificateVerify, Finished) → verify the server Finished MAC → send the client Finished → derive application keys → send an HTTP GET → and decrypt theHTTP/1.0 200 okresponse. The whole M1–M6 stack interoperates with OpenSSL end to end. Server authentication now wired (2026-06-25): the client verifies the CertificateVerify signature (Ed25519) over the transcript AND the certificate chain (X509VerifyChain: issuer-name link + leaf signature under the CA + validity dates + SAN hostname), refusing the connection if either fails — verified vsopenssl s_server(certverify=ok+chain-verified=ok). The trusted CA is read from a file (PalOpen/PalRead), not argv (long-argv corruption landmine, see [[track-b-workarounds]]). Remaining hardening: chain-to-system-trust-store ([[feature-tls-system-trust-store]]) instead of a passed CA; RSA-PSS + ECDSACertificateVerifyscheme dispatch (M4 RSA-PKCS1/ECDSA/Ed25519 exist, RSA-PSS not yet); kTLS RX offload (control-recordrecvmsg); ciphersuite negotiation, HelloRetryRequest, session tickets. - M6 TLS 1.3 handshake state machine → a real
https://GET (Pascal record layer); verify against a public host. - M7 (optional) kTLS offload for app-data throughput. Done + verified
2026-06-25:
lib/rtl/tls13_ktls.pas(KtlsEnable= setsockoptTCP_ULP="tls";KtlsSetAesGcm128installs the TLS 1.3 AES-GCM-128tls12_crypto_info) on a new generalPalSetSockOptPAL primitive. Wired intotls13-handshake-devtest: after the Pascal handshake derives the app keys it installs the TX key into the kernel and sends the HTTP GET as plaintext viaNetSend— the kernel encrypts it, andopenssl s_serverdecrypts and returnsHTTP/1.0 200 ok. So a real kTLS data round-trip is proven (with thetlsmodule loaded). RX stays on the Pascal record layer for now (kTLS RX delivers control records like NewSessionTicket viarecvmsg/cmsg, a small follow-on). kTLS is an offload: where the module is absent the devtest falls back to the (baseline) Pascal record layer — which is exactly why the from-scratch stack matters, since kTLS is a grey-area kernel feature.
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
RsaVerifyPssSha256inlib/rtl/rsa.pas— RSASSA-PSS with MGF1-SHA256 and a 32-byte salt (RFC 8017 §8.1.2/§9.1.2), including theemBits = modBits-1derivation, the top-bit check and the0xBCtrailer. This did not exist at all, and TLS 1.3 (RFC 8446 §4.4.3) requires PSS for an RSA CertificateVerify — it forbids the PKCS#1 v1.5 schemes there outright. So a from-scratch client could not authenticate an RSA server, which is most of them.- Dispatch, failing CLOSED. ed25519, ecdsa_secp256r1_sha256 and
rsa_pss_rsae_sha256 are verified; anything else is a hard failure naming the
scheme.
rsa_pkcs1_sha256(0401) is deliberately rejected here even though we advertise it — RFC 8446 permits the codepoint insignature_algorithms(it describes signatures in CERTIFICATES) but not in CertificateVerify. RsaKey/EcdsaRSexported fromx509.pas. The handshake needs the same SPKI and DER-signature decoding the chain code already does; a second DER parser is a second place to get DER wrong.
Proven, not assumed
test/lib_rsa_pss.pas(inlib-test, hermetic): the positive vector is OpenSSL's own signature, pinned rather than regenerated — PSS is randomised, so the point is that we accept a signature we did not make. Six negative cases beside it (wrong message, flipped signature bit, corrupted trailer, truncated, empty, and a PKCS#1 signature offered as PSS), because a verifier that returns True unconditionally passes any single positive test.tools/tls13_handshake_devtest.shnow runs the full handshake against three servers — ed25519, RSA and ECDSA-P256 — and asserts the specificcertverify=ok (<scheme>)line for each, so a future regression to "skip" cannot pass.- The fail-closed path was verified by BUILDING a client with ed25519
verification disabled and confirming it refuses:
CertificateVerify scheme 0807 is not one this client can verify.
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.