Skip to content

test(tls): cover the server-Finished rejection path (audit F2) - #85

Merged
JC-000 merged 1 commit into
docs/benchmark-refreshfrom
fix/audit-f2-bad-finished
Aug 13, 2026
Merged

test(tls): cover the server-Finished rejection path (audit F2)#85
JC-000 merged 1 commit into
docs/benchmark-refreshfrom
fix/audit-f2-bad-finished

Conversation

@JC-000

Copy link
Copy Markdown
Owner

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 (secclc at src/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

filewhat it is
tools/test_finished_verify.pyVICE unit test: drives tls_verify_finished directly over DMA, 18 cases
tools/https_e2e/evil_listener.pyhand-rolled TLS 1.3 server that can emit a valid flight with one bad Finished
tools/uci/test_https_bad_finished.pyhardware e2e: the client must abort rather than reach HTTP 200

Why the server side is hand-rolled. Python's ssl cannot 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 at aead_decrypt and never reaches the Finished comparison. That would pass an F2 test while proving nothing about F2. So evil_listener.py implements 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 Finished verify_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 (cryptography was already a test dep). It is a test fixture, not a TLS stack.

Oracle.src/tls13.s:@error stashes the state it died in, so tls_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 secclc and rebuilding changed exactly one byte of each PRG:

ip65 build : 47,105 B, file off 20203, load addr $56EA: 38 (SEC) -> 18 (CLC) [tls_verify_finished @ $56C4]
pristine sha256 db31111031e2f30c... mutant sha256 d6400dc9cb729fb0...
uci build : 62,977 B, file off 10428, load addr $30BB: 38 (SEC) -> 18 (CLC) [tls_verify_finished @ $3095]
pristine sha256 2f43b6f3201ad135... mutant sha256 6e71ec0fa19ff452...

1. tools/test_finished_verify.py — VICE

PRISTINE ⇒ PASSES:

--- Vector set A (RFC 8448 s_hs_traffic) ---
PASS: positive want C=0 accept, got C=0 accept
PASS: flip_first_byte want C=1 reject, got C=1 reject
PASS: flip_last_byte want C=1 reject, got C=1 reject
PASS: all_zeros want C=1 reject, got C=1 reject
PASS: all_ones want C=1 reject, got C=1 reject
PASS: truncated want C=1 reject, got C=1 reject
PASS: rotated want C=1 reject, got C=1 reject
PASS: wrong_secret want C=1 reject, got C=1 reject
PASS: wrong_transcript want C=1 reject, got C=1 reject
[vector set B identical]
Passed: 18/18
Failed: 0/18
[+] Finished verify: ALL 18 TESTS PASSED

MUTANT (secclc) ⇒ FAILS:

--- Vector set A (RFC 8448 s_hs_traffic) ---
PASS: positive want C=0 accept, got C=0 accept
FAIL: flip_first_byte want C=1 reject, got C=0 accept
FAIL: flip_last_byte want C=1 reject, got C=0 accept
FAIL: all_zeros want C=1 reject, got C=0 accept
FAIL: all_ones want C=1 reject, got C=0 accept
FAIL: truncated want C=1 reject, got C=0 accept
FAIL: rotated want C=1 reject, got C=0 accept
FAIL: wrong_secret want C=1 reject, got C=0 accept
FAIL: wrong_transcript want C=1 reject, got C=0 accept
[vector set B identical]
Passed: 2/18
Failed: 16/18
[-] Finished verify: 16 TEST(S) FAILED

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:

 http_status = 200, http_resp_len = 21, http_resp_buf = 'HELLO FROM TLS SERVER'
server saw: client_accepted_finished=True, client_finished_valid=True,
request=b'GET / HTTP/1.1\r\nHost: 10.43.23.85\r\nConnection: close\r\n\r\n',
error=None
PASS: handshake completed against the control listener

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:

--- C64 state ---
tls_state = ERROR ($FF)
tls_last_state = FINISHED ($06)
http_status = 0
http_resp_len = 0
--- server saw ---
finished_corrupted = True
client_accepted_finished = False
client_reaction = 'closed connection'
response_sent = False
--- criteria (bad mode) ---
OK server reported no error (error=None)
OK server received a ClientHello
OK server sent its full handshake flight
OK server actually corrupted the Finished verify_data
OK client did NOT send its own Finished (reaction: 'closed connection')
OK tls_state is ERROR (got ERROR ($FF))
OK abort happened AT Finished, not earlier (tls_last_state = FINISHED ($06))
OK no HTTP 200 was parsed (http_status=0)
OK response body was not received
OK response body did not reach the screen either
PASS: client rejected the forged server Finished

Screen tail confirms it: ... CERT ... CV ... PROC and then nothing — no FIN, no CFIN.

MUTANT (secclc, FINISHED_MODE=bad) ⇒ FAILS, exit 1:

--- C64 state ---
tls_state = IDLE ($00)
tls_last_state = IDLE ($00)
http_status = 200
http_resp_len = 21
http_resp_buf = 'HELLO FROM TLS SERVER'
--- server saw ---
finished_corrupted = True
client_accepted_finished = True
client_reaction = 'sent its own Finished'
response_sent = True
--- criteria (bad mode) ---
OK server reported no error (error=None)
OK server received a ClientHello
OK server sent its full handshake flight
OK server actually corrupted the Finished verify_data
FAIL client did NOT send its own Finished (reaction: 'sent its own Finished')
FAIL tls_state is ERROR (got IDLE ($00))
FAIL abort happened AT Finished, not earlier (tls_last_state = IDLE ($00))
FAIL no HTTP 200 was parsed (http_status=200)
FAIL response body was not received
OK response body did not reach the screen either
FAIL: see failed criteria above

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)

 PASS: mode=good expect handshake completes
