Skip to content

Implement TLS 1.3 record layer - #2

Merged
JC-000 merged 7 commits into
masterfrom
feature/tls-record-layer
May 6, 2026
Merged

Implement TLS 1.3 record layer#2
JC-000 merged 7 commits into
masterfrom
feature/tls-record-layer

Conversation

@JC-000

Copy link
Copy Markdown
Owner

Summary

  • Implement TLS 1.3 record encrypt/decrypt with ChaCha20-Poly1305 AEAD
  • Nonce construction per RFC 8446 §5.3 (IV XOR sequence number)
  • 64-bit big-endian sequence number management
  • Key selection logic (handshake vs application keys based on tls_state)
  • Record framing: header build/parse, TCP send/receive integration
  • ZP additions at $1E-$21 for record layer pointers

Bug fix

tls_select_keys clobbers zp_temp ($FD) during 32-byte key copy. The encrypt/decrypt functions were using zp_temp to store the AEAD plaintext length, which got corrupted between tls_build_nonce and aead_encrypt. Fixed by using dedicated tls_enc_aead_len storage.

Test plan

  • python3 tools/test_tls_record.py — 17/17 pass
  • All 114 tests pass (56 net + 7 SHA-256 + 22 crypto + 12 HKDF + 17 record)

🤖 Generated with Claude Code


Originally posted by @JC-000 on 2026-03-15

