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..989fd11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,12 +2,23 @@ TLS 1.3 / HTTPS client for the Commodore 64, assembled with ca65/ld65 and delivered as a single PRG. Networking is provided by the ip65/RR-Net stack -(prebuilt blob at $2000). All crypto is hand-written 6502 tuned to fit -under the BASIC ROM shadow at $A000. +(prebuilt blob at $2000). Crypto is sourced from c64-lib-contract-conformant +sibling libraries (`libs/nistcurves@v0.3.0`, `libs/x25519@v0.5.0+5`) plus the +in-tree SHA-256 / ChaCha20-Poly1305 / HMAC / HKDF, all tuned to fit under +the BASIC ROM shadow at $A000. This file is the load-bearing "how does this hang together" reference. Keep it terse. +See also: + - [c64-lib-contract](https://github.com/JC-000/c64-lib-contract) + SPEC.md — library / consumer contract (v0.2.0+); ABI symbol + inventory, minimal-archive build targets, REU bank allocation, + SPEC §8.1 shared-sqtab routing. + - [`docs/library-ingestion-architecture.md`](docs/library-ingestion-architecture.md) + — target-state architecture plan covering hot/cold partition, + manifest contract, overlay model, CI/CD design (2026-05-20). + ## Build Dependencies: @@ -16,8 +27,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 @@ -26,10 +40,23 @@ Targets: libraries (the committed blob is normally reused) Variables: - - `BACKEND=ip65|uci` — select networking backend cfg - (`cfg/c64-https-$(BACKEND).cfg`; default ip65) - - `CA65`, `LD65` — toolchain overrides - - `VICE` — override the `make run` emulator + - `BACKEND=ip65|uci` — select networking backend cfg + (`cfg/c64-https-$(BACKEND).cfg`; default ip65) + - `USE_X25519_SIBLING=1` — link `libs/x25519` minimal archive + (UCI only; see Known issues for ip65 fit) + - `EMBED_P256_OVERLAY=1` — embed P-256 verify overlay blob + (see `cfg/p256-overlay-verify.cfg`) + - `USE_OVERLAY_P384_EMBED=1` — embed P-384 sha384+curve overlay blobs + (see `cfg/p384-overlay-{sha384,curve}.cfg`) + - `CA65`, `LD65` — toolchain overrides + - `VICE` — override the `make run` emulator + +Library integration scripts (`tools/integration/build_*.sh`) are thin +wrappers that invoke `make -C libs/ lib-` on the c64-lib-contract +adopters (`libs/nistcurves`, `libs/x25519`) and stage the resulting +minimal archives under `build/lib/`. The earlier ~636 lines of +sed-strip / object-cherry-pick shell were retired in the c64-lib-contract +alignment landing. Test harness expectations: - Most `tools/test_*.py` scripts run `make clean && make` themselves @@ -53,9 +80,11 @@ buffers in the crypto BSS — see per-module headers for details): X25519 / field arithmetic Default: in-tree `src/crypto/{x25519,fe25519}.s`. - Opt-in: sibling `libs/x25519@v0.4.0` via `make USE_X25519_SIBLING=1` + Opt-in: sibling `libs/x25519@v0.5.0+5` via `make USE_X25519_SIBLING=1` (UCI backend only — see Known issues for the ip65 fit blocker; Phase - C.5). Sibling and in-tree both expose the same ABI: + C.5). Pin is c64-lib-contract SPEC §8.1 conformant (shared sqtab, + minimal-archive `lib-x25519-scalarmult` target). Sibling and in-tree + both expose the same ABI: x25519_scalarmult — X25519 scalar × point, 32-byte buffers fe25519_mul, fe25519_sqr, fe25519_inv @@ -67,11 +96,14 @@ buffers in the crypto BSS — see per-module headers for details): SHA-256 (in-tree; no sibling) sha256_init, sha256_update, sha256_final - ECDSA P-256 (libs/nistcurves sibling, Phase C.4) + ECDSA P-256 (libs/nistcurves@v0.3.0 sibling, Phase C.4) ecdsa_verify_256 — TLS dispatcher in src/crypto/ecdsa_verify.s packs the BE struct + calls the sibling entry ec_scalar_mul_var — variable-base scalar multiplication - (in-tree ecdsa_{curve,fp,mod,points}.s were deleted in Phase G) + (in-tree ecdsa_{curve,fp,mod,points}.s were deleted in Phase G; + pin is c64-lib-contract SPEC §8.1 conformant — shared sqtab routed + via LIB_SHARED_SQTAB_BASE / SHARED_SQTAB_INIT=1, minimal-archive + `lib-p256-verify` build target.) P-384 is *stubbed at the TLS layer* (see `project_p384_stubbed` memory note). The sibling `libs/nistcurves` P-384 primitives are buildable as @@ -79,11 +111,21 @@ an external overlay image (Phase C.3b, `make p384-overlay`) but the target has a pre-existing unresolved-symbol bug (`ec_base384_x/y` in points384_raw.s) — fix that before wiring P-384 into the TLS path. -MEMORY requirements for a drop-in sibling library: - - Code + rodata must load into the `CRYPTO` region at **$6000-$9FFF** - (below the BASIC ROM shadow at $A000, so it survives ROM banking). - - `TABLES_BSS` (`x25519` squaring tables etc.) must stay **below $A000**; - the cfg pins it inside the CRYPTO region with `align = $100`. +MEMORY requirements for a drop-in sibling library (post-W1 hot/cold split): + - Code + rodata must load into `CRYPTO_HOT` at **$6000-$9FFF** + (file-backed; below the BASIC ROM shadow at $A000, so it survives + ROM banking). Library segments use `LIB__*` segment names per + c64-lib-contract SPEC; consumer cfg routes them (e.g. + `LIB_NISTCURVES_P256_CODE` → `CRYPTO_HOT`, `LIB_X25519_*` → ditto). + - Mutable BSS / large tables live in `CRYPTO_COLD_SHADOW` at + **$A000-$BFFF** (mutable state behind the BASIC ROM shadow; CPU + port $01 = $36 selects RAM). + - `TABLES_BSS` (`x25519` squaring tables etc.) must stay **below $C000**; + the cfg pins it inside CRYPTO_HOT or CRYPTO_COLD_SHADOW with + `align = $100`. Under c64-lib-contract SPEC §8.1 the always-resident + shared sqtab is published via `LIB_SHARED_SQTAB_BASE` / + `SHARED_SQTAB_INIT=1`; libraries built with §8.1 adoption reuse it + rather than carrying their own copy. - Zero-page usage is defined in `src/constants.inc` — fe25519 lives at `$2C-$37`, x25519 state at `$38-$3A`, ECDSA bignum at `$22-$3C`. These ranges are time-shared (fe25519 and ChaCha20 never overlap). @@ -143,18 +185,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 @@ -198,14 +254,26 @@ access — negligible for networking. ### Memory layout under UCI -The NET_CODE/NET_BSS regions ($2000-$5FFF) are repurposed: - - $2000-$3FFF UCI_CODE UCI adapter code (`net.s`, `uci_cmd.s`) - $4000-$5FFF UCI_BSS `uci_host_buf`, ipaddr scratch, socket - state, command control block - -All other regions (LOADER, CRYPTO, SHADOW_BSS, TCP_BUF) are identical -to the ip65 layout. +Post-W1 hot/cold partition (see `cfg/c64-https-uci.cfg`): + + $2000-$3B25 NET_CODE UCI adapter code (`net.s`, `uci_cmd.s`) + — shrunk vs pre-W1 to make room for + NET_BSS_TAIL below + $3B26-$41FF NET_BSS_TAIL spill BSS (library-side BSS that needs + file-backing for zero-init: e.g. + `LIB_NISTCURVES_P256_BSS`, + `tls_rec_buf` spill from `src/data.s`, + `cert_buf` spill from `src/der_decode.s`) + $4200-$5FFF CRYPTO_OVERLAY overlay slot: X25519 sibling tables / + P-256 verify overlay blob / + P-384 SHA-384 overlay blob (mutually + exclusive at link time; see + `OVERLAY_BLOB_*` segments) + $6000-$9FFF CRYPTO_HOT code + rodata, file-backed, banked-RAM-safe + $A000-$BFFF CRYPTO_COLD_SHADOW mutable BSS behind BASIC ROM shadow + (was `SHADOW_BSS`) + $C000-$FFFF OVERLAY_FILE_PAD + P-384 curve-overlay blob load area + OVERLAY_BLOB_CURVE_RAM (under-KERNAL) ### UCI test scripts @@ -272,6 +340,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: @@ -347,7 +437,8 @@ Five latent bugs and three new ones were cleared to get here: body. `http_resp_buf` still holds raw ASCII — only the render pipeline is translated. - **X25519 sibling (Phase C.5)** — `make USE_X25519_SIBLING=1` builds - against `libs/x25519@v0.4.0`. Default is OFF; the in-tree + against `libs/x25519@v0.5.0+5` (c64-lib-contract SPEC §8.1 conformant; + shared sqtab + minimal archive). Default is OFF; the in-tree implementation remains the shipped default until the flag flip is decided. The Phase C.1 hang and v0.3.0 retry rollback are both closed by upstream PR #36 + v0.4.0 H2 (defensive REU register init @@ -357,9 +448,10 @@ Five latent bugs and three new ones were cleared to get here: on U64E at 48 MHz: HTTPS handshake completes in ~101 s (vs ~87 s under in-tree X25519; the +14 s is consistent with v0.4.0's release-notes-documented +27 % scalarmult cost over v0.3.0 - for the L1-L29 CT closures). **ip65 backend overflows - CRYPTO_RESIDENT by 1 KB under the flag** — UCI is the supported - path; ip65 fit is a separate cfg-restructure follow-up. See + for the L1-L29 CT closures). **UCI sibling build overflows + CRYPTO_HOT by 364 B; ip65 default overflows by 1662 B** — both + blocked on library-side BSS slim ([c64-nist-curves#54](https://github.com/JC-000/c64-nist-curves/issues/54)). + UCI default (without sibling) builds clean at 62977 B. See `tools/integration/build_x25519.sh` for the staging layout. - **CRYPTO_OVERLAY collisions are now caught by MemoryPolicy.** All `tools/uci/*.py` test scripts derive their scratch DMA addresses @@ -431,10 +523,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 +568,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,20 +576,45 @@ 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 -regions run from $0801 through $9FFF, with SHADOW_BSS at $A000 and the -TCP ring at $C000. +Defined in `cfg/c64-https-{ip65,uci}.cfg`. UCI is the post-W1 reference +(hot/cold split adopted; see "Memory layout under UCI" subsection above +for the full UCI map). ip65 cfg lags behind on partial adoption (still +1662 B over CRYPTO_HOT under the default build until upstream BSS slim +lands — see Known issues + `c64-nist-curves#54`). + +ip65 (legacy / partial post-W1): - $0801-$1FFF LOADER BASIC stub + boot + TLS + HTTP + net wrapper - $2000-$3FFF NET_CODE ip65 code (as .incbin blob) / UCI adapter, - plus LOADER_OVERFLOW tail - $4000-$5FFF NET_BSS ip65 BSS (zero-filled in the PRG) - $6000-$9FFF CRYPTO all crypto code, rodata, and TABLES_BSS - $A000-$BFFF SHADOW_BSS mutable state behind BASIC ROM shadow - (CPU port $01 = $36 selects RAM) - $C000-$CFFF TCP_BUF `tcp_recv_buf`, 4KB ring for ip65 callback + $0801-$1FFF LOADER BASIC stub + boot + TLS + HTTP + net wrapper + $2000-$3FFF NET_CODE ip65 code (as .incbin blob), + plus LOADER_OVERFLOW tail + $4000-$5FFF NET_BSS ip65 BSS (zero-filled in the PRG) + $6000-$9FFF CRYPTO_HOT crypto code + rodata + library + minimal-archive segments (LIB_*_*) + $A000-$BFFF CRYPTO_COLD_SHADOW mutable state + library BSS + (was `SHADOW_BSS`, behind BASIC ROM) + $C000-$CFFF TCP_BUF `tcp_recv_buf`, 4KB ring for ip65 callback `LOADER_OVERFLOW` is a small segment carrying ~125 B of `http.s` growth (Content-Length parser + digit pattern) that did not fit in LOADER's @@ -474,13 +623,18 @@ blob under the ip65 backend and after the UCI adapter under the UCI backend. Both `cfg/c64-https-ip65.cfg` and `cfg/c64-https-uci.cfg` declare it. Reachable via JSR from LOADER-resident CODE. -Tight regions (after Phase 6 fit-up): - - **CRYPTO** is **100%** full. Any new crypto byte requires relocation - or reclamation somewhere. - - **SHADOW_BSS** was **99.8%** full after Phase 6; ≈258 B was reclaimed - in the `tls_hs_buf` removal (256 B buffer + 2 B length word), so - there is a bit more slack now. Still the tightest region after - CRYPTO — check the linker map before adding anything sizeable. +Tight regions (post-W1 fit-up): + - **CRYPTO_HOT** is **100%** full under default UCI build (62977 B); + ip65 default still **1662 B over** until c64-nist-curves#54 + minimal-archive BSS slim lands. Any new crypto byte requires + relocation or reclamation somewhere. + - **CRYPTO_COLD_SHADOW** holds library BSS (`LIB_NISTCURVES_BSS`, + `LIB_NISTCURVES_TABLES`, `LIB_X25519_BSS`, etc.) plus the existing + `tls_*` mutable state. Still tight after the W1 spill into + NET_BSS_TAIL; check the linker map before adding anything sizeable. + - **NET_BSS_TAIL** (UCI only, $3B26-$41FF, ~1750 B) absorbs spill from + library minimal archives plus `tls_rec_buf` (src/data.s) and + `cert_buf` (src/der_decode.s) that no longer fit in CRYPTO_HOT. There is a known TODO to restructure the MEMORY map so that all file-backed regions are physically contiguous in a single ROM-like diff --git a/Makefile b/Makefile index debe323..52a2364 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,65 @@ CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) else ifeq ($(BACKEND),uci) NET_SRCS := $(UCI_SRCS) CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) + +# --- W3: EMBED_P256_OVERLAY=1 stages the P-256 verify .bin as a +# .incbin into CRYPTO_OVERLAY at PRG-load time. Mutually exclusive +# with USE_OVERLAY_P384_EMBED (same slot at $4200) — the rules below +# turn the P-384 SHA embed off when EMBED_P256_OVERLAY=1 to avoid +# overflowing the 7,680 B slot. Default 0 so the un-flagged UCI +# build is byte-identical to today. +# +# Mirrors the P-384 USE_OVERLAY_P384_EMBED bootstrap pattern: the user- +# visible Make flag is `EMBED_P256_OVERLAY=1`; the ca65-level symbol +# `USE_OVERLAY_P256_EMBED` (which gates the .incbin in +# src/crypto/shared/p256_overlay_blobs.s) defaults to the same value +# but can be explicitly overridden via the command line for the +# bootstrap prelim link (avoids the overlay-bin <-> labels.txt cycle on +# a clean tree). Bootstrap workflow: +# make BACKEND=uci EMBED_P256_OVERLAY=1 USE_OVERLAY_P256_EMBED=0 +# make BACKEND=uci EMBED_P256_OVERLAY=1 +EMBED_P256_OVERLAY ?= 0 +USE_OVERLAY_P256_EMBED ?= $(EMBED_P256_OVERLAY) + +# 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. W3: also +# auto-turn-off when EMBED_P256_OVERLAY=1 (mutually-exclusive slot). +ifeq ($(EMBED_P256_OVERLAY),1) +USE_OVERLAY_P384_EMBED ?= 0 +else +# W5 / libs/nistcurves cfa9085+ bump: PR #25's SHA-384 rotr LUTs +# (LIB_NISTCURVES_SHA384_TABLES, 3 KB page-aligned) push the SHA-384 +# overlay-half archive above the 7.5 KB CRYPTO_OVERLAY slot limit by +# ~1.5 KB. Default flips to 0 until the SHA-384 LUTs are either: +# - relocated out of the overlay slot (consumer-side path: route +# LIB_NISTCURVES_SHA384_TABLES to a separate resident region and +# teach the standalone overlay cfg to omit them from the .bin), or +# - shrunk on the library side (eg by sharing rotr LUTs across the +# 8 shift amounts, or runtime-generating them at boot). +# Set USE_OVERLAY_P384_EMBED=1 explicitly to attempt the embed (will +# fail at link with an overflow until the SHA-384 LUTs are dealt with). +USE_OVERLAY_P384_EMBED ?= 0 +endif +ifeq ($(USE_OVERLAY_P384_EMBED),1) +CA65FLAGS += -D USE_OVERLAY_P384_EMBED=1 +endif +endif + +# W3: propagate USE_OVERLAY_P256_EMBED to ca65 (the .incbin in +# src/crypto/shared/p256_overlay_blobs.s is gated on this). The +# default-derivation from EMBED_P256_OVERLAY happens above. +ifeq ($(USE_OVERLAY_P256_EMBED),1) +CA65FLAGS += -D USE_OVERLAY_P256_EMBED=1 +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 +166,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 +198,28 @@ 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 + +# W3: when USE_OVERLAY_P256_EMBED is on, add the P-256 verify .bin to +# PRG_DEPS so make builds it before the .incbin in +# src/crypto/shared/p256_overlay_blobs.s tries to read it. The .bin +# rule below also has an order-only dep on build/labels.txt for the +# main-PRG label lookup (same bootstrap cycle as P-384). Gated on the +# ca65-level USE_OVERLAY_P256_EMBED rather than EMBED_P256_OVERLAY so +# the bootstrap prelim link (with USE_OVERLAY_P256_EMBED=0) skips the +# .bin dep cleanly. +ifeq ($(USE_OVERLAY_P256_EMBED),1) +PRG_DEPS += build/lib/nistcurves-p256-verify.bin +build/crypto/shared/p256_overlay_blobs.o: build/lib/nistcurves-p256-verify.bin +endif + $(PRG): $(PRG_DEPS) @mkdir -p build $(LD65) $(LD65FLAGS) -o $@ $(ALL_OBJS) $(SIBLING_LIB_ARCHIVES) @@ -141,6 +227,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 +252,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 +280,78 @@ 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 + +# W3: P-256 verify overlay .bin (library-ingestion architecture). +# Mirrors the P-384 overlay .bin rule: depends on the P-256 sibling +# archive (build/lib/nistcurves-p256.a, already a default PRG dep) + +# the standalone overlay cfg. Order-only dep on build/labels.txt for +# the lookup_label() fallback (same pattern as P-384 — see Fix D +# comment block above). +build/lib/nistcurves-p256-verify.bin build/labels-p256-verify.txt: \ + build/lib/nistcurves-p256.a \ + cfg/p256-overlay-verify.cfg \ + tools/integration/build_nistcurves_p256_bin.sh \ + | build/labels.txt + bash tools/integration/build_nistcurves_p256_bin.sh + +.PHONY: p256-overlay +p256-overlay: build/lib/nistcurves-p256-verify.bin build/labels-p256-verify.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..194268a 100644 --- a/cfg/c64-https-ip65.cfg +++ b/cfg/c64-https-ip65.cfg @@ -1,22 +1,86 @@ # c64-https ld65 config — ip65/RR-Net backend # -# Memory map: -# $0801-$1FFF : LOADER (BASIC stub + boot + tls + http + net wrapper) -# $2000-$3FFF : NET_CODE (ip65 blob + LOADER_OVERFLOW tail) -# $4000-$5FFF : NET_BSS (ip65 BSS, fully used) -# $6000-$BFFF : CRYPTO_RESIDENT (24 KB — covers old CRYPTO + SHADOW_BSS span) -# $C000-$CFFF : TCP_BUF (tcp_recv_buf, 4 KB ring) -# -# The in-tree src/crypto/x25519.s + src/crypto/fe25519.s provide -# the x25519 primitives under ip65. Phase C.1 attempted a sibling-lib -# overlay integration under BACKEND=uci only; that integration was -# rolled back (see cfg/c64-https-uci.cfg for details). ip65 never -# adopted the overlay, so this cfg is unchanged from the Phase C.0 -# single contiguous CRYPTO_RESIDENT layout. +# Memory map (post-W1 hot/cold split — see comment block at the MEMORY +# entry for the fit constraints): +# $0801-$1FFF : LOADER (BASIC stub + boot + tls + http + net wrapper) +# $2000-$3FFF : NET_CODE (ip65 blob + LOADER_OVERFLOW tail + +# CRYPTO_AUX_CODE2) +# $4000-$4F8B : NET_BSS (ip65 blob's BSS — physical occupancy +# stops at $4F8B per ip65-build/ip65-c64.map) +# $4F8C-$5FFF : CRYPTO_OVERLAY (4,212 B reclaimed BSS-TAIL slot — used +# under W2 as overlay slot for ip65; same +# role as UCI's CRYPTO_OVERLAY at $4200. +# Now reserved for future P-384/SHA-384 +# overlay use; BSS no longer routed here.) +# $6000-$9FFF : CRYPTO_RESIDENT (16 KB file-backed — code + rodata. +# Stays below $A000 because boot zeros +# $A000-$BFFF as zero-init BSS.) +# $A000-$BFFF : CRYPTO_COLD_SHADOW (8 KB file-backed — large BSS chunks +# under banked-on RAM; zero-filled in +# PRG, overwritten at runtime.) +# $C000-$CFFF : TCP_BUF (tcp_recv_buf, 4 KB ring) # # CPU port $01 is set to $36 at boot (BASIC ROM off), so $8000-$BFFF is # plain RAM under what would otherwise be BASIC ROM shadow + the top of # the character ROM gap. +# +# --- W1 hot/cold split (Worker I — root-cause fix for the boot +# zero-fill regression on the UCI side; partial fit on ip65). --- +# +# Pre-W1 ip65 had `CRYPTO_RESIDENT` spanning the full 24 KB +# $6000-$BFFF as a single file-backed region. Worker F's W2 attempt +# carved $4F8C-$5FFF out of NET_BSS as `CRYPTO_OVERLAY` and re-routed +# TABLES_BSS / LIB_NISTCURVES_P256_BSS / BSS_TAIL there; the 4,212 B +# slot proved 1,024 B short of those three combined. +# +# The W1 split addresses the cross-cutting correctness issue +# symmetrically with the UCI cfg: +# +# CRYPTO_RESIDENT $6000-$9FFF 16 KB file-backed. Code + rodata +# plus TLS_CODE / CRYPTO_AUX_CODE in +# the small fraction that fits. +# Stays below $A000 so the boot +# zbss loop in src/boot.s (which +# wipes $A000-$BFFF as zero-init +# BSS) cannot wipe any executable +# bytes. Under the bumped library +# (cfa9085) LIB_NISTCURVES_P256_CODE +# would otherwise have straddled +# $A000 and been silently zeroed — +# the production regression that +# this split closes. +# CRYPTO_OVERLAY $4F8C-$5FFF 4,212 B. Holds TLS_CODE + +# CRYPTO_AUX_CODE (Phase C.4 +# placement) under the W1 split, +# freeing CRYPTO_RESIDENT for the +# resident P-256 verify path + +# in-tree TLS app-data primitives. +# Future P-384 / SHA-384 / X25519 +# sibling overlay segments are +# declared `optional = yes` here. +# CRYPTO_COLD_SHADOW $A000-$BFFF 8 KB file-backed (zero-filled). +# BSS chunks land here. +# +# RESIDUAL FIT ISSUE: the total c64-https + libs/nistcurves BSS claim +# (BSS 4,478 B + CRYPTO_BSS 21 B + TABLES_BSS 1,536 B page-aligned + +# BSS_TAIL 2,084 B + LIB_NISTCURVES_P256_BSS 1,573 B = ~9,692 B before +# alignment) exceeds the 8 KB CRYPTO_COLD_SHADOW budget by ~1.5 KB. +# ld65 surfaces a `BSS overflows CRYPTO_COLD_SHADOW by 1662 bytes` +# warning + a non-zero exit. Cfg-only relief is exhausted under the +# bumped library (see Worker I's trace at the W15 review). Resolution +# requires one of: +# (a) Library-side minimal-archive variant — drop data_p256 anchor +# RAM or audit data_p256.o for unused scratch (~1 KB+). +# (b) Source-level BSS split — partition src/data.s BSS into a +# hot/cold pair so cold-path BSS can be routed elsewhere +# (~1.5 KB+). +# (c) Defer-paging — SHA-384 / P-384 overlay-paging strategy +# extended to a c64-https in-tree BSS slice. +# +# UCI is unaffected (62977 B PRG builds clean under W1) — under UCI +# the NET_CODE region absorbs TLS_CODE+CRYPTO_AUX_CODE+CRYPTO_AUX_CODE2 +# (the UCI adapter is ~1.7 KB vs ip65's 6.95 KB blob), opening enough +# CRYPTO_RESIDENT slack to leave BSS budget intact. FEATURES { STARTADDRESS: default = $0801; @@ -30,23 +94,31 @@ MEMORY { LOADADDR: start = $07FF, size = $0002, file = %O; LOADER: start = $0801, size = $17FF, file = %O, define = yes, fill = yes, fillval = $00; NET_CODE: start = $2000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; - # Phase C.4: NET_BSS is split into the ip65-blob-owned portion - # ($4000-$4F8B per ip65-build/ip65-c64.map, rounded up to $4F8C = - # $F8C B) and a tail slack region reused for TLS_CODE + CRYPTO_AUX_CODE - # relocation. Sibling P-256 integration pushed CRYPTO_RESIDENT over - # its 24 KB budget under ip65; UCI had NET_BSS at only $4000-$41FF so - # it already had headroom. The blob's reserved BSS stops exactly at - # $4F8B; NET_BSS_TAIL starts at the next byte so no blob state is - # clobbered by the relocated code. - NET_BSS: start = $4000, size = $0F8C, file = %O, define = yes, fill = yes, fillval = $00; - NET_BSS_TAIL: start = $4F8C, size = $1074, file = %O, define = yes, fill = yes, fillval = $00; - CRYPTO_RESIDENT: start = $6000, size = $6000, file = %O, define = yes, fill = yes, fillval = $00; + # NET_BSS holds the ip65 blob's BSS ($4000-$4F8B per ip65-build/ + # ip65-c64.map). The blob's reserved BSS stops exactly at $4F8B; + # the remainder of the original $4000-$5FFF span is reclaimed + # below as CRYPTO_OVERLAY. + NET_BSS: start = $4000, size = $0F8C, file = %O, define = yes, fill = yes, fillval = $00; + # CRYPTO_OVERLAY: the historical W2 reclaimed BSS-TAIL slot at + # $4F8C-$5FFF (4,212 B). Under the W1 hot/cold split this slot + # absorbs TLS_CODE + CRYPTO_AUX_CODE (Phase C.4 placement, 3,760 B, + # 452 B headroom). Future P-384 + SHA-384 archives are anchored + # here (`optional = yes`, zero bytes today). file-backed so any + # leftover content lands zero-filled in the PRG. + CRYPTO_OVERLAY: start = $4F8C, size = $1074, file = %O, define = yes, fill = yes, fillval = $00; + # 16 KB code+rodata stays below $A000 so boot's zbss zero-fill of + # $A000-$BFFF cannot wipe any executable bytes. + CRYPTO_RESIDENT: start = $6000, size = $4000, file = %O, define = yes, fill = yes, fillval = $00; + CRYPTO_COLD_SHADOW: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; - # 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_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; } @@ -65,55 +137,90 @@ SEGMENTS { NET_CODE: load = NET_CODE, type = ro; # LOADER-overflow rides along with the ip65 blob in the NET_CODE tail. - # Phase C.4: CRYPTO_AUX_CODE2 (hmac_drbg alone) also rides the NET_CODE - # tail under ip65 because TLS_CODE + sha256's CRYPTO_AUX_CODE together - # would overflow NET_BSS_TAIL by 23 bytes if hmac_drbg were co-located - # there. NET_CODE has ~1 KB of tail slack after the ip65 blob + - # LOADER_OVERFLOW, which covers hmac_drbg's ~830 B. sha256 rides - # NET_BSS_TAIL alongside TLS_CODE. + # CRYPTO_AUX_CODE2 (hmac_drbg alone) also rides the NET_CODE tail to + # spread crypto code across NET_CODE / CRYPTO_RESIDENT / CRYPTO_OVERLAY. LOADER_OVERFLOW: load = NET_CODE, type = ro, optional = yes; CRYPTO_AUX_CODE2: load = NET_CODE, type = ro, optional = yes; NET_BSS: load = NET_BSS, type = bss, optional = yes; - # Phase C.4: TLS_CODE + CRYPTO_AUX_CODE (sha256) relocate out of - # CRYPTO_RESIDENT into NET_BSS_TAIL. hmac_drbg (CRYPTO_AUX_CODE2) - # goes to NET_CODE instead — see the NET_CODE SEGMENTS block. - TLS_CODE: load = NET_BSS_TAIL, type = ro, optional = yes; - CRYPTO_AUX_CODE: load = NET_BSS_TAIL, type = ro, optional = yes; - # --- Overlay slot placeholders (unused under ip65). --- - # Declared `optional = yes` + `load = CRYPTO_RESIDENT` as harmless - # anchors so shared code that references the segment names still - # links; they receive no bytes under ip65 because no overlay - # archives are linked. - OVERLAY_P256: load = CRYPTO_RESIDENT, type = ro, optional = yes; - OVERLAY_P384: load = CRYPTO_RESIDENT, type = ro, optional = yes; - - # Phase C.5: sibling c64-x25519 rodata + bss segments. Under ip65 - # there is no spare 4 KB region available — CRYPTO_OVERLAY is a - # zero-sized alias and NET_BSS_TAIL/NET_CODE both have <1 KB of - # slack. The segments are anchored at CRYPTO_RESIDENT and will - # overflow by ~3.3 KB under USE_X25519_SIBLING=1 until the cfg is - # restructured. Reported as a partial blocker for the integrator; - # USE_X25519_SIBLING=1 works under BACKEND=uci where CRYPTO_OVERLAY - # provides the headroom. - X25519_RODATA: load = CRYPTO_RESIDENT, type = ro, optional = yes, align = $100; - X25519_BSS: load = CRYPTO_RESIDENT, type = bss, optional = yes, align = $100; + # --- W1 hot/cold split: BSS catch-alls land in CRYPTO_COLD_SHADOW. --- + # The total in-tree + library BSS claim (~9.7 KB) exceeds the 8 KB + # CRYPTO_COLD_SHADOW budget. The residual overflow is surfaced at + # link time; the supervisor must decide between source-level BSS + # split, library-side minimal-archive variant, or another overlay + # paging strategy (see the MEMORY entry header). + LIB_NISTCURVES_P256_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + # BSS_TAIL hosts the cert_buf (1.5 KB) + tls_rec_buf (548 B) per + # src/data.s and src/der_decode.s. + BSS_TAIL: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + TABLES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, align = $100; # --- Resident crypto + TLS code / rodata. --- - # Phase C.2 backend-divergence: under UCI, TLS_CODE and CRYPTO_AUX_CODE - # (SHA-256 + HMAC-DRBG + ecdsa_verify dispatcher) relocate to NET_CODE - # to free headroom for Phase C.3 overlays. Phase C.4 gives ip65 the - # same treatment but routes them into NET_BSS_TAIL instead (NET_CODE - # is ~88% full with the ip65 blob under ip65). + # W1 hot/cold split: TLS_CODE + CRYPTO_AUX_CODE ride the historical + # W2 CRYPTO_OVERLAY slot ($4F8C-$5FFF, 4,212 B), restoring their + # Phase C.4 placement. CRYPTO_RESIDENT keeps the resident P-256 + # verify path + chacha20/poly1305 + TLS app-data primitives + rodata + # below $A000. + TLS_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + CRYPTO_AUX_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; CRYPTO_CODE: load = CRYPTO_RESIDENT, type = ro; CRYPTO_RODATA: load = CRYPTO_RESIDENT, type = ro; + # libs/nistcurves segment names (c64-lib-contract / cfa9085+). + # Default: P-256 always-resident in CRYPTO_RESIDENT (matches UCI's + # CRYPTO_HOT default). MUL / TABLES / shared-BSS segments are + # declared `optional = yes` for forward-compat (the minimal P-256 + # archive built by tools/integration/build_nistcurves_p256.sh + # excludes data_shared.o + mul_8x8.o, so these segments receive + # zero bytes today; they would land in CRYPTO_RESIDENT if a future + # archive includes them). + LIB_NISTCURVES_P256_CODE: load = CRYPTO_RESIDENT, type = ro, optional = yes; + LIB_NISTCURVES_P256_RODATA: load = CRYPTO_RESIDENT, type = ro, optional = yes; + LIB_NISTCURVES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + LIB_NISTCURVES_TABLES: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes, align = $100; + LIB_NISTCURVES_MUL_CODE: load = CRYPTO_RESIDENT, type = ro, optional = yes; + # P-384 + SHA-384 routed to CRYPTO_OVERLAY (the reclaimed + # BSS-TAIL slot). Mirrors UCI's overlay-resident routing for the + # same segments. Zero bytes today (no P-384 archive linked under + # ip65); future wiring picks them up here without further cfg + # change. P-384 / SHA-384 BSS lives in CRYPTO_COLD_SHADOW (read/write + # state, BSS-only, not file-backed code). + LIB_NISTCURVES_P384_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_P384_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_P384_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + LIB_NISTCURVES_P384_DATA_BSS: load = CRYPTO_COLD_SHADOW, type = rw, optional = yes; + LIB_NISTCURVES_SHA384_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_TABLES: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100; + LIB_NISTCURVES_SHA384_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; RESIDENT_RODATA: load = CRYPTO_RESIDENT, type = ro, optional = yes; - # --- Resident BSS. Everything that used to live in SHADOW_BSS now - # shares CRYPTO_RESIDENT; TABLES_BSS keeps page alignment. - BSS: load = CRYPTO_RESIDENT, type = bss; - CRYPTO_BSS: load = CRYPTO_RESIDENT, type = bss; - TABLES_BSS: load = CRYPTO_RESIDENT, type = bss, align = $100; + # Phase C.5: sibling c64-x25519 rodata + bss segments. Under ip65 + # CRYPTO_OVERLAY is now real ($4F8C-$5FFF, 4,212 B); the segments + # are anchored there for forward-compat under USE_X25519_SIBLING=1. + # Page alignment respected. + X25519_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100; + X25519_BSS: load = CRYPTO_OVERLAY, type = bss, optional = yes, align = $100; + + # --- Resident BSS. Catch-all c64-https in-tree BSS (src/data.s + # `.segment "BSS"`). Routed to CRYPTO_COLD_SHADOW under the W1 + # hot/cold split; CRYPTO_RESIDENT now carries code+rodata only. + BSS: load = CRYPTO_COLD_SHADOW, type = bss; + CRYPTO_BSS: load = CRYPTO_COLD_SHADOW, type = bss; + + # 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; + + # W3: P-256 verify overlay blob (segment placeholder). Mirrors the + # UCI cfg's slot so src/crypto/shared/p256_overlay_blobs.s links + # cleanly even though ip65 has no live overlay slot and the .ifdef + # USE_OVERLAY_P256_EMBED guard keeps the segment empty regardless. + OVERLAY_BLOB_P256: load = CRYPTO_OVERLAY, 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..ed4352b 100644 --- a/cfg/c64-https-uci.cfg +++ b/cfg/c64-https-uci.cfg @@ -3,34 +3,61 @@ # Target: Commodore Ultimate 64 / U64E using the host-visible UCI # ($DF1B-$DF1F) in place of ip65 + RR-Net. # -# Memory map: -# $0801-$1FFF : LOADER (BASIC stub + boot + http + net wrapper) -# $2000-$3FFF : NET_CODE (UCI adapter + LOADER_OVERFLOW tail + -# TLS_CODE + CRYPTO_AUX_CODE) -# $4000-$41FF : UCI_BSS (uci_host_buf + state, 512 B) -# $4200-$5FFF : CRYPTO_OVERLAY (7.5 KB swappable overlay slot — used -# by the external P-384 smoke test only) -# $6000-$BFFF : CRYPTO_RESIDENT (24 KB always-resident crypto + TLS + BSS) -# $C000-$CFFF : TCP_BUF (tcp_recv_buf, 4 KB ring) +# Memory map (post-W1 partial — see comment block at the MEMORY block +# below for the fit constraints that forced this layout): +# $0801-$1FFF : LOADER (BASIC stub + boot + http + net wrapper) +# $2000-$3B25 : NET_CODE (UCI adapter + LOADER_OVERFLOW tail + +# TLS_CODE + CRYPTO_AUX_CODE) +# $3B26-$3FFF : NET_BSS_TAIL (BSS spill-over reclaimed from the +# NET_CODE tail — UCI_BSS + +# LIB_NISTCURVES_P256_BSS land here) +# $4000-$41FF : UCI_BSS_REGION (zero-size alias post-W1; UCI_BSS +# moved into NET_BSS_TAIL above) +# $4200-$5FFF : CRYPTO_OVERLAY (7.5 KB swappable overlay slot — used +# by the X25519 sibling / P-384 overlay +# / W3 P-256 overlay embed) +# $6000-$BFFF : CRYPTO_HOT (24 KB file-backed; always-resident +# hot + warm + much of the cold path: +# ChaCha20-Poly1305, SHA-256, HKDF/ +# HMAC-DRBG, TLS, HTTP, mul tables, +# AEAD, transcript, libs/nistcurves +# P-256 verify + most BSS. The W1 plan +# wanted a 16 KB hot / 8 KB cold split +# here but the bumped library does not +# fit that partition — see comment +# block at the MEMORY entry.) +# $C000-$CFFF : TCP_BUF (tcp_recv_buf, 4 KB ring) # -# NOTE on UCI_BSS size: the plan's "256 B" target was optimistic — -# `src/net/uci/net.s` + `uci_cmd.s` allocate ~289 B (uci_host_buf 256 B + -# uci_ipaddr_resp 12 B + uci_socket_id/port/send/poll/... ~16 B + -# uci_resp control block 4 B). Rounded up to the next page (512 B) and -# the overlay slot trimmed accordingly (7.5 KB vs. 8 KB under ip65). +# --- W1 hot/cold partition (post-bump library-ingestion architecture) --- +# Before: CRYPTO_RESIDENT was a single 24 KB file-backed region $6000-$BFFF +# carrying every byte of crypto code + rodata + BSS. CLAUDE.md recorded it +# as "100% full" after Phase 6; the libs/nistcurves cfa9085 bump (which +# adds ~768 B of new code via PR #34 + PR #26) would have overflowed it. # -# CRYPTO_RESIDENT is 24 KB on UCI (vs 16 KB target on ip65) because UCI -# does not need $4000-$5FFF for backend BSS, letting CRYPTO_RESIDENT -# start at $6000 and claim the former NET_BSS space. +# The W1 split lets the file-backed region carry only what needs PRG-load +# byte initialization (code + rodata, $6000-$9FFF = 16 KB), and reclaims +# $A000-$BFFF as plain RAM under BASIC ROM banking for BSS that is +# zero-initialized at runtime anyway. This is the same "$01 = $36" RAM +# slice that pre-Phase-6 hosted the original SHADOW_BSS region; bringing +# it back as a dedicated BSS slot leaves room in CRYPTO_HOT for the +# nistcurves bump's additional code bytes. # -# --- x25519 overlay rollback note --- -# Phase C.1 (commit 6c9d2a3) integrated libs/x25519/ as a REU overlay -# and split CRYPTO_RESIDENT around a 1 KB sqtab hole. That integration -# deadlocked the TLS handshake at 48 MHz (x25519_scalarmult hung from -# TLS context) and was rolled back. The in-tree src/crypto/x25519.s + -# src/crypto/fe25519.s are now used under both backends. CRYPTO_OVERLAY -# still exists to serve the P-384 external smoke test (Phase C.3b, -# tools/test_p384_symbols.py). +# Library segments (under c64-lib-contract / libs/nistcurves cfa9085+): +# +# LIB_NISTCURVES_P256_CODE - fp256, mod256, points256_core, ecdsa256. +# Default: CRYPTO_HOT (always-resident). +# Under EMBED_P256_OVERLAY=1: routed via +# OVERLAY_BLOB_P256 to the live overlay +# slot at boot. +# LIB_NISTCURVES_P256_RODATA - curve256, mod256 constants. CRYPTO_HOT. +# LIB_NISTCURVES_P256_BSS - data_p256 working buffers. CRYPTO_HOT BSS. +# LIB_NISTCURVES_P384_* - routed via the OVERLAY_P384_CURVE slot +# (paged from REU bank 7 at handshake time). +# LIB_NISTCURVES_SHA384_* - routed via the OVERLAY_P384_SHA384 slot +# (paged from REU bank 6 at handshake time). +# +# (For the W2 ip65-side restructure, see cfg/c64-https-ip65.cfg. ip65 +# stays on the pre-W1 layout for now — out of scope here.) FEATURES { STARTADDRESS: default = $0801; @@ -42,12 +69,90 @@ MEMORY { LOADADDR: start = $07FF, size = $0002, file = %O; LOADER: start = $0801, size = $17FF, file = %O, define = yes, fill = yes, fillval = $00; - NET_CODE: start = $2000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; - UCI_BSS_REGION: start = $4000, size = $0200, file = %O, define = yes, fill = yes, fillval = $00; + # W1 partial: NET_CODE shrunk to fit current content (~$1B26 = 6950 B + # on branch tip), tail reclaimed as NET_BSS_TAIL to spill BSS that + # does not fit in CRYPTO_HOT under the bumped libs/nistcurves. UCI_BSS + # now shares NET_BSS_TAIL too (the dedicated UCI_BSS_REGION was 512 B + # but only used ~$125 — folding it into NET_BSS_TAIL frees ~370 B + # for the libs/nistcurves spill-over). ip65 has had an equivalent + # NET_BSS_TAIL since Phase C.4 — UCI now adopts the same pattern. + NET_CODE: start = $2000, size = $1B26, file = %O, define = yes, fill = yes, fillval = $00; + # NET_BSS_TAIL spans NET_CODE end through the start of CRYPTO_OVERLAY, + # subsuming the historical UCI_BSS_REGION at $4000-$41FF (UCI_BSS now + # rides on the head of this combined region). Holds 1.7 KB total — + # ld65 distributes UCI_BSS (~293 B) + LIB_NISTCURVES_P256_BSS (~1.5 + # KB) + a stub UCI_BSS_REGION definition. + NET_BSS_TAIL: start = $3B26, size = $06DA, file = %O, define = yes, fill = yes, fillval = $00; + # UCI_BSS_REGION kept as a zero-size alias so any external tooling + # that resolves the symbol via labels.txt still finds it (the cfg's + # `define = yes` emits __UCI_BSS_REGION_START__ etc.). + UCI_BSS_REGION: start = $4000, size = $0000, type = rw, define = yes; 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; + # W1 HOT/COLD SPLIT (Worker I — root-cause fix for the + # boot zero-fill regression): + # + # The W1 partial layout had CRYPTO_HOT spanning the full 24 KB + # $6000-$BFFF as a single file-backed region. Under the bumped + # libs/nistcurves, ld65 placed `LIB_NISTCURVES_P256_CODE` at + # $868F-$A631 — *straddling $A000*. Boot (`src/boot.s` zbss loop) + # zeros $A000-$BFFF as "SHADOW_BSS" zero-init, which wiped the + # upper 1.5 KB of `ecdsa_verify_256`; the first call to verify + # landed the CPU on `00 00 …` ⇒ BRK ⇒ KERNAL warm restart ⇒ + # BASIC READY ⇒ eternal hang during the TLS CertVerify step. + # + # The fix carves the region as originally intended: + # + # CRYPTO_HOT $6000-$9FFF 16 KB file-backed. + # Code + rodata + small BSS that + # do not need page alignment. + # No segment crosses $A000. + # CRYPTO_COLD_SHADOW $A000-$BFFF 8 KB file-backed (zero-filled). + # Catch-all for the large BSS + # segments — wiped at boot by + # the zbss loop, which is benign + # because they are BSS anyway. + # File-backed (`fill = yes`) so + # the PRG stays contiguous on + # disk; the zero bytes are + # overwritten at runtime. + # + # See SEGMENTS{} below for the new routings (BSS / CRYPTO_BSS / + # TABLES_BSS / BSS_TAIL → CRYPTO_COLD_SHADOW; small UCI_BSS stays + # in CRYPTO_HOT). The 16 KB hot half fits the bumped library + # comfortably; the 8 KB cold half has enough room for the four + # largest BSS chunks routed there. + CRYPTO_HOT: start = $6000, size = $4000, file = %O, define = yes, fill = yes, fillval = $00; + CRYPTO_COLD_SHADOW: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; + + # 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_HOT/SHADOW + # 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 { @@ -58,13 +163,13 @@ SEGMENTS { EXEHDR: load = LOADER, type = ro; STARTUP: load = LOADER, type = ro, optional = yes; CODE: load = LOADER, type = ro; - RODATA: load = CRYPTO_RESIDENT, type = ro; + RODATA: load = CRYPTO_HOT, type = ro; INIT: load = LOADER, type = ro, optional = yes; # NET_CODE hosts the UCI adapter + the LOADER_OVERFLOW tail. # Phase C.2 (UCI only): TLS_CODE and CRYPTO_AUX_CODE (SHA-256, # HMAC-DRBG, ecdsa_verify dispatcher) are relocated here as well - # to open headroom in CRYPTO_RESIDENT. The UCI adapter is ~1.7 KB + # to open headroom in CRYPTO_HOT. The UCI adapter is ~1.7 KB # so NET_CODE has ~6.3 KB free after UCI_CODE + LOADER_OVERFLOW; # TLS_CODE ~1.8 KB + CRYPTO_AUX_CODE ~3 KB fits with room to spare. NET_CODE: load = NET_CODE, type = ro, optional = yes; @@ -76,40 +181,125 @@ SEGMENTS { # can split it off into NET_CODE while sha256 stays in NET_BSS_TAIL. # Under UCI both segments flow into NET_CODE identically. CRYPTO_AUX_CODE2: load = NET_CODE, type = ro, optional = yes; - UCI_BSS: load = UCI_BSS_REGION, type = bss, optional = yes; + # UCI_BSS (~293 B) is small and stays in CRYPTO_HOT (16 KB slot + # has room for it alongside code + rodata; keeping it here saves + # space in the 8 KB CRYPTO_COLD_SHADOW for the larger BSS chunks). + UCI_BSS: load = CRYPTO_HOT, type = bss, optional = yes; + # NET_BSS_TAIL is the spill-over BSS region carved from the + # NET_CODE tail. Under the W1 hot/cold split (Worker I) it is + # mostly freed: LIB_NISTCURVES_P256_BSS no longer rides here + # (it moved to CRYPTO_COLD_SHADOW), so this region is effectively + # available for harness scratch. The segment declaration stays + # for any future BSS routing or harness use. + NET_BSS_TAIL: load = NET_BSS_TAIL, type = bss, optional = yes; + # BSS_TAIL hosts the largest c64-https in-tree BSS buffers + # (tls_rec_buf 548 B + cert_buf 1.5 KB). Worker I routed it to + # CRYPTO_COLD_SHADOW ($A000-$BFFF) under the hot/cold split. + BSS_TAIL: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; - # --- Overlay slot: used only by the P-384 external smoke test. --- + # --- Overlay slot. --- # The P-256 / P-384 OVERLAY segments are declared so ld65 has a # valid load address even when nothing is currently linked. The - # production PRG does not embed any overlay image; the P-384 test - # harness DMAs a standalone overlay-p384.bin image into this slot - # at test time (see tools/test_p384_symbols.py). + # production PRG does not always embed an overlay image; under + # EMBED_P256_OVERLAY=1 the P-256 .bin is .incbin'd via + # OVERLAY_BLOB_P256; under USE_OVERLAY_P384_EMBED=1 the P-384 split + # blobs ride OVERLAY_BLOB_SHA384. OVERLAY_P256: load = CRYPTO_OVERLAY, type = ro, optional = yes; OVERLAY_P384: load = CRYPTO_OVERLAY, type = ro, optional = yes; # Phase C.5: sibling c64-x25519 rodata tables (mul38, sqr_lo/hi, # a24_b0..b3 — ~2 KB) AND the sibling's page-aligned BSS buffers # (fe25519_tmp1..4, x25_*, mul_dma_lo/hi/carry — ~1.5 KB) ride - # CRYPTO_OVERLAY under UCI to keep CRYPTO_RESIDENT inside its - # 24 KB budget. CRYPTO_OVERLAY is otherwise unused in the - # production UCI build (only the P-384 external smoke test DMAs - # into it at test time, and that's a harness operation rather - # than a production path). align = $100 so the .align 256 - # directives in data_x25519_{rodata,bss}_raw.s land on real - # page boundaries. + # CRYPTO_OVERLAY under UCI to keep CRYPTO_HOT inside its budget. + # CRYPTO_OVERLAY is otherwise unused in the production UCI build + # unless an overlay-embed flag is set (mutually exclusive across + # USE_X25519_SIBLING / EMBED_P256_OVERLAY / USE_OVERLAY_P384_EMBED). + # align = $100 so the .align 256 directives in + # data_x25519_{rodata,bss}_raw.s land on real page boundaries. X25519_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100; X25519_BSS: load = CRYPTO_OVERLAY, type = bss, optional = yes, align = $100; # --- Resident crypto + TLS code / rodata. --- - CRYPTO_CODE: load = CRYPTO_RESIDENT, type = ro; - CRYPTO_RODATA: load = CRYPTO_RESIDENT, type = ro; - RESIDENT_RODATA: load = CRYPTO_RESIDENT, type = ro, optional = yes; - CRYPTO_INIT_CODE: load = CRYPTO_RESIDENT, type = ro, optional = yes; + CRYPTO_CODE: load = CRYPTO_HOT, type = ro; + CRYPTO_RODATA: load = CRYPTO_HOT, type = ro; + RESIDENT_RODATA: load = CRYPTO_HOT, type = ro, optional = yes; + CRYPTO_INIT_CODE: load = CRYPTO_HOT, type = ro, optional = yes; + + # --- W5: libs/nistcurves segments (c64-lib-contract SPEC §4). --- + # Under the default flag set (EMBED_P256_OVERLAY=0), the P-256 + # verify primitives are always-resident in CRYPTO_HOT — mirrors + # the pre-restructure behavior. Under EMBED_P256_OVERLAY=1 they + # ride the overlay slot; the segment definitions below stay + # routed to CRYPTO_HOT because the embed path operates on a .bin + # image (built by tools/integration/build_nistcurves_p256_bin.sh + # from this same archive), not by re-routing the segments at + # link time. + LIB_NISTCURVES_P256_CODE: load = CRYPTO_HOT, type = ro, optional = yes; + LIB_NISTCURVES_P256_RODATA: load = CRYPTO_HOT, type = ro, optional = yes; + # P-256 BSS is ~1.5 KB (fp256/mod256/points256 scratch + ecdsa256 + # input/output staging + data_p256 working buffers). Stays in + # NET_BSS_TAIL under the W1 hot/cold split — CRYPTO_COLD_SHADOW + # is full carrying the in-tree BSS chunks (BSS / CRYPTO_BSS / + # TABLES_BSS / BSS_TAIL = ~8.1 KB out of the 8 KB budget once + # TABLES_BSS page-alignment is accounted for). NET_BSS_TAIL has + # ~185 B slack after the P-256 claim — adequate for harness use + # via the candidate-fallback path in tools/uci/_memory_policy.py. + LIB_NISTCURVES_P256_BSS: load = NET_BSS_TAIL, type = bss, optional = yes; + # The P-256 archive does not include data_shared.o or mul_8x8.o + # (c64-https provides those — see tools/integration/build_nistcurves_p256.sh), + # so the LIB_NISTCURVES_BSS / _TABLES / _MUL_CODE segments are + # declared `optional = yes` for forward compatibility but receive + # zero bytes today. + LIB_NISTCURVES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + LIB_NISTCURVES_TABLES: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes, align = $100; + LIB_NISTCURVES_MUL_CODE: load = CRYPTO_HOT, type = ro, optional = yes; + # libs/nistcurves P-384 segments — routed via the OVERLAY_P384_CURVE + # bin staging (DMA'd to REU bank 7 at boot, paged into the live + # overlay slot on a P-384 handshake). Mirrors the pre-contract + # OVERLAY_P384_CURVE segment name in the old build_nistcurves_p384.sh + # output. The library now emits the new names natively. + LIB_NISTCURVES_P384_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_P384_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_P384_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + LIB_NISTCURVES_P384_DATA_BSS: load = CRYPTO_COLD_SHADOW, type = rw, optional = yes; + # libs/nistcurves SHA-384 segments — routed via the OVERLAY_P384_SHA384 + # bin staging (DMA'd to REU bank 6 at boot). + LIB_NISTCURVES_SHA384_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_TABLES: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100; + LIB_NISTCURVES_SHA384_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + + # --- Resident BSS — routed to CRYPTO_COLD_SHADOW under the W1 + # hot/cold split (Worker I). The BSS / CRYPTO_BSS segments are + # the catch-all for in-tree c64-https state declarations + # (src/data.s); landing them in the $A000-$BFFF banked-on RAM + # slice frees the 16 KB CRYPTO_HOT region for code + rodata + # (which MUST stay below $A000 because boot zeros that span as + # zero-init BSS). + BSS: load = CRYPTO_COLD_SHADOW, type = bss; + CRYPTO_BSS: load = CRYPTO_COLD_SHADOW, type = bss; + # TABLES_BSS pins the 1.5 KB sqtab/mul_dma tables on a page + # boundary. Page alignment is load-bearing for the no-page-penalty + # `lda abs,Y` addressing in the hot-path multiply routines. + TABLES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, align = $100; - # --- Resident BSS. --- - BSS: load = CRYPTO_RESIDENT, type = bss; - CRYPTO_BSS: load = CRYPTO_RESIDENT, type = bss; - TABLES_BSS: load = CRYPTO_RESIDENT, type = bss, align = $100; + # 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; - TCP_RECV_BUF: load = TCP_BUF, type = bss, optional = yes; + # W3: P-256 verify overlay blob. Optional / off by default; embedded + # when the Makefile flag EMBED_P256_OVERLAY=1 is set (which causes + # src/crypto/shared/p256_overlay_blobs.s to .incbin the .bin file + # under USE_OVERLAY_P256_EMBED). Shares the CRYPTO_OVERLAY slot + # with OVERLAY_BLOB_SHA384 at PRG-load time -- mutually exclusive: + # the Makefile disables P-384 SHA embedding when EMBED_P256_OVERLAY=1. + OVERLAY_BLOB_P256: load = CRYPTO_OVERLAY, type = ro, optional = yes; } diff --git a/cfg/p256-overlay-verify.cfg b/cfg/p256-overlay-verify.cfg new file mode 100644 index 0000000..baaf571 --- /dev/null +++ b/cfg/p256-overlay-verify.cfg @@ -0,0 +1,54 @@ +# cfg/p256-overlay-verify.cfg -- ld65 config for the P-256 verify overlay +# image (W3 library-ingestion architecture). +# +# Mirrors cfg/p384-overlay-{sha384,curve}.cfg. Produces a padded .bin +# image that the boot stash (reu_p384_overlay_init in src/boot.s) loads +# into REU bank 2 slot $22100 (REU_OVERLAY_P256_VERIFY). Subsequent +# `crypto_swap_to_p256_verify` calls DMA the bytes back into the live +# CRYPTO_OVERLAY slot at $4200-$5FFF. +# +# The DATA / BSS exports stay at $C000 (matches the standalone P-384 +# overlay cfgs' RESIDENT layout). Under c64-https's main UCI cfg these +# RW buffers live in CRYPTO_BSS at $A000+; for the overlay-image link +# we just need stable addresses to satisfy linker references -- the +# .bin itself contains only the OVERLAY_P256_VERIFY segment bytes. +# +# Layout matches the live UCI CRYPTO_OVERLAY base ($4200) and size +# ($1E00 = 7,680 B / 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; + + # W5: new library segment names (c64-lib-contract / libs/nistcurves + # cfa9085+) are the canonical set under the contract. The legacy + # OVERLAY_P256_VERIFY alias + CRYPTO_CODE/RODATA aliases stay for + # backward compat with any in-tree force-link path that hasn't + # migrated yet. + OVERLAY_P256_VERIFY: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_P256_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_P256_RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + CRYPTO_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + CRYPTO_RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + CODE: load = OVERLAY_REGION, type = ro, optional = yes; + + # 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; + CRYPTO_BSS: load = RESIDENT, type = bss, optional = yes; + LIB_NISTCURVES_P256_BSS: load = RESIDENT, type = bss, optional = yes; + ZEROPAGE_BSS: load = ZP, type = bss, optional = yes; +} diff --git a/cfg/p384-overlay-curve.cfg b/cfg/p384-overlay-curve.cfg new file mode 100644 index 0000000..db98c9d --- /dev/null +++ b/cfg/p384-overlay-curve.cfg @@ -0,0 +1,51 @@ +# 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; + + # W5: new library segment names (c64-lib-contract / libs/nistcurves + # cfa9085+). The legacy OVERLAY_P384_CURVE alias remains for the + # in-staging shim (build_nistcurves_p384.sh emits + # ec_scalar_mul_384_shim.s with this segment name). + # LIB_NISTCURVES_P384_* is the canonical set under the contract. + OVERLAY_P384_CURVE: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_P384_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_P384_RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + + # 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; + LIB_NISTCURVES_P384_BSS: load = RESIDENT, type = bss, optional = yes; + LIB_NISTCURVES_P384_DATA_BSS: load = RESIDENT, type = rw, optional = yes; +} diff --git a/cfg/p384-overlay-sha384.cfg b/cfg/p384-overlay-sha384.cfg new file mode 100644 index 0000000..19b3d6c --- /dev/null +++ b/cfg/p384-overlay-sha384.cfg @@ -0,0 +1,51 @@ +# 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; + + # W5: new library segment names (c64-lib-contract / libs/nistcurves + # cfa9085+). The legacy OVERLAY_P384_SHA384 alias remains for any + # in-tree force-link stub that still uses it; LIB_NISTCURVES_SHA384_* + # is the canonical set under the contract. + OVERLAY_P384_SHA384: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_TABLES: load = OVERLAY_REGION, type = ro, optional = yes, align = $100; + + # 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; + LIB_NISTCURVES_SHA384_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/cfg/x25519-overlay-scalarmult.cfg b/cfg/x25519-overlay-scalarmult.cfg new file mode 100644 index 0000000..d2a0110 --- /dev/null +++ b/cfg/x25519-overlay-scalarmult.cfg @@ -0,0 +1,56 @@ +# cfg/x25519-overlay-scalarmult.cfg -- ld65 config for the X25519 +# scalarmult overlay .bin image (W3 library-ingestion architecture). +# +# Mirrors cfg/p256-overlay-verify.cfg + cfg/p384-overlay-{sha384,curve}.cfg. +# Produces a 7,680 B padded .bin image of the c64-x25519 sibling's code +# + rodata + bss, suitable for embedding via .incbin into the PRG +# (planned W1 wiring) or for documentation / CI artefact use. +# +# Today the X25519 sibling rodata + bss is linker-placed into +# CRYPTO_OVERLAY at PRG-load time under USE_X25519_SIBLING=1 (see +# cfg/c64-https-uci.cfg's X25519_RODATA / X25519_BSS segments). This +# .bin is the same byte image in a standalone linker invocation -- a +# CI-friendly artefact that can be diffed against the in-PRG slot +# bytes for parity checking. +# +# Layout matches the live UCI CRYPTO_OVERLAY base ($4200) and size +# ($1E00 = 7,680 B / 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; + + # The sibling archive uses CRYPTO_CODE for code and + # X25519_RODATA / X25519_BSS for its tables (set by + # tools/integration/build_x25519.sh). Route the code + rodata + # into the overlay region so the .bin is the byte image the main + # PRG's live slot will hold. X25519_BSS is routed to RESIDENT + # rather than OVERLAY_REGION: BSS bytes are zero-initialized at + # runtime by reu_clear_wide / x25519_init, so the .bin doesn't + # need them. Including BSS in the overlay region overflows by + # ~512 B (combined CRYPTO_CODE + X25519_RODATA + X25519_BSS just + # barely doesn't fit at $1E00). + CRYPTO_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + X25519_RODATA: load = OVERLAY_REGION, type = ro, optional = yes, align = $100; + RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + CODE: load = OVERLAY_REGION, type = ro, optional = yes; + + # Resident RW buffers (none used by x25519 archive today) -- pinned + # at $C000 just so any future symbol references resolve. + X25519_BSS: load = RESIDENT, type = bss, optional = yes, align = $100; + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; + CRYPTO_BSS: load = RESIDENT, type = bss, optional = yes; + TABLES_BSS: load = RESIDENT, type = bss, optional = yes; +} diff --git a/libs/nistcurves b/libs/nistcurves index 19f95d7..b67de54 160000 --- a/libs/nistcurves +++ b/libs/nistcurves @@ -1 +1 @@ -Subproject commit 19f95d792587f1c4e04f13f483cdeca244b502a9 +Subproject commit b67de54520bb4193b073d9703d5c85adcf29f505 diff --git a/libs/x25519 b/libs/x25519 index 47c0ad2..95fdd70 160000 --- a/libs/x25519 +++ b/libs/x25519 @@ -1 +1 @@ -Subproject commit 47c0ad21a57ae443632f5e7689cbe9f3de98460e +Subproject commit 95fdd705b0f7d780cada3dee08158084d327c3f9 diff --git a/src/boot.s b/src/boot.s index 079b953..172ebb3 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,56 @@ .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 + + ; ---- imports: W3 embedded P-256 verify overlay blob anchor ---- + ; Mirror of the P-384 pattern above. Resolved by + ; src/crypto/shared/p256_overlay_blobs.s when + ; USE_OVERLAY_P256_EMBED is on (gated from the top-level Makefile + ; by EMBED_P256_OVERLAY=1). Mutually exclusive with + ; USE_OVERLAY_P384_EMBED at the cfg level -- both target the + ; CRYPTO_OVERLAY slot at PRG-load time, so the Makefile turns + ; P-384 embedding off when EMBED_P256_OVERLAY=1. + .ifdef USE_OVERLAY_P256_EMBED + .import p256_overlay_verify_blob + ; Same REU layout include rationale as the P-384 block above + ; (.ifndef-guarded; idempotent). + .include "reu_layout.inc" + .endif + + ; ---- imports: W3 X25519 sibling slot stash ---- + ; Under USE_X25519_SIBLING=1, the sibling's X25519_RODATA + + ; X25519_BSS segments load into CRYPTO_OVERLAY at PRG-load time. + ; Boot stashes those slot bytes (i.e. the sibling's running + ; code+rodata image) to REU bank 3 so a later + ; `crypto_swap_to_x25519` can refresh the slot from there after + ; a P-256 / P-384 swap has overwritten it. No new .incbin + ; needed -- the linker already pinned the bytes at $4200. + .ifdef USE_X25519_SIBLING + .import __CRYPTO_OVERLAY_START__ + ; Same REU layout include rationale as above. + .include "reu_layout.inc" + .endif + ; ============================================================================= ; BASIC stub: 10 SYS 2061 ; Loaded at $0801 via EXEHDR segment (first bytes of LOADER region). @@ -217,6 +270,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 +814,207 @@ 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 + +; ----------------------------------------------------------------------------- +; W3: P-256 verify image stash (Makefile EMBED_P256_OVERLAY=1). +; +; When `USE_OVERLAY_P256_EMBED` is defined the cfg routes +; OVERLAY_BLOB_P256 into CRYPTO_OVERLAY at PRG-load time (mutually +; exclusive with OVERLAY_BLOB_SHA384 -- the Makefile turns +; USE_OVERLAY_P384_EMBED off when EMBED_P256_OVERLAY=1). Boot DMAs the +; slot bytes to REU_OVERLAY_P256_VERIFY (bank 2, $22100) so a later +; `crypto_swap_to_p256_verify` can refresh the slot. Same SEI window +; + ~8 ms cost as the P-384 stash above; STASH (C64->REU) command +; $90. +; ----------------------------------------------------------------------------- +.ifdef USE_OVERLAY_P256_EMBED + php + sei + lda #p256_overlay_verify_blob + sta reu_c64_hi + lda #REU_OVERLAY_P256_VERIFY + sta reu_reu_hi + lda #^REU_OVERLAY_P256_VERIFY + sta reu_reu_bank + lda #OVERLAY_SIZE + sta reu_len_hi + lda #0 + sta reu_addr_ctrl + lda #$90 ; execute + STASH (C64->REU) + sta reu_command + plp +.endif ; .ifdef USE_OVERLAY_P256_EMBED + +; ----------------------------------------------------------------------------- +; W3: X25519 sibling slot stash (USE_X25519_SIBLING=1). +; +; The sibling's X25519_RODATA + X25519_BSS segments load into +; CRYPTO_OVERLAY at PRG-load time (see cfg/c64-https-uci.cfg). Boot +; STASHes the slot bytes to REU_OVERLAY_X25519 (bank 3, $30000) so a +; later `crypto_swap_to_x25519` can refresh the slot after a P-256 / +; P-384 swap has overwritten it. Same SEI window + ~8 ms cost as the +; P-256 stash above. No .incbin -- the linker already pinned the +; sibling image into CRYPTO_OVERLAY. +; +; NB: this stashes the *initialized* portion of CRYPTO_OVERLAY (the +; sibling's rodata tables) plus any zero-init BSS bytes that fall in +; the same span. The BSS is fine to stash-and-restore because the +; sibling's `reu_mul_init` rebuilds the volatile mul tables anyway; +; the rodata round-trip is the load-bearing part. +; ----------------------------------------------------------------------------- +.ifdef USE_X25519_SIBLING + php + sei + lda #<__CRYPTO_OVERLAY_START__ + sta reu_c64_lo + lda #>__CRYPTO_OVERLAY_START__ + sta reu_c64_hi + lda #REU_OVERLAY_X25519 + sta reu_reu_hi + lda #^REU_OVERLAY_X25519 + sta reu_reu_bank + lda #OVERLAY_SIZE + sta reu_len_hi + lda #0 + sta reu_addr_ctrl + lda #$90 ; execute + STASH (C64->REU) + sta reu_command + plp +.endif ; .ifdef USE_X25519_SIBLING + 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..c938d0a 100644 --- a/src/crypto/shared/crypto_swap.s +++ b/src/crypto/shared/crypto_swap.s @@ -1,104 +1,385 @@ ; ============================================================================= -; 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: +; +; 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.) +; +; (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 ; -; `current_overlay`: 1 byte in CRYPTO_BSS (SHADOW_BSS-resident). -; 0 = none (uninitialized / swap_none) -; 2 = p256 -; 3 = p384 +; 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. ; -; `CRYPTO_OVERLAY_START` is defined by the linker (cfg `MEMORY { }` -; `define = yes` on the CRYPTO_OVERLAY region — see cfg/c64-https-*.cfg). +; 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" + .include "overlay_ids.inc" ; OV_* constants (W3) - .export crypto_swap_to_p256 - .export crypto_swap_to_p384 + .export crypto_swap_to_x25519_sibling + .export crypto_swap_to_x25519 ; W3 new + .export crypto_swap_to_p256_verify ; W3 new + .export crypto_swap_to_p384_sha384 + .export crypto_swap_to_p384_curve .export crypto_swap_none + .export crypto_overlay_call ; W3 new .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 REU_OVERLAY_P256_VERIFY ; W3 new + .export REU_OVERLAY_X25519 ; W3 new .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 -- canonical values live in `overlay_ids.inc` (W3). This +; file's local equates above (OV_NONE, OV_X25519_SIBLING, OV_P384_SHA384, +; OV_P384_CURVE) were folded into the include; the .ifndef-guarded +; definitions there are the single source of truth. `OV_P256_VERIFY` +; (id 2) and `OV_X25519` (id 3) are the new W3 additions. +; +; NB: the architecture plan's "OV_X25519=3, OV_P256_VERIFY=4" sketch +; assumed OV_P384_SHA384/CURVE were 1/2 — they are actually 4/5 (Phase 3 +; intentionally skipped 2/3 for headroom). The W3 IDs slot into the +; reserved gap so existing call sites that compare `current_overlay` +; against OV_P384_* see no renumber. ; ----------------------------------------------------------------------------- -OV_NONE = 0 -OV_P256 = 2 -OV_P384 = 3 + .export OV_NONE + .export OV_X25519_SIBLING + .export OV_P256_VERIFY ; W3 new + .export OV_X25519 ; W3 new + .export OV_P384_SHA384 + .export OV_P384_CURVE ; 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 +swap_done_fast: + 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_P384_SHA384 + ldy #^REU_OVERLAY_P384_SHA384 + jsr do_swap + pla + sta current_overlay + rts + +; ----------------------------------------------------------------------------- +; 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_CURVE + ldy #^REU_OVERLAY_P384_CURVE + jsr do_swap + pla + sta current_overlay + rts + +; ----------------------------------------------------------------------------- +; crypto_swap_to_x25519 -- DMA X25519 sibling image from REU bank 3 +; (REU_OVERLAY_X25519) into the live CRYPTO_OVERLAY slot. Idempotent. +; +; Distinct from `crypto_swap_to_x25519_sibling` above: that entry point +; is the legacy state-only marker (used when the linker placed the +; X25519 sibling rodata into the slot at PRG load time). This new +; entry point DOES the DMA from REU, so it can be called after a +; P-384 or P-256 swap has overwritten the slot. Boot-time stash +; happens in `reu_p384_overlay_init` (boot.s). +; +; Idempotent: re-entering with OV_X25519 already current is a single +; byte compare + rts (no DMA). NB: arrival from the legacy +; OV_X25519_SIBLING state still triggers a DMA (the slot contents are +; assumed identical, but the marker IDs differ and the safe path is +; to refresh from REU rather than to assume the linker-placed bytes +; were not later overwritten). +; ----------------------------------------------------------------------------- +crypto_swap_to_x25519: + lda #OV_X25519 cmp current_overlay beq swap_done_fast pha - lda #REU_OVERLAY_P256 - ldy #^REU_OVERLAY_P256 + lda #REU_OVERLAY_X25519 + ldy #^REU_OVERLAY_X25519 jsr do_swap pla sta current_overlay rts -crypto_swap_to_p384: - lda #OV_P384 +; ----------------------------------------------------------------------------- +; crypto_swap_to_p256_verify -- DMA P-256 verify image (sibling +; libs/nistcurves verify-only minimal subset) from REU_OVERLAY_P256_VERIFY +; (bank 2 slot $22100) into the live CRYPTO_OVERLAY slot. Idempotent. +; +; W3 new. Today the P-256 verify primitives are always-resident in +; CRYPTO_RESIDENT (Phase C.4 sibling integration); W1 will later move +; them into the cold-path overlay slot. Until that wiring, this +; entry point is callable but unused by TLS call sites. +; ----------------------------------------------------------------------------- +crypto_swap_to_p256_verify: + lda #OV_P256_VERIFY cmp current_overlay beq swap_done_fast pha - lda #REU_OVERLAY_P384 - ldy #^REU_OVERLAY_P384 + lda #REU_OVERLAY_P256_VERIFY + ldy #^REU_OVERLAY_P256_VERIFY 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 rts -swap_done_fast: +; ----------------------------------------------------------------------------- +; crypto_overlay_call -- swap-then-call convenience wrapper (W3 new). +; +; Performs an idempotent swap to the requested overlay (no-op if it is +; already current) and then JSRs to (slot_base + offset). Designed +; so a TLS call site can do: +; +; lda #OV_P256_VERIFY +; ldx #<(ecdsa_verify_256 - __CRYPTO_OVERLAY_START__) +; ldy #>(ecdsa_verify_256 - __CRYPTO_OVERLAY_START__) +; jsr crypto_overlay_call +; +; instead of two separate jsr's (swap, then jsr abs). The dispatcher +; is responsible for ensuring fn_offset+slot_base is a valid entry +; point — there is no symbol-table check here. +; +; Inputs: +; A = overlay id (OV_*) +; X = fn offset low byte (relative to __CRYPTO_OVERLAY_START__) +; Y = fn offset high byte +; +; Behaviour: +; * Stashes X / Y / A in self-modifying-code (SMC) slots before +; branching to the swap helper so the swap is free to clobber +; all three registers. +; * Dispatches on A to the matching crypto_swap_to_ entry point. +; Unknown IDs return immediately without swapping or JSRing +; (the call is a no-op; current_overlay is left untouched). +; * After the swap returns, indirect-jsrs through the SMC'd absolute +; address (slot_base + offset). +; * The callee's return value (C flag + A/X/Y) passes through +; unchanged to the caller. +; +; ABI mirror: documented identically in overlay_ids.inc usage notes. +; ----------------------------------------------------------------------------- +crypto_overlay_call: + ; Save the overlay id for the dispatch below. txa/tya in the + ; pointer math below clobbers A, so we have to stash it first. + pha + + ; Compute slot_base + (Y:X) and stash into the indirect JSR slot. + clc + txa + adc #<__CRYPTO_OVERLAY_START__ + sta @call_target+1 + tya + adc #>__CRYPTO_OVERLAY_START__ + sta @call_target+2 + + ; Recover the overlay id and dispatch. Order: most-frequent + ; first (X25519 + P-256 verify will be the W1 hot pair; the + ; P-384 pair is the legacy / 0x0503-only path). + pla + cmp #OV_X25519 + bne @not_x25519 + jsr crypto_swap_to_x25519 + jmp @call_target +@not_x25519: + cmp #OV_P256_VERIFY + bne @not_p256 + jsr crypto_swap_to_p256_verify + jmp @call_target +@not_p256: + cmp #OV_P384_SHA384 + bne @not_sha384 + jsr crypto_swap_to_p384_sha384 + jmp @call_target +@not_sha384: + cmp #OV_P384_CURVE + bne @not_curve + jsr crypto_swap_to_p384_curve + jmp @call_target +@not_curve: + ; Unknown overlay id -- no-op (caller error). Leaves + ; current_overlay untouched and returns with C=1 to surface + ; the misuse. + sec + rts + +@call_target: + jsr $0000 ; absolute address SMC'd above 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 +391,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 +419,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/overlay_ids.inc b/src/crypto/shared/overlay_ids.inc new file mode 100644 index 0000000..8c57ff0 --- /dev/null +++ b/src/crypto/shared/overlay_ids.inc @@ -0,0 +1,57 @@ +; ============================================================================= +; overlay_ids.inc -- Canonical overlay-ID constants for the CRYPTO_OVERLAY +; paging slot. Single source of truth shared between the dispatcher +; (crypto_swap.s) and any caller that compares against `current_overlay` +; or passes an ID to `crypto_overlay_call`. +; +; The IDs are opaque to the swap engine itself -- they exist purely so +; idempotent callers can short-circuit a no-op swap by comparing against +; `current_overlay`. Numbering rules: +; * OV_NONE = 0 is the post-boot / undefined-slot sentinel. +; * Distinct non-zero values per overlay. Gaps are tolerated; new +; overlays should pick the lowest unused ID rather than renumber +; existing ones (forward compatibility across call-sites). +; * IDs are .ifndef-guarded so this file is safe to include twice in a +; single translation unit (e.g. boot.s + crypto_swap.s pulling in +; the same header). +; +; Phase reference: +; 1, 4, 5 -- pre-existing (Phase 3 dual-overlay edition). +; 2, 3 -- new entry points added by the library-ingestion +; architecture W3 work (X25519 + P-256 verify real DMA). +; ============================================================================= + +.ifndef OV_NONE +OV_NONE = 0 +.endif + +; OV_X25519_SIBLING (legacy marker) -- predates the W3 real DMA path. +; Retained because the existing `crypto_swap_to_x25519_sibling` entry +; point in crypto_swap.s still sets it (state-only, no DMA). New +; callers that DMA the sibling image from REU use OV_X25519 instead. +.ifndef OV_X25519_SIBLING +OV_X25519_SIBLING = 1 +.endif + +; OV_P256_VERIFY -- W3 new. Set by `crypto_swap_to_p256_verify` +; after DMA'ing the P-256 verify image (libs/nistcurves' P-256 verify +; minimal-subset) from REU_OVERLAY_P256_VERIFY into the live slot. +.ifndef OV_P256_VERIFY +OV_P256_VERIFY = 2 +.endif + +; OV_X25519 -- W3 new. Set by `crypto_swap_to_x25519` after DMA'ing +; the X25519 sibling image (code + rodata) from REU_OVERLAY_X25519 +; into the live slot. Distinct from OV_X25519_SIBLING (which is the +; state-only marker for the linker-placed PRG-load-time image). +.ifndef OV_X25519 +OV_X25519 = 3 +.endif + +; OV_P384_SHA384 / OV_P384_CURVE -- pre-existing (Phase 3). +.ifndef OV_P384_SHA384 +OV_P384_SHA384 = 4 +.endif +.ifndef OV_P384_CURVE +OV_P384_CURVE = 5 +.endif diff --git a/src/crypto/shared/p256_overlay_blobs.s b/src/crypto/shared/p256_overlay_blobs.s new file mode 100644 index 0000000..cc618a5 --- /dev/null +++ b/src/crypto/shared/p256_overlay_blobs.s @@ -0,0 +1,48 @@ +; ============================================================================= +; p256_overlay_blobs.s -- Embedded P-256 verify overlay image (W3). +; +; Mirror of p384_overlay_blobs.s for the P-256 verify minimal-subset +; overlay image. Embedded when the top-level Makefile flag +; EMBED_P256_OVERLAY=1 is set (which propagates to ca65 as +; -D USE_OVERLAY_P256_EMBED=1). +; +; Mutually exclusive with USE_OVERLAY_P384_EMBED at the cfg level: both +; target the CRYPTO_OVERLAY slot at $4200 at PRG-load time, so the +; Makefile turns USE_OVERLAY_P384_EMBED off when EMBED_P256_OVERLAY=1. +; If both were set the linker would overflow the 7,680 B slot. +; +; Output: +; build/lib/nistcurves-p256-verify.bin (7,680 B padded) -- staged into +; CRYPTO_OVERLAY at PRG load time, then DMA'd by +; reu_p384_overlay_init (boot.s) to REU bank 2 slot $22100. After +; the stash, future calls to `crypto_swap_to_p256_verify` DMA the +; image back into the live slot. +; +; Inert when USE_OVERLAY_P256_EMBED is undefined (default build): +; the segment is left empty (`optional = yes` in the cfg) and boot +; skips the DMA. This is the default state -- the P-256 verify +; primitives stay always-resident in CRYPTO_RESIDENT until W1 wires +; them into a cold-path overlay swap. +; ============================================================================= + + .setcpu "6502" + +.ifdef USE_OVERLAY_P256_EMBED + + .export p256_overlay_verify_blob + .export p256_overlay_verify_blob_end + +; ----------------------------------------------------------------------------- +; P-256 verify overlay image (REU_OVERLAY_P256_VERIFY source) +; +; Loads into the live CRYPTO_OVERLAY slot at $4200-$5FFF at PRG load +; time, then boot DMAs it to REU bank 2 slot $22100. 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_P256" +p256_overlay_verify_blob: + .incbin "../../../build/lib/nistcurves-p256-verify.bin" +p256_overlay_verify_blob_end: + +.endif ; .ifdef USE_OVERLAY_P256_EMBED 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..6a2d975 100644 --- a/src/crypto/shared/reu_layout.inc +++ b/src/crypto/shared/reu_layout.inc @@ -38,16 +38,89 @@ REU_OVERLAY_P256 = $22100 REU_OVERLAY_P384 = $24100 .endif +; --- W3: P-256 verify overlay image (library-ingestion architecture §2.5) --- +; The architecture plan §2.5 proposes "bank 2 at offset $1F00" for this +; image. Offset $1F00 falls inside the slot-1 span ($20100-$22100), so +; we reuse the existing REU_OVERLAY_P256 slot at $22100 instead: it is +; the second 8 KB-aligned slot in bank 2, it is already 8 KB-padded +; (matching OVERLAY_SIZE), and it has been declared since Phase 1.5 +; without a current production consumer (the P-256 sibling is always- +; resident, no live overlay swap). This avoids carving a second slot +; out of bank 2. The alias is `.ifndef`-guarded; downstream override +; via `--asm-define REU_OVERLAY_P256_VERIFY=$xxxxx` remains available. +.ifndef REU_OVERLAY_P256_VERIFY +REU_OVERLAY_P256_VERIFY = REU_OVERLAY_P256 +.endif + +; --- W3: X25519 sibling overlay image (library-ingestion architecture §2.5) --- +; Architecture plan §2.5: "x25519 sibling tables get bank 3 (resolves +; the c64-x25519 #43 collision concern)." Bank 3 ($30000) was nominally +; reserved for P-256 fixed-base precompute, but the TLS path uses +; ec_scalar_mul_var (variable-base) only — the reservation has no +; runtime consumer. Using bank 3 for the X25519 sibling code+rodata +; image keeps the production overlay store (bank 2) free for the +; existing P-384 SHA384/Curve halves at $22100/$24100 and the new +; P-256 verify slot above. +; +; This is the IMAGE address (DMA source for `crypto_swap_to_x25519`) +; — the sibling's running mul/doubled/17-bit-carry tables continue to +; live in their own REU homes (currently banks 0-2 under v0.4.0). +; When c64-x25519 #43 lands and the sibling's table bases become +; `--asm-define`-able, the consumer-side override will relocate the +; sibling tables (e.g. to bank 4+) so they don't collide with the +; bank-3 image staging. Until then, USE_X25519_SIBLING=1 + a real +; `crypto_swap_to_x25519` overwrite of CRYPTO_OVERLAY is gated behind +; W1's hot/cold partition (no current TLS caller). +.ifndef REU_OVERLAY_X25519 +REU_OVERLAY_X25519 = $30000 +.endif + ; --- P-256 precompute (1 bank) --- +; NB: REU_OVERLAY_X25519 above lives at the same bank ($30000). Both +; are reservations -- the P-256 precompute is unused at runtime (the +; TLS dispatcher uses variable-base scalar mul, no Lim-Lee table), +; and the X25519 sibling image is similarly cold until W1 wires a +; real swap into TLS. When either becomes hot, the cfg has to pick +; one and relocate the other. .ifndef REU_P256_PRECOMPUTE_BASE 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 +139,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..e64c246 100644 --- a/src/data.s +++ b/src/data.s @@ -237,7 +237,15 @@ tls_read_seq: .res 8 tls_rec_header: .res 5 tls_rec_type: .res 1 tls_rec_len: .res 2 + +; W1 partial: tls_rec_buf (548 B) lives in BSS_TAIL — a separate BSS +; segment that the UCI cfg routes to the NET_BSS_TAIL region (the +; reclaimed tail of NET_CODE). Keeps the largest single c64-https BSS +; entry out of the CRYPTO_HOT overflow path. ip65 cfg aliases +; BSS_TAIL to BSS so the relocation is invisible there. +.segment "BSS_TAIL" tls_rec_buf: .res 548 +.segment "BSS" ; AEAD nonce construction .export tls_nonce @@ -547,6 +555,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 +572,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/der_decode.s b/src/der_decode.s index 00deda4..f7a4581 100644 --- a/src/der_decode.s +++ b/src/der_decode.s @@ -573,5 +573,12 @@ cert_sig_r: .res 48 ; signature r component (max 48 for P-38 cert_sig_s: .res 48 ; signature s component (max 48 for P-384) cert_sig_len: .res 1 ; 32 (P-256) or 48 (P-384) cert_curve_id: .res 1 ; 0=P-256, 1=P-384 +; W1 partial: cert_buf (1.5 KB) lives in BSS_TAIL — the same offload +; region as src/data.s::tls_rec_buf — routed to NET_BSS_TAIL under +; UCI. Keeps the cert parse staging out of the CRYPTO_HOT overflow +; path. ip65 cfg aliases BSS_TAIL to BSS so the relocation is +; invisible there. +.segment "BSS_TAIL" cert_buf: .res 1536 ; certificate DER buffer +.segment "BSS" cert_buf_len: .res 2 ; certificate length diff --git a/src/exports.s b/src/exports.s index 93dcc75..71e59a6 100644 --- a/src/exports.s +++ b/src/exports.s @@ -32,9 +32,15 @@ .export zp_ptr ; Phase C.4: c64-nist-curves fp256.s references a handful of REU DMA -; registers via `.import` (it was written to live in a linker-visible -; symbol world). Promote the numeric equates from constants.inc so ld65 -; can resolve the sibling's imports. -.export reu_reu_hi -.export reu_reu_bank -.export reu_command +; registers via `.import`. Pre-contract, the sibling shipped no +; constants.o, so c64-https had to promote its in-tree equates from +; constants.inc to satisfy the link. Under c64-lib-contract (libs/ +; nistcurves cfa9085+), the library publishes constants.o which +; `.export`s the same REU register equates. Those declarations now +; satisfy the .imports — remove the c64-https-side .export to avoid +; ld65 "Duplicate external identifier" errors. +; +; Code in c64-https that references these symbols through +; constants.inc continues to work because constants.inc still defines +; them as local equates; the library's .export only becomes +; load-bearing for sources that .import them. 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..558faa8 100755 --- a/tools/integration/build_nistcurves_p256.sh +++ b/tools/integration/build_nistcurves_p256.sh @@ -3,48 +3,45 @@ # tools/integration/build_nistcurves_p256.sh - Build c64-nist-curves P-256 # ECDSA verify primitives as a resident .a archive linked into the main PRG. # -# Phase C.4 of the sibling-lib integration. Produces build/lib/nistcurves-p256.a -# containing the P-256 field arithmetic, modular arithmetic, variable-base -# scalar multiply, Jacobian->affine conversion, and packaged ECDSA verify -# (ecdsa_verify_256). No overlay mechanism; all code always-resident. +# Phase C.4 + W5 (library-ingestion architecture). Under the c64-lib-contract +# (libs/nistcurves v0.3.0), the upstream library publishes a +# `make lib-p256-verify` build target that produces a minimal-subset archive +# carrying exactly the symbols needed for variable-base P-256 verify. This +# script delegates the heavy lifting to `make -C libs/nistcurves`, then +# performs two adjustments before placing the result at the location the +# top-level Makefile expects: # -# Excluded (to fit the budget + avoid REU precompute): -# - ec_scalar_mul - Lim-Lee fixed-base comb. Needs a 16 KB REU bank-2 -# precompute table built by ec_precompute_256 at boot. -# Replaced by a shim in src/crypto/ecdsa_verify.s that -# copies G into ec_base_x/y and tail-calls -# ec_scalar_mul_var. The dispatcher is the ONLY caller -# of ecdsa_verify_256, so the shim covers the sole -# in-PRG use of ec_scalar_mul. -# - ec_precompute_256 - builds the Lim-Lee anchor table into REU bank 2. -# Only useful with ec_scalar_mul. -# - Lim-Lee anchor tables (ec_anchor1_x..ec_anchor8_y, cm_k, ec_aff2g_256_*) -# and all sm256_reu_* REU DMA helpers that service them. -# - All P-384 data/arith (fp384_*, ec384_*, ecdsa384_*, cm_k_384, anchors). -# Lives in nistcurves-p384.a under the separate Phase C.3b smoke test. -# - Shared mul infrastructure (mul_cached_a, mul_src2_buf, mul_dma_lo/hi, -# mul_8x8, sqtab_init, sqtab_lo/hi, poly_prod_lo/hi, reu_fetch_mul_row) - -# the in-tree src/data.s + src/crypto/poly1305.s + src/boot.s already -# provide these and they are shared across fe25519 + P-256 via the REU -# DMA row-fetch pipeline. Adding the sibling's copies would collide. -# - ecdsa_inputs_256, ecdsa_result_256 test-driver scratch - only used by -# the nist-curves PRG's own test harness. +# 1. Rebuild `zp_config.o` with c64-https's ZP-slot overrides (the upstream +# defaults collide with c64-https's canonical map on three slots: +# zp_ptr2, fp_mul_i, fp_mul_j). The library's zp_config.s `.ifndef`- +# guards every slot, so an override-built version replaces the +# upstream default cleanly. # -# The script stages the sibling's .s files in build/lib/nistcurves_p256_staging/, -# applies sed patches to strip Lim-Lee bodies + provide a minimal P-256-only -# data.s, and assembles with canonical ZP equates passed via -D. +# 2. Drop `mul_8x8.o` and `data_shared.o` from the archive. c64-https's +# in-tree `src/crypto/poly1305.s` and `src/data.s` already export the +# same symbols (mul_8x8, sqtab_init, poly_prod_lo/hi, mul_cached_a, +# mul_src2_buf, mul_dma_lo/hi). Including the library's copies would +# cause ld65 duplicate-symbol errors. # -# Usage (from top-level Makefile): -# bash tools/integration/build_nistcurves_p256.sh -# Produces: -# build/lib/nistcurves-p256.a -# build/lib/nistcurves-p256.sizes.txt (per-source byte counts) +# Pre-contract this script was ~400 lines of `sed -i ''` strips and +# heredoc'd hand-extracted curve/data files. Post-contract the +# `make lib-p256-verify` target replaces all of that — no segment +# rewriting (upstream now emits `LIB_NISTCURVES_P256_*` segments by +# convention), no body strips (the lib-p256-verify variant excludes the +# Lim-Lee comb + precompute already), no hand-extracted data heredoc +# (upstream's `data_p256.s` is the canonical RW state list). +# +# Outputs: +# build/lib/nistcurves-p256.a - the consumer-side archive +# build/lib/nistcurves-p256.sizes.txt - per-source byte counts # ============================================================================= set -eo pipefail # --- Paths --- PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -LIB_SRC="$PROJECT_ROOT/libs/nistcurves/src" +LIB_DIR="$PROJECT_ROOT/libs/nistcurves" +LIB_SRC="$LIB_DIR/src" +LIB_BUILD="$LIB_DIR/build" STAGING="$PROJECT_ROOT/build/lib/nistcurves_p256_staging" OUT_DIR="$PROJECT_ROOT/build/lib" ARCHIVE="$OUT_DIR/nistcurves-p256.a" @@ -53,326 +50,97 @@ SIZES="$OUT_DIR/nistcurves-p256.sizes.txt" CA65="${CA65:-ca65}" AR65="${AR65:-ar65}" -# --- Canonical ZP defines --- -# Mirrors the P-384 build's -D flag set. zp_ptr2 is relocated into -# $3D-$3E (inside ZP_CRYPTO, otherwise unused) because the sibling's -# default ($fd-$fe) overlaps with c64-https's zp_temp/zp_count used -# by der_decode.s during cert parsing. ecdsa_verify_256 runs AFTER -# DER parsing completes, so the clobber would be fine in practice, -# but the relocation keeps the lifetime isolation explicit. -ZP_DEFINES=( - '-Dproc_port=$01' - '-Dzp_tmp1=$02' - '-Dzp_tmp2=$03' - '-Dzp_ptr1=$fb' - '-Dzp_ptr2=$3d' - '-Dfp_src1=$22' - '-Dfp_src2=$24' - '-Dfp_dst=$26' - '-Dfp_misc=$28' - '-Dfp_carry=$2a' - '-Dfp_loop=$2b' - '-Dfp_mul_i=$39' - '-Dfp_mul_j=$3a' - '-Dec_scalar_ptr=$3b' - '-Dpoly_i=$1a' - '-Dpoly_j=$1b' - '-Dpoly_carry=$1c' - '-Dpoly_tmp=$1d' +# --- ZP-slot overrides (c64-https canonical map) --- +# zp_ptr2 = $3D : library default $fd collides with c64-https zp_temp/zp_count +# used by der_decode.s during cert parsing. +# fp_mul_i = $39, fp_mul_j = $3A : library defaults $2c/$2d collide with +# c64-https fe25519 ZP claim ($2c-$37). $39-$3a is otherwise +# unused inside ZP_CRYPTO. +# Other slots match upstream defaults — see libs/nistcurves/src/zp_config.s. +ZP_OVERRIDES=( + '-D' 'zp_ptr2=$3d' + '-D' 'fp_mul_i=$39' + '-D' 'fp_mul_j=$3a' ) -# --- Stage sources --- -rm -rf "$STAGING" -mkdir -p "$STAGING" - -cp "$LIB_SRC"/constants.s "$STAGING/" -cp "$LIB_SRC"/zp_config.s "$STAGING/" -cp "$LIB_SRC"/fp256.s "$STAGING/fp256_raw.s" -cp "$LIB_SRC"/mod256.s "$STAGING/mod256_raw.s" -cp "$LIB_SRC"/points256.s "$STAGING/points256_raw.s" -cp "$LIB_SRC"/ecdsa256.s "$STAGING/ecdsa256_raw.s" - -# --- Strip points256.s of the Lim-Lee / REU precompute bodies --- -# Lines 762-1458 in the upstream file cover: -# - sm256_reu_stash_affine / sm256_reu_fetch_affine / sm256_calc_offset_64 -# / sm256_reu_restore (REU DMA helpers for bank-2 anchor table) -# - ec_precompute_256 and its internal helpers (load_G_jac, successive-double -# helpers, anchor accumulate) -# - ec_scalar_mul (Lim-Lee 8-way fixed-base comb) and its anchor-loader -# helpers + anchor base-address table + cm_* / sm256_* state vars -# Keeps ec_point_double (line 60-410), ec_point_add (411-761), -# ec_scalar_mul_var (1459-1609), ec_jacobian_to_affine (1610-end). -sed -i '' '762,1467d' "$STAGING/points256_raw.s" - -# Strip exports + imports that only the removed bodies used. -sed -i '' '/^\.export ec_precompute_256, ec_scalar_mul, ec_scalar_mul_var$/c\ -.export ec_scalar_mul_var' "$STAGING/points256_raw.s" -# Anchor + Lim-Lee state imports -sed -i '' '/^\.import ec_aff2g_256_x, ec_aff2g_256_y$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import ec_anchor[1-8]_x, ec_anchor[1-8]_x, ec_anchor[1-8]_x, ec_anchor[1-8]_x$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import ec_anchor[1-8]_y, ec_anchor[1-8]_y, ec_anchor[1-8]_y, ec_anchor[1-8]_y$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import ec_anchor.*$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import cm_k, mul_dma_lo$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import ec_sc_byte, ec_sc_mask$/d' "$STAGING/points256_raw.s" -# REU DMA register imports. v0.2.0 added a "defensive REU register init" -# block at the top of ec_scalar_mul_var (lines 753-757) that touches -# reu_reu_lo + reu_addr_ctrl, so those two must stay imported even though -# ec_scalar_mul_var is the only retained body. The rest are only used by -# the stripped REU anchor helpers and Lim-Lee comb. -sed -i '' '/^\.import reu_c64_lo, reu_c64_hi, reu_reu_lo, reu_reu_hi$/c\ -.import reu_reu_lo' "$STAGING/points256_raw.s" -sed -i '' '/^\.import reu_reu_bank, reu_len_lo, reu_len_hi$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import reu_addr_ctrl, reu_command$/c\ -.import reu_addr_ctrl' "$STAGING/points256_raw.s" -# ec_mulp / ec_sqrp are used by all three retained bodies - keep. -# fp_tmp1 is used by ec_scalar_mul_var - keep. - -# Sanity: no leftover non-comment references to stripped symbols. -# Filter out comment lines (first non-blank char is `;`) before checking. -if grep -v '^\s*;' "$STAGING/points256_raw.s" \ - | grep -qE '\bec_anchor[0-9]+_|\bcm_k\b|\bec_aff2g_256|\bec_sc_byte\b|\bec_sc_mask\b|\bsm256_reu|\bec_scalar_mul\b[^_]'; then - echo "ERROR: stripped points256 still references removed-body symbols" >&2 - grep -v '^\s*;' "$STAGING/points256_raw.s" \ - | grep -nE '\bec_anchor[0-9]+_|\bcm_k\b|\bec_aff2g_256|\bec_sc_byte\b|\bec_sc_mask\b|\bsm256_reu|\bec_scalar_mul\b[^_]' \ - | head -5 >&2 +# --- 1. Build upstream's lib-p256-verify archive --- +# Upstream's Makefile builds every module with the same recipe (no per-file +# CA65FLAGS hook), so we cannot pass -D overrides via `make CA65=...` here: +# the override would land on every .s, including fp256.s which only +# `.importzp`s the slots and would error on a redefinition. We therefore +# build upstream with its defaults, then rebuild zp_config.o ourselves with +# the overrides below. +# +# Note on c64-lib-contract SPEC §8.1: nistcurves v0.3.0's `mul_8x8.s` is +# the only TU that references sqtab_lo / sqtab_hi (via the local +# `.ifndef LIB_SHARED_SQTAB_BASE` equate in that file). Step 4 below +# drops `mul_8x8.o` from the archive entirely — c64-https provides the +# canonical `sqtab_lo` / `sqtab_hi` via src/data.s and the population +# init via src/crypto/poly1305.s::sqtab_init. So no LIB_SHARED_SQTAB_BASE +# / SHARED_SQTAB_INIT override is needed at the nistcurves Makefile +# invocation — the upstream default baked into mul_8x8.o is discarded +# before it reaches the link. +echo "[p256] building libs/nistcurves lib-p256-verify (upstream defaults)..." +make -s -C "$LIB_DIR" lib-p256-verify >/dev/null + +UPSTREAM_ARCHIVE="$LIB_BUILD/lib/nistcurves-p256-verify.a" +if [ ! -f "$UPSTREAM_ARCHIVE" ]; then + echo "ERROR: upstream archive missing: $UPSTREAM_ARCHIVE" >&2 exit 1 fi -# --- Strip curve256.s to ec_a256, ec_b256, ec_gx256, ec_gy256 only --- -# The test vector constants (ecdsa_test_*) are used only by the sibling's -# own test PRG and would add ~256 B of dead rodata here. -cat > "$STAGING/curve256_raw.s" <<'CURVE_EOF' -.setcpu "6502" - -; ============================================================================= -; curve256_raw.s - P-256 curve parameters for c64-https Phase C.4. -; Hand-trimmed from libs/nistcurves/src/curve256.s: test vectors dropped -; (only used by the sibling's standalone test harness). -; ============================================================================= - -.segment "RODATA" - -.export ec_a256, ec_b256, ec_gx256, ec_gy256 - -; Coefficient a = p - 3 -ec_a256: - .byte $FC, $FF, $FF, $FF, $FF, $FF, $FF, $FF - .byte $FF, $FF, $FF, $FF, $00, $00, $00, $00 - .byte $00, $00, $00, $00, $00, $00, $00, $00 - .byte $01, $00, $00, $00, $FF, $FF, $FF, $FF - -; Coefficient b -ec_b256: - .byte $4B, $60, $D2, $27, $3E, $3C, $CE, $3B - .byte $F6, $B0, $53, $CC, $B0, $06, $1D, $65 - .byte $BC, $86, $98, $76, $55, $BD, $EB, $B3 - .byte $E7, $93, $3A, $AA, $D8, $35, $C6, $5A - -; Generator x coordinate (LE) -ec_gx256: - .byte $96, $C2, $98, $D8, $45, $39, $A1, $F4 - .byte $A0, $33, $EB, $2D, $81, $7D, $03, $77 - .byte $F2, $40, $A4, $63, $E5, $E6, $BC, $F8 - .byte $47, $42, $2C, $E1, $F2, $D1, $17, $6B - -; Generator y coordinate (LE) -ec_gy256: - .byte $F5, $51, $BF, $37, $68, $40, $B6, $CB - .byte $CE, $5E, $31, $6B, $57, $33, $CE, $2B - .byte $16, $9E, $0F, $7C, $4A, $EB, $E7, $8E - .byte $9B, $7F, $1A, $FE, $E2, $42, $E3, $4F -CURVE_EOF - -# --- Emit minimal data_p256_raw.s --- -# Keeps only the RW buffers that fp256 / mod256 / points256 (post-strip) / -# ecdsa256 reference. Shared mul infrastructure (mul_cached_a, mul_src2_buf, -# mul_dma_lo, mul_dma_hi) is provided by in-tree src/data.s. P-384 data and -# Lim-Lee anchors are excluded. -cat > "$STAGING/data_p256_raw.s" <<'DATA_EOF' -.setcpu "6502" - -; ============================================================================= -; data_p256_raw.s - Minimal P-256 RW buffers for c64-https / c64-nist-curves -; integration (Phase C.4). Hand-extracted from the sibling's -; data.s so in-tree shared mul buffers remain unclobbered and -; P-384 / Lim-Lee state is omitted. -; -; All exports here are P-256-exclusive. -; ============================================================================= - -.segment "DATA" - -; --- P-256 field arithmetic working buffers (32 bytes each) --- -; fp_tmp2/3/4 and fp_r1/2/3 are declared by the sibling's full data.s -; but never .importe'd from the retained fp256/mod256/points256/ecdsa256 -; bodies; pruned here to save BSS (~192 B). -.export fp_wide -fp_wide: .res 64, 0 ; 512-bit product from multiply -.export fp_tmp1 -fp_tmp1: .res 32, 0 - -; --- P-256 result registers (only fp_r0 referenced) --- -.export fp_r0 -fp_r0: .res 32, 0 - -; --- P-256 modular inverse working space --- -.export fp_inv_u -fp_inv_u: .res 32, 0 -.export fp_inv_v -fp_inv_v: .res 32, 0 -.export fp_inv_x1 -fp_inv_x1: .res 32, 0 -.export fp_inv_x2 -fp_inv_x2: .res 32, 0 - -; --- P-256 point storage (Jacobian: X=32 + Y=32 + Z=32 = 96 bytes) --- -.export ec_p1 -ec_p1: .res 96, 0 -.export ec_p2 -ec_p2: .res 96, 0 -.export ec_p3 -ec_p3: .res 96, 0 - -; --- P-256 point math temporaries --- -.export ec_t1 -ec_t1: .res 32, 0 -.export ec_t2 -ec_t2: .res 32, 0 -.export ec_t3 -ec_t3: .res 32, 0 -.export ec_t4 -ec_t4: .res 32, 0 -.export ec_t5 -ec_t5: .res 32, 0 -.export ec_t6 -ec_t6: .res 32, 0 - -; --- P-256 affine output --- -.export ec_affine_x -ec_affine_x: .res 32, 0 -.export ec_affine_y -ec_affine_y: .res 32, 0 - -; --- Variable-base scalar-mul input (affine, 32 bytes each, LE). --- -.export ec_base_x -ec_base_x: .res 32, 0 -.export ec_base_y -ec_base_y: .res 32, 0 - -; --- Solinas reduction scratch (33 bytes: 32 + carry) --- -.export fp_red_tmp -fp_red_tmp: .res 33, 0 - -; --- ECDSA verify scratch (P-256). All 32-byte little-endian unless noted. --- -.export ecdsa_r -ecdsa_r: .res 32, 0 ; LE r (byte-reversed from BE input) -.export ecdsa_s -ecdsa_s: .res 32, 0 ; LE s -.export ecdsa_h -ecdsa_h: .res 32, 0 ; LE message hash -.export ecdsa_qx -ecdsa_qx: .res 32, 0 ; LE public-key affine X -.export ecdsa_qy -ecdsa_qy: .res 32, 0 ; LE public-key affine Y -.export ecdsa_w -ecdsa_w: .res 32, 0 ; LE w = s^-1 mod n -.export ecdsa_u1 -ecdsa_u1: .res 32, 0 ; LE u1 = h*w mod n -.export ecdsa_u2 -ecdsa_u2: .res 32, 0 ; LE u2 = r*w mod n -.export ecdsa_u1_be -ecdsa_u1_be: .res 32, 0 ; BE u1 (scalar_mul input) -.export ecdsa_u2_be -ecdsa_u2_be: .res 32, 0 ; BE u2 (scalar_mul_var input) -.export ecdsa_u1g_x -ecdsa_u1g_x: .res 32, 0 ; LE affine X of u1*G -.export ecdsa_u1g_y -ecdsa_u1g_y: .res 32, 0 ; LE affine Y of u1*G - -; --- fp_reverse32 staging buffer (one 32-byte scratch). --- -.export fp_rev_buf -fp_rev_buf: .res 32, 0 -DATA_EOF - -# --- Emit minimal REU register equates --- -# v0.2.0 added a "defensive REU register init" block at the top of -# ec_scalar_mul_var (and also in fp256/ecdsa256 modular-inverse paths) -# that touches reu_reu_lo + reu_addr_ctrl. The sibling's constants.s -# provides these but also exports VIC/CIA/KERNAL equates that would -# collide with c64-https's in-tree definitions, so we emit a minimal -# equate file with only what the retained bodies actually reference. -cat > "$STAGING/reu_equates_raw.s" <<'REU_EOF' -.setcpu "6502" - -; Minimal REU hardware register equates used by retained P-256 bodies -; in v0.2.0 (defensive REU register init in ec_scalar_mul_var, fp_inv, -; ecdsa inverse). Mirror of values in libs/nistcurves/src/constants.s. -.export reu_reu_lo, reu_addr_ctrl -reu_reu_lo = $df04 -reu_addr_ctrl = $df0a -REU_EOF - -# --- Route CODE segments in the raw .s files to CRYPTO_CODE. --- -# The sibling uses `.segment "CODE"`, which under c64-https's cfg is the -# LOADER region ($0801-$1FFF). We want this code in CRYPTO_RESIDENT. -for src in fp256_raw mod256_raw points256_raw ecdsa256_raw; do - sed -i '' 's/^\.segment "CODE"/.segment "CRYPTO_CODE"/' "$STAGING/$src.s" -done - -# --- Route DATA segment in data_p256_raw.s to CRYPTO_BSS. --- -# The c64-https cfg has no "DATA" segment slot; our minimal data file -# only contains `.res` (zero-init) declarations, so CRYPTO_BSS is the -# right home. Don't accidentally match anything inside a string or -# comment: the data_p256_raw.s we emit has exactly one such directive. -sed -i '' 's/^\.segment "DATA"$/.segment "CRYPTO_BSS"/' "$STAGING/data_p256_raw.s" - -# Sanity: no leftover `.segment "CODE"` hunks outside the expected -# pattern (the raw files should only have one CODE segment each). -for src in fp256_raw mod256_raw points256_raw ecdsa256_raw; do - if grep -qE '^\.segment "CODE"$' "$STAGING/$src.s"; then - echo "ERROR: leftover .segment \"CODE\" in $src.s" >&2 - exit 1 - fi -done - -# --- Assemble each staged .s file --- -OBJ_DIR="$STAGING/obj" -rm -rf "$OBJ_DIR" -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. +# --- 2. Stage upstream object files --- +rm -rf "$STAGING" +mkdir -p "$STAGING" "$OUT_DIR" +cp "$UPSTREAM_ARCHIVE" "$STAGING/upstream.a" +(cd "$STAGING" && "$AR65" x upstream.a $( "$AR65" t upstream.a )) + +# --- 3. Rebuild zp_config.o with c64-https overrides --- +# `.ifndef`-guarded slots in src/zp_config.s let -D flags win cleanly. +# The .exportzp declarations propagate the override values to every +# `.importzp` site via the link. "$CA65" \ - -I "$STAGING" \ - -I "$PROJECT_ROOT/src/crypto/shared" \ - "${ZP_DEFINES[@]}" \ - -o "$OBJ_DIR/zp_config.o" "$STAGING/zp_config.s" - -for src in fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw reu_equates_raw; do - "$CA65" \ - -I "$STAGING" \ - -I "$PROJECT_ROOT/src/crypto/shared" \ - -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" -done - -# --- Archive --- + --cpu 6502 \ + -g \ + -I "$LIB_SRC" \ + "${ZP_OVERRIDES[@]}" \ + -o "$STAGING/zp_config.o" \ + "$LIB_SRC/zp_config.s" + +# --- 4. Drop conflicting members --- +# mul_8x8.o: exports mul_8x8, sqtab_init, poly_prod_lo/hi, sqtab_lo/hi, +# reu_fetch_mul_row. c64-https's src/crypto/poly1305.s already +# exports these — including upstream's copy causes ld65 dup-sym. +# data_shared.o: exports mul_cached_a, mul_src2_buf, mul_dma_lo/hi. +# c64-https's src/data.s already exports these — same conflict. +rm -f "$STAGING/mul_8x8.o" "$STAGING/data_shared.o" + +# --- 5. Re-archive into c64-https's expected location --- +# Order matches upstream's lib-p256-verify recipe so labels.txt diffs +# stay readable across bumps. rm -f "$ARCHIVE" "$AR65" a "$ARCHIVE" \ - "$OBJ_DIR/zp_config.o" \ - "$OBJ_DIR/fp256_raw.o" \ - "$OBJ_DIR/mod256_raw.o" \ - "$OBJ_DIR/points256_raw.o" \ - "$OBJ_DIR/ecdsa256_raw.o" \ - "$OBJ_DIR/curve256_raw.o" \ - "$OBJ_DIR/data_p256_raw.o" \ - "$OBJ_DIR/reu_equates_raw.o" - -# --- Per-source byte counts --- + "$STAGING/lib_version.o" \ + "$STAGING/lib_manifest.o" \ + "$STAGING/zp_config.o" \ + "$STAGING/constants.o" \ + "$STAGING/reu_config.o" \ + "$STAGING/fp256.o" \ + "$STAGING/mod256.o" \ + "$STAGING/curve256.o" \ + "$STAGING/points256_core.o" \ + "$STAGING/ecdsa256.o" \ + "$STAGING/data_p256.o" + +# --- 6. Per-source byte counts (for the supervisor's PR description) --- { echo "# nistcurves-p256.a per-source byte counts (ca65 .o file sizes)" - for src in zp_config fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw reu_equates_raw; do - bytes=$(wc -c < "$OBJ_DIR/$src.o") - printf '%-24s %d bytes (.o)\n' "$src" "$bytes" + for src in lib_version lib_manifest zp_config constants reu_config \ + fp256 mod256 curve256 points256_core ecdsa256 data_p256; do + if [ -f "$STAGING/$src.o" ]; then + bytes=$(wc -c < "$STAGING/$src.o") + printf '%-24s %d bytes (.o)\n' "$src" "$bytes" + fi done } > "$SIZES" diff --git a/tools/integration/build_nistcurves_p256_bin.sh b/tools/integration/build_nistcurves_p256_bin.sh new file mode 100755 index 0000000..2151a78 --- /dev/null +++ b/tools/integration/build_nistcurves_p256_bin.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# ============================================================================= +# tools/integration/build_nistcurves_p256_bin.sh -- Link the always-resident +# P-256 sibling archive (`build/lib/nistcurves-p256.a`) as a padded +# overlay .bin image suitable for embedding into the PRG via +# .incbin (src/crypto/shared/p256_overlay_blobs.s). +# +# W3 (library-ingestion architecture) artefact. Today the P-256 verify +# primitives ship always-resident in CRYPTO_RESIDENT; the W1 follow-on +# will move them into the cold-path CRYPTO_OVERLAY slot. This .bin is +# the staging image for that move: boot will DMA the bytes from +# CRYPTO_OVERLAY (PRG-load placement) to REU bank 2 slot $22100, and +# `crypto_swap_to_p256_verify` will DMA them back on demand. +# +# Mirrors tools/integration/build_nistcurves_p384_bin.sh's contract: +# * Inputs: build/lib/nistcurves-p256.a (built by +# build_nistcurves_p256.sh). +# * Output: build/lib/nistcurves-p256-verify.bin (7,680 B padded). +# * Per-image size report: build/lib/nistcurves-p256-verify.sizes.txt. +# * Per-image label file: build/labels-p256-verify.txt. +# * Per-image cc65 .dbg sidecar: +# build/lib/nistcurves-p256-verify.dbg. +# +# The cfg `cfg/p256-overlay-verify.cfg` routes CRYPTO_CODE / +# CRYPTO_RODATA / RODATA into the OVERLAY_REGION ($4200, $1E00 B) and +# pins DATA / BSS at $C000 RESIDENT just so labels resolve. The .bin +# output is truncated to $1E00 so only the overlay portion lands in +# the file. +# +# Usage (from top-level Makefile): +# bash tools/integration/build_nistcurves_p256_bin.sh +# ============================================================================= +set -eo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +ARCHIVE="$PROJECT_ROOT/build/lib/nistcurves-p256.a" +CFG="$PROJECT_ROOT/cfg/p256-overlay-verify.cfg" +OUT_DIR="$PROJECT_ROOT/build/lib" +BIN_OUT="$OUT_DIR/nistcurves-p256-verify.bin" +SIZES_OUT="$OUT_DIR/nistcurves-p256-verify.sizes.txt" +LABELS_OUT="$PROJECT_ROOT/build/labels-p256-verify.txt" +MAP_OUT="$OUT_DIR/nistcurves-p256-verify.map" +DBG_OUT="${BIN_OUT%.bin}.dbg" + +# Live UCI CRYPTO_OVERLAY slot size: $1E00 = 7,680 B. The .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 missing -- run tools/integration/build_nistcurves_p256.sh first" >&2 + echo " missing: $ARCHIVE" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" + +# Extract archive members; ld65 wants plain .o files on the command line. +scratch="$OUT_DIR/p256_bin_scratch" +rm -rf "$scratch" +mkdir -p "$scratch" +cp "$ARCHIVE" "$scratch/" + +archive_basename=$(basename "$ARCHIVE") +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) + +obj_args=() +for m in $members; do + obj_args+=("$scratch/$m") +done + +# Symbol --defines. The P-256 minimal subset imports REU register +# equates (reu_reu_lo, reu_addr_ctrl from reu_equates_raw.s inside the +# archive) plus shared mul infrastructure (mul_cached_a, mul_dma_lo, +# mul_dma_hi, mul_8x8, poly_prod_lo/hi, reu_fetch_mul_row) provided by +# the main PRG's in-tree poly1305.s / data.s / boot.s. For the +# standalone overlay link we pin those imports at fixed addresses -- +# either resolved from build/labels.txt (the main PRG's runtime +# addresses, so the overlay's fp_mul ends up reading/writing the right +# cells when the .bin is DMA'd live) or stubbed. See the P-384 +# overlay script for the same pattern. +MAIN_LABELS="$PROJECT_ROOT/build/labels.txt" + +lookup_label () { + local name="$1" + local fallback="$2" + if [ ! -f "$MAIN_LABELS" ]; then + echo "$fallback" + return + fi + local hex + hex=$(grep -E " \.${name}\$" "$MAIN_LABELS" | head -n1 | awk '{print $2}' | sed 's|^C:||') + if [ -z "$hex" ]; then + echo "$fallback" + else + printf '$%s' "$hex" + fi +} + +# Stubs are $0000 (intentional -- the .bin will be regenerated on the +# second pass once build/labels.txt exists, mirroring the P-384 +# bootstrap workflow in the top-level Makefile). +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_POLY_PROD_LO=$(lookup_label poly_prod_lo '$CFFE') +DEF_POLY_PROD_HI=$(lookup_label poly_prod_hi '$CFFF') +DEF_MUL_8X8=$(lookup_label mul_8x8 '$0000') +# ec_scalar_mul is provided by the shim in src/crypto/ecdsa_verify.s in +# the always-resident path -- for the standalone overlay link it is +# referenced from ecdsa256_raw.s but never called from any path we +# actually exercise (TLS uses ec_scalar_mul_var; ec_scalar_mul is the +# Lim-Lee fixed-base entry whose body was stripped). Pin to a stub +# address; the in-PRG link resolves it properly. Same for +# mul_src2_buf (lives in src/data.s under the main PRG). +DEF_EC_SCALAR_MUL=$(lookup_label ec_scalar_mul '$0000') +DEF_MUL_SRC2_BUF=$(lookup_label mul_src2_buf '$0000') + +# Link. Route the P-256 archive's segments into OVERLAY_REGION via +# the cfg (which lists CRYPTO_CODE / CRYPTO_RODATA / RODATA / CODE as +# overlay-bound and DATA / BSS / CRYPTO_BSS as RESIDENT-bound). The +# zp_config.o object emits ZP equates only -- nothing in the output +# image -- so no segment routing is needed for it. +# NB: the archive's reu_equates_raw.s already exports reu_reu_lo and +# reu_addr_ctrl (the two slots v0.2.0's defensive REU init touches in +# ec_scalar_mul_var). Defining them again here would conflict -- +# pass only the symbols the archive imports without providing. +"$LD65" \ + -C "$CFG" \ + -o "$BIN_OUT" \ + -Ln "$LABELS_OUT" \ + -m "$MAP_OUT" \ + --dbgfile "$DBG_OUT" \ + --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" \ + --define mul_8x8="$DEF_MUL_8X8" \ + --define ec_scalar_mul="$DEF_EC_SCALAR_MUL" \ + --define mul_src2_buf="$DEF_MUL_SRC2_BUF" \ + "${obj_args[@]}" + +# Normalise labels to VICE format 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 segment size from the .map (this only +# captures the OVERLAY_P256_VERIFY segment by name; other segments +# routed into OVERLAY_REGION via the cfg add to the file size but +# don't show under the named segment). +seg_name="OVERLAY_P256_VERIFY" +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") +overlay_bytes="" +if [ -n "$overlay_hex" ]; then + overlay_bytes=$(printf '%d' "0x$overlay_hex") +fi + +# Truncate / pad to exactly $SLOT_BYTES so the .bin DMAs into the +# live UCI overlay slot ($1E00 = 7,680 B). +truncate -s "$SLOT_BYTES" "$BIN_OUT" + +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-p256-verify overlay image (W3)" + 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)" +cat "$SIZES_OUT" diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh index 2caf4d9..e834bbc 100755 --- a/tools/integration/build_nistcurves_p384.sh +++ b/tools/integration/build_nistcurves_p384.sh @@ -1,297 +1,236 @@ #!/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 5 production use. # -# 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 + W5 (library-ingestion architecture). Under the +# c64-lib-contract (libs/nistcurves v0.3.0), upstream publishes +# `make lib-p384-sha384` and `make lib-p384-verify` build targets. This +# script delegates the heavy lifting to upstream `make`, then performs +# adjustments before placing the archives at the locations the top-level +# Makefile expects: # -# 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. +# 1. Rebuild zp_config.o with c64-https's ZP-slot overrides. The +# sibling defaults sha_src/sha_len/sha_w_ptr/sha_w_ptr2 ($04/$06/ +# $08/$0a) collide with c64-https's $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 and is dedicated to the SHA-384 call window. fp_mul_i / +# fp_mul_j also relocated ($39/$3a vs upstream $2c/$2d) to dodge +# the fe25519 claim. Other slots inherit upstream defaults. # -# 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. +# 2. Drop `mul_8x8.o` + `data_shared.o` from the curve archive +# (same conflict reasoning as build_nistcurves_p256.sh — c64-https +# provides them via src/crypto/poly1305.s + src/data.s). # -# 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. +# 3. Emit an ec_scalar_mul_384 shim. The upstream lib-p384-verify +# archive excludes points384_comb.s (the Lim-Lee fixed-base +# comb), so `ec_scalar_mul_384` is unresolved. We provide the +# symbol via a one-page shim that copies G into +# ec_base384_x/y and tail-calls ec_scalar_mul_var_384. Pattern +# mirrors c64-https's existing src/crypto/ecdsa_verify.s::ec_scalar_mul +# (Phase C.4 P-256 dispatcher). # -# 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. -# -# 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) +# Outputs: +# build/lib/nistcurves-p384-sha384.a - SHA-384 overlay archive +# build/lib/nistcurves-p384-curve.a - curve verify overlay archive +# build/lib/nistcurves-p384-{sha384,curve}.sizes.txt # ============================================================================= set -eo pipefail # --- Paths --- PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -LIB_SRC="$PROJECT_ROOT/libs/nistcurves/src" +LIB_DIR="$PROJECT_ROOT/libs/nistcurves" +LIB_SRC="$LIB_DIR/src" +LIB_BUILD="$LIB_DIR/build" 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. -# -# 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 -# (different overlays; only one resident at a time) and the canonical -# map documents the time-sharing. -ZP_DEFINES=( - '-Dproc_port=$01' - '-Dzp_tmp1=$02' - '-Dzp_tmp2=$03' - '-Dzp_ptr1=$fb' - '-Dzp_ptr2=$fd' - '-Dfp_src1=$22' - '-Dfp_src2=$24' - '-Dfp_dst=$26' - '-Dfp_misc=$28' - '-Dfp_carry=$2a' - '-Dfp_loop=$2b' - '-Dfp_mul_i=$39' - '-Dfp_mul_j=$3a' - '-Dec_scalar_ptr=$3b' - '-Dpoly_i=$1a' - '-Dpoly_j=$1b' - '-Dpoly_carry=$1c' - '-Dpoly_tmp=$1d' +# --- ZP-slot overrides (c64-https canonical map + SHA-384 isolated window) --- +ZP_OVERRIDES=( + '-D' 'zp_ptr2=$3d' + '-D' 'fp_mul_i=$39' + '-D' 'fp_mul_j=$3a' + # SHA-384 streaming pointer slots (moved out of $04-$0b defaults + # to avoid w32_* / sha_temp1 collision; see header). + '-D' 'sha_src=$3d' + '-D' 'sha_len=$3f' + '-D' 'sha_w_ptr=$41' + '-D' 'sha_w_ptr2=$43' ) +# Note: zp_ptr2 and sha_src both pin $3d. zp_ptr2 is curve-archive-only +# (ecdsa384.s imports it); sha_src is SHA-archive-only. They never share +# a call window — curve overlay and sha overlay are mutually exclusive +# in the live CRYPTO_OVERLAY slot. The defaults below feed BOTH archives' +# zp_config.o builds, but each archive's call window only consumes the +# slot relevant to its own bodies. Safe. + +# --- 1. Build upstream's lib-p384-sha384 + lib-p384-verify archives --- +# Same caveat as P-256: upstream's Makefile builds every module with the +# same recipe, so we can't pass -D overrides via `make CA65=...` (would +# error on .importzp redefinition in non-zp_config files). Build upstream +# with defaults, then rebuild zp_config.o ourselves below. +# +# Note on c64-lib-contract SPEC §8.1: only nistcurves v0.3.0's +# `mul_8x8.s` references sqtab_lo / sqtab_hi (via the local +# `.ifndef LIB_SHARED_SQTAB_BASE` equate in that file). Step 4 drops +# `mul_8x8.o` and `data_shared.o` from the curve archive entirely; +# the SHA-384 archive (separate compile path) never linked mul_8x8 +# in the first place since SHA-384 is multiply-free. So the upstream +# default LIB_SHARED_SQTAB_BASE baked into mul_8x8.o is discarded +# before it reaches either overlay's link — no override required at +# the nistcurves Makefile invocation. +echo "[p384] building libs/nistcurves lib-p384-sha384 + lib-p384-verify (upstream defaults)..." +make -s -C "$LIB_DIR" lib-p384-sha384 lib-p384-verify >/dev/null + +UPSTREAM_SHA_ARCHIVE="$LIB_BUILD/lib/nistcurves-p384-sha384.a" +UPSTREAM_CURVE_ARCHIVE="$LIB_BUILD/lib/nistcurves-p384-verify.a" +for f in "$UPSTREAM_SHA_ARCHIVE" "$UPSTREAM_CURVE_ARCHIVE"; do + if [ ! -f "$f" ]; then + echo "ERROR: upstream archive missing: $f" >&2 + exit 1 + fi +done -# --- Stage sources --- +# --- 2. Stage upstream object files --- rm -rf "$STAGING" -mkdir -p "$STAGING" +mkdir -p "$STAGING/sha" "$STAGING/curve" "$OUT_DIR" +cp "$UPSTREAM_SHA_ARCHIVE" "$STAGING/sha/upstream.a" +cp "$UPSTREAM_CURVE_ARCHIVE" "$STAGING/curve/upstream.a" +(cd "$STAGING/sha" && "$AR65" x upstream.a $( "$AR65" t upstream.a )) +(cd "$STAGING/curve" && "$AR65" x upstream.a $( "$AR65" t upstream.a )) -# 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). -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" - -# --- 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. +# --- 3. Rebuild zp_config.o with c64-https overrides (shared by both archives) --- +"$CA65" \ + --cpu 6502 \ + -g \ + -I "$LIB_SRC" \ + "${ZP_OVERRIDES[@]}" \ + -o "$STAGING/zp_config.o" \ + "$LIB_SRC/zp_config.s" + +# Distribute the overridden zp_config.o into both staging trees. +cp "$STAGING/zp_config.o" "$STAGING/sha/zp_config.o" +cp "$STAGING/zp_config.o" "$STAGING/curve/zp_config.o" + +# --- 4. Drop conflicting members from the curve archive --- +# Same reasoning as P-256: mul_8x8.o + data_shared.o collide with +# c64-https's in-tree src/crypto/poly1305.s + src/data.s exports. +rm -f "$STAGING/curve/mul_8x8.o" "$STAGING/curve/data_shared.o" + +# --- 5. Build ec_scalar_mul_384 shim (Option A: variable-base scalar-mul) --- +# The upstream lib-p384-verify archive excludes points384_comb.s (the +# Lim-Lee fixed-base comb), so `ec_scalar_mul_384` (the symbol ecdsa384.s +# imports at line 49) is unresolved. We provide it via this shim that +# copies G into ec_base384_x/y and tail-calls ec_scalar_mul_var_384. # -# 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" +# Pattern mirrors src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 +# P-256 dispatcher). Slower per-call than the real Lim-Lee comb but +# avoids the ~24 KB REU bank-2 anchor table + ~100 s +# ec_precompute_384 boot drag. +cat > "$STAGING/curve/ec_scalar_mul_384_shim.s" <<'SHIM_EOF' +.setcpu "6502" -# --- 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. -# -# 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' ; ============================================================================= -; 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. +; ec_scalar_mul_384_shim.s -- Option A shim for the Lim-Lee fixed-base +; scalar-mul (excluded from lib-p384-verify). Provides ec_scalar_mul_384 +; by copying G into ec_base384_x/y and tail-calling ec_scalar_mul_var_384. ; -; All exports here are P-384-exclusive. +; Lives in the P384 code segment (same overlay slot as the rest of the +; curve archive). ; ============================================================================= -.setcpu "6502" - -.segment "DATA" - -; --- P-384 field arithmetic working buffers (48 bytes each) --- -.export fp384_wide -fp384_wide: .res 96, 0 ; 768-bit product from multiply -.export fp384_tmp1 -fp384_tmp1: .res 48, 0 -.export fp384_tmp2 -fp384_tmp2: .res 48, 0 -.export fp384_tmp3 -fp384_tmp3: .res 48, 0 -.export fp384_tmp4 -fp384_tmp4: .res 48, 0 - -; --- P-384 result registers --- -.export fp384_r0 -fp384_r0: .res 48, 0 -.export fp384_r1 -fp384_r1: .res 48, 0 -.export fp384_r2 -fp384_r2: .res 48, 0 -.export fp384_r3 -fp384_r3: .res 48, 0 -; --- P-384 modular inverse working space --- -.export fp384_inv_u -fp384_inv_u: .res 48, 0 -.export fp384_inv_v -fp384_inv_v: .res 48, 0 -.export fp384_inv_x1 -fp384_inv_x1: .res 48, 0 -.export fp384_inv_x2 -fp384_inv_x2: .res 48, 0 +.segment "LIB_NISTCURVES_P384_CODE" + +.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 / bpl safe since 47 < 128) + 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 +SHIM_EOF -; --- P-384 point storage (Jacobian: X=48 + Y=48 + Z=48 = 144 bytes) --- -.export ec384_p1 -ec384_p1: .res 144, 0 -.export ec384_p2 -ec384_p2: .res 144, 0 -.export ec384_p3 -ec384_p3: .res 144, 0 - -; --- P-384 point math temporaries --- -.export ec384_t1 -ec384_t1: .res 48, 0 -.export ec384_t2 -ec384_t2: .res 48, 0 -.export ec384_t3 -ec384_t3: .res 48, 0 -.export ec384_t4 -ec384_t4: .res 48, 0 -.export ec384_t5 -ec384_t5: .res 48, 0 -.export ec384_t6 -ec384_t6: .res 48, 0 - -; --- P-384 affine output --- -.export ec384_affine_x -ec384_affine_x: .res 48, 0 -.export ec384_affine_y -ec384_affine_y: .res 48, 0 - -; --- P-384 Solinas reduction scratch --- -.export fp384_red_tmp -fp384_red_tmp: .res 49, 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 - echo "ERROR: stripped points384 still references removed-body symbols" >&2 - exit 1 -fi - -# --- Assemble each staged .s file --- -OBJ_DIR="$STAGING/obj" -rm -rf "$OBJ_DIR" -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. "$CA65" \ - -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 -# 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 - "$CA65" \ - -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" \ - "$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" + --cpu 6502 \ + -g \ + -I "$LIB_SRC" \ + -o "$STAGING/curve/ec_scalar_mul_384_shim.o" \ + "$STAGING/curve/ec_scalar_mul_384_shim.s" + +# --- 6. Re-archive both halves --- +# SHA archive: zp_config + lib_version + lib_manifest + sha384 + data_sha. +# Self-contained — no curve / mul code. +rm -f "$ARCHIVE_SHA" +"$AR65" a "$ARCHIVE_SHA" \ + "$STAGING/sha/lib_version.o" \ + "$STAGING/sha/lib_manifest.o" \ + "$STAGING/sha/zp_config.o" \ + "$STAGING/sha/sha384.o" \ + "$STAGING/sha/data_sha.o" + +# Curve archive: zp_config + lib_version + lib_manifest + constants + +# reu_config + fp384 + mod384 + curve384 + points384_core + ecdsa384 + +# shim + data_p384. mul_8x8 + data_shared dropped (c64-https owns). +rm -f "$ARCHIVE_CURVE" +"$AR65" a "$ARCHIVE_CURVE" \ + "$STAGING/curve/lib_version.o" \ + "$STAGING/curve/lib_manifest.o" \ + "$STAGING/curve/zp_config.o" \ + "$STAGING/curve/constants.o" \ + "$STAGING/curve/reu_config.o" \ + "$STAGING/curve/fp384.o" \ + "$STAGING/curve/mod384.o" \ + "$STAGING/curve/curve384.o" \ + "$STAGING/curve/points384_core.o" \ + "$STAGING/curve/ecdsa384.o" \ + "$STAGING/curve/ec_scalar_mul_384_shim.o" \ + "$STAGING/curve/data_p384.o" + +# --- 7. Per-source byte counts --- +{ + echo "# nistcurves-p384-sha384.a per-source byte counts (ca65 .o file sizes)" + for src in lib_version lib_manifest zp_config sha384 data_sha; do + if [ -f "$STAGING/sha/$src.o" ]; then + bytes=$(wc -c < "$STAGING/sha/$src.o") + printf '%-32s %d bytes (.o)\n' "$src" "$bytes" + fi + done +} > "$SIZES_SHA" -# --- 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 - bytes=$(wc -c < "$OBJ_DIR/$src.o") - printf '%-24s %d bytes (.o)\n' "$src" "$bytes" + echo "# nistcurves-p384-curve.a per-source byte counts (ca65 .o file sizes)" + for src in lib_version lib_manifest zp_config constants reu_config \ + fp384 mod384 curve384 points384_core ecdsa384 \ + ec_scalar_mul_384_shim data_p384; do + if [ -f "$STAGING/curve/$src.o" ]; then + bytes=$(wc -c < "$STAGING/curve/$src.o") + printf '%-32s %d bytes (.o)\n' "$src" "$bytes" + fi 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..8d6e50d 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,230 @@ 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" + + # Under c64-lib-contract / libs/nistcurves cfa9085+, the upstream + # archive includes `constants.o` which `.export`s every REU register + # equate (`reu_status` / `reu_command` / `reu_c64_lo`-`hi` / + # `reu_reu_lo`-`hi` / `reu_reu_bank` / `reu_len_lo`-`hi` / + # `reu_addr_ctrl`). Pre-contract the .a was missing those exports + # and consumers patched them in via `ld65 --define`. Post-contract + # `--define`-ing them duplicates the symbol and ld65 errors with + # "Duplicate external identifier". + "$LD65" \ + -C "$cfg" \ + -o "$bin_out" \ + -Ln "$labels_out" \ + -m "$map_out" \ + --dbgfile "$dbg_out" \ + --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..f6efe72 100644 --- a/tools/integration/build_x25519.sh +++ b/tools/integration/build_x25519.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ============================================================================= -# tools/integration/build_x25519.sh - Build c64-x25519 v0.4.0 X25519 -# primitives as a resident .a archive linked into the main PRG. +# tools/integration/build_x25519.sh - Build c64-x25519 v0.6.0 +# X25519 primitives as a resident .a archive linked into the main PRG. # # Optional sibling-library integration (Phase C.5). Produces # build/lib/x25519.a containing: @@ -12,6 +12,16 @@ # - data buffers (x25_*, fe25519_tmp*, mul_*, sqr_*, a24_*, fe_p) # - util (vic_blank, vic_unblank, bench helpers — pulled in if referenced) # +# Submodule pin: v0.6.0 (95fdd70) — adopts c64-lib-contract §8.1 (the +# canonical shared 1 KB quarter-square table) plus RAM reclamation in +# x25519_init.s (bank-2 stash removed) and bench rehab (bench_start/stop +# php/plp shape so jiffy-based benches measure real cycles again). +# Earlier contract-§1/§2/§3/§5 adoption landed in v0.4.0-7-g4d1c752 and +# remains in place: every ZP slot is `.exportzp`-ed (zp_config.s), +# LIB_VERSION_*/LIB_ABI_VERSION absolute exports (lib_version.s), +# X25519_REU_BANK configurable REU base (reu_config.s), and the +# LIB_X25519_* aggregate manifest equates. +# # Activated only when `make USE_X25519_SIBLING=1`. Default is OFF; the # in-tree src/crypto/fe25519.s + src/crypto/x25519.s remain the # default implementation until the supervisor + validator sign off on @@ -20,14 +30,21 @@ # # Excluded (replaced by in-tree equivalents): # - src/mul_8x8.s: in-tree src/crypto/poly1305.s already exports -# mul_8x8 / sqtab_init / poly_prod_lo / poly_prod_hi. Including -# the sibling's would duplicate symbols. The two implementations -# are calling-convention-compatible (A=multiplicand, X=multiplier -# → poly_prod_lo/hi). The in-tree variant uses a small branch on -# the sum-page byte; the sibling's is CT-clean via SMC patching. -# Using in-tree's is a CT regression for the X25519 mul path; the -# supervisor's plan accepts this for the integration smoke and -# defers a CT clean-up to a follow-up. +# mul_8x8 / sqtab_init / poly_prod_lo / poly_prod_hi / sqtab_lo / +# sqtab_hi. Including the sibling's would duplicate symbols. The +# two implementations are calling-convention-compatible +# (A=multiplicand, X=multiplier → poly_prod_lo/hi). The in-tree +# variant uses a small branch on the sum-page byte; the sibling's +# is CT-clean via SMC patching. Using in-tree's is a CT regression +# for the X25519 mul path; the supervisor's plan accepts this for +# the integration smoke and defers a CT clean-up to a follow-up. +# Under v0.6.0 §8.1 the sibling's mul_8x8 + the mult66 path inside +# fe25519_sqr both resolve `sqtab_lo` / `sqtab_hi` against the +# LIB_SHARED_SQTAB_BASE equate set via -D below; the equate is +# `.ifndef`-guarded in libs/x25519/src/constants.s so passing +# SHARED_SQTAB_INIT collapses the duplicate init body but keeps +# the SHARED_SQTAB_BASE-derived loads pointing at c64-https's +# resident table. # - src/main.s: the sibling's BASIC stub / test harness entry. We # have our own boot.s entry point. # @@ -55,10 +72,11 @@ CA65="${CA65:-ca65}" AR65="${AR65:-ar65}" # --- Canonical ZP defines --- -# The sibling's constants.s wraps every library-owned ZP equate in -# `.ifndef ` (see libs/x25519/docs/LIBRARY.md §4.2). We use the -# sibling's defaults — they are byte-compatible with the in-tree map -# under the following time-sharing analysis: +# The sibling's src/zp_config.s now wraps every library-owned ZP equate +# in `.ifndef ` AND `.exportzp`-s the symbol (issue #44, closes +# c64-lib-contract §2). c64-https uses the sibling's defaults — they +# are byte-compatible with the in-tree map under the following +# time-sharing analysis: # # Sibling claim In-tree slot at same addr Time-share? # ------------- -------------------------- ----------- @@ -84,20 +102,80 @@ AR65="${AR65:-ar65}" # fe_wide pins here # via .assert # -# No -D overrides needed — sibling defaults are fine. +# No ZP -D overrides needed — sibling defaults match c64-https's map. ZP_DEFINES=() +# --- REU bank base pin --- +# c64-x25519 v0.4.0-7-g4d1c752 ships src/reu_config.s with a +# `.ifndef`-guarded X25519_REU_BANK equate (default $00) — issue #43, +# closes c64-lib-contract §3. The library claims six contiguous REU +# banks starting at X25519_REU_BANK (banks 0..5 at the default) for +# its precomputed mul / doubled / 17th-bit-carry tables. +# +# c64-https pins the base to bank 0, matching the in-tree layout in +# src/crypto/shared/reu_layout.inc: +# REU_X25519_MUL_TABLES_BASE = $00000 (bank 0) +# The in-tree comment block at the bottom of reu_layout.inc enumerates +# the theoretical bank-3/4/5 collision with P-256/P-384 precompute +# reservations under USE_X25519_SIBLING=1; that collision remains +# theoretical only under the current TLS path. Passing X25519_REU_BANK +# explicitly (rather than relying on the library default) defends +# against a future c64-x25519 release bumping its default base. +# +# ca65 takes `-D =` for assemble-time symbol definitions +# (the library docs use "--asm-define" in prose but ca65 only supports +# the `-D` short form per `ca65 --help`). +REU_DEFINES=(-D X25519_REU_BANK=0) + +# --- c64-lib-contract §8.1 shared sqtab adoption (v0.6.0) --- +# c64-https owns the canonical 1 KB quarter-square multiply table — +# `sqtab_lo` / `sqtab_hi` live at $BC00 / $BE00 in TABLES_BSS (see +# src/data.s), populated by src/crypto/poly1305.s::sqtab_init at boot. +# +# Pass LIB_SHARED_SQTAB_BASE so the sibling's mul_8x8.s + fe25519.s +# `mult66` path resolve `sqtab_lo` / `sqtab_hi` against the shared +# c64-https table rather than the sibling's $7800 default (which would +# fight c64-https's TABLES_BSS-resident copy at link time / runtime). +# The `.ifndef`-guarded equate in libs/x25519/src/constants.s +# (v0.6.0 §8.1 adoption) plus `.assert (sqtab_lo & $00ff) = 0` + +# `.assert sqtab_hi = sqtab_lo + $0200` catch a misconfigured base at +# assemble time rather than runtime. +# +# SHARED_SQTAB_INIT signals that the host program supplies the +# canonical `mul_tables_init` from a shared-primitives module +# (c64-https's poly1305.s::sqtab_init, aliased through +# src/crypto/shared/mul_tables.s). With the gate defined, the sibling's +# own `sqtab_init` body collapses to a no-op stub +# (libs/x25519/src/mul_8x8.s::sqtab_init .ifdef SHARED_SQTAB_INIT) so +# the two libs don't duplicate work. +SQTAB_DEFINES=( + -D 'LIB_SHARED_SQTAB_BASE=$BC00' + -D SHARED_SQTAB_INIT=1 +) + # --- Stage sources --- rm -rf "$STAGING" mkdir -p "$STAGING" cp "$LIB_SRC"/constants.s "$STAGING/" +# zp_config.s + reu_config.s — transitively .include'd from constants.s +# (v0.4.0-7-g4d1c752, contract §2 + §3). Both files set +# ZP_CONFIG_NO_EXPORTS / REU_CONFIG_NO_EXPORTS when included via +# constants.s, so the .exportzp / .export directives in them only fire +# once per archive (no duplicate-symbol risk). +cp "$LIB_SRC"/zp_config.s "$STAGING/" +cp "$LIB_SRC"/reu_config.s "$STAGING/" cp "$LIB_SRC"/fe25519.s "$STAGING/fe25519_raw.s" cp "$LIB_SRC"/x25519.s "$STAGING/x25519_raw.s" cp "$LIB_SRC"/x25519_init.s "$STAGING/x25519_init_raw.s" # util.s (bench_*, vic_blank/unblank) is NOT staged — c64-https has no # in-PRG user of those helpers; vic_blank-style display blanking is a # perf optimization for benchmarks, not a correctness requirement. +# lib_version.s (LIB_VERSION_* + LIB_X25519_*) is NOT staged for now — +# c64-https doesn't .import any of those symbols yet. Future +# assemble-time fit/collision checks against LIB_X25519_RESIDENT_BYTES +# / LIB_X25519_REU_BANKS_USED would require staging lib_version.s and +# adding the assertions in a cfg or include file. # Route all sibling data (BSS buffers + initialized rodata tables) to # the page-aligned TABLES_BSS segment. TABLES_BSS has `align = $100` @@ -326,9 +404,15 @@ 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[@]}" \ + "${REU_DEFINES[@]}" \ + "${SQTAB_DEFINES[@]}" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" done @@ -352,3 +436,124 @@ rm -f "$ARCHIVE" echo "built $ARCHIVE" cat "$SIZES" + +# ============================================================================= +# W3 (library-ingestion architecture) -- emit a padded overlay .bin +# image of the sibling for documentation / CI parity checking. +# +# The .bin is the same byte image the linker places into CRYPTO_OVERLAY +# under c64-https's main UCI cfg + USE_X25519_SIBLING=1. Producing it +# as a standalone artefact: +# * lets a CI bot diff the in-PRG slot bytes against the .bin to +# detect cfg drift, +# * gives W1 a ready-to-DMA staging image when the cold-path overlay +# wiring lands (REU bank 3, REU_OVERLAY_X25519), +# * documents the sibling's PRG-load-time bytes in `git status`. +# +# Output: +# build/lib/x25519-scalarmult.bin (7,680 B padded) +# build/lib/x25519-scalarmult.sizes.txt +# +# Pad / truncate to exactly $SLOT_BYTES so the .bin matches the live +# UCI CRYPTO_OVERLAY slot. +# ============================================================================= +BIN_OUT="$OUT_DIR/x25519-scalarmult.bin" +SIZES_BIN_OUT="$OUT_DIR/x25519-scalarmult.sizes.txt" +LABELS_BIN_OUT="$PROJECT_ROOT/build/labels-x25519-scalarmult.txt" +MAP_BIN_OUT="$OUT_DIR/x25519-scalarmult.map" +DBG_BIN_OUT="${BIN_OUT%.bin}.dbg" +CFG_BIN="$PROJECT_ROOT/cfg/x25519-overlay-scalarmult.cfg" +SLOT_BYTES=7680 + +if [ ! -f "$CFG_BIN" ]; then + echo "WARN: $CFG_BIN missing -- skipping .bin emission" >&2 +else + LD65="${LD65:-ld65}" + + # ld65 needs the archive members as plain .o files. We already + # have them in $OBJ_DIR from the archive step above -- pass them + # directly. + OBJ_BIN_ARGS=( + "$OBJ_DIR/fe25519_raw.o" + "$OBJ_DIR/x25519_raw.o" + "$OBJ_DIR/x25519_init_raw.o" + "$OBJ_DIR/data_x25519_bss_raw.o" + "$OBJ_DIR/data_x25519_rodata_raw.o" + ) + + # Resolve sibling imports against the main PRG's labels.txt when + # available (mirrors the P-384 overlay .bin script's pattern). + MAIN_LABELS="$PROJECT_ROOT/build/labels.txt" + + bin_lookup_label () { + local name="$1" + local fallback="$2" + if [ ! -f "$MAIN_LABELS" ]; then + echo "$fallback" + return + fi + local hex + hex=$(grep -E " \.${name}\$" "$MAIN_LABELS" | head -n1 | awk '{print $2}' | sed 's|^C:||') + if [ -z "$hex" ]; then + echo "$fallback" + else + printf '$%s' "$hex" + fi + } + + DEF_MUL_8X8=$(bin_lookup_label mul_8x8 '$0000') + DEF_SQTAB_LO=$(bin_lookup_label sqtab_lo '$0000') + DEF_SQTAB_HI=$(bin_lookup_label sqtab_hi '$0000') + DEF_POLY_PROD_LO=$(bin_lookup_label poly_prod_lo '$CFFE') + DEF_POLY_PROD_HI=$(bin_lookup_label poly_prod_hi '$CFFF') + + "$LD65" \ + -C "$CFG_BIN" \ + -o "$BIN_OUT" \ + -Ln "$LABELS_BIN_OUT" \ + -m "$MAP_BIN_OUT" \ + --dbgfile "$DBG_BIN_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_8x8="$DEF_MUL_8X8" \ + --define sqtab_lo="$DEF_SQTAB_LO" \ + --define sqtab_hi="$DEF_SQTAB_HI" \ + --define poly_prod_lo="$DEF_POLY_PROD_LO" \ + --define poly_prod_hi="$DEF_POLY_PROD_HI" \ + "${OBJ_BIN_ARGS[@]}" \ + 2>"$OUT_DIR/x25519-bin-ld.err" \ + || { + # Standalone .bin link is best-effort -- if it fails (e.g. + # missing symbol on a sibling bump), surface a warning but + # don't break the archive build that the main PRG actually + # needs. The W1 follow-on tightens this when the cold-path + # overlay slot lands. + echo "WARN: standalone x25519-scalarmult.bin link failed:" >&2 + cat "$OUT_DIR/x25519-bin-ld.err" >&2 || true + rm -f "$BIN_OUT" + } + + if [ -f "$BIN_OUT" ]; then + # Pad / truncate to exactly $SLOT_BYTES. + truncate -s "$SLOT_BYTES" "$BIN_OUT" + sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$LABELS_BIN_OUT" 2>/dev/null || true + + size=$(wc -c < "$BIN_OUT") + { + echo "# x25519-scalarmult overlay image (W3)" + echo "# slot size: $SLOT_BYTES B (\$1E00 -- UCI CRYPTO_OVERLAY)" + echo "# padded .bin: $size B" + } > "$SIZES_BIN_OUT" + + echo "built $BIN_OUT ($size B padded)" + cat "$SIZES_BIN_OUT" + fi +fi 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..842405d 100644 --- a/tools/uci/_memory_policy.py +++ b/tools/uci/_memory_policy.py @@ -287,9 +287,122 @@ 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 NET_CODE's tail. + + 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). + The workaround mirrors the P-384 wrapper that lived inline in + ``tools/uci/test_https_local_p384.py`` (factored out here so other + test scripts can reuse it without re-copying the implementation): + ``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``) through $3FFF + is zero-fill in the PRG, never referenced by any production code, + and stays RAM after boot. We: + - round the NET_CODE used-end up to the next $100 boundary (cheap + insurance against off-by-one with the very last code byte), + - reject the carveout 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 ``NET_CODE`` 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 carveout if NET_CODE's tail + yields fewer than this many bytes of free space. + :raises RuntimeError: When the NET_CODE tail is too small (build + change pushed code into the would-be scratch range). + """ + labels_path = Path(labels_path) + bounds = _parse_segment_bounds(labels_path) + used_ends = _parse_used_ends(labels_path) + if "NET_CODE" not in bounds: + raise RuntimeError( + "labels.txt has no NET_CODE segment — cannot carve scratch tail" + ) + netc_start, netc_decl_end = bounds["NET_CODE"] + netc_used_end = used_ends.get("NET_CODE", netc_start) + # Round up to next page so we don't trail right up to the last + # instruction byte (cheap insurance against off-by-one). + scratch_start = (netc_used_end + 0xFF) & ~0xFF + scratch_end_excl = netc_decl_end + free_bytes = scratch_end_excl - scratch_start + if free_bytes < min_scratch_bytes: + raise RuntimeError( + f"NET_CODE tail scratch window too small for harness: " + f"${scratch_start:04X}-${scratch_end_excl:04X} " + f"({free_bytes} B, need >= {min_scratch_bytes} B). " + f"NET_CODE used to ${netc_used_end:04X}, declared end " + f"${netc_decl_end:04X}." + ) + + base = build_policy( + labels_path, + prg_path, + unknown=unknown, + extra_reserved=extra_reserved, + ) + # Surgically trim the NET_CODE 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 NET_CODE shrunk. + new_reserved: list[MemoryRegion] = [] + for r in base.reserved_regions: + if r.start == netc_start and r.end == netc_decl_end: + new_reserved.append(MemoryRegion( + netc_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"NET_CODE-tail harness scratch: " + f"${scratch_start:04X}-${scratch_end_excl - 1:04X} " + f"({free_bytes} B free; NET_CODE used to ${netc_used_end:04X}, " + f"declared end ${netc_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())