diff --git a/.gitmodules b/.gitmodules index f8f2e68..f24150d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,7 @@ [submodule "libs/nistcurves"] path = libs/nistcurves url = https://github.com/JC-000/c64-nist-curves.git +[submodule "libs/x25519"] + path = libs/x25519 + url = https://github.com/JC-000/c64-x25519.git + branch = master diff --git a/CLAUDE.md b/CLAUDE.md index 8c4f892..5d9199e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,11 @@ Public symbols (calling conventions are AX=pointer-low/high-byte except where noted, buffers provided by caller, keys/IVs passed via fixed buffers in the crypto BSS — see per-module headers for details): - X25519 / field arithmetic (in-tree; c64-x25519 overlay deferred, see #33) + 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` + (UCI backend only — see Known issues for the ip65 fit blocker; Phase + C.5). Sibling and in-tree both expose the same ABI: x25519_scalarmult — X25519 scalar × point, 32-byte buffers fe25519_mul, fe25519_sqr, fe25519_inv @@ -338,11 +342,31 @@ Five latent bugs and three new ones were cleared to get here: `tools/uci/test_https_print_body.py` with a mixed-case response body. `http_resp_buf` still holds raw ASCII — only the render pipeline is translated. - - X25519 REU overlay deferred (c64-x25519 #33). Phase C.1 (`6c9d2a3`) - integrated the sibling optimised X25519 as a REU overlay but hung - inside the Montgomery ladder under BACKEND=uci at 48 MHz; rolled - back in `b133ac7`. A retry against the v0.3.0 tag failed the same - way. X25519 stays in-tree until the upstream hang is resolved. + - **X25519 sibling (Phase C.5)** — `make USE_X25519_SIBLING=1` builds + against `libs/x25519@v0.4.0`. 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 + at every `x25519_scalarmult` / `fe25519_mul` / `_sqr` / `_mul_a24` + / `_inv` entry — eliminates the `do_swap` residue confound on + `$DF04`/`$DF0A` that produced the wrong-result symptom). Verified + 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 + `tools/integration/build_x25519.sh` for the staging layout. + - **CRYPTO_OVERLAY address collision lesson**: under the Phase C.5 + flag, `X25519_RODATA` + `X25519_BSS` live at `$4200-$50FF`. + Any test harness that DMA-injects a 6502 stub or scratch into + `CRYPTO_OVERLAY` must avoid that range. `tools/uci/test_https_local.py` + historically placed `ROUTINE_ADDR=$4200` (+ 5 sibling addresses up + to `$4542`) inside that range and silently corrupted `x25_basepoint`, + `fe_p`, `mul38_*_tab`, and `sqr_lo/hi` — every `fe25519_mul`/`_sqr` + then produced garbage and X25519 emitted wrong-but-deterministic + output. Fixed by relocating to `$5100-$5442` (past the X25519_BSS + tail). New harnesses that touch CRYPTO_OVERLAY: prefer `$5100+`. - `make p384-overlay` has a pre-existing unresolved-symbol bug: `points384_raw.s` references `ec_base384_x` / `ec_base384_y` which aren't exported by the current sibling build. Not a Phase C diff --git a/Makefile b/Makefile index a33f858..debe323 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,16 @@ VICE ?= x64sc BACKEND ?= ip65 CFG := cfg/c64-https-$(BACKEND).cfg +# --- Sibling X25519 integration (Phase C.5, c64-x25519 v0.4.0) --- +# `USE_X25519_SIBLING=1` swaps in `build/lib/x25519.a` for the in-tree +# `src/crypto/fe25519.s` + `src/crypto/x25519.s` + in-tree X25519 data +# buffers from `src/data.s`. Default OFF — the in-tree implementation +# stays the shipped default until the supervisor + validator sign off +# on the sibling drop-in. The flag is read at link time; both code +# paths coexist on the branch so an A/B comparison is `make` vs +# `make USE_X25519_SIBLING=1`. +USE_X25519_SIBLING ?= 0 + IP65_DIR := ip65 IP65_BUILD := ip65-build IP65_BIN := $(IP65_BUILD)/ip65-c64.bin @@ -53,13 +63,33 @@ UCI_SRCS := src/net/uci/net.s src/net/uci/uci_cmd.s # for BOTH backends (replaces the in-tree ecdsa_{curve,fp,mod,points}.s). SIBLING_LIB_ARCHIVES := build/lib/nistcurves-p256.a +# Phase C.5 (USE_X25519_SIBLING=1): c64-x25519 v0.4.0 sibling, always-resident, +# replaces in-tree fe25519.s + x25519.s + X25519 buffers in src/data.s. +# Off by default. +ifeq ($(USE_X25519_SIBLING),1) +SIBLING_LIB_ARCHIVES += build/lib/x25519.a +# Propagate the flag to ca65 so src/data.s suppresses the in-tree X25519 +# buffer declarations (the sibling's data_x25519_raw.s provides them). +CA65FLAGS += -D USE_X25519_SIBLING=1 +endif + +# Phase C.5: under USE_X25519_SIBLING=1, evict the in-tree X25519 +# implementation from the link line — the sibling archive +# (build/lib/x25519.a) provides byte-compatible exports for x25519_* +# and a richer fe25519_* surface than the in-tree fe_* symbols. +ifeq ($(USE_X25519_SIBLING),1) +CRYPTO_SRCS_EFFECTIVE := $(filter-out src/crypto/fe25519.s src/crypto/x25519.s,$(CRYPTO_SRCS_ALL)) +else +CRYPTO_SRCS_EFFECTIVE := $(CRYPTO_SRCS_ALL) +endif + # Per-backend source + object selection. ifeq ($(BACKEND),ip65) NET_SRCS := $(IP65_SRCS) -CRYPTO_SRCS := $(CRYPTO_SRCS_ALL) +CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) else ifeq ($(BACKEND),uci) NET_SRCS := $(UCI_SRCS) -CRYPTO_SRCS := $(CRYPTO_SRCS_ALL) +CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) # 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. @@ -135,6 +165,17 @@ build/lib/nistcurves-p256.a: @mkdir -p build/lib bash tools/integration/build_nistcurves_p256.sh +# Phase C.5: c64-x25519 v0.4.0 X25519 archive — replaces the in-tree +# fe25519.s + x25519.s + X25519 buffer declarations in src/data.s when +# USE_X25519_SIBLING=1. Linked into the PRG under BOTH backends. The +# sibling's reu_mul_init is called from src/boot.s in place of the +# in-tree REU mul table generator; sqtab_init is still served by the +# in-tree src/crypto/poly1305.s (sibling's mul_8x8.s is excluded from +# the archive to avoid duplicate-symbol with poly1305's mul_8x8). +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 diff --git a/cfg/c64-https-ip65.cfg b/cfg/c64-https-ip65.cfg index 5caeb26..dfc3094 100644 --- a/cfg/c64-https-ip65.cfg +++ b/cfg/c64-https-ip65.cfg @@ -88,6 +88,17 @@ SEGMENTS { 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; + # --- 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 diff --git a/cfg/c64-https-uci.cfg b/cfg/c64-https-uci.cfg index 49ca81f..b33ae9e 100644 --- a/cfg/c64-https-uci.cfg +++ b/cfg/c64-https-uci.cfg @@ -87,6 +87,19 @@ SEGMENTS { 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. + 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; diff --git a/libs/x25519 b/libs/x25519 new file mode 160000 index 0000000..47c0ad2 --- /dev/null +++ b/libs/x25519 @@ -0,0 +1 @@ +Subproject commit 47c0ad21a57ae443632f5e7689cbe9f3de98460e diff --git a/src/boot.s b/src/boot.s index f5b1d14..079b953 100644 --- a/src/boot.s +++ b/src/boot.s @@ -11,8 +11,18 @@ .export print_resp_body ; ---- exports: REU multiply table routines ---- + ; Phase C.5: under USE_X25519_SIBLING=1 the sibling's + ; libs/x25519/src/x25519_init.s owns reu_mul_init + + ; reu_fetch_mul_row + reu_fetch_doubled_row + reu_clear_wide. + ; The in-tree definitions below are guarded out to avoid + ; duplicate-symbol errors at link time; the boot caller below + ; imports `reu_mul_init` from the sibling archive instead. + .ifdef USE_X25519_SIBLING + .import reu_mul_init + .else .export reu_mul_init .export reu_fetch_mul_row + .endif ; ---- exports: menu handlers ---- .export do_net_init @@ -619,6 +629,14 @@ ascii_chrout: ; REU multiply table initialization (from c64-x25519 optimizations) ; ============================================================================= +; Phase C.5: in-tree reu_mul_init / reu_fetch_mul_row are guarded out +; under USE_X25519_SIBLING=1. The sibling's libs/x25519/src/x25519_init.s +; supplies a richer initializer that also populates REU banks 2-5 with +; the zero block + doubled tables required by the sibling's +; fe25519_sqr. Calling the in-tree version would leave those banks +; unset and corrupt every fe25519_sqr. +.ifndef USE_X25519_SIBLING + ; ============================================================================= ; reu_mul_init - Generate 256 full multiplication rows and stash in REU ; @@ -735,6 +753,8 @@ reu_fetch_mul_row: sta reu_command rts +.endif ; .ifndef USE_X25519_SIBLING (in-tree reu_mul_init / reu_fetch_mul_row) + ; ============================================================================= ; Strings (read-only) ; ============================================================================= @@ -876,5 +896,9 @@ http_path_root: .segment "BSS" net_initialized: .res 1 +; Phase C.5: reu_init_a/b are state for the in-tree reu_mul_init loop. +; Sibling's reu_mul_init keeps its own state. +.ifndef USE_X25519_SIBLING reu_init_a: .res 1 reu_init_b: .res 1 +.endif diff --git a/src/crypto/shared/reu_layout.inc b/src/crypto/shared/reu_layout.inc index fc193ec..bc6a49d 100644 --- a/src/crypto/shared/reu_layout.inc +++ b/src/crypto/shared/reu_layout.inc @@ -48,6 +48,23 @@ REU_P256_PRECOMPUTE_BASE = $30000 REU_P384_PRECOMPUTE_BASE = $40000 .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. +; Banks 3 / 4 / 5 are also nominally reserved here for P-256 precompute +; (bank 3) and P-384 precompute (banks 4-5). The collision is THEORETICAL +; under the current TLS path: +; - P-256 always uses ec_scalar_mul_var (variable-base) which does not +; touch REU_P256_PRECOMPUTE_BASE. +; - P-384 is stubbed at the TLS layer (see project_p384_stubbed memory +; note); the smoke-test overlay loader uses banks 4-5 only at test +; time, not in the production handshake. +; If either P-256 fixed-base scalar mul or P-384 precompute is ever +; wired into the production TLS path while USE_X25519_SIBLING=1, the +; sibling's banks 3-5 tables get clobbered and X25519 silently corrupts. +; Resolve at that time by relocating one or the other (the sibling's +; 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. diff --git a/src/data.s b/src/data.s index 223f7f1..8701874 100644 --- a/src/data.s +++ b/src/data.s @@ -32,6 +32,13 @@ sqtab2_hi: .byte >(((256-(I+1))*(256-(I+1)))/4 - 1) .endrepeat +; Phase C.5: under USE_X25519_SIBLING=1 the c64-x25519 archive owns +; mul38_lo_tab / mul38_hi_tab / fe_p / x25_basepoint (plus sqr_lo, +; sqr_hi, a24_b0..b3 which the in-tree fe25519 does not have at all). +; Suppress the in-tree definitions in that mode to avoid duplicate- +; symbol errors at link time. +.ifndef USE_X25519_SIBLING + ; --- mul_by_38 lookup tables --- .export mul38_lo_tab .export mul38_hi_tab @@ -60,6 +67,8 @@ x25_basepoint: .byte 9 .res 31, 0 +.endif ; .ifndef USE_X25519_SIBLING + ; ============================================================================= ; Initialized mutable data (needs DATA segment — small defaults) ; ============================================================================= @@ -102,13 +111,25 @@ zp_save_buf: .res 26 ; saves $02-$1B during ip65 calls .segment "TABLES_BSS" +; Phase C.5: under USE_X25519_SIBLING=1 the sibling's data_x25519_raw.s +; declares mul_dma_lo / mul_dma_hi (and the additional mul_dma_carry +; needed by the sibling's reu_fetch_doubled_row). nistcurves-p256 +; imports mul_dma_lo/hi via the in-tree shared mul row-fetch pipeline, +; so we MUST still provide the symbol from somewhere — under the +; sibling path that "somewhere" is the sibling data module rather +; than this file. +.ifndef USE_X25519_SIBLING .align 256 .export mul_dma_lo .export mul_dma_hi mul_dma_lo: .res 256 ; DMA target: lo bytes of a*b for current a mul_dma_hi: .res 256 ; DMA target: hi bytes of a*b for current a +.endif ; .ifndef USE_X25519_SIBLING ; --- Quarter-square tables (runtime-generated by sqtab_init in poly1305.asm) --- +; These remain in-tree under both modes: sqtab_init is still served by +; src/crypto/poly1305.s (the sibling's mul_8x8.s is intentionally +; excluded from build/lib/x25519.a — see tools/integration/build_x25519.sh). .align 256 .export sqtab_lo .export sqtab_hi @@ -450,6 +471,18 @@ aead_scratch: .res 16 ; Poly1305 padding/length block cc20_remain_hi: .res 1 ; high byte of 16-bit ChaCha20/Poly1305 length counter ; (low byte lives in ZP at cc20_remain = $18) +; Phase C.5: the sibling owns the fe25519/X25519 buffers under +; USE_X25519_SIBLING=1. In the sibling layout: +; - fe_wide is a ZP equate ($40-$7F), NOT a BSS label (hard-asserted +; by constants.s) — the in-tree fe_wide BSS declaration would +; collide as a duplicate symbol and break the SMC patch sites in +; fe25519_mul/sqr that depend on the high byte being $00. +; - fe25519_tmp1..4 replace fe_tmp1..4 (renamed, page-aligned). +; - x25_* / mul_cached_a / mul_src2_buf are re-exported by the +; sibling's data.s with correct 32-byte alignment. +; nistcurves-p256 imports mul_cached_a + mul_src2_buf; both are +; satisfied by the sibling's data_x25519_raw.s in the sibling path. +.ifndef USE_X25519_SIBLING ; ----------------------------------------------------------------------------- ; fe25519 field arithmetic temporaries ; ----------------------------------------------------------------------------- @@ -501,6 +534,7 @@ mul_src2_buf: .res 35 ; absolute copy of src2 for fast indexed access ; c64-nist-curves fp256 4x-unrolled mul can ; over-read past j=31 into zeros for its ; fast-skip fast path — Phase C.4) +.endif ; .ifndef USE_X25519_SIBLING ; ----------------------------------------------------------------------------- ; ECDSA signature verification diff --git a/tools/integration/build_x25519.sh b/tools/integration/build_x25519.sh new file mode 100644 index 0000000..5bd645e --- /dev/null +++ b/tools/integration/build_x25519.sh @@ -0,0 +1,354 @@ +#!/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. +# +# Optional sibling-library integration (Phase C.5). Produces +# build/lib/x25519.a containing: +# - fe25519 field arithmetic (fe25519_mul/sqr/inv/...) +# - X25519 Montgomery ladder (x25519_scalarmult, x25519_clamp, x25519_base) +# - x25519_init (reu_mul_init + REU DMA helpers reu_fetch_mul_row, +# reu_fetch_doubled_row, reu_clear_wide) +# - data buffers (x25_*, fe25519_tmp*, mul_*, sqr_*, a24_*, fe_p) +# - util (vic_blank, vic_unblank, bench helpers — pulled in if referenced) +# +# 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 +# the sibling integration. See PR description / commit message for +# the A/B test rollout plan. +# +# 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. +# - src/main.s: the sibling's BASIC stub / test harness entry. We +# have our own boot.s entry point. +# +# Memory: sibling code + rodata goes into CRYPTO_CODE / CRYPTO_RODATA; +# sibling BSS goes into TABLES_BSS (must stay < $A000 for the +# page-aligned mul_dma / sqr / a24 / fe25519_tmp / x25_* buffers). +# +# Usage (from top-level Makefile, gated by USE_X25519_SIBLING=1): +# bash tools/integration/build_x25519.sh +# Produces: +# build/lib/x25519.a +# build/lib/x25519.sizes.txt (per-source byte counts) +# ============================================================================= +set -eo pipefail + +# --- Paths --- +PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LIB_SRC="$PROJECT_ROOT/libs/x25519/src" +STAGING="$PROJECT_ROOT/build/lib/x25519_staging" +OUT_DIR="$PROJECT_ROOT/build/lib" +ARCHIVE="$OUT_DIR/x25519.a" +SIZES="$OUT_DIR/x25519.sizes.txt" + +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: +# +# Sibling claim In-tree slot at same addr Time-share? +# ------------- -------------------------- ----------- +# $14-$16 cc20_round/qr_idx/data_ptr yes — ChaCha20 and +# (lmul0/lmul1 alias) fe25519 never co-run +# by design in TLS +# $1C poly_carry yes — same role +# $1E-$23 tls_rec_ptr/idx/dir, fp_src1 yes — TLS record state +# not live at the X25519 +# call site in +# tls_ecdh_compute_shared; +# ECDSA (fp_src1) runs +# AFTER X25519 in the +# handshake (CertVerify +# follows ServerHello) +# $24-$2A fp_src2/dst/misc/carry/loop yes — ECDSA-only, +# runs after X25519 +# $2C-$2F fe_src1/src2/dst (in-tree yes — in-tree fe25519 +# fe25519 only) is dropped from the +# link under +# USE_X25519_SIBLING=1 +# $40-$7F ZP_WIDE region (fe_wide) yes — sibling's +# fe_wide pins here +# via .assert +# +# No -D overrides needed — sibling defaults are fine. +ZP_DEFINES=() + +# --- Stage sources --- +rm -rf "$STAGING" +mkdir -p "$STAGING" + +cp "$LIB_SRC"/constants.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. + +# Route all sibling data (BSS buffers + initialized rodata tables) to +# the page-aligned TABLES_BSS segment. TABLES_BSS has `align = $100` +# in the c64-https cfg, so the sibling's .align 256 directives land on +# real page boundaries (CRYPTO_RODATA has no segment-level alignment +# and would waste up to 256 B of padding per .align 256 directive). +# +# ld65 emits a "Segment 'TABLES_BSS' with type 'bss' contains +# initialized data" warning, which is benign — the initialized bytes +# are loaded into RAM at PRG load time, same as any RODATA. The +# segment is `type = bss` only for the in-tree mul_dma_lo/hi etc. +# that originally lived there; mixing the modes is what the cfg +# already does (sqtab_lo/hi are `.res` zero-init in TABLES_BSS today). +# +# Emitted from scratch (rather than sed-patched) so the layout is +# explicit and easy to audit. Buffer ordering / size / alignment is +# preserved from libs/x25519/src/data.s. + +cat > "$STAGING/data_x25519_bss_raw.s" <<'BSS_EOF' +.setcpu "6502" + +; ============================================================================= +; data_x25519_bss_raw.s — zero-init buffers + x25_basepoint + fe_p +; extracted from libs/x25519/src/data.s for the c64-https Phase C.5 +; integration. +; +; Routed to a dedicated X25519_BSS segment so each backend cfg places +; it independently of the in-tree TABLES_BSS: +; - UCI : X25519_BSS -> CRYPTO_OVERLAY ($4200-$5FFF, 7.5 KB free) +; - ip65: X25519_BSS -> CRYPTO_RESIDENT (will overflow — see the +; integrator's report; UCI is the supported path under +; USE_X25519_SIBLING=1). +; +; All buffers must live below $A000 (BASIC ROM shadow) and must +; survive bank-out — CRYPTO_OVERLAY at $4200-$5FFF satisfies both +; under UCI. +; +; x25_basepoint and fe_p are *initialized* constants and live in +; data_x25519_rodata_raw.s (X25519_RODATA, type = ro). They were +; previously routed here under the mistaken assumption that the +; "bss type contains initialized data" ld65 warning is benign; +; it is not — ld65 drops init bytes from type=bss segments, which +; left both constants as zero at runtime, making every fe25519 +; modular reduction see fe_p=0 and every x25519_base see basepoint=0. +; ============================================================================= + +.export fe25519_tmp1, fe25519_tmp2, fe25519_tmp3, fe25519_tmp4 +.export x25_x2, x25_z2, x25_x3, x25_z3 +.export x25_a, x25_b, x25_da, x25_cb, x25_e +.export x25_scalar, x25_u, x25_result +.export mul_cached_a, mul_src2_buf +.export mul_dma_lo, mul_dma_hi, mul_dma_carry + +.segment "X25519_BSS" + +; --- Page-aligned 32-byte field buffers (block 1) --- + .align 256 +fe25519_tmp1: .res 32, 0 +fe25519_tmp2: .res 32, 0 +fe25519_tmp3: .res 32, 0 +fe25519_tmp4: .res 32, 0 +x25_x2: .res 32, 0 +x25_z2: .res 32, 0 +x25_x3: .res 32, 0 +x25_z3: .res 32, 0 + +; --- Page-aligned 32-byte field buffers (block 2) --- + .align 256 +x25_a: .res 32, 0 +x25_b: .res 32, 0 +x25_da: .res 32, 0 +x25_cb: .res 32, 0 +x25_e: .res 32, 0 +x25_scalar: .res 32, 0 +x25_u: .res 32, 0 +x25_result: .res 32, 0 + +; --- fe25519_mul optimization scratch (unaligned) --- +; +; mul_src2_buf is 35 bytes: +; - sibling fe25519_sqr body B reads up to byte 32 (phantom slot, +; must be 0) +; - nistcurves fp256 fp_mul writes to bytes 32, 33, 34 (the +; 4x-unrolled inner loop's over-read pad — Phase C.4 note) +; The earlier 33-byte declaration matched the sibling's standalone +; data.s but was 2 bytes short for nistcurves, leaking writes into +; the padding gap before mul_dma_lo at the next page boundary. +mul_cached_a: .res 1, 0 +mul_src2_buf: .res 35, 0 ; 32 + 1 phantom + 2 over-read pad + +; --- REU DMA target buffers, page-aligned for abs,Y without penalty --- + .align 256 +mul_dma_lo: .res 256, 0 +mul_dma_hi: .res 256, 0 +mul_dma_carry: .res 256, 0 + +; --- Alignment asserts (mirrored from sibling data.s) --- +.assert (fe25519_tmp1 & $1F) = 0, lderror, "fe25519_tmp1 must be 32-byte aligned" +.assert (fe25519_tmp2 & $1F) = 0, lderror, "fe25519_tmp2 must be 32-byte aligned" +.assert (fe25519_tmp3 & $1F) = 0, lderror, "fe25519_tmp3 must be 32-byte aligned" +.assert (fe25519_tmp4 & $1F) = 0, lderror, "fe25519_tmp4 must be 32-byte aligned" +.assert (x25_x2 & $1F) = 0, lderror, "x25_x2 must be 32-byte aligned" +.assert (x25_z2 & $1F) = 0, lderror, "x25_z2 must be 32-byte aligned" +.assert (x25_x3 & $1F) = 0, lderror, "x25_x3 must be 32-byte aligned" +.assert (x25_z3 & $1F) = 0, lderror, "x25_z3 must be 32-byte aligned" +.assert (x25_a & $1F) = 0, lderror, "x25_a must be 32-byte aligned" +.assert (x25_b & $1F) = 0, lderror, "x25_b must be 32-byte aligned" +.assert (x25_da & $1F) = 0, lderror, "x25_da must be 32-byte aligned" +.assert (x25_cb & $1F) = 0, lderror, "x25_cb must be 32-byte aligned" +.assert (x25_e & $1F) = 0, lderror, "x25_e must be 32-byte aligned" +.assert (x25_scalar & $1F) = 0, lderror, "x25_scalar must be 32-byte aligned" +.assert (x25_u & $1F) = 0, lderror, "x25_u must be 32-byte aligned" +.assert (x25_result & $1F) = 0, lderror, "x25_result must be 32-byte aligned" +BSS_EOF + +cat > "$STAGING/data_x25519_rodata_raw.s" <<'RODATA_EOF' +.setcpu "6502" + +; ============================================================================= +; data_x25519_rodata_raw.s — initialized lookup tables extracted from +; libs/x25519/src/data.s for the c64-https Phase C.5 integration. +; +; Routed to a dedicated X25519_RODATA segment so each backend cfg can +; place it in its own way: +; - UCI : X25519_RODATA -> CRYPTO_OVERLAY ($4200-$5FFF, 7.5 KB free) +; - ip65: X25519_RODATA -> CRYPTO_RESIDENT (will overflow until the +; ip65 memory map is restructured — currently a blocker for +; the ip65 path; reported by the integrator). +; The segment is declared with `align = $100` in the UCI cfg so the +; .align 256 directives below land on real page boundaries (sqr_lo +; and a24_b0 must start on a page for fe25519_sqr / fe25519_mul_a24). +; ============================================================================= + +.export mul38_lo_tab, mul38_hi_tab +.export sqr_lo, sqr_hi +.export a24_b0, a24_b1, a24_b2, a24_b3 + +.export x25_basepoint, fe_p + +.segment "X25519_RODATA" + +; --- Initialized constants (32 bytes each, 32-byte aligned via +; X25519_RODATA's align = $100 segment alignment + .align 32) --- + .align 32 +x25_basepoint: + .byte 9 + .res 31, 0 +fe_p: + .byte $ed + .res 30, $ff + .byte $7f + +.assert (x25_basepoint & $1F) = 0, lderror, "x25_basepoint must be 32-byte aligned" +.assert (fe_p & $1F) = 0, lderror, "fe_p must be 32-byte aligned" + +; mul_by_38 lookup tables (256 B each) + .align 256 +mul38_lo_tab: + .byte 0 + .repeat 255, i + .byte <((i+1) * 38) + .endrepeat +mul38_hi_tab: + .byte 0 + .repeat 255, i + .byte >((i+1) * 38) + .endrepeat + +; fe25519_sqr diagonal squaring tables (page-aligned) + .align 256 +sqr_lo: + .repeat 256, i + .byte <(i * i) + .endrepeat +sqr_hi: + .repeat 256, i + .byte >(i * i) + .endrepeat + +; fe25519_mul_a24 split tables (page-aligned) + .align 256 +a24_b0: + .repeat 256, i + .byte <(121665 * i) + .endrepeat +a24_b1: + .repeat 256, i + .byte <((121665 * i) >> 8) + .endrepeat +a24_b2: + .repeat 256, i + .byte <((121665 * i) >> 16) + .endrepeat +a24_b3: + .repeat 256, i + .byte <((121665 * i) >> 24) + .endrepeat + +RODATA_EOF + +# mul_8x8.s is intentionally NOT staged. The in-tree src/crypto/poly1305.s +# provides mul_8x8 / sqtab_init / poly_prod_lo / poly_prod_hi / +# sqtab_lo / sqtab_hi. The sibling's fe25519 + x25519_init imports those +# symbols; the in-tree link satisfies them. + +# --- Route CODE segments to CRYPTO_CODE --- +# The sibling uses `.segment "CODE"` (LOADER under c64-https) for all +# code sources. constants.s has no segment directive (pure equates), +# and the data was already split into purpose-built staged files above +# (data_x25519_bss_raw.s + data_x25519_rodata_raw.s). +for src in fe25519_raw x25519_raw x25519_init_raw; do + sed -i '' 's/^\.segment "CODE"$/.segment "CRYPTO_CODE"/' "$STAGING/$src.s" +done + +# --- Sanity: no leftover CODE segments in patched sources --- +for src in fe25519_raw x25519_raw x25519_init_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" + +for src in fe25519_raw x25519_raw x25519_init_raw data_x25519_bss_raw data_x25519_rodata_raw; do + "$CA65" \ + -I "$STAGING" \ + "${ZP_DEFINES[@]}" \ + -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" +done + +# --- Archive --- +rm -f "$ARCHIVE" +"$AR65" a "$ARCHIVE" \ + "$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" + +# --- Per-source byte counts --- +{ + echo "# x25519.a per-source byte counts (ca65 .o file sizes)" + for src in fe25519_raw x25519_raw x25519_init_raw data_x25519_bss_raw data_x25519_rodata_raw; do + bytes=$(wc -c < "$OBJ_DIR/$src.o") + printf '%-24s %d bytes (.o)\n' "$src" "$bytes" + done +} > "$SIZES" + +echo "built $ARCHIVE" +cat "$SIZES" diff --git a/tools/uci/_memory_policy.py b/tools/uci/_memory_policy.py new file mode 100644 index 0000000..7a7f426 --- /dev/null +++ b/tools/uci/_memory_policy.py @@ -0,0 +1,295 @@ +"""Shared MemoryPolicy / MemoryArbiter factory for the c64-https tools/uci/* scripts. + +The c64-https build can lay out its memory map in several different ways +depending on the BACKEND (ip65 vs uci) and the USE_X25519_SIBLING flag. +Hand-coding scratch DMA addresses in each test script invites silent +collisions when the layout changes (see issue surfaced by PR #41: +ROUTINE_ADDR=$4200 clobbered X25519_RODATA under USE_X25519_SIBLING=1). + +This module is the single source of truth for "where is c64-https +holding RAM under the current build?". It reads ``build/labels.txt`` +(produced by ld65) and converts the per-region ``___START__`` / +``___LAST__`` linker-emitted markers into a +:class:`~c64_test_harness.MemoryPolicy`. The returned policy lists +every defined memory region as reserved; ``unknown_policy=WARN`` so +stray harness writes outside the recognised layout surface as +``UserWarning`` instead of silent passes (this is the migration +default — see follow-ups below). + +Typical use in a ``tools/uci/*.py`` script (sibling import — the script +is executed directly so ``tools/uci/`` is on :data:`sys.path[0]`):: + + from _memory_policy import build_policy_and_arbiter + + policy, arbiter = build_policy_and_arbiter(LABELS_PATH, PRG_PATH) + transport.memory_policy = policy + routine_addr = arbiter.alloc(routine_len, name="trampoline") + sentinel_addr = arbiter.alloc(16, name="sentinel") + +The arbiter's allocations are NOT added to the policy by default — +i.e. ``transport.write_memory(routine_addr, ...)`` will not be +re-blocked by the policy on the second use. Call +``arbiter.policy_with_allocations()`` if you want each arbiter claim +to also become a reserved region (e.g. to catch a second piece of code +that bypasses the arbiter and writes to an arbiter-owned address). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from c64_test_harness import ( + MemoryArbiter, + MemoryPolicy, + MemoryRegion, + UnknownPolicy, +) +from c64_test_harness.verify import PrgFile + + +# Match the ld65 segment-boundary symbol shape: +# al C:4200 .__CRYPTO_OVERLAY_START__ +# al C:5100 .__CRYPTO_OVERLAY_LAST__ +# al C:1E00 .__CRYPTO_OVERLAY_SIZE__ +# We use ``START`` + ``SIZE`` to reserve the *declared* memory area +# (i.e. the full ``$4200-$5FFF`` rather than just $4200-$50FF). Reserving +# the declared area is the conservative choice: if a segment later +# grows into the trailing free range, the arbiter's prior allocation +# would silently collide. Reserving the whole declared area forces the +# arbiter to look for unused space *between* declared regions (e.g. +# $A000-$BFFF on UCI, $D000+ KERNAL ROM shadow, etc.). +# +# The CRYPTO_OVERLAY case is the deliberate exception: under +# USE_X25519_SIBLING=1 the X25519_RODATA + _BSS segments only fill the +# first $F00 bytes; the tail $5100-$5FFF is genuinely intended as +# harness scratch (the cfg comment says so explicitly). We re-add that +# tail as a safe_region in :func:`build_policy` so the arbiter can +# allocate there. +_SEGMENT_RX = re.compile( + r"^al\s+(?:C:)?([0-9A-Fa-f]+)\s+\.__([A-Za-z0-9_]+)_(START|LAST|SIZE)__\s*$" +) + +# Regions we never reserve, even if they appear in labels.txt. The +# arbiter and the policy need *some* unblocked range to allocate +# scratch from; the loader is the only memory region we declare as +# reserved-via-PRG separately. +_SKIP_REGIONS: frozenset[str] = frozenset({ + # Zero-page is below the arbiter's default window ($0200+) so + # listing here is belt-and-suspenders. + "ZP_CRYPTO", + "ZP_WIDE", + "ZP_IP65", + "LOADADDR", +}) + + +def _parse_segment_bounds(labels_path: Path) -> dict[str, tuple[int, int]]: + """Pull ``__NAME_START__`` / ``__NAME_SIZE__`` pairs out of labels.txt. + + Returns a dict keyed by region name; values are half-open + ``(start, end_exclusive)`` pairs covering the *declared* memory + area (not just the used subset). + + Reserving the declared area is the conservative choice — segments + can grow into their declared region at link time, so an arbiter + allocation made today inside the tail of a declared region would + silently collide tomorrow. + + Regions with no ``__NAME_SIZE__`` symbol, or with ``SIZE == 0``, + are skipped (matches ip65's zero-sized CRYPTO_OVERLAY alias). + """ + starts: dict[str, int] = {} + sizes: dict[str, int] = {} + with labels_path.open("r", encoding="utf-8") as fh: + for line in fh: + m = _SEGMENT_RX.match(line) + if not m: + continue + addr_hex, name, kind = m.groups() + addr = int(addr_hex, 16) + if kind == "START": + starts[name] = addr + elif kind == "SIZE": + sizes[name] = addr + # LAST is captured but unused — keeping the regex inclusive + # of it for forward-compatibility / debug. + bounds: dict[str, tuple[int, int]] = {} + for name, start in starts.items(): + size = sizes.get(name) + if not size: + continue + end = start + size + if end > 0x10000: + end = 0x10000 + if end <= start: + continue + bounds[name] = (start, end) + return bounds + + +def build_policy( + labels_path: str | Path, + prg_path: str | Path, + *, + unknown: UnknownPolicy = UnknownPolicy.WARN, + extra_reserved: tuple[MemoryRegion, ...] = (), +) -> MemoryPolicy: + """Build a c64-https-aware :class:`MemoryPolicy`. + + Every defined memory region in ``labels_path`` becomes a reserved + region. The PRG load image (``prg_path``) is also reserved via + :meth:`MemoryPolicy.from_prg` — usually redundant with the + ``LOADER`` / ``NET_CODE`` segment markers but it costs nothing and + guards against builds where someone changes the segment names. + + ``unknown=WARN`` by default so writes outside any declared region + surface as ``UserWarning`` without breaking the test. Tighten to + ``UnknownPolicy.DENY`` once the call sites that surface warnings + have been audited. + + ``extra_reserved`` is appended after the labels-derived regions, + useful for declaring "this scratch range belongs to a sibling test + process" or similar runtime-only constraints. + """ + labels_path = Path(labels_path) + prg_path = Path(prg_path) + # ``prg_path`` is accepted so future callers can attach the + # ``from_prg`` PRG-image reservation. We deliberately do NOT use + # it here, because the c64-https PRG declares ``fill = yes`` on + # several memory regions; the resulting PRG image spans + # $0801-$BFFF as one contiguous load. That subsumes the gaps + # where the arbiter would otherwise place harness scratch + # (e.g. CRYPTO_OVERLAY's $5100-$5FFF tail when X25519 occupies + # $4200-$50FF). The per-segment ``___START__`` / + # ``___LAST__`` markers from labels.txt give us the actual + # used ranges, which is exactly what we want. + _ = prg_path # acknowledged-but-unused; future-proofing + + bounds = _parse_segment_bounds(labels_path) + used_ends = _parse_used_ends(labels_path) + reserved: list[MemoryRegion] = [] + for name in sorted(bounds): + if name in _SKIP_REGIONS: + continue + start, declared_end = bounds[name] + # For CRYPTO_OVERLAY specifically, reserve only the *used* + # portion so the unused tail is available as harness scratch. + # The cfg comment on CRYPTO_OVERLAY explicitly designates the + # tail as harness/overlay-test territory, and + # tools/uci/test_https_local.py has used this tail in + # production since PR #41. + # + # Other memory regions (UCI_BSS_REGION, TCP_BUF, etc.) reserve + # the full declared area because their "unused" tail may + # actually be written to at runtime (TCP_BUF holds + # tcp_recv_buf, declared as a BSS segment so labels.txt's + # used-end is $C000 even though the ring fills the full + # $C000-$CFFF at runtime). + if name == "CRYPTO_OVERLAY": + used = used_ends.get(name, declared_end) + reserve_end = max(min(used, declared_end), start) + if reserve_end <= start: + # Empty CRYPTO_OVERLAY (no overlay segments linked) — + # nothing to reserve; the whole region becomes free. + continue + reserved.append( + MemoryRegion(start, reserve_end, note=f"segment:{name}(used)") + ) + else: + reserved.append( + MemoryRegion(start, declared_end, note=f"segment:{name}") + ) + + reserved.extend(extra_reserved) + return MemoryPolicy( + reserved_regions=tuple(reserved), + unknown=unknown, + ) + + +def _parse_used_ends(labels_path: Path) -> dict[str, int]: + """Return the half-open ``___LAST__`` value for every region.""" + ends: dict[str, int] = {} + with labels_path.open("r", encoding="utf-8") as fh: + for line in fh: + m = _SEGMENT_RX.match(line) + if not m: + continue + addr_hex, name, kind = m.groups() + if kind == "LAST": + ends[name] = int(addr_hex, 16) + return ends + + +def attach_arbiter_safe_regions( + policy: MemoryPolicy, + arbiter: MemoryArbiter, +) -> MemoryPolicy: + """Promote every arbiter allocation to a ``safe_region`` in ``policy``. + + Use this AFTER all your ``arbiter.alloc(...)`` calls but BEFORE + you assign the policy to ``transport.memory_policy``. Without it, + every ``transport.write_memory(arbiter_alloced_addr, ...)`` call + fires a ``UserWarning`` (because the address falls outside any + declared safe_region while the policy's ``unknown`` setting is + ``WARN``). Promoting the allocations silences that noise while + still catching writes that *aren't* arbiter-blessed (zero-page, + keyboard buffer, etc.). + """ + out = policy + for start, last_incl, name in arbiter.allocations: + out = out.with_safe( + MemoryRegion(start, last_incl + 1, note=f"arbiter:{name}") + ) + return out + + +def build_arbiter( + policy: MemoryPolicy, + *, + window: tuple[int, int] = (0x4000, 0x5FFF), +) -> MemoryArbiter: + """Build a :class:`MemoryArbiter` scoped to the CRYPTO_OVERLAY range. + + The default window targets the $4000-$5FFF span — under ip65 most + of this is filled by NET_BSS ($4000-$4F8B) + NET_BSS_TAIL + ($4F8C-$5FFF), but under UCI the CRYPTO_OVERLAY's tail ($5100-$5FFF + when USE_X25519_SIBLING=1, or all of $4200-$5FFF when not) is the + natural home for harness scratch: it's RAM-backed, inside the + LOADER/NET_CODE write-banked region, and not used by any code + that the PRG ships with under normal operation. + + Pass ``window=(0xC000, 0xCFFF)`` to allocate inside the TCP_BUF + range instead, but be aware that ``tcp_recv_buf`` lives there and + is actively used by the TLS record path — collisions there will + silently corrupt incoming records. + """ + return MemoryArbiter(policy=policy, window=window) + + +def build_policy_and_arbiter( + labels_path: str | Path, + prg_path: str | Path, + *, + unknown: UnknownPolicy = UnknownPolicy.WARN, + extra_reserved: tuple[MemoryRegion, ...] = (), + window: tuple[int, int] = (0x4000, 0x5FFF), +) -> tuple[MemoryPolicy, MemoryArbiter]: + """One-shot convenience: build the policy + an arbiter scoped to ``window``.""" + policy = build_policy( + labels_path, + prg_path, + unknown=unknown, + extra_reserved=extra_reserved, + ) + arbiter = build_arbiter(policy, window=window) + return policy, arbiter + + +__all__ = [ + "build_policy", + "build_arbiter", + "build_policy_and_arbiter", + "attach_arbiter_safe_regions", +] diff --git a/tools/uci/bench_ecdsa_u64e.py b/tools/uci/bench_ecdsa_u64e.py index b5ff513..c31976b 100644 --- a/tools/uci/bench_ecdsa_u64e.py +++ b/tools/uci/bench_ecdsa_u64e.py @@ -65,6 +65,8 @@ from c64_test_harness.uci_network import enable_uci, disable_uci from c64_test_harness.keyboard import send_text +from _memory_policy import build_policy_and_arbiter + HOST = os.environ.get("U64_HOST", "192.168.1.81") REPO_ROOT = Path(__file__).resolve().parents[2] @@ -77,11 +79,16 @@ ) ) -ROUTINE_ADDR = 0x4200 -RUN_SENTINEL = 0x4540 # $AA = running, cleared after done -DONE_SENTINEL = 0x4541 # $55 = routine completed -CARRY_BYTE = 0x4542 # P register captured via PHP/PLA -PROGRESS_BYTE = 0x4543 # last reached progress marker +# Scratch addresses are arbiter-allocated at runtime (see main()) so the +# bench never collides with X25519_RODATA/BSS under USE_X25519_SIBLING=1 +# or with any future CRYPTO_OVERLAY-resident segment. The historical +# values were $4200 (ROUTINE_ADDR) + $4540-$4543 — *inside* the +# X25519_RODATA + X25519_BSS span under the sibling flag. +ROUTINE_ADDR: int = -1 # arbiter.alloc(320, name="ecdsa_stub") +RUN_SENTINEL: int = -1 # arbiter.alloc(1, name="run_sentinel") +DONE_SENTINEL: int = -1 # arbiter.alloc(1, name="done_sentinel") +CARRY_BYTE: int = -1 # arbiter.alloc(1, name="carry_byte") +PROGRESS_BYTE: int = -1 # arbiter.alloc(1, name="progress_byte") RUN_VALUE = 0xAA DONE_VALUE = 0x55 @@ -376,6 +383,26 @@ def main() -> int: vectors = json.loads(VECTORS_PATH.read_text())["vectors"] print(f"Loaded {len(vectors)} vectors") + # --- Memory policy + arbiter: replace the hardcoded $4200-$4543 + # scratch addresses (which silently overlapped X25519_RODATA/BSS + # under USE_X25519_SIBLING=1) with arbiter-allocated ones derived + # from build/labels.txt. The transport is created inside the + # try-block below; we attach the policy there. + global ROUTINE_ADDR, RUN_SENTINEL, DONE_SENTINEL + global CARRY_BYTE, PROGRESS_BYTE + memory_policy, arbiter = build_policy_and_arbiter(LABELS_PATH, PRG_PATH) + ROUTINE_ADDR = arbiter.alloc(320, name="ecdsa_stub") + RUN_SENTINEL = arbiter.alloc(1, name="run_sentinel") + DONE_SENTINEL = arbiter.alloc(1, name="done_sentinel") + CARRY_BYTE = arbiter.alloc(1, name="carry_byte") + PROGRESS_BYTE = arbiter.alloc(1, name="progress_byte") + print( + f"MemoryPolicy reserved {len(memory_policy.reserved_regions)}" + f" region(s); arbiter allocations:" + ) + for base, last, note in arbiter.allocations: + print(f" ${base:04X}-${last:04X} {note}") + stub = _build_stub(labels) prg = PRG_PATH.read_bytes() run_dir: Path | None = None @@ -399,6 +426,10 @@ def main() -> int: try: client = Ultimate64Client(host=HOST, timeout=60.0) transport = Ultimate64Transport(host=HOST, timeout=60.0, client=client) + # MemoryPolicy guard: any subsequent write_memory(addr, ...) + # that overlaps a reserved c64-https segment raises before + # crossing the wire. + transport.memory_policy = memory_policy print("Enabling UCI...") enable_uci(client) diff --git a/tools/uci/phase3_tcp_echo.py b/tools/uci/phase3_tcp_echo.py index 30395b0..c1fd0f0 100644 --- a/tools/uci/phase3_tcp_echo.py +++ b/tools/uci/phase3_tcp_echo.py @@ -42,25 +42,29 @@ from c64_test_harness.uci_network import enable_uci, disable_uci from c64_test_harness.keyboard import send_text +from _memory_policy import build_policy_and_arbiter + HOST = os.environ.get("U64_HOST", "192.168.1.81") REPO_ROOT = Path(__file__).resolve().parents[2] PRG_PATH = REPO_ROOT / "build" / "c64-https.prg" LABELS_PATH = REPO_ROOT / "build" / "labels.txt" -ROUTINE_ADDR = 0x4200 -HOST_BUF_ADDR = 0x4400 # mirrors uci_host_buf — routine will also - # stage via net_dns_resolve so the adapter - # canonicalizes the copy itself. -TEST_STRING_ADDR = 0x4440 -RESULT_BUF_ADDR = 0x4500 -SENTINEL_ADDR = 0x4540 -PROGRESS_ADDR = 0x4541 -CONNECT_CARRY_ADDR = 0x4542 -SEND_CARRY_ADDR = 0x4543 -RESULT_LEN_ADDR = 0x4544 -POLL_COUNT_ADDR = 0x4545 -RECV_BYTES_ADDR = 0x4500 +# Scratch addresses arbiter-allocated in main(). RECV_BYTES_ADDR +# aliases RESULT_BUF_ADDR (both point at the same drained-bytes +# buffer) — only one allocation is made; the alias is just a +# Python-name convenience kept from the original layout. +ROUTINE_ADDR: int = -1 +HOST_BUF_ADDR: int = -1 +TEST_STRING_ADDR: int = -1 +RESULT_BUF_ADDR: int = -1 +SENTINEL_ADDR: int = -1 +PROGRESS_ADDR: int = -1 +CONNECT_CARRY_ADDR: int = -1 +SEND_CARRY_ADDR: int = -1 +RESULT_LEN_ADDR: int = -1 +POLL_COUNT_ADDR: int = -1 +RECV_BYTES_ADDR: int = -1 SENTINEL_VALUE = 0x42 ECHO_PORT = 7777 @@ -325,6 +329,29 @@ def main() -> int: for n in required: print(f" {n:18s} = ${labels[n]:04X}") + global ROUTINE_ADDR, HOST_BUF_ADDR, TEST_STRING_ADDR, RESULT_BUF_ADDR + global SENTINEL_ADDR, PROGRESS_ADDR, CONNECT_CARRY_ADDR + global SEND_CARRY_ADDR, RESULT_LEN_ADDR, POLL_COUNT_ADDR, RECV_BYTES_ADDR + memory_policy, arbiter = build_policy_and_arbiter(LABELS_PATH, PRG_PATH) + ROUTINE_ADDR = arbiter.alloc(256, name="trampoline") + HOST_BUF_ADDR = arbiter.alloc(64, name="host_buf") + TEST_STRING_ADDR = arbiter.alloc(32, name="test_string") + RESULT_BUF_ADDR = arbiter.alloc(128, name="result_buf") + RECV_BYTES_ADDR = RESULT_BUF_ADDR # alias — both names refer to the + # drained-bytes buffer + SENTINEL_ADDR = arbiter.alloc(1, name="sentinel") + PROGRESS_ADDR = arbiter.alloc(1, name="progress") + CONNECT_CARRY_ADDR = arbiter.alloc(1, name="connect_carry") + SEND_CARRY_ADDR = arbiter.alloc(1, name="send_carry") + RESULT_LEN_ADDR = arbiter.alloc(1, name="result_len") + POLL_COUNT_ADDR = arbiter.alloc(1, name="poll_count") + print( + f"MemoryPolicy reserved {len(memory_policy.reserved_regions)}" + f" region(s); arbiter allocations:" + ) + for base, last, note in arbiter.allocations: + print(f" ${base:04X}-${last:04X} {note}") + test_host_ip = _detect_local_ip(HOST) print(f"Dev host LAN IP : {test_host_ip}") print(f"Echo port : {ECHO_PORT}") @@ -366,6 +393,7 @@ def main() -> int: try: client = Ultimate64Client(host=HOST, timeout=15.0) transport = Ultimate64Transport(host=HOST, timeout=15.0, client=client) + transport.memory_policy = memory_policy print("Enabling UCI (Command Interface)...") enable_uci(client) diff --git a/tools/uci/test_http_live.py b/tools/uci/test_http_live.py index 2c0c9ae..8086e01 100644 --- a/tools/uci/test_http_live.py +++ b/tools/uci/test_http_live.py @@ -25,18 +25,21 @@ from c64_test_harness.uci_network import enable_uci, disable_uci from c64_test_harness.keyboard import send_text +from _memory_policy import build_policy_and_arbiter + HOST = os.environ.get("U64_HOST", "192.168.1.81") REPO_ROOT = Path(__file__).resolve().parents[2] PRG_PATH = REPO_ROOT / "build" / "c64-https.prg" LABELS_PATH = REPO_ROOT / "build" / "labels.txt" -ROUTINE_ADDR = 0x4200 -HOST_STR_ADDR = 0x4400 -PATH_STR_ADDR = 0x4440 -SENTINEL_ADDR = 0x4540 -PROGRESS_ADDR = 0x4541 -CARRY_FLAG_ADDR = 0x4542 +# Arbiter-allocated at runtime; see tools/uci/_memory_policy.py. +ROUTINE_ADDR: int = -1 +HOST_STR_ADDR: int = -1 +PATH_STR_ADDR: int = -1 +SENTINEL_ADDR: int = -1 +PROGRESS_ADDR: int = -1 +CARRY_FLAG_ADDR: int = -1 SENTINEL_VALUE = 0xBB LIVE_HOSTNAME = "www.zimmers.net" @@ -220,6 +223,22 @@ def main() -> int: for n in sorted(required): print(f" {n:20s} = ${labels[n]:04X}") + 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) + 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") + SENTINEL_ADDR = arbiter.alloc(1, name="sentinel") + PROGRESS_ADDR = arbiter.alloc(1, name="progress") + CARRY_FLAG_ADDR = arbiter.alloc(1, name="carry_flag") + print( + f"MemoryPolicy reserved {len(memory_policy.reserved_regions)}" + f" region(s); arbiter allocations:" + ) + for base, last, note in arbiter.allocations: + print(f" ${base:04X}-${last:04X} {note}") + print(f"\nTarget : {LIVE_HOSTNAME}:{LIVE_PORT}") hostname_bytes = LIVE_HOSTNAME.encode("ascii") @@ -242,6 +261,7 @@ def main() -> int: try: client = Ultimate64Client(host=HOST, timeout=15.0) transport = Ultimate64Transport(host=HOST, timeout=15.0, client=client) + transport.memory_policy = memory_policy print("Enabling UCI...") enable_uci(client) diff --git a/tools/uci/test_http_local.py b/tools/uci/test_http_local.py index 7ba9981..4722a5f 100644 --- a/tools/uci/test_http_local.py +++ b/tools/uci/test_http_local.py @@ -33,18 +33,25 @@ from c64_test_harness.uci_network import enable_uci, disable_uci from c64_test_harness.keyboard import send_text +from _memory_policy import build_policy_and_arbiter + HOST = os.environ.get("U64_HOST", "192.168.1.81") REPO_ROOT = Path(__file__).resolve().parents[2] PRG_PATH = REPO_ROOT / "build" / "c64-https.prg" LABELS_PATH = REPO_ROOT / "build" / "labels.txt" -ROUTINE_ADDR = 0x4200 -HOST_STR_ADDR = 0x4400 # where we DMA the hostname string -PATH_STR_ADDR = 0x4440 # where we DMA the path string -SENTINEL_ADDR = 0x4540 -PROGRESS_ADDR = 0x4541 -CARRY_FLAG_ADDR = 0x4542 +# Scratch addresses are arbiter-allocated in main() from +# build/labels.txt. Hardcoded values used to be $4200/$4400/$4440/ +# $4540/$4541/$4542 — *inside* the X25519_RODATA/_BSS span when the +# PRG is built with USE_X25519_SIBLING=1, which would silently corrupt +# the x25519 tables and break TLS unrelated to this http test. +ROUTINE_ADDR: int = -1 +HOST_STR_ADDR: int = -1 +PATH_STR_ADDR: int = -1 +SENTINEL_ADDR: int = -1 +PROGRESS_ADDR: int = -1 +CARRY_FLAG_ADDR: int = -1 SENTINEL_VALUE = 0xAA HTTP_PORT = 8080 @@ -307,6 +314,25 @@ def main() -> int: for n in sorted(required): print(f" {n:20s} = ${labels[n]:04X}") + # Build a MemoryPolicy from the current PRG's segment layout and + # allocate scratch addresses from CRYPTO_OVERLAY's unused tail. + # See tools/uci/_memory_policy.py for the policy shape. + 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) + 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") + SENTINEL_ADDR = arbiter.alloc(1, name="sentinel") + PROGRESS_ADDR = arbiter.alloc(1, name="progress") + CARRY_FLAG_ADDR = arbiter.alloc(1, name="carry_flag") + print( + f"MemoryPolicy reserved {len(memory_policy.reserved_regions)}" + f" region(s); arbiter allocations:" + ) + for base, last, note in arbiter.allocations: + print(f" ${base:04X}-${last:04X} {note}") + test_host_ip = _detect_local_ip(HOST) print(f"\nDev host LAN IP : {test_host_ip}") print(f"HTTP port : {HTTP_PORT}") @@ -356,6 +382,7 @@ def main() -> int: try: client = Ultimate64Client(host=HOST, timeout=15.0) transport = Ultimate64Transport(host=HOST, timeout=15.0, client=client) + transport.memory_policy = memory_policy print("Enabling UCI...") enable_uci(client) diff --git a/tools/uci/test_https_local.py b/tools/uci/test_https_local.py index c52434d..f22e181 100644 --- a/tools/uci/test_https_local.py +++ b/tools/uci/test_https_local.py @@ -82,6 +82,8 @@ from c64_test_harness.keyboard import send_text from c64_test_harness.labels import Labels +from _memory_policy import build_policy_and_arbiter + DEBUG_CAPTURE_ENABLED = os.environ.get("DEBUG_CAPTURE", "1") != "0" UCI_DEBUG_BASE_DIR = Path( @@ -117,12 +119,38 @@ def _keep_cycle(word: int) -> bool: CERT_PATH = REPO_ROOT / "tools" / "https_e2e" / "certs" / "server.pem" KEY_PATH = REPO_ROOT / "tools" / "https_e2e" / "certs" / "server.key" -ROUTINE_ADDR = 0x4200 -HOST_STR_ADDR = 0x4400 -PATH_STR_ADDR = 0x4440 -SENTINEL_ADDR = 0x4540 -PROGRESS_ADDR = 0x4541 -CARRY_FLAG_ADDR = 0x4542 +# NOTE: ROUTINE_ADDR and friends MUST sit in a region that does NOT +# collide with the production CRYPTO_OVERLAY layout. Under +# USE_X25519_SIBLING=1 the sibling X25519 rodata + bss buffers occupy +# $4200-$50FF (see cfg/c64-https-uci.cfg X25519_RODATA / X25519_BSS). +# Placing the DMA routine at $4200 (the historical value) silently +# clobbered x25_basepoint, fe_p, mul38_lo_tab, etc., producing wrong +# X25519 output during the TLS handshake. +# +# These scratch addresses used to be hardcoded ($4200 originally, then +# $5100 to dodge the X25519 RODATA/BSS span under +# USE_X25519_SIBLING=1). The hardcoded layout was the antipattern that +# made the X25519 collision possible in the first place — silently +# wrong-but-deterministic crypto output, with no diagnostic until the +# TLS handshake failed half a minute later. +# +# They are now assigned by a :class:`MemoryArbiter` (see +# ``tools/uci/_memory_policy.py``) at runtime, after we have read +# ``build/labels.txt`` and built a :class:`MemoryPolicy` reflecting the +# *current* build's memory map (BACKEND + USE_X25519_SIBLING are +# observed via the segment markers ld65 emits). The transport's +# memory_policy will then catch any future stray write into a +# c64-https segment before the byte crosses the wire. +# +# Sizes are conservative — the trampoline is ~110 B today; we allow +# 256 B for headroom. HOST/PATH strings are ASCII-NUL-terminated and +# fit easily inside their 64-byte slots. +ROUTINE_ADDR: int = -1 # arbiter.alloc(256, name="trampoline") +HOST_STR_ADDR: int = -1 # arbiter.alloc(64, name="host_str") +PATH_STR_ADDR: int = -1 # arbiter.alloc(64, name="path_str") +SENTINEL_ADDR: int = -1 # arbiter.alloc(1, name="sentinel") +PROGRESS_ADDR: int = -1 # arbiter.alloc(1, name="progress") +CARRY_FLAG_ADDR: int = -1 # arbiter.alloc(1, name="carry_flag") SENTINEL_VALUE = 0xAA @@ -995,6 +1023,29 @@ def main() -> int: for n in sorted(required): print(f" {n:22s} = ${labels[n]:04X}") + # --- 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. + 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) + 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") + SENTINEL_ADDR = arbiter.alloc(1, name="sentinel") + PROGRESS_ADDR = arbiter.alloc(1, name="progress") + CARRY_FLAG_ADDR = arbiter.alloc(1, name="carry_flag") + print( + f"\nMemoryPolicy reserved {len(memory_policy.reserved_regions)}" + f" region(s); arbiter allocations:" + ) + for base, last, note in arbiter.allocations: + print(f" ${base:04X}-${last:04X} {note}") + test_host_ip = _detect_local_ip(HOST) print(f"\nDev host LAN IP : {test_host_ip}") print(f"Cert / key : {CERT_PATH} / {KEY_PATH}") @@ -1070,6 +1121,12 @@ def main() -> int: try: client = Ultimate64Client(host=HOST, timeout=15.0) transport = Ultimate64Transport(host=HOST, timeout=15.0, client=client) + # Attach the c64-https-aware MemoryPolicy. Every subsequent + # transport.write_memory(...) is checked against the policy + # *before* the byte crosses the wire — a collision into a + # reserved segment now raises MemoryPolicyError instead of + # silently corrupting C64 RAM. + transport.memory_policy = memory_policy print("Enabling UCI...") enable_uci(client)