JC-000and others added 7 commits March 14, 2026 14:29
- Add ip65 as git submodule (cc65/ip65)
- Build ip65 TCP + RR-Net driver into binary blob at $2000 via ca65/ld65
- ip65_stub.s: jump table (11 entries) + variable address table
- ip65.cfg: custom linker config placing code at $2000, BSS at $4000
- Wire net.asm to call ip65 through the jump table with ZP save/restore
- net_init, net_dhcp, net_poll, net_dns_resolve, net_tcp_connect/send/close
- net_print_ip: decimal IP display from ip65 config area
- TCP receive ring buffer (256 bytes) for callback data
- Update constants.asm with ip65 jump table offsets and variable addresses
- Change boot.asm to menu-driven startup (I=init, G=get, Q=quit)
- No auto-init on startup — prevents crash without RR-Net hardware
- Update Makefile for two-stage build: ca65/ld65 (ip65) then ACME (our code)
- Add test suite: tools/test_net.py (56 tests)
- Build integrity (14 labels)
- ip65 jump table validation (22 entries)
- ZP save/restore round-trip (10 random patterns)
- TCP receive ring buffer (drain + wrap-around)
- ip65_init without hardware (graceful failure + ZP preservation)
Binary: 15.4 KB ($0801-$458F), ip65 blob: 6.8 KB at $2000-$3B26
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comprehensive per-address documentation of all 256 ZP bytes:
- 5-tier safety classification (always safe, BASIC-only, KERNAL I/O,
IRQ-clobbered, system-reserved)
- Complete IRQ handler ($EA31) footprint with code paths
- KERNAL call side effects for CHROUT, CHRIN, GETIN, OPEN, CLOSE,
LOAD, SAVE, SETLFS, SETNAM, CLRCHN, CHKIN, CHKOUT
- cc65/ip65 ZP convention ($02-$1B)
- Cursor blink disable optimization ($CC)
- Recommended allocation strategy for multi-module projects
- Test harness jsr() BRK artifact documentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy and adapt 6 crypto modules from sibling projects:
- word32.asm (505 lines) — 32-bit arithmetic for ChaCha20
- chacha20.asm (326 lines) — ChaCha20 stream cipher (RFC 7539)
- poly1305.asm (610 lines) — Poly1305 MAC with quarter-square multiply
- aead.asm (311 lines) — ChaCha20-Poly1305 AEAD (RFC 7539 S2.8)
- sha256.asm (1029 lines) — SHA-256 with optimized byte-rotate decomposition
- hmac_drbg.asm (621 lines) — HMAC-SHA256, HMAC-DRBG, entropy collection
Adaptations: strip menu UI code, rename zp_ptr1->zp_ptr, add extra_sid
stubs for entropy, add sqtab/SID equates to constants.asm, merge all
crypto data buffers into data.asm (~1.1 KB).
Test suites (85 tests total):
- test_sha256.py: 7 tests (init IV, NIST "abc", empty, boundary 1/55/56/63)
- test_crypto.py: 22 tests (sqtab_init, ChaCha20 block+encrypt RFC vectors,
Poly1305 MAC RFC vector, AEAD encrypt/decrypt RFC vector + 5 random)
- test_net.py: 56 tests (unchanged, regression pass)
Binary: 22 KB, 406 labels. All crypto at $3B27-$4E88.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace hkdf.asm stubs with working implementations:
- hkdf_extract: HMAC-SHA256(salt, IKM) with zero-salt fallback
- hkdf_expand: HMAC-SHA256(PRK, info || 0x01) for L ≤ 32
- hkdf_expand_label: builds TLS 1.3 HkdfLabel struct in hkdf_info_buf,
copies label via ZP indirect addressing, then calls hkdf_expand
- tls_derive_secret: convenience wrapper (unchanged)
Optimizations: ZP pointer ($FB) for indirect copies in expand_label,
tight DEX/BPL loops for 32-byte copies, direct info_buf construction.
Test suite: tools/test_hkdf.py (12 tests)
- RFC 5869 Extract cases 1 + 3 (empty salt)
- RFC 5869 Expand cases 1 + 3 (L=32 truncated)
- TLS 1.3 early_secret derivation
- HKDF-Expand-Label with "derived" label + empty hash context
- 3 random Extract + 3 random Expand-Label vs Python hmac reference
All 97 tests pass (56 net + 7 SHA-256 + 22 crypto + 12 HKDF).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mark ip65 integration, crypto modules, and HKDF as complete.
Update test section with all 4 suites (97 tests total).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Integrate ip65 TCP/IP stack with RR-Net driver
Core record functions (tls_record.asm):
- tls_select_keys: selects handshake/application key+IV+seq by direction
and tls_state, copies 32-byte key to aead_key via ZP indirect
- tls_build_nonce: RFC 8446 §5.3 nonce = iv XOR (0000||seq), optimized
with unrolled iv[0..3] copy + 8-byte XOR loop
- tls_seq_increment: 64-bit big-endian increment via (tls_rec_ptr),Y
- tls_record_encrypt: append inner content type, build AAD header,
encrypt with ChaCha20-Poly1305, copy tag, increment seq
- tls_record_decrypt: extract tag, decrypt, verify, strip inner type
Record framing/TCP (tls_record_io.asm — new file):
- tls_send_record: send 5-byte header + payload via net_tcp_send
- tls_recv_record: state machine accumulating bytes from ring buffer
- tls_record_send_plaintext/encrypted: convenience wrappers
- tls_record_recv_and_decrypt: receive + auto-decrypt if state >= SERVER_HELLO
Bug fix: tls_enc_aead_len replaces zp_temp for AEAD length storage.
tls_select_keys clobbers zp_temp ($FD) during key copy, which corrupted
the AEAD plaintext length between tls_build_nonce and aead_encrypt calls.
ZP additions: tls_rec_ptr=$1E (2B), tls_rec_idx=$20, tls_direction=$21
Test suite: tools/test_tls_record.py (17 tests)
- 3 nonce construction (zero seq, known seq, read direction)
- 3 sequence increment (simple, carry, multi-byte carry)
- 3 record encrypt (short/handshake/64-byte vs Python ChaCha20Poly1305)
- 3 record decrypt (Python-encrypted, tampered tag, application keys)
- 5 encrypt/decrypt roundtrips (random sizes, both key phases)
All 114 tests pass (56 net + 7 SHA-256 + 22 crypto + 12 HKDF + 17 record).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@JC-000
JC-000 merged commit e3268cd into masterMay 6, 2026
@JC-000
JC-000 deleted the feature/tls-record-layer branch May 6, 2026 19:03
JC-000 added a commit that referenced this pull request Jul 29, 2026
…ServerHello drain (#71)
* feat(test): macOS hardware-free HTTPS e2e — HTTPS_PORT knob, feth/pcap rig test, listener TCP patience
- src/boot.s: HTTPS_PORT build knob (.ifndef default 443; default build
byte-identical, verified ip65+uci PRG sha256 pre/post). Makefile plumbs
an optional HTTPS_PORT=... to ca65 so the e2e listener can bind an
unprivileged port (4433).
- tests/test_vice_https_macos.py: macOS counterpart to test_phase3_https
(Linux path untouched). Rig preflight (feth1 addr, dnsmasq pidfile with
EPERM-means-alive, bpf perms, ethernet-VICE binary, feth0-conflict
detection incl. the duplicate-MAC hazard), listener self-probe (catches
the macOS Local Network permission block), autostart-RUN fallback,
-minimized (host keystrokes must never reach the emulated C64),
per-iteration transport.resume() (binary-monitor reads pause the CPU;
without resume the machine idles at ~19% duty and 'CH' forever),
VICE 3.10 speed model (no runtime warp; Speed=100000 measures ~1.2x on
SDL2 — budget wall-clock for ~1x), SIGTERM runs teardown (an orphaned
VICE stays on feth0 as a duplicate-MAC node), per-phase timeline table.
- tools/https_e2e/https_listener.py: darwin-gated TCP_RXT_CONNDROPTIME
(0x80) = 7200 s on listening + accepted sockets. macOS RSTs a local
connection after ~30 s of unACKed retransmission; ip65 ACKs only when
the C64 polls, and its 1 MHz crypto stalls run 4-25 min (observed: 5x
rexmt over 33 s then RST mid-flight, C64 ACKed into the dead socket
4.5 min later -> TLS HANDSHAKE FAILED at EE decrypt). Linux default
patience is minutes (why the historical TAP rig never hit it); UCI
never hits it (Ultimate firmware TCP ACKs autonomously).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tls): drain+ACK pending frames after ServerHello before the ECDHE stall
First-ever REU-less ip65 HTTPS e2e (VICE, no -reu, onchip profile) PASSES
with this: G->CONNECTION CLOSED in 1832 s at ~1.2x emulation, HTTP 200 +
Content-Length-terminated body.
Root cause chain (wire-proven on the macOS feth rig, runs 6-8):
- ip65's SYN carries no MSS option -> macOS peers default to 512 B
segments; the ~690 B server flight always splits 512+178.
- The client ACKs segment 1 while awaiting SH, then disappears into the
multi-minute ECDHE compute without draining segment 2. macOS hard-drops
a connection after 13 retransmissions (~54 s at LAN RTO) — note
TCP_RXT_CONNDROPTIME is a ceiling-shortener in xnu, it cannot extend
past the 13-rexmt limit.
- The C64 then processes the entire buffered flight OFFLINE — EE, Cert,
CertificateVerify (signature verifies!), server Finished — and dies
only when SENDING client Finished into the RST'd socket. Fingerprint:
tls_state=$FF, tls_read_seq=4, last rec type $16 len $0035; identical
under REU and onchip profiles (backend-generic, not REU-less-specific).
Fix: after tls_parse_server_hello, run a bounded 8x250 net_poll drain
(~10-20 s at 1 MHz) so everything already at the NIC is consumed and
ACKed before compute starts. Zero unACKed data then exists across every
later crypto stall; idle connections survive arbitrarily long verifies.
Wire proof: client ACKs the full flight 34 ms after it lands.
Why other rigs never saw this: Linux servers retransmit for 15-30 min
(historical TAP rig passed); UCI firmware ACKs autonomously regardless of
C64 polling (all Ultimate hardware runs passed). Real internet servers
sit between those — this drain is also a prerequisite for the eventual
1 MHz real-server story, though flights larger than the 1460 B window
will additionally need periodic polling inside long crypto (follow-up).
Also: test asserts the response from C64 memory (http_status/
http_resp_buf via labels) instead of screen-scraping (body scrolls off);
failure-time TLS/net state dump; listener keeps the darwin
TCP_RXT_CONNDROPTIME attempt with a comment documenting why it is
insufficient alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): assert demo-path contract — 200 status line in raw buffer; body-in-buffer is informational
boot.s's HTTPS demo path copies only the FIRST decrypted TLS record into
http_resp_buf (158 B = status line + headers; the 22 B body arrives as
record #2, delivered+ACKed on the wire but never copied). Pre-existing
demo-path behavior, unrelated to the REU-less work — the full
Content-Length body semantics live in the http_get/http_recv_response
path. Follow-up: make the boot.s demo loop append subsequent records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
JC-000 added a commit that referenced this pull request Aug 12, 2026
…t never enters the ciphertext ring (#75)
Closes#72.
The 'G' HTTPS demo in boot.s hand-rolled its response receive: one
net_poll/tls_recv iteration, then a single-page copy of that first
decrypted record into http_resp_buf (the "up to 512 bytes" comment was
also wrong — the loop caps at 256). Whenever the body arrived as a
second TLS record it was received and ACKed but never copied, so the
demo displayed headers only.
Routing the demo through the production parser surfaced a deeper,
pre-existing flaw in http_get's own TLS path: it fed DECRYPTED plaintext
back into the shared TCP ring (tcp_recv_tail) and parsed from there via
net_recv_byte. The ring is the ciphertext queue. On ip65 the rx callback
queues eagerly, so ciphertext for later records (body record #2, the
peer's close_notify) is typically already sitting between head and tail —
the parser then reads ciphertext as HTTP text (garbage status) or loses
the body. UCI masked this because its adapter reads on demand.
Fix — dual-source parser input (http_in_byte):
mode 0 (plain HTTP): input = TCP ring, delegates to net_recv_byte;
the ring legitimately holds plaintext there.
mode 1 (TLS): input = the current decrypted record as a linear
span (http_in_ptr/http_in_len, 16-bit).
The ring stays pure ciphertext on the TLS path: no feed, no discard, and
multi-record bodies parse correctly on both backends. The span path also
drops the old feed loop's "TLS records < 256" assumption (it honored only
tls_app_len's low byte). http_in_byte preserves X/Y to match
net_recv_byte's contract and uses SMC rather than zp_ptr, which the
parser's body-store state owns.
The receive/parse loop is extracted as http_recv_body and shared by
http_get and the boot.s demo, so there is one implementation of the
response semantics. Each path keeps its own close sequence (the demo
prints progress). Mode is set per TLS record and reset to 0 in the plain
path's parser init, so alternating 'H' and 'G' in one session is safe.
Also (issue #72, comment-only): tcp_recv_overflow's semantics documented
at the declaration (src/data.s) and the set site (src/net/ip65/net.s).
Retransmission duplicates enter the ring too, so the flag can latch
during retransmit bursts with in-flight data far below ring size
(observed in the VICE e2e with a 690 B flight, stream intact). It is a
diagnostic breadcrumb, not proof of loss — but ip65 ACKs the full
inbound length regardless of what the callback copies, so if a dropped
tail ever WAS new in-sequence data it is genuinely gone and TLS errors
follow. No accounting redesign here.
User-visible change: the 'G' demo now prints the response body (via
ascii_chrout) instead of the status line + headers. This is shared code,
so it applies to the UCI backend too.
Validation: all five profiles link (ip65 / ip65+onchip / uci /
uci+onchip / uci+comb; sizes unchanged at 47,105 and 62,977 B).
tools/test_http.py 27/27 in VICE — the mode-0 ring path, including the
120-byte-body and 404 cases. ip65 e2e with body assertions: see the
follow-up commit/PR note.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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