test(tls): cover the server-Finished rejection path (audit F2) - #85
Conversation
The client verifies the server's Finished HMAC and aborts on mismatch (tls_verify_finished in src/tls_keyschedule.s -> bcs @enc_error in src/tls13.s), but nothing exercised the abort. Mutation proved it: with the mismatch branch inverted (sec -> clc) the full hardware e2e still reached HTTP 200 with the correct body, undetected. Every listener the suite talks to sends a *correct* Finished, so the branch was invisible to the tests. Two new tests, one narrow and one end-to-end. tools/test_finished_verify.py (VICE) drives tls_verify_finished directly over DMA: two independent (secret, transcript) vector sets x 9 cases — positive, first/last-byte flips, all-zeros, all-ones, truncated, rotated, and the two realistic attacks (a valid HMAC under the wrong secret, and one over the wrong transcript). The carry is latched into RAM by a 6502 stub (JSR / LDA #0 / ROL A / STA) rather than read from the P register; the latch byte is poisoned beforehand and an unwritten latch is reported as inconclusive, never a pass. The positive case also checks the C64's computed tls_verify_data against an independent Python HKDF-Expand-Label + HMAC-SHA256, so "always accept" cannot pass either. tools/uci/test_https_bad_finished.py (U64E/C64U) is the end-to-end version. Python's ssl module cannot corrupt its own Finished, and flipping a bit in the ciphertext only breaks the Poly1305 tag — the client would then reject at aead_decrypt and never reach the Finished comparison, which would be a false pass. So the server side is written out by hand in tools/https_e2e/evil_listener.py: real X25519 ECDHE, real RFC 8446 key schedule, real ChaCha20-Poly1305 record layer, real P-256 CertificateVerify, with exactly one bit flipped in the server Finished verify_data before encryption. It self-validates against Python's own ssl client (--selftest) and carries a FINISHED_MODE=good control that must be run first. The oracle uses tls_last_state, which src/tls13.s:@error stashes on abort: tls_state=$FF plus tls_last_state=6 (FINISHED) proves the abort happened at Finished and not earlier at Certificate or CertificateVerify. Server-side evidence the client cannot fabricate is asserted too (client_accepted_finished). evil_listener.py is a test fixture, not a TLS stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JC-000
commented
Aug 13, 2026
Independently verified by the supervising session, in two parts. 1. The fixture is honest — checked before it ever touched the C64. Ran OpenSSL rejects at the digest check and answers with an alert record — so the corruption is reaching the Finished HMAC comparison, not tripping the AEAD layer earlier. That is the property the whole test depends on, and it is established against a third-party implementation rather than against our own client. 2. The C64 side, on hardware, pristine build,
This is the first positive evidence that c64-https enforces server-Finished verification against an adversarial server. Before this PR the branch was only known to work against a cooperative one, and the audit showed that deleting the check entirely changed nothing observable. Three things here are better practice than the brief asked for: Point 1 in the summary is the crux and deserves emphasis — flipping a bit in the ciphertext would have been a false pass, because it breaks the Poly1305 tag and the client rejects at Catching your own oracle bug via the control run. Asserting The poisoned latch ( Correctly flagged and not absorbed: Confirms audit finding F2. |
F2 — no coverage of the server-Finished rejection path
Defect (one sentence): the client verifies the server's Finished HMAC and aborts on mismatch, but nothing in the repo exercised the abort — with the mismatch branch inverted (
sec→clcatsrc/tls_keyschedule.s:749) the full hardware e2e still reached HTTP 200 with the correct body, because every listener the suite talks to sends a correct Finished.What landed
tools/test_finished_verify.pytls_verify_finisheddirectly over DMA, 18 casestools/https_e2e/evil_listener.pytools/uci/test_https_bad_finished.pyWhy the server side is hand-rolled. Python's
sslcannot corrupt its own Finished — the handshake is entirely inside OpenSSL, with no hook between "compute verify_data" and "put it on the wire". Bit-flipping the ciphertext from outside does not substitute: that breaks the Poly1305 tag, so the client rejects ataead_decryptand never reaches the Finished comparison. That would pass an F2 test while proving nothing about F2. Soevil_listener.pyimplements the server side directly — real X25519 ECDHE, real RFC 8446 key schedule, real ChaCha20-Poly1305 record layer, real P-256 CertificateVerify — and flips exactly one bit of the Finishedverify_databefore encryption, leaving the AEAD tag correct. It is much smaller than it sounds because this client offers one suite, one group, no SNI, no session id, no HRR. No new dependencies (cryptographywas already a test dep). It is a test fixture, not a TLS stack.Oracle.
src/tls13.s:@errorstashes the state it died in, sotls_state=$FF (ERROR)plustls_last_state=6 (FINISHED)proves the abort happened at Finished and not earlier at Certificate (4) or CertificateVerify (5) — a test that only checked "handshake failed" would pass for a fixture with a merely broken certificate. Server-side evidence the client cannot fabricate (client_accepted_finished) is asserted as well.The server deliberately folds the Finished it actually sent into its own transcript, so a client that wrongly accepts stays in lockstep and sails on to HTTP 200. A broken client therefore fails fast and unambiguously instead of hanging until a sentinel timeout — which is exactly what the mutant run below shows.
Acceptance A/B — mutant proven effective at the binary level
Not taken on faith from a line number. Patching
sec→clcand rebuilding changed exactly one byte of each PRG:1.
tools/test_finished_verify.py— VICEPRISTINE ⇒ PASSES:
MUTANT (
sec→clc) ⇒ FAILS:The 2 survivors are exactly the 2 positive cases, which the mutant does not affect.
2.
tools/uci/test_https_bad_finished.py— U64E hardware (10.43.23.81 @ 48 MHz)CONTROL first (
FINISHED_MODE=good, pristine) — a failure on a sick device is not a result. Exit 0:The hand-rolled server drove the real C64 through a complete TLS 1.3 handshake to HTTP 200 — the device is healthy and the fixture genuinely works against this client, not just against OpenSSL.
PRISTINE (
FINISHED_MODE=bad) ⇒ PASSES, exit 0:Screen tail confirms it:
... CERT ... CV ... PROCand then nothing — noFIN, noCFIN.MUTANT (
sec→clc,FINISHED_MODE=bad) ⇒ FAILS, exit 1:That mutant run is the audit finding, reproduced and now caught: a forged server Finished, accepted, all the way to HTTP 200 with the correct body.
3. Fixture self-validation (
evil_listener.py --selftest, hardware-free)OpenSSL's
DIGEST_CHECK_FAILEDis specifically the Finished-HMAC failure, confirming the client rejects where intended rather than at the record layer.Blast radius observed
tls_verify_finished,tls_s_hs_secret,tls_transcript,tls_rec_buf,tls_last_state) were already emitted by-Ln. Pristine PRG sha256s are byte-identical before and after this branch on both backends; every mutation was reverted and the shas re-verified.PvsFLregister trap that bit lane A does not apply here — nothing reads the P register. The carry is latched into RAM by 6502 code and read back as a byte; the latch is poisoned with$A5first and an unwritten latch is reported as inconclusive, never a pass. Side benefit: no register API, so the same stub works on VICE and on hardware.tls_state == CONNECTEDFAILED a genuinely passing run:http_get's success path callstls_close, which resetstls_stateto IDLE, so CONNECTED is only observable mid-flight. Changed totls_state != ERRORwith a comment recording why, since it otherwise reads as a weakening.tls_last_state == FINISHEDdistinguishes an abort at Finished from an abort at Certificate/CertVerify, but not an HMAC mismatch from a decrypt failure on the Finished record. Thegood-mode control closes that in practice — the two flights differ by exactly one bit — but a decrypt-failure discriminator would be a genuine follow-up.ip65-build/ip65-c64.binis gitignored;git submodule update --init --recursiveis necessary but not sufficient, sincemake ip65-blobwants ip65.libarchives the submodule does not carry.Device etiquette
All hardware runs went through
DeviceLock+enable_uci/disable_ucionU64_HOST=10.43.23.81. No REST calls outside the harness, nopkill, no resets or power cycles. Four runs total (1 control that surfaced my oracle bug, 1 control PASS, 1 pristine, 1 mutant), noenable_ucitimeouts.