Skip to content

Fix TLS transcript finalize + UCI net_poll spin - #22

Merged
JC-000 merged 15 commits into
masterfrom
fix/uci-tls-handshake-progression
May 6, 2026
Merged

Fix TLS transcript finalize + UCI net_poll spin#22
JC-000 merged 15 commits into
masterfrom
fix/uci-tls-handshake-progression

Conversation

@JC-000

Copy link
Copy Markdown
Owner

Summary

  • Two functional fixes unblock TLS 1.3 handshake progression on U64E at 48 MHz turbo; handshake now advances to tls_state=0x04 (Certificate), up from stuck at 0x03 (EncryptedExtensions).
  • net_poll preamble was spinning forever in uci_wait_idle on residual STATE bits left after a zero-length SOCKET_READ response. Swapped to uci_wait_not_busy — the same looser gate that uci_push_wait already uses mid-command. Other call sites untouched.
  • tls_transcript_hash was defined and exported but never called, so tls_derive_handshake_keys and tls_derive_traffic_keys were feeding 32 zero bytes into HKDF-Expand-Label as the transcript context. Every AEAD decrypt failed silently with InvalidTag. Now called before both derive sites in src/tls13.s; the finalize routine is non-destructive (snapshots + restores the SHA-256 state).
  • Per-run timestamped debug-artifact directories now land under $UCI_DEBUG_DIR (default /tmp/uci_https_debug/<ISO>/) containing packed raw 6510 trace, full 4 KB ring dump, DMA-read TLS state snapshot (x25519 inputs/output, transcript, handshake/traffic secrets, keys/IVs, sequence numbers), listener server_result, and run metadata. Rotation keeps the last 5; UCI_DEBUG_KEEP_ON_PASS=1 preserves PASS runs.

Commits

  • 08cce43 UCI net_poll entry-gate fix (uci_wait_not_busy)
  • 75a2862 Debug-stream persistence + rotation infrastructure
  • d74dbc4 TLS state dump + ring buffer dump in _dump_full
  • 848ab1f Extended TLS state dump with key-schedule intermediates
  • f910588 Finalize tls_transcript before both derive-keys sites
  • 051275f CLAUDE.md documentation update

Test plan

  • make BACKEND=uci clean rebuild succeeds
  • DEBUG_CAPTURE=1 UCI_DEBUG_KEEP_ON_PASS=1 python3 tools/uci/test_https_local.py on U64E at 192.168.1.81
  • Verify run dir under /tmp/uci_https_debug/<ISO>/ contains ring.bin, tls_state_dump.json, trace.bin (packed + meta), server_result.json, run_info.txt
  • Verify tls_last_state = 0x04 (Certificate) and tls_read_seq = 2 in tls_state_dump.json — two encrypted handshake records successfully AEAD-decrypted
  • Verify tls_s_hs_secret / tls_c_hs_secret are non-zero, derived from the real tls_transcript
  • Regression: BACKEND=uci HTTP GET still passes at 1 MHz and 48 MHz (tools/uci/test_http_local.py)
  • Regression: BACKEND=ip65 test suite still passes (python3 tools/run_all_tests.py --workers 4)
  • Known remaining stall is in tls_handle_certificate (X.509 parsing in src/tls_cert.s) — pre-existing issue, tracked as the next work item

🤖 Generated with Claude Code


Originally posted by @JC-000 on 2026-04-18

