From 76d876ca61ae7ff1e71a6d34c70f0cac6c8e2b8a Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:42:47 -0500 Subject: [PATCH 1/2] libs/x25519 v0.6.0 -> v0.10.0, and make the sibling actually link Bumps the pin to upstream latest (68ae0ef) and migrates the integration wrapper through the four upstream changes a bump has to cross. Along the way USE_X25519_SIBLING=1 links and produces correct results for the first time, on UCI. The pin mattered even though nothing links it: v0.6.0 carries upstream c64-x25519#64, where x25519_scalarmult returns a deterministically wrong shared secret for a peer u with bit 255 set (v0.4.0-v0.6.0, fixed v0.7.0). In-tree is not affected, so nothing shipped was at risk, but the pin was a landmine under the flag. v0.10.0 removes it and this change verifies the removal instead of trusting the changelog: RFC 7748 sec 5.2 vector 2 (u ends 0x93) PASSes against a sibling-linked PRG. Both default PRGs are byte-identical to master (ip65 417c7085..., UCI 741f0e8c...). Every source change is scoped to a flag no shipped configuration sets, verified by sha256 rather than by reading the diff. Five defects sat between "bump the pin" and "it works"; three are ours, and every one of them is silent at the point it goes wrong: 1. The wrapper's segment rewrite had quietly become a no-op. It matched `.segment "CODE"`, renamed to LIB_X25519_CODE in v0.8.0 (which also added LIB_X25519_INIT_CODE). A sed matching nothing assembles fine and only fails at ld65 as an unplaced segment. The check now greps for any surviving LIB_X25519_* name. 2. x25_x1, the buffer v0.7.0's #64 fix introduced, was missing from the hand-emitted BSS module. Costs zero bytes -- it lands in alignment padding that already existed. 3. ct_mul_8x8 / smc_sum_a_imm / smc_diff_a_imm went unresolved: from v0.7.0 the sibling imports the contract SS8.3 multiply body unconditionally. in-tree poly1305.s already has it behind .ifdef USE_NISTCURVES_ONCHIP; gate widened. This one was invisible until the placement problem was fixed -- ld65 aborts on memory-area overflow before it checks symbols. 4. LIB_SHARED_SQTAB_BASE was stale. data.s drops mul_dma_lo/hi (512 B) from TABLES_BSS under this flag, so sqtab_lo links at $B800, not the $BC00 the wrapper baked in. in-tree sqtab_init filled one address, the sibling read the other: no link error, no boot failure, just a wrong shared secret. Now guarded post-link in the $(PRG) recipe, mirroring what USE_NISTCURVES_ONCHIP already does. 5. An REU bank collision this repo had already analysed and dismissed. boot.s STASHes the 8 KB CRYPTO_OVERLAY slot to REU_OVERLAY_X25519 at boot, unconditionally under the flag; at $30000 that is bank 3 = X25519_REU_BANK + 3, the sibling's 17th-bit-carry table, clobbered right after reu_mul_init built it. reu_layout.inc called exactly this overlap THEORETICAL because no TLS caller invokes crypto_swap_to_x25519 -- true, and irrelevant: the damaging access is a write from boot, not a read from a swap. The analysis only asked who reads the reservation. Relocated to bank 6, scoped to the flag so the default PRGs do not move, and the stale note corrected in place rather than deleted. The signature is diagnostic and worth keeping: fe25519_mul correct, add/sub/mul_a24 correct, fe25519_sqr wrong. Banks 0/1 survive the stash; bank 3 is read only by fe25519_sqr's SQR_DMA_K>0 path. What the bump is worth in bytes (od65 on the staged objects): hot code 3,560 B against v0.6.0's 4,207 B. v0.8.0's cold-segment split moves 666 B of boot-only init out of the hot region, and that -647 B is the entire reason UCI fits. src/zp_config.s is byte-identical across the range, so the wrapper's zero-page time-sharing analysis needed no revisiting. ip65 still does not link, and the blocker is ours, not upstream's: X25519_RODATA overflows its 4,212 B CRYPTO_OVERLAY by 2,048 B, and those 2,304 B of tables are hand-emitted by this wrapper. No submodule move fixes it; it needs a memory-map change. No cfg/*.cfg touched. The sibling currently reaches CRYPTO_OVERLAY by borrowing the X25519_RODATA segment name for code, which works but is misnamed; the clean form is a cfg declaring the SPEC SS4 names, and both the sed and the two Makefile exports disappear when it lands. Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 34 ++++++ libs/x25519 | 2 +- src/boot.s | 18 ++- src/crypto/poly1305.s | 20 ++- src/crypto/shared/reu_layout.inc | 63 +++++++++- tools/integration/build_x25519.sh | 197 ++++++++++++++++++++++++++---- 6 files changed, 301 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index f425912..1de8e2f 100644 --- a/Makefile +++ b/Makefile @@ -116,6 +116,30 @@ 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 + +# Split the sibling's code across CRYPTO_HOT and CRYPTO_OVERLAY. This is +# what makes USE_X25519_SIBLING=1 link at all under UCI, so it is a +# default rather than something the operator has to know. +# +# The sibling's 4,226 B of code does not fit either region whole: +# CRYPTO_HOT has ~3.3 KB of room for it (measured), and CRYPTO_OVERLAY +# has ~3.7 KB once the sibling's own 3,840 B of tables and BSS are in +# there. Leaving the ladder (717 B) and the boot-only table init (666 B) +# in CRYPTO_OVERLAY satisfies both. Under USE_X25519_SIBLING=1 that +# region is not a paged overlay -- both embed flags that page it are +# mutually exclusive with this one -- so it is plain resident RAM. +# +# X25519_RODATA is the wrong NAME for executable code; it is used only +# because it is the one segment both backend cfgs already route into +# CRYPTO_OVERLAY. The clean form is a cfg declaring the SPEC §4 names +# (LIB_X25519_CODE / LIB_X25519_INIT_CODE / LIB_X25519_DATA); at that +# point these two lines and the wrapper's sed both go away. +# +# ip65 does not link either way -- its CRYPTO_OVERLAY is 4,212 B and +# already holds TLS_CODE + CRYPTO_AUX_CODE, so X25519_RODATA overflows +# it by 2,048 B (2,816 B with these settings). See build_x25519.sh. +export X25519_INIT_SEGMENT ?= X25519_RODATA +export X25519_SEG_LADDER ?= X25519_RODATA endif # Phase C.5: under USE_X25519_SIBLING=1, evict the in-tree X25519 @@ -279,6 +303,16 @@ ifeq ($(USE_NISTCURVES_ONCHIP),1) @grep -q '^al C:BC00 \.sqtab_reserved' $(LABELS) || \ { echo 'ERROR: sqtab_reserved is not at $$BC00 — TABLES_BSS layout drifted; realign LIB_SHARED_SQTAB_BASE in tools/integration/build_nistcurves_p256.sh'; exit 1; } endif +ifeq ($(USE_X25519_SIBLING),1) + # Same invariant, other sibling: build_x25519.sh bakes sqtab_lo/hi + # into the sibling at X25519_SQTAB_BASE while in-tree sqtab_init + # fills the real table wherever ld65 put it. Disagreement is neither + # a link nor a boot failure, just a wrong shared secret. Rationale + # and the $$B800-vs-$$BC00 history: tools/integration/build_x25519.sh. + @grep -q '^al C:B800 \.sqtab_lo' $(LABELS) || \ + { echo 'ERROR: sqtab_lo is not at $$B800 — TABLES_BSS layout drifted; realign X25519_SQTAB_BASE in tools/integration/build_x25519.sh'; \ + grep ' \.sqtab_lo$$' $(LABELS); exit 1; } +endif # 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 diff --git a/libs/x25519 b/libs/x25519 index 95fdd70..68ae0ef 160000 --- a/libs/x25519 +++ b/libs/x25519 @@ -1 +1 @@ -Subproject commit 95fdd705b0f7d780cada3dee08158084d327c3f9 +Subproject commit 68ae0eff065cb02324742265c466cbb4f7801c69 diff --git a/src/boot.s b/src/boot.s index a243251..4154335 100644 --- a/src/boot.s +++ b/src/boot.s @@ -994,11 +994,19 @@ reu_p384_overlay_init: ; ; 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. +; STASHes the slot bytes to REU_OVERLAY_X25519 (bank 6, $60000 under +; this flag) 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. +; +; THIS WRITE IS THE ONE THAT MATTERS FOR THE REU BANK MAP. It runs +; unconditionally at boot whether or not any swap ever happens, so it +; is not covered by the "no TLS caller invokes crypto_swap_to_x25519" +; argument that reu_layout.inc used to declare the bank-3 overlap +; theoretical. At $30000 it destroyed the sibling's 17th-bit-carry +; table right after reu_mul_init built it, breaking fe25519_sqr (and +; only fe25519_sqr) -- see the relocation note in reu_layout.inc. ; ; NB: this stashes the *initialized* portion of CRYPTO_OVERLAY (the ; sibling's rodata tables) plus any zero-init BSS bytes that fall in diff --git a/src/crypto/poly1305.s b/src/crypto/poly1305.s index 64882b6..992c471 100644 --- a/src/crypto/poly1305.s +++ b/src/crypto/poly1305.s @@ -201,7 +201,7 @@ sqtab_init: ; Uses identity: a*b = sqtab[a+b] - sqtab[|a-b|] ; Clobbers: A, X, Y ; ============================================================================= -.ifdef USE_NISTCURVES_ONCHIP +.if .defined(USE_NISTCURVES_ONCHIP) .or .defined(USE_X25519_SIBLING) ; --- c64-lib-contract §8.3 canonical body (issue #69 integration) --- ; The sibling's FP_ONCHIP_MUL row generator (og_common, rebuilt with ; SHARED_CT_MUL_8X8) imports ct_mul_8x8 + the SMC bake sites from the @@ -210,6 +210,22 @@ sqtab_init: ; smc_sum_a_imm+1 / smc_diff_a_imm+1 once per row, passes b in Y. ; The legacy in-tree convention (A=a, X=b, re-baked per call) is kept ; as the thin `mul_8x8` shim for poly1305/fe25519 call sites. +; +; USE_X25519_SIBLING selects this body too, as of the c64-x25519 +; v0.10.0 bump. From v0.7.0 the sibling's x25519_init.s imports +; ct_mul_8x8 + both SMC bake sites UNCONDITIONALLY (it used to carry +; its own copy in mul_8x8.s, which this wrapper does not stage because +; that file's other exports collide with in-tree ones). Without this +; arm the sibling link dies on three unresolved externals — and it +; dies *behind* the memory-area overflows, so it only becomes visible +; once the placement problem is solved. +; +; The two arms are functionally equivalent implementations of the same +; a*b -> poly_prod_lo/hi contract, so widening the gate is a strict +; no-op for every configuration that does not set one of these flags: +; the default REU builds on both backends stay byte-identical (verified +; by PRG sha256, not by inspection). The two flags are mutually +; exclusive at Makefile:90, so this arm is never selected twice. .export ct_mul_8x8 .export smc_sum_a_imm, smc_diff_a_imm @@ -306,7 +322,7 @@ mul_8x8: sbc sqtab_hi,y sta poly_prod_hi rts -.endif ; USE_NISTCURVES_ONCHIP +.endif ; USE_NISTCURVES_ONCHIP / USE_X25519_SIBLING ; ============================================================================= ; poly1305_multiply - Multiply h (17 bytes) by r (16 bytes), reduce mod 2^130-5 diff --git a/src/crypto/shared/reu_layout.inc b/src/crypto/shared/reu_layout.inc index 6a2d975..f627d24 100644 --- a/src/crypto/shared/reu_layout.inc +++ b/src/crypto/shared/reu_layout.inc @@ -71,13 +71,55 @@ REU_OVERLAY_P256_VERIFY = REU_OVERLAY_P256 ; 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). +; +; *** RELOCATED OFF BANK 3 — THE COLLISION BELOW IS NOT THEORETICAL *** +; +; The "collision note" at the bottom of this file called the bank-3/4/5 +; overlap with the sibling's tables theoretical, on the grounds that no +; TLS caller ever invokes `crypto_swap_to_x25519`. That reasoning misses +; the write that actually happens: `src/boot.s` STASHes the whole 8 KB +; CRYPTO_OVERLAY slot to REU_OVERLAY_X25519 unconditionally under +; `.ifdef USE_X25519_SIBLING`, at boot, whether or not a swap is ever +; performed. At $30000 that landed exactly on bank 3 = +; X25519_REU_BANK + 3, the sibling's 17th-bit-carry table, after +; `reu_mul_init` had filled it. +; +; Measured symptom, once the sibling first linked (see the integrator's +; report): `fe25519_mul` correct, `fe25519_add`/`sub`/`mul_a24` correct, +; `fe25519_sqr` WRONG, and therefore both RFC 7748 §5.2 vectors wrong. +; That signature is diagnostic — banks 0/1 (mul lo/hi) are untouched by +; the stash, bank 3 (carry, read only by fe25519_sqr's SQR_DMA_K>0 +; path) is destroyed. Moving this base to bank 6 makes all seven field +; ops and both RFC 7748 vectors pass at the default SQR_DMA_K=22. +; +; Bank 6 is safe here specifically: it belongs to the P-384 split +; overlays below, and `USE_OVERLAY_P384_EMBED` is forced off whenever +; USE_X25519_SIBLING=1 (Makefile, "ifneq ($(USE_X25519_SIBLING),1)"), +; so the two never coexist. The sibling owns banks 0-5 (six contiguous +; from X25519_REU_BANK=0), which is why 6 is the first free one. +; +; Scoped to the flag rather than applied globally, deliberately. The +; symbol is referenced UNCONDITIONALLY by +; `src/crypto/shared/crypto_swap.s::crypto_swap_to_x25519` — a routine +; with no caller in any build — so changing it outright moves an +; immediate operand in every PRG, including the two that ship. There is +; no behavioural reason for that: bank 3 is only wrong when the sibling +; is actually linked and actually owns the bank. Keeping the default +; arm at $30000 leaves both default PRGs byte-identical (verified by +; sha256), which is what makes this diff reviewable. .ifndef REU_OVERLAY_X25519 +.ifdef USE_X25519_SIBLING +REU_OVERLAY_X25519 = $60000 +.else REU_OVERLAY_X25519 = $30000 .endif +.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 +; NB: REU_OVERLAY_X25519 shares this bank ONLY in the non-sibling arm +; above; under USE_X25519_SIBLING=1 it now sits at bank 6, because the +; sibling owns bank 3 for real. 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 @@ -125,8 +167,21 @@ REU_OVERLAY_P384_CURVE = $70000 ; 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: +; (bank 3) and P-384 precompute (banks 4-5). +; +; UPDATE — one of these was NOT theoretical, and the reasoning below is +; the reason it was missed. It asks only "does anything READ the +; reservation?", and every answer was no. The write nobody looked for +; was boot.s's unconditional STASH of CRYPTO_OVERLAY into +; REU_OVERLAY_X25519, which sat on bank 3 and silently destroyed the +; sibling's carry table. It was invisible because the sibling had never +; linked on either backend, so no build existed in which the collision +; could be observed. Fixed by relocating REU_OVERLAY_X25519 to bank 6 +; (see the block at that definition). Treat the remaining reservations +; below as unverified rather than safe: a reservation with no reader can +; still have a writer. +; +; The rest of the collision analysis, for the reservations that remain: ; - 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 diff --git a/tools/integration/build_x25519.sh b/tools/integration/build_x25519.sh index f6efe72..195ac16 100644 --- a/tools/integration/build_x25519.sh +++ b/tools/integration/build_x25519.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ============================================================================= -# tools/integration/build_x25519.sh - Build c64-x25519 v0.6.0 +# tools/integration/build_x25519.sh - Build c64-x25519 v0.10.0 # X25519 primitives as a resident .a archive linked into the main PRG. # # Optional sibling-library integration (Phase C.5). Produces @@ -12,15 +12,32 @@ # - 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. +# Submodule pin: v0.10.0 (68ae0ef). What the v0.6.0 -> v0.10.0 bump +# changed that this wrapper had to be migrated through: +# +# v0.7.0 RFC 7748 decodeUCoordinate fix (upstream #64) — adds the +# `x25_x1` buffer, declared in the BSS module below. This is +# a CORRECTNESS fix, not a refactor: v0.4.0-v0.6.0 return a +# deterministically wrong shared secret for any peer u with +# bit 255 set. +# v0.8.0 SPEC §4 segment-prefix migration (CODE -> LIB_X25519_CODE) +# plus the cold/init split (LIB_X25519_INIT_CODE). Handled by +# the segment-rewrite block near the bottom of this script. +# Also adds the X25519_ONCHIP_MUL no-REU profile. +# v0.9.0 contract v0.7.0/v0.5.0 manifest migration: lib_manifest.s, +# prefixed exports, SHARED_CONSUMES. Not staged — c64-https +# imports no contract manifest equate, so nothing to do. +# v0.10.0 LIB_ABI_VERSION 1 -> 2 (v0.9.0 erratum) + contract v0.7.4 +# precalc macro. No source-level consumer impact here. +# +# Zero-page layout (src/zp_config.s) is BYTE-IDENTICAL across the whole +# range — `git diff v0.6.0 v0.10.0 -- src/zp_config.s` is empty — so the +# time-sharing analysis below did not need revisiting. +# +# Earlier contract-§1/§2/§3/§5 adoption 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 @@ -148,8 +165,32 @@ REU_DEFINES=(-D X25519_REU_BANK=0) # 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. +# +# THE BASE ADDRESS IS NOT $BC00 ON THIS PATH, AND SAYING SO SILENTLY +# PRODUCES WRONG CRYPTO. `src/data.s` drops `mul_dma_lo` + `mul_dma_hi` +# (512 B) from TABLES_BSS under `.ifndef USE_X25519_SIBLING` — the +# sibling's own data module supplies them instead — so everything after +# them in TABLES_BSS shifts down 512 B and `sqtab_lo` links at **$B800** +# (`sqtab_hi` $BA00), not the $BC00 this script hardcoded for years. +# +# Nothing caught it because the two halves disagree in opposite +# directions and neither is a link error: in-tree `sqtab_init` fills the +# real table at $B800, while the sibling's `fe25519_sqr` mult66 path +# reads $BC00 through the equate below. The link succeeds, boot +# succeeds, and `x25519_scalarmult` returns a deterministically wrong +# shared secret. Measured on the first build in which the sibling ever +# linked: both RFC 7748 §5.2 vectors failed with correct `x25_x1` +# masking, i.e. the ladder was right and the multiply table was not. +# +# The value is therefore paired with a post-link assertion in the +# Makefile's $(PRG) recipe (the `ifeq ($(USE_X25519_SIBLING),1)` grep on +# $(LABELS)), exactly as the USE_NISTCURVES_ONCHIP path already does for +# `sqtab_reserved`. If TABLES_BSS is reordered again, the build fails +# and names this variable, rather than the KATs failing somewhere +# downstream — or worse, not being run at all. +X25519_SQTAB_BASE="${X25519_SQTAB_BASE:-\$B800}" SQTAB_DEFINES=( - -D 'LIB_SHARED_SQTAB_BASE=$BC00' + -D "LIB_SHARED_SQTAB_BASE=$X25519_SQTAB_BASE" -D SHARED_SQTAB_INIT=1 ) @@ -225,7 +266,7 @@ cat > "$STAGING/data_x25519_bss_raw.s" <<'BSS_EOF' .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 x25_scalar, x25_u, x25_result, x25_x1 .export mul_cached_a, mul_src2_buf .export mul_dma_lo, mul_dma_hi, mul_dma_carry @@ -253,6 +294,27 @@ x25_scalar: .res 32, 0 x25_u: .res 32, 0 x25_result: .res 32, 0 +; --- RFC 7748 decoded u-coordinate (upstream #64 fix, v0.7.0) --- +; +; x25519_scalarmult writes the bit-255-masked copy of x25_u here once +; at ladder init, and the ladder's z_3 = x_1 * (DA-CB)^2 step reads +; THIS buffer rather than x25_u. +; +; It exists because v0.4.0 stopped mutating the caller's x25_u (so a +; caller's peer-key buffer survives the call) but left the x_1 read +; pointing at the unmasked original. For any peer u with bit 255 set, +; 2^255 = 19 (mod p), so x_1 desynchronized from the masked x_3 by 19 +; and x25519_scalarmult returned a deterministically wrong shared +; secret. Broken v0.4.0 through v0.6.0 — i.e. throughout the pin this +; wrapper previously staged. +; +; If this declaration is ever dropped, the sibling link fails on an +; unresolved `x25_x1` import from x25519.s rather than silently +; reverting to the bug. The behavioural gate is RFC 7748 §5.2 vector 2 +; in tools/test_x25519.py, whose u ends 0x93. + .align 32 +x25_x1: .res 32, 0 + ; --- fe25519_mul optimization scratch (unaligned) --- ; ; mul_src2_buf is 35 bytes: @@ -289,6 +351,7 @@ mul_dma_carry: .res 256, 0 .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" +.assert (x25_x1 & $1F) = 0, lderror, "x25_x1 must be 32-byte aligned" BSS_EOF cat > "$STAGING/data_x25519_rodata_raw.s" <<'RODATA_EOF' @@ -381,19 +444,95 @@ RODATA_EOF # 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). +# --- Route the sibling's code segments into c64-https segments --- +# +# Segment naming moved twice across the v0.6.0 -> v0.10.0 bump: +# +# v0.6.0 `.segment "CODE"` (single code segment) +# v0.8.0 `.segment "LIB_X25519_CODE"` SPEC §4 prefix migration +# `.segment "LIB_X25519_INIT_CODE"` cold/init split (#68) +# v0.10.0 unchanged from v0.8.0 +# +# c64-https's cfgs do not declare the `LIB_X25519_*` names, so every +# code segment is rewritten to a name both backend cfgs already carry. +# +# WHY THIS IS A `case`, NOT A LOOSE `sed`: the old rule matched the +# literal `.segment "CODE"`, which does not exist anywhere in v0.10.0's +# sources. A no-op rewrite is silent — the sources assemble fine and the +# failure only surfaces at ld65 as an unplaced segment, several minutes +# and one confusing error message later. The post-rewrite assertion +# below therefore checks for *any* surviving `LIB_X25519_*` segment +# rather than for the one name we happened to rewrite, so the next +# upstream rename fails loudly here instead of at link time. +# +# X25519_INIT_SEGMENT selects where the sibling's init-only code +# (reu_mul_init + its table-generation loop, ~800 B, dead after boot) +# lands. Default CRYPTO_CODE preserves the pre-bump layout exactly. +# Setting it to X25519_RODATA moves that code into the CRYPTO_OVERLAY +# slot which the sibling build already claims for its tables — see the +# integrator's report for the fit arithmetic. +# X25519_CODE_SEGMENT does the same for the sibling's runtime-hot code. +# Both default to CRYPTO_CODE, which reproduces the pre-bump placement +# byte for byte; they exist so the fit can be explored without editing +# a cfg. +X25519_INIT_SEGMENT="${X25519_INIT_SEGMENT:-CRYPTO_CODE}" +X25519_CODE_SEGMENT="${X25519_CODE_SEGMENT:-CRYPTO_CODE}" + +# Per-source overrides of X25519_CODE_SEGMENT. These exist because the +# sibling does not fit CRYPTO_HOT whole and does not fit CRYPTO_OVERLAY +# whole either, so making it link at all requires splitting it — and the +# only split that matters is per-source, since ca65 emits one segment +# per source. Measured sizes at v0.10.0 (od65, --dump-segments): +# +# X25519_SEG_FE25519 fe25519.s 2,711 B field arithmetic +# X25519_SEG_LADDER x25519.s 717 B Montgomery ladder +# X25519_SEG_REU x25519_init.s 132 B REU fetch helpers (hot) +# X25519_INIT_SEGMENT x25519_init.s 666 B table init (boot-only) +# +# Anything routed to X25519_RODATA lands in CRYPTO_OVERLAY, which under +# USE_X25519_SIBLING=1 is not a paged overlay at all — the two embed +# flags that page it are mutually exclusive with this one, so it is +# plain resident RAM at $4200-$5FFF and holding executable code there +# is safe. It is still the wrong NAME for code; see the integrator's +# report for the cfg change that does this properly. +X25519_SEG_FE25519="${X25519_SEG_FE25519:-$X25519_CODE_SEGMENT}" +X25519_SEG_LADDER="${X25519_SEG_LADDER:-$X25519_CODE_SEGMENT}" +X25519_SEG_REU="${X25519_SEG_REU:-$X25519_CODE_SEGMENT}" + for src in fe25519_raw x25519_raw x25519_init_raw; do - sed -i '' 's/^\.segment "CODE"$/.segment "CRYPTO_CODE"/' "$STAGING/$src.s" + case "$src" in + fe25519_raw) hot_seg="$X25519_SEG_FE25519" ;; + x25519_raw) hot_seg="$X25519_SEG_LADDER" ;; + x25519_init_raw) hot_seg="$X25519_SEG_REU" ;; + esac + sed -i '' \ + -e 's/^\.segment "LIB_X25519_INIT_CODE"$/.segment "'"$X25519_INIT_SEGMENT"'"/' \ + -e 's/^\.segment "LIB_X25519_CODE"$/.segment "'"$hot_seg"'"/' \ + -e 's/^\.segment "CODE"$/.segment "'"$hot_seg"'"/' \ + "$STAGING/$src.s" done -# --- Sanity: no leftover CODE segments in patched sources --- +# --- Sanity: every sibling code segment must now be one we placed --- +# Catches a leftover pre-§4 `CODE` and any `LIB_X25519_*` segment a +# future bump introduces. Failing here costs one second; failing at ld65 +# costs a full build plus a memory-area message that names the segment +# but not the reason. +# +# The two configured targets are exempt: a consumer cfg that declares +# the SPEC §4 names outright can set X25519_CODE_SEGMENT and +# X25519_INIT_SEGMENT to them, making the rewrite an identity and this +# check a no-op — which is the shape this wrapper should eventually +# take, once a cfg carries `LIB_X25519_CODE` / `LIB_X25519_INIT_CODE`. 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 + leftover=$(grep -E '^\.segment "(CODE|LIB_X25519_[A-Z_]*)"$' "$STAGING/$src.s" \ + | grep -vxF ".segment \"$X25519_SEG_FE25519\"" \ + | grep -vxF ".segment \"$X25519_SEG_LADDER\"" \ + | grep -vxF ".segment \"$X25519_SEG_REU\"" \ + | grep -vxF ".segment \"$X25519_INIT_SEGMENT\"" || true) + if [ -n "$leftover" ]; then + echo "ERROR: unrewritten sibling segment in $src.s:" >&2 + echo "$leftover" >&2 + echo " -> no c64-https cfg declares it; extend the rewrite above." >&2 exit 1 fi done @@ -413,6 +552,7 @@ for src in fe25519_raw x25519_raw x25519_init_raw data_x25519_bss_raw data_x2551 "${ZP_DEFINES[@]}" \ "${REU_DEFINES[@]}" \ "${SQTAB_DEFINES[@]}" \ + ${X25519_EXTRA_DEFINES:-} \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" done @@ -502,6 +642,18 @@ else } DEF_MUL_8X8=$(bin_lookup_label mul_8x8 '$0000') + # v0.7.0+ : x25519_init.s unconditionally imports the canonical + # c64-lib-contract §8.3 multiply body and its two SMC operand sites. + # In the main PRG these resolve against in-tree + # src/crypto/poly1305.s (which exports ct_mul_8x8 / smc_sum_a_imm / + # smc_diff_a_imm for exactly this purpose); the standalone .bin has + # no in-tree objects, so they have to be --define'd like the rest. + # Without these three the .bin link fails on unresolved externals + # and the artifact silently stops being produced — best-effort by + # design, but it should still be produced. + DEF_CT_MUL_8X8=$(bin_lookup_label ct_mul_8x8 '$0000') + DEF_SMC_SUM_A_IMM=$(bin_lookup_label smc_sum_a_imm '$0000') + DEF_SMC_DIFF_A_IMM=$(bin_lookup_label smc_diff_a_imm '$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') @@ -524,6 +676,9 @@ else --define reu_len_hi=\$df08 \ --define reu_addr_ctrl=\$df0a \ --define mul_8x8="$DEF_MUL_8X8" \ + --define ct_mul_8x8="$DEF_CT_MUL_8X8" \ + --define smc_sum_a_imm="$DEF_SMC_SUM_A_IMM" \ + --define smc_diff_a_imm="$DEF_SMC_DIFF_A_IMM" \ --define sqtab_lo="$DEF_SQTAB_LO" \ --define sqtab_hi="$DEF_SQTAB_HI" \ --define poly_prod_lo="$DEF_POLY_PROD_LO" \ From 9c3ec9a5f0518814a0061c44b19f9206c6c76e3a Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:19:26 -0500 Subject: [PATCH 2/2] fix(boot): restore the REU autoload latch after the X25519 slot stash Sixth and last defect blocking USE_X25519_SIBLING=1. With it, the sibling build goes from 3/6 to 6/6 on tools/test_ecdsa_kat_oracle.py while keeping both RFC 7748 vectors green -- so the configuration is now correct across the whole crypto surface, not just X25519. boot.s STASHes the 8 KB CRYPTO_OVERLAY slot to REU under the sibling flag, and that stash is a six-register REU setup (c64 addr, reu addr, bank, len=$2000, addr_ctrl) running AFTER `jsr reu_mul_init`. `reu_fetch_mul_row` is a three-register primitive: it writes only reu_reu_hi / reu_reu_bank / reu_command and trusts the autoload latch for the rest. Left stomped, the next row fetch pulls $2000 bytes into $4200 instead of $0200 bytes into mul_dma_lo. The blast radius is asymmetric, which is the whole reason this was hard to see. fe25519 re-establishes the latch itself on every operation (the tail of reu_clear_wide), so X25519 was completely unaffected -- both RFC 7748 vectors passed against the broken build. libs/nistcurves' fp_mul does not; it relies on the latch boot left behind. So ECDSA P-256 was the single visible casualty of a fault that has nothing to do with ECDSA. Measured before: 3/6, with all three VALID CAVP vectors rejected (C=1) and all three invalid ones correctly rejected. No forged signature was ever accepted, so this was a correctness/availability failure and not a verification bypass. It presents identically to the "missing -reu" garbage-fp_mul mode documented in CLAUDE.md, and would almost certainly have been misdiagnosed as one. Measured after: 6/6. Per-vector wall-clock also drops from 180-270 s to 60-90 s, which is independent corroboration -- the broken build was moving 16x the bytes per row fetch. The fix calls reu_clear_wide, the library's own canonical latch restorer (its tail is documented upstream as one of the two establishers of this state), rather than open-coding the register writes and drifting from it on the next bump. Its ZP clear of fe_wide is incidental and harmless at boot. Entirely inside .ifdef USE_X25519_SIBLING: both default PRGs remain byte-identical to master (ip65 417c7085..., UCI 741f0e8c...), and USE_NISTCURVES_ONCHIP still builds. The default build never hits this because reu_p384_overlay_init's body is empty unless an overlay-embed flag is set, so nothing stomps the latch there. With this and the segment split from the previous commit, the documented command works with no environment variables: make BACKEND=uci USE_X25519_SIBLING=1 # links, 62,977 B (verified: that exact invocation produced the 6/6 build above.) Co-Authored-By: Claude Opus 5 (1M context) --- src/boot.s | 29 +++++++++++++++++++++++++++++ tools/integration/build_x25519.sh | 12 +++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/boot.s b/src/boot.s index 4154335..40e5e73 100644 --- a/src/boot.s +++ b/src/boot.s @@ -27,6 +27,9 @@ ; imports `reu_mul_init` from the sibling archive instead. .ifdef USE_X25519_SIBLING .import reu_mul_init + ; Needed for its autoload-latch restore tail, not for the ZP + ; clear — see the X25519 slot stash near the end of this file. + .import reu_clear_wide .else .export reu_mul_init ; Under USE_NISTCURVES_ONCHIP the sibling's rebuilt @@ -1036,6 +1039,32 @@ reu_p384_overlay_init: lda #$90 ; execute + STASH (C64->REU) sta reu_command plp + + ; RESTORE THE MUL-ROW AUTOLOAD LATCH. The stash above is a full + ; six-register REU setup (c64 addr, reu addr, bank, len=$2000, + ; addr_ctrl) and it runs AFTER `jsr reu_mul_init` in the boot + ; sequence. `reu_fetch_mul_row` is a three-register primitive — + ; it writes only reu_reu_hi / reu_reu_bank / reu_command and + ; trusts the latch for everything else — so leaving it stomped + ; makes the next fetch pull $2000 bytes into $4200 instead of + ; $0200 bytes into mul_dma_lo. + ; + ; The blast radius is asymmetric and that is what made this hard + ; to see: fe25519 re-establishes the latch itself on every op + ; (reu_clear_wide's tail), so X25519 is unaffected and both + ; RFC 7748 vectors pass. libs/nistcurves' fp_mul does NOT — it + ; relies on the boot-time latch — so ECDSA P-256 verify is the + ; only visible casualty: tools/test_ecdsa_kat_oracle.py went + ; 3/6, all three VALID vectors rejected, which reads exactly + ; like the "missing -reu" garbage-fp_mul failure documented in + ; CLAUDE.md and would have been misdiagnosed as one. + ; + ; reu_clear_wide is the library's own canonical restorer (its + ; tail is documented as one of the two establishers of this + ; latch), so we call it rather than open-coding the register + ; writes and drifting from it later. Its ZP clear of fe_wide is + ; incidental and harmless at boot. + jsr reu_clear_wide .endif ; .ifdef USE_X25519_SIBLING rts diff --git a/tools/integration/build_x25519.sh b/tools/integration/build_x25519.sh index 195ac16..b15d966 100644 --- a/tools/integration/build_x25519.sh +++ b/tools/integration/build_x25519.sh @@ -472,9 +472,15 @@ RODATA_EOF # slot which the sibling build already claims for its tables — see the # integrator's report for the fit arithmetic. # X25519_CODE_SEGMENT does the same for the sibling's runtime-hot code. -# Both default to CRYPTO_CODE, which reproduces the pre-bump placement -# byte for byte; they exist so the fit can be explored without editing -# a cfg. +# +# Both default to CRYPTO_CODE here, which reproduces the pre-bump +# placement byte for byte and does NOT link. The top-level Makefile +# exports X25519_INIT_SEGMENT and X25519_SEG_LADDER as X25519_RODATA +# under USE_X25519_SIBLING=1, which is the combination that does link; +# see the comment block beside those two `export` lines. Running this +# script by hand therefore gives you the historical (non-linking) +# layout unless you set the variables yourself — deliberate, so the +# knobs stay explorable in isolation. X25519_INIT_SEGMENT="${X25519_INIT_SEGMENT:-CRYPTO_CODE}" X25519_CODE_SEGMENT="${X25519_CODE_SEGMENT:-CRYPTO_CODE}"