Repository navigation
Description
Symptom
An active MITM (or malicious server) completes a full TLS 1.3 handshake with this client without presenting a certificate at all — no Certificate, no CertificateVerify, no server Finished. The client derives application traffic keys, sets TLS_STATE_CONNECTED, and http_get returns success.
Cause
The handshake message sequence is never enforced. tls_connect issues four unconditional receives (src/tls13.s:157-243), each returning C=0 as soon as one handshake message of any type has dispatched:
lda #TLS_STATE_ENCRYPTED_EXT ; :157
sta tls_state
jsr tls_recv_encrypted
... repeated for CERTIFICATE :216, CERT_VERIFY :227, FINISHED :241
Both dispatchers switch only on the message-type byte — df_dispatch (src/tls_deframe.s:393-418, UCI) and the .else arm in src/tls13.s (ip65). Neither reads tls_state:
lda df_hdr ; message type
cmp #TLS_HS_ENCRYPTED_EXT
beq @disp_ok ; -> clc / rts, no length or content check
tls_state is written before each receive as a progress marker but is read in exactly three places — src/tls_record.s:80, src/tls_record.s:514, src/tls_record_io.s:343 — all for key-epoch / encrypt-or-not selection, never for dispatch. There is no cert_seen / cv_ok flag anywhere in src/ (grep -rn 'cert_seen\|cv_ok\|cv_done\|fin_ok' src/ finds nothing).
The attack: do the normal ClientHello/ServerHello exchange (X25519 is unauthenticated, so this needs no key material from anyone), derive handshake keys, then send four EncryptedExtensions messages under the handshake write key. Four dispatches satisfy the four calls. Reordering variants work equally well — Finished-before-Certificate is accepted, as is Certificate followed by three EE (skipping only the signature).
Callers add no gate: src/http.s:145-148 is jsr tls_connect / bcc @tls_ok / jmp @tls_error, straight to http_build_get.
Why this is worse than the documented "no chain validation" caveat
This is not a restatement of the known gap. It defeats the mitigation shipped for #135: server-name validation is a tail call off x509_extract_pubkey's success exit (src/tls_cert.s:591-597, jmp x509_verify_hostname). If no Certificate message arrives, that code never executes. The SAN check is bypassed by omitting the thing it validates rather than by defeating the check.
It also makes tools/test_finished_verify.py guard a path the protocol never requires. That test is correct and thorough — it is simply not reachable-mandatory.
And it falsifies README.md:157-160 as written: "What the client does prove is that the peer holds the private key for the leaf certificate it presented ... and a forged server Finished is genuinely rejected." On master today the client proves neither, because it does not require either message.
Fix
A tls_hs_expected byte in BSS, set by tls_connect before each tls_recv_encrypted, compared against df_hdr[0] (and tls_rec_buf[0] on the ip65 arm) at the top of dispatch. ~20 B of code + 1 B BSS. Fits on both backends per CLAUDE.md's margins (40 B CRYPTO_RESIDENT / 22 B CRYPTO_OVERLAY / ~59 B NET_CODE tail on ip65), but wants a link check.
Note TLS 1.3 allows an optional CertificateRequest between EE and Certificate; the check should accept the legal set at each point rather than a single exact type.
Test that settles it
Add CERT_MODE=omit to tools/https_e2e/evil_listener.py — after ServerHello, emit four EncryptedExtensions under the handshake key and nothing else — driven from rig_https_bad_finished.py, which already has the "this handshake must abort" harness shape and a FINISHED_MODE=good control.
- Expected: abort.
- Predicted on master today: HTTP 200.
This is the mirror image of the 2026-08-12 audit finding: that found a missing test for a check that existed; this is a test that exists for a check the protocol never requires.
Also
README.md:153-160 needs updating when this lands.
Found by adversarial review, 2026-08-30. Verified independently at source before filing. Should be fixed together with the X25519 all-zero check (filed separately) — together they are "what does this client actually guarantee", and shipping one without the other leaves a misleading story in the README.
Activity
JC-000 commentedon Aug 30, 2026
Companion: #153 (no all-zero X25519 check). Both were found in the same adversarial review and should be fixed in one PR — together they define what the client actually guarantees, and the README rewrite depends on both.
tls: enforce the handshake message sequence (#152)
Merge pull request #160 from JC-000/fix/152-153-handshake-sequence-an…
docs(readme): point at v0.4.3, the security release
docs(readme): point at v0.4.3, the security release
docs(readme): point at v0.4.3, the security release
Build-time leaf SPKI pin (#155 phase 1)
UCI: trust policy -- build pin > signed bundle > TOFU (#155 phase 2, L3)
UCI: trust policy -- build pin > signed bundle > TOFU (#155 phase 2, L3)
UCI: trust policy -- build pin > signed bundle > TOFU (#155 phase 2, L3)