JC-000and others added 15 commits April 16, 2026 15:33
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
UCI banner: ULTIMATE 64 ELITE (UCI) → UCI NETWORKING
Insert 16 NOPs after every STA to UCI_CONTROL/UCI_CMD_DATA via a
uci_fence macro defined in uci_regs.inc. At 48 MHz, 16 NOPs = 32
cycles = ~0.67us, giving the FPGA time to latch each write. At 1 MHz
the overhead is 32us per write -- negligible for networking. Unlike
the LDA UCI_STATUS fence approach, NOPs cannot interfere with the
UCI state machine.
11 fence sites total: 8 in uci_cmd.s (uci_abort, uci_begin_cmd,
uci_put_byte, uci_push_wait, uci_check_err CLR_ERR, uci_drain_resp
NEXT_DATA, uci_drain_status NEXT_DATA, uci_ack) and 3 in net.s
(hostname write loop, null terminator, send data loop).
Test results on U64E hardware:
- 1 MHz: PASS (HTTP 200, body "HELLO FROM TEST SERVER")
- 48 MHz: FAIL (write-side fencing alone is insufficient; read-side
timing also needs work at turbo -- follow-up needed)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three categories of changes for turbo-speed UCI register access:
1. Read-side fencing: uci_fence (48 NOPs) after every LDA from
$DF1C-$DF1F — UCI_STATUS, UCI_ID, UCI_RESP_DATA, UCI_STATUS_DATA.
Without this, reads return stale/glitched values at 8+ MHz.
2. Post-PUSH_CMD settle delay: 255-iteration delay loop in
uci_push_wait so the FPGA has time to latch the command and
assert CMD_BUSY before the CPU starts polling. At 48 MHz the
original uci_fence alone was only 2 us — the FPGA needs ~27 us.
3. 16-bit spin-wait in uci_read_resp_bytes: DATA_AV may not be set
immediately after push_wait returns (e.g. TCP_CONNECT waits for
a full network round-trip). The old code bailed on the first
DATA_AV=0; the new code spins up to 65536 iterations (~150 ms
at 48 MHz) before giving up.
Also bumped uci_fence from 16 to 48 NOPs (0.67 us -> 2 us at 48 MHz)
and converted two short branches to JMPs to fix range errors caused
by the larger fence expansions.
Verified: both BACKEND=uci and default ip65 builds succeed.
1 MHz baseline HTTP test passes. 48 MHz testing blocked on U64
power cycle — to be verified after device recovery.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The FPGA needs ~38 µs of wall-clock time between UCI register
accesses. A 48-NOP sled (2 µs at 48 MHz) was far too short, and
even a 256-NOP sled (10.7 µs at 48 MHz, the max that fits in
the code segment) was insufficient.
Replace the NOP sled with a nested delay loop (OUTER=5, INNER=100,
~2525 cycles = ~52 µs at 48 MHz, ~2.5 ms at 1 MHz). Binary search
found the minimum at OUTER=3 INNER=122 (~38.4 µs); the chosen
values provide 35% margin. Verified passing at both 1 MHz and
48 MHz on U64E hardware.
Also convert all branches that span a fence expansion to JMP
trampolines, since even the 14-byte delay loop can exceed the
8-bit branch range in tight loops.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update CLAUDE.md to reflect that UCI networking works at 48 MHz
turbo via a nested delay-loop fence (~52us per UCI register access).
All four test scenarios pass on real U64E: 22B and 1460B responses
at both 1 MHz and 48 MHz.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
UCI NOP-sled fencing for turbo speed support
- tools/uci/test_https_local.py: reusable HTTPS e2e harness that runs a
local TLS 1.3 listener, DMAs a 6502 stub calling http_get, toggles 48
MHz turbo, captures full post-run diagnostics, and optionally streams
the 6510 bus (DEBUG_CAPTURE=1) with bounded capture for post-mortem.
Reproducibly exposes an unresolved TLS 1.3 handshake stall at
tls_state=0x03 on real U64E at 48 MHz.
- CLAUDE.md: note the new test, record the TLS stall as a known issue,
and document the wall-clock-vs-cycle-count design requirement for any
future bounded-timeout work on the UCI adapter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s-test
Add HTTPS e2e test scaffolding + UCI networking notes
net_poll's preamble required STATE==0 AND CMD_BUSY==0 via uci_wait_idle,
but the UCI firmware accepts new commands while STATE is nonzero — as
already evidenced by uci_push_wait's use of uci_wait_not_busy. After a
zero-length SOCKET_READ response, residual STATE bits ($20) from the
drained-but-not-fully-acked FIFOs trapped subsequent net_poll calls in
an infinite spin, stalling TLS 1.3 handshakes at state 0x03 on real
U64E at 48 MHz turbo.
Verified on U64E at 192.168.1.81 via tools/uci/test_https_local.py
with DEBUG_CAPTURE=1: the prior \$24CC-\$24D5 (uci_wait_idle) hotspot
is gone; net_poll now pushes commands (3206 writes to \$DF1D during
the capture window vs zero before); TLS advances past the stuck state.
Other uci_wait_idle call sites (net_dhcp_acquire, net_tcp_connect,
net_tcp_send, net_tcp_close) are one-shot user-initiated paths from
a known-idle baseline and currently work; left untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously test_https_local.py discarded the raw BusCycle trace after
writing three derived text files (summary/tail/uci_accesses) to fixed
paths in /tmp. Each new investigation angle required a fresh hardware
run, and successive runs silently overwrote each other's artifacts.
Now each run gets a timestamped directory under $UCI_DEBUG_DIR
(default /tmp/uci_https_debug/<ISO>/) containing the four derived
files, the full packed binary trace (4 bytes/cycle u32-LE + JSON
sidecar describing the bit layout), the server-side listener result,
and run metadata. Last 5 directories are kept; older ones prune on
next run. PASS runs self-delete unless KEEP_DEBUG_ON_PASS=1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds _dump_tls_state_snapshot and _dump_ring helpers that DMA-read the
TLS state-machine variables and the full 4 KB tcp_recv_buf ring into
tls_state_dump.json and ring.bin inside the run directory. Non-invasive
(no ASM changes). Enables offline decoding of the TLS 1.3 handshake
stall at tls_state=0x03 without needing additional hardware runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ECDHE priv/pub, server pub, shared_secret, CH/SH randoms,
transcript hash output, and per-stage intermediates (tls_c_hs_secret,
tls_s_hs_secret, tls_derived_tmp, tls_verify_data, tls_finished_key)
to _dump_tls_state_snapshot. This lets a post-mortem Python verifier
reconstruct each RFC 8446 key-schedule stage (X25519 / HKDF-Extract /
HKDF-Expand-Label) independently and isolate which one disagrees
with the C64-derived value.
All newly-dumped labels already exist in build/labels.txt; no ASM
export changes needed. Labels absent from a given build are still
silently skipped by the existing loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tls_transcript_hash was defined and exported in src/tls_transcript.s
but never called. Consequently tls_derive_handshake_keys was feeding
32 zero bytes into HKDF-Expand-Label as the context for "s hs traffic"
/ "c hs traffic"; the resulting traffic keys decrypted nothing, every
server-flight record failed AEAD with InvalidTag, and the record layer
sat waiting indefinitely at tls_state=0x03.
Call tls_transcript_hash twice in tls_connect: once before
tls_derive_handshake_keys (context = SHA-256(CH || SH)) and once
before tls_derive_traffic_keys (context = SHA-256(CH .. ServerFinished)).
The finalize routine is non-destructive — it snapshots the SHA-256
state, finalizes the clone into tls_transcript, and restores the
running state — so subsequent tls_transcript_update calls for EE,
Certificate, CertVerify, ServerFinished, and client Finished keep
feeding the same streaming hash.
Verified on U64E at 192.168.1.81 at 48 MHz turbo via
tools/uci/test_https_local.py + the stage-by-stage Python key-schedule
verifier: stages A-D now all MATCH, AEAD decryption succeeds
(tls_read_seq advances to 2), and TLS progresses from ENCRYPTED_EXT
(0x03) to CERTIFICATE (0x04). X.509 parsing in tls_handle_certificate
is the next downstream blocker for end-to-end HTTPS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two upstream blockers for TLS 1.3 at 48 MHz were fixed this session —
the net_poll uci_wait_idle spin and the missing tls_transcript_hash
call. The handshake now advances through key derivation, handshake-key
AEAD decryption, EncryptedExtensions, and into Certificate processing,
where it currently stalls inside tls_handle_certificate (X.509 parsing
is the next work item).
Also documents the per-run debug-artifact directory that
test_https_local.py now writes (UCI_DEBUG_DIR, UCI_DEBUG_KEEP_ON_PASS,
packed raw trace, ring dump, TLS state snapshot, listener result).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 merged commit 47d0cef into masterMay 6, 2026
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