diff --git a/.gitignore b/.gitignore index 6177422..122bfc1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ ip65-build/*.bin ip65-build/*.map .claude/* !.claude/settings.json -tools/https_e2e/certs/ +tools/https_e2e/certs/* +!tools/https_e2e/certs/README tools/diag_4de0_*.py tools/diag_read_live.py .serena/ diff --git a/CLAUDE.md b/CLAUDE.md index a45ac18..1dd34a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,11 @@ Dependencies: - VICE (`x64sc`) only for `make run` / the test harness Targets: - - `make` — default, produces `build/c64-https.prg` - and `build/labels.txt` (VICE label format) + - `make` — default, produces `build/c64-https.prg`, + `build/labels.txt` (VICE label format), and + `build/c64-https.dbg` (cc65 debug info, + consumable by VICE's monitor + diagnostic + agents; P-384 overlays get `.dbg` sidecars too) - `make clean` — remove build artifacts - `make run` — autostart the PRG in VICE - `make ip65-libs` — rebuild ip65 object libraries from the submodule @@ -143,18 +146,32 @@ is **$C9**. See `src/net/uci/uci_regs.inc` for the full equate list ### UCI command primitives `src/net/uci/uci_cmd.s` provides shared subroutines used by `net.s`: -`uci_wait_idle`, `uci_begin_cmd`, `uci_push_wait`, `uci_end_cmd`, -`uci_read_data`, etc. No zero-page usage — all absolute addressing -and self-modifying code. - -`uci_wait_idle` is wall-clock-bounded (5 s budget via CIA1 TOD) per -the design note below. On timeout it returns C=1 with `net_last_error -= UCI_ERR_WAIT_TIMEOUT`. The four callers (`net_dhcp_acquire`, -`net_tcp_connect`, `net_tcp_send`, `net_tcp_close`) all `bcs` out to -surface the failure rather than letting the C64 hang indefinitely on -a wedged FPGA. `uci_push_wait` and `uci_end_cmd` are still unbounded -and should be converted to the same TOD pattern if a wedge there is -ever observed. +`uci_wait_idle`, `uci_wait_not_busy`, `uci_begin_cmd`, `uci_push_wait`, +`uci_read_resp_bytes`, etc. No zero-page usage — all absolute +addressing and self-modifying code. + +`uci_wait_idle`, `uci_wait_not_busy`, `uci_drain_resp`, and +`uci_drain_status` are all wall-clock-bounded (5 s budget via CIA1 +TOD) per the design note below. On timeout they return C=1 with +`net_last_error = UCI_ERR_WAIT_TIMEOUT`. All `uci_wait_idle` callers +(`net_dhcp_acquire`, `net_tcp_connect`, `net_tcp_send`, +`net_tcp_close`) and all `uci_wait_not_busy` / `uci_push_wait` +callers (`net_poll`, `net_dhcp_acquire`, `net_tcp_connect`, +`net_tcp_send`, `net_tcp_close`) `bcs` out to surface the failure +rather than letting the C64 hang indefinitely on a wedged FPGA. All +13 `uci_drain_resp` / `uci_drain_status` call sites in `net.s` also +`bcs` out — on timeout the routine skips its companion drain + ack, +forces the appropriate `net_tcp_state` (ERROR for poll paths, +CONNECT_FAIL for connect, CLOSED for close, untouched for DHCP/send +which use C=1 as their fail sentinel), and returns. `uci_push_wait` +inherits the bound via its tail-call to `uci_wait_not_busy`. The +`uci_wait_not_busy` conversion was driven by a Phase 5 wedge observed +in CertVerify recv on real U64E hardware that converted a wedge into +a 1843 s test sentinel timeout; the drain conversion (Phase 5j) +closed the secondary risk that `net_tcp_send` / `net_poll` / +`net_tcp_close` could still wedge in `uci_drain_resp` / +`uci_drain_status` post-SOCKET_WRITE if firmware ever left DATA_AV / +STAT_AV asserted. ### UCI error codes @@ -272,6 +289,28 @@ backends: - `http_status = 200`, `http_resp_buf = "HELLO FROM TLS SERVER"`, `http_resp_len = 21` +**ECDSA P-384 also wired end-to-end (Phase 5).** The TLS dispatcher +now negotiates `ecdsa_secp384r1_sha384` (0x0503) alongside the existing +P-256/SHA-256 path; on a 0x0503 CertificateVerify it routes through +`src/crypto/ecdsa_verify_384.s`, which composes the dual-overlay swap +(SHA-384 overlay → ECDSA-P384 curve overlay) plus the sibling's +`ecdsa_verify_384` to verify the server's signature. The +`tls_handle_certificate` cert handler dispatches on `ecdsa_curve_id` +and writes the 48 B P-384 pubkey into the dedicated +`ecdsa_pubkey_x_384` / `_y_384` slots in CRYPTO_BSS (Phase 5 Fix B). +The CertificateVerify signed-content blob is 130 B (RFC 8446 §4.4.3: +64-space pad + 33 B context + 1 B sep + 32 B SHA-256 transcript; +the transcript-hash function stays SHA-256 because c64-https +negotiates only TLS_AES_128_GCM_SHA256 — Phase 5 Fix A). The +end-to-end test is `tools/uci/test_https_local_p384.py` (mirrors +`test_https_local.py` with P-384 cert profile via swapping CERT_PATH +/ KEY_PATH to `tools/https_e2e/certs/server-p384.{pem,key}`); see the +"ECDSA P-384 verify wall-clock" subsection for the wall-clock +expectation. Negotiation plumbing test +`tools/test_tls_p384_negotiation.py` confirms ClientHello advertises +both 0x0403 + 0x0503 and the dispatcher reaches the P-384 path on +0x0503 CertificateVerify (2/2 PASS as of Phase 5). + ### Summary of recent fixes (post-PR23 branch) Five latent bugs and three new ones were cleared to get here: @@ -431,10 +470,42 @@ budget, ample headroom). Further speedups live in the sibling here as a submodule bump without touching TLS call sites. +### ECDSA P-384 verify wall-clock + +Not yet measured end-to-end. The U64E test host was unreachable from +the dev machine when Phase 5's e2e wiring landed (DeviceLock +unavailable; ping/TCP both unreachable to the default +192.168.1.81). Run `tools/uci/test_https_local_p384.py` from a host +with U64E LAN access to capture the number; the script defaults to a +30 minute wall-clock budget (`SENTINEL_POLL_TIMEOUT=1800` / +`ACCEPT_TIMEOUT=1800`) — expect 4-7 minutes per handshake at 48 MHz +turbo, dominated by: + + - one ECDSA-P384 verify (sibling `libs/nistcurves` + `ecdsa_verify_384`); P-256 measures 81.9 s, the P-384 cost is + ~5x because the field is 1.5x wider and the scalar mul does + proportionally more `fp_mul` / `fp_sqr` calls — extrapolate + ~400 s = ~7 min ceiling + - one SHA-384 hash over the 130 B signed-content blob (negligible + vs the verify) + - the dual-overlay swap dance (sha384 overlay swap-in → + sha384_init/update/final → curve overlay swap-in → verify); each + swap is 2 REU DMAs at ~16 ms wallclock — also negligible + - X25519 + Finished HMACs + state-machine overhead (~6-7 s + across the rest of the handshake, per the P-256 baseline) + +Once measured, drop the wall-clock here. Phase 4 cert-profile flag +in the local listener (`HTTPS_LISTENER_CERT_PROFILE=p384` or the +`cert_profile="p384"` kwarg to `start_https_listener`) is the +upstream selector; `tools/uci/test_https_local_p384.py` inlines its +own listener (matching `test_https_local.py`'s pattern) and points it +at `tools/https_e2e/certs/server-p384.{pem,key}`. + ### Design note — bounded timeouts must use wall-clock time Robustness work on the UCI adapter's spin-wait helpers (`uci_wait_idle`, -`uci_push_wait`, etc.) MUST use a wall-clock time source — CIA timer +`uci_wait_not_busy`, `uci_drain_resp`, `uci_drain_status`, etc.) MUST +use a wall-clock time source — CIA timer on stock C64, TOD clock on U64E — rather than a cycle-counted iteration budget. The fences around every UCI register access make per-iteration cost scale with CPU speed: a budget that is ample at 1 MHz collapses @@ -444,7 +515,7 @@ FPGA's wire-level operation durations. A prior attempt on branch budgets and broke DHCP at turbo for exactly this reason; the branch was abandoned. -`uci_wait_idle` is the first helper to follow this pattern (issue #37). +`uci_wait_idle` was the first helper to follow this pattern (issue #37). At entry it samples CIA1 TOD ($DC08-$DC0B) — read order is HOUR (latch) → MIN → SEC → TENTHS (unlatch) — and on each spin pass re-reads TENTHS, bailing with C=1 + `net_last_error = UCI_ERR_WAIT_TIMEOUT` @@ -452,6 +523,26 @@ after 50 transitions (~5 s wall-clock, independent of CPU turbo). State lives in two SMC bytes inside the routine to match the file's no-ZP convention. Use this as the template for any future bounded helper. +`uci_wait_not_busy` was converted to the same pattern after a Phase 5 +wedge in CertVerify recv on real U64E hardware — the unbounded spin +turned an FPGA wedge into a 1843 s test sentinel timeout. Same 5 s +budget, same error code, same SMC-byte state convention. All six +caller sites (`net_poll`, `net_dhcp_acquire`, `net_tcp_connect`, +`net_tcp_send`, `net_tcp_close` direct + via `uci_push_wait`) `bcs` +out on C=1 to surface the timeout. `uci_push_wait` inherits the bound +via its tail-`jmp` into `uci_wait_not_busy` and needs no separate +conversion. + +`uci_drain_resp` and `uci_drain_status` followed in Phase 5j to close +the symmetric risk on the response-drain side: `net_tcp_send` / +`net_poll` / `net_tcp_close` all call drains after their respective +SOCKET_WRITE / POLL_DATA / SOCKET_CLOSE responses, and if firmware +ever leaves DATA_AV / STAT_AV asserted post-response the old +unbounded `jmp ` loops would wedge the C64 with no wall-clock +escape. Same 5 s budget, same error code, same SMC-byte state +convention. All 13 call sites in `net.s` `bcs` out on C=1 to skip +the companion drain + ack and force the appropriate exit state. + ## Memory layout Defined in `cfg/c64-https-ip65.cfg`. Physically contiguous file-backed diff --git a/Makefile b/Makefile index debe323..6f0d607 100644 --- a/Makefile +++ b/Makefile @@ -34,8 +34,8 @@ IP65_DIR := ip65 IP65_BUILD := ip65-build IP65_BIN := $(IP65_BUILD)/ip65-c64.bin -CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net/$(BACKEND) --debug-info -LD65FLAGS := -C $(CFG) -Ln build/labels.txt -m build/c64-https.map +CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net/$(BACKEND) -I build --debug-info +LD65FLAGS := -C $(CFG) -Ln build/labels.txt -m build/c64-https.map --dbgfile build/c64-https.dbg # Source inventory. TOP_SRCS := $(wildcard src/*.s) @@ -90,6 +90,22 @@ CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) else ifeq ($(BACKEND),uci) NET_SRCS := $(UCI_SRCS) CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) +# Phase 3: embed the two P-384 split overlay blobs in the PRG so boot +# can populate REU banks 6/7 at startup. Gated to UCI (ip65 has no +# room for the SHA blob in main RAM) and to !USE_X25519_SIBLING (the +# sibling rodata occupies CRYPTO_OVERLAY at PRG load time, displacing +# the SHA blob). Adds a build-order dep on the .bin files; a missing +# .bin causes the .incbin to fail, so we extend PRG_DEPS below. +ifneq ($(USE_X25519_SIBLING),1) +# Phase 5 Fix D: respect a command-line USE_OVERLAY_P384_EMBED=0 so the +# bootstrap rule below can do a no-overlay-embed prelim link to break +# the overlay-bin <-> labels.txt cycle on a clean tree. Default is +# still 1 unless the operator explicitly disables it. +USE_OVERLAY_P384_EMBED ?= 1 +ifeq ($(USE_OVERLAY_P384_EMBED),1) +CA65FLAGS += -D USE_OVERLAY_P384_EMBED=1 +endif +endif # Phase C.3: add c64-nist-curves P-384 primitives as a REU overlay. # Variable-base P-384 point ops (double/add/jacobian-to-affine) only — # see tools/integration/build_nistcurves_p384.sh for the scope rationale. @@ -107,7 +123,12 @@ CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) # integration can be re-enabled by uncommenting the two lines below once # the cfg is extended. #CA65FLAGS += -D USE_NISTCURVES_P384=1 -#SIBLING_LIB_ARCHIVES += build/lib/nistcurves-p384.a +# Phase 1.5 split the monolithic nistcurves-p384.a into two halves +# (nistcurves-p384-sha384.a + nistcurves-p384-curve.a) since the +# combined image overflowed the live 7.5 KB CRYPTO_OVERLAY slot. +# Either-of approach for the production wire-up will be Phase 4a. +#SIBLING_LIB_ARCHIVES += build/lib/nistcurves-p384-sha384.a +#SIBLING_LIB_ARCHIVES += build/lib/nistcurves-p384-curve.a else $(error Unknown BACKEND=$(BACKEND); expected ip65 or uci) endif @@ -134,6 +155,15 @@ else PRG_DEPS := $(ALL_OBJS) endif +# Phase 3: when USE_OVERLAY_P384_EMBED is on, add the two .bin files +# to PRG_DEPS so make builds them before the .incbin in +# src/crypto/shared/p384_overlay_blobs.s tries to read them. +ifeq ($(USE_OVERLAY_P384_EMBED),1) +PRG_DEPS += build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin +PRG_DEPS += build/p384_overlay_equates.inc +build/crypto/shared/p384_overlay_blobs.o: build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin +endif + $(PRG): $(PRG_DEPS) @mkdir -p build $(LD65) $(LD65FLAGS) -o $@ $(ALL_OBJS) $(SIBLING_LIB_ARCHIVES) @@ -141,6 +171,24 @@ $(PRG): $(PRG_DEPS) # so the c64-test-harness Labels.from_file() reader can parse it. sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' $(LABELS) +# Phase 5 Fix D: $(LABELS) is normally a side-effect of the $(PRG) +# link recipe; we don't add an explicit rule. The overlay-bin rule +# below has an order-only dep on $(LABELS) so its lookup_label() +# resolves the main PRG's runtime mul_dma_lo / mul_dma_hi / +# mul_cached_a / reu_fetch_mul_row to real addresses (was: silent +# $0000 fallback that produced a curve overlay whose fp_mul_384 +# read/wrote $0000 and silently corrupted downstream state). +# +# Bootstrap workflow (clean tree under USE_OVERLAY_P384_EMBED=1): +# make BACKEND=uci USE_OVERLAY_P384_EMBED=0 # produce labels.txt +# make BACKEND=uci # real link with overlays +# After this two-step bootstrap, plain `make BACKEND=uci` rebuilds +# incrementally without intervention. The script +# tools/integration/build_nistcurves_p384_bin.sh prints a clear error +# pointing at this two-step procedure if it runs without labels.txt +# (vs the old silent $0000 stub fallback). + + link: $(PRG) build/%.o: src/%.s @@ -148,10 +196,10 @@ build/%.o: src/%.s $(CA65) $(CA65FLAGS) -o $@ $< # Phase C.3: c64-nist-curves sibling archive (libs/nistcurves/ submodule). -# Same gating as x25519: only linked under BACKEND=uci; ip65 continues -# without P-384 entirely. Exports only the variable-base primitives -# (see the build script for the excluded symbols and why). -build/lib/nistcurves-p384.a: +# Phase 1.5 split: produces TWO archives, one per overlay half. The +# script writes both with a single invocation; the second target is a +# pseudo-rule that piggybacks on the first. +build/lib/nistcurves-p384-sha384.a build/lib/nistcurves-p384-curve.a: @mkdir -p build/lib bash tools/integration/build_nistcurves_p384.sh @@ -176,18 +224,62 @@ build/lib/x25519.a: @mkdir -p build/lib bash tools/integration/build_x25519.sh -# Phase C.3b: P-384 overlay IMAGE + labels for harness-time use only. -# The production PRG does NOT link nistcurves-p384.a — this is smoke-test -# infrastructure. tools/test_p384_symbols.py loads overlay-p384.bin into -# REU at test time via a trampoline, then calls crypto_swap_to_p384 to -# page it into the live slot. Keeps the main PRG size unchanged. +# Phase C.3b / Phase 1.5 split: P-384 overlay IMAGES + labels for +# harness-time use only. The production PRG does NOT link +# nistcurves-p384-{sha384,curve}.a — these are smoke-test infrastructure. +# A future Phase 3 / Phase 4a harness will load both .bins into REU at +# test time, then DMA them into the live slot via two new swap entry +# points (crypto_swap_to_p384_sha384 / crypto_swap_to_p384_curve); +# the existing crypto_swap_to_p384 entry point is now stale -- see the +# comment block at the top of src/crypto/shared/crypto_swap.s. +# +# All four outputs (two .bins + two labels files) are produced by a +# single script invocation; the rule lists all four targets so make +# only runs the script once even when several are stale. # -# Both outputs live below build/; depend on the archive being built first. -build/lib/overlay-p384.bin build/labels-p384.txt: build/lib/nistcurves-p384.a cfg/p384-overlay.cfg tools/integration/build_nistcurves_p384_bin.sh +# Phase 5 Fix D: build/labels.txt is an ORDER-ONLY dependency. The +# overlay-bin script's lookup_label() reads build/labels.txt to resolve +# mul_dma_lo / mul_dma_hi / mul_cached_a / reu_fetch_mul_row to the +# main PRG's runtime addresses (so the curve overlay's fp_mul_384 +# reads/writes the right $BA00 / $BB00 / etc. cells). On a clean +# build, build/labels.txt doesn't exist yet when this rule runs and the +# script falls back to $0000 stubs - silently producing an overlay +# image whose fp_mul_384 reads from $0000. Order-only ('|') ensures +# labels.txt exists before the script runs but doesn't trigger an +# overlay rebuild on every main-PRG link. +build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin build/labels-p384-sha384.txt build/labels-p384-curve.txt: \ + build/lib/nistcurves-p384-sha384.a build/lib/nistcurves-p384-curve.a \ + cfg/p384-overlay-sha384.cfg cfg/p384-overlay-curve.cfg \ + tools/integration/build_nistcurves_p384_bin.sh \ + | build/labels.txt bash tools/integration/build_nistcurves_p384_bin.sh .PHONY: p384-overlay -p384-overlay: build/lib/overlay-p384.bin build/labels-p384.txt +p384-overlay: build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin \ + build/labels-p384-sha384.txt build/labels-p384-curve.txt + +# Phase 5 Fix C: regenerate the P-384 overlay-resident symbol equates +# (build/p384_overlay_equates.inc) from the overlay labels files so the +# TLS-side dispatcher (src/crypto/ecdsa_verify_384.s) picks up address +# changes via .include, with .assert pins catching drift. Whenever +# either labels file is rebuilt, the .inc regenerates and the +# dispatcher .o is forced to rebuild. +build/p384_overlay_equates.inc: build/labels-p384-sha384.txt build/labels-p384-curve.txt \ + tools/integration/gen_p384_overlay_equates.sh + bash tools/integration/gen_p384_overlay_equates.sh \ + build/labels-p384-sha384.txt build/labels-p384-curve.txt $@ + +# The dispatcher .o now depends on the generated equates file (via +# .include) AND on the overlay .bin files (PRG_DEPS already lists those +# under USE_OVERLAY_P384_EMBED). Phase 5 Fix D: gate the .inc dep on +# USE_OVERLAY_P384_EMBED so the bootstrap rule for $(LABELS) (which +# sub-makes with USE_OVERLAY_P384_EMBED=0) can skip rebuilding the .inc +# from labels-p384-* (those depend on overlay-bins which depend on +# $(LABELS) -- cycle). The bootstrap pre-creates a placeholder .inc +# before sub-making. +ifeq ($(USE_OVERLAY_P384_EMBED),1) +build/crypto/ecdsa_verify_384.o: build/p384_overlay_equates.inc +endif # Build ip65 object libraries from the submodule. Only needed if the ip65 # submodule changes; the prebuilt blob is committed to ip65-build/. diff --git a/cfg/c64-https-ip65.cfg b/cfg/c64-https-ip65.cfg index dfc3094..0af0345 100644 --- a/cfg/c64-https-ip65.cfg +++ b/cfg/c64-https-ip65.cfg @@ -44,10 +44,19 @@ MEMORY { # CRYPTO_OVERLAY is not used under ip65 (no REU-overlay swapping). # A zero-size rw alias is declared here only to satisfy - # `crypto_swap.s`'s `.import __CRYPTO_OVERLAY_START__` — ip65 never - # actually issues the DMA, so the address value is unused. + # `crypto_swap.s`'s `.import __CRYPTO_OVERLAY_START__` -- ip65 + # never actually issues the DMA, so the address value is unused. CRYPTO_OVERLAY: start = $6000, size = $0000, type = rw, define = yes; + # Phase 3: ip65 backend does NOT embed the P-384 split overlay + # blobs (no room in main RAM after the existing layout, and ip65 + # is the production X25519-only path that never calls into P-384). + # The OVERLAY_BLOB_SHA384 / OVERLAY_BLOB_CURVE segments below are + # `optional = yes` and stay empty under ip65; their MEMORY anchors + # are zero-size aliases just to give ld65 valid load addresses + # for the segment names referenced from src/crypto/shared/. + OVERLAY_BLOB_CURVE_RAM: start = $E000, size = $0000, type = rw, define = yes; + TCP_BUF: start = $C000, size = $1000, type = rw, define = yes; } @@ -115,5 +124,14 @@ SEGMENTS { CRYPTO_BSS: load = CRYPTO_RESIDENT, type = bss; TABLES_BSS: load = CRYPTO_RESIDENT, type = bss, align = $100; + # Phase 3: ip65 backend stays at the historical 47 KB PRG size -- + # USE_OVERLAY_P384_EMBED is gated off in the Makefile under ip65, + # so src/crypto/shared/p384_overlay_blobs.s emits no bytes and + # both segments below stay empty. The segment declarations are + # kept (`optional = yes`) so the cfg parses identically across + # backends and the ip65/UCI link line stays uniform. + OVERLAY_BLOB_SHA384: load = CRYPTO_OVERLAY, type = ro, optional = yes; + OVERLAY_BLOB_CURVE: load = OVERLAY_BLOB_CURVE_RAM, type = ro, optional = yes; + TCP_RECV_BUF: load = TCP_BUF, type = bss, optional = yes; } diff --git a/cfg/c64-https-uci.cfg b/cfg/c64-https-uci.cfg index b33ae9e..9cedb90 100644 --- a/cfg/c64-https-uci.cfg +++ b/cfg/c64-https-uci.cfg @@ -47,7 +47,34 @@ MEMORY { CRYPTO_OVERLAY: start = $4200, size = $1E00, file = %O, define = yes, fill = yes, fillval = $00; CRYPTO_RESIDENT: start = $6000, size = $6000, file = %O, define = yes, fill = yes, fillval = $00; - TCP_BUF: start = $C000, size = $1000, type = rw, define = yes; + # Phase 3: file-backed pad region from $C000-$DFFF. ld65 emits + # contiguous file output; the under-KERNAL OVERLAY_BLOB_CURVE_RAM + # region at $E000-$FDFF requires the gap between CRYPTO_RESIDENT + # and $E000 to land in the file as zeros so KERNAL LOAD writes the + # curve blob bytes to $E000 (not $C801). $C000-$CFFF is TCP_BUF + # at runtime (RAM, populated by ip65/UCI rx callback after net + # init); the zero-fill PRG-load write is harmless because TCP_BUF + # is zero-initialised at first use anyway. $D000-$DFFF is I/O on + # a real C64 + 1541 the PRG load WOULD momentarily corrupt VIC / + # SID / CIA registers; production targets are VICE warp + U64 + # fastload, both of which inject bytes directly to RAM and bypass + # CPU I/O writes during PRG load. + OVERLAY_FILE_PAD: start = $C000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; + + # Phase 3: under-KERNAL ROM RAM at $E000-$FDFF holds the P-384 + # CURVE overlay blob (7,680 B) at PRG load time. Boot DMAs it to + # REU bank 7 then this region is reusable. KERNAL LOAD writes + # pass through to the underlying RAM regardless of $01 banking. + OVERLAY_BLOB_CURVE_RAM: start = $E000, size = $1E00, file = %O, define = yes, fill = yes, fillval = $00; + + # NOTE: the historical TCP_BUF MEMORY region at $C000-$CFFF was + # removed in Phase 3 -- the actual TCP rx ring is just the + # `tcp_recv_buf = $c000` equate in src/constants.inc and the bytes + # at runtime live inside OVERLAY_FILE_PAD's address range. PRG + # load zeros the ring; the rx callback overwrites it after net + # init. The optional TCP_RECV_BUF segment was also dropped from + # SEGMENTS below (no .s file references the segment name; the + # buffer is addressed via the equate, not via a segment label). } SEGMENTS { @@ -111,5 +138,15 @@ SEGMENTS { CRYPTO_BSS: load = CRYPTO_RESIDENT, type = bss; TABLES_BSS: load = CRYPTO_RESIDENT, type = bss, align = $100; - TCP_RECV_BUF: load = TCP_BUF, type = bss, optional = yes; + # Phase 3: P-384 split overlay blobs embedded in the PRG. Boot + # DMAs them out to REU banks 6/7 then the staging RAM is free. + # Both segments are `optional = yes` so that builds without the + # .bin files (e.g. before make p384-overlay has run) still link; + # in that case the segments are empty and boot's reu_p384_overlay_init + # DMAs zero bytes. The .ifndef USE_X25519_SIBLING guard inside + # src/crypto/shared/p384_overlay_blobs.s keeps the segments empty + # under the sibling flag (CRYPTO_OVERLAY is taken by X25519_RODATA + # in that build). + OVERLAY_BLOB_SHA384: load = CRYPTO_OVERLAY, type = ro, optional = yes; + OVERLAY_BLOB_CURVE: load = OVERLAY_BLOB_CURVE_RAM, type = ro, optional = yes; } diff --git a/cfg/p384-overlay-curve.cfg b/cfg/p384-overlay-curve.cfg new file mode 100644 index 0000000..815ffe0 --- /dev/null +++ b/cfg/p384-overlay-curve.cfg @@ -0,0 +1,42 @@ +# cfg/p384-overlay-curve.cfg — ld65 config for the curve / verify half of +# the split P-384 overlay (Phase 1.5). +# +# Holds: fp384, mod384, points384 (post Lim-Lee strip), curve384, +# ecdsa384 (verify_384 ONLY — the verify_with_message_384 wrapper that +# imports sha384_init/update/final is dropped here; TLS calls the SHA +# overlay separately and pre-stages the digest into the resident DATA +# struct), and the ec_scalar_mul_384 -> ec_scalar_mul_var_384 shim. +# +# Loaded into the live UCI CRYPTO_OVERLAY slot ($4200-$5FFF, 7.5 KB) +# AFTER the sha384 half has done its work. The TLS dispatcher sequence: +# 1. swap-in sha384 overlay -> sha384_init / update* / final +# 2. swap-in curve overlay -> ecdsa_verify_384 +# The 240 B BE input struct (ecdsa_inputs_384) and the 48 B SHA digest +# (sha384_digest) live in CRYPTO_RESIDENT DATA at $C000 so they survive +# the swap. +# +# Layout matches the live UCI CRYPTO_OVERLAY base ($4200) and size +# ($1E00 = 7.5 KB) so the .bin DMAs into the live slot cleanly at +# harness time. + +FEATURES { + STARTADDRESS: default = $4200; +} + +MEMORY { + ZP: start = $0022, size = $001E, type = rw, define = yes; + OVERLAY_REGION: start = $4200, size = $1E00, file = %O, define = yes, + fill = yes, fillval = $00; + RESIDENT: start = $C000, size = $1000, type = rw, define = yes; +} + +SEGMENTS { + ZEROPAGE: load = ZP, type = zp, optional = yes; + + OVERLAY_P384_CURVE: load = OVERLAY_REGION, type = ro; + + # Resident RW buffers — we don't write them to the .bin, but they + # need real addresses so labels are correct. + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; +} diff --git a/cfg/p384-overlay-sha384.cfg b/cfg/p384-overlay-sha384.cfg new file mode 100644 index 0000000..c020bbf --- /dev/null +++ b/cfg/p384-overlay-sha384.cfg @@ -0,0 +1,43 @@ +# cfg/p384-overlay-sha384.cfg — ld65 config for the SHA-384 half of the +# split P-384 overlay (Phase 1.5). +# +# Phase 1b's monolithic OVERLAY_P384 (12,836 B) overflowed the live UCI +# CRYPTO_OVERLAY slot (7,680 B / 7.5 KB at $4200-$5FFF). The fix is to +# load the SHA-384 hash code and the curve / verify code as two separate +# overlay images; only one is resident at a time. The TLS path drives +# them in sequence: +# 1. swap-in sha384 overlay -> sha384_init / update* / final +# 2. swap-in curve overlay -> ecdsa_verify_384 (digest pre-staged in +# ecdsa_inputs_384[96..143] in resident DATA) +# +# The DATA / BSS exports stay in CRYPTO_RESIDENT (the same slot Phase 1b +# pinned them at, $C000) so the SHA digest survives the swap window +# between sha384_final and ecdsa_verify_384. See Phase 1b's +# build_nistcurves_p384.sh + the comment block at the top of +# src/crypto/shared/crypto_swap.s for the full rationale. +# +# Layout matches the live UCI CRYPTO_OVERLAY base ($4200) and size +# ($1E00 = 7.5 KB) so the .bin DMAs into the live slot cleanly at +# harness time. + +FEATURES { + STARTADDRESS: default = $4200; +} + +MEMORY { + ZP: start = $0022, size = $001E, type = rw, define = yes; + OVERLAY_REGION: start = $4200, size = $1E00, file = %O, define = yes, + fill = yes, fillval = $00; + RESIDENT: start = $C000, size = $1000, type = rw, define = yes; +} + +SEGMENTS { + ZEROPAGE: load = ZP, type = zp, optional = yes; + + OVERLAY_P384_SHA384: load = OVERLAY_REGION, type = ro; + + # Resident RW buffers — we don't write them to the .bin, but they + # need real addresses so labels are correct. + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; +} diff --git a/cfg/p384-overlay.cfg b/cfg/p384-overlay.cfg deleted file mode 100644 index 5ddd902..0000000 --- a/cfg/p384-overlay.cfg +++ /dev/null @@ -1,42 +0,0 @@ -# cfg/p384-overlay.cfg — minimal ld65 config for extracting the P-384 -# OVERLAY image as a standalone binary, used only by -# `tools/integration/build_nistcurves_p384_bin.sh`. -# -# NOT USED by the main c64-https PRG build. The production PRG does NOT -# link the P-384 archive (Phase C.3b keeps P-384 external / smoke-test-only); -# this cfg exists purely so we can extract a padded 8 KB binary image plus -# a VICE-format labels file that `tools/test_p384_symbols.py` loads into -# REU at harness time. -# -# Layout: -# $4200-$61FF : OVERLAY_P384 region (8 KB, padded with $00). Matches the -# CRYPTO_OVERLAY base under the UCI cfg so the image DMAs -# into the live overlay slot cleanly at harness time. -# $C000-$CFFF : RESIDENT — holds the P-384 RW buffers (DATA / BSS). -# These addresses intentionally land inside TCP_BUF -# ($C000-$CFFF) because networking is NOT active during -# the P-384 smoke test — the TCP ring is free space. -# This avoids clashing with the main PRG's CRYPTO code -# segments at $7C00-$BFFF which remain live. - -FEATURES { - STARTADDRESS: default = $4200; -} - -MEMORY { - ZP: start = $0022, size = $001E, type = rw, define = yes; - OVERLAY_REGION: start = $4200, size = $2000, file = %O, define = yes, - fill = yes, fillval = $00; - RESIDENT: start = $C000, size = $1000, type = rw, define = yes; -} - -SEGMENTS { - ZEROPAGE: load = ZP, type = zp, optional = yes; - - OVERLAY_P384: load = OVERLAY_REGION, type = ro; - - # Resident RW buffers — we don't write them to the .bin, but they - # need real addresses so labels are correct. - DATA: load = RESIDENT, type = rw, optional = yes; - BSS: load = RESIDENT, type = bss, optional = yes; -} diff --git a/libs/nistcurves b/libs/nistcurves index 19f95d7..90830c9 160000 --- a/libs/nistcurves +++ b/libs/nistcurves @@ -1 +1 @@ -Subproject commit 19f95d792587f1c4e04f13f483cdeca244b502a9 +Subproject commit 90830c920af7fcc5ded7da6b4dd201ab535e57b4 diff --git a/src/boot.s b/src/boot.s index 079b953..9707bad 100644 --- a/src/boot.s +++ b/src/boot.s @@ -24,6 +24,9 @@ .export reu_fetch_mul_row .endif + ; ---- exports: Phase 3 P-384 overlay REU stash ---- + .export reu_p384_overlay_init + ; ---- exports: menu handlers ---- .export do_net_init .export do_http_get @@ -131,6 +134,27 @@ .import poly_prod_lo .import poly_prod_hi + ; ---- imports: Phase 3 embedded P-384 overlay blob anchors ---- + ; Resolved by src/crypto/shared/p384_overlay_blobs.s when + ; USE_OVERLAY_P384_EMBED is on; the symbols are weak/optional + ; in the same way OVERLAY_BLOB_* segments are optional in the + ; cfg. reu_p384_overlay_init below is .ifdef-gated so it does + ; not reference the symbols when the flag is off (otherwise the + ; .import would fail for a missing symbol). + .ifdef USE_OVERLAY_P384_EMBED + .import p384_overlay_sha384_blob + .import p384_overlay_curve_blob + ; Re-include the REU layout header so REU_OVERLAY_P384_* + ; (24-bit) and OVERLAY_SIZE (16-bit) resolve as local literals + ; at assembly time rather than as cross-TU imports. This + ; sidesteps the ld65 "size mismatch" warning that fires when a + ; 24-bit export from crypto_swap.o is .import'd as the default + ; 16-bit absolute (ca65 has no `:far` attribute on the 6502 + ; CPU). The header is `.ifndef`-guarded so the duplicate + ; include is a no-op aside from making the equates visible. + .include "reu_layout.inc" + .endif + ; ============================================================================= ; BASIC stub: 10 SYS 2061 ; Loaded at $0801 via EXEHDR segment (first bytes of LOADER region). @@ -217,6 +241,12 @@ start: sta $01 jsr reu_mul_init + ; Phase 3: stash both P-384 split overlay images in REU banks 6 + ; and 7 from the .incbin'd staging blocks at $4200 and $E000. + ; Inert under USE_X25519_SIBLING=1 / BACKEND=ip65 (see + ; reu_p384_overlay_init's body for the conditional). + jsr reu_p384_overlay_init + ; Auto-initialize networking at boot so the banner shows the ; firmware-assigned IP without waiting for the user to press 'I'. ; On ip65 this runs the full cs8900a + DHCP handshake; on the @@ -755,6 +785,130 @@ reu_fetch_mul_row: .endif ; .ifndef USE_X25519_SIBLING (in-tree reu_mul_init / reu_fetch_mul_row) +; ============================================================================= +; reu_p384_overlay_init - Stash both P-384 split-overlay images in REU. +; +; Reads the two .incbin'd images at p384_overlay_sha384_blob ($4200) and +; p384_overlay_curve_blob ($E000) and STASHes (C64->REU) each into the +; REU bank reserved by src/crypto/shared/reu_layout.inc: +; +; REU bank 6 ($60000) <- $4200..$5FFF (OVERLAY_SIZE bytes, sha384) +; REU bank 7 ($70000) <- $E000..$FDFF (OVERLAY_SIZE bytes, curve) +; +; After this returns, the live CRYPTO_OVERLAY slot at $4200 still holds +; the SHA-384 image bytes -- but the linker considers it free (no segment +; references the bytes by symbol after this point) so a subsequent +; jsr crypto_swap_to_p384_curve will overwrite the slot with the curve +; image from REU bank 7. The under-KERNAL block at $E000-$FDFF is +; freed unconditionally; KERNAL ROM is banked in by default so future +; reads from $E000 hit ROM, not the no-longer-needed blob bytes. +; +; Inert when USE_OVERLAY_P384_EMBED is undefined (BACKEND=ip65, or +; USE_X25519_SIBLING=1 under UCI) -- the routine compiles to a single +; RTS so the call site in `start` is harmless. +; +; SEI around each DMA window; restores caller's I flag. ~16 ms total +; wall-clock at any CPU speed (REU DMA bus runs at ~1 MHz regardless +; of turbo). +; +; Clobbers: A. Does NOT update current_overlay -- crypto_swap_none has +; that responsibility; boot calls neither because the BSS reset at +; entry already left current_overlay = OV_NONE = 0. +; ============================================================================= +reu_p384_overlay_init: +.ifdef USE_OVERLAY_P384_EMBED + ; --- Stash 1: $4200 (SHA blob) -> REU bank 6, offset $0000 --- + php + sei + lda #p384_overlay_sha384_blob + sta reu_c64_hi + lda #REU_OVERLAY_P384_SHA384 + sta reu_reu_hi + lda #^REU_OVERLAY_P384_SHA384 + sta reu_reu_bank + lda #OVERLAY_SIZE + sta reu_len_hi + lda #0 + sta reu_addr_ctrl ; both addresses autoincrement + lda #$90 ; execute + STASH (C64->REU) + sta reu_command + plp + + ; --- Intermediate: copy CURVE blob from $E000 -> CRYPTO_OVERLAY --- + ; The CURVE blob lives in RAM under KERNAL ROM at $E000-$FDFF. + ; VICE's REU emulator reads C64 RAM via a path that does NOT + ; respect $01 banking for the $E000-$FFFF range -- a STASH + ; from $E000 with KERNAL banked off still returns ROM bytes + ; (and on bank 7 specifically returns an undefined fill + ; pattern, see the empirical results documented in Phase 3 + ; commit). Workaround: CPU-copy the blob from $E000 (with + ; KERNAL banked off so the LDA sees RAM) into the now-free + ; CRYPTO_OVERLAY slot at $4200 (the SHA-384 blob has already + ; been stashed to REU bank 6, so the slot bytes are no longer + ; load-bearing), then STASH from $4200. CPU copy is + ; ~7,680 * 5 cy ~= 38 K cycles ~= 38 ms at 1 MHz / ~0.8 ms at + ; 48 MHz -- negligible vs the DMA latency itself. + php + sei + lda $01 + pha ; save banking + and #%11111101 ; clear bit 1 (KERNAL ROM off, RAM at $E000) + sta $01 + + ; Copy 30 pages ($1E00 = 7,680 B) from $E000-$FDFF to $4200-$5FFF + ; via self-modifying base+Y indexing. Y walks 0..255; outer + ; loop bumps the high byte of both src and dst pointers. + lda #$E0 + sta @cp_src+2 + lda #$42 + sta @cp_dst+2 + ldx #30 ; 30 pages = $1E00 bytes +@cp_page: + ldy #0 +@cp_byte: +@cp_src: + lda $E000,y ; high byte self-modified above +@cp_dst: + sta $4200,y ; high byte self-modified above + iny + bne @cp_byte + inc @cp_src+2 + inc @cp_dst+2 + dex + bne @cp_page + + pla ; restore banking (KERNAL back on) + sta $01 + + ; --- Stash 2: $4200 (CURVE blob, freshly copied) -> REU bank 7 --- + lda #$00 + sta reu_c64_lo + lda #$42 + sta reu_c64_hi + lda #REU_OVERLAY_P384_CURVE + sta reu_reu_hi + lda #^REU_OVERLAY_P384_CURVE + sta reu_reu_bank + lda #OVERLAY_SIZE + sta reu_len_hi + lda #0 + sta reu_addr_ctrl + lda #$90 + sta reu_command + plp +.endif ; .ifdef USE_OVERLAY_P384_EMBED + rts + ; ============================================================================= ; Strings (read-only) ; ============================================================================= diff --git a/src/crypto/ecdsa_verify.s b/src/crypto/ecdsa_verify.s index 7f3b1f1..f27a579 100644 --- a/src/crypto/ecdsa_verify.s +++ b/src/crypto/ecdsa_verify.s @@ -13,7 +13,10 @@ ; ; Output: C=0 signature VALID, C=1 INVALID or unsupported curve. ; -; P-384 dispatch remains stubbed (see project_p384_stubbed memory note). +; Phase 4a: P-384 dispatch jumps to ecdsa_verify_384_tls in +; src/crypto/ecdsa_verify_384.s, which composes the dual-overlay swap +; (sha384 -> curve) plus sibling ecdsa_verify_384. See that file's +; header for the per-step contract and the SHA-384 transcript caveat. ; ============================================================================= .include "constants.inc" @@ -24,6 +27,9 @@ .import ec_gx256, ec_gy256 .import ec_base_x, ec_base_y +; --- Phase 4a: P-384 TLS dispatcher (src/crypto/ecdsa_verify_384.s) --- +.import ecdsa_verify_384_tls + ; --- State buffers (in-tree data.s) --- .import ecdsa_curve_id .import ecdsa_hash @@ -55,9 +61,10 @@ ecdsa_verify: ; redundant and has been removed to save bytes. lda ecdsa_curve_id beq @p256 - ; P-384 verify still stubbed (project_p384_stubbed). - sec - rts + ; Phase 4a: P-384 dispatcher composes the dual-overlay swap + ; (sha384 -> curve) + sibling ecdsa_verify_384. Tail-call so + ; the dispatcher's carry return propagates as our return. + jmp ecdsa_verify_384_tls @p256: ; The TLS-populated ecdsa_sig_r, ecdsa_sig_s, ecdsa_hash, diff --git a/src/crypto/ecdsa_verify_384.s b/src/crypto/ecdsa_verify_384.s new file mode 100644 index 0000000..934abf3 --- /dev/null +++ b/src/crypto/ecdsa_verify_384.s @@ -0,0 +1,474 @@ +; ============================================================================= +; ecdsa_verify_384.s - Phase 4a TLS-side P-384 verify dispatcher. +; +; Composes the dual-overlay swap dance + SHA-384 hashing + sibling +; ecdsa_verify_384 into a single entry callable from +; src/crypto/ecdsa_verify.s::ecdsa_verify when ecdsa_curve_id = 1. +; +; Call sequence (matches the design template at the top of +; src/crypto/shared/crypto_swap.s): +; +; 1. Parse the DER ECDSA signature out of tls_rec_buf into the BE +; r/s slots of ecdsa_inputs_384 (48 B each, slots +0 and +48). +; 2. Copy the 48-byte big-endian server pubkey (X then Y) into +; ecdsa_inputs_384 slots +144 and +192. +; +; Phase 5 Fix B: src/data.s defines separate 48 B slots +; ecdsa_pubkey_x_384 and ecdsa_pubkey_y_384 in CRYPTO_BSS; +; src/tls_cert.s's cert handler dispatches on ecdsa_curve_id and +; writes the P-384 pubkey into those slots when the leaf cert +; advertises secp384r1. The dispatcher reads from the _384 slots +; for the verify input, leaving the contiguous 32 B P-256 packed +; struct (r|s|h|Qx|Qy) intact for ecdsa_verify_256. +; 3. Build the TLS 1.3 §4.4.3 signed-content blob (130 bytes) at +; $CA00 in tcp_recv_buf scratch RAM: +; [0..63] 64 spaces (0x20) +; [64..96] "TLS 1.3, server CertificateVerify" (33 bytes) +; [97] 0x00 separator +; [98..129] transcript hash (32 bytes — SHA-256) +; tcp_recv_buf is idle during crypto and the chosen window +; ($CA00..$CA81) sits well above both overlays' resident DATA +; ranges (SHA overlay ends at $C411, curve overlay at $C9F7) so +; it survives the swap. +; +; Phase 5 Fix A: blob length is 130 bytes, not 146. RFC 8446 +; §4.4.1 specifies the transcript-hash uses the negotiated cipher +; suite's hash function — c64-https only negotiates +; TLS_AES_128_GCM_SHA256, so the transcript is always 32 B SHA-256 +; regardless of the signature scheme. The 46+33+1+32 = 130 layout +; is what the server signed; padding to 48 B for SHA-384's digest +; width would feed the verifier a different message than the one +; the server hashed. SHA-384(blob) still produces a 48 B digest +; that is spliced into ecdsa_inputs_384[96..143] (h slot) — the +; hash function and digest size for the signature itself are +; independent from the transcript-hash function. +; 4. crypto_swap_to_p384_sha384 -> sha384_init / update / final. +; sha384_digest (48 B BE) lands at $C3E1 in the SHA overlay's +; resident DATA. +; 5. Splice digest into ecdsa_inputs_384[96..143] (h slot). +; 6. crypto_swap_to_p384_curve -> ecdsa_verify_384. +; C=0 valid / C=1 invalid -- propagated to caller. +; +; Phase 5 note: c64-https only negotiates TLS_AES_128_GCM_SHA256, so +; the TLS 1.3 transcript-hash function is always SHA-256 (RFC 8446 +; §4.4.1 ties transcript-hash to the cipher suite's hash, not to the +; signature_algorithm). The signed-content blob therefore embeds a +; 32 B SHA-256 transcript verbatim (no padding), totalling 130 bytes. +; SHA-384 then hashes the 130 B blob and produces a 48 B digest that +; goes into ecdsa_inputs_384's h slot for the P-384 verifier. The +; previous Phase 4a draft (146 B blob with the 32 B transcript zero- +; padded to 48 B) is superseded by Phase 5 Fix A. +; +; Overlay-resident symbols: the sibling overlay images +; (overlay-p384-sha384.bin / overlay-p384-curve.bin) are NOT linked +; into the c64-https PRG -- they're DMA'd in at runtime via +; crypto_swap_to_p384_*. Their entry points and resident DATA +; addresses are therefore declared as numeric equates here (sourced +; from build/labels-p384-sha384.txt and build/labels-p384-curve.txt; +; pinned by cfg/p384-overlay-{sha384,curve}.cfg). If the overlay +; images are rebuilt and the addresses move, this file's equates +; must be re-synced -- there is no link-time check. +; +; ZP usage: $3D-$44 (sha_src $3D/$3E, sha_len $3F/$40, sha_w_ptr +; $41/$42, sha_w_ptr2 $43/$44) -- per Phase 1.5 these slots are +; demonstrably unused by c64-https/ip65/UCI/fe25519/x25519/ECDSA +; bignum across the SHA-384 window, so no save/restore is required. +; Also clobbers $FB-$FC (zp_ptr) for DER walk and $FE-$FF (zp_count) +; via tls_rec_buf indirect access. +; ============================================================================= + + .include "constants.inc" + + ; --- TLS-side state we read --- + .import tls_rec_buf ; CertificateVerify message buffer + .import tls_transcript ; 32 B running SHA-256 transcript + .import ecdsa_pubkey_x_384 ; 48 B server pubkey X (Phase 5 Fix B) + .import ecdsa_pubkey_y_384 ; 48 B server pubkey Y (Phase 5 Fix B) + + ; --- Overlay swap entry points (in main PRG, always-resident) --- + .import crypto_swap_to_p384_sha384 + .import crypto_swap_to_p384_curve + + .export ecdsa_verify_384_tls + +; ----------------------------------------------------------------------------- +; Overlay-resident symbol equates (NOT linked from the main PRG). +; Sourced from build/labels-p384-sha384.txt + build/labels-p384-curve.txt +; via tools/integration/gen_p384_overlay_equates.sh, regenerated by the +; Makefile whenever either overlay labels file changes (Phase 5 Fix C). +; +; Both overlays load at $4200 (CRYPTO_OVERLAY) and their resident DATA +; lives at $C000+ (TCP_BUF range). The build-time .assert pins below +; catch drift if either invariant breaks (e.g. an overlay cfg restructure +; moves sha384_init off $4200 or pushes ecdsa_inputs_384 outside $C000+). +; ----------------------------------------------------------------------------- + +; Phase 5 Fix D: gate the .include on USE_OVERLAY_P384_EMBED so the +; dispatcher .o can compile during the bootstrap labels-only link +; (USE_OVERLAY_P384_EMBED=0) before the overlay-bins exist and the +; generated equates .inc has been produced. Under +; USE_OVERLAY_P384_EMBED=0 the dispatcher entry isn't actually +; reachable from the boot path (p384_overlay_blobs.s is empty so +; reu_p384_overlay_init is inert; tls_handle_cert_verify will still +; route here for sig_scheme=0x0503 but the call would land on a +; non-populated overlay slot — production builds always run with +; USE_OVERLAY_P384_EMBED=1). Stub equates suffice to satisfy the +; assembler when the .inc is absent. +.ifdef USE_OVERLAY_P384_EMBED + .include "p384_overlay_equates.inc" +.else + ; Stub equates for the labels-only bootstrap link. Values are + ; deliberately within the legal slot range so the .asserts pass + ; even though they don't point at real overlay code. +sha384_init = $4200 +sha384_update = $4200 +sha384_final = $4200 +sha384_digest = $C000 +ecdsa_verify_384 = $4200 +ecdsa_inputs_384 = $C000 +.endif + +; Build-time pins. CRYPTO_OVERLAY is $4200..$5FFF and overlay DATA +; resides at $C000..$CFFF (TCP_BUF, idle during crypto). If a regenerated +; equates file violates either range, ld65 / ca65 won't catch it; these +; .asserts will. +.assert sha384_init = $4200, error, "sha384_init must be at the overlay slot start ($4200)" +.assert sha384_update >= $4200 .and sha384_update < $6000, error, "sha384_update outside CRYPTO_OVERLAY" +.assert sha384_final >= $4200 .and sha384_final < $6000, error, "sha384_final outside CRYPTO_OVERLAY" +.assert sha384_digest >= $C000 .and sha384_digest < $D000, error, "sha384_digest outside overlay-resident DATA range" +.assert ecdsa_verify_384 >= $4200 .and ecdsa_verify_384 < $6000, error, "ecdsa_verify_384 outside CRYPTO_OVERLAY" +.assert ecdsa_inputs_384 >= $C000 .and ecdsa_inputs_384 < $D000, error, "ecdsa_inputs_384 outside overlay-resident DATA range" + +; ----------------------------------------------------------------------------- +; ZP slots dedicated to SHA-384 (Phase 1.5). +; ----------------------------------------------------------------------------- +sha_src = $3D ; 2 B pointer to message bytes +sha_len = $3F ; 2 B 16-bit length + +; ----------------------------------------------------------------------------- +; Signed-content blob staging address. 130 B in tcp_recv_buf scratch +; ($CA00..$CA81). Phase 5 Fix A: shrunk from 146 B because the TLS 1.3 +; transcript-hash is SHA-256 (32 B) not SHA-384 (48 B); see file header. +; ----------------------------------------------------------------------------- +SIGNED_BLOB_ADDR = $CA00 +SIGNED_BLOB_LEN = 130 ; 64 + 33 + 1 + 32 (RFC 8446 §4.4.3) + +; Compile-time assertion: 64-space pad + label + sep + SHA-256 transcript = 130. +.assert (64 + 33 + 1 + 32) = SIGNED_BLOB_LEN, error, "P-384 signed-content blob length" + + + .segment "CRYPTO_AUX_CODE" + +; ============================================================================= +; ecdsa_verify_384_tls - dispatcher entry, called from ecdsa_verify +; when ecdsa_curve_id = 1. +; +; Inputs (set up by the TLS layer before tls_handle_cert_verify reaches +; the P-384 short-circuit at src/tls_cert.s): +; tls_rec_buf+0..3 handshake header (type=15, len) +; tls_rec_buf+4..5 signature_scheme = 0x0503 +; tls_rec_buf+6..7 16-bit signature length (BE; high byte = 0) +; tls_rec_buf+8.. DER-encoded ECDSA signature (SEQUENCE { r, s }) +; ecdsa_pubkey_x_384 48 B server pubkey X (BE, from cert; Phase 5 Fix B) +; ecdsa_pubkey_y_384 48 B server pubkey Y (BE, from cert; Phase 5 Fix B) +; tls_transcript 32 B SHA-256 transcript (Phase 4a placeholder -- +; see CAVEAT in file header) +; +; Output: C=0 signature VALID, C=1 INVALID/malformed. +; ============================================================================= +ecdsa_verify_384_tls: + ; ----------------------------------------------------------------- + ; Step 0: zero out the full 240 B BE input struct so any failed + ; intermediate step leaves a deterministic state (helps + ; post-mortem DMA reads). + ; ----------------------------------------------------------------- + lda #0 + ldx #0 +@clr_struct: + sta ecdsa_inputs_384,x + inx + cpx #240 + bne @clr_struct + + ; ----------------------------------------------------------------- + ; Step 1: parse DER signature into ecdsa_inputs_384[0..47] (r) + ; and ecdsa_inputs_384[48..95] (s). Both 48 B BE, right-aligned. + ; The sig bytes start at tls_rec_buf+8. + ; ----------------------------------------------------------------- + lda #<(tls_rec_buf+8) + sta zp_ptr + lda #>(tls_rec_buf+8) + sta zp_ptr+1 + jsr parse_der_sig_384 + bcc @sig_parsed + sec + rts ; malformed DER -> propagate failure +@sig_parsed: + + ; ----------------------------------------------------------------- + ; Step 2: copy pubkey X -> ecdsa_inputs_384+144 (Qx slot, 48 B). + ; copy pubkey Y -> ecdsa_inputs_384+192 (Qy slot, 48 B). + ; ----------------------------------------------------------------- + ldx #47 +@copy_qx: + lda ecdsa_pubkey_x_384,x + sta ecdsa_inputs_384+144,x + dex + bpl @copy_qx + + ldx #47 +@copy_qy: + lda ecdsa_pubkey_y_384,x + sta ecdsa_inputs_384+192,x + dex + bpl @copy_qy + + ; ----------------------------------------------------------------- + ; Step 3: build 146 B signed-content blob at SIGNED_BLOB_ADDR. + ; ----------------------------------------------------------------- + ; [0..63] 64 spaces + ldx #63 + lda #$20 +@fill_spaces: + sta SIGNED_BLOB_ADDR,x + dex + bpl @fill_spaces + + ; [64..96] 33-byte label "TLS 1.3, server CertificateVerify" + ldx #32 ; label is 33 bytes (index 0..32) +@copy_label: + lda cv_label_384,x + sta SIGNED_BLOB_ADDR+64,x + dex + bpl @copy_label + + ; [97] 0x00 separator + lda #$00 + sta SIGNED_BLOB_ADDR+97 + + ; [98..129] transcript hash (32 B SHA-256). Phase 5 Fix A: + ; copy the 32 B SHA-256 tls_transcript verbatim — no padding. + ; The TLS 1.3 transcript-hash is bound to the cipher suite + ; (SHA-256 via TLS_AES_128_GCM_SHA256), independent from the + ; signature_algorithm's hash (SHA-384 here). Padding to 48 B + ; would feed the verifier a different message than the server + ; signed. + ldx #31 +@copy_xcript: + lda tls_transcript,x + sta SIGNED_BLOB_ADDR+98,x + dex + bpl @copy_xcript + + ; ----------------------------------------------------------------- + ; Step 4: swap in SHA-384 overlay and hash the blob. + ; ----------------------------------------------------------------- + jsr crypto_swap_to_p384_sha384 + + jsr sha384_init + + lda #SIGNED_BLOB_ADDR + sta sha_src+1 + lda #SIGNED_BLOB_LEN + sta sha_len+1 + jsr sha384_update + + jsr sha384_final ; sha384_digest := SHA-384(blob) + + ; ----------------------------------------------------------------- + ; Step 5: splice digest into ecdsa_inputs_384[96..143] (h slot). + ; sha384_digest survives the upcoming curve-overlay swap because + ; the curve overlay's resident DATA also starts at $C000+ and + ; doesn't write the $C3E1..$C411 range until ecdsa_verify_384 + ; runs -- and we copy out before triggering the swap. + ; ----------------------------------------------------------------- + ldx #47 +@splice_h: + lda sha384_digest,x + sta ecdsa_inputs_384+96,x + dex + bpl @splice_h + + ; ----------------------------------------------------------------- + ; Step 6: swap in curve / verify overlay and call ecdsa_verify_384. + ; ----------------------------------------------------------------- + jsr crypto_swap_to_p384_curve + + lda #ecdsa_inputs_384 + jmp ecdsa_verify_384 ; tail-call: C return passes through + + +; ============================================================================= +; parse_der_sig_384 - Parse ASN.1 DER ECDSA signature into +; ecdsa_inputs_384[0..47] (r) and ecdsa_inputs_384[48..95] (s). +; +; Mirrors the in-tree ecdsa_parse_der_sig logic from ecdsa_verify.s but +; with 48-byte (P-384) component slots and writes into the absolute BE +; struct rather than the legacy ecdsa_sig_r/s 32 B labels. +; +; Input: zp_ptr = pointer to SEQUENCE start. +; Output: ecdsa_inputs_384[0..47] = r (BE, right-aligned, zero-padded) +; ecdsa_inputs_384[48..95] = s (BE, right-aligned, zero-padded) +; C=0 success, C=1 malformed. +; +; DER format: 30 02 02 +; INTEGERs may have a leading 0x00 padding byte if the high bit is set. +; ============================================================================= + +R384_LEN = 48 +S384_OFFS = 48 ; ecdsa_inputs_384[48..95] is s slot + +parse_der_sig_384: + ldy #0 + + ; Expect SEQUENCE tag (0x30) + lda (zp_ptr),y + cmp #$30 + bne @der_error + iny + + ; Skip SEQUENCE length byte (assume <= 127 -- short-form DER, true + ; for any P-384 ECDSA signature whose total payload <= 110 B). + iny + + ; --- Parse INTEGER r --- + lda (zp_ptr),y + cmp #$02 + bne @der_error + iny + lda (zp_ptr),y + sta der_int_len_384 + iny + + ; r slot is already zero (Step 0 cleared all 240 B); just compute + ; right-align offset and copy. + jsr parse_int_r + bcs @der_error + + ; --- Parse INTEGER s --- + lda (zp_ptr),y + cmp #$02 + bne @der_error + iny + lda (zp_ptr),y + sta der_int_len_384 + iny + jsr parse_int_s + bcs @der_error + + clc + rts + +@der_error: + sec + rts + + +; --------------------------------------------------------------------------- +; parse_int_r - Copy DER INTEGER bytes into ecdsa_inputs_384[0..47]. +; Y advances over the parsed bytes (caller-visible). +; Returns C=0 ok, C=1 malformed. +; --------------------------------------------------------------------------- +parse_int_r: + lda der_int_len_384 + cmp #R384_LEN+1 ; 49: leading-zero pad case + beq @r_skip_pad + cmp #R384_LEN+1 + bcs @der_int_too_long + bcc @r_no_pad +@r_skip_pad: + ; int_len = 49 -> consume one leading 0x00 padding byte. + lda (zp_ptr),y + bne @der_int_too_long ; pad byte must be zero + iny + lda #R384_LEN + sta der_int_len_384 +@r_no_pad: + ; int_len <= 48: right-align into ecdsa_inputs_384[0..47]. + ; dest start offset = 48 - int_len. + sec + lda #R384_LEN + sbc der_int_len_384 + tax ; X = dest offset + lda der_int_len_384 + sta der_copy_cnt_384 +@r_copy: + lda der_copy_cnt_384 + beq @r_done + lda (zp_ptr),y + sta ecdsa_inputs_384+0,x + iny + inx + dec der_copy_cnt_384 + jmp @r_copy +@r_done: + clc + rts +@der_int_too_long: + sec + rts + + +; --------------------------------------------------------------------------- +; parse_int_s - Copy DER INTEGER bytes into ecdsa_inputs_384[48..95]. +; Y advances over the parsed bytes. Returns C=0/1 same as +; parse_int_r. +; --------------------------------------------------------------------------- +parse_int_s: + lda der_int_len_384 + cmp #R384_LEN+1 + beq @s_skip_pad + bcs @der_int_too_long_s + bcc @s_no_pad +@s_skip_pad: + lda (zp_ptr),y + bne @der_int_too_long_s + iny + lda #R384_LEN + sta der_int_len_384 +@s_no_pad: + sec + lda #R384_LEN + sbc der_int_len_384 + tax + lda der_int_len_384 + sta der_copy_cnt_384 +@s_copy: + lda der_copy_cnt_384 + beq @s_done + lda (zp_ptr),y + sta ecdsa_inputs_384+S384_OFFS,x + iny + inx + dec der_copy_cnt_384 + jmp @s_copy +@s_done: + clc + rts +@der_int_too_long_s: + sec + rts + + +; ============================================================================= +; RODATA -- the 33-byte signed-content context string. +; ============================================================================= + .segment "CRYPTO_RODATA" + +cv_label_384: + .byte "TLS 1.3, server CertificateVerify" +.assert (* - cv_label_384) = 33, error, "P-384 CV label must be 33 bytes" + + +; ============================================================================= +; BSS -- DER parser scratch. +; ============================================================================= + .segment "BSS" + +der_int_len_384: .res 1 +der_copy_cnt_384: .res 1 diff --git a/src/crypto/shared/crypto_swap.s b/src/crypto/shared/crypto_swap.s index 9237057..6adb2a8 100644 --- a/src/crypto/shared/crypto_swap.s +++ b/src/crypto/shared/crypto_swap.s @@ -1,90 +1,222 @@ ; ============================================================================= -; crypto_swap.s - Crypto overlay DMA dispatcher +; crypto_swap.s - Crypto overlay DMA dispatcher (Phase 3 dual-overlay edition) ; -; Pages one of two 8 KB overlay images (P-256, P-384) from REU -; bank 2 into the live CRYPTO_OVERLAY region. Call sites prefix each -; overlay-targeting primitive with `jsr crypto_swap_to_`. +; Pages one of two REU-resident overlay images into the live CRYPTO_OVERLAY +; region on demand. Phase 3 adds two new entry points +; (crypto_swap_to_p384_sha384 / crypto_swap_to_p384_curve) that load the +; sha384 and curve halves of the split P-384 build (Phase 1.5) from REU +; banks 6 and 7, replacing the now-stale single-image +; crypto_swap_to_p384. The P-256 overlay swap was never exercised in +; production -- the in-tree P-256 path went away in Phase G and +; nistcurves-p256 is always-resident -- so the legacy crypto_swap_to_p256 +; entry has been dropped along with the OV_P256 state. ; ; Idempotent: re-entering with the same overlay already resident is a ; single-byte compare + rts (no DMA). ; ; Interrupt discipline: SEI around the DMA window; restores original I -; flag on exit. ~8 ms DMA latency at any CPU speed (REU bus runs at +; flag on exit. ~8 ms DMA latency at any CPU speed (REU bus runs at ; ~1 MHz regardless of turbo). ; -; Phase C.1 rollback note: the x25519 overlay integration was removed -; after it broke the TLS handshake at 48 MHz UCI. `crypto_swap_to_x25519` -; no longer exists; in-tree x25519 in `src/crypto/x25519.s` is -; always-resident. The remaining swap entry points exist for the -; external P-384 smoke test (tools/test_p384_symbols.py). +; ----------------------------------------------------------------------------- +; Overlay state machine +; ----------------------------------------------------------------------------- +; `current_overlay` is a single byte in CRYPTO_BSS (SHADOW_BSS-resident). +; Values are opaque to the swap engine -- they exist purely so callers +; can short-circuit a no-op swap. The four states this dispatcher knows +; about: ; -; `current_overlay`: 1 byte in CRYPTO_BSS (SHADOW_BSS-resident). -; 0 = none (uninitialized / swap_none) -; 2 = p256 -; 3 = p384 +; 0 = OV_NONE (uninitialized / swap_none -- after boot, +; before any P-384 swap; live slot bytes are +; undefined and MUST NOT be jsr'd into. Boot +; leaves current_overlay = OV_NONE so the +; first crypto_swap_to_p384_* call always +; DMAs.) +; 1 = OV_X25519_SIBLING (X25519 sibling rodata as set up by the +; Phase C.5 build under USE_X25519_SIBLING=1. +; Marker only -- there is no boot-time REU +; stash for the sibling rodata, so this entry +; does NOT DMA; it just records that the live +; slot already holds X25519 sibling rodata +; because the linker placed X25519_RODATA in +; CRYPTO_OVERLAY at PRG load time. Once a +; P-384 overlay has been swapped in, calling +; crypto_swap_to_x25519_sibling ALONE is NOT +; sufficient to restore X25519 rodata bytes; +; a follow-up phase needs to add a REU stash +; for the sibling rodata if that round-trip +; is ever required. Phase 3 leaves it as a +; state-only marker because the production +; TLS path (X25519 only / no P-384) never +; swaps anything in over X25519.) +; 4 = OV_P384_SHA384 (P-384 SHA-384 hash code + IV/K[80] RODATA; +; 5,456 B unpadded. REU bank 6, $60000.) +; 5 = OV_P384_CURVE (P-384 fp384 / mod384 / points384 / curve384 / +; ecdsa_verify_384 + shim; 7,317 B unpadded. +; REU bank 7, $70000.) ; -; `CRYPTO_OVERLAY_START` is defined by the linker (cfg `MEMORY { }` -; `define = yes` on the CRYPTO_OVERLAY region — see cfg/c64-https-*.cfg). +; (IDs 2 and 3 intentionally skipped to leave headroom for future +; overlays without renumbering.) +; +; ----------------------------------------------------------------------------- +; Boot-time invariants (Phase 3) +; ----------------------------------------------------------------------------- +; src/boot.s populates REU banks 6 and 7 from .incbin'd images at +; startup (see src/crypto/shared/p384_overlay_blobs.s and the +; reu_p384_overlay_init routine in boot.s). Once boot finishes, +; subsequent calls to crypto_swap_to_p384_sha384 / _curve simply DMA +; from those banks into the live slot at $4200. No call site needs +; to know about the boot-time staging. +; +; ----------------------------------------------------------------------------- +; TLS-side call sequence (Phase 4a will implement the dispatcher) +; ----------------------------------------------------------------------------- +; ; --- 1. Hash the handshake transcript --- +; jsr crypto_swap_to_p384_sha384 +; jsr sha384_init +; ldx #transcript ; jsr setup_sha_src/sha_len +; jsr sha384_update ; (one or more times) +; jsr sha384_final ; sha384_digest now holds the 48 B BE digest +; +; ; --- 2. Splice the digest into the resident BE input struct --- +; ; (resident DATA at $C000 survives the swap window) +; ldy #47 +; @cp: lda sha384_digest,y +; sta ecdsa_inputs_384+96,y +; dey +; bpl @cp +; +; ; --- 3. Swap in the curve / verify overlay and call verify --- +; jsr crypto_swap_to_p384_curve +; lda #ecdsa_inputs_384 +; jsr ecdsa_verify_384 +; ; C=0 VALID, C=1 INVALID/malformed +; +; Resident DATA invariants (Phase 1b -- Phase 1.5 preserves these): +; - sha384_digest (48 B) lives in the SHA archive's resident DATA; +; written by sha384_final, read by the TLS-side splice loop above. +; - ecdsa_inputs_384 (240 B BE struct: r|s|h|Qx|Qy each 48 B) lives +; in the curve archive's resident DATA; TLS pre-fills r/s/Qx/Qy, +; splices h from sha384_digest, then calls ecdsa_verify_384. +; - All other ec384_* / fp384_* / ecdsa384_* RW buffers and the +; sha_state / sha_w / sha_block_* SHA-384 state ALSO live in +; resident DATA. Resident DATA footprint is unchanged from +; Phase 1b's 3,541 B. +; +; ZP save/restore obligation (Phase 4a): +; Phase 1.5 moves the sibling's SHA-384 streaming pointer slots +; out of their default $04-$0B (which collide with c64-https's +; canonical $04-$09 = w32_* ChaCha20/Poly1305 and $0A-$0D = +; sha_temp1 SHA-256) into a free contiguous block at $3D-$44: +; sha_src = $3D / $3E +; sha_len = $3F / $40 +; sha_w_ptr = $41 / $42 +; sha_w_ptr2 = $43 / $44 +; These slots are demonstrably unused by any other crypto / TLS / +; ip65 / UCI / fe25519 / x25519 / ECDSA-bignum path during the +; SHA-384 call window, so NO save/restore is required around the +; SHA window. Phase 4a's TLS dispatcher MAY clobber $3D-$44 +; freely while sha384_init/update/final is in flight. ; ============================================================================= .include "constants.inc" ; reu_* register equates .include "reu_layout.inc" - .export crypto_swap_to_p256 - .export crypto_swap_to_p384 + .export crypto_swap_to_x25519_sibling + .export crypto_swap_to_p384_sha384 + .export crypto_swap_to_p384_curve .export crypto_swap_none .export current_overlay - ; Export REU layout equates once (guarded against multi-include). - .export REU_OVERLAY_P256 - .export REU_OVERLAY_P384 + ; Export REU layout equates once (kept in sync with reu_layout.inc). + .export REU_OVERLAY_P384_SHA384 + .export REU_OVERLAY_P384_CURVE .export OVERLAY_SIZE ; Live overlay slot start address (from the cfg's MEMORY{} define). .import __CRYPTO_OVERLAY_START__ ; ----------------------------------------------------------------------------- -; Overlay IDs — must stay in sync with `current_overlay` comments. +; Overlay IDs -- must stay in sync with `current_overlay` comments above. ; ----------------------------------------------------------------------------- -OV_NONE = 0 -OV_P256 = 2 -OV_P384 = 3 + .export OV_NONE + .export OV_X25519_SIBLING + .export OV_P384_SHA384 + .export OV_P384_CURVE + +OV_NONE = 0 +OV_X25519_SIBLING = 1 +OV_P384_SHA384 = 4 +OV_P384_CURVE = 5 ; REU command: execute REU->C64 stash (bit 7 = start, bits 1-0 = direction -; 01 = REU-to-C64). Matches the DMA issue used elsewhere in the codebase. +; 01 = REU-to-C64). Matches the DMA issue used elsewhere in the codebase. REU_CMD_REU_TO_C64 = $91 ; ----------------------------------------------------------------------------- -; crypto_swap_to_p256 / _p384 +; crypto_swap_to_x25519_sibling -- state-only marker (no DMA). +; +; Records that the live slot holds X25519 sibling rodata. Used at boot +; time only -- the linker has already placed X25519_RODATA in +; CRYPTO_OVERLAY at PRG load time when USE_X25519_SIBLING=1, so the +; first time the TLS path needs X25519 the bytes are already there and +; current_overlay just needs to reflect that. +; +; NOTE (Phase 3): if a future caller swaps in a P-384 overlay and then +; needs to round-trip back to X25519 sibling rodata, this entry is NOT +; sufficient -- it does not restore the bytes. A subsequent phase +; needs to add a REU stash of the sibling rodata and a real DMA path +; here. Today's TLS production path (no P-384) never triggers that +; sequence so the gap is benign. ; ----------------------------------------------------------------------------- .segment "LOADER_OVERFLOW" -crypto_swap_to_p256: - lda #OV_P256 +crypto_swap_to_x25519_sibling: + lda #OV_X25519_SIBLING + sta current_overlay + rts + +; ----------------------------------------------------------------------------- +; crypto_swap_to_p384_sha384 -- DMA P-384 SHA-384 image from REU bank 6 +; into the live CRYPTO_OVERLAY slot. Idempotent. +; ----------------------------------------------------------------------------- +crypto_swap_to_p384_sha384: + lda #OV_P384_SHA384 cmp current_overlay beq swap_done_fast pha - lda #REU_OVERLAY_P256 - ldy #^REU_OVERLAY_P256 + lda #REU_OVERLAY_P384_SHA384 + ldy #^REU_OVERLAY_P384_SHA384 jsr do_swap pla sta current_overlay rts -crypto_swap_to_p384: - lda #OV_P384 +; ----------------------------------------------------------------------------- +; crypto_swap_to_p384_curve -- DMA P-384 curve / verify image from REU +; bank 7 into the live CRYPTO_OVERLAY slot. Idempotent. +; ----------------------------------------------------------------------------- +crypto_swap_to_p384_curve: + lda #OV_P384_CURVE cmp current_overlay beq swap_done_fast pha - lda #REU_OVERLAY_P384 - ldy #^REU_OVERLAY_P384 + lda #REU_OVERLAY_P384_CURVE + ldy #^REU_OVERLAY_P384_CURVE jsr do_swap pla sta current_overlay rts +; ----------------------------------------------------------------------------- +; crypto_swap_none -- mark the slot as undefined. +; +; Does NOT zero the slot bytes -- callers MUST NOT jsr into the slot +; while OV_NONE is current. The state byte is the contract. +; ----------------------------------------------------------------------------- crypto_swap_none: lda #OV_NONE sta current_overlay @@ -94,11 +226,12 @@ swap_done_fast: rts ; ----------------------------------------------------------------------------- -; do_swap - issue the REU -> C64 DMA of 8 KB into CRYPTO_OVERLAY +; do_swap - issue the REU -> C64 DMA of OVERLAY_SIZE bytes into +; CRYPTO_OVERLAY. ; IN: A = REU source low byte ; X = REU source middle byte ; Y = REU source bank byte -; Clobbers A, X, Y. Saves / restores original I flag. +; Clobbers A, X, Y. Saves / restores original I flag. ; ----------------------------------------------------------------------------- do_swap: ; Save current I flag on the stack (bit 2 of P). @@ -110,13 +243,15 @@ do_swap: stx reu_reu_hi sty reu_reu_bank - ; C64 target: CRYPTO_OVERLAY_START, 8 KB window + ; C64 target: CRYPTO_OVERLAY_START lda #<__CRYPTO_OVERLAY_START__ sta reu_c64_lo lda #>__CRYPTO_OVERLAY_START__ sta reu_c64_hi - ; 8 KB = $2000 + ; Transfer length = OVERLAY_SIZE ($1E00 = 7,680 B; matches the + ; live CRYPTO_OVERLAY slot under UCI and the padded .bin images + ; produced by tools/integration/build_nistcurves_p384_bin.sh). lda #OVERLAY_SIZE @@ -136,8 +271,8 @@ do_swap: ; ----------------------------------------------------------------------------- ; current_overlay - single-byte state tracking which overlay is resident. -; Lives in SHADOW_BSS-resident CRYPTO_BSS (via BSS segment) so it survives -; across calls without polluting ZP. +; Lives in SHADOW_BSS-resident CRYPTO_BSS (via BSS segment) so it +; survives across calls without polluting ZP. ; ----------------------------------------------------------------------------- .segment "BSS" current_overlay: .res 1 diff --git a/src/crypto/shared/p384_overlay_blobs.s b/src/crypto/shared/p384_overlay_blobs.s new file mode 100644 index 0000000..5eb892a --- /dev/null +++ b/src/crypto/shared/p384_overlay_blobs.s @@ -0,0 +1,110 @@ +; ============================================================================= +; p384_overlay_blobs.s -- Embedded P-384 split overlay images (Phase 3). +; +; Phase 1.5 split the monolithic P-384 overlay into two 7,680 B images: +; +; build/lib/overlay-p384-sha384.bin (REU bank 6, $60000) +; build/lib/overlay-p384-curve.bin (REU bank 7, $70000) +; +; Phase 3 boots them into REU at startup so the TLS path (Phase 4a) can +; jsr crypto_swap_to_p384_{sha384,curve} on demand without staging +; anything from disk at handshake time. This file is the .incbin +; equivalent of src/net/ip65/ip65_blob.s -- it just embeds the two +; .bin payloads into the linker-controlled MEMORY map so ld65 places +; them at known C64 addresses. src/boot.s::reu_p384_overlay_init then +; copies them out to REU banks 6/7 in two STASH DMAs (~16 ms total at +; any CPU speed) and the C64 RAM holding the staging copies is free +; to be reused (CRYPTO_OVERLAY for the live overlay slot itself, and +; the under-KERNAL block at $E000-$FDFF for whatever). +; +; ----------------------------------------------------------------------------- +; Boot strategy: ".incbin into a fixed RAM region" (Phase 3) +; ----------------------------------------------------------------------------- +; Why this layout instead of disk-LOAD-at-boot? C64 PRG is a single +; contiguous load, and 47 KB (existing PRG) + 15 KB (two blobs) = +; ~62 KB does not fit anywhere in main RAM that avoids the I/O hole at +; $D000-$DFFF. We work around it by: +; +; 1. Placing the SHA-384 blob at $4200-$5FFF (CRYPTO_OVERLAY region). +; Under default builds the live overlay slot is otherwise empty +; at PRG load time; the blob occupies it transiently until boot +; DMAs it out. After boot the slot is "free" (current_overlay = +; OV_NONE) and the next jsr crypto_swap_to_p384_sha384 will DMA +; the same bytes back from REU bank 6. Under USE_X25519_SIBLING=1 +; the X25519 sibling rodata occupies CRYPTO_OVERLAY at PRG load +; time -- this file is .ifdef-gated out in that build (see below) +; and REU bank 6 is left unpopulated. The shipped TLS path under +; the sibling flag never calls crypto_swap_to_p384_sha384 so the +; gap is benign. +; +; 2. Placing the CURVE blob at $E000-$FDFF (under KERNAL ROM). The +; C64 ALWAYS has RAM there; the KERNAL ROM only intercepts reads. +; KERNAL LOAD writes pass through to the underlying RAM regardless +; of $01 banking, so the PRG load deposits the blob bytes there +; cleanly. Boot reads them back via REU DMA (which doesn't go +; through CPU $01 banking either) and stashes them in REU bank 7. +; Once the DMA completes the under-KERNAL block is free for any +; future use. +; +; The PRG file gains ~15 KB (one 7,680 B blob + the 8 KB pad from +; $C000-$DFFF that ld65 generates between CRYPTO_RESIDENT and the +; under-KERNAL region) growing to ~62 KB. Loading via VICE warp / +; Ultimate-64 fastload writes bytes directly to RAM (no real CPU I/O +; passthrough during the load), so the embedded $D000-$DFFF zeros +; cause no harm. On a real C64 + 1541 the load WOULD momentarily +; write zeros to VIC/SID/CIA registers; the PRG is not intended for +; that target. +; +; The gating below mirrors the same `.ifdef USE_X25519_SIBLING` guard +; used in src/boot.s and src/data.s -- a Make-time -D from the top +; Makefile toggles it. +; ============================================================================= + + .setcpu "6502" + +; The blobs are embedded only when USE_OVERLAY_P384_EMBED is asserted by +; the top-level Makefile (UCI backend, no USE_X25519_SIBLING flag). +; Under ip65 there is no room in main RAM after the existing layout +; (NET_BSS_TAIL has only ~800 B of slack and CRYPTO_OVERLAY is a +; zero-size alias). Under USE_X25519_SIBLING=1 the X25519 sibling +; rodata occupies CRYPTO_OVERLAY at PRG load time so the SHA blob +; cannot share that slot. Either gate leaves the segments empty; +; boot's reu_p384_overlay_init detects the empty state via a build-time +; flag and skips the DMAs entirely. +.ifdef USE_OVERLAY_P384_EMBED + + ; Force-link the two segments by exporting two anchor symbols. + ; Without these, ld65 can theoretically drop optional segments + ; that have no `.import` references; the boot code DMAs from the + ; segments by symbol so a stable label per segment is required + ; anyway. + .export p384_overlay_sha384_blob + .export p384_overlay_sha384_blob_end + .export p384_overlay_curve_blob + .export p384_overlay_curve_blob_end + +; ----------------------------------------------------------------------------- +; SHA-384 overlay image (REU bank 6 source) +; +; Loads into the live CRYPTO_OVERLAY slot at $4200-$5FFF at PRG load +; time, then boot DMAs it to REU bank 6. The .incbin path is resolved +; by ca65 relative to this source file: from src/crypto/shared/ the +; build/ tree is two levels up. +; ----------------------------------------------------------------------------- + .segment "OVERLAY_BLOB_SHA384" +p384_overlay_sha384_blob: + .incbin "../../../build/lib/overlay-p384-sha384.bin" +p384_overlay_sha384_blob_end: + +; ----------------------------------------------------------------------------- +; CURVE overlay image (REU bank 7 source) +; +; Loads into the under-KERNAL region at $E000-$FDFF at PRG load time, +; then boot DMAs it to REU bank 7. +; ----------------------------------------------------------------------------- + .segment "OVERLAY_BLOB_CURVE" +p384_overlay_curve_blob: + .incbin "../../../build/lib/overlay-p384-curve.bin" +p384_overlay_curve_blob_end: + +.endif ; .ifdef USE_OVERLAY_P384_EMBED diff --git a/src/crypto/shared/reu_layout.inc b/src/crypto/shared/reu_layout.inc index bc6a49d..e954b6c 100644 --- a/src/crypto/shared/reu_layout.inc +++ b/src/crypto/shared/reu_layout.inc @@ -43,11 +43,41 @@ REU_OVERLAY_P384 = $24100 REU_P256_PRECOMPUTE_BASE = $30000 .endif -; --- P-384 precompute (4 banks) --- +; --- P-384 precompute (4 banks at $40000-$5FFFF) --- +; Banks 4-5 are reserved for any future Lim-Lee-style precompute table +; for P-384 fixed-base scalar mul. Currently unused: the Phase 1b/1.5 +; overlay strips the Lim-Lee body and replaces it with a shim that +; tail-calls ec_scalar_mul_var_384 (no precompute). .ifndef REU_P384_PRECOMPUTE_BASE REU_P384_PRECOMPUTE_BASE = $40000 .endif +; --- P-384 split-overlay storage (Phase 1.5) --- +; Phase 1b's monolithic OVERLAY_P384 (12.5 KB) overflowed the live UCI +; CRYPTO_OVERLAY slot (7.5 KB). The fix: two halves, loaded one at a +; time on demand. Bank 6 holds the SHA-384 hash code; bank 7 holds the +; curve / verify code. Each image is padded to OVERLAY_SIZE (8 KB) for +; DMA alignment; actual code is < 7,680 B per half (fits the live slot). +; +; Layout (REU 24-bit address): +; $60000-$6FFFF bank 6 (64 KB) REU_OVERLAY_P384_SHA384 +; (8 KB image; remaining 56 KB headroom) +; $70000-$7FFFF bank 7 (64 KB) REU_OVERLAY_P384_CURVE +; (8 KB image; remaining 56 KB headroom) +; +; The TLS path (Phase 4a) drives the two halves in sequence: +; 1. crypto_swap_to_p384_sha384 -> sha384_init/update/final +; 2. crypto_swap_to_p384_curve -> ecdsa_verify_384 (digest pre-staged +; in resident DATA at ecdsa_inputs_384[96..143]) +; Phase 3 will add the swap entry points; the existing +; crypto_swap_to_p384 (single-image) is now stale. +.ifndef REU_OVERLAY_P384_SHA384 +REU_OVERLAY_P384_SHA384 = $60000 +.endif +.ifndef REU_OVERLAY_P384_CURVE +REU_OVERLAY_P384_CURVE = $70000 +.endif + ; --- Phase C.5 collision note (USE_X25519_SIBLING=1) --- ; The sibling c64-x25519 v0.4.0 reu_mul_init populates banks 3, 4, and 5 ; with its own doubled-product and 17th-bit-carry tables for fe25519_sqr. @@ -66,10 +96,20 @@ REU_P384_PRECOMPUTE_BASE = $40000 ; cfg/x25519.cfg pins these via SYMBOLS — downstream override available). ; --- overlay slot size (bytes) --- -; Each overlay image occupies exactly this many bytes in the REU store and -; is DMA'd into the live CRYPTO_OVERLAY region at runtime. +; Each overlay image occupies exactly this many bytes in the REU store +; and is DMA'd into the live CRYPTO_OVERLAY region at runtime. +; +; Phase 3: trimmed from $2000 (8 KB) to $1E00 (7,680 B) to match the +; actual live UCI CRYPTO_OVERLAY slot ($4200-$5FFF) and the padded +; .bin image size from tools/integration/build_nistcurves_p384_bin.sh +; (`SLOT_BYTES=7680`). The previous $2000 value caused crypto_swap.s +; to DMA 512 bytes past the slot, into the start of CRYPTO_RESIDENT +; RODATA at $6000-$61FF -- silently corrupting that range on every +; swap. Tests didn't catch it because no code read $6000+ between +; back-to-back swaps; the production TLS path (Phase 4a) WOULD have +; been broken by it. .ifndef OVERLAY_SIZE -OVERLAY_SIZE = $2000 ; 8 KB +OVERLAY_SIZE = $1E00 ; 7,680 B .endif ; Note: `.export` of these equates happens once in diff --git a/src/data.s b/src/data.s index 8701874..541f6ad 100644 --- a/src/data.s +++ b/src/data.s @@ -547,6 +547,8 @@ mul_src2_buf: .res 35 ; absolute copy of src2 for fast indexed access .export ecdsa_sig_s .export ecdsa_pubkey_x .export ecdsa_pubkey_y +.export ecdsa_pubkey_x_384 +.export ecdsa_pubkey_y_384 ecdsa_curve_id: .res 1 ; 0=P-256, 1=P-384 ; Phase C.4: for P-256, these five 32-byte BE buffers are laid out @@ -562,6 +564,17 @@ ecdsa_hash: .res 32 ; message hash (BE, struct +64) ecdsa_pubkey_x: .res 32 ; public key Q.x (BE, struct +96) ecdsa_pubkey_y: .res 32 ; public key Q.y (BE, struct +128) +; Phase 5 Fix B: separate 48 B P-384 pubkey slots. The P-256 packed +; struct above (r|s|h|Qx|Qy contiguous 32 B each) is read verbatim by +; the sibling ecdsa_verify_256, so we can't widen the existing +; ecdsa_pubkey_{x,y} to 48 B without breaking P-256. The cert handler +; (src/tls_cert.s) targets ecdsa_pubkey_{x,y}_384 when ecdsa_curve_id=1 +; and the P-384 dispatcher (src/crypto/ecdsa_verify_384.s) reads from +; the _384 slots. 96 B total in CRYPTO_BSS — well within the slack +; reclaimed by Phase 6's tls_hs_buf removal. +ecdsa_pubkey_x_384: .res 48 ; P-384 public key Q.x (BE, dispatcher input) +ecdsa_pubkey_y_384: .res 48 ; P-384 public key Q.y (BE, dispatcher input) + ; --- Legacy in-tree ECDSA scratch (ecdsa_verify_tmp, ev_u1, ev_u2, ; ev_point_save, ev_u1_384, ev_u2_384, ev_point_save_384) was ; reclaimed in Phase C.4. The sibling c64-nist-curves diff --git a/src/net/uci/net.s b/src/net/uci/net.s index c27964b..1250d53 100644 --- a/src/net/uci/net.s +++ b/src/net/uci/net.s @@ -139,6 +139,14 @@ net_poll: rts @do_poll: jsr uci_wait_not_busy + bcc :+ + ; FPGA wedged before we could push SOCKET_READ — net_last_error is + ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to ERROR so the + ; HTTP/TLS layer stops polling on this socket. + lda #UCI_TCP_ERROR + sta net_tcp_state + rts +: lda #UCI_TARGET_NETWORK jsr uci_begin_cmd @@ -156,6 +164,14 @@ net_poll: jsr uci_put_byte jsr uci_push_wait + bcc :+ + ; FPGA wedged waiting for SOCKET_READ response — net_last_error is + ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to ERROR so the + ; HTTP/TLS layer stops polling on this socket. + lda #UCI_TCP_ERROR + sta net_tcp_state + rts +: jsr uci_check_err bcc @no_err @@ -165,8 +181,11 @@ net_poll: lda #UCI_TCP_ERROR sta net_tcp_state jsr uci_drain_resp + bcs @pe_drain_to ; drain wedged — tcp_state already ERROR jsr uci_drain_status + bcs @pe_drain_to jsr uci_ack +@pe_drain_to: rts @no_err: @@ -199,9 +218,15 @@ net_poll: @hdr_done_short: ; Firmware returned fewer than 2 bytes. Treat as "no data". jsr uci_drain_resp + bcs @hds_drain_to ; drain wedged — surface as ERROR jsr uci_drain_status + bcs @hds_drain_to jsr uci_ack rts +@hds_drain_to: + lda #UCI_TCP_ERROR + sta net_tcp_state + rts @hdr_done: ; actual_len = uci_read_hdr (LE). If zero, drain/ack and return. @@ -212,9 +237,15 @@ net_poll: ora uci_poll_rem+0 bne @have_data jsr uci_drain_resp + bcs @hd0_drain_to ; drain wedged — surface as ERROR jsr uci_drain_status + bcs @hd0_drain_to jsr uci_ack rts +@hd0_drain_to: + lda #UCI_TCP_ERROR + sta net_tcp_state + rts @have_data: ; Copy exactly (uci_poll_rem) bytes from UCI_RESP_DATA into the @@ -289,9 +320,15 @@ net_poll: @done_data: jsr uci_drain_resp + bcs @dd_drain_to ; drain wedged — surface as ERROR jsr uci_drain_status + bcs @dd_drain_to jsr uci_ack rts +@dd_drain_to: + lda #UCI_TCP_ERROR + sta net_tcp_state + rts ; ============================================================================= ; net_dhcp_acquire — read the firmware-assigned IP via UCI GET_IPADDR @@ -327,6 +364,8 @@ net_dhcp_acquire: jsr uci_put_byte jsr uci_push_wait + bcs @dhcp_wait_to ; FPGA wedged after PUSH_CMD — bail with C=1 + ; (net_last_error already UCI_ERR_WAIT_TIMEOUT) jsr uci_check_err bcc @no_err @@ -351,7 +390,9 @@ net_dhcp_acquire: ; but this is cheap insurance against firmware revisions that ; return a longer record). jsr uci_drain_resp + bcs @dhcp_wait_to ; drain wedged — surface as DHCP fail jsr uci_drain_status + bcs @dhcp_wait_to jsr uci_ack ; Copy the first 4 bytes (IP) into net_local_ip. @@ -442,6 +483,15 @@ net_tcp_connect: uci_fence jsr uci_push_wait + bcc :+ + ; FPGA wedged waiting for TCP_CONNECT response — net_last_error is + ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to CONNECT_FAIL so + ; callers don't try to use a phantom socket. + lda #UCI_TCP_CONNECT_FAIL + sta net_tcp_state + sec + rts +: jsr uci_check_err bcc @tc_no_err @@ -449,8 +499,11 @@ net_tcp_connect: lda #UCI_ERR_CONNECT_FAIL sta net_last_error jsr uci_drain_resp + bcs @tc_err_drain_to ; drain wedged — still surface CONNECT_FAIL jsr uci_drain_status + bcs @tc_err_drain_to jsr uci_ack +@tc_err_drain_to: sec rts @@ -469,8 +522,19 @@ net_tcp_connect: jsr uci_read_resp_bytes jsr uci_drain_resp + bcs @tc_ok_drain_to ; drain wedged — surface as CONNECT_FAIL + ; (net_last_error already + ; UCI_ERR_WAIT_TIMEOUT from the drain) jsr uci_drain_status + bcs @tc_ok_drain_to jsr uci_ack + jmp @tc_validate +@tc_ok_drain_to: + lda #UCI_TCP_CONNECT_FAIL + sta net_tcp_state + sec + rts +@tc_validate: ; Validate the response: firmware must have returned at least 1 ; byte (uci_resp_count) AND a non-zero socket_id. Issue #36 — at @@ -603,6 +667,12 @@ net_tcp_send: @sb_push: jsr uci_push_wait + bcc :+ + ; FPGA wedged waiting for SOCKET_WRITE response — net_last_error is + ; already UCI_ERR_WAIT_TIMEOUT. Bail with C=1. + sec + rts +: jsr uci_check_err bcc @sb_no_err @@ -610,8 +680,11 @@ net_tcp_send: lda #UCI_ERR_SEND_FAIL sta net_last_error jsr uci_drain_resp + bcs @sb_err_drain_to ; drain wedged — preserve SEND_FAIL exit jsr uci_drain_status + bcs @sb_err_drain_to jsr uci_ack +@sb_err_drain_to: sec rts @@ -626,8 +699,16 @@ net_tcp_send: jsr uci_read_resp_bytes jsr uci_drain_resp + bcs @sb_ok_drain_to ; drain wedged post-SOCKET_WRITE — bail jsr uci_drain_status + bcs @sb_ok_drain_to jsr uci_ack + jmp @sb_continue +@sb_ok_drain_to: + ; net_last_error already UCI_ERR_WAIT_TIMEOUT from the drain. + sec + rts +@sb_continue: ; Sanity: if written != requested-for-this-chunk, flag short-write. ; We still treat the send as done (MVP semantics). @@ -702,11 +783,21 @@ net_tcp_close: jsr uci_put_byte jsr uci_push_wait + bcc :+ + ; FPGA wedged on close — force CLOSED state and bail. Best-effort + ; semantics: skip drains (FIFO state is undefined when wedged). + lda #UCI_TCP_CLOSED + sta net_tcp_state + rts +: jsr uci_check_err ; clear latched error if any jsr uci_drain_resp + bcs @cl_drain_to ; drain wedged — still force CLOSED jsr uci_drain_status + bcs @cl_drain_to jsr uci_ack +@cl_drain_to: lda #UCI_TCP_CLOSED sta net_tcp_state rts diff --git a/src/net/uci/uci_cmd.s b/src/net/uci/uci_cmd.s index b07cd70..f1dd5f7 100644 --- a/src/net/uci/uci_cmd.s +++ b/src/net/uci/uci_cmd.s @@ -9,8 +9,8 @@ ; Exported primitives (see the per-routine headers for calling conventions): ; ; uci_abort — flush the state machine (write ABORT + short delay) -; uci_wait_idle — spin until (STATE==0 AND CMD_BUSY==0) -; uci_wait_not_busy — spin until CMD_BUSY==0 +; uci_wait_idle — spin until (STATE==0 AND CMD_BUSY==0); TOD-bounded +; uci_wait_not_busy — spin until CMD_BUSY==0; TOD-bounded ; uci_begin_cmd — A = target id; writes target to UCI_CMD_DATA ; uci_put_byte — A = parameter byte; writes to UCI_CMD_DATA ; uci_push_wait — writes PUSH_CMD, then uci_wait_not_busy @@ -18,8 +18,10 @@ ; uci_read_resp_bytes— drain DATA_AV bytes to caller-provided buffer ; (caller fills uci_resp_dst/uci_resp_max beforehand; ; uci_resp_count returned; Y = count) -; uci_drain_resp — drain remaining DATA_AV bytes to nowhere, ACKing each -; uci_drain_status — drain remaining STAT_AV bytes to nowhere, ACKing each +; uci_drain_resp — drain remaining DATA_AV bytes to nowhere, ACKing +; each; TOD-bounded (5 s wall-clock) +; uci_drain_status — drain remaining STAT_AV bytes to nowhere, ACKing +; each; TOD-bounded (5 s wall-clock) ; uci_ack — single NEXT_DATA pulse ; ; Phase 2 only needs enough machinery for GET_IPADDR (12-byte response, @@ -28,7 +30,8 @@ .include "uci_regs.inc" .include "uci_errors.inc" -; net_last_error lives in net.s's BSS — we set it on wait timeout (#37). +; net_last_error lives in net.s's BSS — we set it on wait timeout +; (#37 for uci_wait_idle; Phase 5 wedge for uci_wait_not_busy). .import net_last_error .export uci_abort @@ -130,19 +133,56 @@ uci_wait_idle: @wi_elapsed: .byte 0 ; ============================================================================= -; uci_wait_not_busy — spin until CMD_BUSY==0 (ignore STATE) +; uci_wait_not_busy — spin until CMD_BUSY==0 (ignore STATE), wall-clock bounded ; Called after writing PUSH_CMD while response data / status is still being ; prepared — STATE is allowed to be nonzero here. +; +; Phase 5 wedge (CertVerify recv on U64E at 10.43.23.81, May 2026) — the +; historical unbounded spin converted an FPGA wedge into a 1843 s test +; sentinel timeout. Per the parent CLAUDE.md "Design note — bounded +; timeouts must use wall-clock time", convert to the same CIA1 TOD pattern +; used by uci_wait_idle (issue #37). Same 5 s budget, same error code, +; same SMC-byte state convention (no ZP). +; +; Output: C=0 on not-busy, C=1 on timeout (net_last_error = UCI_ERR_WAIT_TIMEOUT). ; Clobbers: A ; ============================================================================= uci_wait_not_busy: + ; Sample initial TENTHS for delta-tracking. Latch via HOUR, + ; release via TENTHS. We don't care about the HOUR value itself. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + sta @wnb_last_tenths + lda #$00 + sta @wnb_elapsed +@wnb_loop: lda UCI_STATUS uci_fence ; settle read before testing bits and #UCI_STAT_CMD_BUSY - beq @busy_done - jmp uci_wait_not_busy ; long branch: fence too wide for BNE -@busy_done: + beq @wnb_done + + ; Check TOD for elapsed tenths. Latch (HOUR) then read TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + cmp @wnb_last_tenths + beq @wnb_loop_long ; no change — keep spinning + sta @wnb_last_tenths + inc @wnb_elapsed + lda @wnb_elapsed + cmp #UCI_WAIT_IDLE_BUDGET_TENTHS + bcc @wnb_loop_long ; under budget — continue + ; Timeout + lda #UCI_ERR_WAIT_TIMEOUT + sta net_last_error + sec rts +@wnb_loop_long: + jmp @wnb_loop ; long branch: fence too wide for BCC/BEQ +@wnb_done: + clc + rts +@wnb_last_tenths: .byte 0 +@wnb_elapsed: .byte 0 ; ============================================================================= ; uci_begin_cmd — entry: A = target id (e.g. UCI_TARGET_NETWORK = $03) @@ -297,13 +337,32 @@ uci_read_resp_bytes: ; Used after uci_read_resp_bytes when the caller only wanted the first N bytes ; of a potentially longer response. Reads UCI_RESP_DATA (forcing the FIFO to ; advance on firmwares that require a read), then pulses NEXT_DATA. +; +; Phase 5j — wall-clock-bounded via CIA1 TOD (5 s budget, mirrors +; uci_wait_idle / uci_wait_not_busy from issue #37 and Phase 5b). +; Secondary-risk fix per CLAUDE.md Phase 5j brief: net_tcp_send / +; net_poll / net_tcp_close all call drains after a SOCKET_WRITE or +; POLL_DATA; if firmware ever leaves DATA_AV asserted post-SOCKET_WRITE +; the unbounded `jmp` loop wedges with no wall-clock escape. +; +; Output: C=0 on drain complete, C=1 on timeout +; (net_last_error = UCI_ERR_WAIT_TIMEOUT). ; Clobbers: A ; ============================================================================= uci_drain_resp: + ; Sample initial TENTHS for delta-tracking. Latch via HOUR, + ; release via TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + sta @drn_last_tenths + lda #$00 + sta @drn_elapsed +@drn_loop: lda UCI_STATUS uci_fence ; settle before testing DATA_AV and #UCI_STAT_DATA_AV bne @drn_have + clc rts @drn_have: lda UCI_RESP_DATA @@ -311,18 +370,52 @@ uci_drain_resp: lda #UCI_CTRL_NEXT_DATA sta UCI_CONTROL uci_fence - jmp uci_drain_resp + + ; Check TOD for elapsed tenths. Latch (HOUR) then read TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + cmp @drn_last_tenths + beq @drn_loop_long ; no change — keep draining + sta @drn_last_tenths + inc @drn_elapsed + lda @drn_elapsed + cmp #UCI_WAIT_IDLE_BUDGET_TENTHS + bcc @drn_loop_long ; under budget — continue + ; Timeout + lda #UCI_ERR_WAIT_TIMEOUT + sta net_last_error + sec + rts +@drn_loop_long: + jmp @drn_loop ; long branch: fence too wide for BEQ/BCC +@drn_last_tenths: .byte 0 +@drn_elapsed: .byte 0 ; ============================================================================= ; uci_drain_status — ACK remaining status string bytes until STAT_AV is clear. ; Phase 2 discards the status string; later phases may want to capture it. +; +; Phase 5j — wall-clock-bounded via CIA1 TOD (5 s budget, mirrors +; uci_drain_resp above). +; +; Output: C=0 on drain complete, C=1 on timeout +; (net_last_error = UCI_ERR_WAIT_TIMEOUT). ; Clobbers: A ; ============================================================================= uci_drain_status: + ; Sample initial TENTHS for delta-tracking. Latch via HOUR, + ; release via TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + sta @dst_last_tenths + lda #$00 + sta @dst_elapsed +@dst_loop: lda UCI_STATUS uci_fence ; settle before testing STAT_AV and #UCI_STAT_STAT_AV bne @dst_have + clc rts @dst_have: lda UCI_STATUS_DATA @@ -330,7 +423,26 @@ uci_drain_status: lda #UCI_CTRL_NEXT_DATA sta UCI_CONTROL uci_fence - jmp uci_drain_status + + ; Check TOD for elapsed tenths. Latch (HOUR) then read TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + cmp @dst_last_tenths + beq @dst_loop_long ; no change — keep draining + sta @dst_last_tenths + inc @dst_elapsed + lda @dst_elapsed + cmp #UCI_WAIT_IDLE_BUDGET_TENTHS + bcc @dst_loop_long ; under budget — continue + ; Timeout + lda #UCI_ERR_WAIT_TIMEOUT + sta net_last_error + sec + rts +@dst_loop_long: + jmp @dst_loop ; long branch: fence too wide for BEQ/BCC +@dst_last_tenths: .byte 0 +@dst_elapsed: .byte 0 ; ============================================================================= ; Control block for uci_read_resp_bytes — lives in UCI_BSS so no ZP is needed diff --git a/src/tls_cert.s b/src/tls_cert.s index c03755d..42cb42d 100644 --- a/src/tls_cert.s +++ b/src/tls_cert.s @@ -27,6 +27,7 @@ .export tls_handle_certificate .export x509_extract_pubkey .export tls_handle_cert_verify + .export cv_sig_scheme ; Phase 4b: exported for negotiation tests .import tls_rec_buf .import tls_rec_len @@ -46,6 +47,11 @@ .import ecdsa_sig_len .import ecdsa_pubkey_x .import ecdsa_pubkey_y + ; Phase 5 Fix B: separate 48 B P-384 pubkey slots so the cert + ; handler doesn't overrun the 32 B P-256 slots when the leaf + ; cert advertises secp384r1. + .import ecdsa_pubkey_x_384 + .import ecdsa_pubkey_y_384 ; Debug progress byte repurposed from tls_record_io.s's existing label. ; Used by tls_handle_cert_verify to mark which stage we reached so a @@ -455,17 +461,34 @@ x509_extract_pubkey: adc zp_ptr+1 sta zp_ptr+1 - ; Copy X coordinate to ecdsa_pubkey_x + ; Copy X coordinate. Phase 5 Fix B: dispatch on ecdsa_curve_id + ; to the correctly-sized BSS slot — P-256 32 B slot stays + ; ecdsa_pubkey_x; P-384 48 B slot is ecdsa_pubkey_x_384 (the + ; P-256 buffer would only hold 32 of the 48 bytes and the + ; remaining 16 would clobber the next BSS variable). lda ecdsa_sig_len ; 32 or 48 sta zp_count + lda ecdsa_curve_id + beq @copy_x_p256 + ; --- P-384 --- ldy #0 -@copy_x: +@copy_x_p384: + lda (zp_ptr),y + sta ecdsa_pubkey_x_384,y + iny + cpy zp_count + bne @copy_x_p384 + jmp @advance_past_x +@copy_x_p256: + ldy #0 +@copy_x_p256_loop: lda (zp_ptr),y sta ecdsa_pubkey_x,y iny cpy zp_count - bne @copy_x + bne @copy_x_p256_loop +@advance_past_x: ; Advance zp_ptr past X lda zp_count clc @@ -475,15 +498,27 @@ x509_extract_pubkey: adc zp_ptr+1 sta zp_ptr+1 - ; Copy Y coordinate to ecdsa_pubkey_y + ; Copy Y coordinate. Same dispatch as X. + lda ecdsa_curve_id + beq @copy_y_p256 + ldy #0 +@copy_y_p384: + lda (zp_ptr),y + sta ecdsa_pubkey_y_384,y + iny + cpy zp_count + bne @copy_y_p384 + jmp @copy_y_done +@copy_y_p256: ldy #0 -@copy_y: +@copy_y_p256_loop: lda (zp_ptr),y sta ecdsa_pubkey_y,y iny cpy zp_count - bne @copy_y + bne @copy_y_p256_loop +@copy_y_done: ; Success clc rts @@ -512,20 +547,43 @@ tls_handle_cert_verify: sta tls_recv_sub_progress ; --- Read signature algorithm [4-5] --- - ; Must be 0x0403 (ecdsa_secp256r1_sha256) - lda tls_rec_buf+4 - cmp #$04 - beq :+ - jmp @cv_error -: + ; Accept 0x0403 (ecdsa_secp256r1_sha256) or 0x0503 + ; (ecdsa_secp384r1_sha384). Phase 4b: negotiation plumbing for + ; P-384 — actual P-384 verify dispatch is filled in by Phase 4a + ; through the existing ecdsa_verify entry (curve_id-switched). + ; cv_sig_scheme := high byte - $04, so 0 = P-256, 1 = P-384. lda tls_rec_buf+5 cmp #$03 beq :+ - jmp @cv_error + jmp @cv_error ; low byte must be 03 for both : + lda tls_rec_buf+4 + sec + sbc #$04 + cmp #2 + bcc :+ + jmp @cv_error ; high byte not in {$04,$05} +: sta cv_sig_scheme + lda #$22 sta tls_recv_sub_progress + ; --- P-384 short-circuit (Phase 4b) ------------------------------- + ; The P-256 path below assumes 32-byte sig components and a + ; SHA-256 transcript hash; running it against a 48-byte/SHA-384 + ; CertificateVerify would mis-parse the signature and feed the + ; wrong digest to the dispatcher. Until Phase 4a wires up the + ; real P-384 verify, jump straight to ecdsa_verify with + ; curve_id = 1. The dispatcher currently returns C=1 for + ; curve_id != 0 (sec/rts stub); we tail-call so its carry + ; propagates as our return. Negotiation has reached the ECDSA + ; layer — the Phase 4b deliverable. + lda cv_sig_scheme + beq @cv_p256_path + sta ecdsa_curve_id ; A = 1 + jmp ecdsa_verify +@cv_p256_path: + ; --- Read signature length [6-7] (big-endian) --- lda tls_rec_buf+6 ; high byte (expect 0) beq :+ @@ -717,3 +775,4 @@ cert_bs_len: .res 1 ; BIT STRING content length ; CertificateVerify parsing state cv_sig_len: .res 1 ; DER signature length +cv_sig_scheme: .res 1 ; 0 = P-256/SHA-256, 1 = P-384/SHA-384 diff --git a/src/tls_handshake.s b/src/tls_handshake.s index f121866..56a9c45 100644 --- a/src/tls_handshake.s +++ b/src/tls_handshake.s @@ -163,31 +163,20 @@ tls_build_client_hello: iny ; 8 bytes written ; --- Extension 3: signature_algorithms (0x000D) --- - ; 00 0d 00 04 00 02 04 03 - lda #$00 - sta tls_rec_buf,y - iny - lda #$0d - sta tls_rec_buf,y - iny - lda #$00 - sta tls_rec_buf,y - iny - lda #$04 - sta tls_rec_buf,y - iny - lda #$00 - sta tls_rec_buf,y - iny - lda #$02 - sta tls_rec_buf,y - iny - lda #$04 + ; 00 0d 00 06 00 04 04 03 05 03 + ; Two schemes advertised: ecdsa_secp256r1_sha256 (0x0403) + ; and ecdsa_secp384r1_sha384 (0x0503). Inner list length = 4 + ; (two 2-byte schemes); extension data length = 6. Table-driven + ; to keep LOADER from overflowing — adding two more LDA/STA/INY + ; triples directly costs 12 B that the segment doesn't have. + ldx #0 +@sig_algs_ext_loop: + lda sig_algs_ext_data,x sta tls_rec_buf,y iny - lda #$03 - sta tls_rec_buf,y - iny ; 8 bytes written + inx + cpx #10 + bne @sig_algs_ext_loop ; 10 bytes written ; --- Extension 4: key_share (0x0033) --- ; 00 33 00 26 00 24 00 1d 00 20 [32 bytes pubkey] @@ -596,3 +585,19 @@ sh_found_ks: .res 1 tls_hostname: .res 64 tls_hostname_len: .res 1 + + +; ============================================================================= +; signature_algorithms extension payload (TLS 1.3, two ECDSA schemes). +; Lives in RODATA to keep the LOADER segment from overflowing — emitting +; ten LDA/STA/INY triples in CODE costs 60 B vs ~24 B (table + 7-insn +; copy loop). +; ============================================================================= +.segment "RODATA" + +sig_algs_ext_data: + .byte $00, $0d ; extension type = signature_algorithms + .byte $00, $06 ; extension data length = 6 + .byte $00, $04 ; supported_signature_algorithms length = 4 + .byte $04, $03 ; ecdsa_secp256r1_sha256 + .byte $05, $03 ; ecdsa_secp384r1_sha384 diff --git a/tools/https_e2e/certs/README b/tools/https_e2e/certs/README new file mode 100644 index 0000000..7745e7b --- /dev/null +++ b/tools/https_e2e/certs/README @@ -0,0 +1,76 @@ +Test certificates for the https_e2e listener +============================================= + +These are self-signed certs used by the local TLS 1.3 listener +(`https_listener.py`) for end-to-end testing of the c64-https client +against `www.foo.bar`. They are NOT trust-anchors for anything; do not +deploy them anywhere real. + +Two cert profiles are supported: + + server.pem / server.key -- P-256 (secp256r1 / prime256v1), + ecdsa-with-SHA256 + server-p384.pem / server-p384.key -- P-384 (secp384r1), + ecdsa-with-SHA384 + +CN is `www.foo.bar` and SAN covers `foo.bar` + `www.foo.bar` for both. +Validity is 10 years from generation. + +The cert files themselves are gitignored (see the repo `.gitignore`); +they are generated on demand by the listener. + +Auto-generation (default path) +------------------------------ + +Both pairs are auto-generated lazily by `https_listener._ensure_certs_*()` +the first time the listener is started under each profile, using the +Python `cryptography` package. To force regeneration, delete the +files and start the listener once with the matching `cert_profile`. + +Manual regeneration with openssl +-------------------------------- + +If you need to (re)create the P-384 pair without invoking the +listener (e.g. for debugging with `openssl s_client` directly), the +following openssl invocation produces the same cert: + + cat > /tmp/p384_san.cnf <<'EOF' + [req] + distinguished_name = dn + prompt = no + x509_extensions = v3_ext + + [dn] + CN = www.foo.bar + + [v3_ext] + subjectAltName = DNS:foo.bar, DNS:www.foo.bar + EOF + + openssl ecparam -name secp384r1 -genkey -noout \ + -out tools/https_e2e/certs/server-p384.key + + openssl req -new -x509 \ + -key tools/https_e2e/certs/server-p384.key \ + -out tools/https_e2e/certs/server-p384.pem \ + -days 3650 -sha384 -config /tmp/p384_san.cnf + +The P-256 pair can be (re)created the same way with +`-name prime256v1` and `-sha256` — but the listener's auto-generator +is the canonical source. + +Selecting which cert the listener presents +------------------------------------------ + +`start_https_listener()` accepts a `cert_profile` keyword argument: + + cert_profile="p256" (default) -> server.pem / server.key + cert_profile="p384" -> server-p384.pem / server-p384.key + +The same selection can be made via the `HTTPS_LISTENER_CERT_PROFILE` +environment variable (`p256` or `p384`). The kwarg wins if both are +set. Default is unchanged (P-256) so existing tests are unaffected. + +When `cert_profile="p384"`, the listener also pins the ECDH curve to +`secp384r1` so the key exchange and the certificate are on the same +curve. diff --git a/tools/https_e2e/https_listener.py b/tools/https_e2e/https_listener.py index f623f5e..9c6367d 100644 --- a/tools/https_e2e/https_listener.py +++ b/tools/https_e2e/https_listener.py @@ -4,16 +4,28 @@ returns a fixed 200 OK with a short body. The server runs in a daemon thread so the test can drive VICE in the main thread. -The TLS layer uses a self-signed P-256 ECDSA certificate generated at -import time (cached on disk in the certs/ directory next to this file). +The TLS layer uses a self-signed ECDSA certificate. Two cert profiles +are available: + + "p256" (default) -- P-256 / ecdsa-with-SHA256, generated lazily by + this module the first time the listener starts. + "p384" -- P-384 / ecdsa-with-SHA384, generated out-of-band + with openssl (see tools/https_e2e/certs/README). + TLS 1.3 is required; older versions are rejected. Binding to port 443 requires root. The test already runs under sudo (BridgeEnv needs it), so no special handling is needed here. Public API: - start_https_listener(host, port, response_body) -> HttpsListenerHandle + start_https_listener(host, port, response_body, cert_profile=None) + -> HttpsListenerHandle stop_https_listener(handle) + +Cert profile selection precedence (first match wins): + 1. cert_profile= keyword argument to start_https_listener() + 2. HTTPS_LISTENER_CERT_PROFILE environment variable + 3. "p256" (preserves pre-Phase-4 default behaviour) """ from __future__ import annotations @@ -31,12 +43,35 @@ _CERTS_DIR = os.path.join(os.path.dirname(__file__), "certs") _CERT_PATH = os.path.join(_CERTS_DIR, "server.pem") _KEY_PATH = os.path.join(_CERTS_DIR, "server.key") +_CERT_PATH_P384 = os.path.join(_CERTS_DIR, "server-p384.pem") +_KEY_PATH_P384 = os.path.join(_CERTS_DIR, "server-p384.key") + +_CERT_PROFILE_ENV = "HTTPS_LISTENER_CERT_PROFILE" +_DEFAULT_CERT_PROFILE = "p256" +_CERT_PROFILES = ("p256", "p384") DEFAULT_RESPONSE_BODY = "HELLO FROM HTTPS TEST SERVER" -def _ensure_certs() -> tuple[str, str]: - """Return (cert_path, key_path), generating them if they don't exist.""" +def _resolve_cert_profile(cert_profile: str | None) -> str: + """Pick the cert profile from kwarg, env var, or default.""" + if cert_profile is None: + cert_profile = os.environ.get(_CERT_PROFILE_ENV, _DEFAULT_CERT_PROFILE) + cert_profile = cert_profile.lower() + if cert_profile not in _CERT_PROFILES: + raise ValueError( + f"unknown cert_profile {cert_profile!r}; " + f"expected one of {_CERT_PROFILES}" + ) + return cert_profile + + +def _ensure_certs_p256() -> tuple[str, str]: + """Return (cert_path, key_path) for the P-256 profile. + + Generates the cert pair on first use; subsequent calls reuse the + cached files in the certs/ directory. + """ if os.path.isfile(_CERT_PATH) and os.path.isfile(_KEY_PATH): return _CERT_PATH, _KEY_PATH @@ -85,6 +120,71 @@ def _ensure_certs() -> tuple[str, str]: return _CERT_PATH, _KEY_PATH +def _ensure_certs_p384() -> tuple[str, str]: + """Return (cert_path, key_path) for the P-384 profile. + + Generates the cert pair on first use; subsequent calls reuse the + cached files in the certs/ directory. Mirrors _ensure_certs_p256() + but uses SECP384R1 + SHA-384. + """ + if os.path.isfile(_CERT_PATH_P384) and os.path.isfile(_KEY_PATH_P384): + return _CERT_PATH_P384, _KEY_PATH_P384 + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + import datetime + + key = ec.generate_private_key(ec.SECP384R1()) + + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "www.foo.bar"), + ]) + + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName([ + x509.DNSName("foo.bar"), + x509.DNSName("www.foo.bar"), + ]), + critical=False, + ) + .sign(key, hashes.SHA384()) + ) + + os.makedirs(_CERTS_DIR, exist_ok=True) + + with open(_KEY_PATH_P384, "wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + + with open(_CERT_PATH_P384, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + return _CERT_PATH_P384, _KEY_PATH_P384 + + +def _ensure_certs(cert_profile: str) -> tuple[str, str]: + """Dispatch to the per-profile cert loader.""" + if cert_profile == "p256": + return _ensure_certs_p256() + if cert_profile == "p384": + return _ensure_certs_p384() + # Should be unreachable thanks to _resolve_cert_profile(). + raise ValueError(f"unknown cert_profile {cert_profile!r}") + + # --------------------------------------------------------------------------- # HTTPS handler # --------------------------------------------------------------------------- @@ -122,15 +222,26 @@ class HttpsListenerHandle: port: int cert_path: str key_path: str + cert_profile: str def start_https_listener( host: str = "10.0.65.1", port: int = 443, response_body: str = DEFAULT_RESPONSE_BODY, + cert_profile: str | None = None, ) -> HttpsListenerHandle: - """Start a TLS 1.3 HTTPS server in a daemon thread. Returns a handle.""" - cert_path, key_path = _ensure_certs() + """Start a TLS 1.3 HTTPS server in a daemon thread. Returns a handle. + + cert_profile selects which self-signed cert the listener presents: + "p256" (default) -- ECDSA P-256, ecdsa-with-SHA256 + "p384" -- ECDSA P-384, ecdsa-with-SHA384 + + If cert_profile is None, the HTTPS_LISTENER_CERT_PROFILE env var is + consulted; if that is also unset the default ("p256") is used. + """ + profile = _resolve_cert_profile(cert_profile) + cert_path, key_path = _ensure_certs(profile) _Handler.response_body = response_body @@ -139,6 +250,9 @@ def start_https_listener( ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_3 ctx.maximum_version = ssl.TLSVersion.TLSv1_3 + if profile == "p384": + # Pin ECDH to the same curve as the cert so the key share matches. + ctx.set_ecdh_curve("secp384r1") ctx.load_cert_chain(cert_path, key_path) server.socket = ctx.wrap_socket(server.socket, server_side=True) @@ -146,7 +260,7 @@ def start_https_listener( thread.start() return HttpsListenerHandle( server=server, thread=thread, host=host, port=port, - cert_path=cert_path, key_path=key_path, + cert_path=cert_path, key_path=key_path, cert_profile=profile, ) diff --git a/tools/integration/build_nistcurves_p256.sh b/tools/integration/build_nistcurves_p256.sh index 7f39a52..d8f1cfc 100755 --- a/tools/integration/build_nistcurves_p256.sh +++ b/tools/integration/build_nistcurves_p256.sh @@ -342,7 +342,11 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" # zp_config.s is the single point of truth for ZP equates; we apply -D # overrides so sibling defaults get replaced with c64-https's canonical map. +# `-g` embeds cc65 debug info into the .o files so the final ld65 --dbgfile +# (driven from the top-level Makefile) can merge per-source line/symbol +# records into build/c64-https.dbg. Does not change emitted code bytes. "$CA65" \ + -g \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ "${ZP_DEFINES[@]}" \ @@ -350,6 +354,7 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" for src in fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw reu_equates_raw; do "$CA65" \ + -g \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh index 2caf4d9..4190cb3 100755 --- a/tools/integration/build_nistcurves_p384.sh +++ b/tools/integration/build_nistcurves_p384.sh @@ -1,46 +1,79 @@ #!/usr/bin/env bash # ============================================================================= # tools/integration/build_nistcurves_p384.sh - Build c64-nist-curves P-384 -# primitives as a REU overlay .a archive for the UCI backend. +# overlay archives for the UCI backend smoke test. # -# Phase C.3 of the sibling-lib integration. Produces build/lib/nistcurves-p384.a -# containing ONLY the three variable-base P-384 primitives used by TLS -# (ec_point_double_384, ec_point_add_384, ec_jacobian_to_affine_384) -# plus their fp/mod helpers. +# Phase 1.5 split. Phase 1b's monolithic OVERLAY_P384 segment was 12,836 B +# and overflowed the live UCI CRYPTO_OVERLAY slot (7,680 B at $4200-$5FFF). +# This script now produces TWO archives, each fitting the 7.5 KB slot: # -# Segment layout: -# OVERLAY_P384 - all P-384 runtime code (fp384 + mod384 + points384). -# Paged into the live CRYPTO_OVERLAY slot via REU DMA. -# CRYPTO_RESIDENT - P-384 RW data (ec384_* points, fp384_* tmps, etc.) -# routed through the DATA / BSS segments. +# build/lib/nistcurves-p384-sha384.a - SHA-384 streaming hash (sha384.s +# + the SHA-384 portion of the +# minimal data heredoc). +# Segment: OVERLAY_P384_SHA384. +# build/lib/nistcurves-p384-curve.a - fp384 + mod384 + points384 +# (post-strip) + curve384 + +# ecdsa384 (verify_384 ONLY - +# the verify_with_message_384 +# wrapper that imports +# sha384_init/update/final is +# stripped here; TLS drives SHA +# via the sha384 overlay) + the +# ec_scalar_mul_384 shim. +# Segment: OVERLAY_P384_CURVE. # -# Excluded (upstream JC-000/c64-nist-curves#17 tracks what's missing): -# - ec_scalar_mul_384 - fixed-base-only (Lim-Lee comb over precomputed -# anchors). Not useful without variable-base mul. -# - ec_precompute_384 - builds the Lim-Lee comb table; needs REU bank 2 -# layout that conflicts with the overlay store. -# - Lim-Lee anchor tables (ec_anchor1_384_x..ec_anchor8_384_y) and -# comb-scalar state (cm_k_384, ec384_sc_byte/mask, ec384_precomp_i). -# - P-256 modules (fp256/mod256/curve256/points256/inv256). P-256 stays -# in-tree (see src/crypto/ecdsa_*.s); Phase C.3 does not swap it out. -# - curve384.s (ec_a384/b384/gx384/gy384constants). Only imported by the -# stripped ec_precompute_384 / ec_scalar_mul_384. +# Both archives also contribute disjoint subsets of data_raw.s into the +# resident DATA segment (CRYPTO_RESIDENT under the live cfg, at $C000 in +# the standalone overlay cfgs). The split is byte-for-byte identical to +# Phase 1b's combined data_raw.s so resident DATA growth stays at the +# Phase 1b figure (3,541 B); see the per-half data heredocs below. # -# The mul_8x8 runtime + mul_dma_lo/hi tables + reu_fetch_mul_row come -# from the already-linked c64-x25519 sibling archive (build/lib/x25519.a). -# P-384's fp_mul_384 / fp_sqr_384 reuse those REU-backed product tables; -# the table layout (a*512 offset, 256 lo + 256 hi bytes per row) matches -# between the two siblings. +# Wrapper strip: +# ecdsa_verify_with_message_384 + ecdsa_verify_with_msg_384_tramp are +# physically removed from the curve archive's ecdsa384_raw.s so the +# archive does not import sha384_init/update/final (those live only in +# the OTHER half). TLS will call sha384_init / update / final +# directly from the sha384 overlay, then swap in the curve overlay, +# then call ecdsa_verify_384 with the digest pre-spliced into +# ecdsa_inputs_384[96..143]. See Phase 4a's TLS dispatcher work for +# the call sequencing. +# +# ZP allocation (Phase 1.5): +# sha_src = $3D, sha_len = $3F, +# sha_w_ptr = $41, sha_w_ptr2 = $43. +# These supersede the sibling defaults ($04/$06/$08/$0A) which collide +# with c64-https's canonical $04-$09 = w32_* (ChaCha20/Poly1305) and +# $0A-$0D = sha_temp1 (SHA-256). $3D-$44 is the lowest 8-byte +# contiguous free block above the canonical crypto ZP map (ec_scalar_ptr +# ends at $3C; nothing in src/* claims $3D-$FA except the universal +# $FB-$FF general pointers). Verified by grep against +# src/constants.inc, src/crypto/shared/zp_canon.inc, and all .s files +# under src/. Safe during the SHA-384 call window because no other +# crypto / TLS path uses these slots. +# +# Excluded (same as Phase 1b — see comments inline): +# - ec_precompute_384 / ec_scalar_mul_384 (Lim-Lee body) — replaced by +# the in-staging shim that copies G into ec_base384_x/y and +# tail-calls ec_scalar_mul_var_384. +# - Lim-Lee anchor tables and comb-scalar state. +# - sha384_msg_buf (1024 B test scratch). +# - mul_8x8 / sqtab_init / mul_dma_lo/hi / mul_cached_a / mul_src2_buf / +# reu_fetch_mul_row / poly_prod_lo/hi / sqtab_lo/hi - resolved at link +# time by build_nistcurves_p384_bin.sh's --define stubs. +# - ecdsa_verify_with_message_384 + ecdsa_verify_with_msg_384_tramp +# (Phase 1.5 NEW — see "Wrapper strip" above). # # The script stages the sibling's .s files in build/lib/nistcurves_p384_staging/, -# applies a sed-patch to each to override their `.segment "CODE"` / "DATA" -# directives, and assembles with canonical ZP equates passed via -D. +# applies sed-patches to override their `.segment` directives and rewrite +# them into the new dual-segment scheme. # # Usage (from top-level Makefile): # bash tools/integration/build_nistcurves_p384.sh # Produces: -# build/lib/nistcurves-p384.a -# build/lib/nistcurves-p384.sizes.txt (per-source byte counts) +# build/lib/nistcurves-p384-sha384.a +# build/lib/nistcurves-p384-curve.a +# build/lib/nistcurves-p384-sha384.sizes.txt +# build/lib/nistcurves-p384-curve.sizes.txt # ============================================================================= set -eo pipefail @@ -49,20 +82,34 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" LIB_SRC="$PROJECT_ROOT/libs/nistcurves/src" STAGING="$PROJECT_ROOT/build/lib/nistcurves_p384_staging" OUT_DIR="$PROJECT_ROOT/build/lib" -ARCHIVE="$OUT_DIR/nistcurves-p384.a" -SIZES="$OUT_DIR/nistcurves-p384.sizes.txt" +ARCHIVE_SHA="$OUT_DIR/nistcurves-p384-sha384.a" +ARCHIVE_CURVE="$OUT_DIR/nistcurves-p384-curve.a" +SIZES_SHA="$OUT_DIR/nistcurves-p384-sha384.sizes.txt" +SIZES_CURVE="$OUT_DIR/nistcurves-p384-curve.sizes.txt" CA65="${CA65:-ca65}" AR65="${AR65:-ar65}" # --- Canonical ZP defines --- # The sibling's zp_config.s wraps every ZP equate in .ifndef, so command-line -# -D values win over the defaults. We pin the sibling to c64-https's -# canonical ZP map (src/crypto/shared/zp_canon.inc) so the archive's -# absolute ZP references line up with TLS call-site expectations. +# -D values win over the defaults. We pin the sibling to c64-https's +# canonical ZP map (src/crypto/shared/zp_canon.inc) AND override the SHA-384 +# pointer slots to $3D-$44 (Phase 1.5). +# +# Why $3D-$44? The sibling's defaults sha_src=$04, sha_len=$06, +# sha_w_ptr=$08, sha_w_ptr2=$0a collide with c64-https's canonical +# $04-$09 = w32_* (ChaCha20/Poly1305) and $0A-$0D = sha_temp1 (SHA-256). +# $3D-$44 is the lowest 8-byte contiguous free range above the canonical +# crypto ZP map (ec_scalar_ptr ends at $3C); see this file's header for +# the full audit. Demonstrated free during the SHA-384 call window: +# - Not used by ip65 ($02-$1B), ChaCha20/Poly1305 ($04-$1D), +# SHA-256 ($0A-$13), TLS record layer ($1E-$21), fp_* ECDSA bignum +# ($22-$2B + $39-$3C), fe25519 ($2C-$35), or x25519 ($38-$3A). +# - $36-$37 was reserved for fe25519 future expansion (only 2 bytes, +# insufficient for the 8 bytes SHA-384 needs). # # Note: fp_mul_i / fp_mul_j overlap with x25_byte_idx / x25_bit_mask at -# $39/$3a. This is fine because x25519 and P-384 run at different times +# $39/$3a. This is fine because x25519 and P-384 run at different times # (different overlays; only one resident at a time) and the canonical # map documents the time-sharing. ZP_DEFINES=( @@ -84,72 +131,108 @@ ZP_DEFINES=( '-Dpoly_j=$1b' '-Dpoly_carry=$1c' '-Dpoly_tmp=$1d' + # SHA-384 streaming pointer slots (Phase 1.5 — moved out of the + # sibling's $04-$0B defaults to avoid the canonical w32_* / sha_temp1 + # collision; see header). + '-Dsha_src=$3d' + '-Dsha_len=$3f' + '-Dsha_w_ptr=$41' + '-Dsha_w_ptr2=$43' ) # --- Stage sources --- rm -rf "$STAGING" mkdir -p "$STAGING" -# The sibling's constants.s is pulled in via -I; we don't stage it here -# (it has no segment directives we'd rewrite, and it's .include'd by -# zp_config.s / data.s transitively). +# constants.s and zp_config.s are shared between both halves. zp_config.s +# is .include'd transitively; we assemble it once with -D overrides and +# add the resulting .o to BOTH archives. cp "$LIB_SRC"/constants.s "$STAGING/" cp "$LIB_SRC"/zp_config.s "$STAGING/" cp "$LIB_SRC"/fp384.s "$STAGING/fp384_raw.s" cp "$LIB_SRC"/mod384.s "$STAGING/mod384_raw.s" cp "$LIB_SRC"/points384.s "$STAGING/points384_raw.s" -cp "$LIB_SRC"/data.s "$STAGING/data_raw.s" +cp "$LIB_SRC"/curve384.s "$STAGING/curve384_raw.s" +cp "$LIB_SRC"/sha384.s "$STAGING/sha384_raw.s" +cp "$LIB_SRC"/ecdsa384.s "$STAGING/ecdsa384_raw.s" # --- Strip points384.s of ec_precompute_384 and ec_scalar_mul_384 --- -# Those live between lines 787 (just before ec_precompute_384:) and -# 1489 (just before the ec_jacobian_to_affine_384: header). -# We also strip the `.export ec_precompute_384, ec_scalar_mul_384` line -# so the archive doesn't advertise symbols whose bodies were removed. -# The remaining three `.export` symbols (ec_point_double_384, -# ec_point_add_384, ec_jacobian_to_affine_384) stay. +# Same surgery as Phase 1b. Bodies between lines 787 and 1488 inclusive +# are physically removed; the related `.export` and `.import` lines are +# scrubbed below. ec_gx384 / ec_gy384 imports are KEPT (used by the shim). # -# Imports that the removed bodies relied on (anchors, cm_k_384, sc_byte, -# sc_mask, precomp_i, ec_gx384, ec_gy384, ec_set_modp_384... wait ec_set_modp -# is still used by double/add) — we remove ONLY the anchor + comb-state -# imports since everything else is used by the retained primitives. -sed -i '787,1489d' "$STAGING/points384_raw.s" -sed -i '/^\.export ec_precompute_384, ec_scalar_mul_384$/d' "$STAGING/points384_raw.s" -# Strip imports only used by the removed bodies. Patterns are anchored -# to avoid accidentally deleting unrelated lines. -sed -i '/^\.import ec_gx384, ec_gy384$/d' "$STAGING/points384_raw.s" -sed -i '/^\.import ec_anchor[1-8]_384_x/d' "$STAGING/points384_raw.s" -sed -i '/^\.import ec_anchor[1-8]_384_y/d' "$STAGING/points384_raw.s" -sed -i '/^\.import cm_k_384, mul_dma_lo$/d' "$STAGING/points384_raw.s" -sed -i '/^\.import ec384_sc_byte, ec384_sc_mask, ec384_precomp_i$/d' "$STAGING/points384_raw.s" - -# --- Strip data_raw.s of P-256 content + Lim-Lee comb anchors --- -# We only keep the P-384 RW buffers that fp384 / mod384 / points384 reference: -# fp384_wide, fp384_tmp1..4, fp384_r0..r3, fp384_inv_u/v/x1/x2, -# ec384_p1/p2/p3, ec384_t1..t6, ec384_affine_x/y, fp384_red_tmp -# -# We drop: -# - P-256 field buffers (fp_wide, fp_tmp*, fp_r*, fp_inv_*, ec_p1..) -# because c64-https's in-tree ECDSA P-256 already provides these and -# we must not double-define them. ALSO: fp_wide in c64-nist-curves -# is 64 bytes while the in-tree ecdsa_fp.s `fp_wide` is local (no -# export) — keeping the sibling's fp_wide would create a collision. -# - mul_cached_a, mul_src2_buf, mul_dma_lo/hi — provided by the -# x25519 sibling (already linked first in SIBLING_LIB_ARCHIVES). -# - Lim-Lee anchors (ec_anchor*_x/y, ec_aff2g_256_*), cm_k / cm_k_384, -# ec384_sc_*, ec384_precomp_i — only used by the stripped scalar-mul -# and precompute bodies. +# OPTION A choice (Phase 1b): the Lim-Lee body for ec_scalar_mul_384 is +# stripped; an in-staging shim file (ec_scalar_mul_384_shim_raw.s, emitted +# below) provides the symbol by copying G into ec_base384_x/y and +# tail-calling ec_scalar_mul_var_384. This avoids the ~24 KB Lim-Lee +# anchor table + ~100 s ec_precompute_384 boot drag. Pattern mirrors +# src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 for P-256). +# BSD-sed compat: macOS sed requires `-i ''` (empty extension). +sed -i '' '787,1488d' "$STAGING/points384_raw.s" +sed -i '' '/^\.export ec_precompute_384, ec_scalar_mul_384$/d' "$STAGING/points384_raw.s" +sed -i '' '/^\.import ec_anchor[1-8]_384_x/d' "$STAGING/points384_raw.s" +sed -i '' '/^\.import ec_anchor[1-8]_384_y/d' "$STAGING/points384_raw.s" +sed -i '' '/^\.import cm_k_384, mul_dma_lo$/d' "$STAGING/points384_raw.s" +sed -i '' '/^\.import ec384_sc_byte, ec384_sc_mask, ec384_precomp_i$/d' "$STAGING/points384_raw.s" + +# --- Strip ecdsa_verify_with_message_384 wrapper from the curve archive --- +# Phase 1.5 NEW. The wrapper imports sha384_init/update/final, which live +# in the OTHER overlay half (sha384 archive). TLS now drives the SHA +# overlay manually then swaps in the curve overlay and calls +# ecdsa_verify_384 directly with the digest pre-spliced into +# ecdsa_inputs_384[96..143]. # -# Strategy: write a brand new data_raw.s that pulls only what we need. -# We keep the sibling's data.s around for reference but emit an -# explicit minimal one. -cat > "$STAGING/data_raw.s" <<'DATA_EOF' +# In libs/nistcurves@90830c9 the wrapper + trampoline span lines 568-end +# of ecdsa384.s. We delete from line 568 to the end of file ("568,$d") +# and scrub: +# - the two wrapper .export lines (verify_with_message_384 + +# verify_with_msg_384_tramp) +# - the .import sha384_init/update/final line +# - the .import sha384_msg_buf reference (the test trampoline only) +# - the .import ecdsa384_msg_struct_ptr line (wrapper-only scratch) +# - the .import ecdsa_inputs_384, ecdsa_result_msg_384 line +# (test-trampoline only — the standalone curve archive doesn't need +# these symbols since the wrapper that consumed them is gone; ld65 +# would fail to resolve them if we left the .import in place since +# they live in the data heredoc as exports but nothing else references +# them after the wrapper is dropped — keep the .import to keep the +# symbol pulled in via .import-as-link-anchor; data_curve_raw.s still +# exports both for the harness driver path). +# We sed only on the curve copy AFTER making a separate sha-only copy is +# unnecessary because sha384_raw.s never sees ecdsa384_raw.s. +sed -i '' '568,$d' "$STAGING/ecdsa384_raw.s" +sed -i '' '/^\.export ecdsa_verify_with_message_384$/d' "$STAGING/ecdsa384_raw.s" +sed -i '' '/^\.export ecdsa_verify_with_msg_384_tramp$/d' "$STAGING/ecdsa384_raw.s" +sed -i '' '/^\.import sha384_init, sha384_update, sha384_final$/d' "$STAGING/ecdsa384_raw.s" +sed -i '' '/^\.import ecdsa384_msg_struct_ptr$/d' "$STAGING/ecdsa384_raw.s" + +# --- Drop test-only sha384_msg_buf import from sha384.s --- +# sha384.s `.import sha384_digest, sha384_msg_buf` at file scope but never +# references sha384_msg_buf in code. We drop the 1024-byte test scratch +# buffer from data_raw.s, so the import must go too. +sed -i '' 's/^\(\.import sha384_digest, sha384_msg_buf\)$/.import sha384_digest/' "$STAGING/sha384_raw.s" +# Same scrub on the curve-half ecdsa384_raw.s (the .import line is on a +# different line in ecdsa384.s; preserve only sha384_digest if the line is +# present after the wrapper-strip above — it should NOT be, since the +# import for sha384_init/update/final/digest/msg_buf is bundled together. +# Defensive: leave a no-op sed in case the upstream layout changes). +sed -i '' 's/^\(\.import sha384_digest, sha384_msg_buf\)$/.import sha384_digest/' "$STAGING/ecdsa384_raw.s" + +# --- Emit data_curve_raw.s (resident DATA exports for the curve archive) --- +# Hand-extracted from the sibling's data.s — non-SHA portion only. +# This is the SAME byte-for-byte content as Phase 1b's data_raw.s up to +# (but not including) the SHA-384 streaming state block. Land in DATA +# (= CRYPTO_RESIDENT in the live cfg, $C000 in the standalone cfgs). +cat > "$STAGING/data_curve_raw.s" <<'DATA_EOF' ; ============================================================================= -; data_raw.s - Minimal P-384 RW buffers for c64-https / c64-nist-curves -; integration. Hand-extracted from the sibling's data.s so the -; P-256 side (in-tree) and the x25519 sibling's shared mul -; tables remain unclobbered. +; data_curve_raw.s - Resident DATA exports for the curve / verify half of +; the split P-384 overlay (Phase 1.5). Non-SHA portion of Phase 1b's +; minimal data_raw.s. Lands in CRYPTO_RESIDENT under the live cfg. ; -; All exports here are P-384-exclusive. +; The 240 B BE input struct (ecdsa_inputs_384) is shared with the SHA +; archive's caller path -- TLS pre-stages r/s/Qx/Qy here, then drives +; sha384_init/update/final to populate the digest at struct[96..143], +; then swaps in this overlay and calls ecdsa_verify_384. ; ============================================================================= .setcpu "6502" @@ -215,37 +298,201 @@ ec384_affine_x: .res 48, 0 .export ec384_affine_y ec384_affine_y: .res 48, 0 +; --- Variable-base scalar-mul input (affine, 48 bytes each, LE). +; Consumed by ec_scalar_mul_var_384 (ECDSA-verify building block) and +; populated by the ec_scalar_mul_384 shim (G -> ec_base384_x/y). +.export ec_base384_x +ec_base384_x: .res 48, 0 +.export ec_base384_y +ec_base384_y: .res 48, 0 + ; --- P-384 Solinas reduction scratch --- .export fp384_red_tmp fp384_red_tmp: .res 49, 0 + +; --- ECDSA verify scratch (P-384). All 48-byte little-endian unless noted. --- +.export ecdsa384_r +ecdsa384_r: .res 48, 0 ; LE r (byte-reversed from BE input) +.export ecdsa384_s +ecdsa384_s: .res 48, 0 ; LE s +.export ecdsa384_h +ecdsa384_h: .res 48, 0 ; LE message hash +.export ecdsa384_qx +ecdsa384_qx: .res 48, 0 ; LE public-key affine X +.export ecdsa384_qy +ecdsa384_qy: .res 48, 0 ; LE public-key affine Y +.export ecdsa384_w +ecdsa384_w: .res 48, 0 ; LE w = s^-1 mod n +.export ecdsa384_u1 +ecdsa384_u1: .res 48, 0 ; LE u1 = h*w mod n +.export ecdsa384_u2 +ecdsa384_u2: .res 48, 0 ; LE u2 = r*w mod n +.export ecdsa384_u1_be +ecdsa384_u1_be: .res 48, 0 ; BE u1 (scalar_mul input) +.export ecdsa384_u2_be +ecdsa384_u2_be: .res 48, 0 ; BE u2 (scalar_mul_var input) +.export ecdsa384_u1g_x +ecdsa384_u1g_x: .res 48, 0 ; LE affine X of u1*G +.export ecdsa384_u1g_y +ecdsa384_u1g_y: .res 48, 0 ; LE affine Y of u1*G + +; --- fp_reverse48 staging buffer (one 48-byte scratch). --- +.export fp_rev_buf_384 +fp_rev_buf_384: .res 48, 0 + +; --- ECDSA verify test-driver staging buffer (240 B BE struct). +; The c64-test-harness jsr() helper cannot pass register arguments, so +; the BE input struct is staged here and the test trampoline points +; A/X at it. TLS pre-fills r|s|Qx|Qy here, then runs SHA over the +; handshake transcript, then writes the digest into struct[96..143], +; then swaps in the curve overlay and calls ecdsa_verify_384. +.export ecdsa_inputs_384 +ecdsa_inputs_384: .res 240, 0 ; r|s|h|Qx|Qy each 48 B BE + +; --- ECDSA result byte (test driver / dispatcher result) --- +.export ecdsa_result_msg_384 +ecdsa_result_msg_384: .byte 0 DATA_EOF -# --- Route CODE segments to OVERLAY_P384 --- -# fp384_raw.s and mod384_raw.s use `.segment "CODE"` (once each) and -# fp384_raw.s has a second `.segment "BSS"` block at the tail. Those -# tail BSS buffers (fp384_sqr_extra, mul_src2_buf_384, fp384_sqr_pairs) -# must go in CRYPTO_RESIDENT BSS (always-resident state, not overlay) -# since the overlay gets swapped out between calls. We rename the BSS -# segment to the c64-https canonical `BSS` name which the UCI cfg maps -# into CRYPTO_RESIDENT_2 BSS. -sed -i 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/fp384_raw.s" -sed -i 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/mod384_raw.s" -sed -i 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/points384_raw.s" -# fp384_raw.s .segment "BSS" stays — already matches the canonical BSS -# segment which cfg/c64-https-uci.cfg maps into CRYPTO_RESIDENT_2. - -# --- mod384.s curve constants (ec_p384, ec_n384) live in CODE segment -# in the sibling and are emitted inline with .byte directives. After the -# CODE->OVERLAY_P384 rewrite they flow into the overlay alongside the -# code that reads them; that is intentional (ec_p384 is used by -# fp_mod_reduce384 which IS in the overlay). - -# --- ec_sc_byte / ec_sc_mask --- -# points384.s had `.import ec384_sc_byte, ec384_sc_mask, ec384_precomp_i` -# — we stripped that import above since only the removed precompute / -# scalarmul bodies referenced those names. Double-check nothing leaked: -if grep -qE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b|\bec_gx384\b|\bec_gy384\b' "$STAGING/points384_raw.s"; then +# --- Emit data_sha_raw.s (resident DATA exports for the SHA archive) --- +# Hand-extracted from the sibling's data.s — SHA-384 portion only. +# Same byte-for-byte content as Phase 1b's data_raw.s SHA-384 block. +cat > "$STAGING/data_sha_raw.s" <<'DATA_EOF' +; ============================================================================= +; data_sha_raw.s - Resident DATA exports for the SHA-384 half of the split +; P-384 overlay (Phase 1.5). SHA-384 portion of Phase 1b's minimal +; data_raw.s. Lands in CRYPTO_RESIDENT under the live cfg. +; +; Storage convention: each 64-bit word is held LITTLE-ENDIAN-WITHIN-WORD, +; matching 6502 ADC carry propagation. All buffers are owned exclusively +; by sha384.s. sha384_msg_buf (1 KB test scratch) is intentionally OMITTED +; (would inflate resident DATA by ~25%; not used by sha384.s itself). +; ============================================================================= +.setcpu "6502" + +.segment "DATA" + +.export sha_state +sha_state: .res 64, 0 ; H[0..7], 8 bytes each LE-within-word +.export sha_w +sha_w: .res 640, 0 ; W[0..79] message schedule, 8 B each LE +.export sha_abcdefgh +sha_abcdefgh: .res 64, 0 ; working a..h, 8 B each LE +.export sha_t +sha_t: .res 16, 0 ; T1 (8 B) + T2 (8 B), LE +.export sha_scratch +sha_scratch: .res 64, 0 ; 8x 8-byte scratch slots for round helpers +.export sha_block_buf +sha_block_buf: .res 128, 0 ; current 1024-bit block (wire order) +.export sha_block_len +sha_block_len: .byte 0 ; bytes used in sha_block_buf, 0..127 +.export sha_total_len +sha_total_len: .res 16, 0 ; 128-bit total bit count, LE on-chip +.export sha384_digest +sha384_digest: .res 48, 0 ; final BE digest output (read by curve + ; overlay's ecdsa_verify_384 path after + ; TLS splices it into ecdsa_inputs_384[96..143]) +DATA_EOF + +# --- Emit ec_scalar_mul_384 shim (Option A) --- +# Pattern mirrors src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 P-256 +# dispatcher). Lives in OVERLAY_P384_CURVE alongside the rest of the curve +# code. ec_gx384 and ec_gy384 are each contiguous 48-byte slots in +# curve384.s RODATA, so a simple ldy #47 / lda src,y / sta dst,y / dey / +# bpl loop works (47 = $2F has bit 7 clear; DEY updates N flag based on +# the decremented Y, not the LDA byte). +cat > "$STAGING/ec_scalar_mul_384_shim_raw.s" <<'SHIM_EOF' +; ============================================================================= +; ec_scalar_mul_384_shim_raw.s -- Phase 1b shim for the stripped Lim-Lee +; fixed-base scalar-mul (Option A). Provides ec_scalar_mul_384 by copying +; G into ec_base384_x/y and tail-calling ec_scalar_mul_var_384. +; +; Mirrors the Phase C.4 P-256 dispatcher pattern in +; src/crypto/ecdsa_verify.s::ec_scalar_mul. Slower per-call than the real +; Lim-Lee comb (double-and-add vs. windowed comb) but avoids the ~24 KB +; REU bank-2 anchor table + ~100 s ec_precompute_384 boot drag. +; +; Phase 1.5: lives in OVERLAY_P384_CURVE (was OVERLAY_P384 in Phase 1b). +; ============================================================================= +.setcpu "6502" + +.segment "OVERLAY_P384_CURVE" + +.export ec_scalar_mul_384 + +.import ec_gx384, ec_gy384 +.import ec_base384_x, ec_base384_y +.import ec_scalar_mul_var_384 + +ec_scalar_mul_384: + ; Copy G.x -> ec_base384_x (48 bytes; ldy #47, dey/bpl safe) + ldy #47 +@cp_x: lda ec_gx384,y + sta ec_base384_x,y + dey + bpl @cp_x + ; Copy G.y -> ec_base384_y (48 bytes) + ldy #47 +@cp_y: lda ec_gy384,y + sta ec_base384_y,y + dey + bpl @cp_y + jmp ec_scalar_mul_var_384 ; tail-call: result and clobbers passthrough +SHIM_EOF + +# --- Route CODE / RODATA segments into per-half OVERLAY segments --- +# Phase 1.5 split: each source goes into either OVERLAY_P384_SHA384 (just +# sha384) or OVERLAY_P384_CURVE (everything else). +# +# fp384_raw.s also has a `.segment "BSS"` block at the tail (53 B) for +# fp384_sqr_extra / mul_src2_buf_384 / fp384_sqr_pairs. Those land in +# CRYPTO_RESIDENT BSS via the canonical BSS segment name (no rewrite +# needed) since the overlay gets swapped out between calls. +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/fp384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/mod384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/points384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/ecdsa384_raw.s" +# curve384.s uses RODATA -- route into OVERLAY_P384_CURVE (read-only constants). +sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/curve384_raw.s" +# sha384.s: code (CODE) and IV/K[80] round constants (RODATA) both into +# the SHA-384 overlay. +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_SHA384"/' "$STAGING/sha384_raw.s" +sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384_SHA384"/' "$STAGING/sha384_raw.s" + +# --- mod384.s curve constants (ec_p384, ec_n384) live in CODE segment in +# the sibling and are emitted inline with .byte directives. After the +# CODE->OVERLAY_P384_CURVE rewrite they flow into the curve overlay +# alongside the code that reads them; that is intentional +# (fp_mod_reduce384 reads ec_p384 and IS in the curve overlay). + +# --- Forbidden-symbol guard (curve archive only) --- +# After the strip, points384_raw.s must NOT reference any of the removed +# Lim-Lee comb / precompute symbols. ec_gx384 / ec_gy384 / cm_k_384 / +# ec_anchor*_384 patterns CAN appear as comments; we strip leading +# whitespace and a leading `;` before the grep so we only match active +# code. ec_gx384 / ec_gy384 are intentionally left LIVE in the staging +# tree (used by the shim). cm_k_384, ec_anchor[0-9]_384, ec384_sc_byte/ +# mask, ec384_precomp_i remain forbidden -- those bodies were physically +# removed. +if grep -v '^\s*;' "$STAGING/points384_raw.s" \ + | grep -qE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b'; then echo "ERROR: stripped points384 still references removed-body symbols" >&2 + grep -v '^\s*;' "$STAGING/points384_raw.s" \ + | grep -nE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b' \ + | head -5 >&2 + exit 1 +fi + +# --- Forbidden-symbol guard (Phase 1.5 wrapper-strip) --- +# After the wrapper-strip, ecdsa384_raw.s must NOT reference any of the +# SHA-384 entry points (those live in the OTHER overlay half) or the +# wrapper-only labels. Active-code grep only. +if grep -v '^\s*;' "$STAGING/ecdsa384_raw.s" \ + | grep -qE '\bsha384_init\b|\bsha384_update\b|\bsha384_final\b|\becdsa_verify_with_message_384\b|\becdsa_verify_with_msg_384_tramp\b|\becdsa384_msg_struct_ptr\b'; then + echo "ERROR: stripped ecdsa384 still references wrapper / SHA symbols" >&2 + grep -v '^\s*;' "$STAGING/ecdsa384_raw.s" \ + | grep -nE '\bsha384_init\b|\bsha384_update\b|\bsha384_final\b|\becdsa_verify_with_message_384\b|\becdsa_verify_with_msg_384_tramp\b|\becdsa384_msg_struct_ptr\b' \ + | head -5 >&2 exit 1 fi @@ -256,42 +503,79 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" # zp_config.s is the single point of truth for the library's ZP equates. # We assemble it with `-D` overrides so the sibling's defaults are -# replaced by c64-https's canonical ZP map. The other source files use -# `.importzp` to pull these equates from the linker-resolved zp_config.o. +# replaced by c64-https's canonical ZP map (with the Phase 1.5 SHA-384 +# slot moves). The other source files use `.importzp` to pull these +# equates from the linker-resolved zp_config.o. +# `-g` embeds cc65 debug info into each .o; the overlay ld65 invocations +# in build_nistcurves_p384_bin.sh merge it into build/lib/overlay-p384-*.dbg +# sidecars. Does not change emitted code bytes. "$CA65" \ + -g \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ "${ZP_DEFINES[@]}" \ -o "$OBJ_DIR/zp_config.o" "$STAGING/zp_config.s" -# Other files: NO -D. Let `.importzp` resolve through the linker to -# zp_config.o's `.exportzp` declarations. If we passed -D here the +# Other files: NO -D. Let `.importzp` resolve through the linker to +# zp_config.o's `.exportzp` declarations. If we passed -D here the # assembler would treat the symbol as locally-defined absolute and # conflict with the .importzp declaration. -for src in fp384_raw mod384_raw points384_raw data_raw; do +for src in fp384_raw mod384_raw points384_raw curve384_raw \ + sha384_raw ecdsa384_raw ec_scalar_mul_384_shim_raw \ + data_curve_raw data_sha_raw; do "$CA65" \ + -g \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" done -# --- Archive into nistcurves-p384.a --- -rm -f "$ARCHIVE" -"$AR65" a "$ARCHIVE" \ +# --- Archive: nistcurves-p384-sha384.a (SHA-384 hash overlay half) --- +# Members: zp_config + sha384_raw + data_sha_raw. +# The SHA archive does NOT contain ANY curve code; ld65 link resolves +# only the SHA exports + the resident SHA DATA buffers. +rm -f "$ARCHIVE_SHA" +"$AR65" a "$ARCHIVE_SHA" \ + "$OBJ_DIR/zp_config.o" \ + "$OBJ_DIR/sha384_raw.o" \ + "$OBJ_DIR/data_sha_raw.o" + +# --- Archive: nistcurves-p384-curve.a (curve / verify overlay half) --- +# Members: zp_config + fp384 + mod384 + points384 + curve384 + +# ecdsa384 (verify_384 only) + shim + data_curve_raw. +# The curve archive does NOT contain ANY SHA code or SHA DATA exports; +# ld65 link resolves only ecdsa_verify_384 + the resident curve DATA +# buffers. +rm -f "$ARCHIVE_CURVE" +"$AR65" a "$ARCHIVE_CURVE" \ "$OBJ_DIR/zp_config.o" \ "$OBJ_DIR/fp384_raw.o" \ "$OBJ_DIR/mod384_raw.o" \ "$OBJ_DIR/points384_raw.o" \ - "$OBJ_DIR/data_raw.o" + "$OBJ_DIR/curve384_raw.o" \ + "$OBJ_DIR/ecdsa384_raw.o" \ + "$OBJ_DIR/ec_scalar_mul_384_shim_raw.o" \ + "$OBJ_DIR/data_curve_raw.o" # --- Per-source byte counts --- { - echo "# nistcurves-p384.a per-source byte counts (ca65 .o file sizes)" - for src in zp_config fp384_raw mod384_raw points384_raw data_raw; do + echo "# nistcurves-p384-sha384.a per-source byte counts (ca65 .o file sizes)" + for src in zp_config sha384_raw data_sha_raw; do + bytes=$(wc -c < "$OBJ_DIR/$src.o") + printf '%-32s %d bytes (.o)\n' "$src" "$bytes" + done +} > "$SIZES_SHA" + +{ + echo "# nistcurves-p384-curve.a per-source byte counts (ca65 .o file sizes)" + for src in zp_config fp384_raw mod384_raw points384_raw curve384_raw \ + ecdsa384_raw ec_scalar_mul_384_shim_raw data_curve_raw; do bytes=$(wc -c < "$OBJ_DIR/$src.o") - printf '%-24s %d bytes (.o)\n' "$src" "$bytes" + printf '%-32s %d bytes (.o)\n' "$src" "$bytes" done -} > "$SIZES" +} > "$SIZES_CURVE" -echo "built $ARCHIVE" -cat "$SIZES" +echo "built $ARCHIVE_SHA" +cat "$SIZES_SHA" +echo "built $ARCHIVE_CURVE" +cat "$SIZES_CURVE" diff --git a/tools/integration/build_nistcurves_p384_bin.sh b/tools/integration/build_nistcurves_p384_bin.sh index 3386751..9ebe7a5 100755 --- a/tools/integration/build_nistcurves_p384_bin.sh +++ b/tools/integration/build_nistcurves_p384_bin.sh @@ -1,27 +1,44 @@ #!/usr/bin/env bash # ============================================================================= -# tools/integration/build_nistcurves_p384_bin.sh — Extract a standalone -# P-384 overlay image (.bin) and VICE labels from nistcurves-p384.a. +# tools/integration/build_nistcurves_p384_bin.sh — Extract the two split +# P-384 overlay images (.bin) and VICE labels for the SHA-384 and curve / +# verify halves. # -# Phase C.3b. The production PRG does NOT link nistcurves-p384.a (the -# Makefile `USE_NISTCURVES_P384` gate is intentionally commented). Instead, -# tools/test_p384_symbols.py loads the output of THIS script into the -# U64/VICE REU at harness time, then pages it into the live CRYPTO_OVERLAY -# slot via crypto_swap_to_p384. +# Phase 1.5 split. Phase 1b's monolithic overlay (12,836 B) overflowed +# the live UCI CRYPTO_OVERLAY slot ($1E00 = 7,680 B at $4200-$5FFF). +# This script now produces TWO 7.5 KB-padded images, one per archive +# half emitted by build_nistcurves_p384.sh. Each image fits the live +# slot; the TLS path loads them in sequence (sha384 first, then curve). # # Outputs: -# build/lib/overlay-p384.bin — raw 8192-byte OVERLAY_P384 image, -# padded with $00 to the full 8 KB slot. -# build/labels-p384.txt — VICE-format labels for the P-384 -# symbols (ec_point_double_384 etc. -# plus the DATA-resident ec384_p1, -# ec384_affine_x and friends). +# build/lib/overlay-p384-sha384.bin - 7,680-byte padded overlay image +# for the SHA-384 hash code. +# REU dest: REU_OVERLAY_P384_SHA384 +# (bank 6, $60000) -- see +# src/crypto/shared/reu_layout.inc. +# build/lib/overlay-p384-curve.bin - 7,680-byte padded overlay image +# for the curve / verify code. +# REU dest: REU_OVERLAY_P384_CURVE +# (bank 7, $70000). +# build/lib/overlay-p384-sha384.sizes.txt +# build/lib/overlay-p384-curve.sizes.txt +# build/labels-p384-sha384.txt - VICE-format labels for the SHA +# archive's symbols. +# build/labels-p384-curve.txt - VICE-format labels for the curve +# archive's symbols. # -# The cfg at cfg/p384-overlay.cfg places: -# * OVERLAY_P384 at $4200 (matches CRYPTO_OVERLAY base under UCI). -# * DATA / BSS at $7C00 (matches CRYPTO_RESIDENT_2 under UCI). -# so the labels line up with where the harness-time swap actually lands -# the overlay. +# The cfgs at cfg/p384-overlay-sha384.cfg and cfg/p384-overlay-curve.cfg +# pin both OVERLAY_REGION at $4200 size $1E00 (matches the live UCI +# CRYPTO_OVERLAY) and DATA / BSS at $C000 (matches the standalone +# RESIDENT region). +# +# The production PRG does NOT link nistcurves-p384-*.a (the Makefile +# `USE_NISTCURVES_P384` gate is intentionally commented). These outputs +# are smoke-test infrastructure: a future Phase 3 / Phase 4a harness +# will load both .bins into REU at test time, then DMA them into the +# live slot via crypto_swap_to_p384_sha384 / crypto_swap_to_p384_curve +# (Phase 3 will add those; the existing crypto_swap_to_p384 entry point +# is now stale and will be replaced — see crypto_swap.s comment block). # # Imports resolved via ld65 --define: # * REU register equates (not exported by the in-tree build — the @@ -30,7 +47,7 @@ # reu_fetch_mul_row — these come from the x25519 sibling at runtime, # but for the standalone link we define them at their UCI-backend # addresses (read out of build/labels.txt if available, else stubbed -# to $0000 — irrelevant to the OVERLAY_P384 image bytes since those +# to $0000 — irrelevant to the OVERLAY_P384_* image bytes since those # references are resolved as references, not inlined data). # # Usage (from the top-level Makefile): @@ -39,110 +56,232 @@ set -eo pipefail PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -ARCHIVE="$PROJECT_ROOT/build/lib/nistcurves-p384.a" -CFG="$PROJECT_ROOT/cfg/p384-overlay.cfg" +ARCHIVE_SHA="$PROJECT_ROOT/build/lib/nistcurves-p384-sha384.a" +ARCHIVE_CURVE="$PROJECT_ROOT/build/lib/nistcurves-p384-curve.a" +CFG_SHA="$PROJECT_ROOT/cfg/p384-overlay-sha384.cfg" +CFG_CURVE="$PROJECT_ROOT/cfg/p384-overlay-curve.cfg" OUT_DIR="$PROJECT_ROOT/build/lib" -BIN_OUT="$OUT_DIR/overlay-p384.bin" -LABELS_OUT="$PROJECT_ROOT/build/labels-p384.txt" -MAP_OUT="$OUT_DIR/overlay-p384.map" +BIN_OUT_SHA="$OUT_DIR/overlay-p384-sha384.bin" +BIN_OUT_CURVE="$OUT_DIR/overlay-p384-curve.bin" +SIZES_OUT_SHA="$OUT_DIR/overlay-p384-sha384.sizes.txt" +SIZES_OUT_CURVE="$OUT_DIR/overlay-p384-curve.sizes.txt" +LABELS_OUT_SHA="$PROJECT_ROOT/build/labels-p384-sha384.txt" +LABELS_OUT_CURVE="$PROJECT_ROOT/build/labels-p384-curve.txt" +MAP_OUT_SHA="$OUT_DIR/overlay-p384-sha384.map" +MAP_OUT_CURVE="$OUT_DIR/overlay-p384-curve.map" + +# Live UCI CRYPTO_OVERLAY slot size: $1E00 = 7,680 B. Each .bin is +# truncated/padded to exactly this many bytes so it DMAs cleanly into +# the live slot. +SLOT_BYTES=7680 LD65="${LD65:-ld65}" +AR65="${AR65:-ar65}" -if [ ! -f "$ARCHIVE" ]; then - echo "ERROR: $ARCHIVE does not exist — run tools/integration/build_nistcurves_p384.sh first" >&2 +if [ ! -f "$ARCHIVE_SHA" ] || [ ! -f "$ARCHIVE_CURVE" ]; then + echo "ERROR: archive(s) missing — run tools/integration/build_nistcurves_p384.sh first" >&2 + [ ! -f "$ARCHIVE_SHA" ] && echo " missing: $ARCHIVE_SHA" >&2 + [ ! -f "$ARCHIVE_CURVE" ] && echo " missing: $ARCHIVE_CURVE" >&2 exit 1 fi -# ld65 requires at least one plain .o on the command line; an archive -# alone is not enough even with --force-import. Extract the archive -# members into a scratch dir and pass them all as objects. -AR65="${AR65:-ar65}" -SCRATCH="$OUT_DIR/p384_bin_scratch" -rm -rf "$SCRATCH" -mkdir -p "$SCRATCH" -cp "$ARCHIVE" "$SCRATCH/" -(cd "$SCRATCH" && "$AR65" x "$(basename "$ARCHIVE")" \ - zp_config.o fp384_raw.o mod384_raw.o points384_raw.o data_raw.o) - -# Try to pick up x25519-sibling addresses from the main build's labels.txt -# so references resolve to the real runtime locations. If the main build -# hasn't happened yet, stub them to $0000 — the overlay binary doesn't -# actually dereference these; only labels.txt addresses would be wrong, -# and we strip them below anyway. +# Pick up main-PRG addresses for mul_dma_lo / mul_dma_hi / mul_cached_a / +# reu_fetch_mul_row so the curve overlay's fp_mul_384 reads/writes the +# right runtime cells (e.g. mul_dma_lo at $BA00 in the main PRG's +# TABLES_BSS). These symbols belong to the main PRG, not to the overlay +# itself; the overlay's fp_mul_384 was assembled against `.import`s for +# them and ld65 needs `--define`'d addresses to resolve them at overlay +# link time. +# +# Phase 5 Fix D: if build/labels.txt is missing OR any required symbol is +# missing from it, ABORT with a clear error rather than silently falling +# back to $0000 stubs (which used to produce a curve overlay whose +# fp_mul_384 read/wrote $0000/$0001 — silent corruption with no obvious +# symptom downstream). The Makefile lists build/labels.txt as an +# order-only dep on the overlay-bin target so the main PRG's labels are +# present by the time this script runs in normal incremental builds; on +# a clean tree the user must build the main PRG first (which builds +# overlay-bins as a transitive dep — the cycle resolves on the second +# pass). MAIN_LABELS="$PROJECT_ROOT/build/labels.txt" +if [ ! -f "$MAIN_LABELS" ]; then + echo "ERROR: $MAIN_LABELS not found." >&2 + echo " The overlay-bin link needs the main PRG's runtime addresses for" >&2 + echo " mul_dma_lo / mul_dma_hi / mul_cached_a / reu_fetch_mul_row." >&2 + echo " Run 'make' (or 'make BACKEND=uci') once first to produce" >&2 + echo " build/labels.txt, then re-run 'make p384-overlay'." >&2 + exit 3 +fi + lookup_label () { local name="$1" - local fallback="$2" - if [ -f "$MAIN_LABELS" ]; then - local hex - hex=$(grep -E " \.${name}\$" "$MAIN_LABELS" | head -n1 | awk '{print $2}' | sed 's|^C:||') - if [ -n "$hex" ]; then - printf '$%s' "$hex" - return - fi + local hex + hex=$(grep -E " \.${name}\$" "$MAIN_LABELS" | head -n1 | awk '{print $2}' | sed 's|^C:||') + if [ -z "$hex" ]; then + echo "ERROR: required symbol '$name' missing from $MAIN_LABELS" >&2 + echo " Did the main PRG link complete successfully? See build/c64-https.map." >&2 + exit 4 fi - printf '%s' "$fallback" + printf '$%s' "$hex" } -DEF_MUL_CACHED_A=$(lookup_label mul_cached_a '$0000') -DEF_MUL_DMA_LO=$(lookup_label mul_dma_lo '$0000') -DEF_MUL_DMA_HI=$(lookup_label mul_dma_hi '$0000') -DEF_REU_FETCH_MUL_ROW=$(lookup_label reu_fetch_mul_row '$0000') +DEF_MUL_CACHED_A=$(lookup_label mul_cached_a) +DEF_MUL_DMA_LO=$(lookup_label mul_dma_lo) +DEF_MUL_DMA_HI=$(lookup_label mul_dma_hi) +DEF_REU_FETCH_MUL_ROW=$(lookup_label reu_fetch_mul_row) -# poly_prod_lo / poly_prod_hi: 2-byte mul_8x8 output register. The x25519 +# poly_prod_lo / poly_prod_hi: 2-byte mul_8x8 output register. The x25519 # sibling emits these INSIDE OVERLAY_X25519 ($42A0) — unusable when our -# P-384 overlay is swapped in (same slot, different code bytes). Point +# P-384 overlay is swapped in (same slot, different code bytes). Point # the P-384 standalone link to stable scratch RAM at $CFFE-$CFFF, which -# sits in TCP_BUF past the P-384 DATA block ($C000-$C636). +# sits in TCP_BUF past the P-384 DATA block. DEF_POLY_PROD_LO='$CFFE' DEF_POLY_PROD_HI='$CFFF' mkdir -p "$OUT_DIR" -# Link. ld65 -Ln emits labels in the old ca65 format; the main Makefile -# rewrites `al 00XXXX .name` to `al C:XXXX .name` via sed. Mirror that. -"$LD65" \ - -C "$CFG" \ - -o "$BIN_OUT" \ - -Ln "$LABELS_OUT" \ - -m "$MAP_OUT" \ - --define reu_status=\$df00 \ - --define reu_command=\$df01 \ - --define reu_c64_lo=\$df02 \ - --define reu_c64_hi=\$df03 \ - --define reu_reu_lo=\$df04 \ - --define reu_reu_hi=\$df05 \ - --define reu_reu_bank=\$df06 \ - --define reu_len_lo=\$df07 \ - --define reu_len_hi=\$df08 \ - --define reu_addr_ctrl=\$df0a \ - --define mul_cached_a="$DEF_MUL_CACHED_A" \ - --define mul_dma_lo="$DEF_MUL_DMA_LO" \ - --define mul_dma_hi="$DEF_MUL_DMA_HI" \ - --define poly_prod_lo="$DEF_POLY_PROD_LO" \ - --define poly_prod_hi="$DEF_POLY_PROD_HI" \ - --define reu_fetch_mul_row="$DEF_REU_FETCH_MUL_ROW" \ - "$SCRATCH/zp_config.o" \ - "$SCRATCH/fp384_raw.o" \ - "$SCRATCH/mod384_raw.o" \ - "$SCRATCH/points384_raw.o" \ - "$SCRATCH/data_raw.o" - -# Normalise labels to VICE format (al C:XXXX .name) so c64-test-harness's -# Labels.from_file() reader accepts it identically to build/labels.txt. -sed -i 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$LABELS_OUT" - -# ld65 writes the DATA segment bytes (RESIDENT region at $7C00) into the -# output file too, even though RESIDENT has no `file = %O` — so the raw -# output is ~9.5 KB. Truncate to exactly 8192 bytes to get the OVERLAY_P384 -# slot image. DATA lives at runtime addresses and is zero-init; the harness -# does not need its bytes in the overlay image. -truncate -s 8192 "$BIN_OUT" - -size=$(wc -c < "$BIN_OUT") -if [ "$size" -ne 8192 ]; then - echo "ERROR: $BIN_OUT is $size bytes, expected 8192" >&2 - exit 1 -fi +# ----------------------------------------------------------------------------- +# Helper: link one archive into a padded .bin + labels file. +# Args: archive_path, cfg_path, bin_out, labels_out, map_out, sizes_out, archive_label +# ----------------------------------------------------------------------------- +link_one () { + local archive="$1" + local cfg="$2" + local bin_out="$3" + local labels_out="$4" + local map_out="$5" + local sizes_out="$6" + local label="$7" + + local scratch="$OUT_DIR/p384_bin_scratch_${label}" + rm -rf "$scratch" + mkdir -p "$scratch" + cp "$archive" "$scratch/" + + # ld65 requires plain .o objects on the command line; an archive alone + # is not enough even with --force-import. Extract the archive members + # and pass them as objects. We don't know in advance which members + # the archive holds, so use `ar65 t` to enumerate. + local archive_basename + archive_basename=$(basename "$archive") + local members + members=$( (cd "$scratch" && "$AR65" t "$archive_basename") | tr -d '\r' ) + if [ -z "$members" ]; then + echo "ERROR: $archive_basename appears empty" >&2 + exit 1 + fi + (cd "$scratch" && "$AR65" x "$archive_basename" $members) + + local obj_args=() + local m + for m in $members; do + obj_args+=("$scratch/$m") + done + + # Sidecar .dbg path: build/lib/overlay-p384-{sha384,curve}.dbg. + # Pairs with the `-g` ca65 flag added in build_nistcurves_p384.sh so + # ld65 can merge per-source line/symbol records. Does not affect the + # padded .bin image bytes. + local dbg_out + dbg_out="${bin_out%.bin}.dbg" + + "$LD65" \ + -C "$cfg" \ + -o "$bin_out" \ + -Ln "$labels_out" \ + -m "$map_out" \ + --dbgfile "$dbg_out" \ + --define reu_status=\$df00 \ + --define reu_command=\$df01 \ + --define reu_c64_lo=\$df02 \ + --define reu_c64_hi=\$df03 \ + --define reu_reu_lo=\$df04 \ + --define reu_reu_hi=\$df05 \ + --define reu_reu_bank=\$df06 \ + --define reu_len_lo=\$df07 \ + --define reu_len_hi=\$df08 \ + --define reu_addr_ctrl=\$df0a \ + --define mul_cached_a="$DEF_MUL_CACHED_A" \ + --define mul_dma_lo="$DEF_MUL_DMA_LO" \ + --define mul_dma_hi="$DEF_MUL_DMA_HI" \ + --define poly_prod_lo="$DEF_POLY_PROD_LO" \ + --define poly_prod_hi="$DEF_POLY_PROD_HI" \ + --define reu_fetch_mul_row="$DEF_REU_FETCH_MUL_ROW" \ + "${obj_args[@]}" + + # Normalise labels to VICE format (al C:XXXX .name) so c64-test-harness's + # Labels.from_file() reader accepts it identically to build/labels.txt. + sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$labels_out" + + # Compute the on-disk OVERLAY image size from the .map (so the sizes + # report reflects the real loaded bytes, not the post-truncate size). + local seg_name + if [ "$label" = "sha384" ]; then + seg_name="OVERLAY_P384_SHA384" + else + seg_name="OVERLAY_P384_CURVE" + fi + # macOS awk lacks strtonum(); parse the hex Size field via printf. + # The .map has TWO sections that mention segment names: + # "Modules list" rows: Offs=000000 Size=001550 Align=00001 + # "Segment list" rows: Name Start End Size Align (hex, no prefix) + # We want the Segment list size, so anchor on its header line. + local overlay_hex + overlay_hex=$(awk -v seg="$seg_name" ' + /^Segment list:/ { in_seg=1; next } + /^Exports list/ { in_seg=0 } + in_seg && $1 == seg { print $4; exit } + ' "$map_out") + local overlay_bytes="" + if [ -n "$overlay_hex" ]; then + overlay_bytes=$(printf '%d' "0x$overlay_hex") + fi + + # ld65 writes the DATA segment bytes (RESIDENT region at $C000) into + # the output file too, even though RESIDENT has no `file = %O` — so + # the raw output is much larger than the slot. Truncate / pad to + # exactly $SLOT_BYTES so the .bin DMAs into the live UCI overlay + # slot (which is exactly $1E00 = 7,680 B). DATA lives at runtime + # addresses and is zero-init; the harness does not need its bytes + # in the overlay image. + truncate -s "$SLOT_BYTES" "$bin_out" + + local size + size=$(wc -c < "$bin_out") + if [ "$size" -ne "$SLOT_BYTES" ]; then + echo "ERROR: $bin_out is $size bytes, expected $SLOT_BYTES" >&2 + exit 1 + fi + + { + echo "# nistcurves-p384-${label} overlay image (Phase 1.5 split)" + echo "# slot size: $SLOT_BYTES B (\$1E00 — UCI CRYPTO_OVERLAY)" + if [ -n "$overlay_bytes" ]; then + echo "# unpadded overlay: $overlay_bytes B" + echo "# padded .bin: $size B" + echo "# headroom: $((SLOT_BYTES - overlay_bytes)) B" + if [ "$overlay_bytes" -gt "$SLOT_BYTES" ]; then + echo "# *** OVERFLOW: overlay exceeds slot by $((overlay_bytes - SLOT_BYTES)) B ***" + fi + else + echo "# unpadded overlay: (unknown — see $map_out)" + echo "# padded .bin: $size B" + fi + } > "$sizes_out" + + if [ -n "$overlay_bytes" ] && [ "$overlay_bytes" -gt "$SLOT_BYTES" ]; then + echo "ERROR: $bin_out overlay segment ($overlay_bytes B) exceeds 7,680 B slot by $((overlay_bytes - SLOT_BYTES)) B" >&2 + exit 1 + fi + + echo "built $bin_out ($size B padded; overlay = ${overlay_bytes:-unknown} B)" +} + +link_one "$ARCHIVE_SHA" "$CFG_SHA" "$BIN_OUT_SHA" "$LABELS_OUT_SHA" "$MAP_OUT_SHA" "$SIZES_OUT_SHA" "sha384" +link_one "$ARCHIVE_CURVE" "$CFG_CURVE" "$BIN_OUT_CURVE" "$LABELS_OUT_CURVE" "$MAP_OUT_CURVE" "$SIZES_OUT_CURVE" "curve" -echo "built $BIN_OUT (8192 bytes) and $LABELS_OUT" +echo +echo "Phase 1.5 split overlay sizes:" +cat "$SIZES_OUT_SHA" +echo +cat "$SIZES_OUT_CURVE" diff --git a/tools/integration/build_x25519.sh b/tools/integration/build_x25519.sh index 5bd645e..bd52fc9 100644 --- a/tools/integration/build_x25519.sh +++ b/tools/integration/build_x25519.sh @@ -326,7 +326,11 @@ rm -rf "$OBJ_DIR" mkdir -p "$OBJ_DIR" "$OUT_DIR" for src in fe25519_raw x25519_raw x25519_init_raw data_x25519_bss_raw data_x25519_rodata_raw; do + # `-g` embeds cc65 debug info; ld65 --dbgfile (top-level Makefile) + # merges per-source line/symbol records into build/c64-https.dbg. + # Does not change emitted code bytes. "$CA65" \ + -g \ -I "$STAGING" \ "${ZP_DEFINES[@]}" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" diff --git a/tools/integration/gen_p384_overlay_equates.sh b/tools/integration/gen_p384_overlay_equates.sh new file mode 100755 index 0000000..89fe1d5 --- /dev/null +++ b/tools/integration/gen_p384_overlay_equates.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# ============================================================================= +# tools/integration/gen_p384_overlay_equates.sh — Phase 5 Fix C. +# +# Extract P-384 overlay-resident symbol addresses from +# build/labels-p384-sha384.txt and build/labels-p384-curve.txt and emit a +# ca65 .inc file (build/p384_overlay_equates.inc) that the TLS-side P-384 +# verify dispatcher (src/crypto/ecdsa_verify_384.s) can `.include` to +# pick them up at assembly time. +# +# Phase 4a hand-pasted these addresses as numeric equates. When the +# overlay images are rebuilt (e.g. after a libs/nistcurves bump or an +# overlay-cfg restructure) the addresses move silently — the dispatcher +# carries on calling stale addresses with no link-time error. Wiring +# the equates through a generated `.inc` lets ca65's `.assert` (in the +# dispatcher itself) catch drift, and at minimum the dispatcher will +# fail to build if a required label disappears entirely from the +# overlay labels file. +# +# Symbols extracted (must exist in the overlay labels): +# sha384_init — overlay code entry, expected $4200 (sha384 cfg) +# sha384_update — overlay code entry +# sha384_final — overlay code entry +# sha384_digest — overlay-resident DATA, 48 B BE digest output +# ecdsa_verify_384 — overlay code entry, expected $4200..$5FFF +# ecdsa_inputs_384 — overlay-resident DATA, 240 B BE input struct +# +# Usage (from the Makefile): +# bash tools/integration/gen_p384_overlay_equates.sh \ +# build/labels-p384-sha384.txt \ +# build/labels-p384-curve.txt \ +# build/p384_overlay_equates.inc +# ============================================================================= +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +SHA_LABELS="$1" +CURVE_LABELS="$2" +OUT="$3" + +if [ ! -f "$SHA_LABELS" ]; then + echo "ERROR: SHA-384 overlay labels not found: $SHA_LABELS" >&2 + exit 1 +fi +if [ ! -f "$CURVE_LABELS" ]; then + echo "ERROR: curve overlay labels not found: $CURVE_LABELS" >&2 + exit 1 +fi + +# lookup_label +# Emits the 4-hex-char address (e.g. 4200) or fails if the symbol is missing. +lookup_label () { + local file="$1" + local name="$2" + # Format: "al C:HHHH .name" (post-ld65 sed normalisation in Makefile). + local hex + hex=$(awk -v n=".$name" '$3 == n { sub(/^C:/, "", $2); print $2; exit }' "$file") + if [ -z "$hex" ]; then + echo "ERROR: symbol '$name' not found in $file" >&2 + exit 2 + fi + echo "$hex" +} + +SHA384_INIT=$(lookup_label "$SHA_LABELS" sha384_init) +SHA384_UPDATE=$(lookup_label "$SHA_LABELS" sha384_update) +SHA384_FINAL=$(lookup_label "$SHA_LABELS" sha384_final) +SHA384_DIGEST=$(lookup_label "$SHA_LABELS" sha384_digest) +ECDSA_VERIFY_384=$(lookup_label "$CURVE_LABELS" ecdsa_verify_384) +ECDSA_INPUTS_384=$(lookup_label "$CURVE_LABELS" ecdsa_inputs_384) + +mkdir -p "$(dirname "$OUT")" + +# Atomic write: stage to a temp file then mv into place, so a partial +# write can't poison incremental builds. +TMP="$(mktemp "${OUT}.XXXXXX")" +trap 'rm -f "$TMP"' EXIT + +cat > "$TMP" < ecdsa_inputs_384[96..143] + 4. crypto_swap_to_p384_curve ; DMA curve / verify overlay from REU bank 7 + 5. jsr ecdsa_verify_384 ; A/X = pointer to 240 B BE struct + +The stub runs on the C64 and signals completion + result via sentinel +bytes the host polls. The host pre-loads (r, s, Qx, Qy, message) into +the resident DATA buffers via DMA before each invocation. + +Vector subset (RFC 6979 + NIST CAVP P-384,SHA-384). Default ("smoke"): + + - RFC 6979 A.3.1 P-384 "sample" (positive, deterministic-k canonical) + +`--full` adds: + + - RFC 6979 A.3.1 with LSB-flipped r (negative derivative) + - First CAVP SigVer Result=P (positive) + - First CAVP SigVer Result=F (modification 1-4) (negative) + +Single-vector default exists because one P-384 verify costs anywhere from +~5 s (fast Mac, VICE warp) to ~15-30 min (slower hosts) of wall-clock, +and one verify is enough to confirm the dual-overlay flow is wired up. +Use `--full` once you have a wall-clock budget for 4x the per-verify +cost. + +Usage: + + /Users/someone/.local/share/c64-test-harness/venv/bin/python3 \ + tools/test_ecdsa_p384_kat.py [--u64] [--full] [--verbose] + + --u64 Also run on a real Ultimate 64 Elite (requires U64_HOST). + Default skips U64 — VICE-only. + --full Run all 4 vectors (positive RFC + neg-r + CAVP P + CAVP F). + --verbose Print per-vector wall-clock + carry breakdown. + --sha-only Diagnostic: skip the slow ecdsa_verify_384 step. Confirms + the dual-overlay swap dispatch + SHA-384 + splice path + works without paying for the verify (which on a busy + VICE warp host can take 5-30 min/vector). Use this + first if --full hangs. + +Environment: + C64_SKIP_BUILD=1 Reuse existing build artifacts (skip make). + U64_HOST= Ultimate 64 host (default 192.168.1.81). + P384_KAT_VICE_TIMEOUT_S Per-VERIFY-step timeout under VICE + (default 1800 s = 30 min). + P384_KAT_U64_TIMEOUT_S Per-vector timeout under U64 + (default 600 s). + +Build-order trap: this test does a two-pass build internally because +the overlay-bin link script reads `build/labels.txt` to resolve +`mul_dma_lo`/`mul_dma_hi`/`mul_cached_a`/`reu_fetch_mul_row` at the +SAME runtime addresses the main PRG uses. On a clean build, +`labels.txt` doesn't exist when the overlay-bin link runs, those +symbols stub out to `$0000`, and the curve overlay's `fp_mul_384` +hangs in field arithmetic with no obvious symptom. Two passes +(first builds labels.txt, second re-runs the overlay-bin link with +the resolved addresses) work around it; an explicit sanity check +after the build asserts the curve overlay's labels are non-zero. +Cleaner upstream fix: add `build/labels.txt` as an order-only +dependency on the overlay-bin Make target. +""" +from __future__ import annotations + +import hashlib +import os +import subprocess +import sys +import time +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PRG_PATH = PROJECT_ROOT / "build" / "c64-https.prg" +LABELS_PATH = PROJECT_ROOT / "build" / "labels.txt" +LABELS_SHA384_PATH = PROJECT_ROOT / "build" / "labels-p384-sha384.txt" +LABELS_CURVE_PATH = PROJECT_ROOT / "build" / "labels-p384-curve.txt" +NIST_VECTORS_PATH = ( + PROJECT_ROOT / "libs" / "nistcurves" / "tools" / "vectors" + / "nist_p384_sigver.rsp" +) + + +# ----------------------------------------------------------------------------- +# Test vectors +# ----------------------------------------------------------------------------- + +# RFC 6979 Appendix A.3.1 — P-384, SHA-384, message "sample" (positive) +RFC6979_P384 = { + "name": "rfc6979_p384_sample", + "msg": b"sample", + "Qx": 0xEC3A4E415B4E19A4568618029F427FA5DA9A8BC4AE92E02E06AAE5286B300C64DEF8F0EA9055866064A254515480BC13, + "Qy": 0x8015D9B72D7D57244EA8EF9AC0C621896708A59367F9DFB9F54CA84B3F1C9DB1288B231C3AE0D4FE7344FD2533264720, + "r": 0x94EDBB92A5ECB8AAD4736E56C691916B3F88140666CE9FA73D64C4EA95AD133C81A648152E44ACF96E36DD1E80FABE46, + "s": 0x99EF4AEB15F178CEA1FE40DB2603138F130E740A19624526203B6351D0A3A94FA329C145786E679E7B82C71A38628AC8, + "expected_valid": True, +} + + +# Negative derivative of RFC 6979 — flip LSB of r. +RFC6979_P384_NEG_R = { + "name": "rfc6979_p384_sample_flip_r", + "msg": b"sample", + "Qx": RFC6979_P384["Qx"], + "Qy": RFC6979_P384["Qy"], + "r": RFC6979_P384["r"] ^ 1, + "s": RFC6979_P384["s"], + "expected_valid": False, +} + + +def _parse_cavp_p384_section(path: Path) -> list[dict]: + """Parse the [P-384,SHA-384] section of nist_p384_sigver.rsp.""" + out = [] + cur: dict = {} + in_section = False + with path.open("r", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#"): + if cur and in_section and "expected_pass" in cur: + out.append(cur) + cur = {} + continue + if line.startswith("[") and line.endswith("]"): + if cur and in_section and "expected_pass" in cur: + out.append(cur) + cur = {} + in_section = (line[1:-1].strip() == "P-384,SHA-384") + continue + if not in_section: + continue + if "=" in line: + k, _, v = line.partition("=") + k = k.strip() + v = v.strip() + if k == "Msg": + cur["Msg"] = bytes.fromhex(v) + elif k in ("Qx", "Qy", "R", "S"): + cur[k] = int(v, 16) + elif k == "Result": + cur["raw_result"] = v + cur["expected_pass"] = v.startswith("P") + if cur and in_section and "expected_pass" in cur: + out.append(cur) + return out + + +def _build_vector_list(*, full: bool = False) -> list[dict]: + """Pick the diverse smoke-test vector subset. + + Default ("smoke"): RFC 6979 positive only — one verify under VICE + warp can run anywhere from 30 s to several minutes wall-clock + depending on host CPU, and this is the fastest signal that the + end-to-end dual-overlay path is wired up correctly. + + --full: add the RFC 6979 negative derivative + first CAVP P + first + CAVP F. Total wall-clock under VICE warp is 4x the per-vector + verify time plus overhead. + """ + vectors = [RFC6979_P384] + + if not full: + return vectors + + vectors.append(RFC6979_P384_NEG_R) + + cavp = _parse_cavp_p384_section(NIST_VECTORS_PATH) + cavp_pos = next((v for v in cavp if v["expected_pass"]), None) + cavp_neg = next((v for v in cavp if not v["expected_pass"]), None) + if cavp_pos is None or cavp_neg is None: + raise RuntimeError( + f"Could not find a P/F pair in {NIST_VECTORS_PATH} " + f"section [P-384,SHA-384] (parsed {len(cavp)} vectors)" + ) + + vectors.append({ + "name": f"cavp_pos[{cavp_pos['raw_result']}]", + "msg": cavp_pos["Msg"], + "Qx": cavp_pos["Qx"], + "Qy": cavp_pos["Qy"], + "r": cavp_pos["R"], + "s": cavp_pos["S"], + "expected_valid": True, + }) + vectors.append({ + "name": f"cavp_neg[{cavp_neg['raw_result']}]", + "msg": cavp_neg["Msg"], + "Qx": cavp_neg["Qx"], + "Qy": cavp_neg["Qy"], + "r": cavp_neg["R"], + "s": cavp_neg["S"], + "expected_valid": False, + }) + + return vectors + + +# ----------------------------------------------------------------------------- +# Address layout +# +# All overlay-side and dispatch addresses are resolved dynamically from +# build/labels.txt + build/labels-p384-{sha384,curve}.txt at runtime +# (see _resolve_addresses). The constants below are only the pieces +# that *don't* come from labels — harness scratch addresses (where the +# stub + message + sentinels live) and the SHA-384 ZP slots, which the +# sibling's overlay-side labels file expose but which we want to be +# explicit about anyway since they're shared between caller and callee. +# ----------------------------------------------------------------------------- + +# Sibling SHA-384 streaming pointers — ZP $3D-$40 (per Phase 1.5 sibling +# zp_config.s; comment in src/crypto/shared/crypto_swap.s confirms the +# slots are c64-https-safe across all crypto/TLS/ip65/UCI/fe25519/x25519/ +# ECDSA-bignum paths). We cross-check against labels-p384-sha384.txt at +# runtime to detect any sibling-side ZP relocation. +SHA_SRC_ZP_EXPECTED = 0x003D # 2 B little-endian message pointer +SHA_LEN_ZP_EXPECTED = 0x003F # 2 B little-endian message length + +# Harness scratch addresses (live in the OVERLAY_FILE_PAD tail past the +# curve overlay's resident DATA at $C9F7). $CFFE/$CFFF are reserved by +# the overlay link defines for poly_prod_lo/hi, so we stay below $CFE0. +STUB_ADDR = 0xCA00 # 6502 stub body +MSG_BUF_ADDR = 0xCB00 # message bytes (max 256 B; CAVP msgs are 128 B) +RESULT_CARRY = 0xCFE0 # 0=valid (C=0), 1=invalid (C=1) +SENTINEL_DONE = 0xCFE1 # 0=running, $42=stub finished +PROGRESS_BYTE = 0xCFE2 # debug: which step the stub got to + +DONE_VALUE = 0x42 + + +# ----------------------------------------------------------------------------- +# 6502 stub generator (verify-only) +# +# We split the dual-overlay flow into per-step jsr() calls instead of one +# monolithic stub so the harness has full visibility (and can apply +# tighter per-step timeouts) at each transition. The only step that +# needs a stub is the verify itself, because run_subroutine cannot +# preload CPU registers — the verify's BE-struct ABI takes the pointer +# in A/X. The stub is also the natural place to capture the C flag +# returned by ecdsa_verify_384 and stash it for the host to read back. +# ----------------------------------------------------------------------------- + +def _build_verify_stub(addresses: dict[str, int]) -> bytes: + """Emit the verify-only stub: + + lda #ecdsa_inputs_384 + jsr ecdsa_verify_384 + php / pla / and #$01 + sta RESULT_CARRY + rts + + Caller is responsible for: swapping in the curve overlay (otherwise + the bytes at ecdsa_verify_384's address are stale / wrong); having + pre-staged r/s/h/Qx/Qy in ecdsa_inputs_384. + """ + ecdsa_verify_384 = addresses["ecdsa_verify_384"] + ecdsa_inputs_384 = addresses["ecdsa_inputs_384"] + + code = bytearray() + + def emit(*bs: int) -> None: + code.extend(bs) + + code += bytes([ + 0xA9, ecdsa_inputs_384 & 0xFF, # LDA #> 8) & 0xFF, # LDX #>inputs + 0x20, ecdsa_verify_384 & 0xFF, + (ecdsa_verify_384 >> 8) & 0xFF, # JSR ecdsa_verify_384 + 0x08, # PHP + 0x68, # PLA + 0x29, 0x01, # AND #$01 + 0x8D, RESULT_CARRY & 0xFF, + (RESULT_CARRY >> 8) & 0xFF, # STA RESULT_CARRY + 0x60, # RTS + ]) + + return bytes(code) + + +def _build_splice_stub(addresses: dict[str, int]) -> bytes: + """Emit a stub that copies sha384_digest -> ecdsa_inputs_384+96. + + Y-indexed reverse loop: the SHA digest is 48 B and both source and + destination fit in absolute,Y-addressable space. This runs while + the SHA-384 overlay is resident — sha384_digest's address ($C3E1) + is inside the SHA overlay's resident DATA span; ecdsa_inputs_384's + address ($C8D1) is inside the curve overlay's resident DATA span, + BUT ecdsa_inputs_384 is past the SHA's resident DATA tail at $C411 + so writing there does not alias any SHA state. + """ + sha384_digest = addresses["sha384_digest"] + ecdsa_inputs_384 = addresses["ecdsa_inputs_384"] + DIGEST_DST = ecdsa_inputs_384 + 96 + DIGEST_BYTES = 48 + + code = bytearray() + code += bytes([ + 0xA0, DIGEST_BYTES - 1, # LDY #47 + ]) + cp_loop = len(code) + code += bytes([ + 0xB9, sha384_digest & 0xFF, + (sha384_digest >> 8) & 0xFF, # LDA sha384_digest,Y + 0x99, DIGEST_DST & 0xFF, + (DIGEST_DST >> 8) & 0xFF, # STA DIGEST_DST,Y + 0x88, # DEY + ]) + rel = cp_loop - (len(code) + 2) + code += bytes([0x10, rel & 0xFF]) # BPL @cp_loop + code += bytes([0x60]) # RTS + return bytes(code) + + +# ----------------------------------------------------------------------------- +# Label-file readers +# ----------------------------------------------------------------------------- + +def _load_labels(path: Path) -> dict[str, int]: + """Parse a VICE-format labels.txt ("al C:XXXX .name").""" + out: dict[str, int] = {} + with path.open("r", encoding="utf-8") as fh: + for line in fh: + parts = line.split() + if len(parts) < 3 or parts[0] != "al": + continue + addr_field = parts[1] + if addr_field.startswith("C:"): + addr = int(addr_field[2:], 16) + else: + addr = int(addr_field, 16) + name = parts[2].lstrip(".") + out[name] = addr + return out + + +def _resolve_addresses() -> dict[str, int]: + """Pull every needed address from the on-disk label files. + + The main PRG's labels.txt resolves the swap dispatchers + (crypto_swap_to_p384_*). The split-overlay labels-p384-*.txt + files resolve the overlay-resident entry points (sha384_*, + ecdsa_verify_384) and the resident-DATA buffers (sha384_digest, + ecdsa_inputs_384). + + A small consistency check confirms the sibling SHA-384 ZP slots + haven't moved out from under us; the stub doesn't actually use + these addresses (it relies on the host setting them via DMA before + each call), but a relocation of the sibling's zp_config.s would + silently break the test if the host kept writing to $3D/$3F. + """ + main = _load_labels(LABELS_PATH) + sha = _load_labels(LABELS_SHA384_PATH) + curve = _load_labels(LABELS_CURVE_PATH) + + sources = { + "crypto_swap_to_p384_sha384": main, + "crypto_swap_to_p384_curve": main, + "sha384_init": sha, + "sha384_update": sha, + "sha384_final": sha, + "sha384_digest": sha, + "ecdsa_verify_384": curve, + "ecdsa_inputs_384": curve, + "sha_src": sha, + "sha_len": sha, + } + resolved: dict[str, int] = {} + missing: list[str] = [] + for name, label_dict in sources.items(): + if name not in label_dict: + missing.append(name) + continue + resolved[name] = label_dict[name] + if missing: + raise RuntimeError( + f"Required label(s) missing from on-disk labels files: {missing}" + ) + + # Sibling-ZP sanity: the test stub's host-side DMA writes assume + # sha_src=$3D/$3E + sha_len=$3F/$40. If the sibling relocates + # these, the host will write to dead ZP and the on-device sha_* + # routines will read garbage instead of MSG_BUF_ADDR. Catch that + # before running rather than diagnosing wrong-digest failures. + if resolved["sha_src"] != SHA_SRC_ZP_EXPECTED: + raise RuntimeError( + f"sha_src moved to ${resolved['sha_src']:04X} " + f"(expected ${SHA_SRC_ZP_EXPECTED:04X}); update SHA_SRC_ZP_EXPECTED" + f" + the host-side DMA write." + ) + if resolved["sha_len"] != SHA_LEN_ZP_EXPECTED: + raise RuntimeError( + f"sha_len moved to ${resolved['sha_len']:04X} " + f"(expected ${SHA_LEN_ZP_EXPECTED:04X}); update SHA_LEN_ZP_EXPECTED" + f" + the host-side DMA write." + ) + + return resolved + + +# ----------------------------------------------------------------------------- +# Vector → DMA payloads +# ----------------------------------------------------------------------------- + +def _be48(v: int) -> bytes: + """Encode an integer as 48 BE bytes.""" + return v.to_bytes(48, "big") + + +def _stage_vector_buffers(transport, vec: dict, addresses: dict[str, int]) -> None: + """DMA r/s/Qx/Qy into ecdsa_inputs_384 and the message into MSG_BUF. + + The h slot (ecdsa_inputs_384+96) is left for the stub's + sha384_final + memcpy step. Pre-zeroing it is harmless paranoia. + """ + from c64_test_harness import write_bytes + + inputs = addresses["ecdsa_inputs_384"] + + # Layout: r(48) | s(48) | h(48) | Qx(48) | Qy(48) + write_bytes(transport, inputs + 0, _be48(vec["r"])) + write_bytes(transport, inputs + 48, _be48(vec["s"])) + write_bytes(transport, inputs + 96, bytes(48)) # h zeroed + write_bytes(transport, inputs + 144, _be48(vec["Qx"])) + write_bytes(transport, inputs + 192, _be48(vec["Qy"])) + + msg = vec["msg"] + if len(msg) > 0xFE: + raise ValueError( + f"vector {vec['name']!r}: message length {len(msg)} exceeds " + f"the harness scratch budget at MSG_BUF (256 B - guard)." + ) + if len(msg) > 0: + write_bytes(transport, MSG_BUF_ADDR, msg) + + # sha_src = MSG_BUF_ADDR (LE 16-bit), sha_len = len(msg) (LE 16-bit). + write_bytes(transport, addresses["sha_src"], + bytes([MSG_BUF_ADDR & 0xFF, (MSG_BUF_ADDR >> 8) & 0xFF])) + write_bytes(transport, addresses["sha_len"], + bytes([len(msg) & 0xFF, (len(msg) >> 8) & 0xFF])) + + +# ----------------------------------------------------------------------------- +# Test loop +# ----------------------------------------------------------------------------- + +def _run_one_vector(target, vec: dict, addresses: dict[str, int], *, + splice_addr: int, verify_addr: int, + timeout_s: float, verbose: bool = False, + sha_only: bool = False) -> dict: + """Run a single vector through the dual-overlay flow. + + Each step is an isolated run_subroutine() call so we can apply + tight per-step timeouts and surface failures at the granularity of + the failed step (rather than discovering "stub never returned" 600s + later with no breadcrumbs). + + Steps: + 1) DMA r/s/Qx/Qy + message + sha_src/sha_len. + 2) jsr crypto_swap_to_p384_sha384. + 3) jsr sha384_init. + 4) jsr sha384_update (consumes sha_src/sha_len). + 5) jsr sha384_final (writes 48 B to sha384_digest). + 6) jsr splice_stub (memcpy sha384_digest -> ecdsa_inputs_384+96). + 7) Cross-check the spliced digest against host hashlib. + 8) jsr crypto_swap_to_p384_curve. + 9) jsr verify_stub (loads A/X with struct ptr, jsr verify, captures C). + 10) Read RESULT_CARRY and return. + """ + from c64_test_harness import read_bytes + from c64_test_harness.execute import run_subroutine + + transport = target.transport + + expected_digest = hashlib.sha384(vec["msg"]).digest() + + # Step 1: stage all input buffers via DMA. + _stage_vector_buffers(transport, vec, addresses) + + # Helper: invoke an address with a per-step budget; record the step + # that failed in the returned dict. + def _step(label: str, addr: int, *, step_timeout: float) -> dict | None: + t0 = time.perf_counter() + try: + run_subroutine(target, addr, timeout=step_timeout, + trampoline_addr=0x0334) + except TimeoutError as exc: + return { + "error": f"TIMEOUT in step '{label}' after " + f"{step_timeout:.0f}s: {exc}", + "valid": None, + "seconds": time.perf_counter() - t0, + "failed_step": label, + } + return None + + # Steps 2-5: SHA-384 dispatch. + t_overall = time.perf_counter() + err = _step("swap_to_sha384", addresses["crypto_swap_to_p384_sha384"], + step_timeout=10.0) + if err: return err + err = _step("sha384_init", addresses["sha384_init"], step_timeout=10.0) + if err: return err + msg_len = len(vec["msg"]) + # SHA-384 update budget: ~5 ms / byte at 1 MHz, sub-frame under VICE + # warp, so 60 s for the largest CAVP message (128 B) is overkill. + err = _step("sha384_update", addresses["sha384_update"], + step_timeout=60.0) + if err: return err + err = _step("sha384_final", addresses["sha384_final"], step_timeout=15.0) + if err: return err + + # Step 6: splice digest. Tight loop, < 200 cy, sub-frame even at 1 MHz. + err = _step("splice_digest", splice_addr, step_timeout=5.0) + if err: return err + + # Step 7: cross-check the spliced digest against host hashlib BEFORE + # the curve overlay clobbers $C000-$C5A0 (which subsumes + # sha384_digest at $C3E1). ecdsa_inputs_384+96 is at $C931, past + # the curve overlay's first-scratch span at $C5A0, so reading it + # post-swap would also work — but reading here gives us a clean + # error message if the SHA path is the one that broke. + on_device_digest = bytes(read_bytes(transport, + addresses["ecdsa_inputs_384"] + 96, 48)) + if on_device_digest != expected_digest: + return { + "error": f"sha384 digest mismatch (spliced): " + f"device={on_device_digest.hex()} " + f"expected={expected_digest.hex()}", + "valid": None, + "seconds": time.perf_counter() - t_overall, + "failed_step": "sha_digest_check", + } + + # Step 8: swap to curve overlay. + err = _step("swap_to_curve", addresses["crypto_swap_to_p384_curve"], + step_timeout=10.0) + if err: return err + + if sha_only: + # Diagnostic short-circuit: confirm dual-overlay flow + SHA + splice + # all work without paying for the (slow) ecdsa_verify_384 call. + # We've already verified the digest matched host hashlib above; the + # swap_to_curve completed; treat that as a "structural PASS" and + # synthesise a result dict. + if verbose: + print(f" [--sha-only] dual-overlay structural PASS " + f"(skipped ecdsa_verify_384)") + return { + "valid": vec["expected_valid"], # synthesised: assume PASS + "carry": 0xFE, # marker for "skipped" + "seconds": 0.0, + "overall_seconds": time.perf_counter() - t_overall, + "sha_only": True, + } + + # Step 9: verify (the slow one — bench wall-clock ~75-90 s on real + # 1 MHz, sub-second to a few seconds under VICE warp on a fast host + # but can be tens of seconds on a busy machine). + t_verify = time.perf_counter() + err = _step("ecdsa_verify_384", verify_addr, step_timeout=timeout_s) + if err: return err + verify_seconds = time.perf_counter() - t_verify + + # Step 10: read result. + carry = read_bytes(transport, RESULT_CARRY, 1)[0] + valid = (carry == 0) + overall = time.perf_counter() - t_overall + if verbose: + print(f" digest OK, carry=${carry:02X}, " + f"verify_dt={verify_seconds:.3f}s, " + f"overall_dt={overall:.3f}s") + return { + "valid": valid, + "carry": carry, + "seconds": verify_seconds, + "overall_seconds": overall, + } + + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- + +def _build_prg() -> None: + if os.environ.get("C64_SKIP_BUILD") == "1": + print(" C64_SKIP_BUILD=1 — reusing existing build artifacts") + return + # Two-pass build dance. The overlay-bin link + # (tools/integration/build_nistcurves_p384_bin.sh) looks up the + # main PRG's `mul_cached_a` / `mul_dma_lo` / `mul_dma_hi` / + # `reu_fetch_mul_row` symbols in build/labels.txt to resolve them + # at the SAME runtime addresses the main PRG uses. On a clean + # build, labels.txt does not exist when the overlay-bin step runs, + # and those symbols silently fall back to $0000 — the curve + # overlay's fp_mul_384 then reads/writes $0000 instead of + # $BA00/$BB00 (mul_dma_lo/hi) and the verify hangs in field + # arithmetic with no obvious symptom. The Makefile's overlay-bin + # target only depends on the cfg + archives + script, NOT on + # labels.txt, so a single `make` after `make clean` produces a + # broken overlay. We work around it with a two-pass build: first + # `make` produces labels.txt; force-touching the overlay-bin + # script makes the second `make` re-run the overlay-bin link with + # the now-resolved addresses; then ld65 re-links the main PRG + # with the corrected .bin embedded. Future fix: add labels.txt + # as an order-only dep on the overlay-bin target in the Makefile. + print(" Building (BACKEND=uci, two-pass for overlay-bin resolution)...") + print(" [pass 1] make clean + make...") + subprocess.run(["make", "clean", "BACKEND=uci"], + capture_output=True, cwd=str(PROJECT_ROOT)) + r1 = subprocess.run(["make", "BACKEND=uci"], + capture_output=True, text=True, + cwd=str(PROJECT_ROOT)) + if r1.returncode != 0: + print(f"Build pass 1 failed:\n{r1.stderr}") + sys.exit(1) + + # Touch the overlay-bin script so make re-runs it now that + # labels.txt exists. The script-touch beats the .bin's mtime, so + # make rebuilds the .bin, which in turn forces a re-link of the + # main PRG that .incbins it. + script = PROJECT_ROOT / "tools" / "integration" / "build_nistcurves_p384_bin.sh" + script.touch() + + print(" [pass 2] re-link overlay + main PRG with resolved addresses...") + r2 = subprocess.run(["make", "BACKEND=uci"], + capture_output=True, text=True, + cwd=str(PROJECT_ROOT)) + if r2.returncode != 0: + print(f"Build pass 2 failed:\n{r2.stderr}") + sys.exit(1) + + # Sanity-check the overlay links resolved the imports rather than + # leaving them stubbed at $0000. This is the canary that catches + # the build-order bug above if it ever resurfaces (e.g. someone + # changes the cfg in a way that re-shuffles the linker's + # dependency graph). + curve_labels = _load_labels(LABELS_CURVE_PATH) + for sym in ("mul_dma_lo", "mul_dma_hi", "mul_cached_a", + "reu_fetch_mul_row"): + addr = curve_labels.get(sym) + if addr is None or addr == 0: + print(f"FATAL: curve overlay's {sym} resolved to " + f"${addr:04X} after two-pass build (expected non-zero " + f"main-PRG address); fp_mul_384 will hang. " + f"Re-run after `make clean BACKEND=uci && make BACKEND=uci`.") + sys.exit(1) + + +def _run_backend(*, backend: str, vectors: list[dict], + splice_stub: bytes, verify_stub: bytes, + addresses: dict[str, int], + timeout_s: float, verbose: bool, + sha_only: bool = False) -> tuple[int, int, list[dict]]: + """Acquire a target, install the stubs, run all vectors, return + (passed, failed, details).""" + from c64_test_harness import ( + UnifiedManager, ViceConfig, write_bytes, read_bytes, wait_for_text, + ) + from c64_test_harness.keyboard import send_text + + if backend == "vice": + config = ViceConfig( + prg_path=str(PRG_PATH), warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"], + ) + mgr = UnifiedManager(backend="vice", vice_config=config) + else: + mgr = UnifiedManager(backend="u64", lock_timeout=120.0) + + passed = failed = 0 + details: list[dict] = [] + + # Layout the two stubs back-to-back inside our scratch range. The + # splice stub is ~12 B, the verify stub is ~16 B — fit comfortably + # in the 256 B page at $CA00. + splice_addr = STUB_ADDR + verify_addr = STUB_ADDR + ((len(splice_stub) + 15) & ~15) # 16-B aligned + + target = mgr.acquire() + try: + transport = target.transport + if backend == "vice": + print(f" VICE PID={target.pid}, transport ready") + else: + print(f" U64 transport ready") + + # Wait for menu — confirms boot sequence (incl. reu_p384_overlay_init) + # has finished and main_loop is polling. + if backend == "vice": + grid = wait_for_text(transport, "Q=QUIT", timeout=180.0, + verbose=False) + if grid is None: + raise RuntimeError("VICE: menu banner never appeared") + else: + # On U64 the device is already running the PRG (run_prg). + # Give boot ~30 s to populate REU banks + run do_net_init. + time.sleep(30.0) + + # Install the two stubs at $CA00 / $CA10 (in OVERLAY_FILE_PAD + # tail past the curve overlay's resident DATA at $C9F7). + write_bytes(transport, splice_addr, splice_stub) + write_bytes(transport, verify_addr, verify_stub) + print(f" splice stub at ${splice_addr:04X} ({len(splice_stub)} B)") + print(f" verify stub at ${verify_addr:04X} ({len(verify_stub)} B)") + + # On U64, exit main_loop to BASIC so SYS-injection works for the + # run_subroutine trampoline. VICE's binary-monitor jsr() does + # not need this. + if backend == "u64": + send_text(transport, "q\r") + time.sleep(2.0) + + for vec in vectors: + print(f" [{backend}] {vec['name']}: running" + f" (msg={len(vec['msg'])} B, expect=" + f"{'VALID' if vec['expected_valid'] else 'INVALID'})...", + flush=True) + result = _run_one_vector( + target, vec, addresses, + splice_addr=splice_addr, verify_addr=verify_addr, + timeout_s=timeout_s, verbose=verbose, + sha_only=sha_only, + ) + result["name"] = vec["name"] + result["expected_valid"] = vec["expected_valid"] + result["backend"] = backend + details.append(result) + if "error" in result: + print(f" FAIL [{backend}] {vec['name']}: {result['error']}") + failed += 1 + continue + ok = (result["valid"] == vec["expected_valid"]) + tag = "PASS" if ok else "FAIL" + overall = result.get("overall_seconds", result["seconds"]) + print(f" {tag} [{backend}] {vec['name']}: " + f"valid={result['valid']} (expected {vec['expected_valid']}) " + f"verify={result['seconds']:.3f}s " + f"overall={overall:.3f}s " + f"carry=${result['carry']:02X}") + if ok: + passed += 1 + else: + failed += 1 + finally: + mgr.release(target) + mgr.shutdown() + + return passed, failed, details + + +def main() -> int: + # Force line-buffered stdout/stderr so live progress shows up under + # piped invocations (otherwise Python block-buffers when not connected + # to a terminal and the user only sees output at process exit). + try: + sys.stdout.reconfigure(line_buffering=True) # type: ignore[attr-defined] + sys.stderr.reconfigure(line_buffering=True) # type: ignore[attr-defined] + except AttributeError: + pass # Python < 3.7 + + args = sys.argv[1:] + run_u64 = "--u64" in args + verbose = "--verbose" in args + full = "--full" in args + sha_only = "--sha-only" in args # diagnostic: run swap+sha+splice but + # skip the slow ecdsa_verify_384 step + + print(f"=== test_ecdsa_p384_kat.py (P-384 dual-overlay KAT) ===") + os.chdir(str(PROJECT_ROOT)) + + # Check the upstream test vector file exists. + if not NIST_VECTORS_PATH.exists(): + print(f"FATAL: vector file not found: {NIST_VECTORS_PATH}") + return 1 + + _build_prg() + + for path in (PRG_PATH, LABELS_PATH, LABELS_SHA384_PATH, LABELS_CURVE_PATH): + if not path.exists(): + print(f"FATAL: required artifact missing: {path}") + return 1 + + addresses = _resolve_addresses() + print(f" Addresses verified against on-disk labels:") + for name in sorted(addresses): + print(f" {name:32s} = ${addresses[name]:04X}") + + vectors = _build_vector_list(full=full) + print(f" Loaded {len(vectors)} vectors (full={full}):") + for v in vectors: + print(f" - {v['name']:30s} expect={v['expected_valid']!s} " + f"msg={len(v['msg'])} B") + + splice_stub = _build_splice_stub(addresses) + verify_stub = _build_verify_stub(addresses) + print(f" splice stub: {len(splice_stub)} B") + print(f" verify stub: {len(verify_stub)} B") + + # Per-VERIFY-step timeout. VICE warp wall-clock for one P-384 + # verify is ~10-300 s on a fast Mac but can be 10-30 min on a + # slower host, since VICE single-threads through 6502 emulation + + # 600+ M cycles of REU DMA setup. Default 1800 s gives generous + # headroom; override via env if you need a tight budget. + vice_timeout = float(os.environ.get("P384_KAT_VICE_TIMEOUT_S", "1800.0")) + u64_timeout = float(os.environ.get("P384_KAT_U64_TIMEOUT_S", "600.0")) + + print(f"\n=== VICE backend (warp, -reu) ===") + v_pass, v_fail, v_details = _run_backend( + backend="vice", vectors=vectors, + splice_stub=splice_stub, verify_stub=verify_stub, + addresses=addresses, + timeout_s=vice_timeout, verbose=verbose, + sha_only=sha_only, + ) + + u_pass = u_fail = 0 + u_details: list[dict] = [] + if run_u64: + print(f"\n=== U64 backend (real hardware) ===") + if not os.environ.get("U64_HOST"): + print(" SKIP: --u64 requested but U64_HOST not set in env") + else: + try: + u_pass, u_fail, u_details = _run_backend( + backend="u64", vectors=vectors, + splice_stub=splice_stub, verify_stub=verify_stub, + addresses=addresses, + timeout_s=u64_timeout, verbose=verbose, + sha_only=sha_only, + ) + except Exception as exc: + print(f" U64 backend FAILED: {exc!r}") + u_fail = len(vectors) + else: + print(f"\n=== U64 backend SKIPPED (pass --u64 to enable) ===") + + print(f"\n{'=' * 60}") + print(f"VICE: {v_pass}/{v_pass + v_fail} passed") + if run_u64: + print(f"U64: {u_pass}/{u_pass + u_fail} passed") + print(f"{'=' * 60}") + + total_fail = v_fail + u_fail + overall = "PASS" if total_fail == 0 else "FAIL" + print(f"OVERALL: {overall}") + return 0 if total_fail == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_p384_symbols.py b/tools/test_p384_symbols.py index 6a3c41d..99e5862 100755 --- a/tools/test_p384_symbols.py +++ b/tools/test_p384_symbols.py @@ -1,62 +1,65 @@ #!/usr/bin/env python3 -"""test_p384_symbols.py -- P-384 primitive smoke test (c64-nist-curves sibling). - -Phase C.3b design: P-384 is smoke-test-only — the production PRG does NOT -link the P-384 archive (the Makefile USE_NISTCURVES_P384 gate is commented -out intentionally). Instead we ship the P-384 overlay as a separate -`build/lib/overlay-p384.bin` (8 KB raw image) + `build/labels-p384.txt` -(addresses of the primitives + DATA buffers), and THIS script loads them -into REU at harness time: - - 1. Build main PRG (BACKEND=uci) -- same size as without P-384. - 2. Build overlay-p384.bin + labels-p384.txt via - `bash tools/integration/build_nistcurves_p384_bin.sh`. - 3. Boot VICE and wait for the main menu. - 4. Stage the 8 KB image into C64 RAM at $2000 (clobbers the UCI adapter, - which is fine — no networking used in this test). - 5. DMA-copy $2000..$3FFF into REU bank 2 offset $4100 (REU_OVERLAY_P384) - via a tiny injected trampoline at $0340. - 6. Call `crypto_swap_to_p384` — REU→$4200 DMA inside the PRG. The live - overlay slot now holds P-384 code. - 7. Exercise ec_point_double_384 / ec_point_add_384 / - ec_jacobian_to_affine_384 against NIST P-384 generator vectors, - comparing affine outputs to a Python reference. - -Endian: c64-nist-curves stores field elements LITTLE-ENDIAN (byte 0 = LSB, -48 bytes per coordinate). Python `cryptography` gives integers; we -convert in-script with `int_to_le48`. - -P-384 DATA buffers (ec384_p1, fp384_wide, ec384_affine_x, ...) live at -$C000+ in this standalone link (inside TCP_BUF). TCP_BUF is unused at -test time (no networking), so we can safely use it as P-384 scratch. +"""test_p384_symbols.py -- Phase 3 dual-overlay swap dispatcher smoke test. + +Phase 3 (this rewrite) replaced the legacy single-image overlay flow +(crypto_swap_to_p384, overlay-p384.bin, harness-side staging) with a +build-time embedded dual-overlay flow: + + - The build produces TWO .bin images: + build/lib/overlay-p384-sha384.bin (REU bank 6, $60000) + build/lib/overlay-p384-curve.bin (REU bank 7, $70000) + - `make BACKEND=uci` `.incbin`s both blobs into the PRG + (src/crypto/shared/p384_overlay_blobs.s) and grows the PRG from + 47105 B -> 62977 B (+15872 B, the two 7,680 B blobs plus the 8 KB + of zeros ld65 emits across the $C000-$DFFF gap so the under-KERNAL + OVERLAY_BLOB_CURVE_RAM region at $E000 lands at the right RAM + address after KERNAL LOAD). + - At boot, src/boot.s calls reu_p384_overlay_init which STASHes the + embedded blobs from $4200 (sha384) and $E000 (curve) into REU + banks 6 and 7 in two ~8 ms DMA windows. + - The TLS path (Phase 4a will implement) calls + crypto_swap_to_p384_sha384 to hash the transcript, then + crypto_swap_to_p384_curve to verify the ECDSA-P384 signature. + Each swap is a single REU->C64 DMA into the live CRYPTO_OVERLAY + slot at $4200; idempotent if `current_overlay` already matches. + +This script smoke-tests the dispatcher in isolation: + + 1. Verify both .bin files exist (rebuild if missing) and report sizes. + 2. Verify build/labels.txt exposes all four swap entry points and + the current_overlay state byte. + 3. Boot the PRG in VICE -reu and wait for the menu banner. + 4. Read current_overlay -- expected OV_NONE (= 0) right after boot. + 5. JSR crypto_swap_to_p384_sha384. Confirm current_overlay == 4 + (OV_P384_SHA384) and the first 16 B at $4200 match the sha384 .bin. + 6. JSR crypto_swap_to_p384_curve. Confirm current_overlay == 5 + (OV_P384_CURVE) and the first 16 B at $4200 match the curve .bin. + 7. JSR crypto_swap_to_p384_sha384 again. Confirm idempotent + + direction-reversal: current_overlay == 4 and $4200 reverts to + the sha384 image. + 8. JSR crypto_swap_to_x25519_sibling. Confirm current_overlay == 1 + (OV_X25519_SIBLING). This entry is a state-only marker today + (Phase 3 deferred actual REU restoration of X25519 rodata to a + follow-up phase), so the live slot bytes do NOT change -- we + only assert the state byte updates. + 9. JSR crypto_swap_none. Confirm current_overlay == 0. + +Exits 0 on PASS, 1 on FAIL or environmental error. + +VICE harness gotcha: the PRG's boot path executes nistcurves P-256 +fp_mul, which fetches 8x8 multiply rows from REU banks 0/1. Without +`-reu`, those banks don't exist and the boot crashes. We launch +VICE with `extra_args=["-reu", "-reusize", "512"]` per the documented +project gotcha (CLAUDE.md "VICE harness gotcha", and the +vice_reu_required_for_p256 user memory note). Usage: - BACKEND=uci python3 tools/test_p384_symbols.py [--verbose] - -Under BACKEND=ip65 (or any other backend where nistcurves-p384.a is not -built), the script exits 0 with a skip message — the overlay image is -built only under UCI via the integration script. - -Known issue (Phase C.3b investigation): - fp_mul_384 works correctly after the harness's REU-reg restore step - (2*3=6 smoke-verified), but fp_sqr_384 hangs when invoked on any - nonzero input in this standalone link configuration. Consequently - ec_point_double_384 (which calls ec_sqrp_384 -> fp_mod_sqr_384 -> - fp_sqr_384) times out on Test 1. The root cause has not been - identified yet; most likely candidates: - - Subtle interaction between the PRG's x25519 sibling leaving - REU registers in a state fp_sqr_384 doesn't re-program (fp_sqr's - inline DMA writes only $DF05/$DF06/$DF01, relying on other REU - regs being pre-set to the mul-row FETCH config). - - A local BSS symbol in fp384_raw.s (fp384_sqr_pairs, mul_src2_buf_384) - resolving to an address that collides with something else in the - standalone-link RESIDENT placement at $C000-$CFFF. This has been - checked against the linker map and addresses look clean, but some - interaction with TCP_BUF scratch used for overlay staging hasn't - been fully ruled out. - The test infrastructure (overlay upload, crypto_swap_to_p384, REU-reg - restore, ZP/fp_src wiring, output readback) is verified working - end-to-end by the fp_mul_384 path. + BACKEND=uci /Users/someone/.local/share/c64-test-harness/venv/bin/python3 \ + tools/test_p384_symbols.py [--verbose] + +Under BACKEND=ip65 the script exits 0 with a skip message -- the +embedded-blobs path is UCI-only (ip65 has no main-RAM headroom for the +extra 15 KB; see cfg/c64-https-ip65.cfg's Phase 3 comment block). """ import os @@ -66,205 +69,44 @@ PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") -P384_LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels-p384.txt") -P384_IMAGE_PATH = os.path.join(PROJECT_ROOT, "build", "lib", "overlay-p384.bin") +SHA_BIN_PATH = os.path.join(PROJECT_ROOT, "build", "lib", "overlay-p384-sha384.bin") +CURVE_BIN_PATH = os.path.join(PROJECT_ROOT, "build", "lib", "overlay-p384-curve.bin") VERBOSE = False -# P-384 curve parameters (NIST FIPS 186-4). -P_384 = 2**384 - 2**128 - 2**96 + 2**32 - 1 -A_384 = -3 % P_384 -B_384 = int( - "b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875a" - "c656398d8a2ed19d2a85c8edd3ec2aef", - 16, -) -GX_384 = int( - "aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a38" - "5502f25dbf55296c3a545e3872760ab7", - 16, -) -GY_384 = int( - "3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c0" - "0a60b1ce1d7e819d7a431d7c90ea0e5f", - 16, -) - -# REU bank/offset used by the overlay store. Kept in sync with -# src/crypto/shared/reu_layout.inc. -REU_OVERLAY_P384 = 0x24100 # 24-bit REU address = bank 2, offset $4100. - -# Harness staging area. The 8 KB image is uploaded to REU in two 4 KB -# halves so we can stage each half in TCP_BUF ($C000-$CFFF, free because -# networking is off). We can't stage at $2000 even though it's -# big enough — the UCI cfg puts LOADER_OVERFLOW (containing -# crypto_swap_to_p384 itself!) in NET_CODE at $2000-$3FFF, and clobbering -# that would crash the next jsr(crypto_swap_to_p384). -C64_STAGE_ADDR = 0xC000 -C64_STAGE_SIZE = 0x1000 # 4 KB per chunk. -OVERLAY_SIZE = 0x2000 # 8 KB. - -# Address we inject the DMA trampoline at. Inside the cassette buffer, -# safely past the jsr() scratch at $0334-$0338. The trampoline is 55 B -# so it occupies $0340-$0377 (ASCII). -DMA_TRAMPOLINE_ADDR = 0x0340 - - -# ----------------------------------------------------------------------------- -# Byte-order helpers. -# ----------------------------------------------------------------------------- - -def int_to_le48(v: int) -> bytes: - """Convert an integer to 48-byte little-endian representation.""" - return (v % P_384).to_bytes(48, "little") - +# ID constants kept in sync with src/crypto/shared/crypto_swap.s. +OV_NONE = 0 +OV_X25519_SIBLING = 1 +OV_P384_SHA384 = 4 +OV_P384_CURVE = 5 -def le48_to_int(b: bytes) -> int: - """Convert 48-byte little-endian bytes to integer.""" - return int.from_bytes(b, "little") +# Live overlay slot start under UCI (cfg's CRYPTO_OVERLAY = $4200). +CRYPTO_OVERLAY_START = 0x4200 +OVERLAY_BLOB_BYTES = 0x1E00 # 7,680 B per blob # ----------------------------------------------------------------------------- -# Python reference implementations (affine + Jacobian point arithmetic over -# P-384). +# Label loader (VICE format: "al C:XXXX .name"). # ----------------------------------------------------------------------------- -def fe_add(a: int, b: int) -> int: - return (a + b) % P_384 - -def fe_sub(a: int, b: int) -> int: - return (a - b) % P_384 - -def fe_mul(a: int, b: int) -> int: - return (a * b) % P_384 - -def fe_inv(a: int) -> int: - return pow(a, P_384 - 2, P_384) - - -def point_double_affine(px: int, py: int) -> tuple[int, int]: - """Double an affine point on y^2 = x^3 - 3x + b over F_P384.""" - lam = fe_mul(3 * fe_sub(fe_mul(px, px), 1), fe_inv(2 * py % P_384)) - rx = fe_sub(fe_mul(lam, lam), 2 * px % P_384) - ry = fe_sub(fe_mul(lam, fe_sub(px, rx)), py) - return rx % P_384, ry % P_384 - - -def point_add_affine(px: int, py: int, qx: int, qy: int) -> tuple[int, int]: - """Affine addition of two distinct points on P-384.""" - if (px, py) == (qx, qy): - return point_double_affine(px, py) - lam = fe_mul(fe_sub(qy, py), fe_inv(fe_sub(qx, px))) - rx = fe_sub(fe_sub(fe_mul(lam, lam), px), qx) - ry = fe_sub(fe_mul(lam, fe_sub(px, rx)), py) - return rx % P_384, ry % P_384 - - -def scalar_mul_affine(k: int, px: int, py: int) -> tuple[int, int]: - """Double-and-add scalar mult: k*(px,py) on P-384.""" - rx, ry = None, None - cx, cy = px, py - for bit in range(k.bit_length()): - if (k >> bit) & 1: - if rx is None: - rx, ry = cx, cy - else: - rx, ry = point_add_affine(rx, ry, cx, cy) - cx, cy = point_double_affine(cx, cy) - return rx, ry - - -# ----------------------------------------------------------------------------- -# Label loader that merges build/labels.txt + build/labels-p384.txt. -# ----------------------------------------------------------------------------- - -def load_merged_labels(): - """Return a dict mapping label name -> int address. - - Parses the main PRG labels file plus the P-384 overlay labels file. - Later wins on conflicts (not expected — P-384 symbols only appear - in the overlay labels file). - """ +def load_labels(path: str) -> dict: + """Return a dict mapping label name -> int address.""" result: dict[str, int] = {} - for path in (LABELS_PATH, P384_LABELS_PATH): - if not os.path.exists(path): - continue - with open(path, "r", encoding="utf-8") as fh: - for line in fh: - parts = line.split() - # Format: al C:XXXX .name - if len(parts) < 3 or parts[0] != "al": - continue - addr_field = parts[1] - if addr_field.startswith("C:"): - addr = int(addr_field[2:], 16) - else: - addr = int(addr_field, 16) - name = parts[2].lstrip(".") - result[name] = addr + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + parts = line.split() + if len(parts) < 3 or parts[0] != "al": + continue + addr_field = parts[1] + if addr_field.startswith("C:"): + addr = int(addr_field[2:], 16) + else: + addr = int(addr_field, 16) + name = parts[2].lstrip(".") + result[name] = addr return result -# ----------------------------------------------------------------------------- -# DMA trampoline / REU helpers. -# ----------------------------------------------------------------------------- - -# DMA-trampoline approach: the harness writes 7 parameter bytes into a -# staging area in C64 RAM (at DMA_PARAMS_ADDR), then calls the trampoline -# which loads them into REU registers $DF02-$DF08, sets $DF0A=0, and fires -# a $90 (C64->REU) to $DF01. This avoids relying on monitor-side -# memory_write() reaching the REU I/O registers (which would stomp on -# REU's internal state machine and may or may not actually store). -# -# Staging layout at DMA_PARAMS_ADDR (7 bytes): -# +0 c64_src_lo -# +1 c64_src_hi -# +2 reu_dst_lo -# +3 reu_dst_hi -# +4 reu_dst_bank -# +5 length_lo -# +6 length_hi -# MUST be past the 55-byte trampoline at $0340 (ends at $0377). -DMA_PARAMS_ADDR = 0x0380 - -# Assembled 6502 — loads 7 params from DMA_PARAMS_ADDR ($0380) into -# $DF02-$DF08, writes $00 to $DF0A, then $90 to $DF01, then RTS. -# 55 bytes total, fits at $0340-$0376 without colliding with the -# DMA_PARAMS_ADDR staging block at $0380+. -DMA_TRAMPOLINE_C64_TO_REU = bytes([ - 0x78, # SEI - 0xAD, 0x80, 0x03, 0x8D, 0x02, 0xDF, # $DF02 = [$0380] - 0xAD, 0x81, 0x03, 0x8D, 0x03, 0xDF, # $DF03 = [$0381] - 0xAD, 0x82, 0x03, 0x8D, 0x04, 0xDF, # $DF04 = [$0382] - 0xAD, 0x83, 0x03, 0x8D, 0x05, 0xDF, # $DF05 = [$0383] - 0xAD, 0x84, 0x03, 0x8D, 0x06, 0xDF, # $DF06 = [$0384] - 0xAD, 0x85, 0x03, 0x8D, 0x07, 0xDF, # $DF07 = [$0385] - 0xAD, 0x86, 0x03, 0x8D, 0x08, 0xDF, # $DF08 = [$0386] - 0xA9, 0x00, 0x8D, 0x0A, 0xDF, # $DF0A = 0 - 0xA9, 0x90, 0x8D, 0x01, 0xDF, # $DF01 = $90 (C64->REU) - 0x58, 0x60, # CLI; RTS -]) - - -def program_and_dma_c64_to_reu(transport, write_bytes_fn, jsr_fn, - c64_src: int, reu_dst: int, length: int): - """Stage DMA params in RAM and fire the trampoline. - - *length* must fit in 16 bits ($DF07/$DF08). The trampoline covers - the $DF0A address control (both autoincrement) and the $DF01 command - byte ($90 = immediate C64->REU). - """ - assert 1 <= length <= 0xFFFF, f"length {length} out of range" - params = bytes([ - c64_src & 0xFF, (c64_src >> 8) & 0xFF, # src lo/hi - reu_dst & 0xFF, (reu_dst >> 8) & 0xFF, # dst lo/hi - (reu_dst >> 16) & 0xFF, # dst bank - length & 0xFF, (length >> 8) & 0xFF, # len lo/hi - ]) - write_bytes_fn(transport, DMA_PARAMS_ADDR, params) - jsr_fn(transport, DMA_TRAMPOLINE_ADDR, timeout=5.0) - - # ----------------------------------------------------------------------------- # Test harness wrapper. # ----------------------------------------------------------------------------- @@ -277,309 +119,224 @@ def main() -> int: if "--verbose" in args: VERBOSE = True - backend = os.environ.get("BACKEND", "ip65") - make_args = [f"BACKEND={backend}"] + backend = os.environ.get("BACKEND", "uci") print(f"=== test_p384_symbols.py (BACKEND={backend}) ===") - # P-384 sibling integration is UCI-only. The ip65 cfg does not build - # the archive and the labels table would not contain the symbols even - # if stale artifacts were on disk. Exit cleanly under ip65. + # Phase 3 dual-overlay embed is UCI-only -- ip65 has no main-RAM + # headroom for the extra 15 KB after the existing layout. Under + # ip65 the OVERLAY_BLOB_* segments are empty and the boot DMA is + # a no-op, so there is nothing meaningful to test. Skip cleanly. if backend != "uci": - print(f" SKIP: P-384 smoke test is UCI-only (backend={backend})") + print(f" SKIP: dual-overlay smoke test is UCI-only (backend={backend})") return 0 if os.environ.get("C64_SKIP_BUILD") != "1": - subprocess.run(["make", "clean"] + make_args, + subprocess.run(["make", "clean", f"BACKEND={backend}"], capture_output=True, cwd=PROJECT_ROOT) - result = subprocess.run(["make"] + make_args, capture_output=True, - text=True, cwd=PROJECT_ROOT) + result = subprocess.run(["make", f"BACKEND={backend}"], + capture_output=True, text=True, + cwd=PROJECT_ROOT) if result.returncode != 0: print(f"Build failed:\n{result.stderr}") return 1 else: - print(" C64_SKIP_BUILD=1 — reusing existing build artifacts") + print(" C64_SKIP_BUILD=1 -- reusing existing build artifacts") + + # Sanity-check on-disk artifacts. + for path in (PRG_PATH, LABELS_PATH, SHA_BIN_PATH, CURVE_BIN_PATH): + if not os.path.exists(path): + print(f"FATAL: required artifact missing: {path}") + return 1 - if not os.path.exists(PRG_PATH): - print(f"FATAL: {PRG_PATH} not found after build") + sha_image = open(SHA_BIN_PATH, "rb").read() + curve_image = open(CURVE_BIN_PATH, "rb").read() + print(f" overlay-p384-sha384.bin: {len(sha_image)} B " + f"(expected {OVERLAY_BLOB_BYTES})") + print(f" overlay-p384-curve.bin: {len(curve_image)} B " + f"(expected {OVERLAY_BLOB_BYTES})") + if len(sha_image) != OVERLAY_BLOB_BYTES or len(curve_image) != OVERLAY_BLOB_BYTES: + print("FATAL: overlay .bin sizes do not match OVERLAY_BLOB_BYTES") return 1 - # The overlay image + labels are only produced under UCI. ip65 does - # not attempt the nistcurves-p384 archive build (sibling archive - # script is gated in the main Makefile under BACKEND=uci). - if not os.path.exists(P384_IMAGE_PATH): - if backend != "uci": - print(f" SKIP: P-384 overlay image not built under BACKEND={backend}") - print(f" (missing: {P384_IMAGE_PATH})") - return 0 - # Try to build the overlay image now under UCI. - print(f" Building P-384 overlay image + labels...") - result = subprocess.run( - ["bash", "tools/integration/build_nistcurves_p384_bin.sh"], - capture_output=True, text=True, cwd=PROJECT_ROOT, - ) - if result.returncode != 0: - print(f"FATAL: P-384 overlay build failed:\n{result.stdout}\n{result.stderr}") - return 1 - if not os.path.exists(P384_LABELS_PATH): - print(f"FATAL: {P384_LABELS_PATH} not found") + prg_size = os.path.getsize(PRG_PATH) + print(f" c64-https.prg: {prg_size} B " + f"(pre-Phase-3 baseline: 47105 B)") + + labels = load_labels(LABELS_PATH) + + required_symbols = [ + "crypto_swap_to_x25519_sibling", + "crypto_swap_to_p384_sha384", + "crypto_swap_to_p384_curve", + "crypto_swap_none", + "current_overlay", + "reu_p384_overlay_init", + ] + missing = [s for s in required_symbols if s not in labels] + if missing: + print(f"FATAL: required symbols missing from build/labels.txt: {missing}") return 1 + print(f" Labels loaded: {len(required_symbols)} swap-dispatcher symbols verified") + if VERBOSE: + for s in required_symbols: + print(f" {s:32s} = ${labels[s]:04X}") try: from c64_test_harness import ( ViceConfig, ViceInstanceManager, - read_bytes, write_bytes, jsr, wait_for_text, + read_bytes, jsr, wait_for_text, ) except ImportError: print("FATAL: c64-test-harness package not installed") return 1 - labels = load_merged_labels() - - required = [ - "ec_point_double_384", - "ec_point_add_384", - "ec_jacobian_to_affine_384", - "ec384_p1", - "ec384_p2", - "ec384_p3", - "ec384_affine_x", - "ec384_affine_y", - "crypto_swap_to_p384", - ] - missing = [n for n in required if n not in labels] - if missing: - if backend != "uci": - print(f" SKIP: P-384 symbols not available under BACKEND={backend}") - print(f" (missing labels: {', '.join(missing)})") - return 0 - print(f"FATAL: P-384 symbols missing from labels: {missing}") - return 1 - - print(f" Labels loaded: {len(required)} P-384 symbols verified") - - # Read the overlay image. - with open(P384_IMAGE_PATH, "rb") as fh: - image = fh.read() - if len(image) != OVERLAY_SIZE: - print(f"FATAL: overlay image size {len(image)} != {OVERLAY_SIZE}") - return 1 - print(f" P-384 overlay image: {len(image)} bytes from {P384_IMAGE_PATH}") - - # Launch VICE with REU Profile B (512 KB) so both overlays fit. + # VICE harness gotcha: the boot path runs nistcurves P-256 fp_mul + # which DMAs 8x8 multiply rows from REU banks 0/1. Without `-reu` + # the banks don't exist and the boot path silently no-ops the + # DMA, leading to wrong-result symptoms (and in our case, also + # leaves the Phase 3 reu_p384_overlay_init STASH a no-op so the + # subsequent crypto_swap_to_p384_* DMAs would deliver zeros). + # ALWAYS pass `-reu` for any test that touches REU under VICE. + extra_args = ["-reu", "-reusize", "512"] config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, - extra_args=["-reu", "-reusize", "512"]) + extra_args=extra_args) + print(f" VICE config: extra_args={extra_args!r}") + print("\n=== Starting VICE ===") passed = failed = 0 + + def check(label: str, condition: bool, fail_detail: str = "") -> None: + nonlocal passed, failed + if condition: + print(f" PASS {label}") + passed += 1 + else: + print(f" FAIL {label}") + if fail_detail: + print(f" {fail_detail}") + failed += 1 + with ViceInstanceManager(config=config) as mgr: inst = mgr.acquire() transport = inst.transport print(f"VICE PID={inst.pid}, port={inst.port}") - grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + grid = wait_for_text(transport, "Q=QUIT", timeout=120.0, verbose=False) if grid is None: - print("FATAL: Program menu did not appear") - return 1 - - # Safety: CPU-idle trampoline at $0339 (unused by jsr / dma scratch). - write_bytes(transport, 0x0339, bytes([0x4C, 0x39, 0x03])) - - # Inject the DMA trampoline. - write_bytes(transport, DMA_TRAMPOLINE_ADDR, DMA_TRAMPOLINE_C64_TO_REU) - - # Stage the 8 KB image into REU in two 4 KB halves via TCP_BUF. - # TCP_BUF ($C000-$CFFF) is free because networking is off. Doing - # it in halves avoids clobbering LOADER_OVERFLOW in NET_CODE - # ($2000-$3FFF) where crypto_swap_to_p384 lives. - for chunk_i in range(0, OVERLAY_SIZE, C64_STAGE_SIZE): - half = image[chunk_i:chunk_i + C64_STAGE_SIZE] - reu_dst = REU_OVERLAY_P384 + chunk_i - if VERBOSE: - print(f" Staging half +${chunk_i:04X} (len={len(half)}) at " - f"${C64_STAGE_ADDR:04X} -> REU ${reu_dst:06X}") - write_bytes(transport, C64_STAGE_ADDR, half) - program_and_dma_c64_to_reu( - transport, write_bytes, jsr, - C64_STAGE_ADDR, reu_dst, len(half), - ) - print(f" DMA C64 -> REU ${REU_OVERLAY_P384:06X} ({OVERLAY_SIZE} B)") - - # Verify: round-trip the first 16 B back from REU via an inverse DMA. - # Writes REU bank 2 offset $4100 -> C64 $CF00 using a one-shot - # trampoline, then reads $CF00. - pullback = bytes([ - 0x78, - 0xA9, 0x00, 0x8D, 0x02, 0xDF, # c64 lo = $00 - 0xA9, 0xCF, 0x8D, 0x03, 0xDF, # c64 hi = $CF - 0xA9, 0x00, 0x8D, 0x04, 0xDF, # reu lo = $00 - 0xA9, 0x41, 0x8D, 0x05, 0xDF, # reu hi = $41 - 0xA9, 0x02, 0x8D, 0x06, 0xDF, # reu bank = 2 - 0xA9, 0x10, 0x8D, 0x07, 0xDF, # len lo = 16 - 0xA9, 0x00, 0x8D, 0x08, 0xDF, # len hi = 0 - 0xA9, 0x00, 0x8D, 0x0A, 0xDF, # addr_ctrl = 0 - 0xA9, 0x91, 0x8D, 0x01, 0xDF, # cmd = $91 REU->C64 - 0x58, 0x60, - ]) - write_bytes(transport, DMA_TRAMPOLINE_ADDR, pullback) - jsr(transport, DMA_TRAMPOLINE_ADDR, timeout=10.0) - reu_readback = read_bytes(transport, 0xCF00, 16) - if VERBOSE: - print(f" REU+$4100 readback : {reu_readback.hex()}") - print(f" image +$0000 : {image[:16].hex()}") - if bytes(reu_readback) != image[:16]: - print("FATAL: REU did not receive the overlay image cleanly") - print(f" got {bytes(reu_readback).hex()}") - print(f" expected {image[:16].hex()}") + print("FATAL: program menu did not appear within 120 s") mgr.release(inst) return 1 - # Restore the forward-DMA trampoline for subsequent calls if any. - write_bytes(transport, DMA_TRAMPOLINE_ADDR, DMA_TRAMPOLINE_C64_TO_REU) - - # Swap P-384 overlay into the live CRYPTO_OVERLAY slot. - if "current_overlay" in labels: - pre = read_bytes(transport, labels["current_overlay"], 1) - if VERBOSE: - print(f" current_overlay before swap = 0x{pre[0]:02x}") - print(" Swapping CRYPTO_OVERLAY -> P-384 image") - jsr(transport, labels["crypto_swap_to_p384"], timeout=30.0) - if "current_overlay" in labels and VERBOSE: - post = read_bytes(transport, labels["current_overlay"], 1) - print(f" current_overlay after swap = 0x{post[0]:02x}") - - # Restore REU registers to the "mul-row FETCH config" that the - # x25519 sibling's `reu_fetch_mul_row` expects at rest: - # $DF02/$DF03 = mul_dma_lo ($6600) - # $DF04 = 0 (reu_lo; reu_hi patched per call) - # $DF07/$DF08 = 512 (row length) - # $DF0A = 0 (autoincrement both) - # fp_mul_384 / fp_sqr_384 only overwrite $DF05 (reu_hi), $DF06 - # (bank), and $DF01 (command) inside `reu_fetch_mul_row`. - # MUST happen AFTER crypto_swap_to_p384 — that DMA also writes - # $DF02-$DF08 and would clobber our setup if we restored first. - MUL_DMA_LO = 0x6600 - restore = bytes([ - MUL_DMA_LO & 0xFF, (MUL_DMA_LO >> 8) & 0xFF, # $DF02, $DF03 - 0x00, # $DF04 reu_lo - ]) - write_bytes(transport, 0xDF02, restore) - write_bytes(transport, 0xDF07, bytes([0x00, 0x02])) # len = 512 - write_bytes(transport, 0xDF0A, bytes([0x00])) # autoincrement - - # Sanity check: first 16 bytes at $4200 must match the overlay - # image. If they don't, the REU DMA didn't round-trip and every - # subsequent jsr() will hang (the overlay slot still holds - # x25519 code, not P-384). - live = read_bytes(transport, 0x4200, 16) + ov_addr = labels["current_overlay"] + sha_entry = labels["crypto_swap_to_p384_sha384"] + curve_entry = labels["crypto_swap_to_p384_curve"] + x25519_entry = labels["crypto_swap_to_x25519_sibling"] + none_entry = labels["crypto_swap_none"] + + # By the time the menu has rendered, src/boot.s has already run + # reu_p384_overlay_init -- so REU banks 6 and 7 should hold the + # two overlay images. Note that $4200 at this point holds the + # CURVE blob, not the SHA blob: boot's stash 2 CPU-copies the + # curve bytes from $E000-$FDFF into $4200 (the now-free SHA + # staging slot) before issuing the STASH-to-bank-7 DMA, so the + # final state of $4200 is the curve image. The first + # crypto_swap_to_p384_sha384 call below will DMA the SHA bytes + # back from REU bank 6, restoring the slot to SHA content. + # The harness can't easily read RAM under KERNAL ROM via the + # binary monitor (bank=0 = CPU-banked and $01 is $36 with + # KERNAL on), so we skip $E000 verification here -- the + # round-trip via crypto_swap_to_p384_curve below is the actual + # correctness check on REU bank 7's contents. if VERBOSE: - print(f" live @ $4200: {live.hex()}") - print(f" image @ +$00: {image[:16].hex()}") - if bytes(live) != image[:16]: - print(f"FATAL: overlay DMA mismatch at $4200") - print(f" got {bytes(live).hex()}") - print(f" expected {image[:16].hex()}") - mgr.release(inst) - return 1 - - # Zero the P-384 DATA region at $C000-$C636 so uninitialised buffers - # don't carry residue between tests. - write_bytes(transport, 0xC000, bytes(0x640)) - - # --- Test 1: ec_point_double_384(G) -> 2G --- - print("\n--- Test 1: ec_point_double_384(G) ---") - # Load G into ec384_p1 as Jacobian (X=Gx, Y=Gy, Z=1). - write_bytes(transport, labels["ec384_p1"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p1"] + 48, int_to_le48(GY_384)) - write_bytes(transport, labels["ec384_p1"] + 96, int_to_le48(1)) - jsr(transport, labels["ec_point_double_384"], timeout=600.0) - # Output lands in ec384_p3 (Jacobian). Convert to affine via the - # library's own ec_jacobian_to_affine_384 for comparison. - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - got_x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - got_y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - exp_x, exp_y = point_double_affine(GX_384, GY_384) - if got_x == exp_x and got_y == exp_y: - print(" PASS 2G affine matches Python reference") - passed += 1 - else: - failed += 1 - print(" FAIL 2G mismatch") - print(f" exp_x = {exp_x:#098x}") - print(f" got_x = {got_x:#098x}") - print(f" exp_y = {exp_y:#098x}") - print(f" got_y = {got_y:#098x}") - - # --- Test 2: ec_point_add_384(G, 2G) -> 3G --- - # ABI: ec_p1 (Jacobian) + ec_p2 (affine) -> ec_p3 (Jacobian). - print("\n--- Test 2: ec_point_add_384(G, 2G) ---") - write_bytes(transport, labels["ec384_p1"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p1"] + 48, int_to_le48(GY_384)) - write_bytes(transport, labels["ec384_p1"] + 96, int_to_le48(1)) - write_bytes(transport, labels["ec384_p2"], int_to_le48(exp_x)) - write_bytes(transport, labels["ec384_p2"] + 48, int_to_le48(exp_y)) - jsr(transport, labels["ec_point_add_384"], timeout=600.0) - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - got_x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - got_y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - exp_x3, exp_y3 = scalar_mul_affine(3, GX_384, GY_384) - if got_x == exp_x3 and got_y == exp_y3: - print(" PASS 3G affine matches Python reference") - passed += 1 - else: - failed += 1 - print(" FAIL 3G mismatch") - print(f" exp_x = {exp_x3:#098x}") - print(f" got_x = {got_x:#098x}") - - # --- Test 3: iterated double+add to 17G --- - print("\n--- Test 3: iterated doubling -> 16G -> 17G ---") - write_bytes(transport, labels["ec384_p1"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p1"] + 48, int_to_le48(GY_384)) - write_bytes(transport, labels["ec384_p1"] + 96, int_to_le48(1)) - for _ in range(4): - jsr(transport, labels["ec_point_double_384"], timeout=600.0) - p3_bytes = read_bytes(transport, labels["ec384_p3"], 144) - write_bytes(transport, labels["ec384_p1"], p3_bytes) - # Now p1 = 16G (Jacobian). Convert to affine to get 16G coordinates. - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - aff16x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - aff16y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - # Reload p1 = 16G (Jacobian) and p2 = G (affine), add. - write_bytes(transport, labels["ec384_p1"], p3_bytes) - write_bytes(transport, labels["ec384_p2"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p2"] + 48, int_to_le48(GY_384)) - jsr(transport, labels["ec_point_add_384"], timeout=600.0) - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - got_x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - got_y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - exp_x17, exp_y17 = scalar_mul_affine(17, GX_384, GY_384) - if got_x == exp_x17 and got_y == exp_y17: - print(" PASS 17G affine matches Python reference") - passed += 1 - else: - failed += 1 - print(" FAIL 17G mismatch") - print(f" 16G aff = ({aff16x:#098x}, {aff16y:#098x})") - print(f" exp_x = {exp_x17:#098x}") - print(f" got_x = {got_x:#098x}") - print(f" exp_y = {exp_y17:#098x}") - print(f" got_y = {got_y:#098x}") - - # --- Test 4: ec_jacobian_to_affine with non-trivial Z --- - print("\n--- Test 4: ec_jacobian_to_affine_384 (Z != 1) ---") - write_bytes(transport, labels["ec384_p1"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p1"] + 48, int_to_le48(GY_384)) - write_bytes(transport, labels["ec384_p1"] + 96, int_to_le48(1)) - jsr(transport, labels["ec_point_double_384"], timeout=600.0) - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - got_x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - got_y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - if got_x == exp_x and got_y == exp_y: - print(" PASS jacobian_to_affine_384 matches 2G affine") - passed += 1 - else: - failed += 1 - print(" FAIL jacobian_to_affine_384 mismatch") + stage_4200 = bytes(read_bytes(transport, 0x4200, 16)) + print(f" STAGE post-boot $4200: {stage_4200.hex()}") + print(f" STAGE expected curve +$00 (post-cpy): {curve_image[:16].hex()}") + + # --- Test 1: boot leaves current_overlay = OV_NONE --- + # boot.s zero-initialises SHADOW_BSS (which CRYPTO_BSS lives + # under) and reu_p384_overlay_init does NOT touch the state + # byte, so the first read after boot must be 0. + ov = read_bytes(transport, ov_addr, 1)[0] + check("boot leaves current_overlay = OV_NONE", + ov == OV_NONE, + f"got 0x{ov:02X}, expected 0x{OV_NONE:02X}") + + # --- Test 2: jsr crypto_swap_to_p384_sha384 --- + # First swap should DMA from REU bank 6 into $4200 and update + # current_overlay = OV_P384_SHA384. + jsr(transport, sha_entry, timeout=30.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("crypto_swap_to_p384_sha384 sets current_overlay = OV_P384_SHA384", + ov == OV_P384_SHA384, + f"got 0x{ov:02X}, expected 0x{OV_P384_SHA384:02X}") + + live = bytes(read_bytes(transport, CRYPTO_OVERLAY_START, 16)) + if VERBOSE: + print(f" live @ ${CRYPTO_OVERLAY_START:04X}: {live.hex()}") + print(f" sha image +$00: {sha_image[:16].hex()}") + check("$4200 holds the sha384 blob bytes after first swap", + live == sha_image[:16], + f"got {live.hex()}, expected {sha_image[:16].hex()}") + + # --- Test 3: jsr crypto_swap_to_p384_curve --- + # Second swap should DMA from REU bank 7 into $4200 and update + # current_overlay = OV_P384_CURVE. + jsr(transport, curve_entry, timeout=30.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("crypto_swap_to_p384_curve sets current_overlay = OV_P384_CURVE", + ov == OV_P384_CURVE, + f"got 0x{ov:02X}, expected 0x{OV_P384_CURVE:02X}") + + live = bytes(read_bytes(transport, CRYPTO_OVERLAY_START, 16)) + if VERBOSE: + print(f" live @ ${CRYPTO_OVERLAY_START:04X}: {live.hex()}") + print(f" curve image +$00: {curve_image[:16].hex()}") + check("$4200 holds the curve blob bytes after curve swap", + live == curve_image[:16], + f"got {live.hex()}, expected {curve_image[:16].hex()}") + + # --- Test 4: jsr crypto_swap_to_p384_sha384 again (round-trip) --- + # Direction reversal -- previous state was OV_P384_CURVE, so + # this must DMA again (not short-circuit). current_overlay + # back to OV_P384_SHA384 and bytes back to the sha384 image. + jsr(transport, sha_entry, timeout=30.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("round-trip back to sha384 sets current_overlay = OV_P384_SHA384", + ov == OV_P384_SHA384, + f"got 0x{ov:02X}, expected 0x{OV_P384_SHA384:02X}") + live = bytes(read_bytes(transport, CRYPTO_OVERLAY_START, 16)) + check("$4200 reverts to sha384 blob bytes after round-trip", + live == sha_image[:16], + f"got {live.hex()}, expected {sha_image[:16].hex()}") + + # --- Test 5: jsr crypto_swap_to_p384_sha384 idempotent (no-op) --- + # State already OV_P384_SHA384 -- should single-byte cmp + rts. + # Bytes at $4200 must remain the sha384 image (no DMA, but + # would be the same bytes anyway). + jsr(transport, sha_entry, timeout=5.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("idempotent re-swap to sha384 leaves state unchanged", + ov == OV_P384_SHA384, + f"got 0x{ov:02X}, expected 0x{OV_P384_SHA384:02X}") + + # --- Test 6: jsr crypto_swap_to_x25519_sibling (state-only marker) --- + # Phase 3 leaves this as a state-only marker (no DMA -- there + # is no boot-time REU stash for X25519 sibling rodata). So we + # only check the state byte; the live slot bytes at $4200 are + # whatever the previous swap left there. + jsr(transport, x25519_entry, timeout=5.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("crypto_swap_to_x25519_sibling sets current_overlay = OV_X25519_SIBLING", + ov == OV_X25519_SIBLING, + f"got 0x{ov:02X}, expected 0x{OV_X25519_SIBLING:02X}") + + # --- Test 7: jsr crypto_swap_none --- + jsr(transport, none_entry, timeout=5.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("crypto_swap_none sets current_overlay = OV_NONE", + ov == OV_NONE, + f"got 0x{ov:02X}, expected 0x{OV_NONE:02X}") mgr.release(inst) diff --git a/tools/test_tls_p384_negotiation.py b/tools/test_tls_p384_negotiation.py new file mode 100644 index 0000000..bd7e1bb --- /dev/null +++ b/tools/test_tls_p384_negotiation.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +"""test_tls_p384_negotiation.py - Phase 4b negotiation plumbing test. + +Verifies the c64-https TLS layer offers ecdsa_secp384r1_sha384 (0x0503) +alongside ecdsa_secp256r1_sha256 (0x0403) in the ClientHello, and that the +CertificateVerify handler accepts a 0x0503 signature_scheme by routing +through the ecdsa_verify dispatcher with curve_id=1. + +This test does NOT require a successful P-384 verification; the +ecdsa_verify dispatcher's P-384 branch is still a `sec / rts` stub that +Phase 4a fills in. Successful negotiation = the carry-set return came +out of the dispatcher (curve_id was set to 1, cv_sig_scheme was set to +1, the routine entered the short-circuit branch). + +Two sub-tests: + + [1a] ClientHello signature_algorithms extension contains BOTH 0x0403 + and 0x0503. + [1b] tls_handle_cert_verify with a synthesized CertificateVerify + handshake message whose signature_scheme = 0x0503 sets + cv_sig_scheme = 1, ecdsa_curve_id = 1, and returns C=1. + Pre-Phase-4a this came from the `sec / rts` stub in + ecdsa_verify; post-Phase-4a (commit-this-PR) it comes from + ecdsa_verify_384_tls's DER parse rejecting the 48-zero-byte + dummy signature (first byte must be 0x30 SEQUENCE; rejection + still propagates C=1). The negotiation contract under test + (cv_sig_scheme=1, ecdsa_curve_id=1, dispatcher reached) is + unchanged. Phase 5 will replace this synthetic test with a + real-signature test once a SHA-384 transcript path lands. + +Usage: + /Users/someone/.local/share/c64-test-harness/venv/bin/python \\ + tools/test_tls_p384_negotiation.py [--seed S] + +Requires VICE x64sc. -reu is passed to satisfy the sibling P-256 +fp_mul row-fetch invariant (see "VICE harness gotcha" in CLAUDE.md); +it does not exercise REU but the residency requirement applies to any +test that links the sibling library. +""" + +import os +import random +import struct +import subprocess +import sys + +from c64_test_harness import ( + Labels, + ViceConfig, + ViceInstanceManager, + read_bytes, + write_bytes, + jsr, + wait_for_text, +) + +PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") +LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") + +# Carry-flag trampoline (cassette buffer). Mirrors test_x509.py. +CARRY_TRAMPOLINE = 0x033C +CARRY_RESULT_ADDR = 0x0352 +CARRY_FLAG_ADDR = 0x0353 + + +def jsr_with_carry(transport, addr, timeout=120.0, poll_interval=0.5): + """Call subroutine and capture the carry flag via a memory trampoline. + + Returns the value of the C flag after the JSR (0 = clear, 1 = set). + """ + import time + + target_lo = addr & 0xFF + target_hi = (addr >> 8) & 0xFF + + # Trampoline: + # LDA #$00 / STA flag + # JSR target + # ROL A=0 / AND #$01 (capture C into A) + # STA result + # LDA #$FF / STA flag + # RTS + trampoline = bytes([ + 0xA9, 0x00, # LDA #$00 + 0x8D, CARRY_FLAG_ADDR & 0xFF, (CARRY_FLAG_ADDR >> 8) & 0xFF, + 0x20, target_lo, target_hi, # JSR target + 0xA9, 0x00, # LDA #$00 + 0x2A, # ROL A (C -> bit 0) + 0x29, 0x01, # AND #$01 + 0x8D, CARRY_RESULT_ADDR & 0xFF, (CARRY_RESULT_ADDR >> 8) & 0xFF, + 0xA9, 0xFF, # LDA #$FF + 0x8D, CARRY_FLAG_ADDR & 0xFF, (CARRY_FLAG_ADDR >> 8) & 0xFF, + 0x60, # RTS + ]) + write_bytes(transport, CARRY_TRAMPOLINE, trampoline) + write_bytes(transport, CARRY_FLAG_ADDR, bytes([0x00])) + + jsr(transport, CARRY_TRAMPOLINE, timeout=timeout) + + deadline = time.time() + timeout + while time.time() < deadline: + flag = read_bytes(transport, CARRY_FLAG_ADDR, 1)[0] + if flag == 0xFF: + break + time.sleep(poll_interval) + else: + raise TimeoutError(f"jsr_with_carry timed out after {timeout}s") + + return read_bytes(transport, CARRY_RESULT_ADDR, 1)[0] + + +# --------------------------------------------------------------------------- +# Test 1a: ClientHello advertises both 0x0403 and 0x0503 +# --------------------------------------------------------------------------- + +def test_client_hello_sig_algs(transport, labels, rng): + """Verify ClientHello signature_algorithms contains 0x0403 + 0x0503.""" + print("\n [1a] ClientHello signature_algorithms: 0x0403 AND 0x0503") + + required = [ + "tls_build_client_hello", "tls_rec_buf", "tls_rec_len", + "tls_client_random", "tls_ecdhe_pubkey", + ] + missing = [n for n in required if labels.address(n) is None] + if missing: + print(f" SKIP: missing labels {missing}") + return 0, 0 + + build_ch = labels.address("tls_build_client_hello") + hs_buf = labels.address("tls_rec_buf") + hs_len_addr = labels.address("tls_rec_len") + + # Seed the input buffers with deterministic-ish data. + client_random = bytes(rng.getrandbits(8) for _ in range(32)) + pubkey = bytes(rng.getrandbits(8) for _ in range(32)) + write_bytes(transport, labels.address("tls_client_random"), client_random) + write_bytes(transport, labels.address("tls_ecdhe_pubkey"), pubkey) + + try: + jsr(transport, build_ch, timeout=60.0) + except Exception as e: + print(f" FAIL: tls_build_client_hello jsr raised {e}") + return 0, 1 + + msg_len_bytes = read_bytes(transport, hs_len_addr, 2) + msg_len = msg_len_bytes[0] | (msg_len_bytes[1] << 8) + if msg_len == 0: + print(" FAIL: tls_build_client_hello produced 0-length output") + return 0, 1 + + msg = read_bytes(transport, hs_buf, min(msg_len, 320)) + + # Walk to extensions. Layout after the 4-byte handshake header: + # [4-5] legacy_version + # [6-37] client_random + # [38] session_id_len (0) + # [39-40] cipher_suites_len = 0x0002 + # [41-42] cipher_suite = 0x1303 + # [43] compression_methods_len = 0x01 + # [44] compression_method = 0x00 + # [45-46] extensions_len + # [47..] extension list + if len(msg) < 49: + print(f" FAIL: msg too short ({len(msg)} B)") + return 0, 1 + + pos = 47 + ext_total = (msg[45] << 8) | msg[46] + ext_end = min(pos + ext_total, len(msg)) + + sig_algs_payload = None + while pos + 4 <= ext_end: + ext_type = (msg[pos] << 8) | msg[pos + 1] + ext_len = (msg[pos + 2] << 8) | msg[pos + 3] + ext_data = msg[pos + 4:pos + 4 + ext_len] + if ext_type == 0x000D: + sig_algs_payload = ext_data + break + pos += 4 + ext_len + + if sig_algs_payload is None: + print(" FAIL: signature_algorithms (0x000D) extension not found") + return 0, 1 + + if len(sig_algs_payload) < 2: + print(f" FAIL: signature_algorithms payload too short " + f"({len(sig_algs_payload)} B)") + return 0, 1 + + inner_len = (sig_algs_payload[0] << 8) | sig_algs_payload[1] + inner = sig_algs_payload[2:2 + inner_len] + + # Inner is a list of 16-bit big-endian schemes. + schemes = set() + for i in range(0, len(inner), 2): + if i + 2 <= len(inner): + schemes.add((inner[i] << 8) | inner[i + 1]) + + missing = [] + if 0x0403 not in schemes: + missing.append("0x0403 (ecdsa_secp256r1_sha256)") + if 0x0503 not in schemes: + missing.append("0x0503 (ecdsa_secp384r1_sha384)") + + if missing: + scheme_hex = ", ".join(f"0x{s:04x}" for s in sorted(schemes)) + print(f" FAIL: missing scheme(s): {', '.join(missing)}") + print(f" advertised: {scheme_hex}") + return 0, 1 + + scheme_hex = ", ".join(f"0x{s:04x}" for s in sorted(schemes)) + print(f" PASS: schemes advertised = {scheme_hex}") + return 1, 0 + + +# --------------------------------------------------------------------------- +# Test 1b: CertificateVerify handler accepts 0x0503 and dispatches +# --------------------------------------------------------------------------- + +def test_cert_verify_p384_dispatch(transport, labels): + """Verify tls_handle_cert_verify routes 0x0503 through the dispatcher. + + Synthesizes a CertificateVerify handshake message whose + signature_scheme = 0x0503, calls tls_handle_cert_verify, and asserts: + - cv_sig_scheme = 1 + - ecdsa_curve_id = 1 + - C=1 (carry set). Post-Phase-4a this comes from the dispatcher's + DER parse rejecting the 48-byte all-zero dummy signature (the + first byte must be the DER SEQUENCE tag 0x30); pre-Phase-4a it + came from the `sec / rts` stub in ecdsa_verify. Either path + proves cv_sig_scheme=1, ecdsa_curve_id=1, and dispatcher + reachability -- the contract this subtest exercises. + + The signature payload itself is irrelevant for the negotiation + plumbing under test -- a real-signature P-384 verify needs both a + real ECDSA-P384 cert + signature AND a SHA-384 transcript hash + (Phase 5). Phase 4a's dispatcher composes the dual-overlay swap + (sha384 -> curve) + sibling ecdsa_verify_384, but the SHA-384 + transcript source is a 32 B SHA-256 placeholder zero-padded to + 48 B until Phase 5 wires up tls_transcript_384. + """ + print("\n [1b] CertificateVerify dispatch on signature_scheme=0x0503") + + required = [ + "tls_handle_cert_verify", "tls_rec_buf", "cv_sig_scheme", + "ecdsa_curve_id", + ] + missing = [n for n in required if labels.address(n) is None] + if missing: + print(f" SKIP: missing labels {missing}") + return 0, 0 + + handler = labels.address("tls_handle_cert_verify") + rec_buf = labels.address("tls_rec_buf") + cv_scheme_addr = labels.address("cv_sig_scheme") + curve_id_addr = labels.address("ecdsa_curve_id") + + # Build a minimal CertificateVerify handshake message: + # [0] handshake type = 15 (TLS_HS_CERT_VERIFY) + # [1..3] 24-bit length placeholder (handler doesn't validate it) + # [4..5] signature_scheme = 0x0503 + # [6..7] signature length (16-bit BE; high byte must be 0) + # [8..] signature bytes (untouched by the P-384 short-circuit) + sig = bytes(48) # 48 dummy bytes — value irrelevant under the stub + msg = bytearray() + msg.append(0x0F) # handshake type + msg.extend(b"\x00\x00\x00") # 24-bit length placeholder + msg.extend(b"\x05\x03") # signature_scheme + msg.extend(struct.pack(">H", len(sig))) # signature length + msg.extend(sig) + + write_bytes(transport, rec_buf, bytes(msg)) + + # Pre-clear the state we expect the handler to set. + write_bytes(transport, cv_scheme_addr, bytes([0xFF])) + write_bytes(transport, curve_id_addr, bytes([0xFF])) + + try: + carry = jsr_with_carry(transport, handler, timeout=60.0) + except Exception as e: + print(f" FAIL: jsr_with_carry raised {e}") + return 0, 1 + + cv_scheme = read_bytes(transport, cv_scheme_addr, 1)[0] + curve_id = read_bytes(transport, curve_id_addr, 1)[0] + + ok = True + if cv_scheme != 1: + print(f" FAIL: cv_sig_scheme = {cv_scheme:#x}, expected 0x01") + ok = False + if curve_id != 1: + print(f" FAIL: ecdsa_curve_id = {curve_id:#x}, expected 0x01") + ok = False + if carry != 1: + # Phase 4a's dispatcher should also reject a 48-zero-byte sig at + # the DER parse step (first byte must be 0x30 SEQUENCE). Phase 5 + # will replace this with a real-signature test once SHA-384 + # transcript wiring lands. + print(f" FAIL: carry = {carry}, expected 1 " + f"(DER rejection / stub rejection)") + ok = False + + if ok: + print(" PASS: cv_sig_scheme=1, ecdsa_curve_id=1, " + "C=1 (Phase 4a dispatcher reached)") + return 1, 0 + return 0, 1 + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + +def main(): + os.chdir(PROJECT_ROOT) + + # Args + seed = random.randint(0, 2**32 - 1) + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--seed" and i + 1 < len(args): + seed = int(args[i + 1]) + i += 2 + else: + i += 1 + random.seed(seed) + rng = random.Random(seed) + print(f"Random seed: {seed} (reproduce with --seed {seed})") + + if os.environ.get("C64_SKIP_BUILD"): + print("\n=== Building (skipped: C64_SKIP_BUILD set) ===") + else: + print("\n=== Building ===") + subprocess.run(["make", "clean"], capture_output=True, cwd=PROJECT_ROOT) + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f"Build failed:\n{result.stderr}") + sys.exit(1) + print(f" Build OK: {PRG_PATH}") + + if not os.path.exists(PRG_PATH): + print(f"FATAL: {PRG_PATH} not found") + sys.exit(1) + + labels = Labels.from_file(LABELS_PATH) + print(f" Labels loaded from {LABELS_PATH}") + + # -reu is required: the sibling c64-nist-curves fp_mul fetches 8x8 + # multiply rows from REU banks 0/1 (see CLAUDE.md "VICE harness gotcha" + # under Known issues). This test does not call into the dispatcher's + # body but the link includes the sibling, so the same boot-time + # invariants apply. + config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"]) + + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + transport = inst.transport + print(f"\n=== Starting VICE ===") + print(f" VICE PID={inst.pid}, port={inst.port}") + print(" Waiting for main menu...") + if wait_for_text(transport, "Q=QUIT", timeout=60.0, + verbose=False) is None: + print("FATAL: main menu did not appear") + sys.exit(1) + print(" Main menu ready") + + passed = 0 + failed = 0 + + p, f = test_client_hello_sig_algs(transport, labels, rng) + passed += p + failed += f + + p, f = test_cert_verify_p384_dispatch(transport, labels) + passed += p + failed += f + + mgr.release(inst) + + total = passed + failed + print(f"\n{'=' * 60}") + print("RESULTS") + print(f"{'=' * 60}") + print(f" Passed: {passed}/{total}") + print(f" Failed: {failed}/{total}") + if failed == 0 and passed > 0: + print("\n [+] P-384 negotiation plumbing: PASS") + sys.exit(0) + print("\n [-] P-384 negotiation plumbing: FAIL") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/uci/_memory_policy.py b/tools/uci/_memory_policy.py index 7a7f426..63f255b 100644 --- a/tools/uci/_memory_policy.py +++ b/tools/uci/_memory_policy.py @@ -287,9 +287,254 @@ def build_policy_and_arbiter( return policy, arbiter +def build_policy_and_arbiter_with_overlay_carveout( + labels_path: str | Path, + prg_path: str | Path, + *, + unknown: UnknownPolicy = UnknownPolicy.WARN, + extra_reserved: tuple[MemoryRegion, ...] = (), + min_scratch_bytes: int = 512, +) -> tuple[MemoryPolicy, MemoryArbiter]: + """Build a policy + arbiter that carves harness scratch from a tail region. + + Use this when ``CRYPTO_OVERLAY`` ($4200-$5FFF) is fully occupied by + an overlay blob (e.g. ``OVERLAY_BLOB_SHA384`` under the P-384 build, + or any future overlay that fills the whole region at PRG-load time + AND is the active swap slot at runtime). In that case the default + arbiter window ($4000-$5FFF) finds no free range and raises + :class:`MemoryArbiterError`. + Under the baseline P-256 / X25519-sibling P-256 builds the same + problem appears any time the CRYPTO_OVERLAY tail that + :func:`build_arbiter`'s default window relies on shrinks below the + 387 B of harness scratch the e2e tests need (trampoline + host / + path strings + sentinels). + + Candidate source regions, tried in order (first that fits ``min_scratch_bytes`` + wins): + + 1. ``NET_BSS_TAIL`` — the W1-partial / Phase C.4 spill-over BSS + region (declared $3B26-$41FF under UCI; the ip65 cfg uses a + different range, see cfg/c64-https-ip65.cfg). Most cfg + restructures keep slack at the high end of this region because + ``LIB_NISTCURVES_P256_BSS`` is ld65-placed from the bottom up; + under the post-W1 UCI layout this is where the "harness + scratch" hole actually lives. NET_BSS_TAIL is declared + ``type = bss`` with ``fill = yes, fillval = $00`` (UCI) or + file-backed (ip65), but in both cases the bytes above + ``__NET_BSS_TAIL_LAST__`` are unused by any production code. + 2. ``NET_CODE`` — the legacy carveout location used by every + build prior to Worker D's UCI cfg restructure. ``NET_CODE`` is + declared $2000-$3FFF with ``fill = yes, fillval = $00``; the + adapter + relocated TLS / crypto-aux code fills $2000-$3xxx + (per ``build/labels.txt``'s ``__NET_CODE_LAST__``); the tail + (rounded up to the next page from ``LAST``) is zero-fill in + the PRG, never referenced by any production code, and stays + RAM after boot. Under the post-W1 UCI cfg this region is + completely full (NET_CODE shrunk to ``$1B26 = 6950 B`` so its + tail vanished); the fallback exists for older builds and the + ip65 backend where NET_CODE still has slack. + 3. ``CRYPTO_OVERLAY`` — the 7,680 B swap slot at $4200-$5FFF. + Conditional fallback: usable ONLY when the build has no + overlay blob linked into the slot. The default UCI build + (no ``USE_X25519_SIBLING=1`` / no ``EMBED_P256_OVERLAY=1`` / + no ``USE_OVERLAY_P384_EMBED=1``) leaves CRYPTO_OVERLAY + zero-filled in the PRG with nothing reading from it at + runtime, which is a natural scratch home. + + We gate on labels.txt: if any of + ``__OVERLAY_BLOB_SHA384_SIZE__``, + ``__OVERLAY_BLOB_P256_SIZE__``, + ``__X25519_RODATA_SIZE__``, or ``__X25519_BSS_SIZE__`` is + non-zero — OR any of ``__OVERLAY_P256_SIZE__`` / + ``__OVERLAY_P384_SIZE__`` (the segment names that route + linked .o files into the slot) — CRYPTO_OVERLAY is *skipped* + and we fall through to the standard error message naming the + NET_BSS_TAIL / NET_CODE shortfalls. The harness loses no + functionality on builds that need the overlay slot for real: + it returns the same RuntimeError it raised before this + candidate was added. + + Unlike candidates (1) and (2), CRYPTO_OVERLAY is fully unused + (no ``__CRYPTO_OVERLAY_*_LAST__`` records any byte being + written — the region's "used end" tracks the MEMORY entry's + ``define = yes`` markers, not actual segment placement). When + this candidate is selected we therefore carve from the + region's *start* address rather than from a page-aligned + used-end. + + For the chosen region we: + - round the region's used-end up to the next $100 boundary (cheap + insurance against off-by-one with the very last code byte), + - reject the candidate if it yields fewer than ``min_scratch_bytes`` + of free space (default 512 B, conservative ceiling for the + 387 B the current e2e tests need), + - surgically rewrite the chosen region's reservation in the + labels-derived :class:`MemoryPolicy` to end at the carveout + start (so the freed tail isn't blocked by the reserved-takes- + precedence rule), and + - scope the returned :class:`MemoryArbiter` to that tail. + The CRYPTO_OVERLAY reservation is left intact — the overlay blob + occupies it for real, and the arbiter has no business allocating + there. + :param labels_path: ``build/labels.txt`` from the current build. + :param prg_path: PRG load image (currently unused — passed through + to :func:`build_policy` for consistency). + :param unknown: Passed through to :func:`build_policy`. + :param extra_reserved: Passed through to :func:`build_policy`. + :param min_scratch_bytes: Reject the candidate if its tail yields + fewer than this many bytes of free space. + :raises RuntimeError: When no candidate region's tail can supply + ``min_scratch_bytes`` of free space. The message names every + candidate tried with its measured free byte count so the + supervisor can decide whether to grow the region in the cfg. + """ + labels_path = Path(labels_path) + bounds = _parse_segment_bounds(labels_path) + used_ends = _parse_used_ends(labels_path) + + # Segments that, when present with non-zero size, indicate the + # CRYPTO_OVERLAY slot is in use as a real overlay swap target. + # ld65 only emits ``__NAME_SIZE__`` for a segment that received + # bytes (i.e. its `.segment "NAME"` block was non-empty at link + # time), so the *presence* of any of these symbols in + # ``_parse_segment_bounds()``'s output is itself the gate. + overlay_blockers = ( + "OVERLAY_BLOB_SHA384", + "OVERLAY_BLOB_P256", + "OVERLAY_P256", + "OVERLAY_P384", + "X25519_RODATA", + "X25519_BSS", + ) + crypto_overlay_in_use = any(name in bounds for name in overlay_blockers) + + # Ordered list of (region_name, bounds, used_end). NET_BSS_TAIL is + # the preferred source post-W1 because the UCI cfg keeps its + # harness-relevant slack there. NET_CODE is the historical home + # (pre-W1) and stays as a fallback for builds that haven't been + # restructured (notably the ip65 backend's older cfg variants). + # CRYPTO_OVERLAY is the high-headroom fallback for builds that + # don't currently embed an overlay blob into the slot (default + # UCI build = no embed flags set); 7,680 B of zero-fill RAM at + # $4200-$5FFF, untouched at runtime. + candidate_names: list[str] = ["NET_BSS_TAIL", "NET_CODE"] + if not crypto_overlay_in_use: + candidate_names.append("CRYPTO_OVERLAY") + attempts: list[tuple[str, int, int, int, int]] = [] + # Per-attempt tuple: (name, region_start, region_decl_end, + # region_used_end, scratch_start) + # The chosen attempt also fills in scratch_end_excl + free_bytes + # via the loop variables below. + + chosen_name: str | None = None + chosen_start = chosen_decl_end = chosen_used_end = 0 + scratch_start = scratch_end_excl = free_bytes = 0 + for name in candidate_names: + if name not in bounds: + attempts.append((name, 0, 0, 0, 0)) + continue + rstart, rdecl_end = bounds[name] + if name == "CRYPTO_OVERLAY": + # Whole-region carveout: nothing is loaded into the slot, + # so we use the entire $4200-$5FFF range. No page-rounding + # against ``__CRYPTO_OVERLAY_LAST__`` (which equals + # ``__CRYPTO_OVERLAY_START__`` for an empty region). + rused_end = rstart + sstart = rstart + else: + rused_end = used_ends.get(name, rstart) + # Round up to next page so we don't trail right up to the + # last instruction / BSS byte (cheap insurance against + # off-by-one). + sstart = (rused_end + 0xFF) & ~0xFF + if sstart >= rdecl_end: + attempts.append((name, rstart, rdecl_end, rused_end, sstart)) + continue + send = rdecl_end + fbytes = send - sstart + attempts.append((name, rstart, rdecl_end, rused_end, sstart)) + if fbytes >= min_scratch_bytes: + chosen_name = name + chosen_start, chosen_decl_end = rstart, rdecl_end + chosen_used_end = rused_end + scratch_start, scratch_end_excl, free_bytes = sstart, send, fbytes + break + + if chosen_name is None: + # Build a single multi-line message naming every candidate so + # the supervisor can pick a follow-up (grow NET_BSS_TAIL, route + # P-256 BSS elsewhere, shrink the library, etc.) without + # re-running the test. + diag_lines = [ + "No carveout candidate has enough tail for harness scratch:" + ] + for name, rstart, rdecl_end, rused_end, sstart in attempts: + if rdecl_end == 0: + diag_lines.append( + f" {name}: not declared in labels.txt" + ) + elif sstart >= rdecl_end: + diag_lines.append( + f" {name}: used to ${rused_end:04X}, declared end " + f"${rdecl_end:04X} (no tail above page boundary)" + ) + else: + fb = rdecl_end - sstart + diag_lines.append( + f" {name}: ${sstart:04X}-${rdecl_end:04X} " + f"({fb} B free; used to ${rused_end:04X}, declared end " + f"${rdecl_end:04X})" + ) + if crypto_overlay_in_use: + blockers_present = [n for n in overlay_blockers if n in bounds] + diag_lines.append( + f" CRYPTO_OVERLAY: skipped (in use by: " + f"{', '.join(blockers_present)})" + ) + diag_lines.append(f" need >= {min_scratch_bytes} B in some region.") + raise RuntimeError("\n".join(diag_lines)) + + base = build_policy( + labels_path, + prg_path, + unknown=unknown, + extra_reserved=extra_reserved, + ) + # Surgically trim the chosen region's reservation to end at + # scratch_start so the trailing free range is available to the + # arbiter. ``reserved_regions`` is a tuple of frozen MemoryRegion + # dataclasses; we rebuild a fresh tuple with that one region + # shrunk. + new_reserved: list[MemoryRegion] = [] + for r in base.reserved_regions: + if r.start == chosen_start and r.end == chosen_decl_end: + new_reserved.append(MemoryRegion( + chosen_start, scratch_start, + note=f"{r.note}(overlay_carveout:trimmed)", + )) + else: + new_reserved.append(r) + policy = MemoryPolicy( + reserved_regions=tuple(new_reserved), + safe_regions=base.safe_regions, + unknown=base.unknown, + ) + arbiter = MemoryArbiter( + policy=policy, window=(scratch_start, scratch_end_excl - 1), + ) + print( + f"{chosen_name}-tail harness scratch: " + f"${scratch_start:04X}-${scratch_end_excl - 1:04X} " + f"({free_bytes} B free; {chosen_name} used to ${chosen_used_end:04X}, " + f"declared end ${chosen_decl_end:04X})" + ) + return policy, arbiter + + __all__ = [ "build_policy", "build_arbiter", "build_policy_and_arbiter", + "build_policy_and_arbiter_with_overlay_carveout", "attach_arbiter_safe_regions", ] diff --git a/tools/uci/test_https_local.py b/tools/uci/test_https_local.py index f22e181..0ec3a7b 100644 --- a/tools/uci/test_https_local.py +++ b/tools/uci/test_https_local.py @@ -66,12 +66,14 @@ import traceback from pathlib import Path -from c64_test_harness.backends.device_lock import DeviceLock +from c64_test_harness.backends.device_lock import DeviceLock, DeviceLockTimeout from c64_test_harness.backends.ultimate64 import Ultimate64Transport from c64_test_harness.backends.ultimate64_client import Ultimate64Client from c64_test_harness.backends.ultimate64_helpers import ( set_turbo_mhz, set_debug_stream_mode, + runner_health_check, + Ultimate64RunnerStuckError, DEBUG_MODE_6510, ) from c64_test_harness.backends.u64_debug_capture import ( @@ -82,7 +84,10 @@ from c64_test_harness.keyboard import send_text from c64_test_harness.labels import Labels -from _memory_policy import build_policy_and_arbiter +from _memory_policy import ( + build_policy_and_arbiter, + build_policy_and_arbiter_with_overlay_carveout, +) DEBUG_CAPTURE_ENABLED = os.environ.get("DEBUG_CAPTURE", "1") != "0" @@ -1026,13 +1031,23 @@ def main() -> int: # --- Memory policy + arbiter: derive scratch addresses from the # current build's segment layout instead of hardcoding them. The # policy reserves every PRG segment found in labels.txt; the - # arbiter then allocates inside CRYPTO_OVERLAY's unused tail - # ($5100-$5FFF under USE_X25519_SIBLING=1, $4200-$5FFF when the - # flag is off). Transport hookup happens after the transport is - # constructed inside the try-block below. + # arbiter then allocates inside the NET_CODE zero-fill tail + # ($3xxx-$3FFF), carved out via + # ``build_policy_and_arbiter_with_overlay_carveout``. Previously this + # used ``build_policy_and_arbiter`` (CRYPTO_OVERLAY window), but + # Phase 5's overlay-blob landings fill CRYPTO_OVERLAY end-to-end + # ($4200-$5FFF) and the arbiter could no longer find a slot. The + # overlay-carveout helper steals the NET_CODE tail (declared + # ``fill = yes`` in the cfg, used only up to $3xxx by the adapter + # and relocated TLS/crypto-aux code) instead, which has ~1.6 KB of + # safe RAM under both the P-256 and P-384 builds. + # Transport hookup happens after the transport is constructed + # inside the try-block below. global ROUTINE_ADDR, HOST_STR_ADDR, PATH_STR_ADDR global SENTINEL_ADDR, PROGRESS_ADDR, CARRY_FLAG_ADDR - memory_policy, arbiter = build_policy_and_arbiter(LABELS_PATH, PRG_PATH) + memory_policy, arbiter = build_policy_and_arbiter_with_overlay_carveout( + LABELS_PATH, PRG_PATH, + ) ROUTINE_ADDR = arbiter.alloc(256, name="trampoline") HOST_STR_ADDR = arbiter.alloc(64, name="host_str") PATH_STR_ADDR = arbiter.alloc(64, name="path_str") @@ -1097,10 +1112,26 @@ def main() -> int: prg = PRG_PATH.read_bytes() lock = DeviceLock(HOST) - if not lock.acquire(timeout=60.0): - print(f"ERROR: could not acquire DeviceLock({HOST})", file=sys.stderr) - return 3 - print(f"Acquired DeviceLock({HOST})") + try: + # acquire_or_raise (c64-test-harness PR #88) replaces the legacy + # bare-bool acquire+if pattern. On timeout it gathers holder + # PID/liveness, lockfile age, and a quick REST reachability probe + # and raises DeviceLockTimeout with a diagnostic message that + # disambiguates "queued behind healthy holder" from + # "wedged / stale / unreachable" -- supervisors and humans need + # this signal to know whether to wait, kill the holder, or call + # for a recover() (the last requires explicit user authorization, + # never automated here). + lock.acquire_or_raise(timeout=120.0) + except DeviceLockTimeout as exc: + print(f"[fatal] DeviceLock({HOST}): {exc}", file=sys.stderr) + return 2 + # Surface queue/holder metadata on kickoff so the supervisor log + # has the same diagnostic shape as the timeout path. read_info() + # returns the lockfile JSON dict (or None when the lockfile vanished + # between acquire and this read, which is harmless). + info = lock.read_info() + print(f"Acquired DeviceLock({HOST}); holder info: {info!r}") # --- Per-run debug artifact directory + rotation --- run_dir: Path | None = None @@ -1132,6 +1163,28 @@ def main() -> int: enable_uci(client) uci_enabled = True + # Pre-detect the firmware "Cannot open file" wedged-runner state + # (c64-test-harness PR #88 / runner_health_check). When the U64E + # runner subsystem is stuck, every subsequent client.run_prg(...) + # returns the same 404-ish failure shape, and the test would + # otherwise blow ~10 minutes timing out at the sentinel poll + # before surfacing it. The helper sends a tiny no-op PRG and + # raises Ultimate64RunnerStuckError on the wedge signature; we + # do NOT call recover() (that's a state-changing action that + # requires explicit user authorization), just surface and exit. + try: + runner_health_check(client) + except Ultimate64RunnerStuckError as exc: + print( + f"[fatal] U64E runner wedged at {HOST}: {exc}", + file=sys.stderr, + ) + print( + "[fatal] supervisor: investigate / authorize recover()", + file=sys.stderr, + ) + return 3 + print("Resetting machine...") client.reset() time.sleep(2.5) diff --git a/tools/uci/test_https_local_p384.py b/tools/uci/test_https_local_p384.py new file mode 100644 index 0000000..bcb8ff8 --- /dev/null +++ b/tools/uci/test_https_local_p384.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Phase 5 LOCAL HTTPS (P-384): exercise the real http_get (TLS 1.3) code +path through the UCI backend on a real Ultimate 64 Elite, against a +local listener serving an ECDSA secp384r1 certificate. + +This is the P-384 sibling of tools/uci/test_https_local.py. Differences: + + - Uses the P-384 cert/key bundle at tools/https_e2e/certs/ + (server-p384.pem / server-p384.key). Equivalent to setting + HTTPS_LISTENER_CERT_PROFILE=p384 against the high-level + tools/https_e2e/https_listener.py API; this file inlines its own + listener (matches the parent file's pattern) and points it at the + P-384 certs directly. + + - Default SENTINEL_POLL_TIMEOUT scaled up. ECDSA-P384 verify under + the dual-overlay swap dance is the dominant cost of the handshake; + expect 4-7 minutes per handshake at U64E 48 MHz turbo (the SHA-384 + overlay swaps in for the SHA hash, then the curve overlay swaps in + for the verify; each swap is two REU DMAs ~16 ms wallclock). + +Usage: + /Users/someone/.local/share/c64-test-harness/venv/bin/python \\ + tools/uci/test_https_local_p384.py + +Environment variables (same as test_https_local.py): + U64_HOST - U64E IP (default 192.168.1.81) + TURBO_MHZ - C64 CPU MHz (default 48) + HTTPS_PORT - listener port (default 443; falls back to 4433) + SENTINEL_POLL_TIMEOUT - C64-side sentinel poll budget (default + 5400 * _TIMEOUT_SCALE = 90 min at 48 MHz; per + the Phase 5i diagnostic of artifact + /tmp/uci_https_debug/20260516_152824/, the + handshake actually progresses through Server + Finished decrypt within ~1812 s (tls_read_seq=4 + + tls_rec_buf shows freshly-written Finished), + but Client Finished + HTTP exchange need + additional time. 90 min gives ample slack.) + ACCEPT_TIMEOUT - server-side accept + handshake budget (same + default as SENTINEL_POLL_TIMEOUT) + DEBUG_CAPTURE - 0 to disable 6510 bus capture (default on) + KEEP_DEBUG_ON_PASS - 1 to preserve artifacts on PASS (default 0) + UCI_DEBUG_DIR - artifact dir base (default /tmp/uci_https_debug) + +Flow mirrors test_https_local.py exactly; see that file's docstring +for the per-step description. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# -------------------------------------------------------------------------- +# Patch the parent module's CERT_PATH / KEY_PATH and timeouts BEFORE +# importing it as a module. The parent module reads these at import time +# (top-of-file constants) so we monkey-patch via env vars where possible +# and via attribute injection for the cert paths. +# +# Set the timeout defaults BEFORE the import so the module's +# `os.environ.get(...)` calls pick them up. +# -------------------------------------------------------------------------- + +# Default to a 90 min budget if the user hasn't overridden it. Phase 5i +# diagnostic (artifact /tmp/uci_https_debug/20260516_152824/) showed the +# handshake actually progressing through Server Finished decrypt within +# ~1812 s — i.e. the 30 min budget previously used had insufficient +# slack for the remaining Client Finished + HTTP exchange steps. +# tls_read_seq=4 + tls_rec_buf containing freshly-written Server Finished +# confirms the ECDSA-P384 verify SUCCEEDED and read_seq advanced past it. +# Bumping to 90 min gives the C64 ample time to complete the handshake +# and the subsequent HTTP exchange. +os.environ.setdefault("SENTINEL_POLL_TIMEOUT", "5400") +os.environ.setdefault("ACCEPT_TIMEOUT", "5400") + +# Now import the parent module — it will pick up the timeout env vars +# above, and we patch CERT_PATH / KEY_PATH below before main() runs. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import test_https_local # type: ignore + +# -------------------------------------------------------------------------- +# Swap to the P-384 cert/key. These live in the same dir as the P-256 +# bundle; the listener wraps the socket with whatever ssl.SSLContext we +# load. +# -------------------------------------------------------------------------- +_REPO_ROOT = Path(__file__).resolve().parents[2] +test_https_local.CERT_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.pem" +test_https_local.KEY_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.key" + + +# -------------------------------------------------------------------------- +# Memory arbiter override for the P-384 build. +# +# Under the production P-384 UCI build CRYPTO_OVERLAY ($4200-$5FFF) is +# fully occupied at PRG-load time (OVERLAY_BLOB_SHA384) and is the +# active overlay swap slot at runtime, so the default +# CRYPTO_OVERLAY-scoped arbiter window finds no free range and raises +# MemoryArbiterError. The parent test_https_local.py now defaults to +# ``build_policy_and_arbiter_with_overlay_carveout`` (which carves +# harness scratch from the NET_CODE zero-fill tail $3xxx-$3FFF), so +# the P-384 sibling inherits the correct arbiter window automatically — +# no override needed here. The inline ``_p384_build_policy_and_arbiter`` +# monkey-patch that previously lived in this file was factored into +# ``_memory_policy.build_policy_and_arbiter_with_overlay_carveout`` and +# adopted as the default in PR #... +# -------------------------------------------------------------------------- + +# Sanity check that the certs exist before delegating to main(). +if not test_https_local.CERT_PATH.is_file(): + print( + f"ERROR: P-384 cert not found at {test_https_local.CERT_PATH}", + file=sys.stderr, + ) + sys.exit(2) +if not test_https_local.KEY_PATH.is_file(): + print( + f"ERROR: P-384 key not found at {test_https_local.KEY_PATH}", + file=sys.stderr, + ) + sys.exit(2) + + +def main() -> int: + print("=" * 60) + print("Phase 5 LOCAL HTTPS (P-384)") + print("=" * 60) + print(f"P-384 cert : {test_https_local.CERT_PATH}") + print(f"P-384 key : {test_https_local.KEY_PATH}") + print() + print("NOTE: ECDSA-P384 verify dominates handshake wall-clock;") + print(" expect 4-7 minutes per handshake at U64E 48 MHz turbo.") + print() + + return test_https_local.main() + + +if __name__ == "__main__": + raise SystemExit(main())