client saw : b'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 21\r\n...'
PASS: mode=bad_finished expect client rejects
client saw : SSLError: [SSL: DIGEST_CHECK_FAILED] digest check failed (_ssl.c:1032)
[+] evil_listener self-test: ALL PASSED

OpenSSL's DIGEST_CHECK_FAILED is specifically the Finished-HMAC failure, confirming the client rejects where intended rather than at the record layer.

Blast radius observed

  • No source changes. All labels needed (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.
  • The P vs FL register 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 $A5 first 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.
  • One real bug in my own oracle, caught by running the control first. The initial good-mode criterion tls_state == CONNECTED FAILED a genuinely passing run: http_get's success path calls tls_close, which resets tls_state to IDLE, so CONNECTED is only observable mid-flight. Changed to tls_state != ERROR with a comment recording why, since it otherwise reads as a weakening.
  • Adjacent gap left open, not silently absorbed:tls_last_state == FINISHED distinguishes an abort at Finished from an abort at Certificate/CertVerify, but not an HMAC mismatch from a decrypt failure on the Finished record. The good-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.
  • Fresh-worktree build note for others: a new worktree has no submodules and ip65-build/ip65-c64.bin is gitignored; git submodule update --init --recursive is necessary but not sufficient, since make ip65-blob wants ip65 .lib archives the submodule does not carry.

Device etiquette

All hardware runs went through DeviceLock + enable_uci/disable_uci on U64_HOST=10.43.23.81. No REST calls outside the harness, no pkill, no resets or power cycles. Four runs total (1 control that surfaced my oracle bug, 1 control PASS, 1 pristine, 1 mutant), no enable_uci timeouts.

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

Copy link
Copy Markdown
OwnerAuthor

Independently verified by the supervising session, in two parts.

1. The fixture is honest — checked before it ever touched the C64. Ran evil_listener.py --selftest here:

PASS: mode=good client saw HTTP/1.1 200 OK; server saw client_finished_valid: True
PASS: mode=bad_finished client saw SSLError: [SSL: DIGEST_CHECK_FAILED] digest check failed
server saw finished_corrupted: True, then post_flight_record {content_type: 21, length: 2}

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, FINISHED_MODE=bad:

tls_state = ERROR ($FF)
tls_last_state = FINISHED ($06)
http_status = 0
client_accepted_finished = False
PASS: client rejected the forged server Finished

tls_last_state = FINISHED is what makes this evidence rather than a coincidence: the abort happened at the Finished step, not earlier at Certificate or CertificateVerify.

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 aead_decrypt without ever reaching the Finished comparison. A test built that way would have passed against a client with no Finished check at all. Hand-rolling the server was not gold-plating; it was the only way to make the test test what it claims.

Catching your own oracle bug via the control run. Asserting tls_state == CONNECTED in good mode failed a genuinely passing run, because http_get's success path calls tls_close and resets to IDLE. That is exactly the class of error that would have made this test flaky-then-quietly-loosened later, and the comment recording why it is != ERROR is what stops a future reader from reading the looser check as a weakening.

The poisoned latch ($A5) with an inconclusive verdict applies the fail-closed principle at the lowest level, and sidesteps the P-vs-FL register trap that produced two other findings in this audit by not depending on register naming at all.

Correctly flagged and not absorbed: tls_last_state == FINISHED distinguishes "aborted at Finished" from "aborted earlier", but not an HMAC mismatch from a decrypt failure on that same record. The good-mode control closes it in practice — the two flights differ by one bit — and a proper discriminator is a real follow-up rather than a gap in this PR.

Confirms audit finding F2.

@JC-000
JC-000 merged commit dc392b3 into docs/benchmark-refreshAug 13, 2026
@JC-000
JC-000 deleted the fix/audit-f2-bad-finished branch August 13, 2026 13:29
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@JC-000