diff --git a/CLAUDE.md b/CLAUDE.md index 7f10ec0..8c4f892 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -349,16 +349,23 @@ Five latent bugs and three new ones were cleared to get here: regression — the target has never built cleanly — but should be fixed before P-384 is actually wired into the TLS path. TLS-level P-384 verify remains stubbed regardless (see `project_p384_stubbed`). - - `ecdsa_verify` (P-256) rejects a known-good signature. Surfaced - 2026-05-06 by `tools/test_x509.py` group 3 subtest 3c - ("ECDSA verify: valid signature (C=0)") on macOS once the BSD-sed - portability fixes unblocked the build. The C64 returns C=1 (invalid) - after ~60 s on a Python-pre-verified valid P-256 signature; tampered - signatures (subtest 3d) are correctly rejected, and all five input - buffers (hash, r, s, Qx, Qy) were confirmed Match=True in C64 memory - before the call, so the bug is in the verify path, not input - staging. Used by TLS handshake CertificateVerify; ECDHE/X25519 - handshake is unaffected. + - **VICE harness gotcha**: any test that exercises sibling + `libs/nistcurves` P-256 primitives (`fp_mul`, `fp_inv`, + `ec_scalar_mul_var`, `ecdsa_verify_256`, ...) MUST launch VICE with + `-reu`. The sibling's `fp_mul` fetches 8x8 multiply rows from REU + banks 0/1 (populated by `src/boot.s::reu_mul_init`). Without `-reu`, + the row fetch silently no-ops and `mul_dma_lo/hi` at $BA00/$BB00 + stays stuck at `reu_mul_init`'s final-iteration residue (a=255), so + every `fp_mul` returns `a*255*b mod p` instead of `a*b mod p`. + Cascade: wrong `w=s^-1 mod n`, wrong `u1`/`u2`, wrong computed `R`, + `R.x != r`, verify returns C=1. Pattern is fixed in + `tools/test_x509.py:769` and `tools/test_ecdsa_kat_oracle.py:293`; + mirror this in any new VICE-driven test for P-256: + `ViceConfig(..., extra_args=["-reu", "-reusize", "512"])`. The same + pattern is already in `tools/test_x25519.py:722`, + `tools/bench_x25519.py:138`, `tools/test_p384_symbols.py:370`. The + UCI path is unaffected because the U64E hardware has REU enabled by + default; the symptom was VICE-only. ### ECDSA P-256 verify wall-clock @@ -374,7 +381,21 @@ state-machine overhead. 81.9 s still does not fit a typical 10-30 s real-world server handshake window, so this is a blocker for arbitrary internet TLS -targets that require ECDSA-P256 CertificateVerify. +targets that require ECDSA-P256 CertificateVerify. Note: the earlier +"`ecdsa_verify` rejects a known-good signature" entry in this section +turned out to be a VICE harness misconfiguration (missing `-reu`), not +a verify-path bug — see "VICE harness gotcha" in the Known issues +list. With `-reu` enabled, `tools/test_x509.py` 3c PASSes cleanly in +~60 s wall-clock under VICE warp. + +Under the v0.2.0 submodule pin the U64E 48 MHz handshake measures +**86.7 s** end-to-end (re-measured 2026-05-12, `tools/uci/test_https_local`, +local listener). The +4.8 s vs the 81.9 s v0.1.0-10-gdfdfb59 baseline +above is attributable to v0.2.0's defensive REU register inits at +`fp_mul`/`fp_sqr`/`ec_scalar_mul_var`/`fp_inv`/`ecdsa_verify_256` proc +entry (release notes "Security/correctness defences" — +6 cy/call; +the wall-clock impact compounds across the tens of thousands of +field-mul/sqr calls in the scalar mult). It is fine for the local listener used by the e2e harness (600 s budget, ample headroom). Further speedups live in the sibling `libs/nistcurves` repo — any drop through the Crypto ABI lands diff --git a/libs/nistcurves b/libs/nistcurves index dfdfb59..6b42227 160000 --- a/libs/nistcurves +++ b/libs/nistcurves @@ -1 +1 @@ -Subproject commit dfdfb59f982163a442a911aedead0a30495e873a +Subproject commit 6b4222764dd0cfcd0832b03abd51f95d0d9ba12b diff --git a/tools/integration/build_nistcurves_p256.sh b/tools/integration/build_nistcurves_p256.sh index 4e72faa..7f39a52 100755 --- a/tools/integration/build_nistcurves_p256.sh +++ b/tools/integration/build_nistcurves_p256.sh @@ -102,7 +102,7 @@ cp "$LIB_SRC"/ecdsa256.s "$STAGING/ecdsa256_raw.s" # helpers + anchor base-address table + cm_* / sm256_* state vars # Keeps ec_point_double (line 60-410), ec_point_add (411-761), # ec_scalar_mul_var (1459-1609), ec_jacobian_to_affine (1610-end). -sed -i '' '762,1458d' "$STAGING/points256_raw.s" +sed -i '' '762,1467d' "$STAGING/points256_raw.s" # Strip exports + imports that only the removed bodies used. sed -i '' '/^\.export ec_precompute_256, ec_scalar_mul, ec_scalar_mul_var$/c\ @@ -114,10 +114,16 @@ sed -i '' '/^\.import ec_anchor[1-8]_y, ec_anchor[1-8]_y, ec_anchor[1-8]_y, ec_a sed -i '' '/^\.import ec_anchor.*$/d' "$STAGING/points256_raw.s" sed -i '' '/^\.import cm_k, mul_dma_lo$/d' "$STAGING/points256_raw.s" sed -i '' '/^\.import ec_sc_byte, ec_sc_mask$/d' "$STAGING/points256_raw.s" -# REU DMA register imports (only used by stripped REU anchor helpers) -sed -i '' '/^\.import reu_c64_lo, reu_c64_hi, reu_reu_lo, reu_reu_hi$/d' "$STAGING/points256_raw.s" +# REU DMA register imports. v0.2.0 added a "defensive REU register init" +# block at the top of ec_scalar_mul_var (lines 753-757) that touches +# reu_reu_lo + reu_addr_ctrl, so those two must stay imported even though +# ec_scalar_mul_var is the only retained body. The rest are only used by +# the stripped REU anchor helpers and Lim-Lee comb. +sed -i '' '/^\.import reu_c64_lo, reu_c64_hi, reu_reu_lo, reu_reu_hi$/c\ +.import reu_reu_lo' "$STAGING/points256_raw.s" sed -i '' '/^\.import reu_reu_bank, reu_len_lo, reu_len_hi$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import reu_addr_ctrl, reu_command$/d' "$STAGING/points256_raw.s" +sed -i '' '/^\.import reu_addr_ctrl, reu_command$/c\ +.import reu_addr_ctrl' "$STAGING/points256_raw.s" # ec_mulp / ec_sqrp are used by all three retained bodies - keep. # fp_tmp1 is used by ec_scalar_mul_var - keep. @@ -288,6 +294,24 @@ ecdsa_u1g_y: .res 32, 0 ; LE affine Y of u1*G fp_rev_buf: .res 32, 0 DATA_EOF +# --- Emit minimal REU register equates --- +# v0.2.0 added a "defensive REU register init" block at the top of +# ec_scalar_mul_var (and also in fp256/ecdsa256 modular-inverse paths) +# that touches reu_reu_lo + reu_addr_ctrl. The sibling's constants.s +# provides these but also exports VIC/CIA/KERNAL equates that would +# collide with c64-https's in-tree definitions, so we emit a minimal +# equate file with only what the retained bodies actually reference. +cat > "$STAGING/reu_equates_raw.s" <<'REU_EOF' +.setcpu "6502" + +; Minimal REU hardware register equates used by retained P-256 bodies +; in v0.2.0 (defensive REU register init in ec_scalar_mul_var, fp_inv, +; ecdsa inverse). Mirror of values in libs/nistcurves/src/constants.s. +.export reu_reu_lo, reu_addr_ctrl +reu_reu_lo = $df04 +reu_addr_ctrl = $df0a +REU_EOF + # --- Route CODE segments in the raw .s files to CRYPTO_CODE. --- # The sibling uses `.segment "CODE"`, which under c64-https's cfg is the # LOADER region ($0801-$1FFF). We want this code in CRYPTO_RESIDENT. @@ -324,7 +348,7 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" "${ZP_DEFINES[@]}" \ -o "$OBJ_DIR/zp_config.o" "$STAGING/zp_config.s" -for src in fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw; do +for src in fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw reu_equates_raw; do "$CA65" \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ @@ -340,12 +364,13 @@ rm -f "$ARCHIVE" "$OBJ_DIR/points256_raw.o" \ "$OBJ_DIR/ecdsa256_raw.o" \ "$OBJ_DIR/curve256_raw.o" \ - "$OBJ_DIR/data_p256_raw.o" + "$OBJ_DIR/data_p256_raw.o" \ + "$OBJ_DIR/reu_equates_raw.o" # --- Per-source byte counts --- { echo "# nistcurves-p256.a per-source byte counts (ca65 .o file sizes)" - for src in zp_config fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw; do + for src in zp_config fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw reu_equates_raw; do bytes=$(wc -c < "$OBJ_DIR/$src.o") printf '%-24s %d bytes (.o)\n' "$src" "$bytes" done diff --git a/tools/test_ecdsa_kat_oracle.py b/tools/test_ecdsa_kat_oracle.py new file mode 100644 index 0000000..5518d0f --- /dev/null +++ b/tools/test_ecdsa_kat_oracle.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""test_ecdsa_kat_oracle.py - Library-side KAT oracle for ECDSA P-256 verify. + +Runs additional known-VALID P-256/SHA-256 signature vectors against the +C64's `ecdsa_verify` routine (the c64-https dispatcher over the +libs/nistcurves sibling). Mirrors the structure of +`tools/test_x509.py` group 3 subtest [3c] (call `setup_ecdsa_verify(...)`, +then `jsr_with_carry(... labels["ecdsa_verify"] ...)`, assert C=0) but +exercises 3 additional vectors so we can distinguish a primitive bug +from a [3c]-specific test-setup bug: + + - [3e] CAVP SigVer P-256/SHA-256 valid #1 (Result = P record) + - [3f] CAVP SigVer P-256/SHA-256 valid #2 + - [3g] CAVP SigVer P-256/SHA-256 valid #3 + +Vectors are extracted verbatim from +`libs/nistcurves/tools/vectors/nist_p256_sigver.rsp` (NIST CAVP SigVer, +P-256/SHA-256 section), specifically the records flagged `Result = P`. +For each vector the hash is `SHA-256(Msg)`; r/s/Qx/Qy are taken straight +from the .rsp file in big-endian wire order, matching the BE struct ABI +of the sibling's `ecdsa_verify_256`. + +Usage: + python3 tools/test_ecdsa_kat_oracle.py [--verbose] + +Honours `C64_SKIP_BUILD=1` for the same reason `test_x509.py` does. +""" + +import os +import subprocess +import sys +import time + +from c64_test_harness import ( + Labels, + ViceConfig, + ViceInstanceManager, + read_bytes, + write_bytes, + jsr, + goto, + wait_for_text, +) + +# --------------------------------------------------------------------------- +# Constants (mirrors tools/test_x509.py) +# --------------------------------------------------------------------------- + +PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") +LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") + +VERBOSE = False + +CARRY_TRAMPOLINE = 0x033C +CARRY_RESULT_ADDR = 0x0352 +CARRY_FLAG_ADDR = 0x0353 + +CURVE_P256 = 0 + +ECDSA_LABELS = [ + "ecdsa_verify", + "ecdsa_curve_id", + "ecdsa_hash", + "ecdsa_sig_r", "ecdsa_sig_s", + "ecdsa_pubkey_x", "ecdsa_pubkey_y", + "sqtab_init", +] + + +# --------------------------------------------------------------------------- +# Hardcoded P-256 known-VALID KAT vectors (CAVP SigVer, Result = P records) +# --------------------------------------------------------------------------- + +KAT_VECTORS = [ + # CAVP SigVer P-256/SHA-256 valid record #1 + dict( + tag="CAVP SigVer P-256/SHA-256 valid #1", + hash=bytes.fromhex( + "d1b8ef21eb4182ee270638061063a3f3" + "c16c114e33937f69fb232cc833965a94"), + r=bytes.fromhex( + "bf96b99aa49c705c910be33142017c64" + "2ff540c76349b9dab72f981fd9347f4f"), + s=bytes.fromhex( + "17c55095819089c2e03b9cd415abdf12" + "444e323075d98f31920b9e0f57ec871c"), + qx=bytes.fromhex( + "e424dc61d4bb3cb7ef4344a7f8957a0c" + "5134e16f7a67c074f82e6e12f49abf3c"), + qy=bytes.fromhex( + "970eed7aa2bc48651545949de1dddaf0" + "127e5965ac85d1243d6f60e7dfaee927"), + ), + # CAVP SigVer P-256/SHA-256 valid record #2 + dict( + tag="CAVP SigVer P-256/SHA-256 valid #2", + hash=bytes.fromhex( + "b9336a8d1f3e8ede001d19f41320bc76" + "72d772a3d2cb0e435fff3c27d6804a2c"), + r=bytes.fromhex( + "1d75830cd36f4c9aa181b2c4221e87f1" + "76b7f05b7c87824e82e396c88315c407"), + s=bytes.fromhex( + "cb2acb01dac96efc53a32d4a0d85d0c2" + "e48955214783ecf50a4f0414a319c05a"), + qx=bytes.fromhex( + "e0fc6a6f50e1c57475673ee54e3a57f9" + "a49f3328e743bf52f335e3eeaa3d2864"), + qy=bytes.fromhex( + "7f59d689c91e463607d9194d99faf316" + "e25432870816dde63f5d4b373f12f22a"), + ), + # CAVP SigVer P-256/SHA-256 valid record #3 + dict( + tag="CAVP SigVer P-256/SHA-256 valid #3", + hash=bytes.fromhex( + "41007876926a20f821d72d9c6f2c9dae" + "6c03954123ea6e6939d7e6e669438891"), + r=bytes.fromhex( + "06108e525f845d0155bf60193222b321" + "9c98e3d49424c2fb2a0987f825c17959"), + s=bytes.fromhex( + "62b5cdd591e5b507e560167ba8f6f7cd" + "a74673eb315680cb89ccbc4eec477dce"), + qx=bytes.fromhex( + "2d98ea01f754d34bbc3003df5050200a" + "bf445ec728556d7ed7d5c54c55552b6d"), + qy=bytes.fromhex( + "9b52672742d637a32add056dfd6d8792" + "f2a33c2e69dafabea09b960bc61e230a"), + ), +] + +SUBTEST_LABELS = ["3e", "3f", "3g"] + + +# --------------------------------------------------------------------------- +# jsr_with_carry: copied verbatim from tools/test_x509.py +# --------------------------------------------------------------------------- + +def jsr_with_carry(transport, addr, timeout=2400.0, poll_interval=30.0): + lo = addr & 0xFF + hi = (addr >> 8) & 0xFF + result_lo = CARRY_RESULT_ADDR & 0xFF + result_hi = (CARRY_RESULT_ADDR >> 8) & 0xFF + flag_lo = CARRY_FLAG_ADDR & 0xFF + flag_hi = (CARRY_FLAG_ADDR >> 8) & 0xFF + loop_addr = CARRY_TRAMPOLINE + 19 + trampoline = bytes([ + 0xA9, 0x00, + 0x8D, flag_lo, flag_hi, + 0x20, lo, hi, + 0xA9, 0x00, + 0x2A, + 0x8D, result_lo, result_hi, + 0xA9, 0xFF, + 0x8D, flag_lo, flag_hi, + 0x4C, loop_addr & 0xFF, loop_addr >> 8, + ]) + write_bytes(transport, CARRY_TRAMPOLINE, trampoline) + write_bytes(transport, CARRY_FLAG_ADDR, bytes([0x00])) + goto(transport, CARRY_TRAMPOLINE) + + deadline = time.monotonic() + timeout + while True: + time.sleep(poll_interval) + if time.monotonic() >= deadline: + raise TimeoutError( + f"jsr_with_carry(${addr:04X}) timed out after {timeout:.0f}s") + try: + flag = read_bytes(transport, CARRY_FLAG_ADDR, 1) + if flag[0] == 0xFF: + break + transport.resume() + except Exception: + continue + + return read_bytes(transport, CARRY_RESULT_ADDR, 1)[0] + + +def setup_ecdsa_verify(transport, labels, msg_hash, r_bytes, s_bytes, + qx, qy, curve_id=CURVE_P256): + write_bytes(transport, labels["ecdsa_curve_id"], bytes([curve_id])) + write_bytes(transport, labels["ecdsa_hash"], msg_hash) + write_bytes(transport, labels["ecdsa_sig_r"], r_bytes) + write_bytes(transport, labels["ecdsa_sig_s"], s_bytes) + write_bytes(transport, labels["ecdsa_pubkey_x"], qx) + write_bytes(transport, labels["ecdsa_pubkey_y"], qy) + + +def check_labels(labels, label_list): + for name in label_list: + if labels.address(name) is None: + print(f" SKIP: label '{name}' not found") + return False + return True + + +def run_kat_oracle(transport, labels): + passed = 0 + failed = 0 + + if not check_labels(labels, ECDSA_LABELS): + return 0, 0 + + for idx, vec in enumerate(KAT_VECTORS): + sub = SUBTEST_LABELS[idx] + tag = vec["tag"] + print(f"\n [{sub}] ECDSA verify: {tag} (expected C=0)") + if VERBOSE: + print(f" hash = {vec['hash'][:8].hex()}... r = {vec['r'][:8].hex()}...") + print(f" s = {vec['s'][:8].hex()}... Qx = {vec['qx'][:8].hex()}... Qy = {vec['qy'][:8].hex()}...") + setup_ecdsa_verify(transport, labels, vec["hash"], vec["r"], vec["s"], + vec["qx"], vec["qy"], CURVE_P256) + c64_hash = read_bytes(transport, labels["ecdsa_hash"], 32) + c64_r = read_bytes(transport, labels["ecdsa_sig_r"], 32) + c64_s = read_bytes(transport, labels["ecdsa_sig_s"], 32) + c64_qx = read_bytes(transport, labels["ecdsa_pubkey_x"], 32) + c64_qy = read_bytes(transport, labels["ecdsa_pubkey_y"], 32) + c64_cid = read_bytes(transport, labels["ecdsa_curve_id"], 1)[0] + if VERBOSE: + print(f" readback: hash={c64_hash[:4].hex()}...{c64_hash[-4:].hex()} " + f"r={c64_r[:4].hex()}...{c64_r[-4:].hex()} " + f"s={c64_s[:4].hex()}...{c64_s[-4:].hex()} " + f"Qx={c64_qx[:4].hex()}...{c64_qx[-4:].hex()} " + f"Qy={c64_qy[:4].hex()}...{c64_qy[-4:].hex()} cid={c64_cid}") + all_match = (c64_hash == vec["hash"] and c64_r == vec["r"] + and c64_s == vec["s"] and c64_qx == vec["qx"] + and c64_qy == vec["qy"] and c64_cid == CURVE_P256) + if not all_match: + failed += 1 + print(f" FAIL: input staging mismatch (read-back diverges from intent)") + continue + + try: + t0 = time.time() + carry = jsr_with_carry(transport, labels["ecdsa_verify"], + timeout=2400.0, poll_interval=30.0) + elapsed = time.time() - t0 + if carry == 0: + passed += 1 + print(f" PASS: ecdsa_verify returned C=0 (valid) [{elapsed:.0f}s]") + else: + failed += 1 + print(f" FAIL: ecdsa_verify returned C=1 (invalid) [{elapsed:.0f}s]") + print(f" hash: {c64_hash.hex()}") + print(f" r: {c64_r.hex()}") + print(f" s: {c64_s.hex()}") + print(f" Qx: {c64_qx.hex()}") + print(f" Qy: {c64_qy.hex()}") + except Exception as e: + failed += 1 + print(f" FAIL: {e}") + + return passed, failed + + +def main(): + global VERBOSE + os.chdir(PROJECT_ROOT) + + args = sys.argv[1:] + if "--verbose" in args: + VERBOSE = True + + if os.environ.get("C64_SKIP_BUILD"): + print("\n=== Building (skipped: C64_SKIP_BUILD set) ===") + else: + print("\n=== Building ===") + subprocess.run(["make", "clean"], capture_output=True, cwd=PROJECT_ROOT) + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f"Build failed:\n{result.stderr}") + sys.exit(1) + print(f" Build OK: {PRG_PATH}") + + if not os.path.exists(PRG_PATH): + print(f"FATAL: {PRG_PATH} not found") + sys.exit(1) + + labels = Labels.from_file(LABELS_PATH) + + if not check_labels(labels, ECDSA_LABELS): + print("\nFATAL: ECDSA verify labels missing; nothing to test.") + sys.exit(1) + + print(f"\n Labels loaded from {LABELS_PATH}") + print(f" Vectors to run: {len(KAT_VECTORS)} (CAVP SigVer P-256/SHA-256 valid)") + print(f" Per-vector wallclock budget: 2400 s (VICE warp; typical ~5-16 min)") + + config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"]) + + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + transport = inst.transport + print(f"\n=== Starting VICE ===") + print(f" VICE PID={inst.pid}, port={inst.port}") + + print(" Waiting for main menu...") + grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + if grid is None: + print("FATAL: Main menu did not appear") + sys.exit(1) + print(" Main menu ready") + + print(f"\n=== Initialising sqtab (quarter-square multiply tables) ===") + try: + jsr(transport, labels["sqtab_init"], timeout=60.0) + print(" sqtab_init OK") + except Exception as e: + print(f" sqtab_init FAILED: {e}") + sys.exit(1) + + print(f"\n=== ECDSA P-256 KAT oracle ({len(KAT_VECTORS)} valid vectors) ===") + passed, failed = run_kat_oracle(transport, labels) + + mgr.release(inst) + + total = passed + failed + print(f"\n{'='*60}") + print("RESULTS") + print(f"{'='*60}") + print(f" Passed: {passed}/{total}") + print(f" Failed: {failed}/{total}") + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/tools/test_x509.py b/tools/test_x509.py index 76b9fb6..66e2a31 100644 --- a/tools/test_x509.py +++ b/tools/test_x509.py @@ -765,8 +765,14 @@ def main(): if slow_count > 0: print(f" Estimated total time: {slow_count * 6}-{slow_count * 16} minutes") - # Launch VICE via ViceInstanceManager (safe port allocation) - config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False) + # Launch VICE via ViceInstanceManager (safe port allocation). + # -reu is required: the sibling c64-nist-curves fp_mul fetches 8x8 + # multiply rows from REU banks 0/1 (see src/boot.s reu_mul_init). Without + # -reu the row fetch silently no-ops and mul_dma_lo/hi stays stuck at + # reu_mul_init's final-iteration residue (a=255), so every fp_mul + # returns a*255*b instead of a*b. Pattern mirrors tools/test_x25519.py. + config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"]) with ViceInstanceManager(config=config) as mgr: inst = mgr.acquire()