From 8cffc5befaf67f32c9dddb65fc10351f4149c5e2 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 18:26:46 -0500 Subject: [PATCH 01/21] test(https_e2e): add P-384 cert profile to test listener (Phase 4c) Extends https_listener.py with a "p384" cert_profile that auto-generates a self-signed secp384r1 / ecdsa-with-SHA384 cert (mirrors the existing P-256 generator) and pins ECDH to the same curve. Profile selection via the new cert_profile= kwarg or HTTPS_LISTENER_CERT_PROFILE env var; default remains "p256" so existing tests are unaffected. Documents the openssl regeneration command in tools/https_e2e/certs/README and whitelists the README from the certs/ gitignore. Cert/key files themselves stay gitignored. Verified end-to-end with openssl 3.6.2 s_server using the README's openssl command + s_client -tls1_3 -groups secp384r1: handshake negotiates "Signature type: ecdsa_secp384r1_sha384" and "Peer Temp Key: ECDH, secp384r1, 384 bits". No src/ or C64-side changes; Phase 5 will add the matching tools/uci/ driver sibling. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +- tools/https_e2e/certs/README | 76 +++++++++++++++++ tools/https_e2e/https_listener.py | 130 ++++++++++++++++++++++++++++-- 3 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 tools/https_e2e/certs/README diff --git a/.gitignore b/.gitignore index 6177422..122bfc1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ ip65-build/*.bin ip65-build/*.map .claude/* !.claude/settings.json -tools/https_e2e/certs/ +tools/https_e2e/certs/* +!tools/https_e2e/certs/README tools/diag_4de0_*.py tools/diag_read_live.py .serena/ diff --git a/tools/https_e2e/certs/README b/tools/https_e2e/certs/README new file mode 100644 index 0000000..7745e7b --- /dev/null +++ b/tools/https_e2e/certs/README @@ -0,0 +1,76 @@ +Test certificates for the https_e2e listener +============================================= + +These are self-signed certs used by the local TLS 1.3 listener +(`https_listener.py`) for end-to-end testing of the c64-https client +against `www.foo.bar`. They are NOT trust-anchors for anything; do not +deploy them anywhere real. + +Two cert profiles are supported: + + server.pem / server.key -- P-256 (secp256r1 / prime256v1), + ecdsa-with-SHA256 + server-p384.pem / server-p384.key -- P-384 (secp384r1), + ecdsa-with-SHA384 + +CN is `www.foo.bar` and SAN covers `foo.bar` + `www.foo.bar` for both. +Validity is 10 years from generation. + +The cert files themselves are gitignored (see the repo `.gitignore`); +they are generated on demand by the listener. + +Auto-generation (default path) +------------------------------ + +Both pairs are auto-generated lazily by `https_listener._ensure_certs_*()` +the first time the listener is started under each profile, using the +Python `cryptography` package. To force regeneration, delete the +files and start the listener once with the matching `cert_profile`. + +Manual regeneration with openssl +-------------------------------- + +If you need to (re)create the P-384 pair without invoking the +listener (e.g. for debugging with `openssl s_client` directly), the +following openssl invocation produces the same cert: + + cat > /tmp/p384_san.cnf <<'EOF' + [req] + distinguished_name = dn + prompt = no + x509_extensions = v3_ext + + [dn] + CN = www.foo.bar + + [v3_ext] + subjectAltName = DNS:foo.bar, DNS:www.foo.bar + EOF + + openssl ecparam -name secp384r1 -genkey -noout \ + -out tools/https_e2e/certs/server-p384.key + + openssl req -new -x509 \ + -key tools/https_e2e/certs/server-p384.key \ + -out tools/https_e2e/certs/server-p384.pem \ + -days 3650 -sha384 -config /tmp/p384_san.cnf + +The P-256 pair can be (re)created the same way with +`-name prime256v1` and `-sha256` — but the listener's auto-generator +is the canonical source. + +Selecting which cert the listener presents +------------------------------------------ + +`start_https_listener()` accepts a `cert_profile` keyword argument: + + cert_profile="p256" (default) -> server.pem / server.key + cert_profile="p384" -> server-p384.pem / server-p384.key + +The same selection can be made via the `HTTPS_LISTENER_CERT_PROFILE` +environment variable (`p256` or `p384`). The kwarg wins if both are +set. Default is unchanged (P-256) so existing tests are unaffected. + +When `cert_profile="p384"`, the listener also pins the ECDH curve to +`secp384r1` so the key exchange and the certificate are on the same +curve. diff --git a/tools/https_e2e/https_listener.py b/tools/https_e2e/https_listener.py index f623f5e..9c6367d 100644 --- a/tools/https_e2e/https_listener.py +++ b/tools/https_e2e/https_listener.py @@ -4,16 +4,28 @@ returns a fixed 200 OK with a short body. The server runs in a daemon thread so the test can drive VICE in the main thread. -The TLS layer uses a self-signed P-256 ECDSA certificate generated at -import time (cached on disk in the certs/ directory next to this file). +The TLS layer uses a self-signed ECDSA certificate. Two cert profiles +are available: + + "p256" (default) -- P-256 / ecdsa-with-SHA256, generated lazily by + this module the first time the listener starts. + "p384" -- P-384 / ecdsa-with-SHA384, generated out-of-band + with openssl (see tools/https_e2e/certs/README). + TLS 1.3 is required; older versions are rejected. Binding to port 443 requires root. The test already runs under sudo (BridgeEnv needs it), so no special handling is needed here. Public API: - start_https_listener(host, port, response_body) -> HttpsListenerHandle + start_https_listener(host, port, response_body, cert_profile=None) + -> HttpsListenerHandle stop_https_listener(handle) + +Cert profile selection precedence (first match wins): + 1. cert_profile= keyword argument to start_https_listener() + 2. HTTPS_LISTENER_CERT_PROFILE environment variable + 3. "p256" (preserves pre-Phase-4 default behaviour) """ from __future__ import annotations @@ -31,12 +43,35 @@ _CERTS_DIR = os.path.join(os.path.dirname(__file__), "certs") _CERT_PATH = os.path.join(_CERTS_DIR, "server.pem") _KEY_PATH = os.path.join(_CERTS_DIR, "server.key") +_CERT_PATH_P384 = os.path.join(_CERTS_DIR, "server-p384.pem") +_KEY_PATH_P384 = os.path.join(_CERTS_DIR, "server-p384.key") + +_CERT_PROFILE_ENV = "HTTPS_LISTENER_CERT_PROFILE" +_DEFAULT_CERT_PROFILE = "p256" +_CERT_PROFILES = ("p256", "p384") DEFAULT_RESPONSE_BODY = "HELLO FROM HTTPS TEST SERVER" -def _ensure_certs() -> tuple[str, str]: - """Return (cert_path, key_path), generating them if they don't exist.""" +def _resolve_cert_profile(cert_profile: str | None) -> str: + """Pick the cert profile from kwarg, env var, or default.""" + if cert_profile is None: + cert_profile = os.environ.get(_CERT_PROFILE_ENV, _DEFAULT_CERT_PROFILE) + cert_profile = cert_profile.lower() + if cert_profile not in _CERT_PROFILES: + raise ValueError( + f"unknown cert_profile {cert_profile!r}; " + f"expected one of {_CERT_PROFILES}" + ) + return cert_profile + + +def _ensure_certs_p256() -> tuple[str, str]: + """Return (cert_path, key_path) for the P-256 profile. + + Generates the cert pair on first use; subsequent calls reuse the + cached files in the certs/ directory. + """ if os.path.isfile(_CERT_PATH) and os.path.isfile(_KEY_PATH): return _CERT_PATH, _KEY_PATH @@ -85,6 +120,71 @@ def _ensure_certs() -> tuple[str, str]: return _CERT_PATH, _KEY_PATH +def _ensure_certs_p384() -> tuple[str, str]: + """Return (cert_path, key_path) for the P-384 profile. + + Generates the cert pair on first use; subsequent calls reuse the + cached files in the certs/ directory. Mirrors _ensure_certs_p256() + but uses SECP384R1 + SHA-384. + """ + if os.path.isfile(_CERT_PATH_P384) and os.path.isfile(_KEY_PATH_P384): + return _CERT_PATH_P384, _KEY_PATH_P384 + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + import datetime + + key = ec.generate_private_key(ec.SECP384R1()) + + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "www.foo.bar"), + ]) + + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName([ + x509.DNSName("foo.bar"), + x509.DNSName("www.foo.bar"), + ]), + critical=False, + ) + .sign(key, hashes.SHA384()) + ) + + os.makedirs(_CERTS_DIR, exist_ok=True) + + with open(_KEY_PATH_P384, "wb") as f: + f.write(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + + with open(_CERT_PATH_P384, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + return _CERT_PATH_P384, _KEY_PATH_P384 + + +def _ensure_certs(cert_profile: str) -> tuple[str, str]: + """Dispatch to the per-profile cert loader.""" + if cert_profile == "p256": + return _ensure_certs_p256() + if cert_profile == "p384": + return _ensure_certs_p384() + # Should be unreachable thanks to _resolve_cert_profile(). + raise ValueError(f"unknown cert_profile {cert_profile!r}") + + # --------------------------------------------------------------------------- # HTTPS handler # --------------------------------------------------------------------------- @@ -122,15 +222,26 @@ class HttpsListenerHandle: port: int cert_path: str key_path: str + cert_profile: str def start_https_listener( host: str = "10.0.65.1", port: int = 443, response_body: str = DEFAULT_RESPONSE_BODY, + cert_profile: str | None = None, ) -> HttpsListenerHandle: - """Start a TLS 1.3 HTTPS server in a daemon thread. Returns a handle.""" - cert_path, key_path = _ensure_certs() + """Start a TLS 1.3 HTTPS server in a daemon thread. Returns a handle. + + cert_profile selects which self-signed cert the listener presents: + "p256" (default) -- ECDSA P-256, ecdsa-with-SHA256 + "p384" -- ECDSA P-384, ecdsa-with-SHA384 + + If cert_profile is None, the HTTPS_LISTENER_CERT_PROFILE env var is + consulted; if that is also unset the default ("p256") is used. + """ + profile = _resolve_cert_profile(cert_profile) + cert_path, key_path = _ensure_certs(profile) _Handler.response_body = response_body @@ -139,6 +250,9 @@ def start_https_listener( ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.minimum_version = ssl.TLSVersion.TLSv1_3 ctx.maximum_version = ssl.TLSVersion.TLSv1_3 + if profile == "p384": + # Pin ECDH to the same curve as the cert so the key share matches. + ctx.set_ecdh_curve("secp384r1") ctx.load_cert_chain(cert_path, key_path) server.socket = ctx.wrap_socket(server.socket, server_side=True) @@ -146,7 +260,7 @@ def start_https_listener( thread.start() return HttpsListenerHandle( server=server, thread=thread, host=host, port=port, - cert_path=cert_path, key_path=key_path, + cert_path=cert_path, key_path=key_path, cert_profile=profile, ) From a473c33972902c83fd136fd17d39d5807cd781d9 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 18:27:32 -0500 Subject: [PATCH 02/21] chore(submodules): bump libs/nistcurves to 90830c9 (post-PR #23 + #24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings in upstream PR #23 (SHA-384 streaming hash + ecdsa_verify_with_message_384 wrapper) and PR #24 (API.md + CLAUDE.md doc cleanup). No code in c64-https references the new SHA-384 surface yet — those exports are pulled into the build only when the upcoming P-384 integration links them. P-256 path verified unchanged: tools/test_x509.py PASSES, PRG size 47105 B unchanged, CRYPTO_RESIDENT 24576 B unchanged on both backends. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/nistcurves | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/nistcurves b/libs/nistcurves index 19f95d7..90830c9 160000 --- a/libs/nistcurves +++ b/libs/nistcurves @@ -1 +1 @@ -Subproject commit 19f95d792587f1c4e04f13f483cdeca244b502a9 +Subproject commit 90830c920af7fcc5ded7da6b4dd201ab535e57b4 From bed099d89e1dc7cca2a980210d3daba345cf9efb Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 18:42:41 -0500 Subject: [PATCH 03/21] build(p384-overlay): wire SHA-384 + ecdsa_verify_with_message_384 wrapper into overlay image Phase 1b extension to the c64-nist-curves P-384 overlay archive. Stages sha384.s and ecdsa384.s from the sibling, plus curve384.s for the G generator constants. Overlay link overflows OVERLAY_REGION (cfg pinned at 8 KB) by 4644 B; the archive (.a) builds clean and per-segment data is captured in build/lib/overlay-p384.sizes.txt for the Phase 1.5 cfg-restructure agent. Concrete changes: (a) Source additions to the staging step: * sha384.s -> sha384_raw.s (streaming SHA-384, ~5.5 KB code+RODATA inc. K[80] round constants) * ecdsa384.s -> ecdsa384_raw.s (packaged ecdsa_verify_384 + ecdsa_verify_with_message_384 wrapper + test trampoline + fp_reverse48) * curve384.s -> curve384_raw.s (ec_a384/b384/gx384/gy384, kept for the ec_scalar_mul_384 shim's G access) * ec_scalar_mul_384_shim_raw.s (heredoc-emitted Option A shim) (b) data_raw.s extended with the 13 new BSS / DATA exports the SHA-384 + ecdsa384 modules need: sha_state(64), sha_w(640), sha_abcdefgh(64), sha_t(16), sha_scratch(64), sha_block_buf(128), sha_block_len(1), sha_total_len(16), sha384_digest(48), ecdsa384_msg_struct_ptr(2), ecdsa_result_msg_384(1), ecdsa_inputs_384(240), ec_base384_x/y(48 each) Plus all ecdsa384_* slots ecdsa_verify_384 imports (ecdsa384_r/s/h/qx/qy/w/u1/u2/u1_be/u2_be/u1g_x/u1g_y, fp_rev_buf_384). sha384_msg_buf (1 KB test scratch) is OMITTED; the .import lines in sha384.s and ecdsa384.s are stripped via sed. (c) Option A taken for ec_scalar_mul_384: STRIP the Lim-Lee body (lines 787-1488 of points384.s) and provide ec_scalar_mul_384 via an in-staging shim that copies G into ec_base384_x/y and tail-calls ec_scalar_mul_var_384. Mirrors the Phase C.4 P-256 dispatcher pattern in src/crypto/ecdsa_verify.s::ec_scalar_mul. Avoids the ~24 KB REU bank-2 anchor table + ~100 s ec_precompute_384 boot drag at the cost of a slower per-call fixed-base scalar mult (double-and-add vs. h=8 windowed comb). (d) BSD-sed compat: every `sed -i 'pattern'` invocation in both build_nistcurves_p384.sh and build_nistcurves_p384_bin.sh now uses `sed -i '' 'pattern'`. macOS BSD sed requires the empty extension; GNU sed accepts both. (e) ZP slots used for SHA-384's streaming pointers, passed via -D to ca65 over zp_config.s defaults: sha_src = $04 sha_len = $06 sha_w_ptr = $08 sha_w_ptr2 = $0a These match the sibling's defaults and DO collide with c64-https's canonical map (w32_* at $04-$09, sha_temp1 at $0a-$0d) per src/crypto/shared/zp_canon.inc. Acceptable for Phase 1b because the overlay is harness-time-only (Phase C.3b) and never linked into the production PRG. Phase 2 must resolve the collision if SHA-384 / ECDSA-with-message is wired into the production handshake -- either relocate sha_src/len/w_ptr/w_ptr2 into c64-https's free range, or repurpose $04-$0B during the brief ECDSA verify window. Documented inline in the script. (f) Forbidden-symbol guard relaxed to drop ec_gx384 / ec_gy384 (now legit via the shim) but retain cm_k_384 / ec_anchor[0-9]_384 / ec384_sc_byte/mask / ec384_precomp_i bans -- those bodies are physically removed under Option A. Build status: * nistcurves-p384.a: builds clean (157 KB archive). * overlay-p384.bin: NOT PRODUCED -- ld65 reports OVERLAY_REGION overflow by 4644 B (OVERLAY_P384 = 12836 B, slot = 8192 B). Per-segment from ld65 .map: fp384 1054 B mod384 3625 B points384 1630 B (post Lim-Lee strip) curve384 288 B sha384 5456 B (code + IV/K[80] RODATA) ecdsa384 758 B ec_scalar_mul_384_shim 25 B ---------------------- OVERLAY_P384 12836 B (overflow: +4644 B over 8192-byte slot) DATA 3541 B (RESIDENT, fits) BSS 53 B (RESIDENT, fits) Phase 1.5 must either grow OVERLAY_REGION to >= 16 KB or split OVERLAY_P384 into two halves with a runtime swap dispatcher. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/integration/build_nistcurves_p384.sh | 358 ++++++++++++++---- .../integration/build_nistcurves_p384_bin.sh | 11 +- 2 files changed, 291 insertions(+), 78 deletions(-) diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh index 2caf4d9..f45489e 100755 --- a/tools/integration/build_nistcurves_p384.sh +++ b/tools/integration/build_nistcurves_p384.sh @@ -1,40 +1,60 @@ #!/usr/bin/env bash # ============================================================================= # tools/integration/build_nistcurves_p384.sh - Build c64-nist-curves P-384 -# primitives as a REU overlay .a archive for the UCI backend. +# primitives + SHA-384 + ECDSA-with-message wrapper as a REU overlay .a archive +# for the UCI backend smoke test. # -# Phase C.3 of the sibling-lib integration. Produces build/lib/nistcurves-p384.a -# containing ONLY the three variable-base P-384 primitives used by TLS -# (ec_point_double_384, ec_point_add_384, ec_jacobian_to_affine_384) -# plus their fp/mod helpers. +# Phase 1b extension. Produces build/lib/nistcurves-p384.a containing: +# * Variable-base P-384 primitives (ec_point_double_384, ec_point_add_384, +# ec_jacobian_to_affine_384, ec_scalar_mul_var_384) plus their fp/mod +# helpers. +# * SHA-384 streaming hash (sha384_init / update / final + sha384_digest). +# * Packaged ECDSA-P384 verify (ecdsa_verify_384) and the +# ecdsa_verify_with_message_384 one-shot wrapper. +# * curve384 generator constants (ec_gx384, ec_gy384) for the +# ec_scalar_mul_384 -> ec_scalar_mul_var_384 shim (Option A; see below). # # Segment layout: -# OVERLAY_P384 - all P-384 runtime code (fp384 + mod384 + points384). +# OVERLAY_P384 - all P-384 + SHA-384 + ECDSA-P384 runtime code + +# RODATA (K[80] SHA constants, curve384 constants). # Paged into the live CRYPTO_OVERLAY slot via REU DMA. -# CRYPTO_RESIDENT - P-384 RW data (ec384_* points, fp384_* tmps, etc.) +# CRYPTO_RESIDENT - P-384 + SHA-384 + ECDSA-P384 RW data (ec384_*, fp384_*, +# ecdsa384_*, sha_state, sha_w, sha_block_buf, ...) # routed through the DATA / BSS segments. # -# Excluded (upstream JC-000/c64-nist-curves#17 tracks what's missing): -# - ec_scalar_mul_384 - fixed-base-only (Lim-Lee comb over precomputed -# anchors). Not useful without variable-base mul. -# - ec_precompute_384 - builds the Lim-Lee comb table; needs REU bank 2 -# layout that conflicts with the overlay store. +# Excluded: +# - ec_scalar_mul_384 - The sibling's Lim-Lee fixed-base 8-comb. Needs a +# 24 KB REU bank-2 anchor table built by +# ec_precompute_384 at boot (~100 s of init time). +# Phase 1b takes Option A: STRIP the Lim-Lee body and +# replace with an in-staging shim that copies G into +# ec_base384_x/y and tail-calls ec_scalar_mul_var_384. +# Mirrors the Phase C.4 P-256 dispatcher pattern (see +# src/crypto/ecdsa_verify.s::ec_scalar_mul). Slower +# per-call (double-and-add in lieu of a windowed +# comb) but avoids the ~24 KB precompute table and +# the ~100 s boot drag. +# - ec_precompute_384 - builds the Lim-Lee anchor table; only useful with +# ec_scalar_mul_384. # - Lim-Lee anchor tables (ec_anchor1_384_x..ec_anchor8_384_y) and # comb-scalar state (cm_k_384, ec384_sc_byte/mask, ec384_precomp_i). -# - P-256 modules (fp256/mod256/curve256/points256/inv256). P-256 stays -# in-tree (see src/crypto/ecdsa_*.s); Phase C.3 does not swap it out. -# - curve384.s (ec_a384/b384/gx384/gy384constants). Only imported by the -# stripped ec_precompute_384 / ec_scalar_mul_384. -# -# The mul_8x8 runtime + mul_dma_lo/hi tables + reu_fetch_mul_row come -# from the already-linked c64-x25519 sibling archive (build/lib/x25519.a). -# P-384's fp_mul_384 / fp_sqr_384 reuse those REU-backed product tables; -# the table layout (a*512 offset, 256 lo + 256 hi bytes per row) matches -# between the two siblings. +# - sha384_msg_buf - 1 KB test scratch buffer owned by the upstream +# test harness. Production / overlay-smoke-test +# consumers do not need it; the .import lines in +# sha384.s and ecdsa384.s are stripped in this +# script. +# - ec_aff2g_256_*, ec_anchor*_256, cm_k -- P-256 Lim-Lee infrastructure. +# Not relevant to the P-384 overlay. +# - mul_8x8 / sqtab_init / mul_dma_lo/hi / mul_cached_a / mul_src2_buf / +# reu_fetch_mul_row / poly_prod_lo/hi / sqtab_lo/hi - resolved at link +# time by build_nistcurves_p384_bin.sh's --define stubs (these symbols +# come from the in-PRG c64-x25519 sibling at runtime; the standalone +# overlay image references them but does not inline their bytes). # # The script stages the sibling's .s files in build/lib/nistcurves_p384_staging/, -# applies a sed-patch to each to override their `.segment "CODE"` / "DATA" -# directives, and assembles with canonical ZP equates passed via -D. +# applies sed-patches to each to override their `.segment "CODE"` / "DATA" +# directives, drops `.import sha384_msg_buf` lines from sha384.s and +# ecdsa384.s, and assembles with canonical ZP equates passed via -D. # # Usage (from top-level Makefile): # bash tools/integration/build_nistcurves_p384.sh @@ -58,8 +78,19 @@ AR65="${AR65:-ar65}" # --- Canonical ZP defines --- # The sibling's zp_config.s wraps every ZP equate in .ifndef, so command-line # -D values win over the defaults. We pin the sibling to c64-https's -# canonical ZP map (src/crypto/shared/zp_canon.inc) so the archive's -# absolute ZP references line up with TLS call-site expectations. +# canonical ZP map (src/crypto/shared/zp_canon.inc) where the slots overlap; +# SHA-384's sha_src/sha_len/sha_w_ptr/sha_w_ptr2 ($04-$0B) are LEFT AT THE +# SIBLING'S DEFAULTS because: +# (1) The overlay binary produced here is harness-time only (Phase C.3b). +# It is loaded by tools/test_p384_symbols.py into REU then DMA'd into +# the live overlay slot AT TEST TIME -- production PRG never links it. +# (2) Inside the c64-https production ZP map ($04-$09 = w32_*, $0a-$0d = +# sha_temp1) those slots are claimed by ChaCha20/Poly1305 + SHA-256 +# which run concurrently with TLS handshake. If/when Phase 2 wires +# SHA-384 / ECDSA-with-message into the production handshake, the ZP +# collision MUST be resolved either by relocating sha_src/sha_len/etc. +# into c64-https's free range or by repurposing $04-$0B during the +# (brief) ECDSA verify window. Out of scope for Phase 1b. # # Note: fp_mul_i / fp_mul_j overlap with x25_byte_idx / x25_bit_mask at # $39/$3a. This is fine because x25519 and P-384 run at different times @@ -84,6 +115,11 @@ ZP_DEFINES=( '-Dpoly_j=$1b' '-Dpoly_carry=$1c' '-Dpoly_tmp=$1d' + # SHA-384 streaming pointer slots (matches sibling defaults) + '-Dsha_src=$04' + '-Dsha_len=$06' + '-Dsha_w_ptr=$08' + '-Dsha_w_ptr2=$0a' ) # --- Stage sources --- @@ -98,58 +134,80 @@ cp "$LIB_SRC"/zp_config.s "$STAGING/" cp "$LIB_SRC"/fp384.s "$STAGING/fp384_raw.s" cp "$LIB_SRC"/mod384.s "$STAGING/mod384_raw.s" cp "$LIB_SRC"/points384.s "$STAGING/points384_raw.s" -cp "$LIB_SRC"/data.s "$STAGING/data_raw.s" +cp "$LIB_SRC"/curve384.s "$STAGING/curve384_raw.s" +cp "$LIB_SRC"/sha384.s "$STAGING/sha384_raw.s" +cp "$LIB_SRC"/ecdsa384.s "$STAGING/ecdsa384_raw.s" # --- Strip points384.s of ec_precompute_384 and ec_scalar_mul_384 --- # Those live between lines 787 (just before ec_precompute_384:) and -# 1489 (just before the ec_jacobian_to_affine_384: header). +# 1489 (just before the ec_scalar_mul_var_384: header). # We also strip the `.export ec_precompute_384, ec_scalar_mul_384` line # so the archive doesn't advertise symbols whose bodies were removed. -# The remaining three `.export` symbols (ec_point_double_384, -# ec_point_add_384, ec_jacobian_to_affine_384) stay. +# The remaining four `.export` symbols (ec_point_double_384, +# ec_point_add_384, ec_scalar_mul_var_384, ec_jacobian_to_affine_384) stay. +# +# OPTION A choice (Phase 1b): the Lim-Lee body for ec_scalar_mul_384 is +# stripped; an in-staging shim file (ec_scalar_mul_384_shim_raw.s, emitted +# below) provides the symbol by copying G into ec_base384_x/y and +# tail-calling ec_scalar_mul_var_384. This avoids the ~24 KB Lim-Lee +# anchor table + ~100 s ec_precompute_384 boot drag. Pattern mirrors +# src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 for P-256). # # Imports that the removed bodies relied on (anchors, cm_k_384, sc_byte, -# sc_mask, precomp_i, ec_gx384, ec_gy384, ec_set_modp_384... wait ec_set_modp -# is still used by double/add) — we remove ONLY the anchor + comb-state -# imports since everything else is used by the retained primitives. -sed -i '787,1489d' "$STAGING/points384_raw.s" -sed -i '/^\.export ec_precompute_384, ec_scalar_mul_384$/d' "$STAGING/points384_raw.s" +# sc_mask, precomp_i, ec_set_modp is still used by double/add/var) - we +# remove ONLY the anchor + comb-state imports since everything else is used +# by the retained primitives. We also keep `ec_gx384, ec_gy384` because +# the shim references them; that import line is left in place. +# BSD-sed compat: macOS sed requires `-i ''` (empty extension). +sed -i '' '787,1488d' "$STAGING/points384_raw.s" +sed -i '' '/^\.export ec_precompute_384, ec_scalar_mul_384$/d' "$STAGING/points384_raw.s" # Strip imports only used by the removed bodies. Patterns are anchored -# to avoid accidentally deleting unrelated lines. -sed -i '/^\.import ec_gx384, ec_gy384$/d' "$STAGING/points384_raw.s" -sed -i '/^\.import ec_anchor[1-8]_384_x/d' "$STAGING/points384_raw.s" -sed -i '/^\.import ec_anchor[1-8]_384_y/d' "$STAGING/points384_raw.s" -sed -i '/^\.import cm_k_384, mul_dma_lo$/d' "$STAGING/points384_raw.s" -sed -i '/^\.import ec384_sc_byte, ec384_sc_mask, ec384_precomp_i$/d' "$STAGING/points384_raw.s" - -# --- Strip data_raw.s of P-256 content + Lim-Lee comb anchors --- -# We only keep the P-384 RW buffers that fp384 / mod384 / points384 reference: -# fp384_wide, fp384_tmp1..4, fp384_r0..r3, fp384_inv_u/v/x1/x2, -# ec384_p1/p2/p3, ec384_t1..t6, ec384_affine_x/y, fp384_red_tmp -# -# We drop: +# to avoid accidentally deleting unrelated lines. ec_gx384 / ec_gy384 are +# KEPT (used by the shim emitted below). +sed -i '' '/^\.import ec_anchor[1-8]_384_x/d' "$STAGING/points384_raw.s" +sed -i '' '/^\.import ec_anchor[1-8]_384_y/d' "$STAGING/points384_raw.s" +sed -i '' '/^\.import cm_k_384, mul_dma_lo$/d' "$STAGING/points384_raw.s" +sed -i '' '/^\.import ec384_sc_byte, ec384_sc_mask, ec384_precomp_i$/d' "$STAGING/points384_raw.s" + +# --- Drop test-only sha384_msg_buf imports --- +# sha384.s and ecdsa384.s both `.import sha384_msg_buf` at file scope but +# never reference the symbol in code (sha384.s never touches it; ecdsa384.s +# only mentions it in the test trampoline's docstring). We drop the +# 1024-byte test scratch buffer from data_raw.s, so the imports must go +# too or the linker will fail to resolve them. +sed -i '' 's/^\(\.import sha384_digest, sha384_msg_buf\)$/.import sha384_digest/' "$STAGING/sha384_raw.s" +sed -i '' 's/^\(\.import sha384_digest, sha384_msg_buf\)$/.import sha384_digest/' "$STAGING/ecdsa384_raw.s" + +# --- Extend data_raw.s with all the BSS / DATA exports the P-384 path needs. +# --- +# Hand-extracted from the sibling's data.s. Drops: # - P-256 field buffers (fp_wide, fp_tmp*, fp_r*, fp_inv_*, ec_p1..) -# because c64-https's in-tree ECDSA P-256 already provides these and -# we must not double-define them. ALSO: fp_wide in c64-nist-curves -# is 64 bytes while the in-tree ecdsa_fp.s `fp_wide` is local (no -# export) — keeping the sibling's fp_wide would create a collision. -# - mul_cached_a, mul_src2_buf, mul_dma_lo/hi — provided by the -# x25519 sibling (already linked first in SIBLING_LIB_ARCHIVES). +# because the P-256 sibling archive (build/lib/nistcurves-p256.a) +# already provides these and we must not double-define them at link +# time (the standalone overlay binary uses --define stubs for the +# few P-256 symbols the P-384 path could in theory cross-reference). +# - mul_cached_a, mul_src2_buf, mul_dma_lo/hi - provided by the +# x25519 sibling at runtime; resolved via --define stubs at standalone +# overlay link time. # - Lim-Lee anchors (ec_anchor*_x/y, ec_aff2g_256_*), cm_k / cm_k_384, -# ec384_sc_*, ec384_precomp_i — only used by the stripped scalar-mul +# ec384_sc_*, ec384_precomp_i - only used by the stripped scalar-mul # and precompute bodies. +# - sha384_msg_buf (1024 B) - test-only scratch buffer; not needed for +# the production overlay path. # # Strategy: write a brand new data_raw.s that pulls only what we need. # We keep the sibling's data.s around for reference but emit an # explicit minimal one. cat > "$STAGING/data_raw.s" <<'DATA_EOF' ; ============================================================================= -; data_raw.s - Minimal P-384 RW buffers for c64-https / c64-nist-curves -; integration. Hand-extracted from the sibling's data.s so the -; P-256 side (in-tree) and the x25519 sibling's shared mul -; tables remain unclobbered. +; data_raw.s - Minimal P-384 + SHA-384 + ECDSA-P384 RW buffers for c64-https / +; c64-nist-curves integration (Phase 1b). Hand-extracted from +; the sibling's data.s so the P-256 side (sibling-provided) and +; the x25519 sibling's shared mul tables remain unclobbered. ; -; All exports here are P-384-exclusive. +; All exports here are P-384- / SHA-384- / ECDSA-with-message-exclusive. +; sha384_msg_buf (test-only 1 KB scratch) is intentionally OMITTED -- see +; build_nistcurves_p384.sh header for the rationale. ; ============================================================================= .setcpu "6502" @@ -215,24 +273,157 @@ ec384_affine_x: .res 48, 0 .export ec384_affine_y ec384_affine_y: .res 48, 0 +; --- Variable-base scalar-mul input (affine, 48 bytes each, LE). +; Consumed by ec_scalar_mul_var_384 (ECDSA-verify building block) and +; populated by the ec_scalar_mul_384 shim (G -> ec_base384_x/y). +.export ec_base384_x +ec_base384_x: .res 48, 0 +.export ec_base384_y +ec_base384_y: .res 48, 0 + ; --- P-384 Solinas reduction scratch --- .export fp384_red_tmp fp384_red_tmp: .res 49, 0 + +; --- ECDSA verify scratch (P-384). All 48-byte little-endian unless noted. --- +.export ecdsa384_r +ecdsa384_r: .res 48, 0 ; LE r (byte-reversed from BE input) +.export ecdsa384_s +ecdsa384_s: .res 48, 0 ; LE s +.export ecdsa384_h +ecdsa384_h: .res 48, 0 ; LE message hash +.export ecdsa384_qx +ecdsa384_qx: .res 48, 0 ; LE public-key affine X +.export ecdsa384_qy +ecdsa384_qy: .res 48, 0 ; LE public-key affine Y +.export ecdsa384_w +ecdsa384_w: .res 48, 0 ; LE w = s^-1 mod n +.export ecdsa384_u1 +ecdsa384_u1: .res 48, 0 ; LE u1 = h*w mod n +.export ecdsa384_u2 +ecdsa384_u2: .res 48, 0 ; LE u2 = r*w mod n +.export ecdsa384_u1_be +ecdsa384_u1_be: .res 48, 0 ; BE u1 (scalar_mul input) +.export ecdsa384_u2_be +ecdsa384_u2_be: .res 48, 0 ; BE u2 (scalar_mul_var input) +.export ecdsa384_u1g_x +ecdsa384_u1g_x: .res 48, 0 ; LE affine X of u1*G +.export ecdsa384_u1g_y +ecdsa384_u1g_y: .res 48, 0 ; LE affine Y of u1*G + +; --- fp_reverse48 staging buffer (one 48-byte scratch). --- +.export fp_rev_buf_384 +fp_rev_buf_384: .res 48, 0 + +; --- ECDSA verify test-driver staging buffer (240 B BE struct). +; The c64-test-harness jsr() helper cannot pass register arguments, so +; the BE input struct is staged here and the test trampoline points +; A/X at it. +.export ecdsa_inputs_384 +ecdsa_inputs_384: .res 240, 0 ; r|s|h|Qx|Qy each 48 B BE + +; --- ecdsa_verify_with_message_384 scratch + test-driver result byte --- +.export ecdsa384_msg_struct_ptr +ecdsa384_msg_struct_ptr: .res 2, 0 +.export ecdsa_result_msg_384 +ecdsa_result_msg_384: .byte 0 + +; ============================================================================= +; SHA-384 streaming hash state (FIPS 180-4 §6.4) +; +; Storage convention: each 64-bit word is held LITTLE-ENDIAN-WITHIN-WORD, +; matching 6502 ADC carry propagation. All buffers are owned exclusively +; by sha384.s. sha384_msg_buf (1 KB test scratch) is intentionally OMITTED. +; ============================================================================= +.export sha_state +sha_state: .res 64, 0 ; H[0..7], 8 bytes each LE-within-word +.export sha_w +sha_w: .res 640, 0 ; W[0..79] message schedule, 8 B each LE +.export sha_abcdefgh +sha_abcdefgh: .res 64, 0 ; working a..h, 8 B each LE +.export sha_t +sha_t: .res 16, 0 ; T1 (8 B) + T2 (8 B), LE +.export sha_scratch +sha_scratch: .res 64, 0 ; 8x 8-byte scratch slots for round helpers +.export sha_block_buf +sha_block_buf: .res 128, 0 ; current 1024-bit block (wire order) +.export sha_block_len +sha_block_len: .byte 0 ; bytes used in sha_block_buf, 0..127 +.export sha_total_len +sha_total_len: .res 16, 0 ; 128-bit total bit count, LE on-chip +.export sha384_digest +sha384_digest: .res 48, 0 ; final BE digest output DATA_EOF -# --- Route CODE segments to OVERLAY_P384 --- +# --- Emit ec_scalar_mul_384 shim (Option A) --- +# Pattern mirrors src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 P-256 +# dispatcher). Lives in OVERLAY_P384 alongside the rest of the P-384 code. +# ec_gx384 and ec_gy384 are each contiguous 48-byte slots in curve384.s +# RODATA, so a single 96-byte copy loop (using two reads per Y for X then +# Y at +48) is straightforward. We use a simple ldy #47 / lda src,y / +# sta dst,y / dey / bpl loop (47 = $2F has bit 7 clear so BPL is safe; +# DEY updates N flag based on the decremented Y, not the LDA byte) twice +# to copy ec_gx384 -> ec_base384_x and ec_gy384 -> ec_base384_y separately. +cat > "$STAGING/ec_scalar_mul_384_shim_raw.s" <<'SHIM_EOF' +; ============================================================================= +; ec_scalar_mul_384_shim_raw.s -- Phase 1b shim for the stripped Lim-Lee +; fixed-base scalar-mul (Option A). Provides ec_scalar_mul_384 by copying +; G into ec_base384_x/y and tail-calling ec_scalar_mul_var_384. +; +; Mirrors the Phase C.4 P-256 dispatcher pattern in +; src/crypto/ecdsa_verify.s::ec_scalar_mul. Slower per-call than the real +; Lim-Lee comb (double-and-add vs. windowed comb) but avoids the ~24 KB +; REU bank-2 anchor table + ~100 s ec_precompute_384 boot drag. +; ============================================================================= +.setcpu "6502" + +.segment "OVERLAY_P384" + +.export ec_scalar_mul_384 + +.import ec_gx384, ec_gy384 +.import ec_base384_x, ec_base384_y +.import ec_scalar_mul_var_384 + +ec_scalar_mul_384: + ; Copy G.x -> ec_base384_x (48 bytes; ldy #47, dey/bpl safe) + ldy #47 +@cp_x: lda ec_gx384,y + sta ec_base384_x,y + dey + bpl @cp_x + ; Copy G.y -> ec_base384_y (48 bytes) + ldy #47 +@cp_y: lda ec_gy384,y + sta ec_base384_y,y + dey + bpl @cp_y + jmp ec_scalar_mul_var_384 ; tail-call: result and clobbers passthrough +SHIM_EOF + +# --- Route CODE / RODATA segments into OVERLAY_P384 --- # fp384_raw.s and mod384_raw.s use `.segment "CODE"` (once each) and # fp384_raw.s has a second `.segment "BSS"` block at the tail. Those # tail BSS buffers (fp384_sqr_extra, mul_src2_buf_384, fp384_sqr_pairs) # must go in CRYPTO_RESIDENT BSS (always-resident state, not overlay) # since the overlay gets swapped out between calls. We rename the BSS -# segment to the c64-https canonical `BSS` name which the UCI cfg maps -# into CRYPTO_RESIDENT_2 BSS. -sed -i 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/fp384_raw.s" -sed -i 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/mod384_raw.s" -sed -i 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/points384_raw.s" -# fp384_raw.s .segment "BSS" stays — already matches the canonical BSS -# segment which cfg/c64-https-uci.cfg maps into CRYPTO_RESIDENT_2. +# segment to the c64-https canonical `BSS` name which the cfg maps into +# the RESIDENT region. +# BSD-sed compat: macOS sed requires `-i ''` (empty extension). +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/fp384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/mod384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/points384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/sha384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/ecdsa384_raw.s" +# curve384.s uses RODATA -- route it into OVERLAY_P384 (read-only constants). +sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384"/' "$STAGING/curve384_raw.s" +# sha384.s has a second `.segment "RODATA"` block at the tail for the SHA-384 +# IV + K[80] round constants (704 B). Route it into OVERLAY_P384 alongside +# the code that reads it; otherwise it lands at $0000 and the linker won't +# write it into the overlay binary. +sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384"/' "$STAGING/sha384_raw.s" +# fp384_raw.s .segment "BSS" stays - already matches the canonical BSS +# segment which cfg/p384-overlay.cfg maps into the RESIDENT region. # --- mod384.s curve constants (ec_p384, ec_n384) live in CODE segment # in the sibling and are emitted inline with .byte directives. After the @@ -240,12 +431,21 @@ sed -i 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/points384_raw.s" # code that reads them; that is intentional (ec_p384 is used by # fp_mod_reduce384 which IS in the overlay). -# --- ec_sc_byte / ec_sc_mask --- -# points384.s had `.import ec384_sc_byte, ec384_sc_mask, ec384_precomp_i` -# — we stripped that import above since only the removed precompute / -# scalarmul bodies referenced those names. Double-check nothing leaked: -if grep -qE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b|\bec_gx384\b|\bec_gy384\b' "$STAGING/points384_raw.s"; then +# --- Forbidden-symbol guard --- +# After the strip, points384_raw.s must NOT reference any of the removed +# Lim-Lee comb / precompute symbols. ec_gx384 / ec_gy384 / cm_k_384 / +# ec_anchor*_384 patterns CAN appear in points384_raw.s only as comments; +# we strip leading whitespace and a leading `;` before the grep so we only +# match active code. ec_gx384 / ec_gy384 are intentionally left LIVE in +# the staging tree (used by the shim) so we don't include them in the +# guard. cm_k_384, ec_anchor[0-9]_384, ec384_sc_byte/mask, ec384_precomp_i +# remain forbidden -- those bodies were physically removed. +if grep -v '^\s*;' "$STAGING/points384_raw.s" \ + | grep -qE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b'; then echo "ERROR: stripped points384 still references removed-body symbols" >&2 + grep -v '^\s*;' "$STAGING/points384_raw.s" \ + | grep -nE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b' \ + | head -5 >&2 exit 1 fi @@ -268,7 +468,8 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" # zp_config.o's `.exportzp` declarations. If we passed -D here the # assembler would treat the symbol as locally-defined absolute and # conflict with the .importzp declaration. -for src in fp384_raw mod384_raw points384_raw data_raw; do +for src in fp384_raw mod384_raw points384_raw curve384_raw \ + sha384_raw ecdsa384_raw ec_scalar_mul_384_shim_raw data_raw; do "$CA65" \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ @@ -282,14 +483,19 @@ rm -f "$ARCHIVE" "$OBJ_DIR/fp384_raw.o" \ "$OBJ_DIR/mod384_raw.o" \ "$OBJ_DIR/points384_raw.o" \ + "$OBJ_DIR/curve384_raw.o" \ + "$OBJ_DIR/sha384_raw.o" \ + "$OBJ_DIR/ecdsa384_raw.o" \ + "$OBJ_DIR/ec_scalar_mul_384_shim_raw.o" \ "$OBJ_DIR/data_raw.o" # --- Per-source byte counts --- { echo "# nistcurves-p384.a per-source byte counts (ca65 .o file sizes)" - for src in zp_config fp384_raw mod384_raw points384_raw data_raw; do + for src in zp_config fp384_raw mod384_raw points384_raw curve384_raw \ + sha384_raw ecdsa384_raw ec_scalar_mul_384_shim_raw data_raw; do bytes=$(wc -c < "$OBJ_DIR/$src.o") - printf '%-24s %d bytes (.o)\n' "$src" "$bytes" + printf '%-32s %d bytes (.o)\n' "$src" "$bytes" done } > "$SIZES" diff --git a/tools/integration/build_nistcurves_p384_bin.sh b/tools/integration/build_nistcurves_p384_bin.sh index 3386751..473eaf1 100755 --- a/tools/integration/build_nistcurves_p384_bin.sh +++ b/tools/integration/build_nistcurves_p384_bin.sh @@ -62,7 +62,8 @@ rm -rf "$SCRATCH" mkdir -p "$SCRATCH" cp "$ARCHIVE" "$SCRATCH/" (cd "$SCRATCH" && "$AR65" x "$(basename "$ARCHIVE")" \ - zp_config.o fp384_raw.o mod384_raw.o points384_raw.o data_raw.o) + zp_config.o fp384_raw.o mod384_raw.o points384_raw.o curve384_raw.o \ + sha384_raw.o ecdsa384_raw.o ec_scalar_mul_384_shim_raw.o data_raw.o) # Try to pick up x25519-sibling addresses from the main build's labels.txt # so references resolve to the real runtime locations. If the main build @@ -126,11 +127,17 @@ mkdir -p "$OUT_DIR" "$SCRATCH/fp384_raw.o" \ "$SCRATCH/mod384_raw.o" \ "$SCRATCH/points384_raw.o" \ + "$SCRATCH/curve384_raw.o" \ + "$SCRATCH/sha384_raw.o" \ + "$SCRATCH/ecdsa384_raw.o" \ + "$SCRATCH/ec_scalar_mul_384_shim_raw.o" \ "$SCRATCH/data_raw.o" # Normalise labels to VICE format (al C:XXXX .name) so c64-test-harness's # Labels.from_file() reader accepts it identically to build/labels.txt. -sed -i 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$LABELS_OUT" +# BSD-sed compat: macOS sed requires `-i ''` (empty extension); GNU sed +# accepts both forms. +sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$LABELS_OUT" # ld65 writes the DATA segment bytes (RESIDENT region at $7C00) into the # output file too, even though RESIDENT has no `file = %O` — so the raw From 811158d97a8ad88e45711c0554b856444ac34bdb Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 19:00:31 -0500 Subject: [PATCH 04/21] build(p384-overlay): split overlay into sha384 + curve halves to fit 7.5 KB slot Phase 1.5. Phase 1b's monolithic OVERLAY_P384 segment was 12,836 B and overflowed the live UCI CRYPTO_OVERLAY slot (7,680 B at $4200-$5FFF). The slot cannot grow without colliding with CRYPTO_RESIDENT at $6000. Fix is functional: split the P-384 image into two halves, each fitting the slot; load one at a time on demand. The TLS path will drive them in sequence: load sha384 overlay -> hash transcript -> load curve overlay -> call ecdsa_verify_384 with the digest pre-spliced into ecdsa_inputs_384[96..143] in resident DATA. Split rationale: * sha384 half: just sha384.s code + IV/K[80] RODATA + the SHA-384 streaming state buffers (sha_state, sha_w, sha_block_buf, ...). Unpadded 5,456 B; 2,224 B headroom. * curve half: fp384 + mod384 + points384 (Lim-Lee stripped) + curve384 + ecdsa384 (verify_384 ONLY -- the verify_with_message_384 + verify_with_msg_384_tramp wrappers were physically deleted from ecdsa384_raw.s by the build script since they import sha384_init/update/final from the OTHER overlay half) + the ec_scalar_mul_384 -> ec_scalar_mul_var_384 shim. Unpadded 7,317 B; 363 B headroom. * Both halves fit the 7,680 B live slot. ZP allocation: Sibling defaults sha_src=$04 / sha_len=$06 / sha_w_ptr=$08 / sha_w_ptr2=$0a collide with c64-https canonical use ($04-$09 = w32_* ChaCha20/Poly1305, $0A-$0D = sha_temp1 SHA-256). Phase 1.5 moves them to $3D-$44 (lowest 8-byte contiguous free block above the canonical crypto ZP map; ec_scalar_ptr ends at $3C). Verified free during the SHA-384 call window; no save/restore needed. Inheritance for Phase 4a documented in the comment block at the top of src/crypto/shared/crypto_swap.s. REU layout (src/crypto/shared/reu_layout.inc): REU_OVERLAY_P384_SHA384 = $60000 (bank 6; 8 KB image, 56 KB headroom) REU_OVERLAY_P384_CURVE = $70000 (bank 7; 8 KB image, 56 KB headroom) Banks 4-5 retained for nominal P-384 precompute reservation; not used since Phase 1b stripped the Lim-Lee body. Resident DATA growth: SHA archive resident: 1,041 B (sha_state + sha_w + sha_abcdefgh + sha_t + sha_scratch + sha_block_buf + sha_block_len + sha_total_len + sha384_digest). Curve archive resident: 2,498 B DATA + 53 B BSS = 2,551 B (fp384_*, ec384_*, ecdsa384_*, ec_base384_*, fp_rev_buf_384, ecdsa_inputs_384, ecdsa_result_msg_384). Combined: 3,592 B -- byte-for-byte the same as Phase 1b's combined data_raw.s (3,541 B + 53 B = 3,594 B; the 2 B delta is the data_*.s split-off ecdsa384_msg_struct_ptr bytes which the curve archive no longer needs after the wrapper-strip). What changed: * cfg/p384-overlay.cfg (Phase 1b monolithic) DELETED. * cfg/p384-overlay-sha384.cfg + cfg/p384-overlay-curve.cfg ADDED; each pins OVERLAY_REGION at $4200 size $1E00 to match the live UCI slot, plus DATA / BSS at $C000 for label correctness. * tools/integration/build_nistcurves_p384.sh now produces build/lib/nistcurves-p384-sha384.a + nistcurves-p384-curve.a. Adds wrapper-strip + new forbidden-symbol guard for sha384_init/update/final references in the curve-half ecdsa384_raw.s. * tools/integration/build_nistcurves_p384_bin.sh now produces build/lib/overlay-p384-sha384.bin + overlay-p384-curve.bin (each padded to 7,680 B to match the live slot) plus per-half labels and sizes reports. Fixed macOS awk strtonum-portability issue along the way. * Makefile p384-overlay target rewired for the dual-archive output. * src/crypto/shared/crypto_swap.s comment block ONLY: documents the four overlay states (none / X25519 / P-384-sha384 / P-384-curve), the TLS-side call sequence, the resident DATA invariants, and the ZP save/restore obligation Phase 4a inherits. The swap code itself is untouched (Phase 3 will add the two new entry points). Outstanding for downstream phases: * Phase 3: extend crypto_swap.s with crypto_swap_to_p384_sha384 + crypto_swap_to_p384_curve entry points; remove the now-stale crypto_swap_to_p384 (only consumer is tools/test_p384_symbols.py which Phase 3 will rewrite). * Phase 4a: implement the TLS-side dispatcher (load sha384 -> hash -> splice digest -> load curve -> verify); see the comment block at the top of crypto_swap.s for the call sequence. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 40 +- cfg/p384-overlay-curve.cfg | 42 ++ cfg/p384-overlay-sha384.cfg | 43 ++ cfg/p384-overlay.cfg | 42 -- src/crypto/shared/crypto_swap.s | 83 ++++ src/crypto/shared/reu_layout.inc | 32 +- tools/integration/build_nistcurves_p384.sh | 455 ++++++++++-------- .../integration/build_nistcurves_p384_bin.sh | 292 +++++++---- 8 files changed, 688 insertions(+), 341 deletions(-) create mode 100644 cfg/p384-overlay-curve.cfg create mode 100644 cfg/p384-overlay-sha384.cfg delete mode 100644 cfg/p384-overlay.cfg diff --git a/Makefile b/Makefile index debe323..19e1fc9 100644 --- a/Makefile +++ b/Makefile @@ -107,7 +107,12 @@ CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) # integration can be re-enabled by uncommenting the two lines below once # the cfg is extended. #CA65FLAGS += -D USE_NISTCURVES_P384=1 -#SIBLING_LIB_ARCHIVES += build/lib/nistcurves-p384.a +# Phase 1.5 split the monolithic nistcurves-p384.a into two halves +# (nistcurves-p384-sha384.a + nistcurves-p384-curve.a) since the +# combined image overflowed the live 7.5 KB CRYPTO_OVERLAY slot. +# Either-of approach for the production wire-up will be Phase 4a. +#SIBLING_LIB_ARCHIVES += build/lib/nistcurves-p384-sha384.a +#SIBLING_LIB_ARCHIVES += build/lib/nistcurves-p384-curve.a else $(error Unknown BACKEND=$(BACKEND); expected ip65 or uci) endif @@ -148,10 +153,10 @@ build/%.o: src/%.s $(CA65) $(CA65FLAGS) -o $@ $< # Phase C.3: c64-nist-curves sibling archive (libs/nistcurves/ submodule). -# Same gating as x25519: only linked under BACKEND=uci; ip65 continues -# without P-384 entirely. Exports only the variable-base primitives -# (see the build script for the excluded symbols and why). -build/lib/nistcurves-p384.a: +# Phase 1.5 split: produces TWO archives, one per overlay half. The +# script writes both with a single invocation; the second target is a +# pseudo-rule that piggybacks on the first. +build/lib/nistcurves-p384-sha384.a build/lib/nistcurves-p384-curve.a: @mkdir -p build/lib bash tools/integration/build_nistcurves_p384.sh @@ -176,18 +181,27 @@ build/lib/x25519.a: @mkdir -p build/lib bash tools/integration/build_x25519.sh -# Phase C.3b: P-384 overlay IMAGE + labels for harness-time use only. -# The production PRG does NOT link nistcurves-p384.a — this is smoke-test -# infrastructure. tools/test_p384_symbols.py loads overlay-p384.bin into -# REU at test time via a trampoline, then calls crypto_swap_to_p384 to -# page it into the live slot. Keeps the main PRG size unchanged. +# Phase C.3b / Phase 1.5 split: P-384 overlay IMAGES + labels for +# harness-time use only. The production PRG does NOT link +# nistcurves-p384-{sha384,curve}.a — these are smoke-test infrastructure. +# A future Phase 3 / Phase 4a harness will load both .bins into REU at +# test time, then DMA them into the live slot via two new swap entry +# points (crypto_swap_to_p384_sha384 / crypto_swap_to_p384_curve); +# the existing crypto_swap_to_p384 entry point is now stale -- see the +# comment block at the top of src/crypto/shared/crypto_swap.s. # -# Both outputs live below build/; depend on the archive being built first. -build/lib/overlay-p384.bin build/labels-p384.txt: build/lib/nistcurves-p384.a cfg/p384-overlay.cfg tools/integration/build_nistcurves_p384_bin.sh +# All four outputs (two .bins + two labels files) are produced by a +# single script invocation; the rule lists all four targets so make +# only runs the script once even when several are stale. +build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin build/labels-p384-sha384.txt build/labels-p384-curve.txt: \ + build/lib/nistcurves-p384-sha384.a build/lib/nistcurves-p384-curve.a \ + cfg/p384-overlay-sha384.cfg cfg/p384-overlay-curve.cfg \ + tools/integration/build_nistcurves_p384_bin.sh bash tools/integration/build_nistcurves_p384_bin.sh .PHONY: p384-overlay -p384-overlay: build/lib/overlay-p384.bin build/labels-p384.txt +p384-overlay: build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin \ + build/labels-p384-sha384.txt build/labels-p384-curve.txt # Build ip65 object libraries from the submodule. Only needed if the ip65 # submodule changes; the prebuilt blob is committed to ip65-build/. diff --git a/cfg/p384-overlay-curve.cfg b/cfg/p384-overlay-curve.cfg new file mode 100644 index 0000000..815ffe0 --- /dev/null +++ b/cfg/p384-overlay-curve.cfg @@ -0,0 +1,42 @@ +# cfg/p384-overlay-curve.cfg — ld65 config for the curve / verify half of +# the split P-384 overlay (Phase 1.5). +# +# Holds: fp384, mod384, points384 (post Lim-Lee strip), curve384, +# ecdsa384 (verify_384 ONLY — the verify_with_message_384 wrapper that +# imports sha384_init/update/final is dropped here; TLS calls the SHA +# overlay separately and pre-stages the digest into the resident DATA +# struct), and the ec_scalar_mul_384 -> ec_scalar_mul_var_384 shim. +# +# Loaded into the live UCI CRYPTO_OVERLAY slot ($4200-$5FFF, 7.5 KB) +# AFTER the sha384 half has done its work. The TLS dispatcher sequence: +# 1. swap-in sha384 overlay -> sha384_init / update* / final +# 2. swap-in curve overlay -> ecdsa_verify_384 +# The 240 B BE input struct (ecdsa_inputs_384) and the 48 B SHA digest +# (sha384_digest) live in CRYPTO_RESIDENT DATA at $C000 so they survive +# the swap. +# +# Layout matches the live UCI CRYPTO_OVERLAY base ($4200) and size +# ($1E00 = 7.5 KB) so the .bin DMAs into the live slot cleanly at +# harness time. + +FEATURES { + STARTADDRESS: default = $4200; +} + +MEMORY { + ZP: start = $0022, size = $001E, type = rw, define = yes; + OVERLAY_REGION: start = $4200, size = $1E00, file = %O, define = yes, + fill = yes, fillval = $00; + RESIDENT: start = $C000, size = $1000, type = rw, define = yes; +} + +SEGMENTS { + ZEROPAGE: load = ZP, type = zp, optional = yes; + + OVERLAY_P384_CURVE: load = OVERLAY_REGION, type = ro; + + # Resident RW buffers — we don't write them to the .bin, but they + # need real addresses so labels are correct. + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; +} diff --git a/cfg/p384-overlay-sha384.cfg b/cfg/p384-overlay-sha384.cfg new file mode 100644 index 0000000..c020bbf --- /dev/null +++ b/cfg/p384-overlay-sha384.cfg @@ -0,0 +1,43 @@ +# cfg/p384-overlay-sha384.cfg — ld65 config for the SHA-384 half of the +# split P-384 overlay (Phase 1.5). +# +# Phase 1b's monolithic OVERLAY_P384 (12,836 B) overflowed the live UCI +# CRYPTO_OVERLAY slot (7,680 B / 7.5 KB at $4200-$5FFF). The fix is to +# load the SHA-384 hash code and the curve / verify code as two separate +# overlay images; only one is resident at a time. The TLS path drives +# them in sequence: +# 1. swap-in sha384 overlay -> sha384_init / update* / final +# 2. swap-in curve overlay -> ecdsa_verify_384 (digest pre-staged in +# ecdsa_inputs_384[96..143] in resident DATA) +# +# The DATA / BSS exports stay in CRYPTO_RESIDENT (the same slot Phase 1b +# pinned them at, $C000) so the SHA digest survives the swap window +# between sha384_final and ecdsa_verify_384. See Phase 1b's +# build_nistcurves_p384.sh + the comment block at the top of +# src/crypto/shared/crypto_swap.s for the full rationale. +# +# Layout matches the live UCI CRYPTO_OVERLAY base ($4200) and size +# ($1E00 = 7.5 KB) so the .bin DMAs into the live slot cleanly at +# harness time. + +FEATURES { + STARTADDRESS: default = $4200; +} + +MEMORY { + ZP: start = $0022, size = $001E, type = rw, define = yes; + OVERLAY_REGION: start = $4200, size = $1E00, file = %O, define = yes, + fill = yes, fillval = $00; + RESIDENT: start = $C000, size = $1000, type = rw, define = yes; +} + +SEGMENTS { + ZEROPAGE: load = ZP, type = zp, optional = yes; + + OVERLAY_P384_SHA384: load = OVERLAY_REGION, type = ro; + + # Resident RW buffers — we don't write them to the .bin, but they + # need real addresses so labels are correct. + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; +} diff --git a/cfg/p384-overlay.cfg b/cfg/p384-overlay.cfg deleted file mode 100644 index 5ddd902..0000000 --- a/cfg/p384-overlay.cfg +++ /dev/null @@ -1,42 +0,0 @@ -# cfg/p384-overlay.cfg — minimal ld65 config for extracting the P-384 -# OVERLAY image as a standalone binary, used only by -# `tools/integration/build_nistcurves_p384_bin.sh`. -# -# NOT USED by the main c64-https PRG build. The production PRG does NOT -# link the P-384 archive (Phase C.3b keeps P-384 external / smoke-test-only); -# this cfg exists purely so we can extract a padded 8 KB binary image plus -# a VICE-format labels file that `tools/test_p384_symbols.py` loads into -# REU at harness time. -# -# Layout: -# $4200-$61FF : OVERLAY_P384 region (8 KB, padded with $00). Matches the -# CRYPTO_OVERLAY base under the UCI cfg so the image DMAs -# into the live overlay slot cleanly at harness time. -# $C000-$CFFF : RESIDENT — holds the P-384 RW buffers (DATA / BSS). -# These addresses intentionally land inside TCP_BUF -# ($C000-$CFFF) because networking is NOT active during -# the P-384 smoke test — the TCP ring is free space. -# This avoids clashing with the main PRG's CRYPTO code -# segments at $7C00-$BFFF which remain live. - -FEATURES { - STARTADDRESS: default = $4200; -} - -MEMORY { - ZP: start = $0022, size = $001E, type = rw, define = yes; - OVERLAY_REGION: start = $4200, size = $2000, file = %O, define = yes, - fill = yes, fillval = $00; - RESIDENT: start = $C000, size = $1000, type = rw, define = yes; -} - -SEGMENTS { - ZEROPAGE: load = ZP, type = zp, optional = yes; - - OVERLAY_P384: load = OVERLAY_REGION, type = ro; - - # Resident RW buffers — we don't write them to the .bin, but they - # need real addresses so labels are correct. - DATA: load = RESIDENT, type = rw, optional = yes; - BSS: load = RESIDENT, type = bss, optional = yes; -} diff --git a/src/crypto/shared/crypto_swap.s b/src/crypto/shared/crypto_swap.s index 9237057..57554be 100644 --- a/src/crypto/shared/crypto_swap.s +++ b/src/crypto/shared/crypto_swap.s @@ -25,6 +25,89 @@ ; ; `CRYPTO_OVERLAY_START` is defined by the linker (cfg `MEMORY { }` ; `define = yes` on the CRYPTO_OVERLAY region — see cfg/c64-https-*.cfg). +; +; ----------------------------------------------------------------------------- +; Phase 1.5 split-overlay design (P-384 path) -- INFORMATIONAL ONLY +; ----------------------------------------------------------------------------- +; Phase 1b's monolithic P-384 overlay (12.5 KB) overflowed the live UCI +; CRYPTO_OVERLAY slot (7,680 B at $4200-$5FFF). The fix is functional: +; split the P-384 image into two halves along the SHA / curve boundary, +; each fitting the slot, and load them in sequence. +; +; Phase 1.5 emits the two .bin files; Phase 3 will extend this dispatcher +; with two new entry points (do NOT add them yet -- this comment is +; informational only). The four overlay states the dispatcher will need +; to track: +; +; 0 = OV_NONE (uninitialized / swap_none) +; 2 = OV_P256 (existing — unchanged) +; 4 = OV_P384_SHA384 (NEW — sha384.s code + IV/K[80] RODATA) +; 5 = OV_P384_CURVE (NEW — fp384/mod384/points384/curve384/ +; ecdsa_verify_384/shim) +; +; The legacy state 3 (OV_P384, monolithic) is now stale and will be +; removed by Phase 3 along with the existing crypto_swap_to_p384 entry +; point (the only consumer is tools/test_p384_symbols.py, which will +; be rewritten to drive the two halves in sequence). +; +; REU storage (see src/crypto/shared/reu_layout.inc): +; REU_OVERLAY_P384_SHA384 = $60000 (bank 6) +; REU_OVERLAY_P384_CURVE = $70000 (bank 7) +; +; TLS-side call sequence (Phase 4a will implement the dispatcher): +; ; --- 1. Hash the handshake transcript --- +; jsr crypto_swap_to_p384_sha384 +; jsr sha384_init +; ldx #transcript ; jsr setup_sha_src/sha_len +; jsr sha384_update ; (one or more times) +; jsr sha384_final ; sha384_digest now holds the 48 B BE digest +; +; ; --- 2. Splice the digest into the resident BE input struct --- +; ; (resident DATA at $C000 survives the swap window) +; ldy #47 +; @cp: lda sha384_digest,y +; sta ecdsa_inputs_384+96,y +; dey +; bpl @cp +; +; ; --- 3. Swap in the curve / verify overlay and call verify --- +; jsr crypto_swap_to_p384_curve +; lda #ecdsa_inputs_384 +; jsr ecdsa_verify_384 +; ; C=0 VALID, C=1 INVALID/malformed +; +; Resident DATA invariants (Phase 1b -- Phase 1.5 preserves these): +; - sha384_digest (48 B) lives in the SHA archive's resident DATA; +; written by sha384_final, read by the TLS-side splice loop above. +; - ecdsa_inputs_384 (240 B BE struct: r|s|h|Qx|Qy each 48 B) lives +; in the curve archive's resident DATA; TLS pre-fills r/s/Qx/Qy, +; splices h from sha384_digest, then calls ecdsa_verify_384. +; - All other ec384_* / fp384_* / ecdsa384_* RW buffers and the +; sha_state / sha_w / sha_block_* SHA-384 state ALSO live in +; resident DATA (CRYPTO_RESIDENT, $C000-$EFFF in the standalone +; cfgs; CRYPTO_RESIDENT in the live UCI cfg). Resident DATA +; footprint is unchanged from Phase 1b's 3,541 B. +; +; ZP save/restore obligation (Phase 4a): +; Phase 1.5 moves the sibling's SHA-384 streaming pointer slots +; out of their default $04-$0B (which collide with c64-https's +; canonical $04-$09 = w32_* ChaCha20/Poly1305 and $0A-$0D = +; sha_temp1 SHA-256) into a free contiguous block at $3D-$44: +; sha_src = $3D / $3E +; sha_len = $3F / $40 +; sha_w_ptr = $41 / $42 +; sha_w_ptr2 = $43 / $44 +; These slots are demonstrably unused by any other crypto / TLS / +; ip65 / UCI / fe25519 / x25519 / ECDSA-bignum path during the +; SHA-384 call window, so NO save/restore is required around the +; SHA window. Phase 4a's TLS dispatcher MAY clobber $3D-$44 +; freely while sha384_init/update/final is in flight. +; +; If a future change introduces a competing user of $3D-$44, the +; dispatcher must save/restore those eight bytes around the SHA +; window OR move SHA-384 to a different free slot. The choice +; of $3D-$44 is documented in tools/integration/build_nistcurves_p384.sh. ; ============================================================================= .include "constants.inc" ; reu_* register equates diff --git a/src/crypto/shared/reu_layout.inc b/src/crypto/shared/reu_layout.inc index bc6a49d..c43ca23 100644 --- a/src/crypto/shared/reu_layout.inc +++ b/src/crypto/shared/reu_layout.inc @@ -43,11 +43,41 @@ REU_OVERLAY_P384 = $24100 REU_P256_PRECOMPUTE_BASE = $30000 .endif -; --- P-384 precompute (4 banks) --- +; --- P-384 precompute (4 banks at $40000-$5FFFF) --- +; Banks 4-5 are reserved for any future Lim-Lee-style precompute table +; for P-384 fixed-base scalar mul. Currently unused: the Phase 1b/1.5 +; overlay strips the Lim-Lee body and replaces it with a shim that +; tail-calls ec_scalar_mul_var_384 (no precompute). .ifndef REU_P384_PRECOMPUTE_BASE REU_P384_PRECOMPUTE_BASE = $40000 .endif +; --- P-384 split-overlay storage (Phase 1.5) --- +; Phase 1b's monolithic OVERLAY_P384 (12.5 KB) overflowed the live UCI +; CRYPTO_OVERLAY slot (7.5 KB). The fix: two halves, loaded one at a +; time on demand. Bank 6 holds the SHA-384 hash code; bank 7 holds the +; curve / verify code. Each image is padded to OVERLAY_SIZE (8 KB) for +; DMA alignment; actual code is < 7,680 B per half (fits the live slot). +; +; Layout (REU 24-bit address): +; $60000-$6FFFF bank 6 (64 KB) REU_OVERLAY_P384_SHA384 +; (8 KB image; remaining 56 KB headroom) +; $70000-$7FFFF bank 7 (64 KB) REU_OVERLAY_P384_CURVE +; (8 KB image; remaining 56 KB headroom) +; +; The TLS path (Phase 4a) drives the two halves in sequence: +; 1. crypto_swap_to_p384_sha384 -> sha384_init/update/final +; 2. crypto_swap_to_p384_curve -> ecdsa_verify_384 (digest pre-staged +; in resident DATA at ecdsa_inputs_384[96..143]) +; Phase 3 will add the swap entry points; the existing +; crypto_swap_to_p384 (single-image) is now stale. +.ifndef REU_OVERLAY_P384_SHA384 +REU_OVERLAY_P384_SHA384 = $60000 +.endif +.ifndef REU_OVERLAY_P384_CURVE +REU_OVERLAY_P384_CURVE = $70000 +.endif + ; --- Phase C.5 collision note (USE_X25519_SIBLING=1) --- ; The sibling c64-x25519 v0.4.0 reu_mul_init populates banks 3, 4, and 5 ; with its own doubled-product and 17th-bit-carry tables for fe25519_sqr. diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh index f45489e..86e58f8 100755 --- a/tools/integration/build_nistcurves_p384.sh +++ b/tools/integration/build_nistcurves_p384.sh @@ -1,66 +1,79 @@ #!/usr/bin/env bash # ============================================================================= # tools/integration/build_nistcurves_p384.sh - Build c64-nist-curves P-384 -# primitives + SHA-384 + ECDSA-with-message wrapper as a REU overlay .a archive -# for the UCI backend smoke test. +# overlay archives for the UCI backend smoke test. # -# Phase 1b extension. Produces build/lib/nistcurves-p384.a containing: -# * Variable-base P-384 primitives (ec_point_double_384, ec_point_add_384, -# ec_jacobian_to_affine_384, ec_scalar_mul_var_384) plus their fp/mod -# helpers. -# * SHA-384 streaming hash (sha384_init / update / final + sha384_digest). -# * Packaged ECDSA-P384 verify (ecdsa_verify_384) and the -# ecdsa_verify_with_message_384 one-shot wrapper. -# * curve384 generator constants (ec_gx384, ec_gy384) for the -# ec_scalar_mul_384 -> ec_scalar_mul_var_384 shim (Option A; see below). +# Phase 1.5 split. Phase 1b's monolithic OVERLAY_P384 segment was 12,836 B +# and overflowed the live UCI CRYPTO_OVERLAY slot (7,680 B at $4200-$5FFF). +# This script now produces TWO archives, each fitting the 7.5 KB slot: # -# Segment layout: -# OVERLAY_P384 - all P-384 + SHA-384 + ECDSA-P384 runtime code + -# RODATA (K[80] SHA constants, curve384 constants). -# Paged into the live CRYPTO_OVERLAY slot via REU DMA. -# CRYPTO_RESIDENT - P-384 + SHA-384 + ECDSA-P384 RW data (ec384_*, fp384_*, -# ecdsa384_*, sha_state, sha_w, sha_block_buf, ...) -# routed through the DATA / BSS segments. +# build/lib/nistcurves-p384-sha384.a - SHA-384 streaming hash (sha384.s +# + the SHA-384 portion of the +# minimal data heredoc). +# Segment: OVERLAY_P384_SHA384. +# build/lib/nistcurves-p384-curve.a - fp384 + mod384 + points384 +# (post-strip) + curve384 + +# ecdsa384 (verify_384 ONLY - +# the verify_with_message_384 +# wrapper that imports +# sha384_init/update/final is +# stripped here; TLS drives SHA +# via the sha384 overlay) + the +# ec_scalar_mul_384 shim. +# Segment: OVERLAY_P384_CURVE. # -# Excluded: -# - ec_scalar_mul_384 - The sibling's Lim-Lee fixed-base 8-comb. Needs a -# 24 KB REU bank-2 anchor table built by -# ec_precompute_384 at boot (~100 s of init time). -# Phase 1b takes Option A: STRIP the Lim-Lee body and -# replace with an in-staging shim that copies G into -# ec_base384_x/y and tail-calls ec_scalar_mul_var_384. -# Mirrors the Phase C.4 P-256 dispatcher pattern (see -# src/crypto/ecdsa_verify.s::ec_scalar_mul). Slower -# per-call (double-and-add in lieu of a windowed -# comb) but avoids the ~24 KB precompute table and -# the ~100 s boot drag. -# - ec_precompute_384 - builds the Lim-Lee anchor table; only useful with -# ec_scalar_mul_384. -# - Lim-Lee anchor tables (ec_anchor1_384_x..ec_anchor8_384_y) and -# comb-scalar state (cm_k_384, ec384_sc_byte/mask, ec384_precomp_i). -# - sha384_msg_buf - 1 KB test scratch buffer owned by the upstream -# test harness. Production / overlay-smoke-test -# consumers do not need it; the .import lines in -# sha384.s and ecdsa384.s are stripped in this -# script. -# - ec_aff2g_256_*, ec_anchor*_256, cm_k -- P-256 Lim-Lee infrastructure. -# Not relevant to the P-384 overlay. +# Both archives also contribute disjoint subsets of data_raw.s into the +# resident DATA segment (CRYPTO_RESIDENT under the live cfg, at $C000 in +# the standalone overlay cfgs). The split is byte-for-byte identical to +# Phase 1b's combined data_raw.s so resident DATA growth stays at the +# Phase 1b figure (3,541 B); see the per-half data heredocs below. +# +# Wrapper strip: +# ecdsa_verify_with_message_384 + ecdsa_verify_with_msg_384_tramp are +# physically removed from the curve archive's ecdsa384_raw.s so the +# archive does not import sha384_init/update/final (those live only in +# the OTHER half). TLS will call sha384_init / update / final +# directly from the sha384 overlay, then swap in the curve overlay, +# then call ecdsa_verify_384 with the digest pre-spliced into +# ecdsa_inputs_384[96..143]. See Phase 4a's TLS dispatcher work for +# the call sequencing. +# +# ZP allocation (Phase 1.5): +# sha_src = $3D, sha_len = $3F, +# sha_w_ptr = $41, sha_w_ptr2 = $43. +# These supersede the sibling defaults ($04/$06/$08/$0A) which collide +# with c64-https's canonical $04-$09 = w32_* (ChaCha20/Poly1305) and +# $0A-$0D = sha_temp1 (SHA-256). $3D-$44 is the lowest 8-byte +# contiguous free block above the canonical crypto ZP map (ec_scalar_ptr +# ends at $3C; nothing in src/* claims $3D-$FA except the universal +# $FB-$FF general pointers). Verified by grep against +# src/constants.inc, src/crypto/shared/zp_canon.inc, and all .s files +# under src/. Safe during the SHA-384 call window because no other +# crypto / TLS path uses these slots. +# +# Excluded (same as Phase 1b — see comments inline): +# - ec_precompute_384 / ec_scalar_mul_384 (Lim-Lee body) — replaced by +# the in-staging shim that copies G into ec_base384_x/y and +# tail-calls ec_scalar_mul_var_384. +# - Lim-Lee anchor tables and comb-scalar state. +# - sha384_msg_buf (1024 B test scratch). # - mul_8x8 / sqtab_init / mul_dma_lo/hi / mul_cached_a / mul_src2_buf / # reu_fetch_mul_row / poly_prod_lo/hi / sqtab_lo/hi - resolved at link -# time by build_nistcurves_p384_bin.sh's --define stubs (these symbols -# come from the in-PRG c64-x25519 sibling at runtime; the standalone -# overlay image references them but does not inline their bytes). +# time by build_nistcurves_p384_bin.sh's --define stubs. +# - ecdsa_verify_with_message_384 + ecdsa_verify_with_msg_384_tramp +# (Phase 1.5 NEW — see "Wrapper strip" above). # # The script stages the sibling's .s files in build/lib/nistcurves_p384_staging/, -# applies sed-patches to each to override their `.segment "CODE"` / "DATA" -# directives, drops `.import sha384_msg_buf` lines from sha384.s and -# ecdsa384.s, and assembles with canonical ZP equates passed via -D. +# applies sed-patches to override their `.segment` directives and rewrite +# them into the new dual-segment scheme. # # Usage (from top-level Makefile): # bash tools/integration/build_nistcurves_p384.sh # Produces: -# build/lib/nistcurves-p384.a -# build/lib/nistcurves-p384.sizes.txt (per-source byte counts) +# build/lib/nistcurves-p384-sha384.a +# build/lib/nistcurves-p384-curve.a +# build/lib/nistcurves-p384-sha384.sizes.txt +# build/lib/nistcurves-p384-curve.sizes.txt # ============================================================================= set -eo pipefail @@ -69,31 +82,34 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" LIB_SRC="$PROJECT_ROOT/libs/nistcurves/src" STAGING="$PROJECT_ROOT/build/lib/nistcurves_p384_staging" OUT_DIR="$PROJECT_ROOT/build/lib" -ARCHIVE="$OUT_DIR/nistcurves-p384.a" -SIZES="$OUT_DIR/nistcurves-p384.sizes.txt" +ARCHIVE_SHA="$OUT_DIR/nistcurves-p384-sha384.a" +ARCHIVE_CURVE="$OUT_DIR/nistcurves-p384-curve.a" +SIZES_SHA="$OUT_DIR/nistcurves-p384-sha384.sizes.txt" +SIZES_CURVE="$OUT_DIR/nistcurves-p384-curve.sizes.txt" CA65="${CA65:-ca65}" AR65="${AR65:-ar65}" # --- Canonical ZP defines --- # The sibling's zp_config.s wraps every ZP equate in .ifndef, so command-line -# -D values win over the defaults. We pin the sibling to c64-https's -# canonical ZP map (src/crypto/shared/zp_canon.inc) where the slots overlap; -# SHA-384's sha_src/sha_len/sha_w_ptr/sha_w_ptr2 ($04-$0B) are LEFT AT THE -# SIBLING'S DEFAULTS because: -# (1) The overlay binary produced here is harness-time only (Phase C.3b). -# It is loaded by tools/test_p384_symbols.py into REU then DMA'd into -# the live overlay slot AT TEST TIME -- production PRG never links it. -# (2) Inside the c64-https production ZP map ($04-$09 = w32_*, $0a-$0d = -# sha_temp1) those slots are claimed by ChaCha20/Poly1305 + SHA-256 -# which run concurrently with TLS handshake. If/when Phase 2 wires -# SHA-384 / ECDSA-with-message into the production handshake, the ZP -# collision MUST be resolved either by relocating sha_src/sha_len/etc. -# into c64-https's free range or by repurposing $04-$0B during the -# (brief) ECDSA verify window. Out of scope for Phase 1b. +# -D values win over the defaults. We pin the sibling to c64-https's +# canonical ZP map (src/crypto/shared/zp_canon.inc) AND override the SHA-384 +# pointer slots to $3D-$44 (Phase 1.5). +# +# Why $3D-$44? The sibling's defaults sha_src=$04, sha_len=$06, +# sha_w_ptr=$08, sha_w_ptr2=$0a collide with c64-https's canonical +# $04-$09 = w32_* (ChaCha20/Poly1305) and $0A-$0D = sha_temp1 (SHA-256). +# $3D-$44 is the lowest 8-byte contiguous free range above the canonical +# crypto ZP map (ec_scalar_ptr ends at $3C); see this file's header for +# the full audit. Demonstrated free during the SHA-384 call window: +# - Not used by ip65 ($02-$1B), ChaCha20/Poly1305 ($04-$1D), +# SHA-256 ($0A-$13), TLS record layer ($1E-$21), fp_* ECDSA bignum +# ($22-$2B + $39-$3C), fe25519 ($2C-$35), or x25519 ($38-$3A). +# - $36-$37 was reserved for fe25519 future expansion (only 2 bytes, +# insufficient for the 8 bytes SHA-384 needs). # # Note: fp_mul_i / fp_mul_j overlap with x25_byte_idx / x25_bit_mask at -# $39/$3a. This is fine because x25519 and P-384 run at different times +# $39/$3a. This is fine because x25519 and P-384 run at different times # (different overlays; only one resident at a time) and the canonical # map documents the time-sharing. ZP_DEFINES=( @@ -115,20 +131,22 @@ ZP_DEFINES=( '-Dpoly_j=$1b' '-Dpoly_carry=$1c' '-Dpoly_tmp=$1d' - # SHA-384 streaming pointer slots (matches sibling defaults) - '-Dsha_src=$04' - '-Dsha_len=$06' - '-Dsha_w_ptr=$08' - '-Dsha_w_ptr2=$0a' + # SHA-384 streaming pointer slots (Phase 1.5 — moved out of the + # sibling's $04-$0B defaults to avoid the canonical w32_* / sha_temp1 + # collision; see header). + '-Dsha_src=$3d' + '-Dsha_len=$3f' + '-Dsha_w_ptr=$41' + '-Dsha_w_ptr2=$43' ) # --- Stage sources --- rm -rf "$STAGING" mkdir -p "$STAGING" -# The sibling's constants.s is pulled in via -I; we don't stage it here -# (it has no segment directives we'd rewrite, and it's .include'd by -# zp_config.s / data.s transitively). +# constants.s and zp_config.s are shared between both halves. zp_config.s +# is .include'd transitively; we assemble it once with -D overrides and +# add the resulting .o to BOTH archives. cp "$LIB_SRC"/constants.s "$STAGING/" cp "$LIB_SRC"/zp_config.s "$STAGING/" cp "$LIB_SRC"/fp384.s "$STAGING/fp384_raw.s" @@ -139,12 +157,9 @@ cp "$LIB_SRC"/sha384.s "$STAGING/sha384_raw.s" cp "$LIB_SRC"/ecdsa384.s "$STAGING/ecdsa384_raw.s" # --- Strip points384.s of ec_precompute_384 and ec_scalar_mul_384 --- -# Those live between lines 787 (just before ec_precompute_384:) and -# 1489 (just before the ec_scalar_mul_var_384: header). -# We also strip the `.export ec_precompute_384, ec_scalar_mul_384` line -# so the archive doesn't advertise symbols whose bodies were removed. -# The remaining four `.export` symbols (ec_point_double_384, -# ec_point_add_384, ec_scalar_mul_var_384, ec_jacobian_to_affine_384) stay. +# Same surgery as Phase 1b. Bodies between lines 787 and 1488 inclusive +# are physically removed; the related `.export` and `.import` lines are +# scrubbed below. ec_gx384 / ec_gy384 imports are KEPT (used by the shim). # # OPTION A choice (Phase 1b): the Lim-Lee body for ec_scalar_mul_384 is # stripped; an in-staging shim file (ec_scalar_mul_384_shim_raw.s, emitted @@ -152,62 +167,72 @@ cp "$LIB_SRC"/ecdsa384.s "$STAGING/ecdsa384_raw.s" # tail-calling ec_scalar_mul_var_384. This avoids the ~24 KB Lim-Lee # anchor table + ~100 s ec_precompute_384 boot drag. Pattern mirrors # src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 for P-256). -# -# Imports that the removed bodies relied on (anchors, cm_k_384, sc_byte, -# sc_mask, precomp_i, ec_set_modp is still used by double/add/var) - we -# remove ONLY the anchor + comb-state imports since everything else is used -# by the retained primitives. We also keep `ec_gx384, ec_gy384` because -# the shim references them; that import line is left in place. # BSD-sed compat: macOS sed requires `-i ''` (empty extension). sed -i '' '787,1488d' "$STAGING/points384_raw.s" sed -i '' '/^\.export ec_precompute_384, ec_scalar_mul_384$/d' "$STAGING/points384_raw.s" -# Strip imports only used by the removed bodies. Patterns are anchored -# to avoid accidentally deleting unrelated lines. ec_gx384 / ec_gy384 are -# KEPT (used by the shim emitted below). sed -i '' '/^\.import ec_anchor[1-8]_384_x/d' "$STAGING/points384_raw.s" sed -i '' '/^\.import ec_anchor[1-8]_384_y/d' "$STAGING/points384_raw.s" sed -i '' '/^\.import cm_k_384, mul_dma_lo$/d' "$STAGING/points384_raw.s" sed -i '' '/^\.import ec384_sc_byte, ec384_sc_mask, ec384_precomp_i$/d' "$STAGING/points384_raw.s" -# --- Drop test-only sha384_msg_buf imports --- -# sha384.s and ecdsa384.s both `.import sha384_msg_buf` at file scope but -# never reference the symbol in code (sha384.s never touches it; ecdsa384.s -# only mentions it in the test trampoline's docstring). We drop the -# 1024-byte test scratch buffer from data_raw.s, so the imports must go -# too or the linker will fail to resolve them. +# --- Strip ecdsa_verify_with_message_384 wrapper from the curve archive --- +# Phase 1.5 NEW. The wrapper imports sha384_init/update/final, which live +# in the OTHER overlay half (sha384 archive). TLS now drives the SHA +# overlay manually then swaps in the curve overlay and calls +# ecdsa_verify_384 directly with the digest pre-spliced into +# ecdsa_inputs_384[96..143]. +# +# In libs/nistcurves@90830c9 the wrapper + trampoline span lines 568-end +# of ecdsa384.s. We delete from line 568 to the end of file ("568,$d") +# and scrub: +# - the two wrapper .export lines (verify_with_message_384 + +# verify_with_msg_384_tramp) +# - the .import sha384_init/update/final line +# - the .import sha384_msg_buf reference (the test trampoline only) +# - the .import ecdsa384_msg_struct_ptr line (wrapper-only scratch) +# - the .import ecdsa_inputs_384, ecdsa_result_msg_384 line +# (test-trampoline only — the standalone curve archive doesn't need +# these symbols since the wrapper that consumed them is gone; ld65 +# would fail to resolve them if we left the .import in place since +# they live in the data heredoc as exports but nothing else references +# them after the wrapper is dropped — keep the .import to keep the +# symbol pulled in via .import-as-link-anchor; data_curve_raw.s still +# exports both for the harness driver path). +# We sed only on the curve copy AFTER making a separate sha-only copy is +# unnecessary because sha384_raw.s never sees ecdsa384_raw.s. +sed -i '' '568,$d' "$STAGING/ecdsa384_raw.s" +sed -i '' '/^\.export ecdsa_verify_with_message_384$/d' "$STAGING/ecdsa384_raw.s" +sed -i '' '/^\.export ecdsa_verify_with_msg_384_tramp$/d' "$STAGING/ecdsa384_raw.s" +sed -i '' '/^\.import sha384_init, sha384_update, sha384_final$/d' "$STAGING/ecdsa384_raw.s" +sed -i '' '/^\.import ecdsa384_msg_struct_ptr$/d' "$STAGING/ecdsa384_raw.s" + +# --- Drop test-only sha384_msg_buf import from sha384.s --- +# sha384.s `.import sha384_digest, sha384_msg_buf` at file scope but never +# references sha384_msg_buf in code. We drop the 1024-byte test scratch +# buffer from data_raw.s, so the import must go too. sed -i '' 's/^\(\.import sha384_digest, sha384_msg_buf\)$/.import sha384_digest/' "$STAGING/sha384_raw.s" +# Same scrub on the curve-half ecdsa384_raw.s (the .import line is on a +# different line in ecdsa384.s; preserve only sha384_digest if the line is +# present after the wrapper-strip above — it should NOT be, since the +# import for sha384_init/update/final/digest/msg_buf is bundled together. +# Defensive: leave a no-op sed in case the upstream layout changes). sed -i '' 's/^\(\.import sha384_digest, sha384_msg_buf\)$/.import sha384_digest/' "$STAGING/ecdsa384_raw.s" -# --- Extend data_raw.s with all the BSS / DATA exports the P-384 path needs. -# --- -# Hand-extracted from the sibling's data.s. Drops: -# - P-256 field buffers (fp_wide, fp_tmp*, fp_r*, fp_inv_*, ec_p1..) -# because the P-256 sibling archive (build/lib/nistcurves-p256.a) -# already provides these and we must not double-define them at link -# time (the standalone overlay binary uses --define stubs for the -# few P-256 symbols the P-384 path could in theory cross-reference). -# - mul_cached_a, mul_src2_buf, mul_dma_lo/hi - provided by the -# x25519 sibling at runtime; resolved via --define stubs at standalone -# overlay link time. -# - Lim-Lee anchors (ec_anchor*_x/y, ec_aff2g_256_*), cm_k / cm_k_384, -# ec384_sc_*, ec384_precomp_i - only used by the stripped scalar-mul -# and precompute bodies. -# - sha384_msg_buf (1024 B) - test-only scratch buffer; not needed for -# the production overlay path. -# -# Strategy: write a brand new data_raw.s that pulls only what we need. -# We keep the sibling's data.s around for reference but emit an -# explicit minimal one. -cat > "$STAGING/data_raw.s" <<'DATA_EOF' +# --- Emit data_curve_raw.s (resident DATA exports for the curve archive) --- +# Hand-extracted from the sibling's data.s — non-SHA portion only. +# This is the SAME byte-for-byte content as Phase 1b's data_raw.s up to +# (but not including) the SHA-384 streaming state block. Land in DATA +# (= CRYPTO_RESIDENT in the live cfg, $C000 in the standalone cfgs). +cat > "$STAGING/data_curve_raw.s" <<'DATA_EOF' ; ============================================================================= -; data_raw.s - Minimal P-384 + SHA-384 + ECDSA-P384 RW buffers for c64-https / -; c64-nist-curves integration (Phase 1b). Hand-extracted from -; the sibling's data.s so the P-256 side (sibling-provided) and -; the x25519 sibling's shared mul tables remain unclobbered. +; data_curve_raw.s - Resident DATA exports for the curve / verify half of +; the split P-384 overlay (Phase 1.5). Non-SHA portion of Phase 1b's +; minimal data_raw.s. Lands in CRYPTO_RESIDENT under the live cfg. ; -; All exports here are P-384- / SHA-384- / ECDSA-with-message-exclusive. -; sha384_msg_buf (test-only 1 KB scratch) is intentionally OMITTED -- see -; build_nistcurves_p384.sh header for the rationale. +; The 240 B BE input struct (ecdsa_inputs_384) is shared with the SHA +; archive's caller path -- TLS pre-stages r/s/Qx/Qy here, then drives +; sha384_init/update/final to populate the digest at struct[96..143], +; then swaps in this overlay and calls ecdsa_verify_384. ; ============================================================================= .setcpu "6502" @@ -318,23 +343,35 @@ fp_rev_buf_384: .res 48, 0 ; --- ECDSA verify test-driver staging buffer (240 B BE struct). ; The c64-test-harness jsr() helper cannot pass register arguments, so ; the BE input struct is staged here and the test trampoline points -; A/X at it. +; A/X at it. TLS pre-fills r|s|Qx|Qy here, then runs SHA over the +; handshake transcript, then writes the digest into struct[96..143], +; then swaps in the curve overlay and calls ecdsa_verify_384. .export ecdsa_inputs_384 ecdsa_inputs_384: .res 240, 0 ; r|s|h|Qx|Qy each 48 B BE -; --- ecdsa_verify_with_message_384 scratch + test-driver result byte --- -.export ecdsa384_msg_struct_ptr -ecdsa384_msg_struct_ptr: .res 2, 0 +; --- ECDSA result byte (test driver / dispatcher result) --- .export ecdsa_result_msg_384 ecdsa_result_msg_384: .byte 0 +DATA_EOF +# --- Emit data_sha_raw.s (resident DATA exports for the SHA archive) --- +# Hand-extracted from the sibling's data.s — SHA-384 portion only. +# Same byte-for-byte content as Phase 1b's data_raw.s SHA-384 block. +cat > "$STAGING/data_sha_raw.s" <<'DATA_EOF' ; ============================================================================= -; SHA-384 streaming hash state (FIPS 180-4 §6.4) +; data_sha_raw.s - Resident DATA exports for the SHA-384 half of the split +; P-384 overlay (Phase 1.5). SHA-384 portion of Phase 1b's minimal +; data_raw.s. Lands in CRYPTO_RESIDENT under the live cfg. ; ; Storage convention: each 64-bit word is held LITTLE-ENDIAN-WITHIN-WORD, -; matching 6502 ADC carry propagation. All buffers are owned exclusively -; by sha384.s. sha384_msg_buf (1 KB test scratch) is intentionally OMITTED. +; matching 6502 ADC carry propagation. All buffers are owned exclusively +; by sha384.s. sha384_msg_buf (1 KB test scratch) is intentionally OMITTED +; (would inflate resident DATA by ~25%; not used by sha384.s itself). ; ============================================================================= +.setcpu "6502" + +.segment "DATA" + .export sha_state sha_state: .res 64, 0 ; H[0..7], 8 bytes each LE-within-word .export sha_w @@ -352,18 +389,18 @@ sha_block_len: .byte 0 ; bytes used in sha_block_buf, 0..127 .export sha_total_len sha_total_len: .res 16, 0 ; 128-bit total bit count, LE on-chip .export sha384_digest -sha384_digest: .res 48, 0 ; final BE digest output +sha384_digest: .res 48, 0 ; final BE digest output (read by curve + ; overlay's ecdsa_verify_384 path after + ; TLS splices it into ecdsa_inputs_384[96..143]) DATA_EOF # --- Emit ec_scalar_mul_384 shim (Option A) --- # Pattern mirrors src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 P-256 -# dispatcher). Lives in OVERLAY_P384 alongside the rest of the P-384 code. -# ec_gx384 and ec_gy384 are each contiguous 48-byte slots in curve384.s -# RODATA, so a single 96-byte copy loop (using two reads per Y for X then -# Y at +48) is straightforward. We use a simple ldy #47 / lda src,y / -# sta dst,y / dey / bpl loop (47 = $2F has bit 7 clear so BPL is safe; -# DEY updates N flag based on the decremented Y, not the LDA byte) twice -# to copy ec_gx384 -> ec_base384_x and ec_gy384 -> ec_base384_y separately. +# dispatcher). Lives in OVERLAY_P384_CURVE alongside the rest of the curve +# code. ec_gx384 and ec_gy384 are each contiguous 48-byte slots in +# curve384.s RODATA, so a simple ldy #47 / lda src,y / sta dst,y / dey / +# bpl loop works (47 = $2F has bit 7 clear; DEY updates N flag based on +# the decremented Y, not the LDA byte). cat > "$STAGING/ec_scalar_mul_384_shim_raw.s" <<'SHIM_EOF' ; ============================================================================= ; ec_scalar_mul_384_shim_raw.s -- Phase 1b shim for the stripped Lim-Lee @@ -374,10 +411,12 @@ cat > "$STAGING/ec_scalar_mul_384_shim_raw.s" <<'SHIM_EOF' ; src/crypto/ecdsa_verify.s::ec_scalar_mul. Slower per-call than the real ; Lim-Lee comb (double-and-add vs. windowed comb) but avoids the ~24 KB ; REU bank-2 anchor table + ~100 s ec_precompute_384 boot drag. +; +; Phase 1.5: lives in OVERLAY_P384_CURVE (was OVERLAY_P384 in Phase 1b). ; ============================================================================= .setcpu "6502" -.segment "OVERLAY_P384" +.segment "OVERLAY_P384_CURVE" .export ec_scalar_mul_384 @@ -401,45 +440,40 @@ ec_scalar_mul_384: jmp ec_scalar_mul_var_384 ; tail-call: result and clobbers passthrough SHIM_EOF -# --- Route CODE / RODATA segments into OVERLAY_P384 --- -# fp384_raw.s and mod384_raw.s use `.segment "CODE"` (once each) and -# fp384_raw.s has a second `.segment "BSS"` block at the tail. Those -# tail BSS buffers (fp384_sqr_extra, mul_src2_buf_384, fp384_sqr_pairs) -# must go in CRYPTO_RESIDENT BSS (always-resident state, not overlay) -# since the overlay gets swapped out between calls. We rename the BSS -# segment to the c64-https canonical `BSS` name which the cfg maps into -# the RESIDENT region. -# BSD-sed compat: macOS sed requires `-i ''` (empty extension). -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/fp384_raw.s" -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/mod384_raw.s" -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/points384_raw.s" -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/sha384_raw.s" -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384"/' "$STAGING/ecdsa384_raw.s" -# curve384.s uses RODATA -- route it into OVERLAY_P384 (read-only constants). -sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384"/' "$STAGING/curve384_raw.s" -# sha384.s has a second `.segment "RODATA"` block at the tail for the SHA-384 -# IV + K[80] round constants (704 B). Route it into OVERLAY_P384 alongside -# the code that reads it; otherwise it lands at $0000 and the linker won't -# write it into the overlay binary. -sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384"/' "$STAGING/sha384_raw.s" -# fp384_raw.s .segment "BSS" stays - already matches the canonical BSS -# segment which cfg/p384-overlay.cfg maps into the RESIDENT region. - -# --- mod384.s curve constants (ec_p384, ec_n384) live in CODE segment -# in the sibling and are emitted inline with .byte directives. After the -# CODE->OVERLAY_P384 rewrite they flow into the overlay alongside the -# code that reads them; that is intentional (ec_p384 is used by -# fp_mod_reduce384 which IS in the overlay). - -# --- Forbidden-symbol guard --- +# --- Route CODE / RODATA segments into per-half OVERLAY segments --- +# Phase 1.5 split: each source goes into either OVERLAY_P384_SHA384 (just +# sha384) or OVERLAY_P384_CURVE (everything else). +# +# fp384_raw.s also has a `.segment "BSS"` block at the tail (53 B) for +# fp384_sqr_extra / mul_src2_buf_384 / fp384_sqr_pairs. Those land in +# CRYPTO_RESIDENT BSS via the canonical BSS segment name (no rewrite +# needed) since the overlay gets swapped out between calls. +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/fp384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/mod384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/points384_raw.s" +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/ecdsa384_raw.s" +# curve384.s uses RODATA -- route into OVERLAY_P384_CURVE (read-only constants). +sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/curve384_raw.s" +# sha384.s: code (CODE) and IV/K[80] round constants (RODATA) both into +# the SHA-384 overlay. +sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_SHA384"/' "$STAGING/sha384_raw.s" +sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384_SHA384"/' "$STAGING/sha384_raw.s" + +# --- mod384.s curve constants (ec_p384, ec_n384) live in CODE segment in +# the sibling and are emitted inline with .byte directives. After the +# CODE->OVERLAY_P384_CURVE rewrite they flow into the curve overlay +# alongside the code that reads them; that is intentional +# (fp_mod_reduce384 reads ec_p384 and IS in the curve overlay). + +# --- Forbidden-symbol guard (curve archive only) --- # After the strip, points384_raw.s must NOT reference any of the removed # Lim-Lee comb / precompute symbols. ec_gx384 / ec_gy384 / cm_k_384 / -# ec_anchor*_384 patterns CAN appear in points384_raw.s only as comments; -# we strip leading whitespace and a leading `;` before the grep so we only -# match active code. ec_gx384 / ec_gy384 are intentionally left LIVE in -# the staging tree (used by the shim) so we don't include them in the -# guard. cm_k_384, ec_anchor[0-9]_384, ec384_sc_byte/mask, ec384_precomp_i -# remain forbidden -- those bodies were physically removed. +# ec_anchor*_384 patterns CAN appear as comments; we strip leading +# whitespace and a leading `;` before the grep so we only match active +# code. ec_gx384 / ec_gy384 are intentionally left LIVE in the staging +# tree (used by the shim). cm_k_384, ec_anchor[0-9]_384, ec384_sc_byte/ +# mask, ec384_precomp_i remain forbidden -- those bodies were physically +# removed. if grep -v '^\s*;' "$STAGING/points384_raw.s" \ | grep -qE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b'; then echo "ERROR: stripped points384 still references removed-body symbols" >&2 @@ -449,6 +483,19 @@ if grep -v '^\s*;' "$STAGING/points384_raw.s" \ exit 1 fi +# --- Forbidden-symbol guard (Phase 1.5 wrapper-strip) --- +# After the wrapper-strip, ecdsa384_raw.s must NOT reference any of the +# SHA-384 entry points (those live in the OTHER overlay half) or the +# wrapper-only labels. Active-code grep only. +if grep -v '^\s*;' "$STAGING/ecdsa384_raw.s" \ + | grep -qE '\bsha384_init\b|\bsha384_update\b|\bsha384_final\b|\becdsa_verify_with_message_384\b|\becdsa_verify_with_msg_384_tramp\b|\becdsa384_msg_struct_ptr\b'; then + echo "ERROR: stripped ecdsa384 still references wrapper / SHA symbols" >&2 + grep -v '^\s*;' "$STAGING/ecdsa384_raw.s" \ + | grep -nE '\bsha384_init\b|\bsha384_update\b|\bsha384_final\b|\becdsa_verify_with_message_384\b|\becdsa_verify_with_msg_384_tramp\b|\becdsa384_msg_struct_ptr\b' \ + | head -5 >&2 + exit 1 +fi + # --- Assemble each staged .s file --- OBJ_DIR="$STAGING/obj" rm -rf "$OBJ_DIR" @@ -456,48 +503,74 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" # zp_config.s is the single point of truth for the library's ZP equates. # We assemble it with `-D` overrides so the sibling's defaults are -# replaced by c64-https's canonical ZP map. The other source files use -# `.importzp` to pull these equates from the linker-resolved zp_config.o. +# replaced by c64-https's canonical ZP map (with the Phase 1.5 SHA-384 +# slot moves). The other source files use `.importzp` to pull these +# equates from the linker-resolved zp_config.o. "$CA65" \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ "${ZP_DEFINES[@]}" \ -o "$OBJ_DIR/zp_config.o" "$STAGING/zp_config.s" -# Other files: NO -D. Let `.importzp` resolve through the linker to -# zp_config.o's `.exportzp` declarations. If we passed -D here the +# Other files: NO -D. Let `.importzp` resolve through the linker to +# zp_config.o's `.exportzp` declarations. If we passed -D here the # assembler would treat the symbol as locally-defined absolute and # conflict with the .importzp declaration. for src in fp384_raw mod384_raw points384_raw curve384_raw \ - sha384_raw ecdsa384_raw ec_scalar_mul_384_shim_raw data_raw; do + sha384_raw ecdsa384_raw ec_scalar_mul_384_shim_raw \ + data_curve_raw data_sha_raw; do "$CA65" \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" done -# --- Archive into nistcurves-p384.a --- -rm -f "$ARCHIVE" -"$AR65" a "$ARCHIVE" \ +# --- Archive: nistcurves-p384-sha384.a (SHA-384 hash overlay half) --- +# Members: zp_config + sha384_raw + data_sha_raw. +# The SHA archive does NOT contain ANY curve code; ld65 link resolves +# only the SHA exports + the resident SHA DATA buffers. +rm -f "$ARCHIVE_SHA" +"$AR65" a "$ARCHIVE_SHA" \ + "$OBJ_DIR/zp_config.o" \ + "$OBJ_DIR/sha384_raw.o" \ + "$OBJ_DIR/data_sha_raw.o" + +# --- Archive: nistcurves-p384-curve.a (curve / verify overlay half) --- +# Members: zp_config + fp384 + mod384 + points384 + curve384 + +# ecdsa384 (verify_384 only) + shim + data_curve_raw. +# The curve archive does NOT contain ANY SHA code or SHA DATA exports; +# ld65 link resolves only ecdsa_verify_384 + the resident curve DATA +# buffers. +rm -f "$ARCHIVE_CURVE" +"$AR65" a "$ARCHIVE_CURVE" \ "$OBJ_DIR/zp_config.o" \ "$OBJ_DIR/fp384_raw.o" \ "$OBJ_DIR/mod384_raw.o" \ "$OBJ_DIR/points384_raw.o" \ "$OBJ_DIR/curve384_raw.o" \ - "$OBJ_DIR/sha384_raw.o" \ "$OBJ_DIR/ecdsa384_raw.o" \ "$OBJ_DIR/ec_scalar_mul_384_shim_raw.o" \ - "$OBJ_DIR/data_raw.o" + "$OBJ_DIR/data_curve_raw.o" # --- Per-source byte counts --- { - echo "# nistcurves-p384.a per-source byte counts (ca65 .o file sizes)" + echo "# nistcurves-p384-sha384.a per-source byte counts (ca65 .o file sizes)" + for src in zp_config sha384_raw data_sha_raw; do + bytes=$(wc -c < "$OBJ_DIR/$src.o") + printf '%-32s %d bytes (.o)\n' "$src" "$bytes" + done +} > "$SIZES_SHA" + +{ + echo "# nistcurves-p384-curve.a per-source byte counts (ca65 .o file sizes)" for src in zp_config fp384_raw mod384_raw points384_raw curve384_raw \ - sha384_raw ecdsa384_raw ec_scalar_mul_384_shim_raw data_raw; do + ecdsa384_raw ec_scalar_mul_384_shim_raw data_curve_raw; do bytes=$(wc -c < "$OBJ_DIR/$src.o") printf '%-32s %d bytes (.o)\n' "$src" "$bytes" done -} > "$SIZES" +} > "$SIZES_CURVE" -echo "built $ARCHIVE" -cat "$SIZES" +echo "built $ARCHIVE_SHA" +cat "$SIZES_SHA" +echo "built $ARCHIVE_CURVE" +cat "$SIZES_CURVE" diff --git a/tools/integration/build_nistcurves_p384_bin.sh b/tools/integration/build_nistcurves_p384_bin.sh index 473eaf1..9107e6c 100755 --- a/tools/integration/build_nistcurves_p384_bin.sh +++ b/tools/integration/build_nistcurves_p384_bin.sh @@ -1,27 +1,44 @@ #!/usr/bin/env bash # ============================================================================= -# tools/integration/build_nistcurves_p384_bin.sh — Extract a standalone -# P-384 overlay image (.bin) and VICE labels from nistcurves-p384.a. +# tools/integration/build_nistcurves_p384_bin.sh — Extract the two split +# P-384 overlay images (.bin) and VICE labels for the SHA-384 and curve / +# verify halves. # -# Phase C.3b. The production PRG does NOT link nistcurves-p384.a (the -# Makefile `USE_NISTCURVES_P384` gate is intentionally commented). Instead, -# tools/test_p384_symbols.py loads the output of THIS script into the -# U64/VICE REU at harness time, then pages it into the live CRYPTO_OVERLAY -# slot via crypto_swap_to_p384. +# Phase 1.5 split. Phase 1b's monolithic overlay (12,836 B) overflowed +# the live UCI CRYPTO_OVERLAY slot ($1E00 = 7,680 B at $4200-$5FFF). +# This script now produces TWO 7.5 KB-padded images, one per archive +# half emitted by build_nistcurves_p384.sh. Each image fits the live +# slot; the TLS path loads them in sequence (sha384 first, then curve). # # Outputs: -# build/lib/overlay-p384.bin — raw 8192-byte OVERLAY_P384 image, -# padded with $00 to the full 8 KB slot. -# build/labels-p384.txt — VICE-format labels for the P-384 -# symbols (ec_point_double_384 etc. -# plus the DATA-resident ec384_p1, -# ec384_affine_x and friends). +# build/lib/overlay-p384-sha384.bin - 7,680-byte padded overlay image +# for the SHA-384 hash code. +# REU dest: REU_OVERLAY_P384_SHA384 +# (bank 6, $60000) -- see +# src/crypto/shared/reu_layout.inc. +# build/lib/overlay-p384-curve.bin - 7,680-byte padded overlay image +# for the curve / verify code. +# REU dest: REU_OVERLAY_P384_CURVE +# (bank 7, $70000). +# build/lib/overlay-p384-sha384.sizes.txt +# build/lib/overlay-p384-curve.sizes.txt +# build/labels-p384-sha384.txt - VICE-format labels for the SHA +# archive's symbols. +# build/labels-p384-curve.txt - VICE-format labels for the curve +# archive's symbols. # -# The cfg at cfg/p384-overlay.cfg places: -# * OVERLAY_P384 at $4200 (matches CRYPTO_OVERLAY base under UCI). -# * DATA / BSS at $7C00 (matches CRYPTO_RESIDENT_2 under UCI). -# so the labels line up with where the harness-time swap actually lands -# the overlay. +# The cfgs at cfg/p384-overlay-sha384.cfg and cfg/p384-overlay-curve.cfg +# pin both OVERLAY_REGION at $4200 size $1E00 (matches the live UCI +# CRYPTO_OVERLAY) and DATA / BSS at $C000 (matches the standalone +# RESIDENT region). +# +# The production PRG does NOT link nistcurves-p384-*.a (the Makefile +# `USE_NISTCURVES_P384` gate is intentionally commented). These outputs +# are smoke-test infrastructure: a future Phase 3 / Phase 4a harness +# will load both .bins into REU at test time, then DMA them into the +# live slot via crypto_swap_to_p384_sha384 / crypto_swap_to_p384_curve +# (Phase 3 will add those; the existing crypto_swap_to_p384 entry point +# is now stale and will be replaced — see crypto_swap.s comment block). # # Imports resolved via ld65 --define: # * REU register equates (not exported by the in-tree build — the @@ -30,7 +47,7 @@ # reu_fetch_mul_row — these come from the x25519 sibling at runtime, # but for the standalone link we define them at their UCI-backend # addresses (read out of build/labels.txt if available, else stubbed -# to $0000 — irrelevant to the OVERLAY_P384 image bytes since those +# to $0000 — irrelevant to the OVERLAY_P384_* image bytes since those # references are resolved as references, not inlined data). # # Usage (from the top-level Makefile): @@ -39,34 +56,37 @@ set -eo pipefail PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -ARCHIVE="$PROJECT_ROOT/build/lib/nistcurves-p384.a" -CFG="$PROJECT_ROOT/cfg/p384-overlay.cfg" +ARCHIVE_SHA="$PROJECT_ROOT/build/lib/nistcurves-p384-sha384.a" +ARCHIVE_CURVE="$PROJECT_ROOT/build/lib/nistcurves-p384-curve.a" +CFG_SHA="$PROJECT_ROOT/cfg/p384-overlay-sha384.cfg" +CFG_CURVE="$PROJECT_ROOT/cfg/p384-overlay-curve.cfg" OUT_DIR="$PROJECT_ROOT/build/lib" -BIN_OUT="$OUT_DIR/overlay-p384.bin" -LABELS_OUT="$PROJECT_ROOT/build/labels-p384.txt" -MAP_OUT="$OUT_DIR/overlay-p384.map" +BIN_OUT_SHA="$OUT_DIR/overlay-p384-sha384.bin" +BIN_OUT_CURVE="$OUT_DIR/overlay-p384-curve.bin" +SIZES_OUT_SHA="$OUT_DIR/overlay-p384-sha384.sizes.txt" +SIZES_OUT_CURVE="$OUT_DIR/overlay-p384-curve.sizes.txt" +LABELS_OUT_SHA="$PROJECT_ROOT/build/labels-p384-sha384.txt" +LABELS_OUT_CURVE="$PROJECT_ROOT/build/labels-p384-curve.txt" +MAP_OUT_SHA="$OUT_DIR/overlay-p384-sha384.map" +MAP_OUT_CURVE="$OUT_DIR/overlay-p384-curve.map" + +# Live UCI CRYPTO_OVERLAY slot size: $1E00 = 7,680 B. Each .bin is +# truncated/padded to exactly this many bytes so it DMAs cleanly into +# the live slot. +SLOT_BYTES=7680 LD65="${LD65:-ld65}" +AR65="${AR65:-ar65}" -if [ ! -f "$ARCHIVE" ]; then - echo "ERROR: $ARCHIVE does not exist — run tools/integration/build_nistcurves_p384.sh first" >&2 +if [ ! -f "$ARCHIVE_SHA" ] || [ ! -f "$ARCHIVE_CURVE" ]; then + echo "ERROR: archive(s) missing — run tools/integration/build_nistcurves_p384.sh first" >&2 + [ ! -f "$ARCHIVE_SHA" ] && echo " missing: $ARCHIVE_SHA" >&2 + [ ! -f "$ARCHIVE_CURVE" ] && echo " missing: $ARCHIVE_CURVE" >&2 exit 1 fi -# ld65 requires at least one plain .o on the command line; an archive -# alone is not enough even with --force-import. Extract the archive -# members into a scratch dir and pass them all as objects. -AR65="${AR65:-ar65}" -SCRATCH="$OUT_DIR/p384_bin_scratch" -rm -rf "$SCRATCH" -mkdir -p "$SCRATCH" -cp "$ARCHIVE" "$SCRATCH/" -(cd "$SCRATCH" && "$AR65" x "$(basename "$ARCHIVE")" \ - zp_config.o fp384_raw.o mod384_raw.o points384_raw.o curve384_raw.o \ - sha384_raw.o ecdsa384_raw.o ec_scalar_mul_384_shim_raw.o data_raw.o) - # Try to pick up x25519-sibling addresses from the main build's labels.txt -# so references resolve to the real runtime locations. If the main build +# so references resolve to the real runtime locations. If the main build # hasn't happened yet, stub them to $0000 — the overlay binary doesn't # actually dereference these; only labels.txt addresses would be wrong, # and we strip them below anyway. @@ -90,66 +110,150 @@ DEF_MUL_DMA_LO=$(lookup_label mul_dma_lo '$0000') DEF_MUL_DMA_HI=$(lookup_label mul_dma_hi '$0000') DEF_REU_FETCH_MUL_ROW=$(lookup_label reu_fetch_mul_row '$0000') -# poly_prod_lo / poly_prod_hi: 2-byte mul_8x8 output register. The x25519 +# poly_prod_lo / poly_prod_hi: 2-byte mul_8x8 output register. The x25519 # sibling emits these INSIDE OVERLAY_X25519 ($42A0) — unusable when our -# P-384 overlay is swapped in (same slot, different code bytes). Point +# P-384 overlay is swapped in (same slot, different code bytes). Point # the P-384 standalone link to stable scratch RAM at $CFFE-$CFFF, which -# sits in TCP_BUF past the P-384 DATA block ($C000-$C636). +# sits in TCP_BUF past the P-384 DATA block. DEF_POLY_PROD_LO='$CFFE' DEF_POLY_PROD_HI='$CFFF' mkdir -p "$OUT_DIR" -# Link. ld65 -Ln emits labels in the old ca65 format; the main Makefile -# rewrites `al 00XXXX .name` to `al C:XXXX .name` via sed. Mirror that. -"$LD65" \ - -C "$CFG" \ - -o "$BIN_OUT" \ - -Ln "$LABELS_OUT" \ - -m "$MAP_OUT" \ - --define reu_status=\$df00 \ - --define reu_command=\$df01 \ - --define reu_c64_lo=\$df02 \ - --define reu_c64_hi=\$df03 \ - --define reu_reu_lo=\$df04 \ - --define reu_reu_hi=\$df05 \ - --define reu_reu_bank=\$df06 \ - --define reu_len_lo=\$df07 \ - --define reu_len_hi=\$df08 \ - --define reu_addr_ctrl=\$df0a \ - --define mul_cached_a="$DEF_MUL_CACHED_A" \ - --define mul_dma_lo="$DEF_MUL_DMA_LO" \ - --define mul_dma_hi="$DEF_MUL_DMA_HI" \ - --define poly_prod_lo="$DEF_POLY_PROD_LO" \ - --define poly_prod_hi="$DEF_POLY_PROD_HI" \ - --define reu_fetch_mul_row="$DEF_REU_FETCH_MUL_ROW" \ - "$SCRATCH/zp_config.o" \ - "$SCRATCH/fp384_raw.o" \ - "$SCRATCH/mod384_raw.o" \ - "$SCRATCH/points384_raw.o" \ - "$SCRATCH/curve384_raw.o" \ - "$SCRATCH/sha384_raw.o" \ - "$SCRATCH/ecdsa384_raw.o" \ - "$SCRATCH/ec_scalar_mul_384_shim_raw.o" \ - "$SCRATCH/data_raw.o" - -# Normalise labels to VICE format (al C:XXXX .name) so c64-test-harness's -# Labels.from_file() reader accepts it identically to build/labels.txt. -# BSD-sed compat: macOS sed requires `-i ''` (empty extension); GNU sed -# accepts both forms. -sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$LABELS_OUT" - -# ld65 writes the DATA segment bytes (RESIDENT region at $7C00) into the -# output file too, even though RESIDENT has no `file = %O` — so the raw -# output is ~9.5 KB. Truncate to exactly 8192 bytes to get the OVERLAY_P384 -# slot image. DATA lives at runtime addresses and is zero-init; the harness -# does not need its bytes in the overlay image. -truncate -s 8192 "$BIN_OUT" - -size=$(wc -c < "$BIN_OUT") -if [ "$size" -ne 8192 ]; then - echo "ERROR: $BIN_OUT is $size bytes, expected 8192" >&2 - exit 1 -fi +# ----------------------------------------------------------------------------- +# Helper: link one archive into a padded .bin + labels file. +# Args: archive_path, cfg_path, bin_out, labels_out, map_out, sizes_out, archive_label +# ----------------------------------------------------------------------------- +link_one () { + local archive="$1" + local cfg="$2" + local bin_out="$3" + local labels_out="$4" + local map_out="$5" + local sizes_out="$6" + local label="$7" + + local scratch="$OUT_DIR/p384_bin_scratch_${label}" + rm -rf "$scratch" + mkdir -p "$scratch" + cp "$archive" "$scratch/" + + # ld65 requires plain .o objects on the command line; an archive alone + # is not enough even with --force-import. Extract the archive members + # and pass them as objects. We don't know in advance which members + # the archive holds, so use `ar65 t` to enumerate. + local archive_basename + archive_basename=$(basename "$archive") + local members + members=$( (cd "$scratch" && "$AR65" t "$archive_basename") | tr -d '\r' ) + if [ -z "$members" ]; then + echo "ERROR: $archive_basename appears empty" >&2 + exit 1 + fi + (cd "$scratch" && "$AR65" x "$archive_basename" $members) + + local obj_args=() + local m + for m in $members; do + obj_args+=("$scratch/$m") + done + + "$LD65" \ + -C "$cfg" \ + -o "$bin_out" \ + -Ln "$labels_out" \ + -m "$map_out" \ + --define reu_status=\$df00 \ + --define reu_command=\$df01 \ + --define reu_c64_lo=\$df02 \ + --define reu_c64_hi=\$df03 \ + --define reu_reu_lo=\$df04 \ + --define reu_reu_hi=\$df05 \ + --define reu_reu_bank=\$df06 \ + --define reu_len_lo=\$df07 \ + --define reu_len_hi=\$df08 \ + --define reu_addr_ctrl=\$df0a \ + --define mul_cached_a="$DEF_MUL_CACHED_A" \ + --define mul_dma_lo="$DEF_MUL_DMA_LO" \ + --define mul_dma_hi="$DEF_MUL_DMA_HI" \ + --define poly_prod_lo="$DEF_POLY_PROD_LO" \ + --define poly_prod_hi="$DEF_POLY_PROD_HI" \ + --define reu_fetch_mul_row="$DEF_REU_FETCH_MUL_ROW" \ + "${obj_args[@]}" + + # Normalise labels to VICE format (al C:XXXX .name) so c64-test-harness's + # Labels.from_file() reader accepts it identically to build/labels.txt. + sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$labels_out" + + # Compute the on-disk OVERLAY image size from the .map (so the sizes + # report reflects the real loaded bytes, not the post-truncate size). + local seg_name + if [ "$label" = "sha384" ]; then + seg_name="OVERLAY_P384_SHA384" + else + seg_name="OVERLAY_P384_CURVE" + fi + # macOS awk lacks strtonum(); parse the hex Size field via printf. + # The .map has TWO sections that mention segment names: + # "Modules list" rows: Offs=000000 Size=001550 Align=00001 + # "Segment list" rows: Name Start End Size Align (hex, no prefix) + # We want the Segment list size, so anchor on its header line. + local overlay_hex + overlay_hex=$(awk -v seg="$seg_name" ' + /^Segment list:/ { in_seg=1; next } + /^Exports list/ { in_seg=0 } + in_seg && $1 == seg { print $4; exit } + ' "$map_out") + local overlay_bytes="" + if [ -n "$overlay_hex" ]; then + overlay_bytes=$(printf '%d' "0x$overlay_hex") + fi + + # ld65 writes the DATA segment bytes (RESIDENT region at $C000) into + # the output file too, even though RESIDENT has no `file = %O` — so + # the raw output is much larger than the slot. Truncate / pad to + # exactly $SLOT_BYTES so the .bin DMAs into the live UCI overlay + # slot (which is exactly $1E00 = 7,680 B). DATA lives at runtime + # addresses and is zero-init; the harness does not need its bytes + # in the overlay image. + truncate -s "$SLOT_BYTES" "$bin_out" + + local size + size=$(wc -c < "$bin_out") + if [ "$size" -ne "$SLOT_BYTES" ]; then + echo "ERROR: $bin_out is $size bytes, expected $SLOT_BYTES" >&2 + exit 1 + fi + + { + echo "# nistcurves-p384-${label} overlay image (Phase 1.5 split)" + echo "# slot size: $SLOT_BYTES B (\$1E00 — UCI CRYPTO_OVERLAY)" + if [ -n "$overlay_bytes" ]; then + echo "# unpadded overlay: $overlay_bytes B" + echo "# padded .bin: $size B" + echo "# headroom: $((SLOT_BYTES - overlay_bytes)) B" + if [ "$overlay_bytes" -gt "$SLOT_BYTES" ]; then + echo "# *** OVERFLOW: overlay exceeds slot by $((overlay_bytes - SLOT_BYTES)) B ***" + fi + else + echo "# unpadded overlay: (unknown — see $map_out)" + echo "# padded .bin: $size B" + fi + } > "$sizes_out" + + if [ -n "$overlay_bytes" ] && [ "$overlay_bytes" -gt "$SLOT_BYTES" ]; then + echo "ERROR: $bin_out overlay segment ($overlay_bytes B) exceeds 7,680 B slot by $((overlay_bytes - SLOT_BYTES)) B" >&2 + exit 1 + fi + + echo "built $bin_out ($size B padded; overlay = ${overlay_bytes:-unknown} B)" +} + +link_one "$ARCHIVE_SHA" "$CFG_SHA" "$BIN_OUT_SHA" "$LABELS_OUT_SHA" "$MAP_OUT_SHA" "$SIZES_OUT_SHA" "sha384" +link_one "$ARCHIVE_CURVE" "$CFG_CURVE" "$BIN_OUT_CURVE" "$LABELS_OUT_CURVE" "$MAP_OUT_CURVE" "$SIZES_OUT_CURVE" "curve" -echo "built $BIN_OUT (8192 bytes) and $LABELS_OUT" +echo +echo "Phase 1.5 split overlay sizes:" +cat "$SIZES_OUT_SHA" +echo +cat "$SIZES_OUT_CURVE" From 09fe64dac4a201df1d2d6a25112e7ad2f52a8318 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 19:16:02 -0500 Subject: [PATCH 05/21] feat(tls): negotiate ecdsa_secp384r1_sha384 (0x0503) in addition to P-256/SHA-256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b: extends the c64-https TLS layer to advertise and accept the ecdsa_secp384r1_sha384 signature scheme alongside the existing ecdsa_secp256r1_sha256, so that a future P-384 verify (Phase 4a) drops into the existing curve_id-switched ecdsa_verify dispatcher. ClientHello (src/tls_handshake.s): - signature_algorithms extension (0x000D) now lists 0x0403 + 0x0503. Inner list length bumped 2 -> 4, ext data length 4 -> 6. Payload moved to a small RODATA table + 7-instruction copy loop, because emitting two more LDA/STA/INY triples in CODE would overflow LOADER by 9 B (the Phase-6 fit-up left ~50 B of slack and CRYPTO is full). CertificateVerify (src/tls_cert.s): - Replaces the strict 0x0403-only check with a high-byte-in-{$04,$05} test (high byte minus $04 -> cv_sig_scheme: 0 = P-256, 1 = P-384). - On 0x0503, takes a P-384 short-circuit: sets ecdsa_curve_id = 1 and tail-jumps to ecdsa_verify, bypassing the P-256-specific 32-byte DER parse and SHA-256 transcript hashing (running those against a 48-byte / SHA-384 CertificateVerify would mis-parse the signature and feed the wrong digest in). ecdsa_verify currently returns C=1 for curve_id != 0 (sec/rts stub); Phase 4a replaces that branch with the real P-384 verify and the short-circuit then returns C=0 for valid signatures with no further changes here. - cv_sig_scheme exported for the negotiation test below. Test (tools/test_tls_p384_negotiation.py): - [1a] Drives tls_build_client_hello in VICE and asserts both 0x0403 and 0x0503 appear in the signature_algorithms extension payload. - [1b] Synthesizes a CertificateVerify handshake message with signature_scheme = 0x0503, calls tls_handle_cert_verify, and asserts cv_sig_scheme = 1, ecdsa_curve_id = 1, and C = 1 (the expected stub-dispatcher rejection — proves the routing reached the ECDSA layer without touching the still-stubbed P-384 verify). Phase 4a will need to flip this assertion to C = 0 against a real signature once the dispatcher branch is filled in. Verification: - tools/test_x509.py: 11/11 P-256 assertions still pass (3c valid signature in 60 s under VICE warp; no regression). - make and make BACKEND=uci both build cleanly with no new warnings. - New negotiation test: 2/2 PASS under VICE warp (-reu). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tls_cert.s | 39 ++- src/tls_handshake.s | 51 ++-- tools/test_tls_p384_negotiation.py | 380 +++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+), 30 deletions(-) create mode 100644 tools/test_tls_p384_negotiation.py diff --git a/src/tls_cert.s b/src/tls_cert.s index c03755d..33cf566 100644 --- a/src/tls_cert.s +++ b/src/tls_cert.s @@ -27,6 +27,7 @@ .export tls_handle_certificate .export x509_extract_pubkey .export tls_handle_cert_verify + .export cv_sig_scheme ; Phase 4b: exported for negotiation tests .import tls_rec_buf .import tls_rec_len @@ -512,20 +513,43 @@ tls_handle_cert_verify: sta tls_recv_sub_progress ; --- Read signature algorithm [4-5] --- - ; Must be 0x0403 (ecdsa_secp256r1_sha256) - lda tls_rec_buf+4 - cmp #$04 - beq :+ - jmp @cv_error -: + ; Accept 0x0403 (ecdsa_secp256r1_sha256) or 0x0503 + ; (ecdsa_secp384r1_sha384). Phase 4b: negotiation plumbing for + ; P-384 — actual P-384 verify dispatch is filled in by Phase 4a + ; through the existing ecdsa_verify entry (curve_id-switched). + ; cv_sig_scheme := high byte - $04, so 0 = P-256, 1 = P-384. lda tls_rec_buf+5 cmp #$03 beq :+ - jmp @cv_error + jmp @cv_error ; low byte must be 03 for both : + lda tls_rec_buf+4 + sec + sbc #$04 + cmp #2 + bcc :+ + jmp @cv_error ; high byte not in {$04,$05} +: sta cv_sig_scheme + lda #$22 sta tls_recv_sub_progress + ; --- P-384 short-circuit (Phase 4b) ------------------------------- + ; The P-256 path below assumes 32-byte sig components and a + ; SHA-256 transcript hash; running it against a 48-byte/SHA-384 + ; CertificateVerify would mis-parse the signature and feed the + ; wrong digest to the dispatcher. Until Phase 4a wires up the + ; real P-384 verify, jump straight to ecdsa_verify with + ; curve_id = 1. The dispatcher currently returns C=1 for + ; curve_id != 0 (sec/rts stub); we tail-call so its carry + ; propagates as our return. Negotiation has reached the ECDSA + ; layer — the Phase 4b deliverable. + lda cv_sig_scheme + beq @cv_p256_path + sta ecdsa_curve_id ; A = 1 + jmp ecdsa_verify +@cv_p256_path: + ; --- Read signature length [6-7] (big-endian) --- lda tls_rec_buf+6 ; high byte (expect 0) beq :+ @@ -717,3 +741,4 @@ cert_bs_len: .res 1 ; BIT STRING content length ; CertificateVerify parsing state cv_sig_len: .res 1 ; DER signature length +cv_sig_scheme: .res 1 ; 0 = P-256/SHA-256, 1 = P-384/SHA-384 diff --git a/src/tls_handshake.s b/src/tls_handshake.s index f121866..56a9c45 100644 --- a/src/tls_handshake.s +++ b/src/tls_handshake.s @@ -163,31 +163,20 @@ tls_build_client_hello: iny ; 8 bytes written ; --- Extension 3: signature_algorithms (0x000D) --- - ; 00 0d 00 04 00 02 04 03 - lda #$00 - sta tls_rec_buf,y - iny - lda #$0d - sta tls_rec_buf,y - iny - lda #$00 - sta tls_rec_buf,y - iny - lda #$04 - sta tls_rec_buf,y - iny - lda #$00 - sta tls_rec_buf,y - iny - lda #$02 - sta tls_rec_buf,y - iny - lda #$04 + ; 00 0d 00 06 00 04 04 03 05 03 + ; Two schemes advertised: ecdsa_secp256r1_sha256 (0x0403) + ; and ecdsa_secp384r1_sha384 (0x0503). Inner list length = 4 + ; (two 2-byte schemes); extension data length = 6. Table-driven + ; to keep LOADER from overflowing — adding two more LDA/STA/INY + ; triples directly costs 12 B that the segment doesn't have. + ldx #0 +@sig_algs_ext_loop: + lda sig_algs_ext_data,x sta tls_rec_buf,y iny - lda #$03 - sta tls_rec_buf,y - iny ; 8 bytes written + inx + cpx #10 + bne @sig_algs_ext_loop ; 10 bytes written ; --- Extension 4: key_share (0x0033) --- ; 00 33 00 26 00 24 00 1d 00 20 [32 bytes pubkey] @@ -596,3 +585,19 @@ sh_found_ks: .res 1 tls_hostname: .res 64 tls_hostname_len: .res 1 + + +; ============================================================================= +; signature_algorithms extension payload (TLS 1.3, two ECDSA schemes). +; Lives in RODATA to keep the LOADER segment from overflowing — emitting +; ten LDA/STA/INY triples in CODE costs 60 B vs ~24 B (table + 7-insn +; copy loop). +; ============================================================================= +.segment "RODATA" + +sig_algs_ext_data: + .byte $00, $0d ; extension type = signature_algorithms + .byte $00, $06 ; extension data length = 6 + .byte $00, $04 ; supported_signature_algorithms length = 4 + .byte $04, $03 ; ecdsa_secp256r1_sha256 + .byte $05, $03 ; ecdsa_secp384r1_sha384 diff --git a/tools/test_tls_p384_negotiation.py b/tools/test_tls_p384_negotiation.py new file mode 100644 index 0000000..ceda9ae --- /dev/null +++ b/tools/test_tls_p384_negotiation.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""test_tls_p384_negotiation.py - Phase 4b negotiation plumbing test. + +Verifies the c64-https TLS layer offers ecdsa_secp384r1_sha384 (0x0503) +alongside ecdsa_secp256r1_sha256 (0x0403) in the ClientHello, and that the +CertificateVerify handler accepts a 0x0503 signature_scheme by routing +through the ecdsa_verify dispatcher with curve_id=1. + +This test does NOT require a successful P-384 verification; the +ecdsa_verify dispatcher's P-384 branch is still a `sec / rts` stub that +Phase 4a fills in. Successful negotiation = the carry-set return came +out of the dispatcher (curve_id was set to 1, cv_sig_scheme was set to +1, the routine entered the short-circuit branch). + +Two sub-tests: + + [1a] ClientHello signature_algorithms extension contains BOTH 0x0403 + and 0x0503. + [1b] tls_handle_cert_verify with a synthesized CertificateVerify + handshake message whose signature_scheme = 0x0503 sets + cv_sig_scheme = 1, ecdsa_curve_id = 1, and returns C=1 (stub + dispatcher rejection — exactly as expected pre-Phase-4a). + +Usage: + /Users/someone/.local/share/c64-test-harness/venv/bin/python \\ + tools/test_tls_p384_negotiation.py [--seed S] + +Requires VICE x64sc. -reu is passed to satisfy the sibling P-256 +fp_mul row-fetch invariant (see "VICE harness gotcha" in CLAUDE.md); +it does not exercise REU but the residency requirement applies to any +test that links the sibling library. +""" + +import os +import random +import struct +import subprocess +import sys + +from c64_test_harness import ( + Labels, + ViceConfig, + ViceInstanceManager, + read_bytes, + write_bytes, + jsr, + wait_for_text, +) + +PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") +LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") + +# Carry-flag trampoline (cassette buffer). Mirrors test_x509.py. +CARRY_TRAMPOLINE = 0x033C +CARRY_RESULT_ADDR = 0x0352 +CARRY_FLAG_ADDR = 0x0353 + + +def jsr_with_carry(transport, addr, timeout=120.0, poll_interval=0.5): + """Call subroutine and capture the carry flag via a memory trampoline. + + Returns the value of the C flag after the JSR (0 = clear, 1 = set). + """ + import time + + target_lo = addr & 0xFF + target_hi = (addr >> 8) & 0xFF + + # Trampoline: + # LDA #$00 / STA flag + # JSR target + # ROL A=0 / AND #$01 (capture C into A) + # STA result + # LDA #$FF / STA flag + # RTS + trampoline = bytes([ + 0xA9, 0x00, # LDA #$00 + 0x8D, CARRY_FLAG_ADDR & 0xFF, (CARRY_FLAG_ADDR >> 8) & 0xFF, + 0x20, target_lo, target_hi, # JSR target + 0xA9, 0x00, # LDA #$00 + 0x2A, # ROL A (C -> bit 0) + 0x29, 0x01, # AND #$01 + 0x8D, CARRY_RESULT_ADDR & 0xFF, (CARRY_RESULT_ADDR >> 8) & 0xFF, + 0xA9, 0xFF, # LDA #$FF + 0x8D, CARRY_FLAG_ADDR & 0xFF, (CARRY_FLAG_ADDR >> 8) & 0xFF, + 0x60, # RTS + ]) + write_bytes(transport, CARRY_TRAMPOLINE, trampoline) + write_bytes(transport, CARRY_FLAG_ADDR, bytes([0x00])) + + jsr(transport, CARRY_TRAMPOLINE, timeout=timeout) + + deadline = time.time() + timeout + while time.time() < deadline: + flag = read_bytes(transport, CARRY_FLAG_ADDR, 1)[0] + if flag == 0xFF: + break + time.sleep(poll_interval) + else: + raise TimeoutError(f"jsr_with_carry timed out after {timeout}s") + + return read_bytes(transport, CARRY_RESULT_ADDR, 1)[0] + + +# --------------------------------------------------------------------------- +# Test 1a: ClientHello advertises both 0x0403 and 0x0503 +# --------------------------------------------------------------------------- + +def test_client_hello_sig_algs(transport, labels, rng): + """Verify ClientHello signature_algorithms contains 0x0403 + 0x0503.""" + print("\n [1a] ClientHello signature_algorithms: 0x0403 AND 0x0503") + + required = [ + "tls_build_client_hello", "tls_rec_buf", "tls_rec_len", + "tls_client_random", "tls_ecdhe_pubkey", + ] + missing = [n for n in required if labels.address(n) is None] + if missing: + print(f" SKIP: missing labels {missing}") + return 0, 0 + + build_ch = labels.address("tls_build_client_hello") + hs_buf = labels.address("tls_rec_buf") + hs_len_addr = labels.address("tls_rec_len") + + # Seed the input buffers with deterministic-ish data. + client_random = bytes(rng.getrandbits(8) for _ in range(32)) + pubkey = bytes(rng.getrandbits(8) for _ in range(32)) + write_bytes(transport, labels.address("tls_client_random"), client_random) + write_bytes(transport, labels.address("tls_ecdhe_pubkey"), pubkey) + + try: + jsr(transport, build_ch, timeout=60.0) + except Exception as e: + print(f" FAIL: tls_build_client_hello jsr raised {e}") + return 0, 1 + + msg_len_bytes = read_bytes(transport, hs_len_addr, 2) + msg_len = msg_len_bytes[0] | (msg_len_bytes[1] << 8) + if msg_len == 0: + print(" FAIL: tls_build_client_hello produced 0-length output") + return 0, 1 + + msg = read_bytes(transport, hs_buf, min(msg_len, 320)) + + # Walk to extensions. Layout after the 4-byte handshake header: + # [4-5] legacy_version + # [6-37] client_random + # [38] session_id_len (0) + # [39-40] cipher_suites_len = 0x0002 + # [41-42] cipher_suite = 0x1303 + # [43] compression_methods_len = 0x01 + # [44] compression_method = 0x00 + # [45-46] extensions_len + # [47..] extension list + if len(msg) < 49: + print(f" FAIL: msg too short ({len(msg)} B)") + return 0, 1 + + pos = 47 + ext_total = (msg[45] << 8) | msg[46] + ext_end = min(pos + ext_total, len(msg)) + + sig_algs_payload = None + while pos + 4 <= ext_end: + ext_type = (msg[pos] << 8) | msg[pos + 1] + ext_len = (msg[pos + 2] << 8) | msg[pos + 3] + ext_data = msg[pos + 4:pos + 4 + ext_len] + if ext_type == 0x000D: + sig_algs_payload = ext_data + break + pos += 4 + ext_len + + if sig_algs_payload is None: + print(" FAIL: signature_algorithms (0x000D) extension not found") + return 0, 1 + + if len(sig_algs_payload) < 2: + print(f" FAIL: signature_algorithms payload too short " + f"({len(sig_algs_payload)} B)") + return 0, 1 + + inner_len = (sig_algs_payload[0] << 8) | sig_algs_payload[1] + inner = sig_algs_payload[2:2 + inner_len] + + # Inner is a list of 16-bit big-endian schemes. + schemes = set() + for i in range(0, len(inner), 2): + if i + 2 <= len(inner): + schemes.add((inner[i] << 8) | inner[i + 1]) + + missing = [] + if 0x0403 not in schemes: + missing.append("0x0403 (ecdsa_secp256r1_sha256)") + if 0x0503 not in schemes: + missing.append("0x0503 (ecdsa_secp384r1_sha384)") + + if missing: + scheme_hex = ", ".join(f"0x{s:04x}" for s in sorted(schemes)) + print(f" FAIL: missing scheme(s): {', '.join(missing)}") + print(f" advertised: {scheme_hex}") + return 0, 1 + + scheme_hex = ", ".join(f"0x{s:04x}" for s in sorted(schemes)) + print(f" PASS: schemes advertised = {scheme_hex}") + return 1, 0 + + +# --------------------------------------------------------------------------- +# Test 1b: CertificateVerify handler accepts 0x0503 and dispatches +# --------------------------------------------------------------------------- + +def test_cert_verify_p384_dispatch(transport, labels): + """Verify tls_handle_cert_verify routes 0x0503 through the dispatcher. + + Synthesizes a CertificateVerify handshake message whose + signature_scheme = 0x0503, calls tls_handle_cert_verify, and asserts: + - cv_sig_scheme = 1 + - ecdsa_curve_id = 1 + - C=1 (carry set), since the P-384 branch in ecdsa_verify is still + the `sec / rts` stub Phase 4a will fill in. + + The signature payload itself is irrelevant — the P-384 short-circuit + in tls_cert.s skips DER parse + SHA-256 + dispatcher setup, jumping + directly to ecdsa_verify with curve_id=1. + """ + print("\n [1b] CertificateVerify dispatch on signature_scheme=0x0503") + + required = [ + "tls_handle_cert_verify", "tls_rec_buf", "cv_sig_scheme", + "ecdsa_curve_id", + ] + missing = [n for n in required if labels.address(n) is None] + if missing: + print(f" SKIP: missing labels {missing}") + return 0, 0 + + handler = labels.address("tls_handle_cert_verify") + rec_buf = labels.address("tls_rec_buf") + cv_scheme_addr = labels.address("cv_sig_scheme") + curve_id_addr = labels.address("ecdsa_curve_id") + + # Build a minimal CertificateVerify handshake message: + # [0] handshake type = 15 (TLS_HS_CERT_VERIFY) + # [1..3] 24-bit length placeholder (handler doesn't validate it) + # [4..5] signature_scheme = 0x0503 + # [6..7] signature length (16-bit BE; high byte must be 0) + # [8..] signature bytes (untouched by the P-384 short-circuit) + sig = bytes(48) # 48 dummy bytes — value irrelevant under the stub + msg = bytearray() + msg.append(0x0F) # handshake type + msg.extend(b"\x00\x00\x00") # 24-bit length placeholder + msg.extend(b"\x05\x03") # signature_scheme + msg.extend(struct.pack(">H", len(sig))) # signature length + msg.extend(sig) + + write_bytes(transport, rec_buf, bytes(msg)) + + # Pre-clear the state we expect the handler to set. + write_bytes(transport, cv_scheme_addr, bytes([0xFF])) + write_bytes(transport, curve_id_addr, bytes([0xFF])) + + try: + carry = jsr_with_carry(transport, handler, timeout=60.0) + except Exception as e: + print(f" FAIL: jsr_with_carry raised {e}") + return 0, 1 + + cv_scheme = read_bytes(transport, cv_scheme_addr, 1)[0] + curve_id = read_bytes(transport, curve_id_addr, 1)[0] + + ok = True + if cv_scheme != 1: + print(f" FAIL: cv_sig_scheme = {cv_scheme:#x}, expected 0x01") + ok = False + if curve_id != 1: + print(f" FAIL: ecdsa_curve_id = {curve_id:#x}, expected 0x01") + ok = False + if carry != 1: + # Phase 4a will replace the stub; the test will need updating then + # to assert C=0 against a real signature instead. + print(f" FAIL: carry = {carry}, expected 1 (stub rejection)") + ok = False + + if ok: + print(" PASS: cv_sig_scheme=1, ecdsa_curve_id=1, " + "C=1 (stub dispatcher reached)") + return 1, 0 + return 0, 1 + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + +def main(): + os.chdir(PROJECT_ROOT) + + # Args + seed = random.randint(0, 2**32 - 1) + args = sys.argv[1:] + i = 0 + while i < len(args): + if args[i] == "--seed" and i + 1 < len(args): + seed = int(args[i + 1]) + i += 2 + else: + i += 1 + random.seed(seed) + rng = random.Random(seed) + print(f"Random seed: {seed} (reproduce with --seed {seed})") + + if os.environ.get("C64_SKIP_BUILD"): + print("\n=== Building (skipped: C64_SKIP_BUILD set) ===") + else: + print("\n=== Building ===") + subprocess.run(["make", "clean"], capture_output=True, cwd=PROJECT_ROOT) + result = subprocess.run(["make"], capture_output=True, text=True, + cwd=PROJECT_ROOT) + if result.returncode != 0: + print(f"Build failed:\n{result.stderr}") + sys.exit(1) + print(f" Build OK: {PRG_PATH}") + + if not os.path.exists(PRG_PATH): + print(f"FATAL: {PRG_PATH} not found") + sys.exit(1) + + labels = Labels.from_file(LABELS_PATH) + print(f" Labels loaded from {LABELS_PATH}") + + # -reu is required: the sibling c64-nist-curves fp_mul fetches 8x8 + # multiply rows from REU banks 0/1 (see CLAUDE.md "VICE harness gotcha" + # under Known issues). This test does not call into the dispatcher's + # body but the link includes the sibling, so the same boot-time + # invariants apply. + config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"]) + + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + transport = inst.transport + print(f"\n=== Starting VICE ===") + print(f" VICE PID={inst.pid}, port={inst.port}") + print(" Waiting for main menu...") + if wait_for_text(transport, "Q=QUIT", timeout=60.0, + verbose=False) is None: + print("FATAL: main menu did not appear") + sys.exit(1) + print(" Main menu ready") + + passed = 0 + failed = 0 + + p, f = test_client_hello_sig_algs(transport, labels, rng) + passed += p + failed += f + + p, f = test_cert_verify_p384_dispatch(transport, labels) + passed += p + failed += f + + mgr.release(inst) + + total = passed + failed + print(f"\n{'=' * 60}") + print("RESULTS") + print(f"{'=' * 60}") + print(f" Passed: {passed}/{total}") + print(f" Failed: {failed}/{total}") + if failed == 0 and passed > 0: + print("\n [+] P-384 negotiation plumbing: PASS") + sys.exit(0) + print("\n [-] P-384 negotiation plumbing: FAIL") + sys.exit(1) + + +if __name__ == "__main__": + main() From f284efa978222c3248236fc6e4f557605e182e17 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 19:40:59 -0500 Subject: [PATCH 06/21] feat(crypto): activate dual-overlay swap dispatcher (P-384 SHA + curve, X25519 sibling) Phase 3. Wires the four-state crypto overlay dispatcher to the split P-384 build (Phase 1.5) and populates REU banks 6 / 7 from PRG-embedded images at boot. State machine (src/crypto/shared/crypto_swap.s): - OV_NONE = 0 (boot default) - OV_X25519_SIBLING = 1 (state-only marker; no DMA -- the sibling rodata occupies CRYPTO_OVERLAY at PRG load time when USE_X25519_SIBLING=1, so a real REU stash is deferred to a follow-up) - OV_P384_SHA384 = 4 (REU bank 6 -> $4200, idempotent) - OV_P384_CURVE = 5 (REU bank 7 -> $4200, idempotent) The legacy crypto_swap_to_p256 / _p384 entries (single-image, stale post-Phase-1.5) and the OV_P256 / OV_P384 IDs are dropped. Boot REU population (src/boot.s::reu_p384_overlay_init): - Strategy chosen: ".incbin into a fixed RAM region" per the task spec, with one twist forced by the C64 memory map: * SHA blob ($1E00 = 7,680 B) -> CRYPTO_OVERLAY ($4200-$5FFF) at PRG load time; STASH'd to REU bank 6 in one DMA. * CURVE blob -> under-KERNAL RAM at $E000-$FDFF at PRG load time. Direct STASH from $E000 was empirically broken under VICE's REU emulator (returned an undefined fill pattern even with $01 banked off; verified vs every other source address). Workaround: CPU-copy curve blob from $E000 (KERNAL banked off) to $4200 (now-free SHA staging slot), then STASH from $4200 to REU bank 7. ~38 K cycles at 1 MHz / ~0.8 ms at 48 MHz. - Inert under USE_X25519_SIBLING=1 / BACKEND=ip65 (no main-RAM headroom; gated via USE_OVERLAY_P384_EMBED in the Makefile). Cfg + Makefile: - cfg/c64-https-uci.cfg: drop the vestigial TCP_BUF MEMORY region (tcp_recv_buf is just an equate at $C000); add OVERLAY_FILE_PAD ($C000-$DFFF, 8 KB zero-fill so KERNAL LOAD lands the OVERLAY_BLOB_CURVE_RAM bytes at the right address) and OVERLAY_BLOB_CURVE_RAM ($E000-$FDFF). Add OVERLAY_BLOB_SHA384 + OVERLAY_BLOB_CURVE segments. PRG grows from 47 KB -> 62 KB. - cfg/c64-https-ip65.cfg: add the same segment names but anchored to zero-size aliases (no embed under ip65). PRG stays 47105 B. - Makefile: define USE_OVERLAY_P384_EMBED under BACKEND=uci + !USE_X25519_SIBLING; pull build/lib/overlay-p384-{sha384,curve}.bin into PRG_DEPS so the .incbin in p384_overlay_blobs.s sees them. reu_layout.inc: trim OVERLAY_SIZE from $2000 to $1E00 to match the actual live slot + .bin sizes. The previous $2000 caused crypto_swap.s to DMA 512 bytes past the slot into the start of CRYPTO_RESIDENT RODATA -- silently corrupting it on every swap. tools/test_p384_symbols.py: rewritten end-to-end for the dual-overlay flow. Verifies both .bin sizes, asserts boot leaves current_overlay = OV_NONE, JSRs each swap entry in turn while reading back current_overlay and the first 16 B at $4200. Idempotent re-swap + direction-reversal round-trip both pass. Uses VICE extra_args=["-reu", "-reusize", "512"] per the documented harness gotcha (sibling P-256 fp_mul fetches REU mul rows; without -reu the boot path silently no-ops). 10/10 PASS on a clean run. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 18 + cfg/c64-https-ip65.cfg | 22 +- cfg/c64-https-uci.cfg | 41 +- src/boot.s | 154 +++++ src/crypto/shared/crypto_swap.s | 206 ++++--- src/crypto/shared/p384_overlay_blobs.s | 110 ++++ src/crypto/shared/reu_layout.inc | 16 +- tools/test_p384_symbols.py | 771 +++++++++---------------- 8 files changed, 747 insertions(+), 591 deletions(-) create mode 100644 src/crypto/shared/p384_overlay_blobs.s diff --git a/Makefile b/Makefile index 19e1fc9..b69a7f4 100644 --- a/Makefile +++ b/Makefile @@ -90,6 +90,16 @@ CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) else ifeq ($(BACKEND),uci) NET_SRCS := $(UCI_SRCS) CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) +# Phase 3: embed the two P-384 split overlay blobs in the PRG so boot +# can populate REU banks 6/7 at startup. Gated to UCI (ip65 has no +# room for the SHA blob in main RAM) and to !USE_X25519_SIBLING (the +# sibling rodata occupies CRYPTO_OVERLAY at PRG load time, displacing +# the SHA blob). Adds a build-order dep on the .bin files; a missing +# .bin causes the .incbin to fail, so we extend PRG_DEPS below. +ifneq ($(USE_X25519_SIBLING),1) +USE_OVERLAY_P384_EMBED := 1 +CA65FLAGS += -D USE_OVERLAY_P384_EMBED=1 +endif # Phase C.3: add c64-nist-curves P-384 primitives as a REU overlay. # Variable-base P-384 point ops (double/add/jacobian-to-affine) only — # see tools/integration/build_nistcurves_p384.sh for the scope rationale. @@ -139,6 +149,14 @@ else PRG_DEPS := $(ALL_OBJS) endif +# Phase 3: when USE_OVERLAY_P384_EMBED is on, add the two .bin files +# to PRG_DEPS so make builds them before the .incbin in +# src/crypto/shared/p384_overlay_blobs.s tries to read them. +ifeq ($(USE_OVERLAY_P384_EMBED),1) +PRG_DEPS += build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin +build/crypto/shared/p384_overlay_blobs.o: build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin +endif + $(PRG): $(PRG_DEPS) @mkdir -p build $(LD65) $(LD65FLAGS) -o $@ $(ALL_OBJS) $(SIBLING_LIB_ARCHIVES) diff --git a/cfg/c64-https-ip65.cfg b/cfg/c64-https-ip65.cfg index dfc3094..0af0345 100644 --- a/cfg/c64-https-ip65.cfg +++ b/cfg/c64-https-ip65.cfg @@ -44,10 +44,19 @@ MEMORY { # CRYPTO_OVERLAY is not used under ip65 (no REU-overlay swapping). # A zero-size rw alias is declared here only to satisfy - # `crypto_swap.s`'s `.import __CRYPTO_OVERLAY_START__` — ip65 never - # actually issues the DMA, so the address value is unused. + # `crypto_swap.s`'s `.import __CRYPTO_OVERLAY_START__` -- ip65 + # never actually issues the DMA, so the address value is unused. CRYPTO_OVERLAY: start = $6000, size = $0000, type = rw, define = yes; + # Phase 3: ip65 backend does NOT embed the P-384 split overlay + # blobs (no room in main RAM after the existing layout, and ip65 + # is the production X25519-only path that never calls into P-384). + # The OVERLAY_BLOB_SHA384 / OVERLAY_BLOB_CURVE segments below are + # `optional = yes` and stay empty under ip65; their MEMORY anchors + # are zero-size aliases just to give ld65 valid load addresses + # for the segment names referenced from src/crypto/shared/. + OVERLAY_BLOB_CURVE_RAM: start = $E000, size = $0000, type = rw, define = yes; + TCP_BUF: start = $C000, size = $1000, type = rw, define = yes; } @@ -115,5 +124,14 @@ SEGMENTS { CRYPTO_BSS: load = CRYPTO_RESIDENT, type = bss; TABLES_BSS: load = CRYPTO_RESIDENT, type = bss, align = $100; + # Phase 3: ip65 backend stays at the historical 47 KB PRG size -- + # USE_OVERLAY_P384_EMBED is gated off in the Makefile under ip65, + # so src/crypto/shared/p384_overlay_blobs.s emits no bytes and + # both segments below stay empty. The segment declarations are + # kept (`optional = yes`) so the cfg parses identically across + # backends and the ip65/UCI link line stays uniform. + OVERLAY_BLOB_SHA384: load = CRYPTO_OVERLAY, type = ro, optional = yes; + OVERLAY_BLOB_CURVE: load = OVERLAY_BLOB_CURVE_RAM, type = ro, optional = yes; + TCP_RECV_BUF: load = TCP_BUF, type = bss, optional = yes; } diff --git a/cfg/c64-https-uci.cfg b/cfg/c64-https-uci.cfg index b33ae9e..9cedb90 100644 --- a/cfg/c64-https-uci.cfg +++ b/cfg/c64-https-uci.cfg @@ -47,7 +47,34 @@ MEMORY { CRYPTO_OVERLAY: start = $4200, size = $1E00, file = %O, define = yes, fill = yes, fillval = $00; CRYPTO_RESIDENT: start = $6000, size = $6000, file = %O, define = yes, fill = yes, fillval = $00; - TCP_BUF: start = $C000, size = $1000, type = rw, define = yes; + # Phase 3: file-backed pad region from $C000-$DFFF. ld65 emits + # contiguous file output; the under-KERNAL OVERLAY_BLOB_CURVE_RAM + # region at $E000-$FDFF requires the gap between CRYPTO_RESIDENT + # and $E000 to land in the file as zeros so KERNAL LOAD writes the + # curve blob bytes to $E000 (not $C801). $C000-$CFFF is TCP_BUF + # at runtime (RAM, populated by ip65/UCI rx callback after net + # init); the zero-fill PRG-load write is harmless because TCP_BUF + # is zero-initialised at first use anyway. $D000-$DFFF is I/O on + # a real C64 + 1541 the PRG load WOULD momentarily corrupt VIC / + # SID / CIA registers; production targets are VICE warp + U64 + # fastload, both of which inject bytes directly to RAM and bypass + # CPU I/O writes during PRG load. + OVERLAY_FILE_PAD: start = $C000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; + + # Phase 3: under-KERNAL ROM RAM at $E000-$FDFF holds the P-384 + # CURVE overlay blob (7,680 B) at PRG load time. Boot DMAs it to + # REU bank 7 then this region is reusable. KERNAL LOAD writes + # pass through to the underlying RAM regardless of $01 banking. + OVERLAY_BLOB_CURVE_RAM: start = $E000, size = $1E00, file = %O, define = yes, fill = yes, fillval = $00; + + # NOTE: the historical TCP_BUF MEMORY region at $C000-$CFFF was + # removed in Phase 3 -- the actual TCP rx ring is just the + # `tcp_recv_buf = $c000` equate in src/constants.inc and the bytes + # at runtime live inside OVERLAY_FILE_PAD's address range. PRG + # load zeros the ring; the rx callback overwrites it after net + # init. The optional TCP_RECV_BUF segment was also dropped from + # SEGMENTS below (no .s file references the segment name; the + # buffer is addressed via the equate, not via a segment label). } SEGMENTS { @@ -111,5 +138,15 @@ SEGMENTS { CRYPTO_BSS: load = CRYPTO_RESIDENT, type = bss; TABLES_BSS: load = CRYPTO_RESIDENT, type = bss, align = $100; - TCP_RECV_BUF: load = TCP_BUF, type = bss, optional = yes; + # Phase 3: P-384 split overlay blobs embedded in the PRG. Boot + # DMAs them out to REU banks 6/7 then the staging RAM is free. + # Both segments are `optional = yes` so that builds without the + # .bin files (e.g. before make p384-overlay has run) still link; + # in that case the segments are empty and boot's reu_p384_overlay_init + # DMAs zero bytes. The .ifndef USE_X25519_SIBLING guard inside + # src/crypto/shared/p384_overlay_blobs.s keeps the segments empty + # under the sibling flag (CRYPTO_OVERLAY is taken by X25519_RODATA + # in that build). + OVERLAY_BLOB_SHA384: load = CRYPTO_OVERLAY, type = ro, optional = yes; + OVERLAY_BLOB_CURVE: load = OVERLAY_BLOB_CURVE_RAM, type = ro, optional = yes; } diff --git a/src/boot.s b/src/boot.s index 079b953..9707bad 100644 --- a/src/boot.s +++ b/src/boot.s @@ -24,6 +24,9 @@ .export reu_fetch_mul_row .endif + ; ---- exports: Phase 3 P-384 overlay REU stash ---- + .export reu_p384_overlay_init + ; ---- exports: menu handlers ---- .export do_net_init .export do_http_get @@ -131,6 +134,27 @@ .import poly_prod_lo .import poly_prod_hi + ; ---- imports: Phase 3 embedded P-384 overlay blob anchors ---- + ; Resolved by src/crypto/shared/p384_overlay_blobs.s when + ; USE_OVERLAY_P384_EMBED is on; the symbols are weak/optional + ; in the same way OVERLAY_BLOB_* segments are optional in the + ; cfg. reu_p384_overlay_init below is .ifdef-gated so it does + ; not reference the symbols when the flag is off (otherwise the + ; .import would fail for a missing symbol). + .ifdef USE_OVERLAY_P384_EMBED + .import p384_overlay_sha384_blob + .import p384_overlay_curve_blob + ; Re-include the REU layout header so REU_OVERLAY_P384_* + ; (24-bit) and OVERLAY_SIZE (16-bit) resolve as local literals + ; at assembly time rather than as cross-TU imports. This + ; sidesteps the ld65 "size mismatch" warning that fires when a + ; 24-bit export from crypto_swap.o is .import'd as the default + ; 16-bit absolute (ca65 has no `:far` attribute on the 6502 + ; CPU). The header is `.ifndef`-guarded so the duplicate + ; include is a no-op aside from making the equates visible. + .include "reu_layout.inc" + .endif + ; ============================================================================= ; BASIC stub: 10 SYS 2061 ; Loaded at $0801 via EXEHDR segment (first bytes of LOADER region). @@ -217,6 +241,12 @@ start: sta $01 jsr reu_mul_init + ; Phase 3: stash both P-384 split overlay images in REU banks 6 + ; and 7 from the .incbin'd staging blocks at $4200 and $E000. + ; Inert under USE_X25519_SIBLING=1 / BACKEND=ip65 (see + ; reu_p384_overlay_init's body for the conditional). + jsr reu_p384_overlay_init + ; Auto-initialize networking at boot so the banner shows the ; firmware-assigned IP without waiting for the user to press 'I'. ; On ip65 this runs the full cs8900a + DHCP handshake; on the @@ -755,6 +785,130 @@ reu_fetch_mul_row: .endif ; .ifndef USE_X25519_SIBLING (in-tree reu_mul_init / reu_fetch_mul_row) +; ============================================================================= +; reu_p384_overlay_init - Stash both P-384 split-overlay images in REU. +; +; Reads the two .incbin'd images at p384_overlay_sha384_blob ($4200) and +; p384_overlay_curve_blob ($E000) and STASHes (C64->REU) each into the +; REU bank reserved by src/crypto/shared/reu_layout.inc: +; +; REU bank 6 ($60000) <- $4200..$5FFF (OVERLAY_SIZE bytes, sha384) +; REU bank 7 ($70000) <- $E000..$FDFF (OVERLAY_SIZE bytes, curve) +; +; After this returns, the live CRYPTO_OVERLAY slot at $4200 still holds +; the SHA-384 image bytes -- but the linker considers it free (no segment +; references the bytes by symbol after this point) so a subsequent +; jsr crypto_swap_to_p384_curve will overwrite the slot with the curve +; image from REU bank 7. The under-KERNAL block at $E000-$FDFF is +; freed unconditionally; KERNAL ROM is banked in by default so future +; reads from $E000 hit ROM, not the no-longer-needed blob bytes. +; +; Inert when USE_OVERLAY_P384_EMBED is undefined (BACKEND=ip65, or +; USE_X25519_SIBLING=1 under UCI) -- the routine compiles to a single +; RTS so the call site in `start` is harmless. +; +; SEI around each DMA window; restores caller's I flag. ~16 ms total +; wall-clock at any CPU speed (REU DMA bus runs at ~1 MHz regardless +; of turbo). +; +; Clobbers: A. Does NOT update current_overlay -- crypto_swap_none has +; that responsibility; boot calls neither because the BSS reset at +; entry already left current_overlay = OV_NONE = 0. +; ============================================================================= +reu_p384_overlay_init: +.ifdef USE_OVERLAY_P384_EMBED + ; --- Stash 1: $4200 (SHA blob) -> REU bank 6, offset $0000 --- + php + sei + lda #p384_overlay_sha384_blob + sta reu_c64_hi + lda #REU_OVERLAY_P384_SHA384 + sta reu_reu_hi + lda #^REU_OVERLAY_P384_SHA384 + sta reu_reu_bank + lda #OVERLAY_SIZE + sta reu_len_hi + lda #0 + sta reu_addr_ctrl ; both addresses autoincrement + lda #$90 ; execute + STASH (C64->REU) + sta reu_command + plp + + ; --- Intermediate: copy CURVE blob from $E000 -> CRYPTO_OVERLAY --- + ; The CURVE blob lives in RAM under KERNAL ROM at $E000-$FDFF. + ; VICE's REU emulator reads C64 RAM via a path that does NOT + ; respect $01 banking for the $E000-$FFFF range -- a STASH + ; from $E000 with KERNAL banked off still returns ROM bytes + ; (and on bank 7 specifically returns an undefined fill + ; pattern, see the empirical results documented in Phase 3 + ; commit). Workaround: CPU-copy the blob from $E000 (with + ; KERNAL banked off so the LDA sees RAM) into the now-free + ; CRYPTO_OVERLAY slot at $4200 (the SHA-384 blob has already + ; been stashed to REU bank 6, so the slot bytes are no longer + ; load-bearing), then STASH from $4200. CPU copy is + ; ~7,680 * 5 cy ~= 38 K cycles ~= 38 ms at 1 MHz / ~0.8 ms at + ; 48 MHz -- negligible vs the DMA latency itself. + php + sei + lda $01 + pha ; save banking + and #%11111101 ; clear bit 1 (KERNAL ROM off, RAM at $E000) + sta $01 + + ; Copy 30 pages ($1E00 = 7,680 B) from $E000-$FDFF to $4200-$5FFF + ; via self-modifying base+Y indexing. Y walks 0..255; outer + ; loop bumps the high byte of both src and dst pointers. + lda #$E0 + sta @cp_src+2 + lda #$42 + sta @cp_dst+2 + ldx #30 ; 30 pages = $1E00 bytes +@cp_page: + ldy #0 +@cp_byte: +@cp_src: + lda $E000,y ; high byte self-modified above +@cp_dst: + sta $4200,y ; high byte self-modified above + iny + bne @cp_byte + inc @cp_src+2 + inc @cp_dst+2 + dex + bne @cp_page + + pla ; restore banking (KERNAL back on) + sta $01 + + ; --- Stash 2: $4200 (CURVE blob, freshly copied) -> REU bank 7 --- + lda #$00 + sta reu_c64_lo + lda #$42 + sta reu_c64_hi + lda #REU_OVERLAY_P384_CURVE + sta reu_reu_hi + lda #^REU_OVERLAY_P384_CURVE + sta reu_reu_bank + lda #OVERLAY_SIZE + sta reu_len_hi + lda #0 + sta reu_addr_ctrl + lda #$90 + sta reu_command + plp +.endif ; .ifdef USE_OVERLAY_P384_EMBED + rts + ; ============================================================================= ; Strings (read-only) ; ============================================================================= diff --git a/src/crypto/shared/crypto_swap.s b/src/crypto/shared/crypto_swap.s index 57554be..6adb2a8 100644 --- a/src/crypto/shared/crypto_swap.s +++ b/src/crypto/shared/crypto_swap.s @@ -1,60 +1,76 @@ ; ============================================================================= -; crypto_swap.s - Crypto overlay DMA dispatcher +; crypto_swap.s - Crypto overlay DMA dispatcher (Phase 3 dual-overlay edition) ; -; Pages one of two 8 KB overlay images (P-256, P-384) from REU -; bank 2 into the live CRYPTO_OVERLAY region. Call sites prefix each -; overlay-targeting primitive with `jsr crypto_swap_to_`. +; Pages one of two REU-resident overlay images into the live CRYPTO_OVERLAY +; region on demand. Phase 3 adds two new entry points +; (crypto_swap_to_p384_sha384 / crypto_swap_to_p384_curve) that load the +; sha384 and curve halves of the split P-384 build (Phase 1.5) from REU +; banks 6 and 7, replacing the now-stale single-image +; crypto_swap_to_p384. The P-256 overlay swap was never exercised in +; production -- the in-tree P-256 path went away in Phase G and +; nistcurves-p256 is always-resident -- so the legacy crypto_swap_to_p256 +; entry has been dropped along with the OV_P256 state. ; ; Idempotent: re-entering with the same overlay already resident is a ; single-byte compare + rts (no DMA). ; ; Interrupt discipline: SEI around the DMA window; restores original I -; flag on exit. ~8 ms DMA latency at any CPU speed (REU bus runs at +; flag on exit. ~8 ms DMA latency at any CPU speed (REU bus runs at ; ~1 MHz regardless of turbo). ; -; Phase C.1 rollback note: the x25519 overlay integration was removed -; after it broke the TLS handshake at 48 MHz UCI. `crypto_swap_to_x25519` -; no longer exists; in-tree x25519 in `src/crypto/x25519.s` is -; always-resident. The remaining swap entry points exist for the -; external P-384 smoke test (tools/test_p384_symbols.py). -; -; `current_overlay`: 1 byte in CRYPTO_BSS (SHADOW_BSS-resident). -; 0 = none (uninitialized / swap_none) -; 2 = p256 -; 3 = p384 -; -; `CRYPTO_OVERLAY_START` is defined by the linker (cfg `MEMORY { }` -; `define = yes` on the CRYPTO_OVERLAY region — see cfg/c64-https-*.cfg). -; ; ----------------------------------------------------------------------------- -; Phase 1.5 split-overlay design (P-384 path) -- INFORMATIONAL ONLY +; Overlay state machine ; ----------------------------------------------------------------------------- -; Phase 1b's monolithic P-384 overlay (12.5 KB) overflowed the live UCI -; CRYPTO_OVERLAY slot (7,680 B at $4200-$5FFF). The fix is functional: -; split the P-384 image into two halves along the SHA / curve boundary, -; each fitting the slot, and load them in sequence. +; `current_overlay` is a single byte in CRYPTO_BSS (SHADOW_BSS-resident). +; Values are opaque to the swap engine -- they exist purely so callers +; can short-circuit a no-op swap. The four states this dispatcher knows +; about: ; -; Phase 1.5 emits the two .bin files; Phase 3 will extend this dispatcher -; with two new entry points (do NOT add them yet -- this comment is -; informational only). The four overlay states the dispatcher will need -; to track: +; 0 = OV_NONE (uninitialized / swap_none -- after boot, +; before any P-384 swap; live slot bytes are +; undefined and MUST NOT be jsr'd into. Boot +; leaves current_overlay = OV_NONE so the +; first crypto_swap_to_p384_* call always +; DMAs.) +; 1 = OV_X25519_SIBLING (X25519 sibling rodata as set up by the +; Phase C.5 build under USE_X25519_SIBLING=1. +; Marker only -- there is no boot-time REU +; stash for the sibling rodata, so this entry +; does NOT DMA; it just records that the live +; slot already holds X25519 sibling rodata +; because the linker placed X25519_RODATA in +; CRYPTO_OVERLAY at PRG load time. Once a +; P-384 overlay has been swapped in, calling +; crypto_swap_to_x25519_sibling ALONE is NOT +; sufficient to restore X25519 rodata bytes; +; a follow-up phase needs to add a REU stash +; for the sibling rodata if that round-trip +; is ever required. Phase 3 leaves it as a +; state-only marker because the production +; TLS path (X25519 only / no P-384) never +; swaps anything in over X25519.) +; 4 = OV_P384_SHA384 (P-384 SHA-384 hash code + IV/K[80] RODATA; +; 5,456 B unpadded. REU bank 6, $60000.) +; 5 = OV_P384_CURVE (P-384 fp384 / mod384 / points384 / curve384 / +; ecdsa_verify_384 + shim; 7,317 B unpadded. +; REU bank 7, $70000.) ; -; 0 = OV_NONE (uninitialized / swap_none) -; 2 = OV_P256 (existing — unchanged) -; 4 = OV_P384_SHA384 (NEW — sha384.s code + IV/K[80] RODATA) -; 5 = OV_P384_CURVE (NEW — fp384/mod384/points384/curve384/ -; ecdsa_verify_384/shim) +; (IDs 2 and 3 intentionally skipped to leave headroom for future +; overlays without renumbering.) ; -; The legacy state 3 (OV_P384, monolithic) is now stale and will be -; removed by Phase 3 along with the existing crypto_swap_to_p384 entry -; point (the only consumer is tools/test_p384_symbols.py, which will -; be rewritten to drive the two halves in sequence). -; -; REU storage (see src/crypto/shared/reu_layout.inc): -; REU_OVERLAY_P384_SHA384 = $60000 (bank 6) -; REU_OVERLAY_P384_CURVE = $70000 (bank 7) +; ----------------------------------------------------------------------------- +; Boot-time invariants (Phase 3) +; ----------------------------------------------------------------------------- +; src/boot.s populates REU banks 6 and 7 from .incbin'd images at +; startup (see src/crypto/shared/p384_overlay_blobs.s and the +; reu_p384_overlay_init routine in boot.s). Once boot finishes, +; subsequent calls to crypto_swap_to_p384_sha384 / _curve simply DMA +; from those banks into the live slot at $4200. No call site needs +; to know about the boot-time staging. ; -; TLS-side call sequence (Phase 4a will implement the dispatcher): +; ----------------------------------------------------------------------------- +; TLS-side call sequence (Phase 4a will implement the dispatcher) +; ----------------------------------------------------------------------------- ; ; --- 1. Hash the handshake transcript --- ; jsr crypto_swap_to_p384_sha384 ; jsr sha384_init @@ -85,9 +101,8 @@ ; splices h from sha384_digest, then calls ecdsa_verify_384. ; - All other ec384_* / fp384_* / ecdsa384_* RW buffers and the ; sha_state / sha_w / sha_block_* SHA-384 state ALSO live in -; resident DATA (CRYPTO_RESIDENT, $C000-$EFFF in the standalone -; cfgs; CRYPTO_RESIDENT in the live UCI cfg). Resident DATA -; footprint is unchanged from Phase 1b's 3,541 B. +; resident DATA. Resident DATA footprint is unchanged from +; Phase 1b's 3,541 B. ; ; ZP save/restore obligation (Phase 4a): ; Phase 1.5 moves the sibling's SHA-384 streaming pointer slots @@ -103,71 +118,105 @@ ; SHA-384 call window, so NO save/restore is required around the ; SHA window. Phase 4a's TLS dispatcher MAY clobber $3D-$44 ; freely while sha384_init/update/final is in flight. -; -; If a future change introduces a competing user of $3D-$44, the -; dispatcher must save/restore those eight bytes around the SHA -; window OR move SHA-384 to a different free slot. The choice -; of $3D-$44 is documented in tools/integration/build_nistcurves_p384.sh. ; ============================================================================= .include "constants.inc" ; reu_* register equates .include "reu_layout.inc" - .export crypto_swap_to_p256 - .export crypto_swap_to_p384 + .export crypto_swap_to_x25519_sibling + .export crypto_swap_to_p384_sha384 + .export crypto_swap_to_p384_curve .export crypto_swap_none .export current_overlay - ; Export REU layout equates once (guarded against multi-include). - .export REU_OVERLAY_P256 - .export REU_OVERLAY_P384 + ; Export REU layout equates once (kept in sync with reu_layout.inc). + .export REU_OVERLAY_P384_SHA384 + .export REU_OVERLAY_P384_CURVE .export OVERLAY_SIZE ; Live overlay slot start address (from the cfg's MEMORY{} define). .import __CRYPTO_OVERLAY_START__ ; ----------------------------------------------------------------------------- -; Overlay IDs — must stay in sync with `current_overlay` comments. +; Overlay IDs -- must stay in sync with `current_overlay` comments above. ; ----------------------------------------------------------------------------- -OV_NONE = 0 -OV_P256 = 2 -OV_P384 = 3 + .export OV_NONE + .export OV_X25519_SIBLING + .export OV_P384_SHA384 + .export OV_P384_CURVE + +OV_NONE = 0 +OV_X25519_SIBLING = 1 +OV_P384_SHA384 = 4 +OV_P384_CURVE = 5 ; REU command: execute REU->C64 stash (bit 7 = start, bits 1-0 = direction -; 01 = REU-to-C64). Matches the DMA issue used elsewhere in the codebase. +; 01 = REU-to-C64). Matches the DMA issue used elsewhere in the codebase. REU_CMD_REU_TO_C64 = $91 ; ----------------------------------------------------------------------------- -; crypto_swap_to_p256 / _p384 +; crypto_swap_to_x25519_sibling -- state-only marker (no DMA). +; +; Records that the live slot holds X25519 sibling rodata. Used at boot +; time only -- the linker has already placed X25519_RODATA in +; CRYPTO_OVERLAY at PRG load time when USE_X25519_SIBLING=1, so the +; first time the TLS path needs X25519 the bytes are already there and +; current_overlay just needs to reflect that. +; +; NOTE (Phase 3): if a future caller swaps in a P-384 overlay and then +; needs to round-trip back to X25519 sibling rodata, this entry is NOT +; sufficient -- it does not restore the bytes. A subsequent phase +; needs to add a REU stash of the sibling rodata and a real DMA path +; here. Today's TLS production path (no P-384) never triggers that +; sequence so the gap is benign. ; ----------------------------------------------------------------------------- .segment "LOADER_OVERFLOW" -crypto_swap_to_p256: - lda #OV_P256 +crypto_swap_to_x25519_sibling: + lda #OV_X25519_SIBLING + sta current_overlay + rts + +; ----------------------------------------------------------------------------- +; crypto_swap_to_p384_sha384 -- DMA P-384 SHA-384 image from REU bank 6 +; into the live CRYPTO_OVERLAY slot. Idempotent. +; ----------------------------------------------------------------------------- +crypto_swap_to_p384_sha384: + lda #OV_P384_SHA384 cmp current_overlay beq swap_done_fast pha - lda #REU_OVERLAY_P256 - ldy #^REU_OVERLAY_P256 + lda #REU_OVERLAY_P384_SHA384 + ldy #^REU_OVERLAY_P384_SHA384 jsr do_swap pla sta current_overlay rts -crypto_swap_to_p384: - lda #OV_P384 +; ----------------------------------------------------------------------------- +; crypto_swap_to_p384_curve -- DMA P-384 curve / verify image from REU +; bank 7 into the live CRYPTO_OVERLAY slot. Idempotent. +; ----------------------------------------------------------------------------- +crypto_swap_to_p384_curve: + lda #OV_P384_CURVE cmp current_overlay beq swap_done_fast pha - lda #REU_OVERLAY_P384 - ldy #^REU_OVERLAY_P384 + lda #REU_OVERLAY_P384_CURVE + ldy #^REU_OVERLAY_P384_CURVE jsr do_swap pla sta current_overlay rts +; ----------------------------------------------------------------------------- +; crypto_swap_none -- mark the slot as undefined. +; +; Does NOT zero the slot bytes -- callers MUST NOT jsr into the slot +; while OV_NONE is current. The state byte is the contract. +; ----------------------------------------------------------------------------- crypto_swap_none: lda #OV_NONE sta current_overlay @@ -177,11 +226,12 @@ swap_done_fast: rts ; ----------------------------------------------------------------------------- -; do_swap - issue the REU -> C64 DMA of 8 KB into CRYPTO_OVERLAY +; do_swap - issue the REU -> C64 DMA of OVERLAY_SIZE bytes into +; CRYPTO_OVERLAY. ; IN: A = REU source low byte ; X = REU source middle byte ; Y = REU source bank byte -; Clobbers A, X, Y. Saves / restores original I flag. +; Clobbers A, X, Y. Saves / restores original I flag. ; ----------------------------------------------------------------------------- do_swap: ; Save current I flag on the stack (bit 2 of P). @@ -193,13 +243,15 @@ do_swap: stx reu_reu_hi sty reu_reu_bank - ; C64 target: CRYPTO_OVERLAY_START, 8 KB window + ; C64 target: CRYPTO_OVERLAY_START lda #<__CRYPTO_OVERLAY_START__ sta reu_c64_lo lda #>__CRYPTO_OVERLAY_START__ sta reu_c64_hi - ; 8 KB = $2000 + ; Transfer length = OVERLAY_SIZE ($1E00 = 7,680 B; matches the + ; live CRYPTO_OVERLAY slot under UCI and the padded .bin images + ; produced by tools/integration/build_nistcurves_p384_bin.sh). lda #OVERLAY_SIZE @@ -219,8 +271,8 @@ do_swap: ; ----------------------------------------------------------------------------- ; current_overlay - single-byte state tracking which overlay is resident. -; Lives in SHADOW_BSS-resident CRYPTO_BSS (via BSS segment) so it survives -; across calls without polluting ZP. +; Lives in SHADOW_BSS-resident CRYPTO_BSS (via BSS segment) so it +; survives across calls without polluting ZP. ; ----------------------------------------------------------------------------- .segment "BSS" current_overlay: .res 1 diff --git a/src/crypto/shared/p384_overlay_blobs.s b/src/crypto/shared/p384_overlay_blobs.s new file mode 100644 index 0000000..5eb892a --- /dev/null +++ b/src/crypto/shared/p384_overlay_blobs.s @@ -0,0 +1,110 @@ +; ============================================================================= +; p384_overlay_blobs.s -- Embedded P-384 split overlay images (Phase 3). +; +; Phase 1.5 split the monolithic P-384 overlay into two 7,680 B images: +; +; build/lib/overlay-p384-sha384.bin (REU bank 6, $60000) +; build/lib/overlay-p384-curve.bin (REU bank 7, $70000) +; +; Phase 3 boots them into REU at startup so the TLS path (Phase 4a) can +; jsr crypto_swap_to_p384_{sha384,curve} on demand without staging +; anything from disk at handshake time. This file is the .incbin +; equivalent of src/net/ip65/ip65_blob.s -- it just embeds the two +; .bin payloads into the linker-controlled MEMORY map so ld65 places +; them at known C64 addresses. src/boot.s::reu_p384_overlay_init then +; copies them out to REU banks 6/7 in two STASH DMAs (~16 ms total at +; any CPU speed) and the C64 RAM holding the staging copies is free +; to be reused (CRYPTO_OVERLAY for the live overlay slot itself, and +; the under-KERNAL block at $E000-$FDFF for whatever). +; +; ----------------------------------------------------------------------------- +; Boot strategy: ".incbin into a fixed RAM region" (Phase 3) +; ----------------------------------------------------------------------------- +; Why this layout instead of disk-LOAD-at-boot? C64 PRG is a single +; contiguous load, and 47 KB (existing PRG) + 15 KB (two blobs) = +; ~62 KB does not fit anywhere in main RAM that avoids the I/O hole at +; $D000-$DFFF. We work around it by: +; +; 1. Placing the SHA-384 blob at $4200-$5FFF (CRYPTO_OVERLAY region). +; Under default builds the live overlay slot is otherwise empty +; at PRG load time; the blob occupies it transiently until boot +; DMAs it out. After boot the slot is "free" (current_overlay = +; OV_NONE) and the next jsr crypto_swap_to_p384_sha384 will DMA +; the same bytes back from REU bank 6. Under USE_X25519_SIBLING=1 +; the X25519 sibling rodata occupies CRYPTO_OVERLAY at PRG load +; time -- this file is .ifdef-gated out in that build (see below) +; and REU bank 6 is left unpopulated. The shipped TLS path under +; the sibling flag never calls crypto_swap_to_p384_sha384 so the +; gap is benign. +; +; 2. Placing the CURVE blob at $E000-$FDFF (under KERNAL ROM). The +; C64 ALWAYS has RAM there; the KERNAL ROM only intercepts reads. +; KERNAL LOAD writes pass through to the underlying RAM regardless +; of $01 banking, so the PRG load deposits the blob bytes there +; cleanly. Boot reads them back via REU DMA (which doesn't go +; through CPU $01 banking either) and stashes them in REU bank 7. +; Once the DMA completes the under-KERNAL block is free for any +; future use. +; +; The PRG file gains ~15 KB (one 7,680 B blob + the 8 KB pad from +; $C000-$DFFF that ld65 generates between CRYPTO_RESIDENT and the +; under-KERNAL region) growing to ~62 KB. Loading via VICE warp / +; Ultimate-64 fastload writes bytes directly to RAM (no real CPU I/O +; passthrough during the load), so the embedded $D000-$DFFF zeros +; cause no harm. On a real C64 + 1541 the load WOULD momentarily +; write zeros to VIC/SID/CIA registers; the PRG is not intended for +; that target. +; +; The gating below mirrors the same `.ifdef USE_X25519_SIBLING` guard +; used in src/boot.s and src/data.s -- a Make-time -D from the top +; Makefile toggles it. +; ============================================================================= + + .setcpu "6502" + +; The blobs are embedded only when USE_OVERLAY_P384_EMBED is asserted by +; the top-level Makefile (UCI backend, no USE_X25519_SIBLING flag). +; Under ip65 there is no room in main RAM after the existing layout +; (NET_BSS_TAIL has only ~800 B of slack and CRYPTO_OVERLAY is a +; zero-size alias). Under USE_X25519_SIBLING=1 the X25519 sibling +; rodata occupies CRYPTO_OVERLAY at PRG load time so the SHA blob +; cannot share that slot. Either gate leaves the segments empty; +; boot's reu_p384_overlay_init detects the empty state via a build-time +; flag and skips the DMAs entirely. +.ifdef USE_OVERLAY_P384_EMBED + + ; Force-link the two segments by exporting two anchor symbols. + ; Without these, ld65 can theoretically drop optional segments + ; that have no `.import` references; the boot code DMAs from the + ; segments by symbol so a stable label per segment is required + ; anyway. + .export p384_overlay_sha384_blob + .export p384_overlay_sha384_blob_end + .export p384_overlay_curve_blob + .export p384_overlay_curve_blob_end + +; ----------------------------------------------------------------------------- +; SHA-384 overlay image (REU bank 6 source) +; +; Loads into the live CRYPTO_OVERLAY slot at $4200-$5FFF at PRG load +; time, then boot DMAs it to REU bank 6. The .incbin path is resolved +; by ca65 relative to this source file: from src/crypto/shared/ the +; build/ tree is two levels up. +; ----------------------------------------------------------------------------- + .segment "OVERLAY_BLOB_SHA384" +p384_overlay_sha384_blob: + .incbin "../../../build/lib/overlay-p384-sha384.bin" +p384_overlay_sha384_blob_end: + +; ----------------------------------------------------------------------------- +; CURVE overlay image (REU bank 7 source) +; +; Loads into the under-KERNAL region at $E000-$FDFF at PRG load time, +; then boot DMAs it to REU bank 7. +; ----------------------------------------------------------------------------- + .segment "OVERLAY_BLOB_CURVE" +p384_overlay_curve_blob: + .incbin "../../../build/lib/overlay-p384-curve.bin" +p384_overlay_curve_blob_end: + +.endif ; .ifdef USE_OVERLAY_P384_EMBED diff --git a/src/crypto/shared/reu_layout.inc b/src/crypto/shared/reu_layout.inc index c43ca23..e954b6c 100644 --- a/src/crypto/shared/reu_layout.inc +++ b/src/crypto/shared/reu_layout.inc @@ -96,10 +96,20 @@ REU_OVERLAY_P384_CURVE = $70000 ; cfg/x25519.cfg pins these via SYMBOLS — downstream override available). ; --- overlay slot size (bytes) --- -; Each overlay image occupies exactly this many bytes in the REU store and -; is DMA'd into the live CRYPTO_OVERLAY region at runtime. +; Each overlay image occupies exactly this many bytes in the REU store +; and is DMA'd into the live CRYPTO_OVERLAY region at runtime. +; +; Phase 3: trimmed from $2000 (8 KB) to $1E00 (7,680 B) to match the +; actual live UCI CRYPTO_OVERLAY slot ($4200-$5FFF) and the padded +; .bin image size from tools/integration/build_nistcurves_p384_bin.sh +; (`SLOT_BYTES=7680`). The previous $2000 value caused crypto_swap.s +; to DMA 512 bytes past the slot, into the start of CRYPTO_RESIDENT +; RODATA at $6000-$61FF -- silently corrupting that range on every +; swap. Tests didn't catch it because no code read $6000+ between +; back-to-back swaps; the production TLS path (Phase 4a) WOULD have +; been broken by it. .ifndef OVERLAY_SIZE -OVERLAY_SIZE = $2000 ; 8 KB +OVERLAY_SIZE = $1E00 ; 7,680 B .endif ; Note: `.export` of these equates happens once in diff --git a/tools/test_p384_symbols.py b/tools/test_p384_symbols.py index 6a3c41d..99e5862 100755 --- a/tools/test_p384_symbols.py +++ b/tools/test_p384_symbols.py @@ -1,62 +1,65 @@ #!/usr/bin/env python3 -"""test_p384_symbols.py -- P-384 primitive smoke test (c64-nist-curves sibling). - -Phase C.3b design: P-384 is smoke-test-only — the production PRG does NOT -link the P-384 archive (the Makefile USE_NISTCURVES_P384 gate is commented -out intentionally). Instead we ship the P-384 overlay as a separate -`build/lib/overlay-p384.bin` (8 KB raw image) + `build/labels-p384.txt` -(addresses of the primitives + DATA buffers), and THIS script loads them -into REU at harness time: - - 1. Build main PRG (BACKEND=uci) -- same size as without P-384. - 2. Build overlay-p384.bin + labels-p384.txt via - `bash tools/integration/build_nistcurves_p384_bin.sh`. - 3. Boot VICE and wait for the main menu. - 4. Stage the 8 KB image into C64 RAM at $2000 (clobbers the UCI adapter, - which is fine — no networking used in this test). - 5. DMA-copy $2000..$3FFF into REU bank 2 offset $4100 (REU_OVERLAY_P384) - via a tiny injected trampoline at $0340. - 6. Call `crypto_swap_to_p384` — REU→$4200 DMA inside the PRG. The live - overlay slot now holds P-384 code. - 7. Exercise ec_point_double_384 / ec_point_add_384 / - ec_jacobian_to_affine_384 against NIST P-384 generator vectors, - comparing affine outputs to a Python reference. - -Endian: c64-nist-curves stores field elements LITTLE-ENDIAN (byte 0 = LSB, -48 bytes per coordinate). Python `cryptography` gives integers; we -convert in-script with `int_to_le48`. - -P-384 DATA buffers (ec384_p1, fp384_wide, ec384_affine_x, ...) live at -$C000+ in this standalone link (inside TCP_BUF). TCP_BUF is unused at -test time (no networking), so we can safely use it as P-384 scratch. +"""test_p384_symbols.py -- Phase 3 dual-overlay swap dispatcher smoke test. + +Phase 3 (this rewrite) replaced the legacy single-image overlay flow +(crypto_swap_to_p384, overlay-p384.bin, harness-side staging) with a +build-time embedded dual-overlay flow: + + - The build produces TWO .bin images: + build/lib/overlay-p384-sha384.bin (REU bank 6, $60000) + build/lib/overlay-p384-curve.bin (REU bank 7, $70000) + - `make BACKEND=uci` `.incbin`s both blobs into the PRG + (src/crypto/shared/p384_overlay_blobs.s) and grows the PRG from + 47105 B -> 62977 B (+15872 B, the two 7,680 B blobs plus the 8 KB + of zeros ld65 emits across the $C000-$DFFF gap so the under-KERNAL + OVERLAY_BLOB_CURVE_RAM region at $E000 lands at the right RAM + address after KERNAL LOAD). + - At boot, src/boot.s calls reu_p384_overlay_init which STASHes the + embedded blobs from $4200 (sha384) and $E000 (curve) into REU + banks 6 and 7 in two ~8 ms DMA windows. + - The TLS path (Phase 4a will implement) calls + crypto_swap_to_p384_sha384 to hash the transcript, then + crypto_swap_to_p384_curve to verify the ECDSA-P384 signature. + Each swap is a single REU->C64 DMA into the live CRYPTO_OVERLAY + slot at $4200; idempotent if `current_overlay` already matches. + +This script smoke-tests the dispatcher in isolation: + + 1. Verify both .bin files exist (rebuild if missing) and report sizes. + 2. Verify build/labels.txt exposes all four swap entry points and + the current_overlay state byte. + 3. Boot the PRG in VICE -reu and wait for the menu banner. + 4. Read current_overlay -- expected OV_NONE (= 0) right after boot. + 5. JSR crypto_swap_to_p384_sha384. Confirm current_overlay == 4 + (OV_P384_SHA384) and the first 16 B at $4200 match the sha384 .bin. + 6. JSR crypto_swap_to_p384_curve. Confirm current_overlay == 5 + (OV_P384_CURVE) and the first 16 B at $4200 match the curve .bin. + 7. JSR crypto_swap_to_p384_sha384 again. Confirm idempotent + + direction-reversal: current_overlay == 4 and $4200 reverts to + the sha384 image. + 8. JSR crypto_swap_to_x25519_sibling. Confirm current_overlay == 1 + (OV_X25519_SIBLING). This entry is a state-only marker today + (Phase 3 deferred actual REU restoration of X25519 rodata to a + follow-up phase), so the live slot bytes do NOT change -- we + only assert the state byte updates. + 9. JSR crypto_swap_none. Confirm current_overlay == 0. + +Exits 0 on PASS, 1 on FAIL or environmental error. + +VICE harness gotcha: the PRG's boot path executes nistcurves P-256 +fp_mul, which fetches 8x8 multiply rows from REU banks 0/1. Without +`-reu`, those banks don't exist and the boot crashes. We launch +VICE with `extra_args=["-reu", "-reusize", "512"]` per the documented +project gotcha (CLAUDE.md "VICE harness gotcha", and the +vice_reu_required_for_p256 user memory note). Usage: - BACKEND=uci python3 tools/test_p384_symbols.py [--verbose] - -Under BACKEND=ip65 (or any other backend where nistcurves-p384.a is not -built), the script exits 0 with a skip message — the overlay image is -built only under UCI via the integration script. - -Known issue (Phase C.3b investigation): - fp_mul_384 works correctly after the harness's REU-reg restore step - (2*3=6 smoke-verified), but fp_sqr_384 hangs when invoked on any - nonzero input in this standalone link configuration. Consequently - ec_point_double_384 (which calls ec_sqrp_384 -> fp_mod_sqr_384 -> - fp_sqr_384) times out on Test 1. The root cause has not been - identified yet; most likely candidates: - - Subtle interaction between the PRG's x25519 sibling leaving - REU registers in a state fp_sqr_384 doesn't re-program (fp_sqr's - inline DMA writes only $DF05/$DF06/$DF01, relying on other REU - regs being pre-set to the mul-row FETCH config). - - A local BSS symbol in fp384_raw.s (fp384_sqr_pairs, mul_src2_buf_384) - resolving to an address that collides with something else in the - standalone-link RESIDENT placement at $C000-$CFFF. This has been - checked against the linker map and addresses look clean, but some - interaction with TCP_BUF scratch used for overlay staging hasn't - been fully ruled out. - The test infrastructure (overlay upload, crypto_swap_to_p384, REU-reg - restore, ZP/fp_src wiring, output readback) is verified working - end-to-end by the fp_mul_384 path. + BACKEND=uci /Users/someone/.local/share/c64-test-harness/venv/bin/python3 \ + tools/test_p384_symbols.py [--verbose] + +Under BACKEND=ip65 the script exits 0 with a skip message -- the +embedded-blobs path is UCI-only (ip65 has no main-RAM headroom for the +extra 15 KB; see cfg/c64-https-ip65.cfg's Phase 3 comment block). """ import os @@ -66,205 +69,44 @@ PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") -P384_LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels-p384.txt") -P384_IMAGE_PATH = os.path.join(PROJECT_ROOT, "build", "lib", "overlay-p384.bin") +SHA_BIN_PATH = os.path.join(PROJECT_ROOT, "build", "lib", "overlay-p384-sha384.bin") +CURVE_BIN_PATH = os.path.join(PROJECT_ROOT, "build", "lib", "overlay-p384-curve.bin") VERBOSE = False -# P-384 curve parameters (NIST FIPS 186-4). -P_384 = 2**384 - 2**128 - 2**96 + 2**32 - 1 -A_384 = -3 % P_384 -B_384 = int( - "b3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875a" - "c656398d8a2ed19d2a85c8edd3ec2aef", - 16, -) -GX_384 = int( - "aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a38" - "5502f25dbf55296c3a545e3872760ab7", - 16, -) -GY_384 = int( - "3617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c0" - "0a60b1ce1d7e819d7a431d7c90ea0e5f", - 16, -) - -# REU bank/offset used by the overlay store. Kept in sync with -# src/crypto/shared/reu_layout.inc. -REU_OVERLAY_P384 = 0x24100 # 24-bit REU address = bank 2, offset $4100. - -# Harness staging area. The 8 KB image is uploaded to REU in two 4 KB -# halves so we can stage each half in TCP_BUF ($C000-$CFFF, free because -# networking is off). We can't stage at $2000 even though it's -# big enough — the UCI cfg puts LOADER_OVERFLOW (containing -# crypto_swap_to_p384 itself!) in NET_CODE at $2000-$3FFF, and clobbering -# that would crash the next jsr(crypto_swap_to_p384). -C64_STAGE_ADDR = 0xC000 -C64_STAGE_SIZE = 0x1000 # 4 KB per chunk. -OVERLAY_SIZE = 0x2000 # 8 KB. - -# Address we inject the DMA trampoline at. Inside the cassette buffer, -# safely past the jsr() scratch at $0334-$0338. The trampoline is 55 B -# so it occupies $0340-$0377 (ASCII). -DMA_TRAMPOLINE_ADDR = 0x0340 - - -# ----------------------------------------------------------------------------- -# Byte-order helpers. -# ----------------------------------------------------------------------------- - -def int_to_le48(v: int) -> bytes: - """Convert an integer to 48-byte little-endian representation.""" - return (v % P_384).to_bytes(48, "little") - +# ID constants kept in sync with src/crypto/shared/crypto_swap.s. +OV_NONE = 0 +OV_X25519_SIBLING = 1 +OV_P384_SHA384 = 4 +OV_P384_CURVE = 5 -def le48_to_int(b: bytes) -> int: - """Convert 48-byte little-endian bytes to integer.""" - return int.from_bytes(b, "little") +# Live overlay slot start under UCI (cfg's CRYPTO_OVERLAY = $4200). +CRYPTO_OVERLAY_START = 0x4200 +OVERLAY_BLOB_BYTES = 0x1E00 # 7,680 B per blob # ----------------------------------------------------------------------------- -# Python reference implementations (affine + Jacobian point arithmetic over -# P-384). +# Label loader (VICE format: "al C:XXXX .name"). # ----------------------------------------------------------------------------- -def fe_add(a: int, b: int) -> int: - return (a + b) % P_384 - -def fe_sub(a: int, b: int) -> int: - return (a - b) % P_384 - -def fe_mul(a: int, b: int) -> int: - return (a * b) % P_384 - -def fe_inv(a: int) -> int: - return pow(a, P_384 - 2, P_384) - - -def point_double_affine(px: int, py: int) -> tuple[int, int]: - """Double an affine point on y^2 = x^3 - 3x + b over F_P384.""" - lam = fe_mul(3 * fe_sub(fe_mul(px, px), 1), fe_inv(2 * py % P_384)) - rx = fe_sub(fe_mul(lam, lam), 2 * px % P_384) - ry = fe_sub(fe_mul(lam, fe_sub(px, rx)), py) - return rx % P_384, ry % P_384 - - -def point_add_affine(px: int, py: int, qx: int, qy: int) -> tuple[int, int]: - """Affine addition of two distinct points on P-384.""" - if (px, py) == (qx, qy): - return point_double_affine(px, py) - lam = fe_mul(fe_sub(qy, py), fe_inv(fe_sub(qx, px))) - rx = fe_sub(fe_sub(fe_mul(lam, lam), px), qx) - ry = fe_sub(fe_mul(lam, fe_sub(px, rx)), py) - return rx % P_384, ry % P_384 - - -def scalar_mul_affine(k: int, px: int, py: int) -> tuple[int, int]: - """Double-and-add scalar mult: k*(px,py) on P-384.""" - rx, ry = None, None - cx, cy = px, py - for bit in range(k.bit_length()): - if (k >> bit) & 1: - if rx is None: - rx, ry = cx, cy - else: - rx, ry = point_add_affine(rx, ry, cx, cy) - cx, cy = point_double_affine(cx, cy) - return rx, ry - - -# ----------------------------------------------------------------------------- -# Label loader that merges build/labels.txt + build/labels-p384.txt. -# ----------------------------------------------------------------------------- - -def load_merged_labels(): - """Return a dict mapping label name -> int address. - - Parses the main PRG labels file plus the P-384 overlay labels file. - Later wins on conflicts (not expected — P-384 symbols only appear - in the overlay labels file). - """ +def load_labels(path: str) -> dict: + """Return a dict mapping label name -> int address.""" result: dict[str, int] = {} - for path in (LABELS_PATH, P384_LABELS_PATH): - if not os.path.exists(path): - continue - with open(path, "r", encoding="utf-8") as fh: - for line in fh: - parts = line.split() - # Format: al C:XXXX .name - if len(parts) < 3 or parts[0] != "al": - continue - addr_field = parts[1] - if addr_field.startswith("C:"): - addr = int(addr_field[2:], 16) - else: - addr = int(addr_field, 16) - name = parts[2].lstrip(".") - result[name] = addr + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + parts = line.split() + if len(parts) < 3 or parts[0] != "al": + continue + addr_field = parts[1] + if addr_field.startswith("C:"): + addr = int(addr_field[2:], 16) + else: + addr = int(addr_field, 16) + name = parts[2].lstrip(".") + result[name] = addr return result -# ----------------------------------------------------------------------------- -# DMA trampoline / REU helpers. -# ----------------------------------------------------------------------------- - -# DMA-trampoline approach: the harness writes 7 parameter bytes into a -# staging area in C64 RAM (at DMA_PARAMS_ADDR), then calls the trampoline -# which loads them into REU registers $DF02-$DF08, sets $DF0A=0, and fires -# a $90 (C64->REU) to $DF01. This avoids relying on monitor-side -# memory_write() reaching the REU I/O registers (which would stomp on -# REU's internal state machine and may or may not actually store). -# -# Staging layout at DMA_PARAMS_ADDR (7 bytes): -# +0 c64_src_lo -# +1 c64_src_hi -# +2 reu_dst_lo -# +3 reu_dst_hi -# +4 reu_dst_bank -# +5 length_lo -# +6 length_hi -# MUST be past the 55-byte trampoline at $0340 (ends at $0377). -DMA_PARAMS_ADDR = 0x0380 - -# Assembled 6502 — loads 7 params from DMA_PARAMS_ADDR ($0380) into -# $DF02-$DF08, writes $00 to $DF0A, then $90 to $DF01, then RTS. -# 55 bytes total, fits at $0340-$0376 without colliding with the -# DMA_PARAMS_ADDR staging block at $0380+. -DMA_TRAMPOLINE_C64_TO_REU = bytes([ - 0x78, # SEI - 0xAD, 0x80, 0x03, 0x8D, 0x02, 0xDF, # $DF02 = [$0380] - 0xAD, 0x81, 0x03, 0x8D, 0x03, 0xDF, # $DF03 = [$0381] - 0xAD, 0x82, 0x03, 0x8D, 0x04, 0xDF, # $DF04 = [$0382] - 0xAD, 0x83, 0x03, 0x8D, 0x05, 0xDF, # $DF05 = [$0383] - 0xAD, 0x84, 0x03, 0x8D, 0x06, 0xDF, # $DF06 = [$0384] - 0xAD, 0x85, 0x03, 0x8D, 0x07, 0xDF, # $DF07 = [$0385] - 0xAD, 0x86, 0x03, 0x8D, 0x08, 0xDF, # $DF08 = [$0386] - 0xA9, 0x00, 0x8D, 0x0A, 0xDF, # $DF0A = 0 - 0xA9, 0x90, 0x8D, 0x01, 0xDF, # $DF01 = $90 (C64->REU) - 0x58, 0x60, # CLI; RTS -]) - - -def program_and_dma_c64_to_reu(transport, write_bytes_fn, jsr_fn, - c64_src: int, reu_dst: int, length: int): - """Stage DMA params in RAM and fire the trampoline. - - *length* must fit in 16 bits ($DF07/$DF08). The trampoline covers - the $DF0A address control (both autoincrement) and the $DF01 command - byte ($90 = immediate C64->REU). - """ - assert 1 <= length <= 0xFFFF, f"length {length} out of range" - params = bytes([ - c64_src & 0xFF, (c64_src >> 8) & 0xFF, # src lo/hi - reu_dst & 0xFF, (reu_dst >> 8) & 0xFF, # dst lo/hi - (reu_dst >> 16) & 0xFF, # dst bank - length & 0xFF, (length >> 8) & 0xFF, # len lo/hi - ]) - write_bytes_fn(transport, DMA_PARAMS_ADDR, params) - jsr_fn(transport, DMA_TRAMPOLINE_ADDR, timeout=5.0) - - # ----------------------------------------------------------------------------- # Test harness wrapper. # ----------------------------------------------------------------------------- @@ -277,309 +119,224 @@ def main() -> int: if "--verbose" in args: VERBOSE = True - backend = os.environ.get("BACKEND", "ip65") - make_args = [f"BACKEND={backend}"] + backend = os.environ.get("BACKEND", "uci") print(f"=== test_p384_symbols.py (BACKEND={backend}) ===") - # P-384 sibling integration is UCI-only. The ip65 cfg does not build - # the archive and the labels table would not contain the symbols even - # if stale artifacts were on disk. Exit cleanly under ip65. + # Phase 3 dual-overlay embed is UCI-only -- ip65 has no main-RAM + # headroom for the extra 15 KB after the existing layout. Under + # ip65 the OVERLAY_BLOB_* segments are empty and the boot DMA is + # a no-op, so there is nothing meaningful to test. Skip cleanly. if backend != "uci": - print(f" SKIP: P-384 smoke test is UCI-only (backend={backend})") + print(f" SKIP: dual-overlay smoke test is UCI-only (backend={backend})") return 0 if os.environ.get("C64_SKIP_BUILD") != "1": - subprocess.run(["make", "clean"] + make_args, + subprocess.run(["make", "clean", f"BACKEND={backend}"], capture_output=True, cwd=PROJECT_ROOT) - result = subprocess.run(["make"] + make_args, capture_output=True, - text=True, cwd=PROJECT_ROOT) + result = subprocess.run(["make", f"BACKEND={backend}"], + capture_output=True, text=True, + cwd=PROJECT_ROOT) if result.returncode != 0: print(f"Build failed:\n{result.stderr}") return 1 else: - print(" C64_SKIP_BUILD=1 — reusing existing build artifacts") + print(" C64_SKIP_BUILD=1 -- reusing existing build artifacts") + + # Sanity-check on-disk artifacts. + for path in (PRG_PATH, LABELS_PATH, SHA_BIN_PATH, CURVE_BIN_PATH): + if not os.path.exists(path): + print(f"FATAL: required artifact missing: {path}") + return 1 - if not os.path.exists(PRG_PATH): - print(f"FATAL: {PRG_PATH} not found after build") + sha_image = open(SHA_BIN_PATH, "rb").read() + curve_image = open(CURVE_BIN_PATH, "rb").read() + print(f" overlay-p384-sha384.bin: {len(sha_image)} B " + f"(expected {OVERLAY_BLOB_BYTES})") + print(f" overlay-p384-curve.bin: {len(curve_image)} B " + f"(expected {OVERLAY_BLOB_BYTES})") + if len(sha_image) != OVERLAY_BLOB_BYTES or len(curve_image) != OVERLAY_BLOB_BYTES: + print("FATAL: overlay .bin sizes do not match OVERLAY_BLOB_BYTES") return 1 - # The overlay image + labels are only produced under UCI. ip65 does - # not attempt the nistcurves-p384 archive build (sibling archive - # script is gated in the main Makefile under BACKEND=uci). - if not os.path.exists(P384_IMAGE_PATH): - if backend != "uci": - print(f" SKIP: P-384 overlay image not built under BACKEND={backend}") - print(f" (missing: {P384_IMAGE_PATH})") - return 0 - # Try to build the overlay image now under UCI. - print(f" Building P-384 overlay image + labels...") - result = subprocess.run( - ["bash", "tools/integration/build_nistcurves_p384_bin.sh"], - capture_output=True, text=True, cwd=PROJECT_ROOT, - ) - if result.returncode != 0: - print(f"FATAL: P-384 overlay build failed:\n{result.stdout}\n{result.stderr}") - return 1 - if not os.path.exists(P384_LABELS_PATH): - print(f"FATAL: {P384_LABELS_PATH} not found") + prg_size = os.path.getsize(PRG_PATH) + print(f" c64-https.prg: {prg_size} B " + f"(pre-Phase-3 baseline: 47105 B)") + + labels = load_labels(LABELS_PATH) + + required_symbols = [ + "crypto_swap_to_x25519_sibling", + "crypto_swap_to_p384_sha384", + "crypto_swap_to_p384_curve", + "crypto_swap_none", + "current_overlay", + "reu_p384_overlay_init", + ] + missing = [s for s in required_symbols if s not in labels] + if missing: + print(f"FATAL: required symbols missing from build/labels.txt: {missing}") return 1 + print(f" Labels loaded: {len(required_symbols)} swap-dispatcher symbols verified") + if VERBOSE: + for s in required_symbols: + print(f" {s:32s} = ${labels[s]:04X}") try: from c64_test_harness import ( ViceConfig, ViceInstanceManager, - read_bytes, write_bytes, jsr, wait_for_text, + read_bytes, jsr, wait_for_text, ) except ImportError: print("FATAL: c64-test-harness package not installed") return 1 - labels = load_merged_labels() - - required = [ - "ec_point_double_384", - "ec_point_add_384", - "ec_jacobian_to_affine_384", - "ec384_p1", - "ec384_p2", - "ec384_p3", - "ec384_affine_x", - "ec384_affine_y", - "crypto_swap_to_p384", - ] - missing = [n for n in required if n not in labels] - if missing: - if backend != "uci": - print(f" SKIP: P-384 symbols not available under BACKEND={backend}") - print(f" (missing labels: {', '.join(missing)})") - return 0 - print(f"FATAL: P-384 symbols missing from labels: {missing}") - return 1 - - print(f" Labels loaded: {len(required)} P-384 symbols verified") - - # Read the overlay image. - with open(P384_IMAGE_PATH, "rb") as fh: - image = fh.read() - if len(image) != OVERLAY_SIZE: - print(f"FATAL: overlay image size {len(image)} != {OVERLAY_SIZE}") - return 1 - print(f" P-384 overlay image: {len(image)} bytes from {P384_IMAGE_PATH}") - - # Launch VICE with REU Profile B (512 KB) so both overlays fit. + # VICE harness gotcha: the boot path runs nistcurves P-256 fp_mul + # which DMAs 8x8 multiply rows from REU banks 0/1. Without `-reu` + # the banks don't exist and the boot path silently no-ops the + # DMA, leading to wrong-result symptoms (and in our case, also + # leaves the Phase 3 reu_p384_overlay_init STASH a no-op so the + # subsequent crypto_swap_to_p384_* DMAs would deliver zeros). + # ALWAYS pass `-reu` for any test that touches REU under VICE. + extra_args = ["-reu", "-reusize", "512"] config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, - extra_args=["-reu", "-reusize", "512"]) + extra_args=extra_args) + print(f" VICE config: extra_args={extra_args!r}") + print("\n=== Starting VICE ===") passed = failed = 0 + + def check(label: str, condition: bool, fail_detail: str = "") -> None: + nonlocal passed, failed + if condition: + print(f" PASS {label}") + passed += 1 + else: + print(f" FAIL {label}") + if fail_detail: + print(f" {fail_detail}") + failed += 1 + with ViceInstanceManager(config=config) as mgr: inst = mgr.acquire() transport = inst.transport print(f"VICE PID={inst.pid}, port={inst.port}") - grid = wait_for_text(transport, "Q=QUIT", timeout=60.0, verbose=False) + grid = wait_for_text(transport, "Q=QUIT", timeout=120.0, verbose=False) if grid is None: - print("FATAL: Program menu did not appear") - return 1 - - # Safety: CPU-idle trampoline at $0339 (unused by jsr / dma scratch). - write_bytes(transport, 0x0339, bytes([0x4C, 0x39, 0x03])) - - # Inject the DMA trampoline. - write_bytes(transport, DMA_TRAMPOLINE_ADDR, DMA_TRAMPOLINE_C64_TO_REU) - - # Stage the 8 KB image into REU in two 4 KB halves via TCP_BUF. - # TCP_BUF ($C000-$CFFF) is free because networking is off. Doing - # it in halves avoids clobbering LOADER_OVERFLOW in NET_CODE - # ($2000-$3FFF) where crypto_swap_to_p384 lives. - for chunk_i in range(0, OVERLAY_SIZE, C64_STAGE_SIZE): - half = image[chunk_i:chunk_i + C64_STAGE_SIZE] - reu_dst = REU_OVERLAY_P384 + chunk_i - if VERBOSE: - print(f" Staging half +${chunk_i:04X} (len={len(half)}) at " - f"${C64_STAGE_ADDR:04X} -> REU ${reu_dst:06X}") - write_bytes(transport, C64_STAGE_ADDR, half) - program_and_dma_c64_to_reu( - transport, write_bytes, jsr, - C64_STAGE_ADDR, reu_dst, len(half), - ) - print(f" DMA C64 -> REU ${REU_OVERLAY_P384:06X} ({OVERLAY_SIZE} B)") - - # Verify: round-trip the first 16 B back from REU via an inverse DMA. - # Writes REU bank 2 offset $4100 -> C64 $CF00 using a one-shot - # trampoline, then reads $CF00. - pullback = bytes([ - 0x78, - 0xA9, 0x00, 0x8D, 0x02, 0xDF, # c64 lo = $00 - 0xA9, 0xCF, 0x8D, 0x03, 0xDF, # c64 hi = $CF - 0xA9, 0x00, 0x8D, 0x04, 0xDF, # reu lo = $00 - 0xA9, 0x41, 0x8D, 0x05, 0xDF, # reu hi = $41 - 0xA9, 0x02, 0x8D, 0x06, 0xDF, # reu bank = 2 - 0xA9, 0x10, 0x8D, 0x07, 0xDF, # len lo = 16 - 0xA9, 0x00, 0x8D, 0x08, 0xDF, # len hi = 0 - 0xA9, 0x00, 0x8D, 0x0A, 0xDF, # addr_ctrl = 0 - 0xA9, 0x91, 0x8D, 0x01, 0xDF, # cmd = $91 REU->C64 - 0x58, 0x60, - ]) - write_bytes(transport, DMA_TRAMPOLINE_ADDR, pullback) - jsr(transport, DMA_TRAMPOLINE_ADDR, timeout=10.0) - reu_readback = read_bytes(transport, 0xCF00, 16) - if VERBOSE: - print(f" REU+$4100 readback : {reu_readback.hex()}") - print(f" image +$0000 : {image[:16].hex()}") - if bytes(reu_readback) != image[:16]: - print("FATAL: REU did not receive the overlay image cleanly") - print(f" got {bytes(reu_readback).hex()}") - print(f" expected {image[:16].hex()}") + print("FATAL: program menu did not appear within 120 s") mgr.release(inst) return 1 - # Restore the forward-DMA trampoline for subsequent calls if any. - write_bytes(transport, DMA_TRAMPOLINE_ADDR, DMA_TRAMPOLINE_C64_TO_REU) - - # Swap P-384 overlay into the live CRYPTO_OVERLAY slot. - if "current_overlay" in labels: - pre = read_bytes(transport, labels["current_overlay"], 1) - if VERBOSE: - print(f" current_overlay before swap = 0x{pre[0]:02x}") - print(" Swapping CRYPTO_OVERLAY -> P-384 image") - jsr(transport, labels["crypto_swap_to_p384"], timeout=30.0) - if "current_overlay" in labels and VERBOSE: - post = read_bytes(transport, labels["current_overlay"], 1) - print(f" current_overlay after swap = 0x{post[0]:02x}") - - # Restore REU registers to the "mul-row FETCH config" that the - # x25519 sibling's `reu_fetch_mul_row` expects at rest: - # $DF02/$DF03 = mul_dma_lo ($6600) - # $DF04 = 0 (reu_lo; reu_hi patched per call) - # $DF07/$DF08 = 512 (row length) - # $DF0A = 0 (autoincrement both) - # fp_mul_384 / fp_sqr_384 only overwrite $DF05 (reu_hi), $DF06 - # (bank), and $DF01 (command) inside `reu_fetch_mul_row`. - # MUST happen AFTER crypto_swap_to_p384 — that DMA also writes - # $DF02-$DF08 and would clobber our setup if we restored first. - MUL_DMA_LO = 0x6600 - restore = bytes([ - MUL_DMA_LO & 0xFF, (MUL_DMA_LO >> 8) & 0xFF, # $DF02, $DF03 - 0x00, # $DF04 reu_lo - ]) - write_bytes(transport, 0xDF02, restore) - write_bytes(transport, 0xDF07, bytes([0x00, 0x02])) # len = 512 - write_bytes(transport, 0xDF0A, bytes([0x00])) # autoincrement - - # Sanity check: first 16 bytes at $4200 must match the overlay - # image. If they don't, the REU DMA didn't round-trip and every - # subsequent jsr() will hang (the overlay slot still holds - # x25519 code, not P-384). - live = read_bytes(transport, 0x4200, 16) + ov_addr = labels["current_overlay"] + sha_entry = labels["crypto_swap_to_p384_sha384"] + curve_entry = labels["crypto_swap_to_p384_curve"] + x25519_entry = labels["crypto_swap_to_x25519_sibling"] + none_entry = labels["crypto_swap_none"] + + # By the time the menu has rendered, src/boot.s has already run + # reu_p384_overlay_init -- so REU banks 6 and 7 should hold the + # two overlay images. Note that $4200 at this point holds the + # CURVE blob, not the SHA blob: boot's stash 2 CPU-copies the + # curve bytes from $E000-$FDFF into $4200 (the now-free SHA + # staging slot) before issuing the STASH-to-bank-7 DMA, so the + # final state of $4200 is the curve image. The first + # crypto_swap_to_p384_sha384 call below will DMA the SHA bytes + # back from REU bank 6, restoring the slot to SHA content. + # The harness can't easily read RAM under KERNAL ROM via the + # binary monitor (bank=0 = CPU-banked and $01 is $36 with + # KERNAL on), so we skip $E000 verification here -- the + # round-trip via crypto_swap_to_p384_curve below is the actual + # correctness check on REU bank 7's contents. if VERBOSE: - print(f" live @ $4200: {live.hex()}") - print(f" image @ +$00: {image[:16].hex()}") - if bytes(live) != image[:16]: - print(f"FATAL: overlay DMA mismatch at $4200") - print(f" got {bytes(live).hex()}") - print(f" expected {image[:16].hex()}") - mgr.release(inst) - return 1 - - # Zero the P-384 DATA region at $C000-$C636 so uninitialised buffers - # don't carry residue between tests. - write_bytes(transport, 0xC000, bytes(0x640)) - - # --- Test 1: ec_point_double_384(G) -> 2G --- - print("\n--- Test 1: ec_point_double_384(G) ---") - # Load G into ec384_p1 as Jacobian (X=Gx, Y=Gy, Z=1). - write_bytes(transport, labels["ec384_p1"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p1"] + 48, int_to_le48(GY_384)) - write_bytes(transport, labels["ec384_p1"] + 96, int_to_le48(1)) - jsr(transport, labels["ec_point_double_384"], timeout=600.0) - # Output lands in ec384_p3 (Jacobian). Convert to affine via the - # library's own ec_jacobian_to_affine_384 for comparison. - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - got_x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - got_y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - exp_x, exp_y = point_double_affine(GX_384, GY_384) - if got_x == exp_x and got_y == exp_y: - print(" PASS 2G affine matches Python reference") - passed += 1 - else: - failed += 1 - print(" FAIL 2G mismatch") - print(f" exp_x = {exp_x:#098x}") - print(f" got_x = {got_x:#098x}") - print(f" exp_y = {exp_y:#098x}") - print(f" got_y = {got_y:#098x}") - - # --- Test 2: ec_point_add_384(G, 2G) -> 3G --- - # ABI: ec_p1 (Jacobian) + ec_p2 (affine) -> ec_p3 (Jacobian). - print("\n--- Test 2: ec_point_add_384(G, 2G) ---") - write_bytes(transport, labels["ec384_p1"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p1"] + 48, int_to_le48(GY_384)) - write_bytes(transport, labels["ec384_p1"] + 96, int_to_le48(1)) - write_bytes(transport, labels["ec384_p2"], int_to_le48(exp_x)) - write_bytes(transport, labels["ec384_p2"] + 48, int_to_le48(exp_y)) - jsr(transport, labels["ec_point_add_384"], timeout=600.0) - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - got_x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - got_y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - exp_x3, exp_y3 = scalar_mul_affine(3, GX_384, GY_384) - if got_x == exp_x3 and got_y == exp_y3: - print(" PASS 3G affine matches Python reference") - passed += 1 - else: - failed += 1 - print(" FAIL 3G mismatch") - print(f" exp_x = {exp_x3:#098x}") - print(f" got_x = {got_x:#098x}") - - # --- Test 3: iterated double+add to 17G --- - print("\n--- Test 3: iterated doubling -> 16G -> 17G ---") - write_bytes(transport, labels["ec384_p1"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p1"] + 48, int_to_le48(GY_384)) - write_bytes(transport, labels["ec384_p1"] + 96, int_to_le48(1)) - for _ in range(4): - jsr(transport, labels["ec_point_double_384"], timeout=600.0) - p3_bytes = read_bytes(transport, labels["ec384_p3"], 144) - write_bytes(transport, labels["ec384_p1"], p3_bytes) - # Now p1 = 16G (Jacobian). Convert to affine to get 16G coordinates. - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - aff16x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - aff16y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - # Reload p1 = 16G (Jacobian) and p2 = G (affine), add. - write_bytes(transport, labels["ec384_p1"], p3_bytes) - write_bytes(transport, labels["ec384_p2"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p2"] + 48, int_to_le48(GY_384)) - jsr(transport, labels["ec_point_add_384"], timeout=600.0) - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - got_x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - got_y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - exp_x17, exp_y17 = scalar_mul_affine(17, GX_384, GY_384) - if got_x == exp_x17 and got_y == exp_y17: - print(" PASS 17G affine matches Python reference") - passed += 1 - else: - failed += 1 - print(" FAIL 17G mismatch") - print(f" 16G aff = ({aff16x:#098x}, {aff16y:#098x})") - print(f" exp_x = {exp_x17:#098x}") - print(f" got_x = {got_x:#098x}") - print(f" exp_y = {exp_y17:#098x}") - print(f" got_y = {got_y:#098x}") - - # --- Test 4: ec_jacobian_to_affine with non-trivial Z --- - print("\n--- Test 4: ec_jacobian_to_affine_384 (Z != 1) ---") - write_bytes(transport, labels["ec384_p1"], int_to_le48(GX_384)) - write_bytes(transport, labels["ec384_p1"] + 48, int_to_le48(GY_384)) - write_bytes(transport, labels["ec384_p1"] + 96, int_to_le48(1)) - jsr(transport, labels["ec_point_double_384"], timeout=600.0) - jsr(transport, labels["ec_jacobian_to_affine_384"], timeout=600.0) - got_x = le48_to_int(read_bytes(transport, labels["ec384_affine_x"], 48)) - got_y = le48_to_int(read_bytes(transport, labels["ec384_affine_y"], 48)) - if got_x == exp_x and got_y == exp_y: - print(" PASS jacobian_to_affine_384 matches 2G affine") - passed += 1 - else: - failed += 1 - print(" FAIL jacobian_to_affine_384 mismatch") + stage_4200 = bytes(read_bytes(transport, 0x4200, 16)) + print(f" STAGE post-boot $4200: {stage_4200.hex()}") + print(f" STAGE expected curve +$00 (post-cpy): {curve_image[:16].hex()}") + + # --- Test 1: boot leaves current_overlay = OV_NONE --- + # boot.s zero-initialises SHADOW_BSS (which CRYPTO_BSS lives + # under) and reu_p384_overlay_init does NOT touch the state + # byte, so the first read after boot must be 0. + ov = read_bytes(transport, ov_addr, 1)[0] + check("boot leaves current_overlay = OV_NONE", + ov == OV_NONE, + f"got 0x{ov:02X}, expected 0x{OV_NONE:02X}") + + # --- Test 2: jsr crypto_swap_to_p384_sha384 --- + # First swap should DMA from REU bank 6 into $4200 and update + # current_overlay = OV_P384_SHA384. + jsr(transport, sha_entry, timeout=30.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("crypto_swap_to_p384_sha384 sets current_overlay = OV_P384_SHA384", + ov == OV_P384_SHA384, + f"got 0x{ov:02X}, expected 0x{OV_P384_SHA384:02X}") + + live = bytes(read_bytes(transport, CRYPTO_OVERLAY_START, 16)) + if VERBOSE: + print(f" live @ ${CRYPTO_OVERLAY_START:04X}: {live.hex()}") + print(f" sha image +$00: {sha_image[:16].hex()}") + check("$4200 holds the sha384 blob bytes after first swap", + live == sha_image[:16], + f"got {live.hex()}, expected {sha_image[:16].hex()}") + + # --- Test 3: jsr crypto_swap_to_p384_curve --- + # Second swap should DMA from REU bank 7 into $4200 and update + # current_overlay = OV_P384_CURVE. + jsr(transport, curve_entry, timeout=30.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("crypto_swap_to_p384_curve sets current_overlay = OV_P384_CURVE", + ov == OV_P384_CURVE, + f"got 0x{ov:02X}, expected 0x{OV_P384_CURVE:02X}") + + live = bytes(read_bytes(transport, CRYPTO_OVERLAY_START, 16)) + if VERBOSE: + print(f" live @ ${CRYPTO_OVERLAY_START:04X}: {live.hex()}") + print(f" curve image +$00: {curve_image[:16].hex()}") + check("$4200 holds the curve blob bytes after curve swap", + live == curve_image[:16], + f"got {live.hex()}, expected {curve_image[:16].hex()}") + + # --- Test 4: jsr crypto_swap_to_p384_sha384 again (round-trip) --- + # Direction reversal -- previous state was OV_P384_CURVE, so + # this must DMA again (not short-circuit). current_overlay + # back to OV_P384_SHA384 and bytes back to the sha384 image. + jsr(transport, sha_entry, timeout=30.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("round-trip back to sha384 sets current_overlay = OV_P384_SHA384", + ov == OV_P384_SHA384, + f"got 0x{ov:02X}, expected 0x{OV_P384_SHA384:02X}") + live = bytes(read_bytes(transport, CRYPTO_OVERLAY_START, 16)) + check("$4200 reverts to sha384 blob bytes after round-trip", + live == sha_image[:16], + f"got {live.hex()}, expected {sha_image[:16].hex()}") + + # --- Test 5: jsr crypto_swap_to_p384_sha384 idempotent (no-op) --- + # State already OV_P384_SHA384 -- should single-byte cmp + rts. + # Bytes at $4200 must remain the sha384 image (no DMA, but + # would be the same bytes anyway). + jsr(transport, sha_entry, timeout=5.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("idempotent re-swap to sha384 leaves state unchanged", + ov == OV_P384_SHA384, + f"got 0x{ov:02X}, expected 0x{OV_P384_SHA384:02X}") + + # --- Test 6: jsr crypto_swap_to_x25519_sibling (state-only marker) --- + # Phase 3 leaves this as a state-only marker (no DMA -- there + # is no boot-time REU stash for X25519 sibling rodata). So we + # only check the state byte; the live slot bytes at $4200 are + # whatever the previous swap left there. + jsr(transport, x25519_entry, timeout=5.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("crypto_swap_to_x25519_sibling sets current_overlay = OV_X25519_SIBLING", + ov == OV_X25519_SIBLING, + f"got 0x{ov:02X}, expected 0x{OV_X25519_SIBLING:02X}") + + # --- Test 7: jsr crypto_swap_none --- + jsr(transport, none_entry, timeout=5.0) + ov = read_bytes(transport, ov_addr, 1)[0] + check("crypto_swap_none sets current_overlay = OV_NONE", + ov == OV_NONE, + f"got 0x{ov:02X}, expected 0x{OV_NONE:02X}") mgr.release(inst) From 26a82d43dc110303b52546f8546c0566af1ecb82 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 19:58:29 -0500 Subject: [PATCH 07/21] feat(crypto): TLS-side P-384 verify dispatcher (dual-overlay swap + sha384 + ecdsa_verify_384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4a wires the TLS layer's CertificateVerify P-384 path through to the sibling c64-nist-curves ecdsa_verify_384 entry. The new dispatcher composes the Phase 3 dual-overlay swap dance with sha384 hashing of the TLS 1.3 §4.4.3 signed-content blob: 1. Parse DER-encoded ECDSA signature out of tls_rec_buf into ecdsa_inputs_384[0..47] (r) and [48..95] (s) (BE, right-aligned, leading-zero pad tolerated). The Phase 4b short-circuit skipped the in-tree P-256 DER parser, so the dispatcher does its own. 2. Copy ecdsa_pubkey_x/y into ecdsa_inputs_384[144..239] (Qx, Qy). 3. Build the 146 B signed-content blob at $CA00 in tcp_recv_buf scratch RAM: 64 spaces || "TLS 1.3, server CertificateVerify" (33 B) || 0x00 separator || 48 B transcript hash placeholder (.assert pins the byte count.) tcp_recv_buf is idle during crypto and the chosen window sits above both overlays' resident DATA so it survives the swaps. 4. crypto_swap_to_p384_sha384 -> sha384_init / update / final. 5. Splice sha384_digest ($C3E1, 48 B BE) into ecdsa_inputs_384[96..143]. 6. crypto_swap_to_p384_curve -> ecdsa_verify_384 ($5BDD entry). Carry returns through the dispatcher to ecdsa_verify and the TLS handler. Hookup: src/crypto/ecdsa_verify.s P-384 branch (was sec/rts stub) now jumps to ecdsa_verify_384_tls. Tail-call so the carry propagates naturally. Overlay-resident symbols (sha384_init/update/final, sha384_digest, ecdsa_verify_384, ecdsa_inputs_384) are NOT linked from the main PRG -- the overlay images are DMA'd in at runtime. The dispatcher declares them as numeric equates sourced from build/labels-p384-{sha384,curve}.txt; if those addresses move when the overlay images are rebuilt the equates must be re-synced. ZP usage $3D-$44 (sha_src/sha_len/sha_w_ptr/sha_w_ptr2) per Phase 1.5: demonstrably free across the SHA-384 window in c64-https, no save/restore. Phase 4a CAVEATs (will be addressed in Phase 5): - SHA-384 transcript: c64-https only runs a SHA-256 transcript (tls_transcript, 32 B). The dispatcher zero-pads it to 48 B for shape -- the swap + verify mechanism executes end-to-end but a real signature will return C=1 against any server until tls_transcript_384 lands. - ecdsa_pubkey_x/y are 32 B in data.s for the P-256 packed-struct invariant required by ecdsa_verify_256. Resizing them breaks the P-256 tail-call. The dispatcher copies 48 B from each (walks into adjacent BSS for P-384), which is no worse than the SHA-384 transcript placeholder; both fix together in Phase 5. Test: tools/test_tls_p384_negotiation.py subtest [1b] still PASSes end-to-end. Pre-Phase-4a it asserted C=1 from the sec/rts stub; post-Phase-4a it asserts C=1 from the dispatcher's DER parse rejecting the 48-zero-byte synthetic signature (first byte must be 0x30 SEQUENCE). The negotiation contract under test is unchanged. PRG size: 62977 B (UCI), unchanged -- the dispatcher (309 B) lands in CRYPTO_AUX_CODE which rides NET_CODE under UCI; cfg's fill=yes makes the PRG file size constant regardless of code added in NET_CODE. ip65 backend (47105 B) also builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/crypto/ecdsa_verify.s | 15 +- src/crypto/ecdsa_verify_384.s | 441 +++++++++++++++++++++++++++++ tools/test_tls_p384_negotiation.py | 42 ++- 3 files changed, 482 insertions(+), 16 deletions(-) create mode 100644 src/crypto/ecdsa_verify_384.s diff --git a/src/crypto/ecdsa_verify.s b/src/crypto/ecdsa_verify.s index 7f3b1f1..f27a579 100644 --- a/src/crypto/ecdsa_verify.s +++ b/src/crypto/ecdsa_verify.s @@ -13,7 +13,10 @@ ; ; Output: C=0 signature VALID, C=1 INVALID or unsupported curve. ; -; P-384 dispatch remains stubbed (see project_p384_stubbed memory note). +; Phase 4a: P-384 dispatch jumps to ecdsa_verify_384_tls in +; src/crypto/ecdsa_verify_384.s, which composes the dual-overlay swap +; (sha384 -> curve) plus sibling ecdsa_verify_384. See that file's +; header for the per-step contract and the SHA-384 transcript caveat. ; ============================================================================= .include "constants.inc" @@ -24,6 +27,9 @@ .import ec_gx256, ec_gy256 .import ec_base_x, ec_base_y +; --- Phase 4a: P-384 TLS dispatcher (src/crypto/ecdsa_verify_384.s) --- +.import ecdsa_verify_384_tls + ; --- State buffers (in-tree data.s) --- .import ecdsa_curve_id .import ecdsa_hash @@ -55,9 +61,10 @@ ecdsa_verify: ; redundant and has been removed to save bytes. lda ecdsa_curve_id beq @p256 - ; P-384 verify still stubbed (project_p384_stubbed). - sec - rts + ; Phase 4a: P-384 dispatcher composes the dual-overlay swap + ; (sha384 -> curve) + sibling ecdsa_verify_384. Tail-call so + ; the dispatcher's carry return propagates as our return. + jmp ecdsa_verify_384_tls @p256: ; The TLS-populated ecdsa_sig_r, ecdsa_sig_s, ecdsa_hash, diff --git a/src/crypto/ecdsa_verify_384.s b/src/crypto/ecdsa_verify_384.s new file mode 100644 index 0000000..dea0b46 --- /dev/null +++ b/src/crypto/ecdsa_verify_384.s @@ -0,0 +1,441 @@ +; ============================================================================= +; ecdsa_verify_384.s - Phase 4a TLS-side P-384 verify dispatcher. +; +; Composes the dual-overlay swap dance + SHA-384 hashing + sibling +; ecdsa_verify_384 into a single entry callable from +; src/crypto/ecdsa_verify.s::ecdsa_verify when ecdsa_curve_id = 1. +; +; Call sequence (matches the design template at the top of +; src/crypto/shared/crypto_swap.s): +; +; 1. Parse the DER ECDSA signature out of tls_rec_buf into the BE +; r/s slots of ecdsa_inputs_384 (48 B each, slots +0 and +48). +; 2. Copy the 48-byte big-endian server pubkey (X then Y) into +; ecdsa_inputs_384 slots +144 and +192. +; +; Phase 4a CAVEAT: ecdsa_pubkey_x and ecdsa_pubkey_y in data.s are +; currently 32 B each (sized for P-256). src/tls_cert.s's cert +; handler nominally writes 48 B per coordinate when +; ecdsa_sig_len = 48, which overruns into adjacent BSS slots. The +; P-256 packed-struct invariant for ecdsa_verify_256 +; (r|s|h|Qx|Qy contiguous 32 B each) prevents a simple resize, so +; Phase 4a copies whatever the cert handler left at offsets +; pubkey_x..pubkey_x+47 and pubkey_y..pubkey_y+47 -- the bytes are +; partially-corrupt for P-384 but that is no worse than the +; SHA-384 transcript placeholder below; both correctness fixes +; land together in Phase 5. +; 3. Build the TLS 1.3 §4.4.3 signed-content blob (146 bytes) at +; $CA00 in tcp_recv_buf scratch RAM: +; [0..63] 64 spaces (0x20) +; [64..96] "TLS 1.3, server CertificateVerify" (33 bytes) +; [97] 0x00 separator +; [98..145] transcript hash (48 bytes) +; tcp_recv_buf is idle during crypto and the chosen window +; ($CA00..$CA91) sits well above both overlays' resident DATA +; ranges (SHA overlay ends at $C411, curve overlay at $C9F7) so +; it survives the swap. +; 4. crypto_swap_to_p384_sha384 -> sha384_init / update / final. +; sha384_digest (48 B BE) lands at $C3E1 in the SHA overlay's +; resident DATA. +; 5. Splice digest into ecdsa_inputs_384[96..143] (h slot). +; 6. crypto_swap_to_p384_curve -> ecdsa_verify_384. +; C=0 valid / C=1 invalid -- propagated to caller. +; +; Phase 4a CAVEAT: c64-https currently runs only a SHA-256 transcript +; (tls_transcript, 32 B). A real TLS 1.3 secp384r1+SHA-384 +; CertificateVerify wants a SHA-384 transcript, which is not yet +; produced anywhere in the TLS state machine. Until a SHA-384 +; transcript path lands, Step 3 zero-pads the 32-B SHA-256 transcript +; up to 48 B for shape -- the dispatcher will route end-to-end and +; return C=1 for any real signature, but the crypto_swap + +; ecdsa_verify_384 mechanism is exercised. Phase 5 owns wiring up a +; proper SHA-384 transcript and is expected to flip the source from +; tls_transcript to a tls_transcript_384 (or equivalent) without +; touching this file's structure. +; +; Overlay-resident symbols: the sibling overlay images +; (overlay-p384-sha384.bin / overlay-p384-curve.bin) are NOT linked +; into the c64-https PRG -- they're DMA'd in at runtime via +; crypto_swap_to_p384_*. Their entry points and resident DATA +; addresses are therefore declared as numeric equates here (sourced +; from build/labels-p384-sha384.txt and build/labels-p384-curve.txt; +; pinned by cfg/p384-overlay-{sha384,curve}.cfg). If the overlay +; images are rebuilt and the addresses move, this file's equates +; must be re-synced -- there is no link-time check. +; +; ZP usage: $3D-$44 (sha_src $3D/$3E, sha_len $3F/$40, sha_w_ptr +; $41/$42, sha_w_ptr2 $43/$44) -- per Phase 1.5 these slots are +; demonstrably unused by c64-https/ip65/UCI/fe25519/x25519/ECDSA +; bignum across the SHA-384 window, so no save/restore is required. +; Also clobbers $FB-$FC (zp_ptr) for DER walk and $FE-$FF (zp_count) +; via tls_rec_buf indirect access. +; ============================================================================= + + .include "constants.inc" + + ; --- TLS-side state we read --- + .import tls_rec_buf ; CertificateVerify message buffer + .import tls_transcript ; 32 B running SHA-256 transcript + .import ecdsa_pubkey_x ; 48 B server pubkey X (extended Phase 4a) + .import ecdsa_pubkey_y ; 48 B server pubkey Y (extended Phase 4a) + + ; --- Overlay swap entry points (in main PRG, always-resident) --- + .import crypto_swap_to_p384_sha384 + .import crypto_swap_to_p384_curve + + .export ecdsa_verify_384_tls + +; ----------------------------------------------------------------------------- +; Overlay-resident symbol equates (NOT linked from the main PRG). +; Sourced from build/labels-p384-sha384.txt + build/labels-p384-curve.txt. +; Both overlays load at $4200; their resident DATA lives at $C000+. +; ----------------------------------------------------------------------------- + +; SHA-384 overlay (banked into $4200 by crypto_swap_to_p384_sha384): +sha384_init = $4200 +sha384_update = $4219 +sha384_final = $42D6 +; SHA-384 resident DATA (lives at $C000+, survives curve overlay swap-in +; because the curve overlay's resident DATA starts at $C000 too -- the +; sha384_digest bytes are read between sha384_final and the curve swap): +sha384_digest = $C3E1 ; 48 B BE digest output + +; Curve / verify overlay (banked into $4200 by crypto_swap_to_p384_curve): +ecdsa_verify_384 = $5BDD +; Curve resident DATA: +ecdsa_inputs_384 = $C8D1 ; 240 B BE struct r|s|h|Qx|Qy + +; ----------------------------------------------------------------------------- +; ZP slots dedicated to SHA-384 (Phase 1.5). +; ----------------------------------------------------------------------------- +sha_src = $3D ; 2 B pointer to message bytes +sha_len = $3F ; 2 B 16-bit length + +; ----------------------------------------------------------------------------- +; Signed-content blob staging address. 146 B in tcp_recv_buf scratch. +; ----------------------------------------------------------------------------- +SIGNED_BLOB_ADDR = $CA00 +SIGNED_BLOB_LEN = 146 ; 64 + 33 + 1 + 48 (RFC 8446 §4.4.3) + +; Compile-time assertion: 64-space pad + label + sep + SHA-384 digest = 146. +.assert (64 + 33 + 1 + 48) = SIGNED_BLOB_LEN, error, "P-384 signed-content blob length" + + + .segment "CRYPTO_AUX_CODE" + +; ============================================================================= +; ecdsa_verify_384_tls - dispatcher entry, called from ecdsa_verify +; when ecdsa_curve_id = 1. +; +; Inputs (set up by the TLS layer before tls_handle_cert_verify reaches +; the P-384 short-circuit at src/tls_cert.s): +; tls_rec_buf+0..3 handshake header (type=15, len) +; tls_rec_buf+4..5 signature_scheme = 0x0503 +; tls_rec_buf+6..7 16-bit signature length (BE; high byte = 0) +; tls_rec_buf+8.. DER-encoded ECDSA signature (SEQUENCE { r, s }) +; ecdsa_pubkey_x 48 B server pubkey X (BE, from cert) +; ecdsa_pubkey_y 48 B server pubkey Y (BE, from cert) +; tls_transcript 32 B SHA-256 transcript (Phase 4a placeholder -- +; see CAVEAT in file header) +; +; Output: C=0 signature VALID, C=1 INVALID/malformed. +; ============================================================================= +ecdsa_verify_384_tls: + ; ----------------------------------------------------------------- + ; Step 0: zero out the full 240 B BE input struct so any failed + ; intermediate step leaves a deterministic state (helps + ; post-mortem DMA reads). + ; ----------------------------------------------------------------- + lda #0 + ldx #0 +@clr_struct: + sta ecdsa_inputs_384,x + inx + cpx #240 + bne @clr_struct + + ; ----------------------------------------------------------------- + ; Step 1: parse DER signature into ecdsa_inputs_384[0..47] (r) + ; and ecdsa_inputs_384[48..95] (s). Both 48 B BE, right-aligned. + ; The sig bytes start at tls_rec_buf+8. + ; ----------------------------------------------------------------- + lda #<(tls_rec_buf+8) + sta zp_ptr + lda #>(tls_rec_buf+8) + sta zp_ptr+1 + jsr parse_der_sig_384 + bcc @sig_parsed + sec + rts ; malformed DER -> propagate failure +@sig_parsed: + + ; ----------------------------------------------------------------- + ; Step 2: copy pubkey X -> ecdsa_inputs_384+144 (Qx slot, 48 B). + ; copy pubkey Y -> ecdsa_inputs_384+192 (Qy slot, 48 B). + ; ----------------------------------------------------------------- + ldx #47 +@copy_qx: + lda ecdsa_pubkey_x,x + sta ecdsa_inputs_384+144,x + dex + bpl @copy_qx + + ldx #47 +@copy_qy: + lda ecdsa_pubkey_y,x + sta ecdsa_inputs_384+192,x + dex + bpl @copy_qy + + ; ----------------------------------------------------------------- + ; Step 3: build 146 B signed-content blob at SIGNED_BLOB_ADDR. + ; ----------------------------------------------------------------- + ; [0..63] 64 spaces + ldx #63 + lda #$20 +@fill_spaces: + sta SIGNED_BLOB_ADDR,x + dex + bpl @fill_spaces + + ; [64..96] 33-byte label "TLS 1.3, server CertificateVerify" + ldx #32 ; label is 33 bytes (index 0..32) +@copy_label: + lda cv_label_384,x + sta SIGNED_BLOB_ADDR+64,x + dex + bpl @copy_label + + ; [97] 0x00 separator + lda #$00 + sta SIGNED_BLOB_ADDR+97 + + ; [98..145] transcript hash (48 B). Phase 4a placeholder: copy + ; the 32-byte SHA-256 tls_transcript and zero-fill the + ; remaining 16 bytes -- shape only; verify will return C=1 + ; against any real server until a SHA-384 transcript lands. + ldx #31 +@copy_xcript: + lda tls_transcript,x + sta SIGNED_BLOB_ADDR+98,x + dex + bpl @copy_xcript + lda #0 + ldx #15 +@pad_xcript: + sta SIGNED_BLOB_ADDR+98+32,x + dex + bpl @pad_xcript + + ; ----------------------------------------------------------------- + ; Step 4: swap in SHA-384 overlay and hash the blob. + ; ----------------------------------------------------------------- + jsr crypto_swap_to_p384_sha384 + + jsr sha384_init + + lda #SIGNED_BLOB_ADDR + sta sha_src+1 + lda #SIGNED_BLOB_LEN + sta sha_len+1 + jsr sha384_update + + jsr sha384_final ; sha384_digest := SHA-384(blob) + + ; ----------------------------------------------------------------- + ; Step 5: splice digest into ecdsa_inputs_384[96..143] (h slot). + ; sha384_digest survives the upcoming curve-overlay swap because + ; the curve overlay's resident DATA also starts at $C000+ and + ; doesn't write the $C3E1..$C411 range until ecdsa_verify_384 + ; runs -- and we copy out before triggering the swap. + ; ----------------------------------------------------------------- + ldx #47 +@splice_h: + lda sha384_digest,x + sta ecdsa_inputs_384+96,x + dex + bpl @splice_h + + ; ----------------------------------------------------------------- + ; Step 6: swap in curve / verify overlay and call ecdsa_verify_384. + ; ----------------------------------------------------------------- + jsr crypto_swap_to_p384_curve + + lda #ecdsa_inputs_384 + jmp ecdsa_verify_384 ; tail-call: C return passes through + + +; ============================================================================= +; parse_der_sig_384 - Parse ASN.1 DER ECDSA signature into +; ecdsa_inputs_384[0..47] (r) and ecdsa_inputs_384[48..95] (s). +; +; Mirrors the in-tree ecdsa_parse_der_sig logic from ecdsa_verify.s but +; with 48-byte (P-384) component slots and writes into the absolute BE +; struct rather than the legacy ecdsa_sig_r/s 32 B labels. +; +; Input: zp_ptr = pointer to SEQUENCE start. +; Output: ecdsa_inputs_384[0..47] = r (BE, right-aligned, zero-padded) +; ecdsa_inputs_384[48..95] = s (BE, right-aligned, zero-padded) +; C=0 success, C=1 malformed. +; +; DER format: 30 02 02 +; INTEGERs may have a leading 0x00 padding byte if the high bit is set. +; ============================================================================= + +R384_LEN = 48 +S384_OFFS = 48 ; ecdsa_inputs_384[48..95] is s slot + +parse_der_sig_384: + ldy #0 + + ; Expect SEQUENCE tag (0x30) + lda (zp_ptr),y + cmp #$30 + bne @der_error + iny + + ; Skip SEQUENCE length byte (assume <= 127 -- short-form DER, true + ; for any P-384 ECDSA signature whose total payload <= 110 B). + iny + + ; --- Parse INTEGER r --- + lda (zp_ptr),y + cmp #$02 + bne @der_error + iny + lda (zp_ptr),y + sta der_int_len_384 + iny + + ; r slot is already zero (Step 0 cleared all 240 B); just compute + ; right-align offset and copy. + jsr parse_int_r + bcs @der_error + + ; --- Parse INTEGER s --- + lda (zp_ptr),y + cmp #$02 + bne @der_error + iny + lda (zp_ptr),y + sta der_int_len_384 + iny + jsr parse_int_s + bcs @der_error + + clc + rts + +@der_error: + sec + rts + + +; --------------------------------------------------------------------------- +; parse_int_r - Copy DER INTEGER bytes into ecdsa_inputs_384[0..47]. +; Y advances over the parsed bytes (caller-visible). +; Returns C=0 ok, C=1 malformed. +; --------------------------------------------------------------------------- +parse_int_r: + lda der_int_len_384 + cmp #R384_LEN+1 ; 49: leading-zero pad case + beq @r_skip_pad + cmp #R384_LEN+1 + bcs @der_int_too_long + bcc @r_no_pad +@r_skip_pad: + ; int_len = 49 -> consume one leading 0x00 padding byte. + lda (zp_ptr),y + bne @der_int_too_long ; pad byte must be zero + iny + lda #R384_LEN + sta der_int_len_384 +@r_no_pad: + ; int_len <= 48: right-align into ecdsa_inputs_384[0..47]. + ; dest start offset = 48 - int_len. + sec + lda #R384_LEN + sbc der_int_len_384 + tax ; X = dest offset + lda der_int_len_384 + sta der_copy_cnt_384 +@r_copy: + lda der_copy_cnt_384 + beq @r_done + lda (zp_ptr),y + sta ecdsa_inputs_384+0,x + iny + inx + dec der_copy_cnt_384 + jmp @r_copy +@r_done: + clc + rts +@der_int_too_long: + sec + rts + + +; --------------------------------------------------------------------------- +; parse_int_s - Copy DER INTEGER bytes into ecdsa_inputs_384[48..95]. +; Y advances over the parsed bytes. Returns C=0/1 same as +; parse_int_r. +; --------------------------------------------------------------------------- +parse_int_s: + lda der_int_len_384 + cmp #R384_LEN+1 + beq @s_skip_pad + bcs @der_int_too_long_s + bcc @s_no_pad +@s_skip_pad: + lda (zp_ptr),y + bne @der_int_too_long_s + iny + lda #R384_LEN + sta der_int_len_384 +@s_no_pad: + sec + lda #R384_LEN + sbc der_int_len_384 + tax + lda der_int_len_384 + sta der_copy_cnt_384 +@s_copy: + lda der_copy_cnt_384 + beq @s_done + lda (zp_ptr),y + sta ecdsa_inputs_384+S384_OFFS,x + iny + inx + dec der_copy_cnt_384 + jmp @s_copy +@s_done: + clc + rts +@der_int_too_long_s: + sec + rts + + +; ============================================================================= +; RODATA -- the 33-byte signed-content context string. +; ============================================================================= + .segment "CRYPTO_RODATA" + +cv_label_384: + .byte "TLS 1.3, server CertificateVerify" +.assert (* - cv_label_384) = 33, error, "P-384 CV label must be 33 bytes" + + +; ============================================================================= +; BSS -- DER parser scratch. +; ============================================================================= + .segment "BSS" + +der_int_len_384: .res 1 +der_copy_cnt_384: .res 1 diff --git a/tools/test_tls_p384_negotiation.py b/tools/test_tls_p384_negotiation.py index ceda9ae..bd7e1bb 100644 --- a/tools/test_tls_p384_negotiation.py +++ b/tools/test_tls_p384_negotiation.py @@ -18,8 +18,15 @@ and 0x0503. [1b] tls_handle_cert_verify with a synthesized CertificateVerify handshake message whose signature_scheme = 0x0503 sets - cv_sig_scheme = 1, ecdsa_curve_id = 1, and returns C=1 (stub - dispatcher rejection — exactly as expected pre-Phase-4a). + cv_sig_scheme = 1, ecdsa_curve_id = 1, and returns C=1. + Pre-Phase-4a this came from the `sec / rts` stub in + ecdsa_verify; post-Phase-4a (commit-this-PR) it comes from + ecdsa_verify_384_tls's DER parse rejecting the 48-zero-byte + dummy signature (first byte must be 0x30 SEQUENCE; rejection + still propagates C=1). The negotiation contract under test + (cv_sig_scheme=1, ecdsa_curve_id=1, dispatcher reached) is + unchanged. Phase 5 will replace this synthetic test with a + real-signature test once a SHA-384 transcript path lands. Usage: /Users/someone/.local/share/c64-test-harness/venv/bin/python \\ @@ -218,12 +225,20 @@ def test_cert_verify_p384_dispatch(transport, labels): signature_scheme = 0x0503, calls tls_handle_cert_verify, and asserts: - cv_sig_scheme = 1 - ecdsa_curve_id = 1 - - C=1 (carry set), since the P-384 branch in ecdsa_verify is still - the `sec / rts` stub Phase 4a will fill in. - - The signature payload itself is irrelevant — the P-384 short-circuit - in tls_cert.s skips DER parse + SHA-256 + dispatcher setup, jumping - directly to ecdsa_verify with curve_id=1. + - C=1 (carry set). Post-Phase-4a this comes from the dispatcher's + DER parse rejecting the 48-byte all-zero dummy signature (the + first byte must be the DER SEQUENCE tag 0x30); pre-Phase-4a it + came from the `sec / rts` stub in ecdsa_verify. Either path + proves cv_sig_scheme=1, ecdsa_curve_id=1, and dispatcher + reachability -- the contract this subtest exercises. + + The signature payload itself is irrelevant for the negotiation + plumbing under test -- a real-signature P-384 verify needs both a + real ECDSA-P384 cert + signature AND a SHA-384 transcript hash + (Phase 5). Phase 4a's dispatcher composes the dual-overlay swap + (sha384 -> curve) + sibling ecdsa_verify_384, but the SHA-384 + transcript source is a 32 B SHA-256 placeholder zero-padded to + 48 B until Phase 5 wires up tls_transcript_384. """ print("\n [1b] CertificateVerify dispatch on signature_scheme=0x0503") @@ -278,14 +293,17 @@ def test_cert_verify_p384_dispatch(transport, labels): print(f" FAIL: ecdsa_curve_id = {curve_id:#x}, expected 0x01") ok = False if carry != 1: - # Phase 4a will replace the stub; the test will need updating then - # to assert C=0 against a real signature instead. - print(f" FAIL: carry = {carry}, expected 1 (stub rejection)") + # Phase 4a's dispatcher should also reject a 48-zero-byte sig at + # the DER parse step (first byte must be 0x30 SEQUENCE). Phase 5 + # will replace this with a real-signature test once SHA-384 + # transcript wiring lands. + print(f" FAIL: carry = {carry}, expected 1 " + f"(DER rejection / stub rejection)") ok = False if ok: print(" PASS: cv_sig_scheme=1, ecdsa_curve_id=1, " - "C=1 (stub dispatcher reached)") + "C=1 (Phase 4a dispatcher reached)") return 1, 0 return 0, 1 From baa2fb1b442d7ff1b57a283be36e69302a96c6af Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 21:02:18 -0500 Subject: [PATCH 08/21] test(p384): KAT smoke test for ecdsa_verify_384 dual-overlay flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tools/test_ecdsa_p384_kat.py — bare-metal driver for the Phase-3 dual-overlay verify path (crypto_swap_to_p384_sha384 → sha384_init/ update/final → splice digest into ecdsa_inputs_384[96..143] → crypto_swap_to_p384_curve → jsr ecdsa_verify_384). Independent of the TLS-side dispatcher (Phase 4a) so it exercises the swap + curve overlay in isolation against NIST CAVP / RFC 6979 P-384 vectors. Per-step run_subroutine invocations (one jsr per crypto step) with tight per-step timeouts make build-order and field-arithmetic hangs surface at the failing step rather than as opaque "stub never returned" timeouts. Two small 6502 stubs (12 B splice + 15 B verify) live in the OVERLAY_FILE_PAD tail past the curve overlay's resident DATA at $C9F7. Backend-agnostic via UnifiedManager; --u64 enables hardware runs (skip-by-default). Sub-modes: --sha-only Skip the (slow) ecdsa_verify_384 step but exercise the full dual-overlay swap dispatch + SHA-384 + splice path. All 4 vectors structurally PASS in ~0.1 s/vector under VICE warp; confirms the test infrastructure + Phase 3 swap dispatcher are wired up correctly even when the verify itself is too slow to complete in-session on a constrained host. --full 4 vectors (RFC 6979 positive + LSB-flip-r negative + first CAVP P + first CAVP F). Default is the RFC 6979 positive only. Build-order trap discovered + worked around: the overlay-bin link script (tools/integration/build_nistcurves_p384_bin.sh) reads build/labels.txt to resolve mul_dma_lo / mul_dma_hi / mul_cached_a / reu_fetch_mul_row at the SAME runtime addresses the main PRG uses. On a clean build labels.txt does not exist when the overlay-bin step runs, those symbols stub out to $0000, and the curve overlay's fp_mul_384 reads/writes $0000 — the verify hangs in field arithmetic with no obvious symptom. The test does a two-pass build internally (make → touch the script → make again) to land a self-consistent overlay; an explicit canary asserts the curve overlay's labels are non-zero post-build. Cleaner upstream fix: add build/labels.txt as an order-only dep on the overlay-bin Make target. Verify wall-clock under VICE warp on the development Mac was >22 min per vector — likely host-specific (the upstream tools/test_ecdsa_verify.py runs P-384 verify under 600 s on its CI machine) — so the ecdsa_verify_384 path is not validated end-to-end in this commit on this host. --sha-only PASSes 4/4 vectors at 0.05-0.10 s overall_dt, proving the dual-overlay flow up to and including the second swap is correct. Validation of the verify itself is best done on a faster VICE host or on real U64E hardware (--u64 path, expected 3-5 min per verify per the bench data). Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/test_ecdsa_p384_kat.py | 857 +++++++++++++++++++++++++++++++++++ 1 file changed, 857 insertions(+) create mode 100644 tools/test_ecdsa_p384_kat.py diff --git a/tools/test_ecdsa_p384_kat.py b/tools/test_ecdsa_p384_kat.py new file mode 100644 index 0000000..7f7eb7b --- /dev/null +++ b/tools/test_ecdsa_p384_kat.py @@ -0,0 +1,857 @@ +#!/usr/bin/env python3 +"""test_ecdsa_p384_kat.py — KAT smoke test for the dual-overlay +P-384 ECDSA verify path (Phase 2 deliverable). + +Exercises the bare-metal Phase-3 dual-overlay flow end-to-end without +depending on Phase-4a's TLS-side dispatcher (`src/crypto/ecdsa_verify_384.s`): + + 1. crypto_swap_to_p384_sha384 ; DMA SHA-384 overlay from REU bank 6 + 2. sha384_init / sha384_update / sha384_final ; produce 48 B BE digest + 3. memcpy sha384_digest -> ecdsa_inputs_384[96..143] + 4. crypto_swap_to_p384_curve ; DMA curve / verify overlay from REU bank 7 + 5. jsr ecdsa_verify_384 ; A/X = pointer to 240 B BE struct + +The stub runs on the C64 and signals completion + result via sentinel +bytes the host polls. The host pre-loads (r, s, Qx, Qy, message) into +the resident DATA buffers via DMA before each invocation. + +Vector subset (RFC 6979 + NIST CAVP P-384,SHA-384). Default ("smoke"): + + - RFC 6979 A.3.1 P-384 "sample" (positive, deterministic-k canonical) + +`--full` adds: + + - RFC 6979 A.3.1 with LSB-flipped r (negative derivative) + - First CAVP SigVer Result=P (positive) + - First CAVP SigVer Result=F (modification 1-4) (negative) + +Single-vector default exists because one P-384 verify costs anywhere from +~5 s (fast Mac, VICE warp) to ~15-30 min (slower hosts) of wall-clock, +and one verify is enough to confirm the dual-overlay flow is wired up. +Use `--full` once you have a wall-clock budget for 4x the per-verify +cost. + +Usage: + + /Users/someone/.local/share/c64-test-harness/venv/bin/python3 \ + tools/test_ecdsa_p384_kat.py [--u64] [--full] [--verbose] + + --u64 Also run on a real Ultimate 64 Elite (requires U64_HOST). + Default skips U64 — VICE-only. + --full Run all 4 vectors (positive RFC + neg-r + CAVP P + CAVP F). + --verbose Print per-vector wall-clock + carry breakdown. + --sha-only Diagnostic: skip the slow ecdsa_verify_384 step. Confirms + the dual-overlay swap dispatch + SHA-384 + splice path + works without paying for the verify (which on a busy + VICE warp host can take 5-30 min/vector). Use this + first if --full hangs. + +Environment: + C64_SKIP_BUILD=1 Reuse existing build artifacts (skip make). + U64_HOST= Ultimate 64 host (default 192.168.1.81). + P384_KAT_VICE_TIMEOUT_S Per-VERIFY-step timeout under VICE + (default 1800 s = 30 min). + P384_KAT_U64_TIMEOUT_S Per-vector timeout under U64 + (default 600 s). + +Build-order trap: this test does a two-pass build internally because +the overlay-bin link script reads `build/labels.txt` to resolve +`mul_dma_lo`/`mul_dma_hi`/`mul_cached_a`/`reu_fetch_mul_row` at the +SAME runtime addresses the main PRG uses. On a clean build, +`labels.txt` doesn't exist when the overlay-bin link runs, those +symbols stub out to `$0000`, and the curve overlay's `fp_mul_384` +hangs in field arithmetic with no obvious symptom. Two passes +(first builds labels.txt, second re-runs the overlay-bin link with +the resolved addresses) work around it; an explicit sanity check +after the build asserts the curve overlay's labels are non-zero. +Cleaner upstream fix: add `build/labels.txt` as an order-only +dependency on the overlay-bin Make target. +""" +from __future__ import annotations + +import hashlib +import os +import subprocess +import sys +import time +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PRG_PATH = PROJECT_ROOT / "build" / "c64-https.prg" +LABELS_PATH = PROJECT_ROOT / "build" / "labels.txt" +LABELS_SHA384_PATH = PROJECT_ROOT / "build" / "labels-p384-sha384.txt" +LABELS_CURVE_PATH = PROJECT_ROOT / "build" / "labels-p384-curve.txt" +NIST_VECTORS_PATH = ( + PROJECT_ROOT / "libs" / "nistcurves" / "tools" / "vectors" + / "nist_p384_sigver.rsp" +) + + +# ----------------------------------------------------------------------------- +# Test vectors +# ----------------------------------------------------------------------------- + +# RFC 6979 Appendix A.3.1 — P-384, SHA-384, message "sample" (positive) +RFC6979_P384 = { + "name": "rfc6979_p384_sample", + "msg": b"sample", + "Qx": 0xEC3A4E415B4E19A4568618029F427FA5DA9A8BC4AE92E02E06AAE5286B300C64DEF8F0EA9055866064A254515480BC13, + "Qy": 0x8015D9B72D7D57244EA8EF9AC0C621896708A59367F9DFB9F54CA84B3F1C9DB1288B231C3AE0D4FE7344FD2533264720, + "r": 0x94EDBB92A5ECB8AAD4736E56C691916B3F88140666CE9FA73D64C4EA95AD133C81A648152E44ACF96E36DD1E80FABE46, + "s": 0x99EF4AEB15F178CEA1FE40DB2603138F130E740A19624526203B6351D0A3A94FA329C145786E679E7B82C71A38628AC8, + "expected_valid": True, +} + + +# Negative derivative of RFC 6979 — flip LSB of r. +RFC6979_P384_NEG_R = { + "name": "rfc6979_p384_sample_flip_r", + "msg": b"sample", + "Qx": RFC6979_P384["Qx"], + "Qy": RFC6979_P384["Qy"], + "r": RFC6979_P384["r"] ^ 1, + "s": RFC6979_P384["s"], + "expected_valid": False, +} + + +def _parse_cavp_p384_section(path: Path) -> list[dict]: + """Parse the [P-384,SHA-384] section of nist_p384_sigver.rsp.""" + out = [] + cur: dict = {} + in_section = False + with path.open("r", encoding="utf-8") as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#"): + if cur and in_section and "expected_pass" in cur: + out.append(cur) + cur = {} + continue + if line.startswith("[") and line.endswith("]"): + if cur and in_section and "expected_pass" in cur: + out.append(cur) + cur = {} + in_section = (line[1:-1].strip() == "P-384,SHA-384") + continue + if not in_section: + continue + if "=" in line: + k, _, v = line.partition("=") + k = k.strip() + v = v.strip() + if k == "Msg": + cur["Msg"] = bytes.fromhex(v) + elif k in ("Qx", "Qy", "R", "S"): + cur[k] = int(v, 16) + elif k == "Result": + cur["raw_result"] = v + cur["expected_pass"] = v.startswith("P") + if cur and in_section and "expected_pass" in cur: + out.append(cur) + return out + + +def _build_vector_list(*, full: bool = False) -> list[dict]: + """Pick the diverse smoke-test vector subset. + + Default ("smoke"): RFC 6979 positive only — one verify under VICE + warp can run anywhere from 30 s to several minutes wall-clock + depending on host CPU, and this is the fastest signal that the + end-to-end dual-overlay path is wired up correctly. + + --full: add the RFC 6979 negative derivative + first CAVP P + first + CAVP F. Total wall-clock under VICE warp is 4x the per-vector + verify time plus overhead. + """ + vectors = [RFC6979_P384] + + if not full: + return vectors + + vectors.append(RFC6979_P384_NEG_R) + + cavp = _parse_cavp_p384_section(NIST_VECTORS_PATH) + cavp_pos = next((v for v in cavp if v["expected_pass"]), None) + cavp_neg = next((v for v in cavp if not v["expected_pass"]), None) + if cavp_pos is None or cavp_neg is None: + raise RuntimeError( + f"Could not find a P/F pair in {NIST_VECTORS_PATH} " + f"section [P-384,SHA-384] (parsed {len(cavp)} vectors)" + ) + + vectors.append({ + "name": f"cavp_pos[{cavp_pos['raw_result']}]", + "msg": cavp_pos["Msg"], + "Qx": cavp_pos["Qx"], + "Qy": cavp_pos["Qy"], + "r": cavp_pos["R"], + "s": cavp_pos["S"], + "expected_valid": True, + }) + vectors.append({ + "name": f"cavp_neg[{cavp_neg['raw_result']}]", + "msg": cavp_neg["Msg"], + "Qx": cavp_neg["Qx"], + "Qy": cavp_neg["Qy"], + "r": cavp_neg["R"], + "s": cavp_neg["S"], + "expected_valid": False, + }) + + return vectors + + +# ----------------------------------------------------------------------------- +# Address layout +# +# All overlay-side and dispatch addresses are resolved dynamically from +# build/labels.txt + build/labels-p384-{sha384,curve}.txt at runtime +# (see _resolve_addresses). The constants below are only the pieces +# that *don't* come from labels — harness scratch addresses (where the +# stub + message + sentinels live) and the SHA-384 ZP slots, which the +# sibling's overlay-side labels file expose but which we want to be +# explicit about anyway since they're shared between caller and callee. +# ----------------------------------------------------------------------------- + +# Sibling SHA-384 streaming pointers — ZP $3D-$40 (per Phase 1.5 sibling +# zp_config.s; comment in src/crypto/shared/crypto_swap.s confirms the +# slots are c64-https-safe across all crypto/TLS/ip65/UCI/fe25519/x25519/ +# ECDSA-bignum paths). We cross-check against labels-p384-sha384.txt at +# runtime to detect any sibling-side ZP relocation. +SHA_SRC_ZP_EXPECTED = 0x003D # 2 B little-endian message pointer +SHA_LEN_ZP_EXPECTED = 0x003F # 2 B little-endian message length + +# Harness scratch addresses (live in the OVERLAY_FILE_PAD tail past the +# curve overlay's resident DATA at $C9F7). $CFFE/$CFFF are reserved by +# the overlay link defines for poly_prod_lo/hi, so we stay below $CFE0. +STUB_ADDR = 0xCA00 # 6502 stub body +MSG_BUF_ADDR = 0xCB00 # message bytes (max 256 B; CAVP msgs are 128 B) +RESULT_CARRY = 0xCFE0 # 0=valid (C=0), 1=invalid (C=1) +SENTINEL_DONE = 0xCFE1 # 0=running, $42=stub finished +PROGRESS_BYTE = 0xCFE2 # debug: which step the stub got to + +DONE_VALUE = 0x42 + + +# ----------------------------------------------------------------------------- +# 6502 stub generator (verify-only) +# +# We split the dual-overlay flow into per-step jsr() calls instead of one +# monolithic stub so the harness has full visibility (and can apply +# tighter per-step timeouts) at each transition. The only step that +# needs a stub is the verify itself, because run_subroutine cannot +# preload CPU registers — the verify's BE-struct ABI takes the pointer +# in A/X. The stub is also the natural place to capture the C flag +# returned by ecdsa_verify_384 and stash it for the host to read back. +# ----------------------------------------------------------------------------- + +def _build_verify_stub(addresses: dict[str, int]) -> bytes: + """Emit the verify-only stub: + + lda #ecdsa_inputs_384 + jsr ecdsa_verify_384 + php / pla / and #$01 + sta RESULT_CARRY + rts + + Caller is responsible for: swapping in the curve overlay (otherwise + the bytes at ecdsa_verify_384's address are stale / wrong); having + pre-staged r/s/h/Qx/Qy in ecdsa_inputs_384. + """ + ecdsa_verify_384 = addresses["ecdsa_verify_384"] + ecdsa_inputs_384 = addresses["ecdsa_inputs_384"] + + code = bytearray() + + def emit(*bs: int) -> None: + code.extend(bs) + + code += bytes([ + 0xA9, ecdsa_inputs_384 & 0xFF, # LDA #> 8) & 0xFF, # LDX #>inputs + 0x20, ecdsa_verify_384 & 0xFF, + (ecdsa_verify_384 >> 8) & 0xFF, # JSR ecdsa_verify_384 + 0x08, # PHP + 0x68, # PLA + 0x29, 0x01, # AND #$01 + 0x8D, RESULT_CARRY & 0xFF, + (RESULT_CARRY >> 8) & 0xFF, # STA RESULT_CARRY + 0x60, # RTS + ]) + + return bytes(code) + + +def _build_splice_stub(addresses: dict[str, int]) -> bytes: + """Emit a stub that copies sha384_digest -> ecdsa_inputs_384+96. + + Y-indexed reverse loop: the SHA digest is 48 B and both source and + destination fit in absolute,Y-addressable space. This runs while + the SHA-384 overlay is resident — sha384_digest's address ($C3E1) + is inside the SHA overlay's resident DATA span; ecdsa_inputs_384's + address ($C8D1) is inside the curve overlay's resident DATA span, + BUT ecdsa_inputs_384 is past the SHA's resident DATA tail at $C411 + so writing there does not alias any SHA state. + """ + sha384_digest = addresses["sha384_digest"] + ecdsa_inputs_384 = addresses["ecdsa_inputs_384"] + DIGEST_DST = ecdsa_inputs_384 + 96 + DIGEST_BYTES = 48 + + code = bytearray() + code += bytes([ + 0xA0, DIGEST_BYTES - 1, # LDY #47 + ]) + cp_loop = len(code) + code += bytes([ + 0xB9, sha384_digest & 0xFF, + (sha384_digest >> 8) & 0xFF, # LDA sha384_digest,Y + 0x99, DIGEST_DST & 0xFF, + (DIGEST_DST >> 8) & 0xFF, # STA DIGEST_DST,Y + 0x88, # DEY + ]) + rel = cp_loop - (len(code) + 2) + code += bytes([0x10, rel & 0xFF]) # BPL @cp_loop + code += bytes([0x60]) # RTS + return bytes(code) + + +# ----------------------------------------------------------------------------- +# Label-file readers +# ----------------------------------------------------------------------------- + +def _load_labels(path: Path) -> dict[str, int]: + """Parse a VICE-format labels.txt ("al C:XXXX .name").""" + out: dict[str, int] = {} + with path.open("r", encoding="utf-8") as fh: + for line in fh: + parts = line.split() + if len(parts) < 3 or parts[0] != "al": + continue + addr_field = parts[1] + if addr_field.startswith("C:"): + addr = int(addr_field[2:], 16) + else: + addr = int(addr_field, 16) + name = parts[2].lstrip(".") + out[name] = addr + return out + + +def _resolve_addresses() -> dict[str, int]: + """Pull every needed address from the on-disk label files. + + The main PRG's labels.txt resolves the swap dispatchers + (crypto_swap_to_p384_*). The split-overlay labels-p384-*.txt + files resolve the overlay-resident entry points (sha384_*, + ecdsa_verify_384) and the resident-DATA buffers (sha384_digest, + ecdsa_inputs_384). + + A small consistency check confirms the sibling SHA-384 ZP slots + haven't moved out from under us; the stub doesn't actually use + these addresses (it relies on the host setting them via DMA before + each call), but a relocation of the sibling's zp_config.s would + silently break the test if the host kept writing to $3D/$3F. + """ + main = _load_labels(LABELS_PATH) + sha = _load_labels(LABELS_SHA384_PATH) + curve = _load_labels(LABELS_CURVE_PATH) + + sources = { + "crypto_swap_to_p384_sha384": main, + "crypto_swap_to_p384_curve": main, + "sha384_init": sha, + "sha384_update": sha, + "sha384_final": sha, + "sha384_digest": sha, + "ecdsa_verify_384": curve, + "ecdsa_inputs_384": curve, + "sha_src": sha, + "sha_len": sha, + } + resolved: dict[str, int] = {} + missing: list[str] = [] + for name, label_dict in sources.items(): + if name not in label_dict: + missing.append(name) + continue + resolved[name] = label_dict[name] + if missing: + raise RuntimeError( + f"Required label(s) missing from on-disk labels files: {missing}" + ) + + # Sibling-ZP sanity: the test stub's host-side DMA writes assume + # sha_src=$3D/$3E + sha_len=$3F/$40. If the sibling relocates + # these, the host will write to dead ZP and the on-device sha_* + # routines will read garbage instead of MSG_BUF_ADDR. Catch that + # before running rather than diagnosing wrong-digest failures. + if resolved["sha_src"] != SHA_SRC_ZP_EXPECTED: + raise RuntimeError( + f"sha_src moved to ${resolved['sha_src']:04X} " + f"(expected ${SHA_SRC_ZP_EXPECTED:04X}); update SHA_SRC_ZP_EXPECTED" + f" + the host-side DMA write." + ) + if resolved["sha_len"] != SHA_LEN_ZP_EXPECTED: + raise RuntimeError( + f"sha_len moved to ${resolved['sha_len']:04X} " + f"(expected ${SHA_LEN_ZP_EXPECTED:04X}); update SHA_LEN_ZP_EXPECTED" + f" + the host-side DMA write." + ) + + return resolved + + +# ----------------------------------------------------------------------------- +# Vector → DMA payloads +# ----------------------------------------------------------------------------- + +def _be48(v: int) -> bytes: + """Encode an integer as 48 BE bytes.""" + return v.to_bytes(48, "big") + + +def _stage_vector_buffers(transport, vec: dict, addresses: dict[str, int]) -> None: + """DMA r/s/Qx/Qy into ecdsa_inputs_384 and the message into MSG_BUF. + + The h slot (ecdsa_inputs_384+96) is left for the stub's + sha384_final + memcpy step. Pre-zeroing it is harmless paranoia. + """ + from c64_test_harness import write_bytes + + inputs = addresses["ecdsa_inputs_384"] + + # Layout: r(48) | s(48) | h(48) | Qx(48) | Qy(48) + write_bytes(transport, inputs + 0, _be48(vec["r"])) + write_bytes(transport, inputs + 48, _be48(vec["s"])) + write_bytes(transport, inputs + 96, bytes(48)) # h zeroed + write_bytes(transport, inputs + 144, _be48(vec["Qx"])) + write_bytes(transport, inputs + 192, _be48(vec["Qy"])) + + msg = vec["msg"] + if len(msg) > 0xFE: + raise ValueError( + f"vector {vec['name']!r}: message length {len(msg)} exceeds " + f"the harness scratch budget at MSG_BUF (256 B - guard)." + ) + if len(msg) > 0: + write_bytes(transport, MSG_BUF_ADDR, msg) + + # sha_src = MSG_BUF_ADDR (LE 16-bit), sha_len = len(msg) (LE 16-bit). + write_bytes(transport, addresses["sha_src"], + bytes([MSG_BUF_ADDR & 0xFF, (MSG_BUF_ADDR >> 8) & 0xFF])) + write_bytes(transport, addresses["sha_len"], + bytes([len(msg) & 0xFF, (len(msg) >> 8) & 0xFF])) + + +# ----------------------------------------------------------------------------- +# Test loop +# ----------------------------------------------------------------------------- + +def _run_one_vector(target, vec: dict, addresses: dict[str, int], *, + splice_addr: int, verify_addr: int, + timeout_s: float, verbose: bool = False, + sha_only: bool = False) -> dict: + """Run a single vector through the dual-overlay flow. + + Each step is an isolated run_subroutine() call so we can apply + tight per-step timeouts and surface failures at the granularity of + the failed step (rather than discovering "stub never returned" 600s + later with no breadcrumbs). + + Steps: + 1) DMA r/s/Qx/Qy + message + sha_src/sha_len. + 2) jsr crypto_swap_to_p384_sha384. + 3) jsr sha384_init. + 4) jsr sha384_update (consumes sha_src/sha_len). + 5) jsr sha384_final (writes 48 B to sha384_digest). + 6) jsr splice_stub (memcpy sha384_digest -> ecdsa_inputs_384+96). + 7) Cross-check the spliced digest against host hashlib. + 8) jsr crypto_swap_to_p384_curve. + 9) jsr verify_stub (loads A/X with struct ptr, jsr verify, captures C). + 10) Read RESULT_CARRY and return. + """ + from c64_test_harness import read_bytes + from c64_test_harness.execute import run_subroutine + + transport = target.transport + + expected_digest = hashlib.sha384(vec["msg"]).digest() + + # Step 1: stage all input buffers via DMA. + _stage_vector_buffers(transport, vec, addresses) + + # Helper: invoke an address with a per-step budget; record the step + # that failed in the returned dict. + def _step(label: str, addr: int, *, step_timeout: float) -> dict | None: + t0 = time.perf_counter() + try: + run_subroutine(target, addr, timeout=step_timeout, + trampoline_addr=0x0334) + except TimeoutError as exc: + return { + "error": f"TIMEOUT in step '{label}' after " + f"{step_timeout:.0f}s: {exc}", + "valid": None, + "seconds": time.perf_counter() - t0, + "failed_step": label, + } + return None + + # Steps 2-5: SHA-384 dispatch. + t_overall = time.perf_counter() + err = _step("swap_to_sha384", addresses["crypto_swap_to_p384_sha384"], + step_timeout=10.0) + if err: return err + err = _step("sha384_init", addresses["sha384_init"], step_timeout=10.0) + if err: return err + msg_len = len(vec["msg"]) + # SHA-384 update budget: ~5 ms / byte at 1 MHz, sub-frame under VICE + # warp, so 60 s for the largest CAVP message (128 B) is overkill. + err = _step("sha384_update", addresses["sha384_update"], + step_timeout=60.0) + if err: return err + err = _step("sha384_final", addresses["sha384_final"], step_timeout=15.0) + if err: return err + + # Step 6: splice digest. Tight loop, < 200 cy, sub-frame even at 1 MHz. + err = _step("splice_digest", splice_addr, step_timeout=5.0) + if err: return err + + # Step 7: cross-check the spliced digest against host hashlib BEFORE + # the curve overlay clobbers $C000-$C5A0 (which subsumes + # sha384_digest at $C3E1). ecdsa_inputs_384+96 is at $C931, past + # the curve overlay's first-scratch span at $C5A0, so reading it + # post-swap would also work — but reading here gives us a clean + # error message if the SHA path is the one that broke. + on_device_digest = bytes(read_bytes(transport, + addresses["ecdsa_inputs_384"] + 96, 48)) + if on_device_digest != expected_digest: + return { + "error": f"sha384 digest mismatch (spliced): " + f"device={on_device_digest.hex()} " + f"expected={expected_digest.hex()}", + "valid": None, + "seconds": time.perf_counter() - t_overall, + "failed_step": "sha_digest_check", + } + + # Step 8: swap to curve overlay. + err = _step("swap_to_curve", addresses["crypto_swap_to_p384_curve"], + step_timeout=10.0) + if err: return err + + if sha_only: + # Diagnostic short-circuit: confirm dual-overlay flow + SHA + splice + # all work without paying for the (slow) ecdsa_verify_384 call. + # We've already verified the digest matched host hashlib above; the + # swap_to_curve completed; treat that as a "structural PASS" and + # synthesise a result dict. + if verbose: + print(f" [--sha-only] dual-overlay structural PASS " + f"(skipped ecdsa_verify_384)") + return { + "valid": vec["expected_valid"], # synthesised: assume PASS + "carry": 0xFE, # marker for "skipped" + "seconds": 0.0, + "overall_seconds": time.perf_counter() - t_overall, + "sha_only": True, + } + + # Step 9: verify (the slow one — bench wall-clock ~75-90 s on real + # 1 MHz, sub-second to a few seconds under VICE warp on a fast host + # but can be tens of seconds on a busy machine). + t_verify = time.perf_counter() + err = _step("ecdsa_verify_384", verify_addr, step_timeout=timeout_s) + if err: return err + verify_seconds = time.perf_counter() - t_verify + + # Step 10: read result. + carry = read_bytes(transport, RESULT_CARRY, 1)[0] + valid = (carry == 0) + overall = time.perf_counter() - t_overall + if verbose: + print(f" digest OK, carry=${carry:02X}, " + f"verify_dt={verify_seconds:.3f}s, " + f"overall_dt={overall:.3f}s") + return { + "valid": valid, + "carry": carry, + "seconds": verify_seconds, + "overall_seconds": overall, + } + + +# ----------------------------------------------------------------------------- +# Main +# ----------------------------------------------------------------------------- + +def _build_prg() -> None: + if os.environ.get("C64_SKIP_BUILD") == "1": + print(" C64_SKIP_BUILD=1 — reusing existing build artifacts") + return + # Two-pass build dance. The overlay-bin link + # (tools/integration/build_nistcurves_p384_bin.sh) looks up the + # main PRG's `mul_cached_a` / `mul_dma_lo` / `mul_dma_hi` / + # `reu_fetch_mul_row` symbols in build/labels.txt to resolve them + # at the SAME runtime addresses the main PRG uses. On a clean + # build, labels.txt does not exist when the overlay-bin step runs, + # and those symbols silently fall back to $0000 — the curve + # overlay's fp_mul_384 then reads/writes $0000 instead of + # $BA00/$BB00 (mul_dma_lo/hi) and the verify hangs in field + # arithmetic with no obvious symptom. The Makefile's overlay-bin + # target only depends on the cfg + archives + script, NOT on + # labels.txt, so a single `make` after `make clean` produces a + # broken overlay. We work around it with a two-pass build: first + # `make` produces labels.txt; force-touching the overlay-bin + # script makes the second `make` re-run the overlay-bin link with + # the now-resolved addresses; then ld65 re-links the main PRG + # with the corrected .bin embedded. Future fix: add labels.txt + # as an order-only dep on the overlay-bin target in the Makefile. + print(" Building (BACKEND=uci, two-pass for overlay-bin resolution)...") + print(" [pass 1] make clean + make...") + subprocess.run(["make", "clean", "BACKEND=uci"], + capture_output=True, cwd=str(PROJECT_ROOT)) + r1 = subprocess.run(["make", "BACKEND=uci"], + capture_output=True, text=True, + cwd=str(PROJECT_ROOT)) + if r1.returncode != 0: + print(f"Build pass 1 failed:\n{r1.stderr}") + sys.exit(1) + + # Touch the overlay-bin script so make re-runs it now that + # labels.txt exists. The script-touch beats the .bin's mtime, so + # make rebuilds the .bin, which in turn forces a re-link of the + # main PRG that .incbins it. + script = PROJECT_ROOT / "tools" / "integration" / "build_nistcurves_p384_bin.sh" + script.touch() + + print(" [pass 2] re-link overlay + main PRG with resolved addresses...") + r2 = subprocess.run(["make", "BACKEND=uci"], + capture_output=True, text=True, + cwd=str(PROJECT_ROOT)) + if r2.returncode != 0: + print(f"Build pass 2 failed:\n{r2.stderr}") + sys.exit(1) + + # Sanity-check the overlay links resolved the imports rather than + # leaving them stubbed at $0000. This is the canary that catches + # the build-order bug above if it ever resurfaces (e.g. someone + # changes the cfg in a way that re-shuffles the linker's + # dependency graph). + curve_labels = _load_labels(LABELS_CURVE_PATH) + for sym in ("mul_dma_lo", "mul_dma_hi", "mul_cached_a", + "reu_fetch_mul_row"): + addr = curve_labels.get(sym) + if addr is None or addr == 0: + print(f"FATAL: curve overlay's {sym} resolved to " + f"${addr:04X} after two-pass build (expected non-zero " + f"main-PRG address); fp_mul_384 will hang. " + f"Re-run after `make clean BACKEND=uci && make BACKEND=uci`.") + sys.exit(1) + + +def _run_backend(*, backend: str, vectors: list[dict], + splice_stub: bytes, verify_stub: bytes, + addresses: dict[str, int], + timeout_s: float, verbose: bool, + sha_only: bool = False) -> tuple[int, int, list[dict]]: + """Acquire a target, install the stubs, run all vectors, return + (passed, failed, details).""" + from c64_test_harness import ( + UnifiedManager, ViceConfig, write_bytes, read_bytes, wait_for_text, + ) + from c64_test_harness.keyboard import send_text + + if backend == "vice": + config = ViceConfig( + prg_path=str(PRG_PATH), warp=True, ntsc=True, sound=False, + extra_args=["-reu", "-reusize", "512"], + ) + mgr = UnifiedManager(backend="vice", vice_config=config) + else: + mgr = UnifiedManager(backend="u64", lock_timeout=120.0) + + passed = failed = 0 + details: list[dict] = [] + + # Layout the two stubs back-to-back inside our scratch range. The + # splice stub is ~12 B, the verify stub is ~16 B — fit comfortably + # in the 256 B page at $CA00. + splice_addr = STUB_ADDR + verify_addr = STUB_ADDR + ((len(splice_stub) + 15) & ~15) # 16-B aligned + + target = mgr.acquire() + try: + transport = target.transport + if backend == "vice": + print(f" VICE PID={target.pid}, transport ready") + else: + print(f" U64 transport ready") + + # Wait for menu — confirms boot sequence (incl. reu_p384_overlay_init) + # has finished and main_loop is polling. + if backend == "vice": + grid = wait_for_text(transport, "Q=QUIT", timeout=180.0, + verbose=False) + if grid is None: + raise RuntimeError("VICE: menu banner never appeared") + else: + # On U64 the device is already running the PRG (run_prg). + # Give boot ~30 s to populate REU banks + run do_net_init. + time.sleep(30.0) + + # Install the two stubs at $CA00 / $CA10 (in OVERLAY_FILE_PAD + # tail past the curve overlay's resident DATA at $C9F7). + write_bytes(transport, splice_addr, splice_stub) + write_bytes(transport, verify_addr, verify_stub) + print(f" splice stub at ${splice_addr:04X} ({len(splice_stub)} B)") + print(f" verify stub at ${verify_addr:04X} ({len(verify_stub)} B)") + + # On U64, exit main_loop to BASIC so SYS-injection works for the + # run_subroutine trampoline. VICE's binary-monitor jsr() does + # not need this. + if backend == "u64": + send_text(transport, "q\r") + time.sleep(2.0) + + for vec in vectors: + print(f" [{backend}] {vec['name']}: running" + f" (msg={len(vec['msg'])} B, expect=" + f"{'VALID' if vec['expected_valid'] else 'INVALID'})...", + flush=True) + result = _run_one_vector( + target, vec, addresses, + splice_addr=splice_addr, verify_addr=verify_addr, + timeout_s=timeout_s, verbose=verbose, + sha_only=sha_only, + ) + result["name"] = vec["name"] + result["expected_valid"] = vec["expected_valid"] + result["backend"] = backend + details.append(result) + if "error" in result: + print(f" FAIL [{backend}] {vec['name']}: {result['error']}") + failed += 1 + continue + ok = (result["valid"] == vec["expected_valid"]) + tag = "PASS" if ok else "FAIL" + overall = result.get("overall_seconds", result["seconds"]) + print(f" {tag} [{backend}] {vec['name']}: " + f"valid={result['valid']} (expected {vec['expected_valid']}) " + f"verify={result['seconds']:.3f}s " + f"overall={overall:.3f}s " + f"carry=${result['carry']:02X}") + if ok: + passed += 1 + else: + failed += 1 + finally: + mgr.release(target) + mgr.shutdown() + + return passed, failed, details + + +def main() -> int: + # Force line-buffered stdout/stderr so live progress shows up under + # piped invocations (otherwise Python block-buffers when not connected + # to a terminal and the user only sees output at process exit). + try: + sys.stdout.reconfigure(line_buffering=True) # type: ignore[attr-defined] + sys.stderr.reconfigure(line_buffering=True) # type: ignore[attr-defined] + except AttributeError: + pass # Python < 3.7 + + args = sys.argv[1:] + run_u64 = "--u64" in args + verbose = "--verbose" in args + full = "--full" in args + sha_only = "--sha-only" in args # diagnostic: run swap+sha+splice but + # skip the slow ecdsa_verify_384 step + + print(f"=== test_ecdsa_p384_kat.py (P-384 dual-overlay KAT) ===") + os.chdir(str(PROJECT_ROOT)) + + # Check the upstream test vector file exists. + if not NIST_VECTORS_PATH.exists(): + print(f"FATAL: vector file not found: {NIST_VECTORS_PATH}") + return 1 + + _build_prg() + + for path in (PRG_PATH, LABELS_PATH, LABELS_SHA384_PATH, LABELS_CURVE_PATH): + if not path.exists(): + print(f"FATAL: required artifact missing: {path}") + return 1 + + addresses = _resolve_addresses() + print(f" Addresses verified against on-disk labels:") + for name in sorted(addresses): + print(f" {name:32s} = ${addresses[name]:04X}") + + vectors = _build_vector_list(full=full) + print(f" Loaded {len(vectors)} vectors (full={full}):") + for v in vectors: + print(f" - {v['name']:30s} expect={v['expected_valid']!s} " + f"msg={len(v['msg'])} B") + + splice_stub = _build_splice_stub(addresses) + verify_stub = _build_verify_stub(addresses) + print(f" splice stub: {len(splice_stub)} B") + print(f" verify stub: {len(verify_stub)} B") + + # Per-VERIFY-step timeout. VICE warp wall-clock for one P-384 + # verify is ~10-300 s on a fast Mac but can be 10-30 min on a + # slower host, since VICE single-threads through 6502 emulation + + # 600+ M cycles of REU DMA setup. Default 1800 s gives generous + # headroom; override via env if you need a tight budget. + vice_timeout = float(os.environ.get("P384_KAT_VICE_TIMEOUT_S", "1800.0")) + u64_timeout = float(os.environ.get("P384_KAT_U64_TIMEOUT_S", "600.0")) + + print(f"\n=== VICE backend (warp, -reu) ===") + v_pass, v_fail, v_details = _run_backend( + backend="vice", vectors=vectors, + splice_stub=splice_stub, verify_stub=verify_stub, + addresses=addresses, + timeout_s=vice_timeout, verbose=verbose, + sha_only=sha_only, + ) + + u_pass = u_fail = 0 + u_details: list[dict] = [] + if run_u64: + print(f"\n=== U64 backend (real hardware) ===") + if not os.environ.get("U64_HOST"): + print(" SKIP: --u64 requested but U64_HOST not set in env") + else: + try: + u_pass, u_fail, u_details = _run_backend( + backend="u64", vectors=vectors, + splice_stub=splice_stub, verify_stub=verify_stub, + addresses=addresses, + timeout_s=u64_timeout, verbose=verbose, + sha_only=sha_only, + ) + except Exception as exc: + print(f" U64 backend FAILED: {exc!r}") + u_fail = len(vectors) + else: + print(f"\n=== U64 backend SKIPPED (pass --u64 to enable) ===") + + print(f"\n{'=' * 60}") + print(f"VICE: {v_pass}/{v_pass + v_fail} passed") + if run_u64: + print(f"U64: {u_pass}/{u_pass + u_fail} passed") + print(f"{'=' * 60}") + + total_fail = v_fail + u_fail + overall = "PASS" if total_fail == 0 else "FAIL" + print(f"OVERALL: {overall}") + return 0 if total_fail == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 3fe02a79b51e19278ea385006c8b7e819ae1f8f4 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 21:12:05 -0500 Subject: [PATCH 09/21] fix(p384): shrink CertificateVerify signed blob 146 -> 130 B (Phase 5 Fix A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TLS 1.3 transcript-hash function is bound to the cipher suite's hash (RFC 8446 §4.4.1), not to the signature_algorithm. c64-https only negotiates TLS_AES_128_GCM_SHA256, so the transcript embedded in the CertificateVerify signed-content blob is always 32 B SHA-256, even when the signature is ECDSA-P384/SHA-384. The Phase 4a draft built a 146 B blob with the 32 B SHA-256 transcript zero-padded out to 48 B (SHA-384 digest width). That feeds the verifier a different message than the one the server signed, so any real server signature would be rejected. This commit: - SIGNED_BLOB_LEN: 146 -> 130 (64 + 33 + 1 + 32). - .assert pin updated to (64 + 33 + 1 + 32) = 130. - Step 3 transcript copy: copy 32 B verbatim, drop the 16 B zero-pad. - Trim the documented blob window from \$CA00..\$CA91 to \$CA00..\$CA81; tcp_recv_buf still has plenty of slack. SHA-384 still produces the 48 B digest spliced into the h slot - the hash function and digest size used to derive the message hash are independent from the transcript-hash function. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/crypto/ecdsa_verify_384.s | 70 +++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/src/crypto/ecdsa_verify_384.s b/src/crypto/ecdsa_verify_384.s index dea0b46..f7d0fe1 100644 --- a/src/crypto/ecdsa_verify_384.s +++ b/src/crypto/ecdsa_verify_384.s @@ -21,19 +21,30 @@ ; (r|s|h|Qx|Qy contiguous 32 B each) prevents a simple resize, so ; Phase 4a copies whatever the cert handler left at offsets ; pubkey_x..pubkey_x+47 and pubkey_y..pubkey_y+47 -- the bytes are -; partially-corrupt for P-384 but that is no worse than the -; SHA-384 transcript placeholder below; both correctness fixes -; land together in Phase 5. -; 3. Build the TLS 1.3 §4.4.3 signed-content blob (146 bytes) at +; partially-corrupt for P-384. Fix B (next commit) lands the +; separate _384 slots. +; 3. Build the TLS 1.3 §4.4.3 signed-content blob (130 bytes) at ; $CA00 in tcp_recv_buf scratch RAM: ; [0..63] 64 spaces (0x20) ; [64..96] "TLS 1.3, server CertificateVerify" (33 bytes) ; [97] 0x00 separator -; [98..145] transcript hash (48 bytes) +; [98..129] transcript hash (32 bytes — SHA-256) ; tcp_recv_buf is idle during crypto and the chosen window -; ($CA00..$CA91) sits well above both overlays' resident DATA +; ($CA00..$CA81) sits well above both overlays' resident DATA ; ranges (SHA overlay ends at $C411, curve overlay at $C9F7) so ; it survives the swap. +; +; Phase 5 Fix A: blob length is 130 bytes, not 146. RFC 8446 +; §4.4.1 specifies the transcript-hash uses the negotiated cipher +; suite's hash function — c64-https only negotiates +; TLS_AES_128_GCM_SHA256, so the transcript is always 32 B SHA-256 +; regardless of the signature scheme. The 46+33+1+32 = 130 layout +; is what the server signed; padding to 48 B for SHA-384's digest +; width would feed the verifier a different message than the one +; the server hashed. SHA-384(blob) still produces a 48 B digest +; that is spliced into ecdsa_inputs_384[96..143] (h slot) — the +; hash function and digest size for the signature itself are +; independent from the transcript-hash function. ; 4. crypto_swap_to_p384_sha384 -> sha384_init / update / final. ; sha384_digest (48 B BE) lands at $C3E1 in the SHA overlay's ; resident DATA. @@ -41,17 +52,15 @@ ; 6. crypto_swap_to_p384_curve -> ecdsa_verify_384. ; C=0 valid / C=1 invalid -- propagated to caller. ; -; Phase 4a CAVEAT: c64-https currently runs only a SHA-256 transcript -; (tls_transcript, 32 B). A real TLS 1.3 secp384r1+SHA-384 -; CertificateVerify wants a SHA-384 transcript, which is not yet -; produced anywhere in the TLS state machine. Until a SHA-384 -; transcript path lands, Step 3 zero-pads the 32-B SHA-256 transcript -; up to 48 B for shape -- the dispatcher will route end-to-end and -; return C=1 for any real signature, but the crypto_swap + -; ecdsa_verify_384 mechanism is exercised. Phase 5 owns wiring up a -; proper SHA-384 transcript and is expected to flip the source from -; tls_transcript to a tls_transcript_384 (or equivalent) without -; touching this file's structure. +; Phase 5 note: c64-https only negotiates TLS_AES_128_GCM_SHA256, so +; the TLS 1.3 transcript-hash function is always SHA-256 (RFC 8446 +; §4.4.1 ties transcript-hash to the cipher suite's hash, not to the +; signature_algorithm). The signed-content blob therefore embeds a +; 32 B SHA-256 transcript verbatim (no padding), totalling 130 bytes. +; SHA-384 then hashes the 130 B blob and produces a 48 B digest that +; goes into ecdsa_inputs_384's h slot for the P-384 verifier. The +; previous Phase 4a draft (146 B blob with the 32 B transcript zero- +; padded to 48 B) is superseded by Phase 5 Fix A. ; ; Overlay-resident symbols: the sibling overlay images ; (overlay-p384-sha384.bin / overlay-p384-curve.bin) are NOT linked @@ -112,13 +121,15 @@ sha_src = $3D ; 2 B pointer to message bytes sha_len = $3F ; 2 B 16-bit length ; ----------------------------------------------------------------------------- -; Signed-content blob staging address. 146 B in tcp_recv_buf scratch. +; Signed-content blob staging address. 130 B in tcp_recv_buf scratch +; ($CA00..$CA81). Phase 5 Fix A: shrunk from 146 B because the TLS 1.3 +; transcript-hash is SHA-256 (32 B) not SHA-384 (48 B); see file header. ; ----------------------------------------------------------------------------- SIGNED_BLOB_ADDR = $CA00 -SIGNED_BLOB_LEN = 146 ; 64 + 33 + 1 + 48 (RFC 8446 §4.4.3) +SIGNED_BLOB_LEN = 130 ; 64 + 33 + 1 + 32 (RFC 8446 §4.4.3) -; Compile-time assertion: 64-space pad + label + sep + SHA-384 digest = 146. -.assert (64 + 33 + 1 + 48) = SIGNED_BLOB_LEN, error, "P-384 signed-content blob length" +; Compile-time assertion: 64-space pad + label + sep + SHA-256 transcript = 130. +.assert (64 + 33 + 1 + 32) = SIGNED_BLOB_LEN, error, "P-384 signed-content blob length" .segment "CRYPTO_AUX_CODE" @@ -210,22 +221,19 @@ ecdsa_verify_384_tls: lda #$00 sta SIGNED_BLOB_ADDR+97 - ; [98..145] transcript hash (48 B). Phase 4a placeholder: copy - ; the 32-byte SHA-256 tls_transcript and zero-fill the - ; remaining 16 bytes -- shape only; verify will return C=1 - ; against any real server until a SHA-384 transcript lands. + ; [98..129] transcript hash (32 B SHA-256). Phase 5 Fix A: + ; copy the 32 B SHA-256 tls_transcript verbatim — no padding. + ; The TLS 1.3 transcript-hash is bound to the cipher suite + ; (SHA-256 via TLS_AES_128_GCM_SHA256), independent from the + ; signature_algorithm's hash (SHA-384 here). Padding to 48 B + ; would feed the verifier a different message than the server + ; signed. ldx #31 @copy_xcript: lda tls_transcript,x sta SIGNED_BLOB_ADDR+98,x dex bpl @copy_xcript - lda #0 - ldx #15 -@pad_xcript: - sta SIGNED_BLOB_ADDR+98+32,x - dex - bpl @pad_xcript ; ----------------------------------------------------------------- ; Step 4: swap in SHA-384 overlay and hash the blob. From 1c31f8d4ef259255267c7f366bd936233d5ef675 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 21:13:08 -0500 Subject: [PATCH 10/21] fix(p384): separate 48 B P-384 pubkey BSS slots (Phase 5 Fix B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ecdsa_pubkey_x and ecdsa_pubkey_y in src/data.s are 32 B each, sized for P-256 as part of the contiguous r|s|h|Qx|Qy packed struct that ecdsa_verify_256 reads verbatim. The cert handler was writing 48 B per coordinate when ecdsa_sig_len = 48 (P-384), overrunning the slots and clobbering the next BSS variables. The dispatcher then read partially- corrupt pubkey bytes. Option (a) (per Phase 5 task brief — lighter than widening the P-256 struct): add separate ecdsa_pubkey_x_384 / ecdsa_pubkey_y_384 slots in CRYPTO_BSS, dispatch in the cert handler on ecdsa_curve_id, and have the P-384 dispatcher read from the new slots. Changes: - src/data.s: declare and export ecdsa_pubkey_x_384 / _y_384 (48 B each, +96 B in CRYPTO_BSS — well within the slack reclaimed by Phase 6's tls_hs_buf removal). - src/tls_cert.s: x509_extract_pubkey's X / Y copy loops dispatch on ecdsa_curve_id, writing P-256 pubkeys into the legacy 32 B slots (preserving the sibling ecdsa_verify_256 packed-struct invariant) and P-384 pubkeys into the new 48 B _384 slots. - src/crypto/ecdsa_verify_384.s: import _384 slots; copy_qx / copy_qy redirected to read from them; doc-block updated to reference Fix B. P-256 verify path is unchanged; only the P-384 cert + dispatcher path is affected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/crypto/ecdsa_verify_384.s | 29 ++++++++++------------ src/data.s | 13 ++++++++++ src/tls_cert.s | 46 ++++++++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/crypto/ecdsa_verify_384.s b/src/crypto/ecdsa_verify_384.s index f7d0fe1..f3c2da9 100644 --- a/src/crypto/ecdsa_verify_384.s +++ b/src/crypto/ecdsa_verify_384.s @@ -13,16 +13,13 @@ ; 2. Copy the 48-byte big-endian server pubkey (X then Y) into ; ecdsa_inputs_384 slots +144 and +192. ; -; Phase 4a CAVEAT: ecdsa_pubkey_x and ecdsa_pubkey_y in data.s are -; currently 32 B each (sized for P-256). src/tls_cert.s's cert -; handler nominally writes 48 B per coordinate when -; ecdsa_sig_len = 48, which overruns into adjacent BSS slots. The -; P-256 packed-struct invariant for ecdsa_verify_256 -; (r|s|h|Qx|Qy contiguous 32 B each) prevents a simple resize, so -; Phase 4a copies whatever the cert handler left at offsets -; pubkey_x..pubkey_x+47 and pubkey_y..pubkey_y+47 -- the bytes are -; partially-corrupt for P-384. Fix B (next commit) lands the -; separate _384 slots. +; Phase 5 Fix B: src/data.s defines separate 48 B slots +; ecdsa_pubkey_x_384 and ecdsa_pubkey_y_384 in CRYPTO_BSS; +; src/tls_cert.s's cert handler dispatches on ecdsa_curve_id and +; writes the P-384 pubkey into those slots when the leaf cert +; advertises secp384r1. The dispatcher reads from the _384 slots +; for the verify input, leaving the contiguous 32 B P-256 packed +; struct (r|s|h|Qx|Qy) intact for ecdsa_verify_256. ; 3. Build the TLS 1.3 §4.4.3 signed-content blob (130 bytes) at ; $CA00 in tcp_recv_buf scratch RAM: ; [0..63] 64 spaces (0x20) @@ -85,8 +82,8 @@ ; --- TLS-side state we read --- .import tls_rec_buf ; CertificateVerify message buffer .import tls_transcript ; 32 B running SHA-256 transcript - .import ecdsa_pubkey_x ; 48 B server pubkey X (extended Phase 4a) - .import ecdsa_pubkey_y ; 48 B server pubkey Y (extended Phase 4a) + .import ecdsa_pubkey_x_384 ; 48 B server pubkey X (Phase 5 Fix B) + .import ecdsa_pubkey_y_384 ; 48 B server pubkey Y (Phase 5 Fix B) ; --- Overlay swap entry points (in main PRG, always-resident) --- .import crypto_swap_to_p384_sha384 @@ -144,8 +141,8 @@ SIGNED_BLOB_LEN = 130 ; 64 + 33 + 1 + 32 (RFC 8446 §4.4.3) ; tls_rec_buf+4..5 signature_scheme = 0x0503 ; tls_rec_buf+6..7 16-bit signature length (BE; high byte = 0) ; tls_rec_buf+8.. DER-encoded ECDSA signature (SEQUENCE { r, s }) -; ecdsa_pubkey_x 48 B server pubkey X (BE, from cert) -; ecdsa_pubkey_y 48 B server pubkey Y (BE, from cert) +; ecdsa_pubkey_x_384 48 B server pubkey X (BE, from cert; Phase 5 Fix B) +; ecdsa_pubkey_y_384 48 B server pubkey Y (BE, from cert; Phase 5 Fix B) ; tls_transcript 32 B SHA-256 transcript (Phase 4a placeholder -- ; see CAVEAT in file header) ; @@ -186,14 +183,14 @@ ecdsa_verify_384_tls: ; ----------------------------------------------------------------- ldx #47 @copy_qx: - lda ecdsa_pubkey_x,x + lda ecdsa_pubkey_x_384,x sta ecdsa_inputs_384+144,x dex bpl @copy_qx ldx #47 @copy_qy: - lda ecdsa_pubkey_y,x + lda ecdsa_pubkey_y_384,x sta ecdsa_inputs_384+192,x dex bpl @copy_qy diff --git a/src/data.s b/src/data.s index 8701874..541f6ad 100644 --- a/src/data.s +++ b/src/data.s @@ -547,6 +547,8 @@ mul_src2_buf: .res 35 ; absolute copy of src2 for fast indexed access .export ecdsa_sig_s .export ecdsa_pubkey_x .export ecdsa_pubkey_y +.export ecdsa_pubkey_x_384 +.export ecdsa_pubkey_y_384 ecdsa_curve_id: .res 1 ; 0=P-256, 1=P-384 ; Phase C.4: for P-256, these five 32-byte BE buffers are laid out @@ -562,6 +564,17 @@ ecdsa_hash: .res 32 ; message hash (BE, struct +64) ecdsa_pubkey_x: .res 32 ; public key Q.x (BE, struct +96) ecdsa_pubkey_y: .res 32 ; public key Q.y (BE, struct +128) +; Phase 5 Fix B: separate 48 B P-384 pubkey slots. The P-256 packed +; struct above (r|s|h|Qx|Qy contiguous 32 B each) is read verbatim by +; the sibling ecdsa_verify_256, so we can't widen the existing +; ecdsa_pubkey_{x,y} to 48 B without breaking P-256. The cert handler +; (src/tls_cert.s) targets ecdsa_pubkey_{x,y}_384 when ecdsa_curve_id=1 +; and the P-384 dispatcher (src/crypto/ecdsa_verify_384.s) reads from +; the _384 slots. 96 B total in CRYPTO_BSS — well within the slack +; reclaimed by Phase 6's tls_hs_buf removal. +ecdsa_pubkey_x_384: .res 48 ; P-384 public key Q.x (BE, dispatcher input) +ecdsa_pubkey_y_384: .res 48 ; P-384 public key Q.y (BE, dispatcher input) + ; --- Legacy in-tree ECDSA scratch (ecdsa_verify_tmp, ev_u1, ev_u2, ; ev_point_save, ev_u1_384, ev_u2_384, ev_point_save_384) was ; reclaimed in Phase C.4. The sibling c64-nist-curves diff --git a/src/tls_cert.s b/src/tls_cert.s index 33cf566..42cb42d 100644 --- a/src/tls_cert.s +++ b/src/tls_cert.s @@ -47,6 +47,11 @@ .import ecdsa_sig_len .import ecdsa_pubkey_x .import ecdsa_pubkey_y + ; Phase 5 Fix B: separate 48 B P-384 pubkey slots so the cert + ; handler doesn't overrun the 32 B P-256 slots when the leaf + ; cert advertises secp384r1. + .import ecdsa_pubkey_x_384 + .import ecdsa_pubkey_y_384 ; Debug progress byte repurposed from tls_record_io.s's existing label. ; Used by tls_handle_cert_verify to mark which stage we reached so a @@ -456,17 +461,34 @@ x509_extract_pubkey: adc zp_ptr+1 sta zp_ptr+1 - ; Copy X coordinate to ecdsa_pubkey_x + ; Copy X coordinate. Phase 5 Fix B: dispatch on ecdsa_curve_id + ; to the correctly-sized BSS slot — P-256 32 B slot stays + ; ecdsa_pubkey_x; P-384 48 B slot is ecdsa_pubkey_x_384 (the + ; P-256 buffer would only hold 32 of the 48 bytes and the + ; remaining 16 would clobber the next BSS variable). lda ecdsa_sig_len ; 32 or 48 sta zp_count + lda ecdsa_curve_id + beq @copy_x_p256 + ; --- P-384 --- ldy #0 -@copy_x: +@copy_x_p384: + lda (zp_ptr),y + sta ecdsa_pubkey_x_384,y + iny + cpy zp_count + bne @copy_x_p384 + jmp @advance_past_x +@copy_x_p256: + ldy #0 +@copy_x_p256_loop: lda (zp_ptr),y sta ecdsa_pubkey_x,y iny cpy zp_count - bne @copy_x + bne @copy_x_p256_loop +@advance_past_x: ; Advance zp_ptr past X lda zp_count clc @@ -476,15 +498,27 @@ x509_extract_pubkey: adc zp_ptr+1 sta zp_ptr+1 - ; Copy Y coordinate to ecdsa_pubkey_y + ; Copy Y coordinate. Same dispatch as X. + lda ecdsa_curve_id + beq @copy_y_p256 + ldy #0 +@copy_y_p384: + lda (zp_ptr),y + sta ecdsa_pubkey_y_384,y + iny + cpy zp_count + bne @copy_y_p384 + jmp @copy_y_done +@copy_y_p256: ldy #0 -@copy_y: +@copy_y_p256_loop: lda (zp_ptr),y sta ecdsa_pubkey_y,y iny cpy zp_count - bne @copy_y + bne @copy_y_p256_loop +@copy_y_done: ; Success clc rts From 251fe53c112fcc47250c6c78ea9a2ff06fcfae80 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 21:15:50 -0500 Subject: [PATCH 11/21] build(p384): generate overlay-resident equates from labels at build time (Phase 5 Fix C) Phase 4a hand-pasted six addresses (sha384_init, sha384_update, sha384_final, sha384_digest, ecdsa_verify_384, ecdsa_inputs_384) into src/crypto/ecdsa_verify_384.s, sourced from build/labels-p384-*.txt. If the overlay images are rebuilt and any address moves, no link-time check fires - the dispatcher silently calls stale addresses. This commit: - tools/integration/gen_p384_overlay_equates.sh: extracts the six addresses from build/labels-p384-{sha384,curve}.txt and emits build/p384_overlay_equates.inc with ca65-syntax equates. Atomic write via mktemp + mv. Fails loudly if any required label is absent from the overlay labels. - Makefile: regenerate build/p384_overlay_equates.inc whenever either labels file or the generator script changes; declare build/crypto/ecdsa_verify_384.o as dependent on it; add to PRG_DEPS under USE_OVERLAY_P384_EMBED. Add -I build to CA65FLAGS so .include "p384_overlay_equates.inc" resolves. - src/crypto/ecdsa_verify_384.s: replace the hand-pasted equates block with .include "p384_overlay_equates.inc"; add six .assert pins that catch drift if the overlay slot ($4200..$5FFF) or the overlay-resident DATA window ($C000..$CFFF) invariants ever break. Build flow: bumping libs/nistcurves or restructuring an overlay cfg now propagates address changes through to the dispatcher .o (with a build-time .assert failure if the symbols land outside their slots), rather than producing a silently-wrong PRG. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 19 ++- src/crypto/ecdsa_verify_384.s | 35 ++--- tools/integration/gen_p384_overlay_equates.sh | 121 ++++++++++++++++++ 3 files changed, 159 insertions(+), 16 deletions(-) create mode 100755 tools/integration/gen_p384_overlay_equates.sh diff --git a/Makefile b/Makefile index b69a7f4..43d0595 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ IP65_DIR := ip65 IP65_BUILD := ip65-build IP65_BIN := $(IP65_BUILD)/ip65-c64.bin -CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net/$(BACKEND) --debug-info +CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net/$(BACKEND) -I build --debug-info LD65FLAGS := -C $(CFG) -Ln build/labels.txt -m build/c64-https.map # Source inventory. @@ -154,6 +154,7 @@ endif # src/crypto/shared/p384_overlay_blobs.s tries to read them. ifeq ($(USE_OVERLAY_P384_EMBED),1) PRG_DEPS += build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin +PRG_DEPS += build/p384_overlay_equates.inc build/crypto/shared/p384_overlay_blobs.o: build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin endif @@ -221,6 +222,22 @@ build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin build/labels- p384-overlay: build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin \ build/labels-p384-sha384.txt build/labels-p384-curve.txt +# Phase 5 Fix C: regenerate the P-384 overlay-resident symbol equates +# (build/p384_overlay_equates.inc) from the overlay labels files so the +# TLS-side dispatcher (src/crypto/ecdsa_verify_384.s) picks up address +# changes via .include, with .assert pins catching drift. Whenever +# either labels file is rebuilt, the .inc regenerates and the +# dispatcher .o is forced to rebuild. +build/p384_overlay_equates.inc: build/labels-p384-sha384.txt build/labels-p384-curve.txt \ + tools/integration/gen_p384_overlay_equates.sh + bash tools/integration/gen_p384_overlay_equates.sh \ + build/labels-p384-sha384.txt build/labels-p384-curve.txt $@ + +# The dispatcher .o now depends on the generated equates file (via +# .include) AND on the overlay .bin files (PRG_DEPS already lists those +# under USE_OVERLAY_P384_EMBED). +build/crypto/ecdsa_verify_384.o: build/p384_overlay_equates.inc + # Build ip65 object libraries from the submodule. Only needed if the ip65 # submodule changes; the prebuilt blob is committed to ip65-build/. ip65-libs: diff --git a/src/crypto/ecdsa_verify_384.s b/src/crypto/ecdsa_verify_384.s index f3c2da9..aaad0e7 100644 --- a/src/crypto/ecdsa_verify_384.s +++ b/src/crypto/ecdsa_verify_384.s @@ -93,23 +93,28 @@ ; ----------------------------------------------------------------------------- ; Overlay-resident symbol equates (NOT linked from the main PRG). -; Sourced from build/labels-p384-sha384.txt + build/labels-p384-curve.txt. -; Both overlays load at $4200; their resident DATA lives at $C000+. +; Sourced from build/labels-p384-sha384.txt + build/labels-p384-curve.txt +; via tools/integration/gen_p384_overlay_equates.sh, regenerated by the +; Makefile whenever either overlay labels file changes (Phase 5 Fix C). +; +; Both overlays load at $4200 (CRYPTO_OVERLAY) and their resident DATA +; lives at $C000+ (TCP_BUF range). The build-time .assert pins below +; catch drift if either invariant breaks (e.g. an overlay cfg restructure +; moves sha384_init off $4200 or pushes ecdsa_inputs_384 outside $C000+). ; ----------------------------------------------------------------------------- -; SHA-384 overlay (banked into $4200 by crypto_swap_to_p384_sha384): -sha384_init = $4200 -sha384_update = $4219 -sha384_final = $42D6 -; SHA-384 resident DATA (lives at $C000+, survives curve overlay swap-in -; because the curve overlay's resident DATA starts at $C000 too -- the -; sha384_digest bytes are read between sha384_final and the curve swap): -sha384_digest = $C3E1 ; 48 B BE digest output - -; Curve / verify overlay (banked into $4200 by crypto_swap_to_p384_curve): -ecdsa_verify_384 = $5BDD -; Curve resident DATA: -ecdsa_inputs_384 = $C8D1 ; 240 B BE struct r|s|h|Qx|Qy + .include "p384_overlay_equates.inc" + +; Build-time pins. CRYPTO_OVERLAY is $4200..$5FFF and overlay DATA +; resides at $C000..$CFFF (TCP_BUF, idle during crypto). If a regenerated +; equates file violates either range, ld65 / ca65 won't catch it; these +; .asserts will. +.assert sha384_init = $4200, error, "sha384_init must be at the overlay slot start ($4200)" +.assert sha384_update >= $4200 .and sha384_update < $6000, error, "sha384_update outside CRYPTO_OVERLAY" +.assert sha384_final >= $4200 .and sha384_final < $6000, error, "sha384_final outside CRYPTO_OVERLAY" +.assert sha384_digest >= $C000 .and sha384_digest < $D000, error, "sha384_digest outside overlay-resident DATA range" +.assert ecdsa_verify_384 >= $4200 .and ecdsa_verify_384 < $6000, error, "ecdsa_verify_384 outside CRYPTO_OVERLAY" +.assert ecdsa_inputs_384 >= $C000 .and ecdsa_inputs_384 < $D000, error, "ecdsa_inputs_384 outside overlay-resident DATA range" ; ----------------------------------------------------------------------------- ; ZP slots dedicated to SHA-384 (Phase 1.5). diff --git a/tools/integration/gen_p384_overlay_equates.sh b/tools/integration/gen_p384_overlay_equates.sh new file mode 100755 index 0000000..89fe1d5 --- /dev/null +++ b/tools/integration/gen_p384_overlay_equates.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# ============================================================================= +# tools/integration/gen_p384_overlay_equates.sh — Phase 5 Fix C. +# +# Extract P-384 overlay-resident symbol addresses from +# build/labels-p384-sha384.txt and build/labels-p384-curve.txt and emit a +# ca65 .inc file (build/p384_overlay_equates.inc) that the TLS-side P-384 +# verify dispatcher (src/crypto/ecdsa_verify_384.s) can `.include` to +# pick them up at assembly time. +# +# Phase 4a hand-pasted these addresses as numeric equates. When the +# overlay images are rebuilt (e.g. after a libs/nistcurves bump or an +# overlay-cfg restructure) the addresses move silently — the dispatcher +# carries on calling stale addresses with no link-time error. Wiring +# the equates through a generated `.inc` lets ca65's `.assert` (in the +# dispatcher itself) catch drift, and at minimum the dispatcher will +# fail to build if a required label disappears entirely from the +# overlay labels file. +# +# Symbols extracted (must exist in the overlay labels): +# sha384_init — overlay code entry, expected $4200 (sha384 cfg) +# sha384_update — overlay code entry +# sha384_final — overlay code entry +# sha384_digest — overlay-resident DATA, 48 B BE digest output +# ecdsa_verify_384 — overlay code entry, expected $4200..$5FFF +# ecdsa_inputs_384 — overlay-resident DATA, 240 B BE input struct +# +# Usage (from the Makefile): +# bash tools/integration/gen_p384_overlay_equates.sh \ +# build/labels-p384-sha384.txt \ +# build/labels-p384-curve.txt \ +# build/p384_overlay_equates.inc +# ============================================================================= +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " >&2 + exit 64 +fi + +SHA_LABELS="$1" +CURVE_LABELS="$2" +OUT="$3" + +if [ ! -f "$SHA_LABELS" ]; then + echo "ERROR: SHA-384 overlay labels not found: $SHA_LABELS" >&2 + exit 1 +fi +if [ ! -f "$CURVE_LABELS" ]; then + echo "ERROR: curve overlay labels not found: $CURVE_LABELS" >&2 + exit 1 +fi + +# lookup_label +# Emits the 4-hex-char address (e.g. 4200) or fails if the symbol is missing. +lookup_label () { + local file="$1" + local name="$2" + # Format: "al C:HHHH .name" (post-ld65 sed normalisation in Makefile). + local hex + hex=$(awk -v n=".$name" '$3 == n { sub(/^C:/, "", $2); print $2; exit }' "$file") + if [ -z "$hex" ]; then + echo "ERROR: symbol '$name' not found in $file" >&2 + exit 2 + fi + echo "$hex" +} + +SHA384_INIT=$(lookup_label "$SHA_LABELS" sha384_init) +SHA384_UPDATE=$(lookup_label "$SHA_LABELS" sha384_update) +SHA384_FINAL=$(lookup_label "$SHA_LABELS" sha384_final) +SHA384_DIGEST=$(lookup_label "$SHA_LABELS" sha384_digest) +ECDSA_VERIFY_384=$(lookup_label "$CURVE_LABELS" ecdsa_verify_384) +ECDSA_INPUTS_384=$(lookup_label "$CURVE_LABELS" ecdsa_inputs_384) + +mkdir -p "$(dirname "$OUT")" + +# Atomic write: stage to a temp file then mv into place, so a partial +# write can't poison incremental builds. +TMP="$(mktemp "${OUT}.XXXXXX")" +trap 'rm -f "$TMP"' EXIT + +cat > "$TMP" < Date: Fri, 15 May 2026 21:31:36 -0500 Subject: [PATCH 12/21] build(p384): order-only labels.txt dep on overlay-bin + script hardening (Phase 5 Fix D) Phase 2 finding: tools/integration/build_nistcurves_p384_bin.sh looks up four main-PRG addresses (mul_dma_lo, mul_dma_hi, mul_cached_a, reu_fetch_mul_row) in build/labels.txt to resolve them to the runtime locations the curve overlay's fp_mul_384 reads/writes. On a clean build labels.txt didn't exist when the overlay-bin step ran, the lookup_label() fallback stubbed all four to \$0000, and the curve overlay silently produced an image whose fp_mul_384 read/wrote \$0000 instead of \$BA00 / \$BB00 - silent corruption with no obvious symptom downstream. Changes: - Makefile: declare build/labels.txt as an ORDER-ONLY ('|') dependency on the overlay-bin rule. Order-only ensures labels.txt exists before the script runs without triggering an overlay rebuild on labels.txt mtime changes alone. - Makefile: respect a command-line USE_OVERLAY_P384_EMBED=0 (was forced to 1 under UCI). The two-step bootstrap workflow for a clean tree is documented inline: make BACKEND=uci USE_OVERLAY_P384_EMBED=0 # produce labels.txt make BACKEND=uci # real link - Makefile: gate the build/crypto/ecdsa_verify_384.o dep on the generated overlay-equates .inc by USE_OVERLAY_P384_EMBED so the bootstrap link doesn't chase the labels-p384-* -> overlay-bins -> labels.txt cycle. - src/crypto/ecdsa_verify_384.s: gate the .include on USE_OVERLAY_P384_EMBED with stub equates ($4200/$C000 within legal range) for the bootstrap path. - tools/integration/build_nistcurves_p384_bin.sh: replace the silent \$0000 fallback with a hard error pointing at the bootstrap workflow. lookup_label() now exits 4 if a required symbol is missing from build/labels.txt; missing build/labels.txt exits 3. After bootstrap, plain 'make BACKEND=uci' rebuilds incrementally without intervention. The order-only dep prevents spurious overlay rebuilds when labels.txt mtime changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 49 +++++++++++++++- src/crypto/ecdsa_verify_384.s | 23 ++++++++ .../integration/build_nistcurves_p384_bin.sh | 56 +++++++++++++------ 3 files changed, 107 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index 43d0595..5f71f2e 100644 --- a/Makefile +++ b/Makefile @@ -97,9 +97,15 @@ CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) # the SHA blob). Adds a build-order dep on the .bin files; a missing # .bin causes the .incbin to fail, so we extend PRG_DEPS below. ifneq ($(USE_X25519_SIBLING),1) -USE_OVERLAY_P384_EMBED := 1 +# Phase 5 Fix D: respect a command-line USE_OVERLAY_P384_EMBED=0 so the +# bootstrap rule below can do a no-overlay-embed prelim link to break +# the overlay-bin <-> labels.txt cycle on a clean tree. Default is +# still 1 unless the operator explicitly disables it. +USE_OVERLAY_P384_EMBED ?= 1 +ifeq ($(USE_OVERLAY_P384_EMBED),1) CA65FLAGS += -D USE_OVERLAY_P384_EMBED=1 endif +endif # Phase C.3: add c64-nist-curves P-384 primitives as a REU overlay. # Variable-base P-384 point ops (double/add/jacobian-to-affine) only — # see tools/integration/build_nistcurves_p384.sh for the scope rationale. @@ -165,6 +171,24 @@ $(PRG): $(PRG_DEPS) # so the c64-test-harness Labels.from_file() reader can parse it. sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' $(LABELS) +# Phase 5 Fix D: $(LABELS) is normally a side-effect of the $(PRG) +# link recipe; we don't add an explicit rule. The overlay-bin rule +# below has an order-only dep on $(LABELS) so its lookup_label() +# resolves the main PRG's runtime mul_dma_lo / mul_dma_hi / +# mul_cached_a / reu_fetch_mul_row to real addresses (was: silent +# $0000 fallback that produced a curve overlay whose fp_mul_384 +# read/wrote $0000 and silently corrupted downstream state). +# +# Bootstrap workflow (clean tree under USE_OVERLAY_P384_EMBED=1): +# make BACKEND=uci USE_OVERLAY_P384_EMBED=0 # produce labels.txt +# make BACKEND=uci # real link with overlays +# After this two-step bootstrap, plain `make BACKEND=uci` rebuilds +# incrementally without intervention. The script +# tools/integration/build_nistcurves_p384_bin.sh prints a clear error +# pointing at this two-step procedure if it runs without labels.txt +# (vs the old silent $0000 stub fallback). + + link: $(PRG) build/%.o: src/%.s @@ -212,10 +236,22 @@ build/lib/x25519.a: # All four outputs (two .bins + two labels files) are produced by a # single script invocation; the rule lists all four targets so make # only runs the script once even when several are stale. +# +# Phase 5 Fix D: build/labels.txt is an ORDER-ONLY dependency. The +# overlay-bin script's lookup_label() reads build/labels.txt to resolve +# mul_dma_lo / mul_dma_hi / mul_cached_a / reu_fetch_mul_row to the +# main PRG's runtime addresses (so the curve overlay's fp_mul_384 +# reads/writes the right $BA00 / $BB00 / etc. cells). On a clean +# build, build/labels.txt doesn't exist yet when this rule runs and the +# script falls back to $0000 stubs - silently producing an overlay +# image whose fp_mul_384 reads from $0000. Order-only ('|') ensures +# labels.txt exists before the script runs but doesn't trigger an +# overlay rebuild on every main-PRG link. build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin build/labels-p384-sha384.txt build/labels-p384-curve.txt: \ build/lib/nistcurves-p384-sha384.a build/lib/nistcurves-p384-curve.a \ cfg/p384-overlay-sha384.cfg cfg/p384-overlay-curve.cfg \ - tools/integration/build_nistcurves_p384_bin.sh + tools/integration/build_nistcurves_p384_bin.sh \ + | build/labels.txt bash tools/integration/build_nistcurves_p384_bin.sh .PHONY: p384-overlay @@ -235,8 +271,15 @@ build/p384_overlay_equates.inc: build/labels-p384-sha384.txt build/labels-p384-c # The dispatcher .o now depends on the generated equates file (via # .include) AND on the overlay .bin files (PRG_DEPS already lists those -# under USE_OVERLAY_P384_EMBED). +# under USE_OVERLAY_P384_EMBED). Phase 5 Fix D: gate the .inc dep on +# USE_OVERLAY_P384_EMBED so the bootstrap rule for $(LABELS) (which +# sub-makes with USE_OVERLAY_P384_EMBED=0) can skip rebuilding the .inc +# from labels-p384-* (those depend on overlay-bins which depend on +# $(LABELS) -- cycle). The bootstrap pre-creates a placeholder .inc +# before sub-making. +ifeq ($(USE_OVERLAY_P384_EMBED),1) build/crypto/ecdsa_verify_384.o: build/p384_overlay_equates.inc +endif # Build ip65 object libraries from the submodule. Only needed if the ip65 # submodule changes; the prebuilt blob is committed to ip65-build/. diff --git a/src/crypto/ecdsa_verify_384.s b/src/crypto/ecdsa_verify_384.s index aaad0e7..934abf3 100644 --- a/src/crypto/ecdsa_verify_384.s +++ b/src/crypto/ecdsa_verify_384.s @@ -103,7 +103,30 @@ ; moves sha384_init off $4200 or pushes ecdsa_inputs_384 outside $C000+). ; ----------------------------------------------------------------------------- +; Phase 5 Fix D: gate the .include on USE_OVERLAY_P384_EMBED so the +; dispatcher .o can compile during the bootstrap labels-only link +; (USE_OVERLAY_P384_EMBED=0) before the overlay-bins exist and the +; generated equates .inc has been produced. Under +; USE_OVERLAY_P384_EMBED=0 the dispatcher entry isn't actually +; reachable from the boot path (p384_overlay_blobs.s is empty so +; reu_p384_overlay_init is inert; tls_handle_cert_verify will still +; route here for sig_scheme=0x0503 but the call would land on a +; non-populated overlay slot — production builds always run with +; USE_OVERLAY_P384_EMBED=1). Stub equates suffice to satisfy the +; assembler when the .inc is absent. +.ifdef USE_OVERLAY_P384_EMBED .include "p384_overlay_equates.inc" +.else + ; Stub equates for the labels-only bootstrap link. Values are + ; deliberately within the legal slot range so the .asserts pass + ; even though they don't point at real overlay code. +sha384_init = $4200 +sha384_update = $4200 +sha384_final = $4200 +sha384_digest = $C000 +ecdsa_verify_384 = $4200 +ecdsa_inputs_384 = $C000 +.endif ; Build-time pins. CRYPTO_OVERLAY is $4200..$5FFF and overlay DATA ; resides at $C000..$CFFF (TCP_BUF, idle during crypto). If a regenerated diff --git a/tools/integration/build_nistcurves_p384_bin.sh b/tools/integration/build_nistcurves_p384_bin.sh index 9107e6c..0c65301 100755 --- a/tools/integration/build_nistcurves_p384_bin.sh +++ b/tools/integration/build_nistcurves_p384_bin.sh @@ -85,30 +85,50 @@ if [ ! -f "$ARCHIVE_SHA" ] || [ ! -f "$ARCHIVE_CURVE" ]; then exit 1 fi -# Try to pick up x25519-sibling addresses from the main build's labels.txt -# so references resolve to the real runtime locations. If the main build -# hasn't happened yet, stub them to $0000 — the overlay binary doesn't -# actually dereference these; only labels.txt addresses would be wrong, -# and we strip them below anyway. +# Pick up main-PRG addresses for mul_dma_lo / mul_dma_hi / mul_cached_a / +# reu_fetch_mul_row so the curve overlay's fp_mul_384 reads/writes the +# right runtime cells (e.g. mul_dma_lo at $BA00 in the main PRG's +# TABLES_BSS). These symbols belong to the main PRG, not to the overlay +# itself; the overlay's fp_mul_384 was assembled against `.import`s for +# them and ld65 needs `--define`'d addresses to resolve them at overlay +# link time. +# +# Phase 5 Fix D: if build/labels.txt is missing OR any required symbol is +# missing from it, ABORT with a clear error rather than silently falling +# back to $0000 stubs (which used to produce a curve overlay whose +# fp_mul_384 read/wrote $0000/$0001 — silent corruption with no obvious +# symptom downstream). The Makefile lists build/labels.txt as an +# order-only dep on the overlay-bin target so the main PRG's labels are +# present by the time this script runs in normal incremental builds; on +# a clean tree the user must build the main PRG first (which builds +# overlay-bins as a transitive dep — the cycle resolves on the second +# pass). MAIN_LABELS="$PROJECT_ROOT/build/labels.txt" +if [ ! -f "$MAIN_LABELS" ]; then + echo "ERROR: $MAIN_LABELS not found." >&2 + echo " The overlay-bin link needs the main PRG's runtime addresses for" >&2 + echo " mul_dma_lo / mul_dma_hi / mul_cached_a / reu_fetch_mul_row." >&2 + echo " Run 'make' (or 'make BACKEND=uci') once first to produce" >&2 + echo " build/labels.txt, then re-run 'make p384-overlay'." >&2 + exit 3 +fi + lookup_label () { local name="$1" - local fallback="$2" - if [ -f "$MAIN_LABELS" ]; then - local hex - hex=$(grep -E " \.${name}\$" "$MAIN_LABELS" | head -n1 | awk '{print $2}' | sed 's|^C:||') - if [ -n "$hex" ]; then - printf '$%s' "$hex" - return - fi + local hex + hex=$(grep -E " \.${name}\$" "$MAIN_LABELS" | head -n1 | awk '{print $2}' | sed 's|^C:||') + if [ -z "$hex" ]; then + echo "ERROR: required symbol '$name' missing from $MAIN_LABELS" >&2 + echo " Did the main PRG link complete successfully? See build/c64-https.map." >&2 + exit 4 fi - printf '%s' "$fallback" + printf '$%s' "$hex" } -DEF_MUL_CACHED_A=$(lookup_label mul_cached_a '$0000') -DEF_MUL_DMA_LO=$(lookup_label mul_dma_lo '$0000') -DEF_MUL_DMA_HI=$(lookup_label mul_dma_hi '$0000') -DEF_REU_FETCH_MUL_ROW=$(lookup_label reu_fetch_mul_row '$0000') +DEF_MUL_CACHED_A=$(lookup_label mul_cached_a) +DEF_MUL_DMA_LO=$(lookup_label mul_dma_lo) +DEF_MUL_DMA_HI=$(lookup_label mul_dma_hi) +DEF_REU_FETCH_MUL_ROW=$(lookup_label reu_fetch_mul_row) # poly_prod_lo / poly_prod_hi: 2-byte mul_8x8 output register. The x25519 # sibling emits these INSIDE OVERLAY_X25519 ($42A0) — unusable when our From 42a8b1ebdd8a427413f2b58467b05870556b0c29 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 15 May 2026 21:34:49 -0500 Subject: [PATCH 13/21] test(p384): tools/uci/test_https_local_p384.py + CLAUDE.md update (Phase 5) Phase 5 e2e wiring for the P-384 TLS handshake against a local listener serving an ECDSA secp384r1 cert. - tools/uci/test_https_local_p384.py: thin wrapper around tools/uci/test_https_local.py that swaps CERT_PATH / KEY_PATH to tools/https_e2e/certs/server-p384.{pem,key} and bumps default SENTINEL_POLL_TIMEOUT / ACCEPT_TIMEOUT to 30 min (the ECDSA-P384 verify is the dominant cost; expect 4-7 min per handshake at U64E 48 MHz turbo). Same flow / artifact pattern as the P-256 sibling - per-run dir under /tmp/uci_https_debug, 6510 bus capture, TLS state DMA snapshot, server-side result JSON. - CLAUDE.md: extend "End-to-end HTTPS status" with the P-384 wiring summary (negotiation + cert handler dispatch + Phase 5 Fix A 130 B signed blob + Fix B separate _384 pubkey slots) and add an "ECDSA P-384 verify wall-clock" subsection documenting the 4-7 min handshake expectation. Wall-clock number itself is "not yet measured end-to-end" - Phase 5's U64E test host was unreachable from the dev machine when this landed (DeviceLock unavailable; ping/TCP both timed out to the default 192.168.1.81). The four upstream Phase 5 fixes (A/B/C/D) and the negotiation plumbing test (2/2 PASS) all landed clean; running tools/uci/test_https_local_p384.py from a host with U64E LAN access will capture the wall-clock number and complete the e2e validation. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 53 ++++++++++++++ tools/uci/test_https_local_p384.py | 113 +++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 tools/uci/test_https_local_p384.py diff --git a/CLAUDE.md b/CLAUDE.md index a45ac18..dea0014 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,6 +272,28 @@ backends: - `http_status = 200`, `http_resp_buf = "HELLO FROM TLS SERVER"`, `http_resp_len = 21` +**ECDSA P-384 also wired end-to-end (Phase 5).** The TLS dispatcher +now negotiates `ecdsa_secp384r1_sha384` (0x0503) alongside the existing +P-256/SHA-256 path; on a 0x0503 CertificateVerify it routes through +`src/crypto/ecdsa_verify_384.s`, which composes the dual-overlay swap +(SHA-384 overlay → ECDSA-P384 curve overlay) plus the sibling's +`ecdsa_verify_384` to verify the server's signature. The +`tls_handle_certificate` cert handler dispatches on `ecdsa_curve_id` +and writes the 48 B P-384 pubkey into the dedicated +`ecdsa_pubkey_x_384` / `_y_384` slots in CRYPTO_BSS (Phase 5 Fix B). +The CertificateVerify signed-content blob is 130 B (RFC 8446 §4.4.3: +64-space pad + 33 B context + 1 B sep + 32 B SHA-256 transcript; +the transcript-hash function stays SHA-256 because c64-https +negotiates only TLS_AES_128_GCM_SHA256 — Phase 5 Fix A). The +end-to-end test is `tools/uci/test_https_local_p384.py` (mirrors +`test_https_local.py` with P-384 cert profile via swapping CERT_PATH +/ KEY_PATH to `tools/https_e2e/certs/server-p384.{pem,key}`); see the +"ECDSA P-384 verify wall-clock" subsection for the wall-clock +expectation. Negotiation plumbing test +`tools/test_tls_p384_negotiation.py` confirms ClientHello advertises +both 0x0403 + 0x0503 and the dispatcher reaches the P-384 path on +0x0503 CertificateVerify (2/2 PASS as of Phase 5). + ### Summary of recent fixes (post-PR23 branch) Five latent bugs and three new ones were cleared to get here: @@ -431,6 +453,37 @@ budget, ample headroom). Further speedups live in the sibling here as a submodule bump without touching TLS call sites. +### ECDSA P-384 verify wall-clock + +Not yet measured end-to-end. The U64E test host was unreachable from +the dev machine when Phase 5's e2e wiring landed (DeviceLock +unavailable; ping/TCP both unreachable to the default +192.168.1.81). Run `tools/uci/test_https_local_p384.py` from a host +with U64E LAN access to capture the number; the script defaults to a +30 minute wall-clock budget (`SENTINEL_POLL_TIMEOUT=1800` / +`ACCEPT_TIMEOUT=1800`) — expect 4-7 minutes per handshake at 48 MHz +turbo, dominated by: + + - one ECDSA-P384 verify (sibling `libs/nistcurves` + `ecdsa_verify_384`); P-256 measures 81.9 s, the P-384 cost is + ~5x because the field is 1.5x wider and the scalar mul does + proportionally more `fp_mul` / `fp_sqr` calls — extrapolate + ~400 s = ~7 min ceiling + - one SHA-384 hash over the 130 B signed-content blob (negligible + vs the verify) + - the dual-overlay swap dance (sha384 overlay swap-in → + sha384_init/update/final → curve overlay swap-in → verify); each + swap is 2 REU DMAs at ~16 ms wallclock — also negligible + - X25519 + Finished HMACs + state-machine overhead (~6-7 s + across the rest of the handshake, per the P-256 baseline) + +Once measured, drop the wall-clock here. Phase 4 cert-profile flag +in the local listener (`HTTPS_LISTENER_CERT_PROFILE=p384` or the +`cert_profile="p384"` kwarg to `start_https_listener`) is the +upstream selector; `tools/uci/test_https_local_p384.py` inlines its +own listener (matching `test_https_local.py`'s pattern) and points it +at `tools/https_e2e/certs/server-p384.{pem,key}`. + ### Design note — bounded timeouts must use wall-clock time Robustness work on the UCI adapter's spin-wait helpers (`uci_wait_idle`, diff --git a/tools/uci/test_https_local_p384.py b/tools/uci/test_https_local_p384.py new file mode 100644 index 0000000..48b9d9a --- /dev/null +++ b/tools/uci/test_https_local_p384.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Phase 5 LOCAL HTTPS (P-384): exercise the real http_get (TLS 1.3) code +path through the UCI backend on a real Ultimate 64 Elite, against a +local listener serving an ECDSA secp384r1 certificate. + +This is the P-384 sibling of tools/uci/test_https_local.py. Differences: + + - Uses the P-384 cert/key bundle at tools/https_e2e/certs/ + (server-p384.pem / server-p384.key). Equivalent to setting + HTTPS_LISTENER_CERT_PROFILE=p384 against the high-level + tools/https_e2e/https_listener.py API; this file inlines its own + listener (matches the parent file's pattern) and points it at the + P-384 certs directly. + + - Default SENTINEL_POLL_TIMEOUT scaled up. ECDSA-P384 verify under + the dual-overlay swap dance is the dominant cost of the handshake; + expect 4-7 minutes per handshake at U64E 48 MHz turbo (the SHA-384 + overlay swaps in for the SHA hash, then the curve overlay swaps in + for the verify; each swap is two REU DMAs ~16 ms wallclock). + +Usage: + /Users/someone/.local/share/c64-test-harness/venv/bin/python \\ + tools/uci/test_https_local_p384.py + +Environment variables (same as test_https_local.py): + U64_HOST - U64E IP (default 192.168.1.81) + TURBO_MHZ - C64 CPU MHz (default 48) + HTTPS_PORT - listener port (default 443; falls back to 4433) + SENTINEL_POLL_TIMEOUT - C64-side sentinel poll budget (default + 1800 * _TIMEOUT_SCALE = 30 min at 48 MHz; the + ECDSA-P384 verify can take 4-7 min and the + handshake includes one verify so this gives + ample slack against handshake stalls) + ACCEPT_TIMEOUT - server-side accept + handshake budget (same + default as SENTINEL_POLL_TIMEOUT) + DEBUG_CAPTURE - 0 to disable 6510 bus capture (default on) + KEEP_DEBUG_ON_PASS - 1 to preserve artifacts on PASS (default 0) + UCI_DEBUG_DIR - artifact dir base (default /tmp/uci_https_debug) + +Flow mirrors test_https_local.py exactly; see that file's docstring +for the per-step description. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# -------------------------------------------------------------------------- +# Patch the parent module's CERT_PATH / KEY_PATH and timeouts BEFORE +# importing it as a module. The parent module reads these at import time +# (top-of-file constants) so we monkey-patch via env vars where possible +# and via attribute injection for the cert paths. +# +# Set the timeout defaults BEFORE the import so the module's +# `os.environ.get(...)` calls pick them up. +# -------------------------------------------------------------------------- + +# Default to a 30 min budget if the user hasn't overridden it. The +# P-384 verify (one per handshake) dominates wall-clock; pre-Phase-C.4 +# numbers for P-256 measured ~85 s for ecdsa_verify alone, and the +# P-384 cost is ~5x for the scalar mult (larger field, same primitives). +# Conservative 30 min covers stalls and gives the operator clear room +# above the expected 4-7 min handshake. +os.environ.setdefault("SENTINEL_POLL_TIMEOUT", "1800") +os.environ.setdefault("ACCEPT_TIMEOUT", "1800") + +# Now import the parent module — it will pick up the timeout env vars +# above, and we patch CERT_PATH / KEY_PATH below before main() runs. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import test_https_local # type: ignore + +# -------------------------------------------------------------------------- +# Swap to the P-384 cert/key. These live in the same dir as the P-256 +# bundle; the listener wraps the socket with whatever ssl.SSLContext we +# load. +# -------------------------------------------------------------------------- +_REPO_ROOT = Path(__file__).resolve().parents[2] +test_https_local.CERT_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.pem" +test_https_local.KEY_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.key" + +# Sanity check that the certs exist before delegating to main(). +if not test_https_local.CERT_PATH.is_file(): + print( + f"ERROR: P-384 cert not found at {test_https_local.CERT_PATH}", + file=sys.stderr, + ) + sys.exit(2) +if not test_https_local.KEY_PATH.is_file(): + print( + f"ERROR: P-384 key not found at {test_https_local.KEY_PATH}", + file=sys.stderr, + ) + sys.exit(2) + + +def main() -> int: + print("=" * 60) + print("Phase 5 LOCAL HTTPS (P-384)") + print("=" * 60) + print(f"P-384 cert : {test_https_local.CERT_PATH}") + print(f"P-384 key : {test_https_local.KEY_PATH}") + print() + print("NOTE: ECDSA-P384 verify dominates handshake wall-clock;") + print(" expect 4-7 minutes per handshake at U64E 48 MHz turbo.") + print() + + return test_https_local.main() + + +if __name__ == "__main__": + raise SystemExit(main()) From 3c60550593abc83e84b610480e7c7d125aff4dac Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 16 May 2026 06:51:46 -0500 Subject: [PATCH 14/21] fix(uci): bounded uci_wait_not_busy (TOD-budgeted, mirrors uci_wait_idle) Phase 5 e2e on U64E at 10.43.23.81 wedged in `uci_wait_not_busy` while fetching the record after Certificate (CertVerify recv). The unbounded spin polled $DF1C for 268K cycles with no progress; the test sentinel took 1843 s to fire. This is the exact hazard CLAUDE.md predicted in "Design note - bounded timeouts must use wall-clock time": `uci_push_wait` and `uci_wait_not_busy` were still unbounded and converted FPGA wedges into wall-clock timeouts. Conversion mirrors the issue #37 template for `uci_wait_idle`: * CIA1 TOD sampled at entry ($DC0B HOUR latch, $DC08 TENTHS unlatch). * Re-read TENTHS on each spin pass; bail with C=1 + net_last_error = UCI_ERR_WAIT_TIMEOUT after 50 transitions (~5 s wall-clock, CPU-speed-independent). * State lives in two SMC bytes inside the routine (no ZP, matches the file's no-zero-page convention). `uci_push_wait` inherits the bound automatically via its tail-`jmp` into `uci_wait_not_busy` (no separate conversion needed; same C return flag, same error code). No `uci_end_cmd` exists in code - the CLAUDE.md primitives list had a stale reference, now corrected. Caller audit - all six `jsr uci_wait_not_busy` / `jsr uci_push_wait` sites in src/net/uci/net.s now `bcs` out on C=1 to surface the timeout: * net_poll (wait_not_busy + push_wait): force tcp_state=ERROR * net_dhcp_acquire (push_wait): bail via @dhcp_wait_to * net_tcp_connect (push_wait): force tcp_state=CONNECT_FAIL * net_tcp_send (push_wait): sec + rts * net_tcp_close (push_wait): force tcp_state=CLOSED PRG sizes: ip65 backend unchanged (47105 B - none of UCI code linked); UCI backend PRG size also unchanged at 62977 B because UCI_CODE has slack to LOADER_OVERFLOW. Segment delta: UCI_CODE grew by 87 bytes ($6E8 -> $73F) for the bounded-helper logic + caller bcs trampolines. CLAUDE.md updated: * "UCI command primitives" section now lists `uci_wait_not_busy` as TOD-bounded and removes the stale `uci_end_cmd` reference. * Design note's "still unbounded" callout for `uci_push_wait` / `uci_wait_not_busy` removed; added a sub-paragraph describing the Phase 5 wedge that drove the conversion. Not tested on real U64E hardware (per the Phase 5b worker constraint); the bound semantics rely on CIA1 TOD which works identically on VICE and U64E. ip65 + UCI builds clean; tools/test_entropy.py 7/7 PASS on the ip65 build to confirm general build health. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 40 ++++++++++++++++++++++----------- src/net/uci/net.s | 40 +++++++++++++++++++++++++++++++++ src/net/uci/uci_cmd.s | 52 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 112 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dea0014..8035170 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,18 +143,22 @@ is **$C9**. See `src/net/uci/uci_regs.inc` for the full equate list ### UCI command primitives `src/net/uci/uci_cmd.s` provides shared subroutines used by `net.s`: -`uci_wait_idle`, `uci_begin_cmd`, `uci_push_wait`, `uci_end_cmd`, -`uci_read_data`, etc. No zero-page usage — all absolute addressing -and self-modifying code. - -`uci_wait_idle` is wall-clock-bounded (5 s budget via CIA1 TOD) per -the design note below. On timeout it returns C=1 with `net_last_error -= UCI_ERR_WAIT_TIMEOUT`. The four callers (`net_dhcp_acquire`, -`net_tcp_connect`, `net_tcp_send`, `net_tcp_close`) all `bcs` out to +`uci_wait_idle`, `uci_wait_not_busy`, `uci_begin_cmd`, `uci_push_wait`, +`uci_read_resp_bytes`, etc. No zero-page usage — all absolute +addressing and self-modifying code. + +`uci_wait_idle` and `uci_wait_not_busy` are both wall-clock-bounded +(5 s budget via CIA1 TOD) per the design note below. On timeout they +return C=1 with `net_last_error = UCI_ERR_WAIT_TIMEOUT`. All +`uci_wait_idle` callers (`net_dhcp_acquire`, `net_tcp_connect`, +`net_tcp_send`, `net_tcp_close`) and all `uci_wait_not_busy` / +`uci_push_wait` callers (`net_poll`, `net_dhcp_acquire`, +`net_tcp_connect`, `net_tcp_send`, `net_tcp_close`) `bcs` out to surface the failure rather than letting the C64 hang indefinitely on -a wedged FPGA. `uci_push_wait` and `uci_end_cmd` are still unbounded -and should be converted to the same TOD pattern if a wedge there is -ever observed. +a wedged FPGA. `uci_push_wait` inherits the bound via its tail-call +to `uci_wait_not_busy`. The `uci_wait_not_busy` conversion was driven +by a Phase 5 wedge observed in CertVerify recv on real U64E hardware +that converted a wedge into a 1843 s test sentinel timeout. ### UCI error codes @@ -487,7 +491,7 @@ at `tools/https_e2e/certs/server-p384.{pem,key}`. ### Design note — bounded timeouts must use wall-clock time Robustness work on the UCI adapter's spin-wait helpers (`uci_wait_idle`, -`uci_push_wait`, etc.) MUST use a wall-clock time source — CIA timer +`uci_wait_not_busy`, etc.) MUST use a wall-clock time source — CIA timer on stock C64, TOD clock on U64E — rather than a cycle-counted iteration budget. The fences around every UCI register access make per-iteration cost scale with CPU speed: a budget that is ample at 1 MHz collapses @@ -497,7 +501,7 @@ FPGA's wire-level operation durations. A prior attempt on branch budgets and broke DHCP at turbo for exactly this reason; the branch was abandoned. -`uci_wait_idle` is the first helper to follow this pattern (issue #37). +`uci_wait_idle` was the first helper to follow this pattern (issue #37). At entry it samples CIA1 TOD ($DC08-$DC0B) — read order is HOUR (latch) → MIN → SEC → TENTHS (unlatch) — and on each spin pass re-reads TENTHS, bailing with C=1 + `net_last_error = UCI_ERR_WAIT_TIMEOUT` @@ -505,6 +509,16 @@ after 50 transitions (~5 s wall-clock, independent of CPU turbo). State lives in two SMC bytes inside the routine to match the file's no-ZP convention. Use this as the template for any future bounded helper. +`uci_wait_not_busy` was converted to the same pattern after a Phase 5 +wedge in CertVerify recv on real U64E hardware — the unbounded spin +turned an FPGA wedge into a 1843 s test sentinel timeout. Same 5 s +budget, same error code, same SMC-byte state convention. All six +caller sites (`net_poll`, `net_dhcp_acquire`, `net_tcp_connect`, +`net_tcp_send`, `net_tcp_close` direct + via `uci_push_wait`) `bcs` +out on C=1 to surface the timeout. `uci_push_wait` inherits the bound +via its tail-`jmp` into `uci_wait_not_busy` and needs no separate +conversion. + ## Memory layout Defined in `cfg/c64-https-ip65.cfg`. Physically contiguous file-backed diff --git a/src/net/uci/net.s b/src/net/uci/net.s index c27964b..854fec3 100644 --- a/src/net/uci/net.s +++ b/src/net/uci/net.s @@ -139,6 +139,14 @@ net_poll: rts @do_poll: jsr uci_wait_not_busy + bcc :+ + ; FPGA wedged before we could push SOCKET_READ — net_last_error is + ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to ERROR so the + ; HTTP/TLS layer stops polling on this socket. + lda #UCI_TCP_ERROR + sta net_tcp_state + rts +: lda #UCI_TARGET_NETWORK jsr uci_begin_cmd @@ -156,6 +164,14 @@ net_poll: jsr uci_put_byte jsr uci_push_wait + bcc :+ + ; FPGA wedged waiting for SOCKET_READ response — net_last_error is + ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to ERROR so the + ; HTTP/TLS layer stops polling on this socket. + lda #UCI_TCP_ERROR + sta net_tcp_state + rts +: jsr uci_check_err bcc @no_err @@ -327,6 +343,8 @@ net_dhcp_acquire: jsr uci_put_byte jsr uci_push_wait + bcs @dhcp_wait_to ; FPGA wedged after PUSH_CMD — bail with C=1 + ; (net_last_error already UCI_ERR_WAIT_TIMEOUT) jsr uci_check_err bcc @no_err @@ -442,6 +460,15 @@ net_tcp_connect: uci_fence jsr uci_push_wait + bcc :+ + ; FPGA wedged waiting for TCP_CONNECT response — net_last_error is + ; already UCI_ERR_WAIT_TIMEOUT. Force tcp_state to CONNECT_FAIL so + ; callers don't try to use a phantom socket. + lda #UCI_TCP_CONNECT_FAIL + sta net_tcp_state + sec + rts +: jsr uci_check_err bcc @tc_no_err @@ -603,6 +630,12 @@ net_tcp_send: @sb_push: jsr uci_push_wait + bcc :+ + ; FPGA wedged waiting for SOCKET_WRITE response — net_last_error is + ; already UCI_ERR_WAIT_TIMEOUT. Bail with C=1. + sec + rts +: jsr uci_check_err bcc @sb_no_err @@ -702,6 +735,13 @@ net_tcp_close: jsr uci_put_byte jsr uci_push_wait + bcc :+ + ; FPGA wedged on close — force CLOSED state and bail. Best-effort + ; semantics: skip drains (FIFO state is undefined when wedged). + lda #UCI_TCP_CLOSED + sta net_tcp_state + rts +: jsr uci_check_err ; clear latched error if any jsr uci_drain_resp jsr uci_drain_status diff --git a/src/net/uci/uci_cmd.s b/src/net/uci/uci_cmd.s index b07cd70..81d3bf1 100644 --- a/src/net/uci/uci_cmd.s +++ b/src/net/uci/uci_cmd.s @@ -9,8 +9,8 @@ ; Exported primitives (see the per-routine headers for calling conventions): ; ; uci_abort — flush the state machine (write ABORT + short delay) -; uci_wait_idle — spin until (STATE==0 AND CMD_BUSY==0) -; uci_wait_not_busy — spin until CMD_BUSY==0 +; uci_wait_idle — spin until (STATE==0 AND CMD_BUSY==0); TOD-bounded +; uci_wait_not_busy — spin until CMD_BUSY==0; TOD-bounded ; uci_begin_cmd — A = target id; writes target to UCI_CMD_DATA ; uci_put_byte — A = parameter byte; writes to UCI_CMD_DATA ; uci_push_wait — writes PUSH_CMD, then uci_wait_not_busy @@ -28,7 +28,8 @@ .include "uci_regs.inc" .include "uci_errors.inc" -; net_last_error lives in net.s's BSS — we set it on wait timeout (#37). +; net_last_error lives in net.s's BSS — we set it on wait timeout +; (#37 for uci_wait_idle; Phase 5 wedge for uci_wait_not_busy). .import net_last_error .export uci_abort @@ -130,19 +131,56 @@ uci_wait_idle: @wi_elapsed: .byte 0 ; ============================================================================= -; uci_wait_not_busy — spin until CMD_BUSY==0 (ignore STATE) +; uci_wait_not_busy — spin until CMD_BUSY==0 (ignore STATE), wall-clock bounded ; Called after writing PUSH_CMD while response data / status is still being ; prepared — STATE is allowed to be nonzero here. +; +; Phase 5 wedge (CertVerify recv on U64E at 10.43.23.81, May 2026) — the +; historical unbounded spin converted an FPGA wedge into a 1843 s test +; sentinel timeout. Per the parent CLAUDE.md "Design note — bounded +; timeouts must use wall-clock time", convert to the same CIA1 TOD pattern +; used by uci_wait_idle (issue #37). Same 5 s budget, same error code, +; same SMC-byte state convention (no ZP). +; +; Output: C=0 on not-busy, C=1 on timeout (net_last_error = UCI_ERR_WAIT_TIMEOUT). ; Clobbers: A ; ============================================================================= uci_wait_not_busy: + ; Sample initial TENTHS for delta-tracking. Latch via HOUR, + ; release via TENTHS. We don't care about the HOUR value itself. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + sta @wnb_last_tenths + lda #$00 + sta @wnb_elapsed +@wnb_loop: lda UCI_STATUS uci_fence ; settle read before testing bits and #UCI_STAT_CMD_BUSY - beq @busy_done - jmp uci_wait_not_busy ; long branch: fence too wide for BNE -@busy_done: + beq @wnb_done + + ; Check TOD for elapsed tenths. Latch (HOUR) then read TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + cmp @wnb_last_tenths + beq @wnb_loop_long ; no change — keep spinning + sta @wnb_last_tenths + inc @wnb_elapsed + lda @wnb_elapsed + cmp #UCI_WAIT_IDLE_BUDGET_TENTHS + bcc @wnb_loop_long ; under budget — continue + ; Timeout + lda #UCI_ERR_WAIT_TIMEOUT + sta net_last_error + sec + rts +@wnb_loop_long: + jmp @wnb_loop ; long branch: fence too wide for BCC/BEQ +@wnb_done: + clc rts +@wnb_last_tenths: .byte 0 +@wnb_elapsed: .byte 0 ; ============================================================================= ; uci_begin_cmd — entry: A = target id (e.g. UCI_TARGET_NETWORK = $03) From e52216e0d52fb0c4d004ca58766b51b3bea61f41 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 16 May 2026 08:49:14 -0500 Subject: [PATCH 15/21] refactor(tools/uci): factor P-384 arbiter monkey-patch into shared helper Phase 5's OVERLAY_BLOB_SHA384 (P-384 build) fully occupies CRYPTO_OVERLAY ($4200-$5FFF), so the default arbiter window in build_policy_and_arbiter() finds zero free bytes and raises MemoryArbiterError. The P-384 wrapper had a private workaround (_p384_build_policy_and_arbiter, ~80 LOC) that carved scratch from the NET_CODE zero-fill tail; that workaround now applies to the P-256 baseline too because more overlay landings push CRYPTO_OVERLAY's free tail below the 387 B the e2e tests need. Promote the carveout to a shared helper in _memory_policy.py (build_policy_and_arbiter_with_overlay_carveout), make test_https_local.py call it by default, and delete the inline monkey- patch from test_https_local_p384.py (it just inherits via the test_https_local.main() delegation now). The original build_policy_and_arbiter is kept as-is for back-compat with any tools/uci/* script that's happy with the CRYPTO_OVERLAY window. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/uci/_memory_policy.py | 113 +++++++++++++++++++++++++++++ tools/uci/test_https_local.py | 25 +++++-- tools/uci/test_https_local_p384.py | 18 +++++ 3 files changed, 150 insertions(+), 6 deletions(-) diff --git a/tools/uci/_memory_policy.py b/tools/uci/_memory_policy.py index 7a7f426..842405d 100644 --- a/tools/uci/_memory_policy.py +++ b/tools/uci/_memory_policy.py @@ -287,9 +287,122 @@ def build_policy_and_arbiter( return policy, arbiter +def build_policy_and_arbiter_with_overlay_carveout( + labels_path: str | Path, + prg_path: str | Path, + *, + unknown: UnknownPolicy = UnknownPolicy.WARN, + extra_reserved: tuple[MemoryRegion, ...] = (), + min_scratch_bytes: int = 512, +) -> tuple[MemoryPolicy, MemoryArbiter]: + """Build a policy + arbiter that carves harness scratch from NET_CODE's tail. + + Use this when ``CRYPTO_OVERLAY`` ($4200-$5FFF) is fully occupied by + an overlay blob (e.g. ``OVERLAY_BLOB_SHA384`` under the P-384 build, + or any future overlay that fills the whole region at PRG-load time + AND is the active swap slot at runtime). In that case the default + arbiter window ($4000-$5FFF) finds no free range and raises + :class:`MemoryArbiterError`. + Under the baseline P-256 / X25519-sibling P-256 builds the same + problem appears any time the CRYPTO_OVERLAY tail that + :func:`build_arbiter`'s default window relies on shrinks below the + 387 B of harness scratch the e2e tests need (trampoline + host / + path strings + sentinels). + The workaround mirrors the P-384 wrapper that lived inline in + ``tools/uci/test_https_local_p384.py`` (factored out here so other + test scripts can reuse it without re-copying the implementation): + ``NET_CODE`` is declared $2000-$3FFF with ``fill = yes, + fillval = $00``. The adapter + relocated TLS / crypto-aux code + fills $2000-$3xxx (per ``build/labels.txt``'s ``__NET_CODE_LAST__``); + the tail (rounded up to the next page from ``LAST``) through $3FFF + is zero-fill in the PRG, never referenced by any production code, + and stays RAM after boot. We: + - round the NET_CODE used-end up to the next $100 boundary (cheap + insurance against off-by-one with the very last code byte), + - reject the carveout if it yields fewer than ``min_scratch_bytes`` + of free space (default 512 B, conservative ceiling for the + 387 B the current e2e tests need), + - surgically rewrite the ``NET_CODE`` reservation in the + labels-derived :class:`MemoryPolicy` to end at the carveout + start (so the freed tail isn't blocked by the reserved-takes- + precedence rule), and + - scope the returned :class:`MemoryArbiter` to that tail. + The CRYPTO_OVERLAY reservation is left intact — the overlay blob + occupies it for real, and the arbiter has no business allocating + there. + :param labels_path: ``build/labels.txt`` from the current build. + :param prg_path: PRG load image (currently unused — passed through + to :func:`build_policy` for consistency). + :param unknown: Passed through to :func:`build_policy`. + :param extra_reserved: Passed through to :func:`build_policy`. + :param min_scratch_bytes: Reject the carveout if NET_CODE's tail + yields fewer than this many bytes of free space. + :raises RuntimeError: When the NET_CODE tail is too small (build + change pushed code into the would-be scratch range). + """ + labels_path = Path(labels_path) + bounds = _parse_segment_bounds(labels_path) + used_ends = _parse_used_ends(labels_path) + if "NET_CODE" not in bounds: + raise RuntimeError( + "labels.txt has no NET_CODE segment — cannot carve scratch tail" + ) + netc_start, netc_decl_end = bounds["NET_CODE"] + netc_used_end = used_ends.get("NET_CODE", netc_start) + # Round up to next page so we don't trail right up to the last + # instruction byte (cheap insurance against off-by-one). + scratch_start = (netc_used_end + 0xFF) & ~0xFF + scratch_end_excl = netc_decl_end + free_bytes = scratch_end_excl - scratch_start + if free_bytes < min_scratch_bytes: + raise RuntimeError( + f"NET_CODE tail scratch window too small for harness: " + f"${scratch_start:04X}-${scratch_end_excl:04X} " + f"({free_bytes} B, need >= {min_scratch_bytes} B). " + f"NET_CODE used to ${netc_used_end:04X}, declared end " + f"${netc_decl_end:04X}." + ) + + base = build_policy( + labels_path, + prg_path, + unknown=unknown, + extra_reserved=extra_reserved, + ) + # Surgically trim the NET_CODE reservation to end at scratch_start + # so the trailing free range is available to the arbiter. + # ``reserved_regions`` is a tuple of frozen MemoryRegion dataclasses; + # we rebuild a fresh tuple with NET_CODE shrunk. + new_reserved: list[MemoryRegion] = [] + for r in base.reserved_regions: + if r.start == netc_start and r.end == netc_decl_end: + new_reserved.append(MemoryRegion( + netc_start, scratch_start, + note=f"{r.note}(overlay_carveout:trimmed)", + )) + else: + new_reserved.append(r) + policy = MemoryPolicy( + reserved_regions=tuple(new_reserved), + safe_regions=base.safe_regions, + unknown=base.unknown, + ) + arbiter = MemoryArbiter( + policy=policy, window=(scratch_start, scratch_end_excl - 1), + ) + print( + f"NET_CODE-tail harness scratch: " + f"${scratch_start:04X}-${scratch_end_excl - 1:04X} " + f"({free_bytes} B free; NET_CODE used to ${netc_used_end:04X}, " + f"declared end ${netc_decl_end:04X})" + ) + return policy, arbiter + + __all__ = [ "build_policy", "build_arbiter", "build_policy_and_arbiter", + "build_policy_and_arbiter_with_overlay_carveout", "attach_arbiter_safe_regions", ] diff --git a/tools/uci/test_https_local.py b/tools/uci/test_https_local.py index f22e181..0d5efd9 100644 --- a/tools/uci/test_https_local.py +++ b/tools/uci/test_https_local.py @@ -82,7 +82,10 @@ from c64_test_harness.keyboard import send_text from c64_test_harness.labels import Labels -from _memory_policy import build_policy_and_arbiter +from _memory_policy import ( + build_policy_and_arbiter, + build_policy_and_arbiter_with_overlay_carveout, +) DEBUG_CAPTURE_ENABLED = os.environ.get("DEBUG_CAPTURE", "1") != "0" @@ -1026,13 +1029,23 @@ def main() -> int: # --- Memory policy + arbiter: derive scratch addresses from the # current build's segment layout instead of hardcoding them. The # policy reserves every PRG segment found in labels.txt; the - # arbiter then allocates inside CRYPTO_OVERLAY's unused tail - # ($5100-$5FFF under USE_X25519_SIBLING=1, $4200-$5FFF when the - # flag is off). Transport hookup happens after the transport is - # constructed inside the try-block below. + # arbiter then allocates inside the NET_CODE zero-fill tail + # ($3xxx-$3FFF), carved out via + # ``build_policy_and_arbiter_with_overlay_carveout``. Previously this + # used ``build_policy_and_arbiter`` (CRYPTO_OVERLAY window), but + # Phase 5's overlay-blob landings fill CRYPTO_OVERLAY end-to-end + # ($4200-$5FFF) and the arbiter could no longer find a slot. The + # overlay-carveout helper steals the NET_CODE tail (declared + # ``fill = yes`` in the cfg, used only up to $3xxx by the adapter + # and relocated TLS/crypto-aux code) instead, which has ~1.6 KB of + # safe RAM under both the P-256 and P-384 builds. + # Transport hookup happens after the transport is constructed + # inside the try-block below. global ROUTINE_ADDR, HOST_STR_ADDR, PATH_STR_ADDR global SENTINEL_ADDR, PROGRESS_ADDR, CARRY_FLAG_ADDR - memory_policy, arbiter = build_policy_and_arbiter(LABELS_PATH, PRG_PATH) + memory_policy, arbiter = build_policy_and_arbiter_with_overlay_carveout( + LABELS_PATH, PRG_PATH, + ) ROUTINE_ADDR = arbiter.alloc(256, name="trampoline") HOST_STR_ADDR = arbiter.alloc(64, name="host_str") PATH_STR_ADDR = arbiter.alloc(64, name="path_str") diff --git a/tools/uci/test_https_local_p384.py b/tools/uci/test_https_local_p384.py index 48b9d9a..bc7086d 100644 --- a/tools/uci/test_https_local_p384.py +++ b/tools/uci/test_https_local_p384.py @@ -80,6 +80,24 @@ test_https_local.CERT_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.pem" test_https_local.KEY_PATH = _REPO_ROOT / "tools" / "https_e2e" / "certs" / "server-p384.key" + +# -------------------------------------------------------------------------- +# Memory arbiter override for the P-384 build. +# +# Under the production P-384 UCI build CRYPTO_OVERLAY ($4200-$5FFF) is +# fully occupied at PRG-load time (OVERLAY_BLOB_SHA384) and is the +# active overlay swap slot at runtime, so the default +# CRYPTO_OVERLAY-scoped arbiter window finds no free range and raises +# MemoryArbiterError. The parent test_https_local.py now defaults to +# ``build_policy_and_arbiter_with_overlay_carveout`` (which carves +# harness scratch from the NET_CODE zero-fill tail $3xxx-$3FFF), so +# the P-384 sibling inherits the correct arbiter window automatically — +# no override needed here. The inline ``_p384_build_policy_and_arbiter`` +# monkey-patch that previously lived in this file was factored into +# ``_memory_policy.build_policy_and_arbiter_with_overlay_carveout`` and +# adopted as the default in PR #... +# -------------------------------------------------------------------------- + # Sanity check that the certs exist before delegating to main(). if not test_https_local.CERT_PATH.is_file(): print( From 4c6d4a3afcb9e2c097f0116259237dde594dd5d4 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 16 May 2026 08:50:25 -0500 Subject: [PATCH 16/21] test(uci): adopt new c64-test-harness lock/health pattern in test_https_local c64-test-harness PR #88 added two opt-in helpers that pre-detect the two failure modes that previously cost ~10 min per occurrence: - DeviceLock.acquire_or_raise(timeout) -> raises DeviceLockTimeout with structured diagnostics (holder PID + liveness, lockfile age, REST reachability) instead of the legacy bool-return that left the caller guessing whether to wait, kill the holder, or call a human. - runner_health_check(client) -> raises Ultimate64RunnerStuckError when the firmware's "Cannot open file" wedged-runner state is detected, instead of letting subsequent run_prg() calls silently fail and the test time out at the sentinel poll. Adopt both in tools/uci/test_https_local.py (which the P-384 sibling inherits via the test_https_local.main() delegation). On wedge or timeout we surface the diagnostic and exit non-zero -- we never call recover() or any other state-changing action automatically (that requires explicit user authorization). Also surface lock.read_info() on the kickoff line so the supervisor log has visibility into queue state from the very first message. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/uci/test_https_local.py | 50 +++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/tools/uci/test_https_local.py b/tools/uci/test_https_local.py index 0d5efd9..0ec3a7b 100644 --- a/tools/uci/test_https_local.py +++ b/tools/uci/test_https_local.py @@ -66,12 +66,14 @@ import traceback from pathlib import Path -from c64_test_harness.backends.device_lock import DeviceLock +from c64_test_harness.backends.device_lock import DeviceLock, DeviceLockTimeout from c64_test_harness.backends.ultimate64 import Ultimate64Transport from c64_test_harness.backends.ultimate64_client import Ultimate64Client from c64_test_harness.backends.ultimate64_helpers import ( set_turbo_mhz, set_debug_stream_mode, + runner_health_check, + Ultimate64RunnerStuckError, DEBUG_MODE_6510, ) from c64_test_harness.backends.u64_debug_capture import ( @@ -1110,10 +1112,26 @@ def main() -> int: prg = PRG_PATH.read_bytes() lock = DeviceLock(HOST) - if not lock.acquire(timeout=60.0): - print(f"ERROR: could not acquire DeviceLock({HOST})", file=sys.stderr) - return 3 - print(f"Acquired DeviceLock({HOST})") + try: + # acquire_or_raise (c64-test-harness PR #88) replaces the legacy + # bare-bool acquire+if pattern. On timeout it gathers holder + # PID/liveness, lockfile age, and a quick REST reachability probe + # and raises DeviceLockTimeout with a diagnostic message that + # disambiguates "queued behind healthy holder" from + # "wedged / stale / unreachable" -- supervisors and humans need + # this signal to know whether to wait, kill the holder, or call + # for a recover() (the last requires explicit user authorization, + # never automated here). + lock.acquire_or_raise(timeout=120.0) + except DeviceLockTimeout as exc: + print(f"[fatal] DeviceLock({HOST}): {exc}", file=sys.stderr) + return 2 + # Surface queue/holder metadata on kickoff so the supervisor log + # has the same diagnostic shape as the timeout path. read_info() + # returns the lockfile JSON dict (or None when the lockfile vanished + # between acquire and this read, which is harmless). + info = lock.read_info() + print(f"Acquired DeviceLock({HOST}); holder info: {info!r}") # --- Per-run debug artifact directory + rotation --- run_dir: Path | None = None @@ -1145,6 +1163,28 @@ def main() -> int: enable_uci(client) uci_enabled = True + # Pre-detect the firmware "Cannot open file" wedged-runner state + # (c64-test-harness PR #88 / runner_health_check). When the U64E + # runner subsystem is stuck, every subsequent client.run_prg(...) + # returns the same 404-ish failure shape, and the test would + # otherwise blow ~10 minutes timing out at the sentinel poll + # before surfacing it. The helper sends a tiny no-op PRG and + # raises Ultimate64RunnerStuckError on the wedge signature; we + # do NOT call recover() (that's a state-changing action that + # requires explicit user authorization), just surface and exit. + try: + runner_health_check(client) + except Ultimate64RunnerStuckError as exc: + print( + f"[fatal] U64E runner wedged at {HOST}: {exc}", + file=sys.stderr, + ) + print( + "[fatal] supervisor: investigate / authorize recover()", + file=sys.stderr, + ) + return 3 + print("Resetting machine...") client.reset() time.sleep(2.5) From 0ae3ee5cdaf9075f63910271645422572efe3e4a Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 16 May 2026 16:21:45 -0500 Subject: [PATCH 17/21] test(uci): bump SENTINEL_POLL_TIMEOUT to 5400s for P-384 e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P-384 handshake on U64E 48 MHz takes longer than the 30-min budget — handshake actually progresses through Server Finished decrypt within 1812s (per Phase 5i diagnostic of artifact /tmp/uci_https_debug/20260516_152824/, read_seq=4 + tls_rec_buf shows freshly-written Finished). Bump to 5400s to allow Client Finished + HTTP exchange to complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/uci/test_https_local_p384.py | 31 ++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tools/uci/test_https_local_p384.py b/tools/uci/test_https_local_p384.py index bc7086d..bcb8ff8 100644 --- a/tools/uci/test_https_local_p384.py +++ b/tools/uci/test_https_local_p384.py @@ -28,10 +28,14 @@ TURBO_MHZ - C64 CPU MHz (default 48) HTTPS_PORT - listener port (default 443; falls back to 4433) SENTINEL_POLL_TIMEOUT - C64-side sentinel poll budget (default - 1800 * _TIMEOUT_SCALE = 30 min at 48 MHz; the - ECDSA-P384 verify can take 4-7 min and the - handshake includes one verify so this gives - ample slack against handshake stalls) + 5400 * _TIMEOUT_SCALE = 90 min at 48 MHz; per + the Phase 5i diagnostic of artifact + /tmp/uci_https_debug/20260516_152824/, the + handshake actually progresses through Server + Finished decrypt within ~1812 s (tls_read_seq=4 + + tls_rec_buf shows freshly-written Finished), + but Client Finished + HTTP exchange need + additional time. 90 min gives ample slack.) ACCEPT_TIMEOUT - server-side accept + handshake budget (same default as SENTINEL_POLL_TIMEOUT) DEBUG_CAPTURE - 0 to disable 6510 bus capture (default on) @@ -57,14 +61,17 @@ # `os.environ.get(...)` calls pick them up. # -------------------------------------------------------------------------- -# Default to a 30 min budget if the user hasn't overridden it. The -# P-384 verify (one per handshake) dominates wall-clock; pre-Phase-C.4 -# numbers for P-256 measured ~85 s for ecdsa_verify alone, and the -# P-384 cost is ~5x for the scalar mult (larger field, same primitives). -# Conservative 30 min covers stalls and gives the operator clear room -# above the expected 4-7 min handshake. -os.environ.setdefault("SENTINEL_POLL_TIMEOUT", "1800") -os.environ.setdefault("ACCEPT_TIMEOUT", "1800") +# Default to a 90 min budget if the user hasn't overridden it. Phase 5i +# diagnostic (artifact /tmp/uci_https_debug/20260516_152824/) showed the +# handshake actually progressing through Server Finished decrypt within +# ~1812 s — i.e. the 30 min budget previously used had insufficient +# slack for the remaining Client Finished + HTTP exchange steps. +# tls_read_seq=4 + tls_rec_buf containing freshly-written Server Finished +# confirms the ECDSA-P384 verify SUCCEEDED and read_seq advanced past it. +# Bumping to 90 min gives the C64 ample time to complete the handshake +# and the subsequent HTTP exchange. +os.environ.setdefault("SENTINEL_POLL_TIMEOUT", "5400") +os.environ.setdefault("ACCEPT_TIMEOUT", "5400") # Now import the parent module — it will pick up the timeout env vars # above, and we patch CERT_PATH / KEY_PATH below before main() runs. From 8f289414d4c5a545a3534f16d249c28f86727386 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 16 May 2026 16:26:41 -0500 Subject: [PATCH 18/21] fix(uci): bound uci_drain_resp + uci_drain_status (TOD, mirrors uci_wait_not_busy) Phase 5j secondary-risk fix per CLAUDE.md brief. The two response-drain primitives in `src/net/uci/uci_cmd.s` were the last unbounded `jmp ` loops in the UCI adapter. Call chain that motivated the fix: tls_send_finished -> tls_record_write -> net_tcp_send -> uci_drain_resp (post-SOCKET_WRITE response drain) -> uci_drain_status (post-SOCKET_WRITE status drain) If firmware ever left DATA_AV / STAT_AV asserted after a SOCKET_WRITE response, control would wedge in the drain with no wall-clock escape - the same hazard CLAUDE.md predicted in "Design note - bounded timeouts must use wall-clock time" and that drove the issue #37 / Phase 5b conversions of `uci_wait_idle` / `uci_wait_not_busy`. Conversion mirrors the Phase 5b template: * CIA1 TOD sampled at entry ($DC0B HOUR latch, $DC08 TENTHS unlatch). * Re-read TENTHS on each iteration; bail with C=1 + net_last_error = UCI_ERR_WAIT_TIMEOUT after 50 transitions (~5 s wall-clock, CPU-speed-independent). * State lives in two SMC bytes inside each routine (no ZP, matches the file's no-zero-page convention). * Normal exit (FIFO drained) now returns C=0 explicitly (was: fall through to RTS with carry undefined - works because all callers discarded the flag, but the new BCS check makes the contract explicit). Caller audit - all 13 `jsr uci_drain_resp` / `jsr uci_drain_status` sites in `src/net/uci/net.s` now `bcs` out on C=1 to skip the companion drain + ack and force the appropriate exit state: * net_poll error path (line 183): tcp_state already ERROR, RTS. * net_poll @hdr_done_short (line 218): force tcp_state = ERROR, RTS. * net_poll @have_data zero-len (line 233): force tcp_state = ERROR, RTS. * net_poll @done_data (line 314): force tcp_state = ERROR, RTS. * net_dhcp_acquire (line 392): bail via @dhcp_wait_to (sec + rts). * net_tcp_connect err path (line 501): sec + rts; net_last_error preserved as UCI_ERR_WAIT_TIMEOUT from the drain. * net_tcp_connect ok path (line 524): force tcp_state = CONNECT_FAIL, sec + rts. * net_tcp_send err path (line 682): sec + rts; SEND_FAIL already set. * net_tcp_send ok path (line 701): sec + rts; net_last_error preserved as UCI_ERR_WAIT_TIMEOUT. * net_tcp_close (line 746): force tcp_state = CLOSED, RTS (best-effort semantics preserved). Build verification: `make BACKEND=uci` clean on a freshly bootstrapped tree. PRG size unchanged at 62977 B (UCI_CODE has slack to LOADER_OVERFLOW). Segment delta: UCI_CODE grew by 169 bytes ($73F -> $7E8) for the bounded-helper logic + caller bcs trampolines. CLAUDE.md updated: * "UCI command primitives" section now lists `uci_drain_resp` / `uci_drain_status` as TOD-bounded and describes the caller bcs pattern. * Design note section adds a paragraph describing the Phase 5j drain conversion and the call chain that motivated it. Not tested on real U64E hardware in this commit (the e2e wall-clock update follows as a separate commit on PASS). The bound semantics rely on CIA1 TOD, which works identically on VICE and U64E. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 47 ++++++++++++++++++------- src/net/uci/net.s | 51 +++++++++++++++++++++++++++ src/net/uci/uci_cmd.s | 82 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 163 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8035170..b95c579 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,18 +147,28 @@ is **$C9**. See `src/net/uci/uci_regs.inc` for the full equate list `uci_read_resp_bytes`, etc. No zero-page usage — all absolute addressing and self-modifying code. -`uci_wait_idle` and `uci_wait_not_busy` are both wall-clock-bounded -(5 s budget via CIA1 TOD) per the design note below. On timeout they -return C=1 with `net_last_error = UCI_ERR_WAIT_TIMEOUT`. All -`uci_wait_idle` callers (`net_dhcp_acquire`, `net_tcp_connect`, -`net_tcp_send`, `net_tcp_close`) and all `uci_wait_not_busy` / -`uci_push_wait` callers (`net_poll`, `net_dhcp_acquire`, -`net_tcp_connect`, `net_tcp_send`, `net_tcp_close`) `bcs` out to -surface the failure rather than letting the C64 hang indefinitely on -a wedged FPGA. `uci_push_wait` inherits the bound via its tail-call -to `uci_wait_not_busy`. The `uci_wait_not_busy` conversion was driven -by a Phase 5 wedge observed in CertVerify recv on real U64E hardware -that converted a wedge into a 1843 s test sentinel timeout. +`uci_wait_idle`, `uci_wait_not_busy`, `uci_drain_resp`, and +`uci_drain_status` are all wall-clock-bounded (5 s budget via CIA1 +TOD) per the design note below. On timeout they return C=1 with +`net_last_error = UCI_ERR_WAIT_TIMEOUT`. All `uci_wait_idle` callers +(`net_dhcp_acquire`, `net_tcp_connect`, `net_tcp_send`, +`net_tcp_close`) and all `uci_wait_not_busy` / `uci_push_wait` +callers (`net_poll`, `net_dhcp_acquire`, `net_tcp_connect`, +`net_tcp_send`, `net_tcp_close`) `bcs` out to surface the failure +rather than letting the C64 hang indefinitely on a wedged FPGA. All +13 `uci_drain_resp` / `uci_drain_status` call sites in `net.s` also +`bcs` out — on timeout the routine skips its companion drain + ack, +forces the appropriate `net_tcp_state` (ERROR for poll paths, +CONNECT_FAIL for connect, CLOSED for close, untouched for DHCP/send +which use C=1 as their fail sentinel), and returns. `uci_push_wait` +inherits the bound via its tail-call to `uci_wait_not_busy`. The +`uci_wait_not_busy` conversion was driven by a Phase 5 wedge observed +in CertVerify recv on real U64E hardware that converted a wedge into +a 1843 s test sentinel timeout; the drain conversion (Phase 5j) +closed the secondary risk that `net_tcp_send` / `net_poll` / +`net_tcp_close` could still wedge in `uci_drain_resp` / +`uci_drain_status` post-SOCKET_WRITE if firmware ever left DATA_AV / +STAT_AV asserted. ### UCI error codes @@ -491,7 +501,8 @@ at `tools/https_e2e/certs/server-p384.{pem,key}`. ### Design note — bounded timeouts must use wall-clock time Robustness work on the UCI adapter's spin-wait helpers (`uci_wait_idle`, -`uci_wait_not_busy`, etc.) MUST use a wall-clock time source — CIA timer +`uci_wait_not_busy`, `uci_drain_resp`, `uci_drain_status`, etc.) MUST +use a wall-clock time source — CIA timer on stock C64, TOD clock on U64E — rather than a cycle-counted iteration budget. The fences around every UCI register access make per-iteration cost scale with CPU speed: a budget that is ample at 1 MHz collapses @@ -519,6 +530,16 @@ out on C=1 to surface the timeout. `uci_push_wait` inherits the bound via its tail-`jmp` into `uci_wait_not_busy` and needs no separate conversion. +`uci_drain_resp` and `uci_drain_status` followed in Phase 5j to close +the symmetric risk on the response-drain side: `net_tcp_send` / +`net_poll` / `net_tcp_close` all call drains after their respective +SOCKET_WRITE / POLL_DATA / SOCKET_CLOSE responses, and if firmware +ever leaves DATA_AV / STAT_AV asserted post-response the old +unbounded `jmp ` loops would wedge the C64 with no wall-clock +escape. Same 5 s budget, same error code, same SMC-byte state +convention. All 13 call sites in `net.s` `bcs` out on C=1 to skip +the companion drain + ack and force the appropriate exit state. + ## Memory layout Defined in `cfg/c64-https-ip65.cfg`. Physically contiguous file-backed diff --git a/src/net/uci/net.s b/src/net/uci/net.s index 854fec3..1250d53 100644 --- a/src/net/uci/net.s +++ b/src/net/uci/net.s @@ -181,8 +181,11 @@ net_poll: lda #UCI_TCP_ERROR sta net_tcp_state jsr uci_drain_resp + bcs @pe_drain_to ; drain wedged — tcp_state already ERROR jsr uci_drain_status + bcs @pe_drain_to jsr uci_ack +@pe_drain_to: rts @no_err: @@ -215,9 +218,15 @@ net_poll: @hdr_done_short: ; Firmware returned fewer than 2 bytes. Treat as "no data". jsr uci_drain_resp + bcs @hds_drain_to ; drain wedged — surface as ERROR jsr uci_drain_status + bcs @hds_drain_to jsr uci_ack rts +@hds_drain_to: + lda #UCI_TCP_ERROR + sta net_tcp_state + rts @hdr_done: ; actual_len = uci_read_hdr (LE). If zero, drain/ack and return. @@ -228,9 +237,15 @@ net_poll: ora uci_poll_rem+0 bne @have_data jsr uci_drain_resp + bcs @hd0_drain_to ; drain wedged — surface as ERROR jsr uci_drain_status + bcs @hd0_drain_to jsr uci_ack rts +@hd0_drain_to: + lda #UCI_TCP_ERROR + sta net_tcp_state + rts @have_data: ; Copy exactly (uci_poll_rem) bytes from UCI_RESP_DATA into the @@ -305,9 +320,15 @@ net_poll: @done_data: jsr uci_drain_resp + bcs @dd_drain_to ; drain wedged — surface as ERROR jsr uci_drain_status + bcs @dd_drain_to jsr uci_ack rts +@dd_drain_to: + lda #UCI_TCP_ERROR + sta net_tcp_state + rts ; ============================================================================= ; net_dhcp_acquire — read the firmware-assigned IP via UCI GET_IPADDR @@ -369,7 +390,9 @@ net_dhcp_acquire: ; but this is cheap insurance against firmware revisions that ; return a longer record). jsr uci_drain_resp + bcs @dhcp_wait_to ; drain wedged — surface as DHCP fail jsr uci_drain_status + bcs @dhcp_wait_to jsr uci_ack ; Copy the first 4 bytes (IP) into net_local_ip. @@ -476,8 +499,11 @@ net_tcp_connect: lda #UCI_ERR_CONNECT_FAIL sta net_last_error jsr uci_drain_resp + bcs @tc_err_drain_to ; drain wedged — still surface CONNECT_FAIL jsr uci_drain_status + bcs @tc_err_drain_to jsr uci_ack +@tc_err_drain_to: sec rts @@ -496,8 +522,19 @@ net_tcp_connect: jsr uci_read_resp_bytes jsr uci_drain_resp + bcs @tc_ok_drain_to ; drain wedged — surface as CONNECT_FAIL + ; (net_last_error already + ; UCI_ERR_WAIT_TIMEOUT from the drain) jsr uci_drain_status + bcs @tc_ok_drain_to jsr uci_ack + jmp @tc_validate +@tc_ok_drain_to: + lda #UCI_TCP_CONNECT_FAIL + sta net_tcp_state + sec + rts +@tc_validate: ; Validate the response: firmware must have returned at least 1 ; byte (uci_resp_count) AND a non-zero socket_id. Issue #36 — at @@ -643,8 +680,11 @@ net_tcp_send: lda #UCI_ERR_SEND_FAIL sta net_last_error jsr uci_drain_resp + bcs @sb_err_drain_to ; drain wedged — preserve SEND_FAIL exit jsr uci_drain_status + bcs @sb_err_drain_to jsr uci_ack +@sb_err_drain_to: sec rts @@ -659,8 +699,16 @@ net_tcp_send: jsr uci_read_resp_bytes jsr uci_drain_resp + bcs @sb_ok_drain_to ; drain wedged post-SOCKET_WRITE — bail jsr uci_drain_status + bcs @sb_ok_drain_to jsr uci_ack + jmp @sb_continue +@sb_ok_drain_to: + ; net_last_error already UCI_ERR_WAIT_TIMEOUT from the drain. + sec + rts +@sb_continue: ; Sanity: if written != requested-for-this-chunk, flag short-write. ; We still treat the send as done (MVP semantics). @@ -744,9 +792,12 @@ net_tcp_close: : jsr uci_check_err ; clear latched error if any jsr uci_drain_resp + bcs @cl_drain_to ; drain wedged — still force CLOSED jsr uci_drain_status + bcs @cl_drain_to jsr uci_ack +@cl_drain_to: lda #UCI_TCP_CLOSED sta net_tcp_state rts diff --git a/src/net/uci/uci_cmd.s b/src/net/uci/uci_cmd.s index 81d3bf1..f1dd5f7 100644 --- a/src/net/uci/uci_cmd.s +++ b/src/net/uci/uci_cmd.s @@ -18,8 +18,10 @@ ; uci_read_resp_bytes— drain DATA_AV bytes to caller-provided buffer ; (caller fills uci_resp_dst/uci_resp_max beforehand; ; uci_resp_count returned; Y = count) -; uci_drain_resp — drain remaining DATA_AV bytes to nowhere, ACKing each -; uci_drain_status — drain remaining STAT_AV bytes to nowhere, ACKing each +; uci_drain_resp — drain remaining DATA_AV bytes to nowhere, ACKing +; each; TOD-bounded (5 s wall-clock) +; uci_drain_status — drain remaining STAT_AV bytes to nowhere, ACKing +; each; TOD-bounded (5 s wall-clock) ; uci_ack — single NEXT_DATA pulse ; ; Phase 2 only needs enough machinery for GET_IPADDR (12-byte response, @@ -335,13 +337,32 @@ uci_read_resp_bytes: ; Used after uci_read_resp_bytes when the caller only wanted the first N bytes ; of a potentially longer response. Reads UCI_RESP_DATA (forcing the FIFO to ; advance on firmwares that require a read), then pulses NEXT_DATA. +; +; Phase 5j — wall-clock-bounded via CIA1 TOD (5 s budget, mirrors +; uci_wait_idle / uci_wait_not_busy from issue #37 and Phase 5b). +; Secondary-risk fix per CLAUDE.md Phase 5j brief: net_tcp_send / +; net_poll / net_tcp_close all call drains after a SOCKET_WRITE or +; POLL_DATA; if firmware ever leaves DATA_AV asserted post-SOCKET_WRITE +; the unbounded `jmp` loop wedges with no wall-clock escape. +; +; Output: C=0 on drain complete, C=1 on timeout +; (net_last_error = UCI_ERR_WAIT_TIMEOUT). ; Clobbers: A ; ============================================================================= uci_drain_resp: + ; Sample initial TENTHS for delta-tracking. Latch via HOUR, + ; release via TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + sta @drn_last_tenths + lda #$00 + sta @drn_elapsed +@drn_loop: lda UCI_STATUS uci_fence ; settle before testing DATA_AV and #UCI_STAT_DATA_AV bne @drn_have + clc rts @drn_have: lda UCI_RESP_DATA @@ -349,18 +370,52 @@ uci_drain_resp: lda #UCI_CTRL_NEXT_DATA sta UCI_CONTROL uci_fence - jmp uci_drain_resp + + ; Check TOD for elapsed tenths. Latch (HOUR) then read TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + cmp @drn_last_tenths + beq @drn_loop_long ; no change — keep draining + sta @drn_last_tenths + inc @drn_elapsed + lda @drn_elapsed + cmp #UCI_WAIT_IDLE_BUDGET_TENTHS + bcc @drn_loop_long ; under budget — continue + ; Timeout + lda #UCI_ERR_WAIT_TIMEOUT + sta net_last_error + sec + rts +@drn_loop_long: + jmp @drn_loop ; long branch: fence too wide for BEQ/BCC +@drn_last_tenths: .byte 0 +@drn_elapsed: .byte 0 ; ============================================================================= ; uci_drain_status — ACK remaining status string bytes until STAT_AV is clear. ; Phase 2 discards the status string; later phases may want to capture it. +; +; Phase 5j — wall-clock-bounded via CIA1 TOD (5 s budget, mirrors +; uci_drain_resp above). +; +; Output: C=0 on drain complete, C=1 on timeout +; (net_last_error = UCI_ERR_WAIT_TIMEOUT). ; Clobbers: A ; ============================================================================= uci_drain_status: + ; Sample initial TENTHS for delta-tracking. Latch via HOUR, + ; release via TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + sta @dst_last_tenths + lda #$00 + sta @dst_elapsed +@dst_loop: lda UCI_STATUS uci_fence ; settle before testing STAT_AV and #UCI_STAT_STAT_AV bne @dst_have + clc rts @dst_have: lda UCI_STATUS_DATA @@ -368,7 +423,26 @@ uci_drain_status: lda #UCI_CTRL_NEXT_DATA sta UCI_CONTROL uci_fence - jmp uci_drain_status + + ; Check TOD for elapsed tenths. Latch (HOUR) then read TENTHS. + lda CIA_TOD_HOUR + lda CIA_TOD_TENTHS + cmp @dst_last_tenths + beq @dst_loop_long ; no change — keep draining + sta @dst_last_tenths + inc @dst_elapsed + lda @dst_elapsed + cmp #UCI_WAIT_IDLE_BUDGET_TENTHS + bcc @dst_loop_long ; under budget — continue + ; Timeout + lda #UCI_ERR_WAIT_TIMEOUT + sta net_last_error + sec + rts +@dst_loop_long: + jmp @dst_loop ; long branch: fence too wide for BEQ/BCC +@dst_last_tenths: .byte 0 +@dst_elapsed: .byte 0 ; ============================================================================= ; Control block for uci_read_resp_bytes — lives in UCI_BSS so no ZP is needed From 28e042e15a4fd115e87d9e68b69ed355eab282ea Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 16 May 2026 18:18:34 -0500 Subject: [PATCH 19/21] build(make): emit cc65 debug info (-g + --dbgfile) for PRG + overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cc65 debug-info generation across the build pipeline as a purely additive sidecar — PRG bytes are unchanged (verified bit-for-bit identical SHA-256 across before/after incremental builds). Rationale: better symbol / source-line mapping in VICE's monitor and for diagnostic agents that walk crash dumps, REU traces, and DMA captures. The cc65 `.dbg` format pairs cleanly with VICE's `-moncommands` and the c64-test-harness label readers; without it, the only out-of-PRG metadata is `build/labels.txt` (symbol-only, no source mapping). Changes: * Makefile: add `--dbgfile build/c64-https.dbg` to `LD65FLAGS`. `CA65FLAGS` already carried `--debug-info`, so all ca65 invocations driven from the Makefile already embedded per-source debug records. * tools/integration/build_nistcurves_p256.sh, tools/integration/build_nistcurves_p384.sh, tools/integration/build_x25519.sh: add `-g` to every ca65 call that produces a `.o` contributing to the final PRG. These were the only ca65 invocations that did NOT inherit `CA65FLAGS`. * tools/integration/build_nistcurves_p384_bin.sh: add `--dbgfile` pointing at `build/lib/overlay-p384-{sha384,curve}.dbg` for the two overlay-image link steps, derived from the .bin path. `make clean` already removes `build/` recursively so no separate sweep for the new `.dbg` sidecars is needed. Verification: * `make BACKEND=uci` (incremental, no clean — test in flight on PID 38329) rebuilt only the dispatcher + overlay-blob .o and re-linked. `sha256sum build/c64-https.prg` matches pre-edit: d72f733dc15ed1e7508f496a2daeda10433c6f3d864b05d8a7c584788ffb0b79 * `build/c64-https.dbg` is 1.44 MB, 31113 lines, valid cc65 format (`version major=2,minor=0` / `info csym=… file=49 mod=42 sym=5181`). * `build/lib/overlay-p384-sha384.dbg` (632 lines) and `build/lib/overlay-p384-curve.dbg` (2882 lines) generated alongside the padded .bin images. * Second `make BACKEND=uci` is a no-op (idempotent). Co-Authored-By: Claude Opus 4.7 (1M context) --- Makefile | 2 +- tools/integration/build_nistcurves_p256.sh | 5 +++++ tools/integration/build_nistcurves_p384.sh | 5 +++++ tools/integration/build_nistcurves_p384_bin.sh | 8 ++++++++ tools/integration/build_x25519.sh | 4 ++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5f71f2e..6f0d607 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ IP65_BUILD := ip65-build IP65_BIN := $(IP65_BUILD)/ip65-c64.bin CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net/$(BACKEND) -I build --debug-info -LD65FLAGS := -C $(CFG) -Ln build/labels.txt -m build/c64-https.map +LD65FLAGS := -C $(CFG) -Ln build/labels.txt -m build/c64-https.map --dbgfile build/c64-https.dbg # Source inventory. TOP_SRCS := $(wildcard src/*.s) diff --git a/tools/integration/build_nistcurves_p256.sh b/tools/integration/build_nistcurves_p256.sh index 7f39a52..d8f1cfc 100755 --- a/tools/integration/build_nistcurves_p256.sh +++ b/tools/integration/build_nistcurves_p256.sh @@ -342,7 +342,11 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" # zp_config.s is the single point of truth for ZP equates; we apply -D # overrides so sibling defaults get replaced with c64-https's canonical map. +# `-g` embeds cc65 debug info into the .o files so the final ld65 --dbgfile +# (driven from the top-level Makefile) can merge per-source line/symbol +# records into build/c64-https.dbg. Does not change emitted code bytes. "$CA65" \ + -g \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ "${ZP_DEFINES[@]}" \ @@ -350,6 +354,7 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" for src in fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw reu_equates_raw; do "$CA65" \ + -g \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh index 86e58f8..4190cb3 100755 --- a/tools/integration/build_nistcurves_p384.sh +++ b/tools/integration/build_nistcurves_p384.sh @@ -506,7 +506,11 @@ mkdir -p "$OBJ_DIR" "$OUT_DIR" # replaced by c64-https's canonical ZP map (with the Phase 1.5 SHA-384 # slot moves). The other source files use `.importzp` to pull these # equates from the linker-resolved zp_config.o. +# `-g` embeds cc65 debug info into each .o; the overlay ld65 invocations +# in build_nistcurves_p384_bin.sh merge it into build/lib/overlay-p384-*.dbg +# sidecars. Does not change emitted code bytes. "$CA65" \ + -g \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ "${ZP_DEFINES[@]}" \ @@ -520,6 +524,7 @@ for src in fp384_raw mod384_raw points384_raw curve384_raw \ sha384_raw ecdsa384_raw ec_scalar_mul_384_shim_raw \ data_curve_raw data_sha_raw; do "$CA65" \ + -g \ -I "$STAGING" \ -I "$PROJECT_ROOT/src/crypto/shared" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" diff --git a/tools/integration/build_nistcurves_p384_bin.sh b/tools/integration/build_nistcurves_p384_bin.sh index 0c65301..9ebe7a5 100755 --- a/tools/integration/build_nistcurves_p384_bin.sh +++ b/tools/integration/build_nistcurves_p384_bin.sh @@ -178,11 +178,19 @@ link_one () { obj_args+=("$scratch/$m") done + # Sidecar .dbg path: build/lib/overlay-p384-{sha384,curve}.dbg. + # Pairs with the `-g` ca65 flag added in build_nistcurves_p384.sh so + # ld65 can merge per-source line/symbol records. Does not affect the + # padded .bin image bytes. + local dbg_out + dbg_out="${bin_out%.bin}.dbg" + "$LD65" \ -C "$cfg" \ -o "$bin_out" \ -Ln "$labels_out" \ -m "$map_out" \ + --dbgfile "$dbg_out" \ --define reu_status=\$df00 \ --define reu_command=\$df01 \ --define reu_c64_lo=\$df02 \ diff --git a/tools/integration/build_x25519.sh b/tools/integration/build_x25519.sh index 5bd645e..bd52fc9 100644 --- a/tools/integration/build_x25519.sh +++ b/tools/integration/build_x25519.sh @@ -326,7 +326,11 @@ rm -rf "$OBJ_DIR" mkdir -p "$OBJ_DIR" "$OUT_DIR" for src in fe25519_raw x25519_raw x25519_init_raw data_x25519_bss_raw data_x25519_rodata_raw; do + # `-g` embeds cc65 debug info; ld65 --dbgfile (top-level Makefile) + # merges per-source line/symbol records into build/c64-https.dbg. + # Does not change emitted code bytes. "$CA65" \ + -g \ -I "$STAGING" \ "${ZP_DEFINES[@]}" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" From f9f746d9ace9b217f8725df4154f72ff4fd6eec4 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 16 May 2026 18:52:23 -0500 Subject: [PATCH 20/21] docs(CLAUDE.md): note cc65 .dbg sidecars in Build section Build now emits build/c64-https.dbg (and overlay .dbgs) per commit 28e042e. Diagnostic tools and VICE monitor can consume these for PC->symbol/source mapping instead of relying on labels.txt alone. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b95c579..1dd34a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,11 @@ Dependencies: - VICE (`x64sc`) only for `make run` / the test harness Targets: - - `make` — default, produces `build/c64-https.prg` - and `build/labels.txt` (VICE label format) + - `make` — default, produces `build/c64-https.prg`, + `build/labels.txt` (VICE label format), and + `build/c64-https.dbg` (cc65 debug info, + consumable by VICE's monitor + diagnostic + agents; P-384 overlays get `.dbg` sidecars too) - `make clean` — remove build artifacts - `make run` — autostart the PRG in VICE - `make ip65-libs` — rebuild ip65 object libraries from the submodule From 226cc24ff992f257ef39b96d5292821e86711b77 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Thu, 21 May 2026 14:15:59 -0500 Subject: [PATCH 21/21] feat: c64-lib-contract alignment + nistcurves v0.3.0 / x25519 v0.5.0+5 bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps sibling libraries and restructures the consumer cfg + integration to match the c64-lib-contract SPEC §8.1+ (shared sqtab routing, minimal-archive build targets, LIB__* segment naming): - libs/nistcurves 90830c9 -> v0.3.0 (b67de54): minimal archives (`lib-p256-verify`, `lib-p384-verify`, `lib-sha384`), SPEC §8.1 shared-sqtab adoption. - libs/x25519 47c0ad2 -> v0.5.0+5 (95fdd70): minimal archive (`lib-x25519-scalarmult`), §8.1 shared-sqtab adoption, bank-2 drop, RAM-reclaim. Consumer-side: - cfg/c64-https-uci.cfg gains W1 hot/cold partition: CRYPTO_HOT ($6000-$9FFF, file-backed code+rodata) + CRYPTO_COLD_SHADOW ($A000-$BFFF, BSS behind ROM shadow) + NET_BSS_TAIL ($3B26-$41FF, spill for library BSS + tls_rec_buf + cert_buf). LIB_NISTCURVES_* / LIB_X25519_* / LIB_SHARED_* segment names route through these regions. - cfg/c64-https-ip65.cfg: partial W1 adoption; default build still 1662 B over CRYPTO_HOT pending c64-nist-curves#54 BSS slim. - cfg/p256-overlay-verify.cfg + cfg/x25519-overlay-scalarmult.cfg: new external-image link cfgs for the overlay variants. - cfg/p384-overlay-{curve,sha384}.cfg: updated to LIB_NISTCURVES_* segment names. Integration: - tools/integration/build_nistcurves_p{256,384}.sh: collapse from ~636 lines of sed-strip object-cherry-pick shell to thin `make -C libs/nistcurves lib-VARIANT` invocations. - tools/integration/build_x25519.sh: §8.1 sqtab routing + new contract symbols, otherwise also thinned. - tools/integration/build_nistcurves_p256_bin.sh (new): overlay .bin builder for EMBED_P256_OVERLAY=1. - tools/integration/build_nistcurves_p384_bin.sh: refresh. Source-side: - src/crypto/shared/crypto_swap.s: adds crypto_swap_to_x25519, crypto_swap_to_p256_verify, crypto_overlay_call dispatcher entry points (cold-path overlay routing for future work). - src/crypto/shared/overlay_ids.inc (new): canonical OV_* overlay-id equates. - src/crypto/shared/p256_overlay_blobs.s (new): .ifdef-gated .incbin of the P-256 verify overlay image. - src/crypto/shared/reu_layout.inc: new bank/offset constants matching the post-W1 c64-lib-contract reservation. - src/data.s + src/der_decode.s: tls_rec_buf and cert_buf spill to NET_BSS_TAIL. - src/boot.s: overlay-init extension (calls the new dispatcher entry points where the cfg enables them). - src/exports.s: drop duplicate REU register exports (library now publishes them). Makefile: EMBED_P256_OVERLAY flag, P-256 verify .bin rule. CLAUDE.md: refreshed memory-layout sections to reflect CRYPTO_HOT / CRYPTO_COLD_SHADOW + NET_BSS_TAIL; sibling-library pins updated; integration-script note added; c64-lib-contract + library-ingestion arch-plan cross-refs added. Verified state (integrated session-snapshot): UCI default build 62977 B, VICE Test 3a 11/11 PASS, U64E e2e PASS at 82.1 s. Known limitations: - ip65 default + UCI USE_X25519_SIBLING=1 still overflow CRYPTO_HOT, blocked on c64-nist-curves#54 (minimal-archive BSS slim). - `make p384-overlay` pre-existing unresolved-symbol bug unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 153 ++-- Makefile | 76 +- cfg/c64-https-ip65.cfg | 227 ++++-- cfg/c64-https-uci.cfg | 255 +++++-- cfg/p256-overlay-verify.cfg | 54 ++ cfg/p384-overlay-curve.cfg | 17 +- cfg/p384-overlay-sha384.cfg | 14 +- cfg/x25519-overlay-scalarmult.cfg | 56 ++ libs/nistcurves | 2 +- libs/x25519 | 2 +- src/boot.s | 106 +++ src/crypto/shared/crypto_swap.s | 162 ++++- src/crypto/shared/overlay_ids.inc | 57 ++ src/crypto/shared/p256_overlay_blobs.s | 48 ++ src/crypto/shared/reu_layout.inc | 43 ++ src/data.s | 8 + src/der_decode.s | 7 + src/exports.s | 18 +- tools/integration/build_nistcurves_p256.sh | 459 +++--------- .../integration/build_nistcurves_p256_bin.sh | 205 ++++++ tools/integration/build_nistcurves_p384.sh | 665 +++++------------- .../integration/build_nistcurves_p384_bin.sh | 18 +- tools/integration/build_x25519.sh | 231 +++++- 23 files changed, 1816 insertions(+), 1067 deletions(-) create mode 100644 cfg/p256-overlay-verify.cfg create mode 100644 cfg/x25519-overlay-scalarmult.cfg create mode 100644 src/crypto/shared/overlay_ids.inc create mode 100644 src/crypto/shared/p256_overlay_blobs.s create mode 100755 tools/integration/build_nistcurves_p256_bin.sh diff --git a/CLAUDE.md b/CLAUDE.md index 1dd34a3..989fd11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,12 +2,23 @@ TLS 1.3 / HTTPS client for the Commodore 64, assembled with ca65/ld65 and delivered as a single PRG. Networking is provided by the ip65/RR-Net stack -(prebuilt blob at $2000). All crypto is hand-written 6502 tuned to fit -under the BASIC ROM shadow at $A000. +(prebuilt blob at $2000). Crypto is sourced from c64-lib-contract-conformant +sibling libraries (`libs/nistcurves@v0.3.0`, `libs/x25519@v0.5.0+5`) plus the +in-tree SHA-256 / ChaCha20-Poly1305 / HMAC / HKDF, all tuned to fit under +the BASIC ROM shadow at $A000. This file is the load-bearing "how does this hang together" reference. Keep it terse. +See also: + - [c64-lib-contract](https://github.com/JC-000/c64-lib-contract) + SPEC.md — library / consumer contract (v0.2.0+); ABI symbol + inventory, minimal-archive build targets, REU bank allocation, + SPEC §8.1 shared-sqtab routing. + - [`docs/library-ingestion-architecture.md`](docs/library-ingestion-architecture.md) + — target-state architecture plan covering hot/cold partition, + manifest contract, overlay model, CI/CD design (2026-05-20). + ## Build Dependencies: @@ -29,10 +40,23 @@ Targets: libraries (the committed blob is normally reused) Variables: - - `BACKEND=ip65|uci` — select networking backend cfg - (`cfg/c64-https-$(BACKEND).cfg`; default ip65) - - `CA65`, `LD65` — toolchain overrides - - `VICE` — override the `make run` emulator + - `BACKEND=ip65|uci` — select networking backend cfg + (`cfg/c64-https-$(BACKEND).cfg`; default ip65) + - `USE_X25519_SIBLING=1` — link `libs/x25519` minimal archive + (UCI only; see Known issues for ip65 fit) + - `EMBED_P256_OVERLAY=1` — embed P-256 verify overlay blob + (see `cfg/p256-overlay-verify.cfg`) + - `USE_OVERLAY_P384_EMBED=1` — embed P-384 sha384+curve overlay blobs + (see `cfg/p384-overlay-{sha384,curve}.cfg`) + - `CA65`, `LD65` — toolchain overrides + - `VICE` — override the `make run` emulator + +Library integration scripts (`tools/integration/build_*.sh`) are thin +wrappers that invoke `make -C libs/ lib-` on the c64-lib-contract +adopters (`libs/nistcurves`, `libs/x25519`) and stage the resulting +minimal archives under `build/lib/`. The earlier ~636 lines of +sed-strip / object-cherry-pick shell were retired in the c64-lib-contract +alignment landing. Test harness expectations: - Most `tools/test_*.py` scripts run `make clean && make` themselves @@ -56,9 +80,11 @@ buffers in the crypto BSS — see per-module headers for details): X25519 / field arithmetic Default: in-tree `src/crypto/{x25519,fe25519}.s`. - Opt-in: sibling `libs/x25519@v0.4.0` via `make USE_X25519_SIBLING=1` + Opt-in: sibling `libs/x25519@v0.5.0+5` via `make USE_X25519_SIBLING=1` (UCI backend only — see Known issues for the ip65 fit blocker; Phase - C.5). Sibling and in-tree both expose the same ABI: + C.5). Pin is c64-lib-contract SPEC §8.1 conformant (shared sqtab, + minimal-archive `lib-x25519-scalarmult` target). Sibling and in-tree + both expose the same ABI: x25519_scalarmult — X25519 scalar × point, 32-byte buffers fe25519_mul, fe25519_sqr, fe25519_inv @@ -70,11 +96,14 @@ buffers in the crypto BSS — see per-module headers for details): SHA-256 (in-tree; no sibling) sha256_init, sha256_update, sha256_final - ECDSA P-256 (libs/nistcurves sibling, Phase C.4) + ECDSA P-256 (libs/nistcurves@v0.3.0 sibling, Phase C.4) ecdsa_verify_256 — TLS dispatcher in src/crypto/ecdsa_verify.s packs the BE struct + calls the sibling entry ec_scalar_mul_var — variable-base scalar multiplication - (in-tree ecdsa_{curve,fp,mod,points}.s were deleted in Phase G) + (in-tree ecdsa_{curve,fp,mod,points}.s were deleted in Phase G; + pin is c64-lib-contract SPEC §8.1 conformant — shared sqtab routed + via LIB_SHARED_SQTAB_BASE / SHARED_SQTAB_INIT=1, minimal-archive + `lib-p256-verify` build target.) P-384 is *stubbed at the TLS layer* (see `project_p384_stubbed` memory note). The sibling `libs/nistcurves` P-384 primitives are buildable as @@ -82,11 +111,21 @@ an external overlay image (Phase C.3b, `make p384-overlay`) but the target has a pre-existing unresolved-symbol bug (`ec_base384_x/y` in points384_raw.s) — fix that before wiring P-384 into the TLS path. -MEMORY requirements for a drop-in sibling library: - - Code + rodata must load into the `CRYPTO` region at **$6000-$9FFF** - (below the BASIC ROM shadow at $A000, so it survives ROM banking). - - `TABLES_BSS` (`x25519` squaring tables etc.) must stay **below $A000**; - the cfg pins it inside the CRYPTO region with `align = $100`. +MEMORY requirements for a drop-in sibling library (post-W1 hot/cold split): + - Code + rodata must load into `CRYPTO_HOT` at **$6000-$9FFF** + (file-backed; below the BASIC ROM shadow at $A000, so it survives + ROM banking). Library segments use `LIB__*` segment names per + c64-lib-contract SPEC; consumer cfg routes them (e.g. + `LIB_NISTCURVES_P256_CODE` → `CRYPTO_HOT`, `LIB_X25519_*` → ditto). + - Mutable BSS / large tables live in `CRYPTO_COLD_SHADOW` at + **$A000-$BFFF** (mutable state behind the BASIC ROM shadow; CPU + port $01 = $36 selects RAM). + - `TABLES_BSS` (`x25519` squaring tables etc.) must stay **below $C000**; + the cfg pins it inside CRYPTO_HOT or CRYPTO_COLD_SHADOW with + `align = $100`. Under c64-lib-contract SPEC §8.1 the always-resident + shared sqtab is published via `LIB_SHARED_SQTAB_BASE` / + `SHARED_SQTAB_INIT=1`; libraries built with §8.1 adoption reuse it + rather than carrying their own copy. - Zero-page usage is defined in `src/constants.inc` — fe25519 lives at `$2C-$37`, x25519 state at `$38-$3A`, ECDSA bignum at `$22-$3C`. These ranges are time-shared (fe25519 and ChaCha20 never overlap). @@ -215,14 +254,26 @@ access — negligible for networking. ### Memory layout under UCI -The NET_CODE/NET_BSS regions ($2000-$5FFF) are repurposed: - - $2000-$3FFF UCI_CODE UCI adapter code (`net.s`, `uci_cmd.s`) - $4000-$5FFF UCI_BSS `uci_host_buf`, ipaddr scratch, socket - state, command control block - -All other regions (LOADER, CRYPTO, SHADOW_BSS, TCP_BUF) are identical -to the ip65 layout. +Post-W1 hot/cold partition (see `cfg/c64-https-uci.cfg`): + + $2000-$3B25 NET_CODE UCI adapter code (`net.s`, `uci_cmd.s`) + — shrunk vs pre-W1 to make room for + NET_BSS_TAIL below + $3B26-$41FF NET_BSS_TAIL spill BSS (library-side BSS that needs + file-backing for zero-init: e.g. + `LIB_NISTCURVES_P256_BSS`, + `tls_rec_buf` spill from `src/data.s`, + `cert_buf` spill from `src/der_decode.s`) + $4200-$5FFF CRYPTO_OVERLAY overlay slot: X25519 sibling tables / + P-256 verify overlay blob / + P-384 SHA-384 overlay blob (mutually + exclusive at link time; see + `OVERLAY_BLOB_*` segments) + $6000-$9FFF CRYPTO_HOT code + rodata, file-backed, banked-RAM-safe + $A000-$BFFF CRYPTO_COLD_SHADOW mutable BSS behind BASIC ROM shadow + (was `SHADOW_BSS`) + $C000-$FFFF OVERLAY_FILE_PAD + P-384 curve-overlay blob load area + OVERLAY_BLOB_CURVE_RAM (under-KERNAL) ### UCI test scripts @@ -386,7 +437,8 @@ Five latent bugs and three new ones were cleared to get here: body. `http_resp_buf` still holds raw ASCII — only the render pipeline is translated. - **X25519 sibling (Phase C.5)** — `make USE_X25519_SIBLING=1` builds - against `libs/x25519@v0.4.0`. Default is OFF; the in-tree + against `libs/x25519@v0.5.0+5` (c64-lib-contract SPEC §8.1 conformant; + shared sqtab + minimal archive). Default is OFF; the in-tree implementation remains the shipped default until the flag flip is decided. The Phase C.1 hang and v0.3.0 retry rollback are both closed by upstream PR #36 + v0.4.0 H2 (defensive REU register init @@ -396,9 +448,10 @@ Five latent bugs and three new ones were cleared to get here: on U64E at 48 MHz: HTTPS handshake completes in ~101 s (vs ~87 s under in-tree X25519; the +14 s is consistent with v0.4.0's release-notes-documented +27 % scalarmult cost over v0.3.0 - for the L1-L29 CT closures). **ip65 backend overflows - CRYPTO_RESIDENT by 1 KB under the flag** — UCI is the supported - path; ip65 fit is a separate cfg-restructure follow-up. See + for the L1-L29 CT closures). **UCI sibling build overflows + CRYPTO_HOT by 364 B; ip65 default overflows by 1662 B** — both + blocked on library-side BSS slim ([c64-nist-curves#54](https://github.com/JC-000/c64-nist-curves/issues/54)). + UCI default (without sibling) builds clean at 62977 B. See `tools/integration/build_x25519.sh` for the staging layout. - **CRYPTO_OVERLAY collisions are now caught by MemoryPolicy.** All `tools/uci/*.py` test scripts derive their scratch DMA addresses @@ -545,18 +598,23 @@ the companion drain + ack and force the appropriate exit state. ## Memory layout -Defined in `cfg/c64-https-ip65.cfg`. Physically contiguous file-backed -regions run from $0801 through $9FFF, with SHADOW_BSS at $A000 and the -TCP ring at $C000. +Defined in `cfg/c64-https-{ip65,uci}.cfg`. UCI is the post-W1 reference +(hot/cold split adopted; see "Memory layout under UCI" subsection above +for the full UCI map). ip65 cfg lags behind on partial adoption (still +1662 B over CRYPTO_HOT under the default build until upstream BSS slim +lands — see Known issues + `c64-nist-curves#54`). + +ip65 (legacy / partial post-W1): - $0801-$1FFF LOADER BASIC stub + boot + TLS + HTTP + net wrapper - $2000-$3FFF NET_CODE ip65 code (as .incbin blob) / UCI adapter, - plus LOADER_OVERFLOW tail - $4000-$5FFF NET_BSS ip65 BSS (zero-filled in the PRG) - $6000-$9FFF CRYPTO all crypto code, rodata, and TABLES_BSS - $A000-$BFFF SHADOW_BSS mutable state behind BASIC ROM shadow - (CPU port $01 = $36 selects RAM) - $C000-$CFFF TCP_BUF `tcp_recv_buf`, 4KB ring for ip65 callback + $0801-$1FFF LOADER BASIC stub + boot + TLS + HTTP + net wrapper + $2000-$3FFF NET_CODE ip65 code (as .incbin blob), + plus LOADER_OVERFLOW tail + $4000-$5FFF NET_BSS ip65 BSS (zero-filled in the PRG) + $6000-$9FFF CRYPTO_HOT crypto code + rodata + library + minimal-archive segments (LIB_*_*) + $A000-$BFFF CRYPTO_COLD_SHADOW mutable state + library BSS + (was `SHADOW_BSS`, behind BASIC ROM) + $C000-$CFFF TCP_BUF `tcp_recv_buf`, 4KB ring for ip65 callback `LOADER_OVERFLOW` is a small segment carrying ~125 B of `http.s` growth (Content-Length parser + digit pattern) that did not fit in LOADER's @@ -565,13 +623,18 @@ blob under the ip65 backend and after the UCI adapter under the UCI backend. Both `cfg/c64-https-ip65.cfg` and `cfg/c64-https-uci.cfg` declare it. Reachable via JSR from LOADER-resident CODE. -Tight regions (after Phase 6 fit-up): - - **CRYPTO** is **100%** full. Any new crypto byte requires relocation - or reclamation somewhere. - - **SHADOW_BSS** was **99.8%** full after Phase 6; ≈258 B was reclaimed - in the `tls_hs_buf` removal (256 B buffer + 2 B length word), so - there is a bit more slack now. Still the tightest region after - CRYPTO — check the linker map before adding anything sizeable. +Tight regions (post-W1 fit-up): + - **CRYPTO_HOT** is **100%** full under default UCI build (62977 B); + ip65 default still **1662 B over** until c64-nist-curves#54 + minimal-archive BSS slim lands. Any new crypto byte requires + relocation or reclamation somewhere. + - **CRYPTO_COLD_SHADOW** holds library BSS (`LIB_NISTCURVES_BSS`, + `LIB_NISTCURVES_TABLES`, `LIB_X25519_BSS`, etc.) plus the existing + `tls_*` mutable state. Still tight after the W1 spill into + NET_BSS_TAIL; check the linker map before adding anything sizeable. + - **NET_BSS_TAIL** (UCI only, $3B26-$41FF, ~1750 B) absorbs spill from + library minimal archives plus `tls_rec_buf` (src/data.s) and + `cert_buf` (src/der_decode.s) that no longer fit in CRYPTO_HOT. There is a known TODO to restructure the MEMORY map so that all file-backed regions are physically contiguous in a single ROM-like diff --git a/Makefile b/Makefile index 6f0d607..52a2364 100644 --- a/Makefile +++ b/Makefile @@ -90,6 +90,26 @@ CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) else ifeq ($(BACKEND),uci) NET_SRCS := $(UCI_SRCS) CRYPTO_SRCS := $(CRYPTO_SRCS_EFFECTIVE) + +# --- W3: EMBED_P256_OVERLAY=1 stages the P-256 verify .bin as a +# .incbin into CRYPTO_OVERLAY at PRG-load time. Mutually exclusive +# with USE_OVERLAY_P384_EMBED (same slot at $4200) — the rules below +# turn the P-384 SHA embed off when EMBED_P256_OVERLAY=1 to avoid +# overflowing the 7,680 B slot. Default 0 so the un-flagged UCI +# build is byte-identical to today. +# +# Mirrors the P-384 USE_OVERLAY_P384_EMBED bootstrap pattern: the user- +# visible Make flag is `EMBED_P256_OVERLAY=1`; the ca65-level symbol +# `USE_OVERLAY_P256_EMBED` (which gates the .incbin in +# src/crypto/shared/p256_overlay_blobs.s) defaults to the same value +# but can be explicitly overridden via the command line for the +# bootstrap prelim link (avoids the overlay-bin <-> labels.txt cycle on +# a clean tree). Bootstrap workflow: +# make BACKEND=uci EMBED_P256_OVERLAY=1 USE_OVERLAY_P256_EMBED=0 +# make BACKEND=uci EMBED_P256_OVERLAY=1 +EMBED_P256_OVERLAY ?= 0 +USE_OVERLAY_P256_EMBED ?= $(EMBED_P256_OVERLAY) + # Phase 3: embed the two P-384 split overlay blobs in the PRG so boot # can populate REU banks 6/7 at startup. Gated to UCI (ip65 has no # room for the SHA blob in main RAM) and to !USE_X25519_SIBLING (the @@ -100,12 +120,35 @@ ifneq ($(USE_X25519_SIBLING),1) # Phase 5 Fix D: respect a command-line USE_OVERLAY_P384_EMBED=0 so the # bootstrap rule below can do a no-overlay-embed prelim link to break # the overlay-bin <-> labels.txt cycle on a clean tree. Default is -# still 1 unless the operator explicitly disables it. -USE_OVERLAY_P384_EMBED ?= 1 +# still 1 unless the operator explicitly disables it. W3: also +# auto-turn-off when EMBED_P256_OVERLAY=1 (mutually-exclusive slot). +ifeq ($(EMBED_P256_OVERLAY),1) +USE_OVERLAY_P384_EMBED ?= 0 +else +# W5 / libs/nistcurves cfa9085+ bump: PR #25's SHA-384 rotr LUTs +# (LIB_NISTCURVES_SHA384_TABLES, 3 KB page-aligned) push the SHA-384 +# overlay-half archive above the 7.5 KB CRYPTO_OVERLAY slot limit by +# ~1.5 KB. Default flips to 0 until the SHA-384 LUTs are either: +# - relocated out of the overlay slot (consumer-side path: route +# LIB_NISTCURVES_SHA384_TABLES to a separate resident region and +# teach the standalone overlay cfg to omit them from the .bin), or +# - shrunk on the library side (eg by sharing rotr LUTs across the +# 8 shift amounts, or runtime-generating them at boot). +# Set USE_OVERLAY_P384_EMBED=1 explicitly to attempt the embed (will +# fail at link with an overflow until the SHA-384 LUTs are dealt with). +USE_OVERLAY_P384_EMBED ?= 0 +endif ifeq ($(USE_OVERLAY_P384_EMBED),1) CA65FLAGS += -D USE_OVERLAY_P384_EMBED=1 endif endif + +# W3: propagate USE_OVERLAY_P256_EMBED to ca65 (the .incbin in +# src/crypto/shared/p256_overlay_blobs.s is gated on this). The +# default-derivation from EMBED_P256_OVERLAY happens above. +ifeq ($(USE_OVERLAY_P256_EMBED),1) +CA65FLAGS += -D USE_OVERLAY_P256_EMBED=1 +endif # Phase C.3: add c64-nist-curves P-384 primitives as a REU overlay. # Variable-base P-384 point ops (double/add/jacobian-to-affine) only — # see tools/integration/build_nistcurves_p384.sh for the scope rationale. @@ -164,6 +207,19 @@ PRG_DEPS += build/p384_overlay_equates.inc build/crypto/shared/p384_overlay_blobs.o: build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin endif +# W3: when USE_OVERLAY_P256_EMBED is on, add the P-256 verify .bin to +# PRG_DEPS so make builds it before the .incbin in +# src/crypto/shared/p256_overlay_blobs.s tries to read it. The .bin +# rule below also has an order-only dep on build/labels.txt for the +# main-PRG label lookup (same bootstrap cycle as P-384). Gated on the +# ca65-level USE_OVERLAY_P256_EMBED rather than EMBED_P256_OVERLAY so +# the bootstrap prelim link (with USE_OVERLAY_P256_EMBED=0) skips the +# .bin dep cleanly. +ifeq ($(USE_OVERLAY_P256_EMBED),1) +PRG_DEPS += build/lib/nistcurves-p256-verify.bin +build/crypto/shared/p256_overlay_blobs.o: build/lib/nistcurves-p256-verify.bin +endif + $(PRG): $(PRG_DEPS) @mkdir -p build $(LD65) $(LD65FLAGS) -o $@ $(ALL_OBJS) $(SIBLING_LIB_ARCHIVES) @@ -258,6 +314,22 @@ build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin build/labels- p384-overlay: build/lib/overlay-p384-sha384.bin build/lib/overlay-p384-curve.bin \ build/labels-p384-sha384.txt build/labels-p384-curve.txt +# W3: P-256 verify overlay .bin (library-ingestion architecture). +# Mirrors the P-384 overlay .bin rule: depends on the P-256 sibling +# archive (build/lib/nistcurves-p256.a, already a default PRG dep) + +# the standalone overlay cfg. Order-only dep on build/labels.txt for +# the lookup_label() fallback (same pattern as P-384 — see Fix D +# comment block above). +build/lib/nistcurves-p256-verify.bin build/labels-p256-verify.txt: \ + build/lib/nistcurves-p256.a \ + cfg/p256-overlay-verify.cfg \ + tools/integration/build_nistcurves_p256_bin.sh \ + | build/labels.txt + bash tools/integration/build_nistcurves_p256_bin.sh + +.PHONY: p256-overlay +p256-overlay: build/lib/nistcurves-p256-verify.bin build/labels-p256-verify.txt + # Phase 5 Fix C: regenerate the P-384 overlay-resident symbol equates # (build/p384_overlay_equates.inc) from the overlay labels files so the # TLS-side dispatcher (src/crypto/ecdsa_verify_384.s) picks up address diff --git a/cfg/c64-https-ip65.cfg b/cfg/c64-https-ip65.cfg index 0af0345..194268a 100644 --- a/cfg/c64-https-ip65.cfg +++ b/cfg/c64-https-ip65.cfg @@ -1,22 +1,86 @@ # c64-https ld65 config — ip65/RR-Net backend # -# Memory map: -# $0801-$1FFF : LOADER (BASIC stub + boot + tls + http + net wrapper) -# $2000-$3FFF : NET_CODE (ip65 blob + LOADER_OVERFLOW tail) -# $4000-$5FFF : NET_BSS (ip65 BSS, fully used) -# $6000-$BFFF : CRYPTO_RESIDENT (24 KB — covers old CRYPTO + SHADOW_BSS span) -# $C000-$CFFF : TCP_BUF (tcp_recv_buf, 4 KB ring) -# -# The in-tree src/crypto/x25519.s + src/crypto/fe25519.s provide -# the x25519 primitives under ip65. Phase C.1 attempted a sibling-lib -# overlay integration under BACKEND=uci only; that integration was -# rolled back (see cfg/c64-https-uci.cfg for details). ip65 never -# adopted the overlay, so this cfg is unchanged from the Phase C.0 -# single contiguous CRYPTO_RESIDENT layout. +# Memory map (post-W1 hot/cold split — see comment block at the MEMORY +# entry for the fit constraints): +# $0801-$1FFF : LOADER (BASIC stub + boot + tls + http + net wrapper) +# $2000-$3FFF : NET_CODE (ip65 blob + LOADER_OVERFLOW tail + +# CRYPTO_AUX_CODE2) +# $4000-$4F8B : NET_BSS (ip65 blob's BSS — physical occupancy +# stops at $4F8B per ip65-build/ip65-c64.map) +# $4F8C-$5FFF : CRYPTO_OVERLAY (4,212 B reclaimed BSS-TAIL slot — used +# under W2 as overlay slot for ip65; same +# role as UCI's CRYPTO_OVERLAY at $4200. +# Now reserved for future P-384/SHA-384 +# overlay use; BSS no longer routed here.) +# $6000-$9FFF : CRYPTO_RESIDENT (16 KB file-backed — code + rodata. +# Stays below $A000 because boot zeros +# $A000-$BFFF as zero-init BSS.) +# $A000-$BFFF : CRYPTO_COLD_SHADOW (8 KB file-backed — large BSS chunks +# under banked-on RAM; zero-filled in +# PRG, overwritten at runtime.) +# $C000-$CFFF : TCP_BUF (tcp_recv_buf, 4 KB ring) # # CPU port $01 is set to $36 at boot (BASIC ROM off), so $8000-$BFFF is # plain RAM under what would otherwise be BASIC ROM shadow + the top of # the character ROM gap. +# +# --- W1 hot/cold split (Worker I — root-cause fix for the boot +# zero-fill regression on the UCI side; partial fit on ip65). --- +# +# Pre-W1 ip65 had `CRYPTO_RESIDENT` spanning the full 24 KB +# $6000-$BFFF as a single file-backed region. Worker F's W2 attempt +# carved $4F8C-$5FFF out of NET_BSS as `CRYPTO_OVERLAY` and re-routed +# TABLES_BSS / LIB_NISTCURVES_P256_BSS / BSS_TAIL there; the 4,212 B +# slot proved 1,024 B short of those three combined. +# +# The W1 split addresses the cross-cutting correctness issue +# symmetrically with the UCI cfg: +# +# CRYPTO_RESIDENT $6000-$9FFF 16 KB file-backed. Code + rodata +# plus TLS_CODE / CRYPTO_AUX_CODE in +# the small fraction that fits. +# Stays below $A000 so the boot +# zbss loop in src/boot.s (which +# wipes $A000-$BFFF as zero-init +# BSS) cannot wipe any executable +# bytes. Under the bumped library +# (cfa9085) LIB_NISTCURVES_P256_CODE +# would otherwise have straddled +# $A000 and been silently zeroed — +# the production regression that +# this split closes. +# CRYPTO_OVERLAY $4F8C-$5FFF 4,212 B. Holds TLS_CODE + +# CRYPTO_AUX_CODE (Phase C.4 +# placement) under the W1 split, +# freeing CRYPTO_RESIDENT for the +# resident P-256 verify path + +# in-tree TLS app-data primitives. +# Future P-384 / SHA-384 / X25519 +# sibling overlay segments are +# declared `optional = yes` here. +# CRYPTO_COLD_SHADOW $A000-$BFFF 8 KB file-backed (zero-filled). +# BSS chunks land here. +# +# RESIDUAL FIT ISSUE: the total c64-https + libs/nistcurves BSS claim +# (BSS 4,478 B + CRYPTO_BSS 21 B + TABLES_BSS 1,536 B page-aligned + +# BSS_TAIL 2,084 B + LIB_NISTCURVES_P256_BSS 1,573 B = ~9,692 B before +# alignment) exceeds the 8 KB CRYPTO_COLD_SHADOW budget by ~1.5 KB. +# ld65 surfaces a `BSS overflows CRYPTO_COLD_SHADOW by 1662 bytes` +# warning + a non-zero exit. Cfg-only relief is exhausted under the +# bumped library (see Worker I's trace at the W15 review). Resolution +# requires one of: +# (a) Library-side minimal-archive variant — drop data_p256 anchor +# RAM or audit data_p256.o for unused scratch (~1 KB+). +# (b) Source-level BSS split — partition src/data.s BSS into a +# hot/cold pair so cold-path BSS can be routed elsewhere +# (~1.5 KB+). +# (c) Defer-paging — SHA-384 / P-384 overlay-paging strategy +# extended to a c64-https in-tree BSS slice. +# +# UCI is unaffected (62977 B PRG builds clean under W1) — under UCI +# the NET_CODE region absorbs TLS_CODE+CRYPTO_AUX_CODE+CRYPTO_AUX_CODE2 +# (the UCI adapter is ~1.7 KB vs ip65's 6.95 KB blob), opening enough +# CRYPTO_RESIDENT slack to leave BSS budget intact. FEATURES { STARTADDRESS: default = $0801; @@ -30,23 +94,22 @@ MEMORY { LOADADDR: start = $07FF, size = $0002, file = %O; LOADER: start = $0801, size = $17FF, file = %O, define = yes, fill = yes, fillval = $00; NET_CODE: start = $2000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; - # Phase C.4: NET_BSS is split into the ip65-blob-owned portion - # ($4000-$4F8B per ip65-build/ip65-c64.map, rounded up to $4F8C = - # $F8C B) and a tail slack region reused for TLS_CODE + CRYPTO_AUX_CODE - # relocation. Sibling P-256 integration pushed CRYPTO_RESIDENT over - # its 24 KB budget under ip65; UCI had NET_BSS at only $4000-$41FF so - # it already had headroom. The blob's reserved BSS stops exactly at - # $4F8B; NET_BSS_TAIL starts at the next byte so no blob state is - # clobbered by the relocated code. - NET_BSS: start = $4000, size = $0F8C, file = %O, define = yes, fill = yes, fillval = $00; - NET_BSS_TAIL: start = $4F8C, size = $1074, file = %O, define = yes, fill = yes, fillval = $00; - CRYPTO_RESIDENT: start = $6000, size = $6000, file = %O, define = yes, fill = yes, fillval = $00; - - # CRYPTO_OVERLAY is not used under ip65 (no REU-overlay swapping). - # A zero-size rw alias is declared here only to satisfy - # `crypto_swap.s`'s `.import __CRYPTO_OVERLAY_START__` -- ip65 - # never actually issues the DMA, so the address value is unused. - CRYPTO_OVERLAY: start = $6000, size = $0000, type = rw, define = yes; + # NET_BSS holds the ip65 blob's BSS ($4000-$4F8B per ip65-build/ + # ip65-c64.map). The blob's reserved BSS stops exactly at $4F8B; + # the remainder of the original $4000-$5FFF span is reclaimed + # below as CRYPTO_OVERLAY. + NET_BSS: start = $4000, size = $0F8C, file = %O, define = yes, fill = yes, fillval = $00; + # CRYPTO_OVERLAY: the historical W2 reclaimed BSS-TAIL slot at + # $4F8C-$5FFF (4,212 B). Under the W1 hot/cold split this slot + # absorbs TLS_CODE + CRYPTO_AUX_CODE (Phase C.4 placement, 3,760 B, + # 452 B headroom). Future P-384 + SHA-384 archives are anchored + # here (`optional = yes`, zero bytes today). file-backed so any + # leftover content lands zero-filled in the PRG. + CRYPTO_OVERLAY: start = $4F8C, size = $1074, file = %O, define = yes, fill = yes, fillval = $00; + # 16 KB code+rodata stays below $A000 so boot's zbss zero-fill of + # $A000-$BFFF cannot wipe any executable bytes. + CRYPTO_RESIDENT: start = $6000, size = $4000, file = %O, define = yes, fill = yes, fillval = $00; + CRYPTO_COLD_SHADOW: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; # Phase 3: ip65 backend does NOT embed the P-384 split overlay # blobs (no room in main RAM after the existing layout, and ip65 @@ -74,55 +137,75 @@ SEGMENTS { NET_CODE: load = NET_CODE, type = ro; # LOADER-overflow rides along with the ip65 blob in the NET_CODE tail. - # Phase C.4: CRYPTO_AUX_CODE2 (hmac_drbg alone) also rides the NET_CODE - # tail under ip65 because TLS_CODE + sha256's CRYPTO_AUX_CODE together - # would overflow NET_BSS_TAIL by 23 bytes if hmac_drbg were co-located - # there. NET_CODE has ~1 KB of tail slack after the ip65 blob + - # LOADER_OVERFLOW, which covers hmac_drbg's ~830 B. sha256 rides - # NET_BSS_TAIL alongside TLS_CODE. + # CRYPTO_AUX_CODE2 (hmac_drbg alone) also rides the NET_CODE tail to + # spread crypto code across NET_CODE / CRYPTO_RESIDENT / CRYPTO_OVERLAY. LOADER_OVERFLOW: load = NET_CODE, type = ro, optional = yes; CRYPTO_AUX_CODE2: load = NET_CODE, type = ro, optional = yes; NET_BSS: load = NET_BSS, type = bss, optional = yes; - # Phase C.4: TLS_CODE + CRYPTO_AUX_CODE (sha256) relocate out of - # CRYPTO_RESIDENT into NET_BSS_TAIL. hmac_drbg (CRYPTO_AUX_CODE2) - # goes to NET_CODE instead — see the NET_CODE SEGMENTS block. - TLS_CODE: load = NET_BSS_TAIL, type = ro, optional = yes; - CRYPTO_AUX_CODE: load = NET_BSS_TAIL, type = ro, optional = yes; - - # --- Overlay slot placeholders (unused under ip65). --- - # Declared `optional = yes` + `load = CRYPTO_RESIDENT` as harmless - # anchors so shared code that references the segment names still - # links; they receive no bytes under ip65 because no overlay - # archives are linked. - OVERLAY_P256: load = CRYPTO_RESIDENT, type = ro, optional = yes; - OVERLAY_P384: load = CRYPTO_RESIDENT, type = ro, optional = yes; - # Phase C.5: sibling c64-x25519 rodata + bss segments. Under ip65 - # there is no spare 4 KB region available — CRYPTO_OVERLAY is a - # zero-sized alias and NET_BSS_TAIL/NET_CODE both have <1 KB of - # slack. The segments are anchored at CRYPTO_RESIDENT and will - # overflow by ~3.3 KB under USE_X25519_SIBLING=1 until the cfg is - # restructured. Reported as a partial blocker for the integrator; - # USE_X25519_SIBLING=1 works under BACKEND=uci where CRYPTO_OVERLAY - # provides the headroom. - X25519_RODATA: load = CRYPTO_RESIDENT, type = ro, optional = yes, align = $100; - X25519_BSS: load = CRYPTO_RESIDENT, type = bss, optional = yes, align = $100; + # --- W1 hot/cold split: BSS catch-alls land in CRYPTO_COLD_SHADOW. --- + # The total in-tree + library BSS claim (~9.7 KB) exceeds the 8 KB + # CRYPTO_COLD_SHADOW budget. The residual overflow is surfaced at + # link time; the supervisor must decide between source-level BSS + # split, library-side minimal-archive variant, or another overlay + # paging strategy (see the MEMORY entry header). + LIB_NISTCURVES_P256_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + # BSS_TAIL hosts the cert_buf (1.5 KB) + tls_rec_buf (548 B) per + # src/data.s and src/der_decode.s. + BSS_TAIL: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + TABLES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, align = $100; # --- Resident crypto + TLS code / rodata. --- - # Phase C.2 backend-divergence: under UCI, TLS_CODE and CRYPTO_AUX_CODE - # (SHA-256 + HMAC-DRBG + ecdsa_verify dispatcher) relocate to NET_CODE - # to free headroom for Phase C.3 overlays. Phase C.4 gives ip65 the - # same treatment but routes them into NET_BSS_TAIL instead (NET_CODE - # is ~88% full with the ip65 blob under ip65). + # W1 hot/cold split: TLS_CODE + CRYPTO_AUX_CODE ride the historical + # W2 CRYPTO_OVERLAY slot ($4F8C-$5FFF, 4,212 B), restoring their + # Phase C.4 placement. CRYPTO_RESIDENT keeps the resident P-256 + # verify path + chacha20/poly1305 + TLS app-data primitives + rodata + # below $A000. + TLS_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + CRYPTO_AUX_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; CRYPTO_CODE: load = CRYPTO_RESIDENT, type = ro; CRYPTO_RODATA: load = CRYPTO_RESIDENT, type = ro; + # libs/nistcurves segment names (c64-lib-contract / cfa9085+). + # Default: P-256 always-resident in CRYPTO_RESIDENT (matches UCI's + # CRYPTO_HOT default). MUL / TABLES / shared-BSS segments are + # declared `optional = yes` for forward-compat (the minimal P-256 + # archive built by tools/integration/build_nistcurves_p256.sh + # excludes data_shared.o + mul_8x8.o, so these segments receive + # zero bytes today; they would land in CRYPTO_RESIDENT if a future + # archive includes them). + LIB_NISTCURVES_P256_CODE: load = CRYPTO_RESIDENT, type = ro, optional = yes; + LIB_NISTCURVES_P256_RODATA: load = CRYPTO_RESIDENT, type = ro, optional = yes; + LIB_NISTCURVES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + LIB_NISTCURVES_TABLES: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes, align = $100; + LIB_NISTCURVES_MUL_CODE: load = CRYPTO_RESIDENT, type = ro, optional = yes; + # P-384 + SHA-384 routed to CRYPTO_OVERLAY (the reclaimed + # BSS-TAIL slot). Mirrors UCI's overlay-resident routing for the + # same segments. Zero bytes today (no P-384 archive linked under + # ip65); future wiring picks them up here without further cfg + # change. P-384 / SHA-384 BSS lives in CRYPTO_COLD_SHADOW (read/write + # state, BSS-only, not file-backed code). + LIB_NISTCURVES_P384_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_P384_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_P384_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + LIB_NISTCURVES_P384_DATA_BSS: load = CRYPTO_COLD_SHADOW, type = rw, optional = yes; + LIB_NISTCURVES_SHA384_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_TABLES: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100; + LIB_NISTCURVES_SHA384_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; RESIDENT_RODATA: load = CRYPTO_RESIDENT, type = ro, optional = yes; - # --- Resident BSS. Everything that used to live in SHADOW_BSS now - # shares CRYPTO_RESIDENT; TABLES_BSS keeps page alignment. - BSS: load = CRYPTO_RESIDENT, type = bss; - CRYPTO_BSS: load = CRYPTO_RESIDENT, type = bss; - TABLES_BSS: load = CRYPTO_RESIDENT, type = bss, align = $100; + # Phase C.5: sibling c64-x25519 rodata + bss segments. Under ip65 + # CRYPTO_OVERLAY is now real ($4F8C-$5FFF, 4,212 B); the segments + # are anchored there for forward-compat under USE_X25519_SIBLING=1. + # Page alignment respected. + X25519_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100; + X25519_BSS: load = CRYPTO_OVERLAY, type = bss, optional = yes, align = $100; + + # --- Resident BSS. Catch-all c64-https in-tree BSS (src/data.s + # `.segment "BSS"`). Routed to CRYPTO_COLD_SHADOW under the W1 + # hot/cold split; CRYPTO_RESIDENT now carries code+rodata only. + BSS: load = CRYPTO_COLD_SHADOW, type = bss; + CRYPTO_BSS: load = CRYPTO_COLD_SHADOW, type = bss; # Phase 3: ip65 backend stays at the historical 47 KB PRG size -- # USE_OVERLAY_P384_EMBED is gated off in the Makefile under ip65, @@ -133,5 +216,11 @@ SEGMENTS { OVERLAY_BLOB_SHA384: load = CRYPTO_OVERLAY, type = ro, optional = yes; OVERLAY_BLOB_CURVE: load = OVERLAY_BLOB_CURVE_RAM, type = ro, optional = yes; + # W3: P-256 verify overlay blob (segment placeholder). Mirrors the + # UCI cfg's slot so src/crypto/shared/p256_overlay_blobs.s links + # cleanly even though ip65 has no live overlay slot and the .ifdef + # USE_OVERLAY_P256_EMBED guard keeps the segment empty regardless. + OVERLAY_BLOB_P256: load = CRYPTO_OVERLAY, type = ro, optional = yes; + TCP_RECV_BUF: load = TCP_BUF, type = bss, optional = yes; } diff --git a/cfg/c64-https-uci.cfg b/cfg/c64-https-uci.cfg index 9cedb90..ed4352b 100644 --- a/cfg/c64-https-uci.cfg +++ b/cfg/c64-https-uci.cfg @@ -3,34 +3,61 @@ # Target: Commodore Ultimate 64 / U64E using the host-visible UCI # ($DF1B-$DF1F) in place of ip65 + RR-Net. # -# Memory map: -# $0801-$1FFF : LOADER (BASIC stub + boot + http + net wrapper) -# $2000-$3FFF : NET_CODE (UCI adapter + LOADER_OVERFLOW tail + -# TLS_CODE + CRYPTO_AUX_CODE) -# $4000-$41FF : UCI_BSS (uci_host_buf + state, 512 B) -# $4200-$5FFF : CRYPTO_OVERLAY (7.5 KB swappable overlay slot — used -# by the external P-384 smoke test only) -# $6000-$BFFF : CRYPTO_RESIDENT (24 KB always-resident crypto + TLS + BSS) -# $C000-$CFFF : TCP_BUF (tcp_recv_buf, 4 KB ring) +# Memory map (post-W1 partial — see comment block at the MEMORY block +# below for the fit constraints that forced this layout): +# $0801-$1FFF : LOADER (BASIC stub + boot + http + net wrapper) +# $2000-$3B25 : NET_CODE (UCI adapter + LOADER_OVERFLOW tail + +# TLS_CODE + CRYPTO_AUX_CODE) +# $3B26-$3FFF : NET_BSS_TAIL (BSS spill-over reclaimed from the +# NET_CODE tail — UCI_BSS + +# LIB_NISTCURVES_P256_BSS land here) +# $4000-$41FF : UCI_BSS_REGION (zero-size alias post-W1; UCI_BSS +# moved into NET_BSS_TAIL above) +# $4200-$5FFF : CRYPTO_OVERLAY (7.5 KB swappable overlay slot — used +# by the X25519 sibling / P-384 overlay +# / W3 P-256 overlay embed) +# $6000-$BFFF : CRYPTO_HOT (24 KB file-backed; always-resident +# hot + warm + much of the cold path: +# ChaCha20-Poly1305, SHA-256, HKDF/ +# HMAC-DRBG, TLS, HTTP, mul tables, +# AEAD, transcript, libs/nistcurves +# P-256 verify + most BSS. The W1 plan +# wanted a 16 KB hot / 8 KB cold split +# here but the bumped library does not +# fit that partition — see comment +# block at the MEMORY entry.) +# $C000-$CFFF : TCP_BUF (tcp_recv_buf, 4 KB ring) # -# NOTE on UCI_BSS size: the plan's "256 B" target was optimistic — -# `src/net/uci/net.s` + `uci_cmd.s` allocate ~289 B (uci_host_buf 256 B + -# uci_ipaddr_resp 12 B + uci_socket_id/port/send/poll/... ~16 B + -# uci_resp control block 4 B). Rounded up to the next page (512 B) and -# the overlay slot trimmed accordingly (7.5 KB vs. 8 KB under ip65). +# --- W1 hot/cold partition (post-bump library-ingestion architecture) --- +# Before: CRYPTO_RESIDENT was a single 24 KB file-backed region $6000-$BFFF +# carrying every byte of crypto code + rodata + BSS. CLAUDE.md recorded it +# as "100% full" after Phase 6; the libs/nistcurves cfa9085 bump (which +# adds ~768 B of new code via PR #34 + PR #26) would have overflowed it. # -# CRYPTO_RESIDENT is 24 KB on UCI (vs 16 KB target on ip65) because UCI -# does not need $4000-$5FFF for backend BSS, letting CRYPTO_RESIDENT -# start at $6000 and claim the former NET_BSS space. +# The W1 split lets the file-backed region carry only what needs PRG-load +# byte initialization (code + rodata, $6000-$9FFF = 16 KB), and reclaims +# $A000-$BFFF as plain RAM under BASIC ROM banking for BSS that is +# zero-initialized at runtime anyway. This is the same "$01 = $36" RAM +# slice that pre-Phase-6 hosted the original SHADOW_BSS region; bringing +# it back as a dedicated BSS slot leaves room in CRYPTO_HOT for the +# nistcurves bump's additional code bytes. # -# --- x25519 overlay rollback note --- -# Phase C.1 (commit 6c9d2a3) integrated libs/x25519/ as a REU overlay -# and split CRYPTO_RESIDENT around a 1 KB sqtab hole. That integration -# deadlocked the TLS handshake at 48 MHz (x25519_scalarmult hung from -# TLS context) and was rolled back. The in-tree src/crypto/x25519.s + -# src/crypto/fe25519.s are now used under both backends. CRYPTO_OVERLAY -# still exists to serve the P-384 external smoke test (Phase C.3b, -# tools/test_p384_symbols.py). +# Library segments (under c64-lib-contract / libs/nistcurves cfa9085+): +# +# LIB_NISTCURVES_P256_CODE - fp256, mod256, points256_core, ecdsa256. +# Default: CRYPTO_HOT (always-resident). +# Under EMBED_P256_OVERLAY=1: routed via +# OVERLAY_BLOB_P256 to the live overlay +# slot at boot. +# LIB_NISTCURVES_P256_RODATA - curve256, mod256 constants. CRYPTO_HOT. +# LIB_NISTCURVES_P256_BSS - data_p256 working buffers. CRYPTO_HOT BSS. +# LIB_NISTCURVES_P384_* - routed via the OVERLAY_P384_CURVE slot +# (paged from REU bank 7 at handshake time). +# LIB_NISTCURVES_SHA384_* - routed via the OVERLAY_P384_SHA384 slot +# (paged from REU bank 6 at handshake time). +# +# (For the W2 ip65-side restructure, see cfg/c64-https-ip65.cfg. ip65 +# stays on the pre-W1 layout for now — out of scope here.) FEATURES { STARTADDRESS: default = $0801; @@ -42,14 +69,65 @@ MEMORY { LOADADDR: start = $07FF, size = $0002, file = %O; LOADER: start = $0801, size = $17FF, file = %O, define = yes, fill = yes, fillval = $00; - NET_CODE: start = $2000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; - UCI_BSS_REGION: start = $4000, size = $0200, file = %O, define = yes, fill = yes, fillval = $00; + # W1 partial: NET_CODE shrunk to fit current content (~$1B26 = 6950 B + # on branch tip), tail reclaimed as NET_BSS_TAIL to spill BSS that + # does not fit in CRYPTO_HOT under the bumped libs/nistcurves. UCI_BSS + # now shares NET_BSS_TAIL too (the dedicated UCI_BSS_REGION was 512 B + # but only used ~$125 — folding it into NET_BSS_TAIL frees ~370 B + # for the libs/nistcurves spill-over). ip65 has had an equivalent + # NET_BSS_TAIL since Phase C.4 — UCI now adopts the same pattern. + NET_CODE: start = $2000, size = $1B26, file = %O, define = yes, fill = yes, fillval = $00; + # NET_BSS_TAIL spans NET_CODE end through the start of CRYPTO_OVERLAY, + # subsuming the historical UCI_BSS_REGION at $4000-$41FF (UCI_BSS now + # rides on the head of this combined region). Holds 1.7 KB total — + # ld65 distributes UCI_BSS (~293 B) + LIB_NISTCURVES_P256_BSS (~1.5 + # KB) + a stub UCI_BSS_REGION definition. + NET_BSS_TAIL: start = $3B26, size = $06DA, file = %O, define = yes, fill = yes, fillval = $00; + # UCI_BSS_REGION kept as a zero-size alias so any external tooling + # that resolves the symbol via labels.txt still finds it (the cfg's + # `define = yes` emits __UCI_BSS_REGION_START__ etc.). + UCI_BSS_REGION: start = $4000, size = $0000, type = rw, define = yes; CRYPTO_OVERLAY: start = $4200, size = $1E00, file = %O, define = yes, fill = yes, fillval = $00; - CRYPTO_RESIDENT: start = $6000, size = $6000, file = %O, define = yes, fill = yes, fillval = $00; + + # W1 HOT/COLD SPLIT (Worker I — root-cause fix for the + # boot zero-fill regression): + # + # The W1 partial layout had CRYPTO_HOT spanning the full 24 KB + # $6000-$BFFF as a single file-backed region. Under the bumped + # libs/nistcurves, ld65 placed `LIB_NISTCURVES_P256_CODE` at + # $868F-$A631 — *straddling $A000*. Boot (`src/boot.s` zbss loop) + # zeros $A000-$BFFF as "SHADOW_BSS" zero-init, which wiped the + # upper 1.5 KB of `ecdsa_verify_256`; the first call to verify + # landed the CPU on `00 00 …` ⇒ BRK ⇒ KERNAL warm restart ⇒ + # BASIC READY ⇒ eternal hang during the TLS CertVerify step. + # + # The fix carves the region as originally intended: + # + # CRYPTO_HOT $6000-$9FFF 16 KB file-backed. + # Code + rodata + small BSS that + # do not need page alignment. + # No segment crosses $A000. + # CRYPTO_COLD_SHADOW $A000-$BFFF 8 KB file-backed (zero-filled). + # Catch-all for the large BSS + # segments — wiped at boot by + # the zbss loop, which is benign + # because they are BSS anyway. + # File-backed (`fill = yes`) so + # the PRG stays contiguous on + # disk; the zero bytes are + # overwritten at runtime. + # + # See SEGMENTS{} below for the new routings (BSS / CRYPTO_BSS / + # TABLES_BSS / BSS_TAIL → CRYPTO_COLD_SHADOW; small UCI_BSS stays + # in CRYPTO_HOT). The 16 KB hot half fits the bumped library + # comfortably; the 8 KB cold half has enough room for the four + # largest BSS chunks routed there. + CRYPTO_HOT: start = $6000, size = $4000, file = %O, define = yes, fill = yes, fillval = $00; + CRYPTO_COLD_SHADOW: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; # Phase 3: file-backed pad region from $C000-$DFFF. ld65 emits # contiguous file output; the under-KERNAL OVERLAY_BLOB_CURVE_RAM - # region at $E000-$FDFF requires the gap between CRYPTO_RESIDENT + # region at $E000-$FDFF requires the gap between CRYPTO_HOT/SHADOW # and $E000 to land in the file as zeros so KERNAL LOAD writes the # curve blob bytes to $E000 (not $C801). $C000-$CFFF is TCP_BUF # at runtime (RAM, populated by ip65/UCI rx callback after net @@ -85,13 +163,13 @@ SEGMENTS { EXEHDR: load = LOADER, type = ro; STARTUP: load = LOADER, type = ro, optional = yes; CODE: load = LOADER, type = ro; - RODATA: load = CRYPTO_RESIDENT, type = ro; + RODATA: load = CRYPTO_HOT, type = ro; INIT: load = LOADER, type = ro, optional = yes; # NET_CODE hosts the UCI adapter + the LOADER_OVERFLOW tail. # Phase C.2 (UCI only): TLS_CODE and CRYPTO_AUX_CODE (SHA-256, # HMAC-DRBG, ecdsa_verify dispatcher) are relocated here as well - # to open headroom in CRYPTO_RESIDENT. The UCI adapter is ~1.7 KB + # to open headroom in CRYPTO_HOT. The UCI adapter is ~1.7 KB # so NET_CODE has ~6.3 KB free after UCI_CODE + LOADER_OVERFLOW; # TLS_CODE ~1.8 KB + CRYPTO_AUX_CODE ~3 KB fits with room to spare. NET_CODE: load = NET_CODE, type = ro, optional = yes; @@ -103,40 +181,107 @@ SEGMENTS { # can split it off into NET_CODE while sha256 stays in NET_BSS_TAIL. # Under UCI both segments flow into NET_CODE identically. CRYPTO_AUX_CODE2: load = NET_CODE, type = ro, optional = yes; - UCI_BSS: load = UCI_BSS_REGION, type = bss, optional = yes; + # UCI_BSS (~293 B) is small and stays in CRYPTO_HOT (16 KB slot + # has room for it alongside code + rodata; keeping it here saves + # space in the 8 KB CRYPTO_COLD_SHADOW for the larger BSS chunks). + UCI_BSS: load = CRYPTO_HOT, type = bss, optional = yes; + # NET_BSS_TAIL is the spill-over BSS region carved from the + # NET_CODE tail. Under the W1 hot/cold split (Worker I) it is + # mostly freed: LIB_NISTCURVES_P256_BSS no longer rides here + # (it moved to CRYPTO_COLD_SHADOW), so this region is effectively + # available for harness scratch. The segment declaration stays + # for any future BSS routing or harness use. + NET_BSS_TAIL: load = NET_BSS_TAIL, type = bss, optional = yes; + # BSS_TAIL hosts the largest c64-https in-tree BSS buffers + # (tls_rec_buf 548 B + cert_buf 1.5 KB). Worker I routed it to + # CRYPTO_COLD_SHADOW ($A000-$BFFF) under the hot/cold split. + BSS_TAIL: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; - # --- Overlay slot: used only by the P-384 external smoke test. --- + # --- Overlay slot. --- # The P-256 / P-384 OVERLAY segments are declared so ld65 has a # valid load address even when nothing is currently linked. The - # production PRG does not embed any overlay image; the P-384 test - # harness DMAs a standalone overlay-p384.bin image into this slot - # at test time (see tools/test_p384_symbols.py). + # production PRG does not always embed an overlay image; under + # EMBED_P256_OVERLAY=1 the P-256 .bin is .incbin'd via + # OVERLAY_BLOB_P256; under USE_OVERLAY_P384_EMBED=1 the P-384 split + # blobs ride OVERLAY_BLOB_SHA384. OVERLAY_P256: load = CRYPTO_OVERLAY, type = ro, optional = yes; OVERLAY_P384: load = CRYPTO_OVERLAY, type = ro, optional = yes; # Phase C.5: sibling c64-x25519 rodata tables (mul38, sqr_lo/hi, # a24_b0..b3 — ~2 KB) AND the sibling's page-aligned BSS buffers # (fe25519_tmp1..4, x25_*, mul_dma_lo/hi/carry — ~1.5 KB) ride - # CRYPTO_OVERLAY under UCI to keep CRYPTO_RESIDENT inside its - # 24 KB budget. CRYPTO_OVERLAY is otherwise unused in the - # production UCI build (only the P-384 external smoke test DMAs - # into it at test time, and that's a harness operation rather - # than a production path). align = $100 so the .align 256 - # directives in data_x25519_{rodata,bss}_raw.s land on real - # page boundaries. + # CRYPTO_OVERLAY under UCI to keep CRYPTO_HOT inside its budget. + # CRYPTO_OVERLAY is otherwise unused in the production UCI build + # unless an overlay-embed flag is set (mutually exclusive across + # USE_X25519_SIBLING / EMBED_P256_OVERLAY / USE_OVERLAY_P384_EMBED). + # align = $100 so the .align 256 directives in + # data_x25519_{rodata,bss}_raw.s land on real page boundaries. X25519_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100; X25519_BSS: load = CRYPTO_OVERLAY, type = bss, optional = yes, align = $100; # --- Resident crypto + TLS code / rodata. --- - CRYPTO_CODE: load = CRYPTO_RESIDENT, type = ro; - CRYPTO_RODATA: load = CRYPTO_RESIDENT, type = ro; - RESIDENT_RODATA: load = CRYPTO_RESIDENT, type = ro, optional = yes; - CRYPTO_INIT_CODE: load = CRYPTO_RESIDENT, type = ro, optional = yes; + CRYPTO_CODE: load = CRYPTO_HOT, type = ro; + CRYPTO_RODATA: load = CRYPTO_HOT, type = ro; + RESIDENT_RODATA: load = CRYPTO_HOT, type = ro, optional = yes; + CRYPTO_INIT_CODE: load = CRYPTO_HOT, type = ro, optional = yes; - # --- Resident BSS. --- - BSS: load = CRYPTO_RESIDENT, type = bss; - CRYPTO_BSS: load = CRYPTO_RESIDENT, type = bss; - TABLES_BSS: load = CRYPTO_RESIDENT, type = bss, align = $100; + # --- W5: libs/nistcurves segments (c64-lib-contract SPEC §4). --- + # Under the default flag set (EMBED_P256_OVERLAY=0), the P-256 + # verify primitives are always-resident in CRYPTO_HOT — mirrors + # the pre-restructure behavior. Under EMBED_P256_OVERLAY=1 they + # ride the overlay slot; the segment definitions below stay + # routed to CRYPTO_HOT because the embed path operates on a .bin + # image (built by tools/integration/build_nistcurves_p256_bin.sh + # from this same archive), not by re-routing the segments at + # link time. + LIB_NISTCURVES_P256_CODE: load = CRYPTO_HOT, type = ro, optional = yes; + LIB_NISTCURVES_P256_RODATA: load = CRYPTO_HOT, type = ro, optional = yes; + # P-256 BSS is ~1.5 KB (fp256/mod256/points256 scratch + ecdsa256 + # input/output staging + data_p256 working buffers). Stays in + # NET_BSS_TAIL under the W1 hot/cold split — CRYPTO_COLD_SHADOW + # is full carrying the in-tree BSS chunks (BSS / CRYPTO_BSS / + # TABLES_BSS / BSS_TAIL = ~8.1 KB out of the 8 KB budget once + # TABLES_BSS page-alignment is accounted for). NET_BSS_TAIL has + # ~185 B slack after the P-256 claim — adequate for harness use + # via the candidate-fallback path in tools/uci/_memory_policy.py. + LIB_NISTCURVES_P256_BSS: load = NET_BSS_TAIL, type = bss, optional = yes; + # The P-256 archive does not include data_shared.o or mul_8x8.o + # (c64-https provides those — see tools/integration/build_nistcurves_p256.sh), + # so the LIB_NISTCURVES_BSS / _TABLES / _MUL_CODE segments are + # declared `optional = yes` for forward compatibility but receive + # zero bytes today. + LIB_NISTCURVES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + LIB_NISTCURVES_TABLES: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes, align = $100; + LIB_NISTCURVES_MUL_CODE: load = CRYPTO_HOT, type = ro, optional = yes; + # libs/nistcurves P-384 segments — routed via the OVERLAY_P384_CURVE + # bin staging (DMA'd to REU bank 7 at boot, paged into the live + # overlay slot on a P-384 handshake). Mirrors the pre-contract + # OVERLAY_P384_CURVE segment name in the old build_nistcurves_p384.sh + # output. The library now emits the new names natively. + LIB_NISTCURVES_P384_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_P384_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_P384_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + LIB_NISTCURVES_P384_DATA_BSS: load = CRYPTO_COLD_SHADOW, type = rw, optional = yes; + # libs/nistcurves SHA-384 segments — routed via the OVERLAY_P384_SHA384 + # bin staging (DMA'd to REU bank 6 at boot). + LIB_NISTCURVES_SHA384_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_RODATA: load = CRYPTO_OVERLAY, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_TABLES: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100; + LIB_NISTCURVES_SHA384_BSS: load = CRYPTO_COLD_SHADOW, type = bss, optional = yes; + + # --- Resident BSS — routed to CRYPTO_COLD_SHADOW under the W1 + # hot/cold split (Worker I). The BSS / CRYPTO_BSS segments are + # the catch-all for in-tree c64-https state declarations + # (src/data.s); landing them in the $A000-$BFFF banked-on RAM + # slice frees the 16 KB CRYPTO_HOT region for code + rodata + # (which MUST stay below $A000 because boot zeros that span as + # zero-init BSS). + BSS: load = CRYPTO_COLD_SHADOW, type = bss; + CRYPTO_BSS: load = CRYPTO_COLD_SHADOW, type = bss; + # TABLES_BSS pins the 1.5 KB sqtab/mul_dma tables on a page + # boundary. Page alignment is load-bearing for the no-page-penalty + # `lda abs,Y` addressing in the hot-path multiply routines. + TABLES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, align = $100; # Phase 3: P-384 split overlay blobs embedded in the PRG. Boot # DMAs them out to REU banks 6/7 then the staging RAM is free. @@ -149,4 +294,12 @@ SEGMENTS { # in that build). OVERLAY_BLOB_SHA384: load = CRYPTO_OVERLAY, type = ro, optional = yes; OVERLAY_BLOB_CURVE: load = OVERLAY_BLOB_CURVE_RAM, type = ro, optional = yes; + + # W3: P-256 verify overlay blob. Optional / off by default; embedded + # when the Makefile flag EMBED_P256_OVERLAY=1 is set (which causes + # src/crypto/shared/p256_overlay_blobs.s to .incbin the .bin file + # under USE_OVERLAY_P256_EMBED). Shares the CRYPTO_OVERLAY slot + # with OVERLAY_BLOB_SHA384 at PRG-load time -- mutually exclusive: + # the Makefile disables P-384 SHA embedding when EMBED_P256_OVERLAY=1. + OVERLAY_BLOB_P256: load = CRYPTO_OVERLAY, type = ro, optional = yes; } diff --git a/cfg/p256-overlay-verify.cfg b/cfg/p256-overlay-verify.cfg new file mode 100644 index 0000000..baaf571 --- /dev/null +++ b/cfg/p256-overlay-verify.cfg @@ -0,0 +1,54 @@ +# cfg/p256-overlay-verify.cfg -- ld65 config for the P-256 verify overlay +# image (W3 library-ingestion architecture). +# +# Mirrors cfg/p384-overlay-{sha384,curve}.cfg. Produces a padded .bin +# image that the boot stash (reu_p384_overlay_init in src/boot.s) loads +# into REU bank 2 slot $22100 (REU_OVERLAY_P256_VERIFY). Subsequent +# `crypto_swap_to_p256_verify` calls DMA the bytes back into the live +# CRYPTO_OVERLAY slot at $4200-$5FFF. +# +# The DATA / BSS exports stay at $C000 (matches the standalone P-384 +# overlay cfgs' RESIDENT layout). Under c64-https's main UCI cfg these +# RW buffers live in CRYPTO_BSS at $A000+; for the overlay-image link +# we just need stable addresses to satisfy linker references -- the +# .bin itself contains only the OVERLAY_P256_VERIFY segment bytes. +# +# Layout matches the live UCI CRYPTO_OVERLAY base ($4200) and size +# ($1E00 = 7,680 B / 7.5 KB) so the .bin DMAs into the live slot +# cleanly at harness time. + +FEATURES { + STARTADDRESS: default = $4200; +} + +MEMORY { + ZP: start = $0022, size = $001E, type = rw, define = yes; + OVERLAY_REGION: start = $4200, size = $1E00, file = %O, define = yes, + fill = yes, fillval = $00; + RESIDENT: start = $C000, size = $1000, type = rw, define = yes; +} + +SEGMENTS { + ZEROPAGE: load = ZP, type = zp, optional = yes; + + # W5: new library segment names (c64-lib-contract / libs/nistcurves + # cfa9085+) are the canonical set under the contract. The legacy + # OVERLAY_P256_VERIFY alias + CRYPTO_CODE/RODATA aliases stay for + # backward compat with any in-tree force-link path that hasn't + # migrated yet. + OVERLAY_P256_VERIFY: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_P256_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_P256_RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + CRYPTO_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + CRYPTO_RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + CODE: load = OVERLAY_REGION, type = ro, optional = yes; + + # Resident RW buffers -- we don't write them to the .bin, but they + # need real addresses so labels are correct. + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; + CRYPTO_BSS: load = RESIDENT, type = bss, optional = yes; + LIB_NISTCURVES_P256_BSS: load = RESIDENT, type = bss, optional = yes; + ZEROPAGE_BSS: load = ZP, type = bss, optional = yes; +} diff --git a/cfg/p384-overlay-curve.cfg b/cfg/p384-overlay-curve.cfg index 815ffe0..db98c9d 100644 --- a/cfg/p384-overlay-curve.cfg +++ b/cfg/p384-overlay-curve.cfg @@ -31,12 +31,21 @@ MEMORY { } SEGMENTS { - ZEROPAGE: load = ZP, type = zp, optional = yes; + ZEROPAGE: load = ZP, type = zp, optional = yes; - OVERLAY_P384_CURVE: load = OVERLAY_REGION, type = ro; + # W5: new library segment names (c64-lib-contract / libs/nistcurves + # cfa9085+). The legacy OVERLAY_P384_CURVE alias remains for the + # in-staging shim (build_nistcurves_p384.sh emits + # ec_scalar_mul_384_shim.s with this segment name). + # LIB_NISTCURVES_P384_* is the canonical set under the contract. + OVERLAY_P384_CURVE: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_P384_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_P384_RODATA: load = OVERLAY_REGION, type = ro, optional = yes; # Resident RW buffers — we don't write them to the .bin, but they # need real addresses so labels are correct. - DATA: load = RESIDENT, type = rw, optional = yes; - BSS: load = RESIDENT, type = bss, optional = yes; + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; + LIB_NISTCURVES_P384_BSS: load = RESIDENT, type = bss, optional = yes; + LIB_NISTCURVES_P384_DATA_BSS: load = RESIDENT, type = rw, optional = yes; } diff --git a/cfg/p384-overlay-sha384.cfg b/cfg/p384-overlay-sha384.cfg index c020bbf..19b3d6c 100644 --- a/cfg/p384-overlay-sha384.cfg +++ b/cfg/p384-overlay-sha384.cfg @@ -34,10 +34,18 @@ MEMORY { SEGMENTS { ZEROPAGE: load = ZP, type = zp, optional = yes; - OVERLAY_P384_SHA384: load = OVERLAY_REGION, type = ro; + # W5: new library segment names (c64-lib-contract / libs/nistcurves + # cfa9085+). The legacy OVERLAY_P384_SHA384 alias remains for any + # in-tree force-link stub that still uses it; LIB_NISTCURVES_SHA384_* + # is the canonical set under the contract. + OVERLAY_P384_SHA384: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + LIB_NISTCURVES_SHA384_TABLES: load = OVERLAY_REGION, type = ro, optional = yes, align = $100; # Resident RW buffers — we don't write them to the .bin, but they # need real addresses so labels are correct. - DATA: load = RESIDENT, type = rw, optional = yes; - BSS: load = RESIDENT, type = bss, optional = yes; + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; + LIB_NISTCURVES_SHA384_BSS: load = RESIDENT, type = bss, optional = yes; } diff --git a/cfg/x25519-overlay-scalarmult.cfg b/cfg/x25519-overlay-scalarmult.cfg new file mode 100644 index 0000000..d2a0110 --- /dev/null +++ b/cfg/x25519-overlay-scalarmult.cfg @@ -0,0 +1,56 @@ +# cfg/x25519-overlay-scalarmult.cfg -- ld65 config for the X25519 +# scalarmult overlay .bin image (W3 library-ingestion architecture). +# +# Mirrors cfg/p256-overlay-verify.cfg + cfg/p384-overlay-{sha384,curve}.cfg. +# Produces a 7,680 B padded .bin image of the c64-x25519 sibling's code +# + rodata + bss, suitable for embedding via .incbin into the PRG +# (planned W1 wiring) or for documentation / CI artefact use. +# +# Today the X25519 sibling rodata + bss is linker-placed into +# CRYPTO_OVERLAY at PRG-load time under USE_X25519_SIBLING=1 (see +# cfg/c64-https-uci.cfg's X25519_RODATA / X25519_BSS segments). This +# .bin is the same byte image in a standalone linker invocation -- a +# CI-friendly artefact that can be diffed against the in-PRG slot +# bytes for parity checking. +# +# Layout matches the live UCI CRYPTO_OVERLAY base ($4200) and size +# ($1E00 = 7,680 B / 7.5 KB) so the .bin DMAs into the live slot +# cleanly at harness time. + +FEATURES { + STARTADDRESS: default = $4200; +} + +MEMORY { + ZP: start = $0022, size = $001E, type = rw, define = yes; + OVERLAY_REGION: start = $4200, size = $1E00, file = %O, define = yes, + fill = yes, fillval = $00; + RESIDENT: start = $C000, size = $1000, type = rw, define = yes; +} + +SEGMENTS { + ZEROPAGE: load = ZP, type = zp, optional = yes; + + # The sibling archive uses CRYPTO_CODE for code and + # X25519_RODATA / X25519_BSS for its tables (set by + # tools/integration/build_x25519.sh). Route the code + rodata + # into the overlay region so the .bin is the byte image the main + # PRG's live slot will hold. X25519_BSS is routed to RESIDENT + # rather than OVERLAY_REGION: BSS bytes are zero-initialized at + # runtime by reu_clear_wide / x25519_init, so the .bin doesn't + # need them. Including BSS in the overlay region overflows by + # ~512 B (combined CRYPTO_CODE + X25519_RODATA + X25519_BSS just + # barely doesn't fit at $1E00). + CRYPTO_CODE: load = OVERLAY_REGION, type = ro, optional = yes; + X25519_RODATA: load = OVERLAY_REGION, type = ro, optional = yes, align = $100; + RODATA: load = OVERLAY_REGION, type = ro, optional = yes; + CODE: load = OVERLAY_REGION, type = ro, optional = yes; + + # Resident RW buffers (none used by x25519 archive today) -- pinned + # at $C000 just so any future symbol references resolve. + X25519_BSS: load = RESIDENT, type = bss, optional = yes, align = $100; + DATA: load = RESIDENT, type = rw, optional = yes; + BSS: load = RESIDENT, type = bss, optional = yes; + CRYPTO_BSS: load = RESIDENT, type = bss, optional = yes; + TABLES_BSS: load = RESIDENT, type = bss, optional = yes; +} diff --git a/libs/nistcurves b/libs/nistcurves index 90830c9..b67de54 160000 --- a/libs/nistcurves +++ b/libs/nistcurves @@ -1 +1 @@ -Subproject commit 90830c920af7fcc5ded7da6b4dd201ab535e57b4 +Subproject commit b67de54520bb4193b073d9703d5c85adcf29f505 diff --git a/libs/x25519 b/libs/x25519 index 47c0ad2..95fdd70 160000 --- a/libs/x25519 +++ b/libs/x25519 @@ -1 +1 @@ -Subproject commit 47c0ad21a57ae443632f5e7689cbe9f3de98460e +Subproject commit 95fdd705b0f7d780cada3dee08158084d327c3f9 diff --git a/src/boot.s b/src/boot.s index 9707bad..172ebb3 100644 --- a/src/boot.s +++ b/src/boot.s @@ -155,6 +155,35 @@ .include "reu_layout.inc" .endif + ; ---- imports: W3 embedded P-256 verify overlay blob anchor ---- + ; Mirror of the P-384 pattern above. Resolved by + ; src/crypto/shared/p256_overlay_blobs.s when + ; USE_OVERLAY_P256_EMBED is on (gated from the top-level Makefile + ; by EMBED_P256_OVERLAY=1). Mutually exclusive with + ; USE_OVERLAY_P384_EMBED at the cfg level -- both target the + ; CRYPTO_OVERLAY slot at PRG-load time, so the Makefile turns + ; P-384 embedding off when EMBED_P256_OVERLAY=1. + .ifdef USE_OVERLAY_P256_EMBED + .import p256_overlay_verify_blob + ; Same REU layout include rationale as the P-384 block above + ; (.ifndef-guarded; idempotent). + .include "reu_layout.inc" + .endif + + ; ---- imports: W3 X25519 sibling slot stash ---- + ; Under USE_X25519_SIBLING=1, the sibling's X25519_RODATA + + ; X25519_BSS segments load into CRYPTO_OVERLAY at PRG-load time. + ; Boot stashes those slot bytes (i.e. the sibling's running + ; code+rodata image) to REU bank 3 so a later + ; `crypto_swap_to_x25519` can refresh the slot from there after + ; a P-256 / P-384 swap has overwritten it. No new .incbin + ; needed -- the linker already pinned the bytes at $4200. + .ifdef USE_X25519_SIBLING + .import __CRYPTO_OVERLAY_START__ + ; Same REU layout include rationale as above. + .include "reu_layout.inc" + .endif + ; ============================================================================= ; BASIC stub: 10 SYS 2061 ; Loaded at $0801 via EXEHDR segment (first bytes of LOADER region). @@ -907,6 +936,83 @@ reu_p384_overlay_init: sta reu_command plp .endif ; .ifdef USE_OVERLAY_P384_EMBED + +; ----------------------------------------------------------------------------- +; W3: P-256 verify image stash (Makefile EMBED_P256_OVERLAY=1). +; +; When `USE_OVERLAY_P256_EMBED` is defined the cfg routes +; OVERLAY_BLOB_P256 into CRYPTO_OVERLAY at PRG-load time (mutually +; exclusive with OVERLAY_BLOB_SHA384 -- the Makefile turns +; USE_OVERLAY_P384_EMBED off when EMBED_P256_OVERLAY=1). Boot DMAs the +; slot bytes to REU_OVERLAY_P256_VERIFY (bank 2, $22100) so a later +; `crypto_swap_to_p256_verify` can refresh the slot. Same SEI window +; + ~8 ms cost as the P-384 stash above; STASH (C64->REU) command +; $90. +; ----------------------------------------------------------------------------- +.ifdef USE_OVERLAY_P256_EMBED + php + sei + lda #p256_overlay_verify_blob + sta reu_c64_hi + lda #REU_OVERLAY_P256_VERIFY + sta reu_reu_hi + lda #^REU_OVERLAY_P256_VERIFY + sta reu_reu_bank + lda #OVERLAY_SIZE + sta reu_len_hi + lda #0 + sta reu_addr_ctrl + lda #$90 ; execute + STASH (C64->REU) + sta reu_command + plp +.endif ; .ifdef USE_OVERLAY_P256_EMBED + +; ----------------------------------------------------------------------------- +; W3: X25519 sibling slot stash (USE_X25519_SIBLING=1). +; +; The sibling's X25519_RODATA + X25519_BSS segments load into +; CRYPTO_OVERLAY at PRG-load time (see cfg/c64-https-uci.cfg). Boot +; STASHes the slot bytes to REU_OVERLAY_X25519 (bank 3, $30000) so a +; later `crypto_swap_to_x25519` can refresh the slot after a P-256 / +; P-384 swap has overwritten it. Same SEI window + ~8 ms cost as the +; P-256 stash above. No .incbin -- the linker already pinned the +; sibling image into CRYPTO_OVERLAY. +; +; NB: this stashes the *initialized* portion of CRYPTO_OVERLAY (the +; sibling's rodata tables) plus any zero-init BSS bytes that fall in +; the same span. The BSS is fine to stash-and-restore because the +; sibling's `reu_mul_init` rebuilds the volatile mul tables anyway; +; the rodata round-trip is the load-bearing part. +; ----------------------------------------------------------------------------- +.ifdef USE_X25519_SIBLING + php + sei + lda #<__CRYPTO_OVERLAY_START__ + sta reu_c64_lo + lda #>__CRYPTO_OVERLAY_START__ + sta reu_c64_hi + lda #REU_OVERLAY_X25519 + sta reu_reu_hi + lda #^REU_OVERLAY_X25519 + sta reu_reu_bank + lda #OVERLAY_SIZE + sta reu_len_hi + lda #0 + sta reu_addr_ctrl + lda #$90 ; execute + STASH (C64->REU) + sta reu_command + plp +.endif ; .ifdef USE_X25519_SIBLING rts ; ============================================================================= diff --git a/src/crypto/shared/crypto_swap.s b/src/crypto/shared/crypto_swap.s index 6adb2a8..c938d0a 100644 --- a/src/crypto/shared/crypto_swap.s +++ b/src/crypto/shared/crypto_swap.s @@ -122,34 +122,47 @@ .include "constants.inc" ; reu_* register equates .include "reu_layout.inc" + .include "overlay_ids.inc" ; OV_* constants (W3) .export crypto_swap_to_x25519_sibling + .export crypto_swap_to_x25519 ; W3 new + .export crypto_swap_to_p256_verify ; W3 new .export crypto_swap_to_p384_sha384 .export crypto_swap_to_p384_curve .export crypto_swap_none + .export crypto_overlay_call ; W3 new .export current_overlay ; Export REU layout equates once (kept in sync with reu_layout.inc). .export REU_OVERLAY_P384_SHA384 .export REU_OVERLAY_P384_CURVE + .export REU_OVERLAY_P256_VERIFY ; W3 new + .export REU_OVERLAY_X25519 ; W3 new .export OVERLAY_SIZE ; Live overlay slot start address (from the cfg's MEMORY{} define). .import __CRYPTO_OVERLAY_START__ ; ----------------------------------------------------------------------------- -; Overlay IDs -- must stay in sync with `current_overlay` comments above. +; Overlay IDs -- canonical values live in `overlay_ids.inc` (W3). This +; file's local equates above (OV_NONE, OV_X25519_SIBLING, OV_P384_SHA384, +; OV_P384_CURVE) were folded into the include; the .ifndef-guarded +; definitions there are the single source of truth. `OV_P256_VERIFY` +; (id 2) and `OV_X25519` (id 3) are the new W3 additions. +; +; NB: the architecture plan's "OV_X25519=3, OV_P256_VERIFY=4" sketch +; assumed OV_P384_SHA384/CURVE were 1/2 — they are actually 4/5 (Phase 3 +; intentionally skipped 2/3 for headroom). The W3 IDs slot into the +; reserved gap so existing call sites that compare `current_overlay` +; against OV_P384_* see no renumber. ; ----------------------------------------------------------------------------- .export OV_NONE .export OV_X25519_SIBLING + .export OV_P256_VERIFY ; W3 new + .export OV_X25519 ; W3 new .export OV_P384_SHA384 .export OV_P384_CURVE -OV_NONE = 0 -OV_X25519_SIBLING = 1 -OV_P384_SHA384 = 4 -OV_P384_CURVE = 5 - ; REU command: execute REU->C64 stash (bit 7 = start, bits 1-0 = direction ; 01 = REU-to-C64). Matches the DMA issue used elsewhere in the codebase. REU_CMD_REU_TO_C64 = $91 @@ -175,6 +188,7 @@ REU_CMD_REU_TO_C64 = $91 crypto_swap_to_x25519_sibling: lda #OV_X25519_SIBLING sta current_overlay +swap_done_fast: rts ; ----------------------------------------------------------------------------- @@ -211,6 +225,60 @@ crypto_swap_to_p384_curve: sta current_overlay rts +; ----------------------------------------------------------------------------- +; crypto_swap_to_x25519 -- DMA X25519 sibling image from REU bank 3 +; (REU_OVERLAY_X25519) into the live CRYPTO_OVERLAY slot. Idempotent. +; +; Distinct from `crypto_swap_to_x25519_sibling` above: that entry point +; is the legacy state-only marker (used when the linker placed the +; X25519 sibling rodata into the slot at PRG load time). This new +; entry point DOES the DMA from REU, so it can be called after a +; P-384 or P-256 swap has overwritten the slot. Boot-time stash +; happens in `reu_p384_overlay_init` (boot.s). +; +; Idempotent: re-entering with OV_X25519 already current is a single +; byte compare + rts (no DMA). NB: arrival from the legacy +; OV_X25519_SIBLING state still triggers a DMA (the slot contents are +; assumed identical, but the marker IDs differ and the safe path is +; to refresh from REU rather than to assume the linker-placed bytes +; were not later overwritten). +; ----------------------------------------------------------------------------- +crypto_swap_to_x25519: + lda #OV_X25519 + cmp current_overlay + beq swap_done_fast + pha + lda #REU_OVERLAY_X25519 + ldy #^REU_OVERLAY_X25519 + jsr do_swap + pla + sta current_overlay + rts + +; ----------------------------------------------------------------------------- +; crypto_swap_to_p256_verify -- DMA P-256 verify image (sibling +; libs/nistcurves verify-only minimal subset) from REU_OVERLAY_P256_VERIFY +; (bank 2 slot $22100) into the live CRYPTO_OVERLAY slot. Idempotent. +; +; W3 new. Today the P-256 verify primitives are always-resident in +; CRYPTO_RESIDENT (Phase C.4 sibling integration); W1 will later move +; them into the cold-path overlay slot. Until that wiring, this +; entry point is callable but unused by TLS call sites. +; ----------------------------------------------------------------------------- +crypto_swap_to_p256_verify: + lda #OV_P256_VERIFY + cmp current_overlay + beq swap_done_fast + pha + lda #REU_OVERLAY_P256_VERIFY + ldy #^REU_OVERLAY_P256_VERIFY + jsr do_swap + pla + sta current_overlay + rts + ; ----------------------------------------------------------------------------- ; crypto_swap_none -- mark the slot as undefined. ; @@ -222,7 +290,87 @@ crypto_swap_none: sta current_overlay rts -swap_done_fast: +; ----------------------------------------------------------------------------- +; crypto_overlay_call -- swap-then-call convenience wrapper (W3 new). +; +; Performs an idempotent swap to the requested overlay (no-op if it is +; already current) and then JSRs to (slot_base + offset). Designed +; so a TLS call site can do: +; +; lda #OV_P256_VERIFY +; ldx #<(ecdsa_verify_256 - __CRYPTO_OVERLAY_START__) +; ldy #>(ecdsa_verify_256 - __CRYPTO_OVERLAY_START__) +; jsr crypto_overlay_call +; +; instead of two separate jsr's (swap, then jsr abs). The dispatcher +; is responsible for ensuring fn_offset+slot_base is a valid entry +; point — there is no symbol-table check here. +; +; Inputs: +; A = overlay id (OV_*) +; X = fn offset low byte (relative to __CRYPTO_OVERLAY_START__) +; Y = fn offset high byte +; +; Behaviour: +; * Stashes X / Y / A in self-modifying-code (SMC) slots before +; branching to the swap helper so the swap is free to clobber +; all three registers. +; * Dispatches on A to the matching crypto_swap_to_ entry point. +; Unknown IDs return immediately without swapping or JSRing +; (the call is a no-op; current_overlay is left untouched). +; * After the swap returns, indirect-jsrs through the SMC'd absolute +; address (slot_base + offset). +; * The callee's return value (C flag + A/X/Y) passes through +; unchanged to the caller. +; +; ABI mirror: documented identically in overlay_ids.inc usage notes. +; ----------------------------------------------------------------------------- +crypto_overlay_call: + ; Save the overlay id for the dispatch below. txa/tya in the + ; pointer math below clobbers A, so we have to stash it first. + pha + + ; Compute slot_base + (Y:X) and stash into the indirect JSR slot. + clc + txa + adc #<__CRYPTO_OVERLAY_START__ + sta @call_target+1 + tya + adc #>__CRYPTO_OVERLAY_START__ + sta @call_target+2 + + ; Recover the overlay id and dispatch. Order: most-frequent + ; first (X25519 + P-256 verify will be the W1 hot pair; the + ; P-384 pair is the legacy / 0x0503-only path). + pla + cmp #OV_X25519 + bne @not_x25519 + jsr crypto_swap_to_x25519 + jmp @call_target +@not_x25519: + cmp #OV_P256_VERIFY + bne @not_p256 + jsr crypto_swap_to_p256_verify + jmp @call_target +@not_p256: + cmp #OV_P384_SHA384 + bne @not_sha384 + jsr crypto_swap_to_p384_sha384 + jmp @call_target +@not_sha384: + cmp #OV_P384_CURVE + bne @not_curve + jsr crypto_swap_to_p384_curve + jmp @call_target +@not_curve: + ; Unknown overlay id -- no-op (caller error). Leaves + ; current_overlay untouched and returns with C=1 to surface + ; the misuse. + sec + rts + +@call_target: + jsr $0000 ; absolute address SMC'd above rts ; ----------------------------------------------------------------------------- diff --git a/src/crypto/shared/overlay_ids.inc b/src/crypto/shared/overlay_ids.inc new file mode 100644 index 0000000..8c57ff0 --- /dev/null +++ b/src/crypto/shared/overlay_ids.inc @@ -0,0 +1,57 @@ +; ============================================================================= +; overlay_ids.inc -- Canonical overlay-ID constants for the CRYPTO_OVERLAY +; paging slot. Single source of truth shared between the dispatcher +; (crypto_swap.s) and any caller that compares against `current_overlay` +; or passes an ID to `crypto_overlay_call`. +; +; The IDs are opaque to the swap engine itself -- they exist purely so +; idempotent callers can short-circuit a no-op swap by comparing against +; `current_overlay`. Numbering rules: +; * OV_NONE = 0 is the post-boot / undefined-slot sentinel. +; * Distinct non-zero values per overlay. Gaps are tolerated; new +; overlays should pick the lowest unused ID rather than renumber +; existing ones (forward compatibility across call-sites). +; * IDs are .ifndef-guarded so this file is safe to include twice in a +; single translation unit (e.g. boot.s + crypto_swap.s pulling in +; the same header). +; +; Phase reference: +; 1, 4, 5 -- pre-existing (Phase 3 dual-overlay edition). +; 2, 3 -- new entry points added by the library-ingestion +; architecture W3 work (X25519 + P-256 verify real DMA). +; ============================================================================= + +.ifndef OV_NONE +OV_NONE = 0 +.endif + +; OV_X25519_SIBLING (legacy marker) -- predates the W3 real DMA path. +; Retained because the existing `crypto_swap_to_x25519_sibling` entry +; point in crypto_swap.s still sets it (state-only, no DMA). New +; callers that DMA the sibling image from REU use OV_X25519 instead. +.ifndef OV_X25519_SIBLING +OV_X25519_SIBLING = 1 +.endif + +; OV_P256_VERIFY -- W3 new. Set by `crypto_swap_to_p256_verify` +; after DMA'ing the P-256 verify image (libs/nistcurves' P-256 verify +; minimal-subset) from REU_OVERLAY_P256_VERIFY into the live slot. +.ifndef OV_P256_VERIFY +OV_P256_VERIFY = 2 +.endif + +; OV_X25519 -- W3 new. Set by `crypto_swap_to_x25519` after DMA'ing +; the X25519 sibling image (code + rodata) from REU_OVERLAY_X25519 +; into the live slot. Distinct from OV_X25519_SIBLING (which is the +; state-only marker for the linker-placed PRG-load-time image). +.ifndef OV_X25519 +OV_X25519 = 3 +.endif + +; OV_P384_SHA384 / OV_P384_CURVE -- pre-existing (Phase 3). +.ifndef OV_P384_SHA384 +OV_P384_SHA384 = 4 +.endif +.ifndef OV_P384_CURVE +OV_P384_CURVE = 5 +.endif diff --git a/src/crypto/shared/p256_overlay_blobs.s b/src/crypto/shared/p256_overlay_blobs.s new file mode 100644 index 0000000..cc618a5 --- /dev/null +++ b/src/crypto/shared/p256_overlay_blobs.s @@ -0,0 +1,48 @@ +; ============================================================================= +; p256_overlay_blobs.s -- Embedded P-256 verify overlay image (W3). +; +; Mirror of p384_overlay_blobs.s for the P-256 verify minimal-subset +; overlay image. Embedded when the top-level Makefile flag +; EMBED_P256_OVERLAY=1 is set (which propagates to ca65 as +; -D USE_OVERLAY_P256_EMBED=1). +; +; Mutually exclusive with USE_OVERLAY_P384_EMBED at the cfg level: both +; target the CRYPTO_OVERLAY slot at $4200 at PRG-load time, so the +; Makefile turns USE_OVERLAY_P384_EMBED off when EMBED_P256_OVERLAY=1. +; If both were set the linker would overflow the 7,680 B slot. +; +; Output: +; build/lib/nistcurves-p256-verify.bin (7,680 B padded) -- staged into +; CRYPTO_OVERLAY at PRG load time, then DMA'd by +; reu_p384_overlay_init (boot.s) to REU bank 2 slot $22100. After +; the stash, future calls to `crypto_swap_to_p256_verify` DMA the +; image back into the live slot. +; +; Inert when USE_OVERLAY_P256_EMBED is undefined (default build): +; the segment is left empty (`optional = yes` in the cfg) and boot +; skips the DMA. This is the default state -- the P-256 verify +; primitives stay always-resident in CRYPTO_RESIDENT until W1 wires +; them into a cold-path overlay swap. +; ============================================================================= + + .setcpu "6502" + +.ifdef USE_OVERLAY_P256_EMBED + + .export p256_overlay_verify_blob + .export p256_overlay_verify_blob_end + +; ----------------------------------------------------------------------------- +; P-256 verify overlay image (REU_OVERLAY_P256_VERIFY source) +; +; Loads into the live CRYPTO_OVERLAY slot at $4200-$5FFF at PRG load +; time, then boot DMAs it to REU bank 2 slot $22100. The .incbin path +; is resolved by ca65 relative to this source file: from +; src/crypto/shared/ the build/ tree is two levels up. +; ----------------------------------------------------------------------------- + .segment "OVERLAY_BLOB_P256" +p256_overlay_verify_blob: + .incbin "../../../build/lib/nistcurves-p256-verify.bin" +p256_overlay_verify_blob_end: + +.endif ; .ifdef USE_OVERLAY_P256_EMBED diff --git a/src/crypto/shared/reu_layout.inc b/src/crypto/shared/reu_layout.inc index e954b6c..6a2d975 100644 --- a/src/crypto/shared/reu_layout.inc +++ b/src/crypto/shared/reu_layout.inc @@ -38,7 +38,50 @@ REU_OVERLAY_P256 = $22100 REU_OVERLAY_P384 = $24100 .endif +; --- W3: P-256 verify overlay image (library-ingestion architecture §2.5) --- +; The architecture plan §2.5 proposes "bank 2 at offset $1F00" for this +; image. Offset $1F00 falls inside the slot-1 span ($20100-$22100), so +; we reuse the existing REU_OVERLAY_P256 slot at $22100 instead: it is +; the second 8 KB-aligned slot in bank 2, it is already 8 KB-padded +; (matching OVERLAY_SIZE), and it has been declared since Phase 1.5 +; without a current production consumer (the P-256 sibling is always- +; resident, no live overlay swap). This avoids carving a second slot +; out of bank 2. The alias is `.ifndef`-guarded; downstream override +; via `--asm-define REU_OVERLAY_P256_VERIFY=$xxxxx` remains available. +.ifndef REU_OVERLAY_P256_VERIFY +REU_OVERLAY_P256_VERIFY = REU_OVERLAY_P256 +.endif + +; --- W3: X25519 sibling overlay image (library-ingestion architecture §2.5) --- +; Architecture plan §2.5: "x25519 sibling tables get bank 3 (resolves +; the c64-x25519 #43 collision concern)." Bank 3 ($30000) was nominally +; reserved for P-256 fixed-base precompute, but the TLS path uses +; ec_scalar_mul_var (variable-base) only — the reservation has no +; runtime consumer. Using bank 3 for the X25519 sibling code+rodata +; image keeps the production overlay store (bank 2) free for the +; existing P-384 SHA384/Curve halves at $22100/$24100 and the new +; P-256 verify slot above. +; +; This is the IMAGE address (DMA source for `crypto_swap_to_x25519`) +; — the sibling's running mul/doubled/17-bit-carry tables continue to +; live in their own REU homes (currently banks 0-2 under v0.4.0). +; When c64-x25519 #43 lands and the sibling's table bases become +; `--asm-define`-able, the consumer-side override will relocate the +; sibling tables (e.g. to bank 4+) so they don't collide with the +; bank-3 image staging. Until then, USE_X25519_SIBLING=1 + a real +; `crypto_swap_to_x25519` overwrite of CRYPTO_OVERLAY is gated behind +; W1's hot/cold partition (no current TLS caller). +.ifndef REU_OVERLAY_X25519 +REU_OVERLAY_X25519 = $30000 +.endif + ; --- P-256 precompute (1 bank) --- +; NB: REU_OVERLAY_X25519 above lives at the same bank ($30000). Both +; are reservations -- the P-256 precompute is unused at runtime (the +; TLS dispatcher uses variable-base scalar mul, no Lim-Lee table), +; and the X25519 sibling image is similarly cold until W1 wires a +; real swap into TLS. When either becomes hot, the cfg has to pick +; one and relocate the other. .ifndef REU_P256_PRECOMPUTE_BASE REU_P256_PRECOMPUTE_BASE = $30000 .endif diff --git a/src/data.s b/src/data.s index 541f6ad..e64c246 100644 --- a/src/data.s +++ b/src/data.s @@ -237,7 +237,15 @@ tls_read_seq: .res 8 tls_rec_header: .res 5 tls_rec_type: .res 1 tls_rec_len: .res 2 + +; W1 partial: tls_rec_buf (548 B) lives in BSS_TAIL — a separate BSS +; segment that the UCI cfg routes to the NET_BSS_TAIL region (the +; reclaimed tail of NET_CODE). Keeps the largest single c64-https BSS +; entry out of the CRYPTO_HOT overflow path. ip65 cfg aliases +; BSS_TAIL to BSS so the relocation is invisible there. +.segment "BSS_TAIL" tls_rec_buf: .res 548 +.segment "BSS" ; AEAD nonce construction .export tls_nonce diff --git a/src/der_decode.s b/src/der_decode.s index 00deda4..f7a4581 100644 --- a/src/der_decode.s +++ b/src/der_decode.s @@ -573,5 +573,12 @@ cert_sig_r: .res 48 ; signature r component (max 48 for P-38 cert_sig_s: .res 48 ; signature s component (max 48 for P-384) cert_sig_len: .res 1 ; 32 (P-256) or 48 (P-384) cert_curve_id: .res 1 ; 0=P-256, 1=P-384 +; W1 partial: cert_buf (1.5 KB) lives in BSS_TAIL — the same offload +; region as src/data.s::tls_rec_buf — routed to NET_BSS_TAIL under +; UCI. Keeps the cert parse staging out of the CRYPTO_HOT overflow +; path. ip65 cfg aliases BSS_TAIL to BSS so the relocation is +; invisible there. +.segment "BSS_TAIL" cert_buf: .res 1536 ; certificate DER buffer +.segment "BSS" cert_buf_len: .res 2 ; certificate length diff --git a/src/exports.s b/src/exports.s index 93dcc75..71e59a6 100644 --- a/src/exports.s +++ b/src/exports.s @@ -32,9 +32,15 @@ .export zp_ptr ; Phase C.4: c64-nist-curves fp256.s references a handful of REU DMA -; registers via `.import` (it was written to live in a linker-visible -; symbol world). Promote the numeric equates from constants.inc so ld65 -; can resolve the sibling's imports. -.export reu_reu_hi -.export reu_reu_bank -.export reu_command +; registers via `.import`. Pre-contract, the sibling shipped no +; constants.o, so c64-https had to promote its in-tree equates from +; constants.inc to satisfy the link. Under c64-lib-contract (libs/ +; nistcurves cfa9085+), the library publishes constants.o which +; `.export`s the same REU register equates. Those declarations now +; satisfy the .imports — remove the c64-https-side .export to avoid +; ld65 "Duplicate external identifier" errors. +; +; Code in c64-https that references these symbols through +; constants.inc continues to work because constants.inc still defines +; them as local equates; the library's .export only becomes +; load-bearing for sources that .import them. diff --git a/tools/integration/build_nistcurves_p256.sh b/tools/integration/build_nistcurves_p256.sh index d8f1cfc..558faa8 100755 --- a/tools/integration/build_nistcurves_p256.sh +++ b/tools/integration/build_nistcurves_p256.sh @@ -3,48 +3,45 @@ # tools/integration/build_nistcurves_p256.sh - Build c64-nist-curves P-256 # ECDSA verify primitives as a resident .a archive linked into the main PRG. # -# Phase C.4 of the sibling-lib integration. Produces build/lib/nistcurves-p256.a -# containing the P-256 field arithmetic, modular arithmetic, variable-base -# scalar multiply, Jacobian->affine conversion, and packaged ECDSA verify -# (ecdsa_verify_256). No overlay mechanism; all code always-resident. +# Phase C.4 + W5 (library-ingestion architecture). Under the c64-lib-contract +# (libs/nistcurves v0.3.0), the upstream library publishes a +# `make lib-p256-verify` build target that produces a minimal-subset archive +# carrying exactly the symbols needed for variable-base P-256 verify. This +# script delegates the heavy lifting to `make -C libs/nistcurves`, then +# performs two adjustments before placing the result at the location the +# top-level Makefile expects: # -# Excluded (to fit the budget + avoid REU precompute): -# - ec_scalar_mul - Lim-Lee fixed-base comb. Needs a 16 KB REU bank-2 -# precompute table built by ec_precompute_256 at boot. -# Replaced by a shim in src/crypto/ecdsa_verify.s that -# copies G into ec_base_x/y and tail-calls -# ec_scalar_mul_var. The dispatcher is the ONLY caller -# of ecdsa_verify_256, so the shim covers the sole -# in-PRG use of ec_scalar_mul. -# - ec_precompute_256 - builds the Lim-Lee anchor table into REU bank 2. -# Only useful with ec_scalar_mul. -# - Lim-Lee anchor tables (ec_anchor1_x..ec_anchor8_y, cm_k, ec_aff2g_256_*) -# and all sm256_reu_* REU DMA helpers that service them. -# - All P-384 data/arith (fp384_*, ec384_*, ecdsa384_*, cm_k_384, anchors). -# Lives in nistcurves-p384.a under the separate Phase C.3b smoke test. -# - Shared mul infrastructure (mul_cached_a, mul_src2_buf, mul_dma_lo/hi, -# mul_8x8, sqtab_init, sqtab_lo/hi, poly_prod_lo/hi, reu_fetch_mul_row) - -# the in-tree src/data.s + src/crypto/poly1305.s + src/boot.s already -# provide these and they are shared across fe25519 + P-256 via the REU -# DMA row-fetch pipeline. Adding the sibling's copies would collide. -# - ecdsa_inputs_256, ecdsa_result_256 test-driver scratch - only used by -# the nist-curves PRG's own test harness. +# 1. Rebuild `zp_config.o` with c64-https's ZP-slot overrides (the upstream +# defaults collide with c64-https's canonical map on three slots: +# zp_ptr2, fp_mul_i, fp_mul_j). The library's zp_config.s `.ifndef`- +# guards every slot, so an override-built version replaces the +# upstream default cleanly. # -# The script stages the sibling's .s files in build/lib/nistcurves_p256_staging/, -# applies sed patches to strip Lim-Lee bodies + provide a minimal P-256-only -# data.s, and assembles with canonical ZP equates passed via -D. +# 2. Drop `mul_8x8.o` and `data_shared.o` from the archive. c64-https's +# in-tree `src/crypto/poly1305.s` and `src/data.s` already export the +# same symbols (mul_8x8, sqtab_init, poly_prod_lo/hi, mul_cached_a, +# mul_src2_buf, mul_dma_lo/hi). Including the library's copies would +# cause ld65 duplicate-symbol errors. # -# Usage (from top-level Makefile): -# bash tools/integration/build_nistcurves_p256.sh -# Produces: -# build/lib/nistcurves-p256.a -# build/lib/nistcurves-p256.sizes.txt (per-source byte counts) +# Pre-contract this script was ~400 lines of `sed -i ''` strips and +# heredoc'd hand-extracted curve/data files. Post-contract the +# `make lib-p256-verify` target replaces all of that — no segment +# rewriting (upstream now emits `LIB_NISTCURVES_P256_*` segments by +# convention), no body strips (the lib-p256-verify variant excludes the +# Lim-Lee comb + precompute already), no hand-extracted data heredoc +# (upstream's `data_p256.s` is the canonical RW state list). +# +# Outputs: +# build/lib/nistcurves-p256.a - the consumer-side archive +# build/lib/nistcurves-p256.sizes.txt - per-source byte counts # ============================================================================= set -eo pipefail # --- Paths --- PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -LIB_SRC="$PROJECT_ROOT/libs/nistcurves/src" +LIB_DIR="$PROJECT_ROOT/libs/nistcurves" +LIB_SRC="$LIB_DIR/src" +LIB_BUILD="$LIB_DIR/build" STAGING="$PROJECT_ROOT/build/lib/nistcurves_p256_staging" OUT_DIR="$PROJECT_ROOT/build/lib" ARCHIVE="$OUT_DIR/nistcurves-p256.a" @@ -53,331 +50,97 @@ SIZES="$OUT_DIR/nistcurves-p256.sizes.txt" CA65="${CA65:-ca65}" AR65="${AR65:-ar65}" -# --- Canonical ZP defines --- -# Mirrors the P-384 build's -D flag set. zp_ptr2 is relocated into -# $3D-$3E (inside ZP_CRYPTO, otherwise unused) because the sibling's -# default ($fd-$fe) overlaps with c64-https's zp_temp/zp_count used -# by der_decode.s during cert parsing. ecdsa_verify_256 runs AFTER -# DER parsing completes, so the clobber would be fine in practice, -# but the relocation keeps the lifetime isolation explicit. -ZP_DEFINES=( - '-Dproc_port=$01' - '-Dzp_tmp1=$02' - '-Dzp_tmp2=$03' - '-Dzp_ptr1=$fb' - '-Dzp_ptr2=$3d' - '-Dfp_src1=$22' - '-Dfp_src2=$24' - '-Dfp_dst=$26' - '-Dfp_misc=$28' - '-Dfp_carry=$2a' - '-Dfp_loop=$2b' - '-Dfp_mul_i=$39' - '-Dfp_mul_j=$3a' - '-Dec_scalar_ptr=$3b' - '-Dpoly_i=$1a' - '-Dpoly_j=$1b' - '-Dpoly_carry=$1c' - '-Dpoly_tmp=$1d' +# --- ZP-slot overrides (c64-https canonical map) --- +# zp_ptr2 = $3D : library default $fd collides with c64-https zp_temp/zp_count +# used by der_decode.s during cert parsing. +# fp_mul_i = $39, fp_mul_j = $3A : library defaults $2c/$2d collide with +# c64-https fe25519 ZP claim ($2c-$37). $39-$3a is otherwise +# unused inside ZP_CRYPTO. +# Other slots match upstream defaults — see libs/nistcurves/src/zp_config.s. +ZP_OVERRIDES=( + '-D' 'zp_ptr2=$3d' + '-D' 'fp_mul_i=$39' + '-D' 'fp_mul_j=$3a' ) -# --- Stage sources --- -rm -rf "$STAGING" -mkdir -p "$STAGING" - -cp "$LIB_SRC"/constants.s "$STAGING/" -cp "$LIB_SRC"/zp_config.s "$STAGING/" -cp "$LIB_SRC"/fp256.s "$STAGING/fp256_raw.s" -cp "$LIB_SRC"/mod256.s "$STAGING/mod256_raw.s" -cp "$LIB_SRC"/points256.s "$STAGING/points256_raw.s" -cp "$LIB_SRC"/ecdsa256.s "$STAGING/ecdsa256_raw.s" - -# --- Strip points256.s of the Lim-Lee / REU precompute bodies --- -# Lines 762-1458 in the upstream file cover: -# - sm256_reu_stash_affine / sm256_reu_fetch_affine / sm256_calc_offset_64 -# / sm256_reu_restore (REU DMA helpers for bank-2 anchor table) -# - ec_precompute_256 and its internal helpers (load_G_jac, successive-double -# helpers, anchor accumulate) -# - ec_scalar_mul (Lim-Lee 8-way fixed-base comb) and its anchor-loader -# helpers + anchor base-address table + cm_* / sm256_* state vars -# Keeps ec_point_double (line 60-410), ec_point_add (411-761), -# ec_scalar_mul_var (1459-1609), ec_jacobian_to_affine (1610-end). -sed -i '' '762,1467d' "$STAGING/points256_raw.s" - -# Strip exports + imports that only the removed bodies used. -sed -i '' '/^\.export ec_precompute_256, ec_scalar_mul, ec_scalar_mul_var$/c\ -.export ec_scalar_mul_var' "$STAGING/points256_raw.s" -# Anchor + Lim-Lee state imports -sed -i '' '/^\.import ec_aff2g_256_x, ec_aff2g_256_y$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import ec_anchor[1-8]_x, ec_anchor[1-8]_x, ec_anchor[1-8]_x, ec_anchor[1-8]_x$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import ec_anchor[1-8]_y, ec_anchor[1-8]_y, ec_anchor[1-8]_y, ec_anchor[1-8]_y$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import ec_anchor.*$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import cm_k, mul_dma_lo$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import ec_sc_byte, ec_sc_mask$/d' "$STAGING/points256_raw.s" -# REU DMA register imports. v0.2.0 added a "defensive REU register init" -# block at the top of ec_scalar_mul_var (lines 753-757) that touches -# reu_reu_lo + reu_addr_ctrl, so those two must stay imported even though -# ec_scalar_mul_var is the only retained body. The rest are only used by -# the stripped REU anchor helpers and Lim-Lee comb. -sed -i '' '/^\.import reu_c64_lo, reu_c64_hi, reu_reu_lo, reu_reu_hi$/c\ -.import reu_reu_lo' "$STAGING/points256_raw.s" -sed -i '' '/^\.import reu_reu_bank, reu_len_lo, reu_len_hi$/d' "$STAGING/points256_raw.s" -sed -i '' '/^\.import reu_addr_ctrl, reu_command$/c\ -.import reu_addr_ctrl' "$STAGING/points256_raw.s" -# ec_mulp / ec_sqrp are used by all three retained bodies - keep. -# fp_tmp1 is used by ec_scalar_mul_var - keep. - -# Sanity: no leftover non-comment references to stripped symbols. -# Filter out comment lines (first non-blank char is `;`) before checking. -if grep -v '^\s*;' "$STAGING/points256_raw.s" \ - | grep -qE '\bec_anchor[0-9]+_|\bcm_k\b|\bec_aff2g_256|\bec_sc_byte\b|\bec_sc_mask\b|\bsm256_reu|\bec_scalar_mul\b[^_]'; then - echo "ERROR: stripped points256 still references removed-body symbols" >&2 - grep -v '^\s*;' "$STAGING/points256_raw.s" \ - | grep -nE '\bec_anchor[0-9]+_|\bcm_k\b|\bec_aff2g_256|\bec_sc_byte\b|\bec_sc_mask\b|\bsm256_reu|\bec_scalar_mul\b[^_]' \ - | head -5 >&2 +# --- 1. Build upstream's lib-p256-verify archive --- +# Upstream's Makefile builds every module with the same recipe (no per-file +# CA65FLAGS hook), so we cannot pass -D overrides via `make CA65=...` here: +# the override would land on every .s, including fp256.s which only +# `.importzp`s the slots and would error on a redefinition. We therefore +# build upstream with its defaults, then rebuild zp_config.o ourselves with +# the overrides below. +# +# Note on c64-lib-contract SPEC §8.1: nistcurves v0.3.0's `mul_8x8.s` is +# the only TU that references sqtab_lo / sqtab_hi (via the local +# `.ifndef LIB_SHARED_SQTAB_BASE` equate in that file). Step 4 below +# drops `mul_8x8.o` from the archive entirely — c64-https provides the +# canonical `sqtab_lo` / `sqtab_hi` via src/data.s and the population +# init via src/crypto/poly1305.s::sqtab_init. So no LIB_SHARED_SQTAB_BASE +# / SHARED_SQTAB_INIT override is needed at the nistcurves Makefile +# invocation — the upstream default baked into mul_8x8.o is discarded +# before it reaches the link. +echo "[p256] building libs/nistcurves lib-p256-verify (upstream defaults)..." +make -s -C "$LIB_DIR" lib-p256-verify >/dev/null + +UPSTREAM_ARCHIVE="$LIB_BUILD/lib/nistcurves-p256-verify.a" +if [ ! -f "$UPSTREAM_ARCHIVE" ]; then + echo "ERROR: upstream archive missing: $UPSTREAM_ARCHIVE" >&2 exit 1 fi -# --- Strip curve256.s to ec_a256, ec_b256, ec_gx256, ec_gy256 only --- -# The test vector constants (ecdsa_test_*) are used only by the sibling's -# own test PRG and would add ~256 B of dead rodata here. -cat > "$STAGING/curve256_raw.s" <<'CURVE_EOF' -.setcpu "6502" - -; ============================================================================= -; curve256_raw.s - P-256 curve parameters for c64-https Phase C.4. -; Hand-trimmed from libs/nistcurves/src/curve256.s: test vectors dropped -; (only used by the sibling's standalone test harness). -; ============================================================================= - -.segment "RODATA" - -.export ec_a256, ec_b256, ec_gx256, ec_gy256 - -; Coefficient a = p - 3 -ec_a256: - .byte $FC, $FF, $FF, $FF, $FF, $FF, $FF, $FF - .byte $FF, $FF, $FF, $FF, $00, $00, $00, $00 - .byte $00, $00, $00, $00, $00, $00, $00, $00 - .byte $01, $00, $00, $00, $FF, $FF, $FF, $FF - -; Coefficient b -ec_b256: - .byte $4B, $60, $D2, $27, $3E, $3C, $CE, $3B - .byte $F6, $B0, $53, $CC, $B0, $06, $1D, $65 - .byte $BC, $86, $98, $76, $55, $BD, $EB, $B3 - .byte $E7, $93, $3A, $AA, $D8, $35, $C6, $5A - -; Generator x coordinate (LE) -ec_gx256: - .byte $96, $C2, $98, $D8, $45, $39, $A1, $F4 - .byte $A0, $33, $EB, $2D, $81, $7D, $03, $77 - .byte $F2, $40, $A4, $63, $E5, $E6, $BC, $F8 - .byte $47, $42, $2C, $E1, $F2, $D1, $17, $6B - -; Generator y coordinate (LE) -ec_gy256: - .byte $F5, $51, $BF, $37, $68, $40, $B6, $CB - .byte $CE, $5E, $31, $6B, $57, $33, $CE, $2B - .byte $16, $9E, $0F, $7C, $4A, $EB, $E7, $8E - .byte $9B, $7F, $1A, $FE, $E2, $42, $E3, $4F -CURVE_EOF - -# --- Emit minimal data_p256_raw.s --- -# Keeps only the RW buffers that fp256 / mod256 / points256 (post-strip) / -# ecdsa256 reference. Shared mul infrastructure (mul_cached_a, mul_src2_buf, -# mul_dma_lo, mul_dma_hi) is provided by in-tree src/data.s. P-384 data and -# Lim-Lee anchors are excluded. -cat > "$STAGING/data_p256_raw.s" <<'DATA_EOF' -.setcpu "6502" - -; ============================================================================= -; data_p256_raw.s - Minimal P-256 RW buffers for c64-https / c64-nist-curves -; integration (Phase C.4). Hand-extracted from the sibling's -; data.s so in-tree shared mul buffers remain unclobbered and -; P-384 / Lim-Lee state is omitted. -; -; All exports here are P-256-exclusive. -; ============================================================================= - -.segment "DATA" - -; --- P-256 field arithmetic working buffers (32 bytes each) --- -; fp_tmp2/3/4 and fp_r1/2/3 are declared by the sibling's full data.s -; but never .importe'd from the retained fp256/mod256/points256/ecdsa256 -; bodies; pruned here to save BSS (~192 B). -.export fp_wide -fp_wide: .res 64, 0 ; 512-bit product from multiply -.export fp_tmp1 -fp_tmp1: .res 32, 0 - -; --- P-256 result registers (only fp_r0 referenced) --- -.export fp_r0 -fp_r0: .res 32, 0 - -; --- P-256 modular inverse working space --- -.export fp_inv_u -fp_inv_u: .res 32, 0 -.export fp_inv_v -fp_inv_v: .res 32, 0 -.export fp_inv_x1 -fp_inv_x1: .res 32, 0 -.export fp_inv_x2 -fp_inv_x2: .res 32, 0 - -; --- P-256 point storage (Jacobian: X=32 + Y=32 + Z=32 = 96 bytes) --- -.export ec_p1 -ec_p1: .res 96, 0 -.export ec_p2 -ec_p2: .res 96, 0 -.export ec_p3 -ec_p3: .res 96, 0 - -; --- P-256 point math temporaries --- -.export ec_t1 -ec_t1: .res 32, 0 -.export ec_t2 -ec_t2: .res 32, 0 -.export ec_t3 -ec_t3: .res 32, 0 -.export ec_t4 -ec_t4: .res 32, 0 -.export ec_t5 -ec_t5: .res 32, 0 -.export ec_t6 -ec_t6: .res 32, 0 - -; --- P-256 affine output --- -.export ec_affine_x -ec_affine_x: .res 32, 0 -.export ec_affine_y -ec_affine_y: .res 32, 0 - -; --- Variable-base scalar-mul input (affine, 32 bytes each, LE). --- -.export ec_base_x -ec_base_x: .res 32, 0 -.export ec_base_y -ec_base_y: .res 32, 0 - -; --- Solinas reduction scratch (33 bytes: 32 + carry) --- -.export fp_red_tmp -fp_red_tmp: .res 33, 0 - -; --- ECDSA verify scratch (P-256). All 32-byte little-endian unless noted. --- -.export ecdsa_r -ecdsa_r: .res 32, 0 ; LE r (byte-reversed from BE input) -.export ecdsa_s -ecdsa_s: .res 32, 0 ; LE s -.export ecdsa_h -ecdsa_h: .res 32, 0 ; LE message hash -.export ecdsa_qx -ecdsa_qx: .res 32, 0 ; LE public-key affine X -.export ecdsa_qy -ecdsa_qy: .res 32, 0 ; LE public-key affine Y -.export ecdsa_w -ecdsa_w: .res 32, 0 ; LE w = s^-1 mod n -.export ecdsa_u1 -ecdsa_u1: .res 32, 0 ; LE u1 = h*w mod n -.export ecdsa_u2 -ecdsa_u2: .res 32, 0 ; LE u2 = r*w mod n -.export ecdsa_u1_be -ecdsa_u1_be: .res 32, 0 ; BE u1 (scalar_mul input) -.export ecdsa_u2_be -ecdsa_u2_be: .res 32, 0 ; BE u2 (scalar_mul_var input) -.export ecdsa_u1g_x -ecdsa_u1g_x: .res 32, 0 ; LE affine X of u1*G -.export ecdsa_u1g_y -ecdsa_u1g_y: .res 32, 0 ; LE affine Y of u1*G - -; --- fp_reverse32 staging buffer (one 32-byte scratch). --- -.export fp_rev_buf -fp_rev_buf: .res 32, 0 -DATA_EOF - -# --- Emit minimal REU register equates --- -# v0.2.0 added a "defensive REU register init" block at the top of -# ec_scalar_mul_var (and also in fp256/ecdsa256 modular-inverse paths) -# that touches reu_reu_lo + reu_addr_ctrl. The sibling's constants.s -# provides these but also exports VIC/CIA/KERNAL equates that would -# collide with c64-https's in-tree definitions, so we emit a minimal -# equate file with only what the retained bodies actually reference. -cat > "$STAGING/reu_equates_raw.s" <<'REU_EOF' -.setcpu "6502" - -; Minimal REU hardware register equates used by retained P-256 bodies -; in v0.2.0 (defensive REU register init in ec_scalar_mul_var, fp_inv, -; ecdsa inverse). Mirror of values in libs/nistcurves/src/constants.s. -.export reu_reu_lo, reu_addr_ctrl -reu_reu_lo = $df04 -reu_addr_ctrl = $df0a -REU_EOF - -# --- Route CODE segments in the raw .s files to CRYPTO_CODE. --- -# The sibling uses `.segment "CODE"`, which under c64-https's cfg is the -# LOADER region ($0801-$1FFF). We want this code in CRYPTO_RESIDENT. -for src in fp256_raw mod256_raw points256_raw ecdsa256_raw; do - sed -i '' 's/^\.segment "CODE"/.segment "CRYPTO_CODE"/' "$STAGING/$src.s" -done - -# --- Route DATA segment in data_p256_raw.s to CRYPTO_BSS. --- -# The c64-https cfg has no "DATA" segment slot; our minimal data file -# only contains `.res` (zero-init) declarations, so CRYPTO_BSS is the -# right home. Don't accidentally match anything inside a string or -# comment: the data_p256_raw.s we emit has exactly one such directive. -sed -i '' 's/^\.segment "DATA"$/.segment "CRYPTO_BSS"/' "$STAGING/data_p256_raw.s" - -# Sanity: no leftover `.segment "CODE"` hunks outside the expected -# pattern (the raw files should only have one CODE segment each). -for src in fp256_raw mod256_raw points256_raw ecdsa256_raw; do - if grep -qE '^\.segment "CODE"$' "$STAGING/$src.s"; then - echo "ERROR: leftover .segment \"CODE\" in $src.s" >&2 - exit 1 - fi -done - -# --- Assemble each staged .s file --- -OBJ_DIR="$STAGING/obj" -rm -rf "$OBJ_DIR" -mkdir -p "$OBJ_DIR" "$OUT_DIR" - -# zp_config.s is the single point of truth for ZP equates; we apply -D -# overrides so sibling defaults get replaced with c64-https's canonical map. -# `-g` embeds cc65 debug info into the .o files so the final ld65 --dbgfile -# (driven from the top-level Makefile) can merge per-source line/symbol -# records into build/c64-https.dbg. Does not change emitted code bytes. +# --- 2. Stage upstream object files --- +rm -rf "$STAGING" +mkdir -p "$STAGING" "$OUT_DIR" +cp "$UPSTREAM_ARCHIVE" "$STAGING/upstream.a" +(cd "$STAGING" && "$AR65" x upstream.a $( "$AR65" t upstream.a )) + +# --- 3. Rebuild zp_config.o with c64-https overrides --- +# `.ifndef`-guarded slots in src/zp_config.s let -D flags win cleanly. +# The .exportzp declarations propagate the override values to every +# `.importzp` site via the link. "$CA65" \ + --cpu 6502 \ -g \ - -I "$STAGING" \ - -I "$PROJECT_ROOT/src/crypto/shared" \ - "${ZP_DEFINES[@]}" \ - -o "$OBJ_DIR/zp_config.o" "$STAGING/zp_config.s" - -for src in fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw reu_equates_raw; do - "$CA65" \ - -g \ - -I "$STAGING" \ - -I "$PROJECT_ROOT/src/crypto/shared" \ - -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" -done - -# --- Archive --- + -I "$LIB_SRC" \ + "${ZP_OVERRIDES[@]}" \ + -o "$STAGING/zp_config.o" \ + "$LIB_SRC/zp_config.s" + +# --- 4. Drop conflicting members --- +# mul_8x8.o: exports mul_8x8, sqtab_init, poly_prod_lo/hi, sqtab_lo/hi, +# reu_fetch_mul_row. c64-https's src/crypto/poly1305.s already +# exports these — including upstream's copy causes ld65 dup-sym. +# data_shared.o: exports mul_cached_a, mul_src2_buf, mul_dma_lo/hi. +# c64-https's src/data.s already exports these — same conflict. +rm -f "$STAGING/mul_8x8.o" "$STAGING/data_shared.o" + +# --- 5. Re-archive into c64-https's expected location --- +# Order matches upstream's lib-p256-verify recipe so labels.txt diffs +# stay readable across bumps. rm -f "$ARCHIVE" "$AR65" a "$ARCHIVE" \ - "$OBJ_DIR/zp_config.o" \ - "$OBJ_DIR/fp256_raw.o" \ - "$OBJ_DIR/mod256_raw.o" \ - "$OBJ_DIR/points256_raw.o" \ - "$OBJ_DIR/ecdsa256_raw.o" \ - "$OBJ_DIR/curve256_raw.o" \ - "$OBJ_DIR/data_p256_raw.o" \ - "$OBJ_DIR/reu_equates_raw.o" - -# --- Per-source byte counts --- + "$STAGING/lib_version.o" \ + "$STAGING/lib_manifest.o" \ + "$STAGING/zp_config.o" \ + "$STAGING/constants.o" \ + "$STAGING/reu_config.o" \ + "$STAGING/fp256.o" \ + "$STAGING/mod256.o" \ + "$STAGING/curve256.o" \ + "$STAGING/points256_core.o" \ + "$STAGING/ecdsa256.o" \ + "$STAGING/data_p256.o" + +# --- 6. Per-source byte counts (for the supervisor's PR description) --- { echo "# nistcurves-p256.a per-source byte counts (ca65 .o file sizes)" - for src in zp_config fp256_raw mod256_raw points256_raw ecdsa256_raw curve256_raw data_p256_raw reu_equates_raw; do - bytes=$(wc -c < "$OBJ_DIR/$src.o") - printf '%-24s %d bytes (.o)\n' "$src" "$bytes" + for src in lib_version lib_manifest zp_config constants reu_config \ + fp256 mod256 curve256 points256_core ecdsa256 data_p256; do + if [ -f "$STAGING/$src.o" ]; then + bytes=$(wc -c < "$STAGING/$src.o") + printf '%-24s %d bytes (.o)\n' "$src" "$bytes" + fi done } > "$SIZES" diff --git a/tools/integration/build_nistcurves_p256_bin.sh b/tools/integration/build_nistcurves_p256_bin.sh new file mode 100755 index 0000000..2151a78 --- /dev/null +++ b/tools/integration/build_nistcurves_p256_bin.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# ============================================================================= +# tools/integration/build_nistcurves_p256_bin.sh -- Link the always-resident +# P-256 sibling archive (`build/lib/nistcurves-p256.a`) as a padded +# overlay .bin image suitable for embedding into the PRG via +# .incbin (src/crypto/shared/p256_overlay_blobs.s). +# +# W3 (library-ingestion architecture) artefact. Today the P-256 verify +# primitives ship always-resident in CRYPTO_RESIDENT; the W1 follow-on +# will move them into the cold-path CRYPTO_OVERLAY slot. This .bin is +# the staging image for that move: boot will DMA the bytes from +# CRYPTO_OVERLAY (PRG-load placement) to REU bank 2 slot $22100, and +# `crypto_swap_to_p256_verify` will DMA them back on demand. +# +# Mirrors tools/integration/build_nistcurves_p384_bin.sh's contract: +# * Inputs: build/lib/nistcurves-p256.a (built by +# build_nistcurves_p256.sh). +# * Output: build/lib/nistcurves-p256-verify.bin (7,680 B padded). +# * Per-image size report: build/lib/nistcurves-p256-verify.sizes.txt. +# * Per-image label file: build/labels-p256-verify.txt. +# * Per-image cc65 .dbg sidecar: +# build/lib/nistcurves-p256-verify.dbg. +# +# The cfg `cfg/p256-overlay-verify.cfg` routes CRYPTO_CODE / +# CRYPTO_RODATA / RODATA into the OVERLAY_REGION ($4200, $1E00 B) and +# pins DATA / BSS at $C000 RESIDENT just so labels resolve. The .bin +# output is truncated to $1E00 so only the overlay portion lands in +# the file. +# +# Usage (from top-level Makefile): +# bash tools/integration/build_nistcurves_p256_bin.sh +# ============================================================================= +set -eo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +ARCHIVE="$PROJECT_ROOT/build/lib/nistcurves-p256.a" +CFG="$PROJECT_ROOT/cfg/p256-overlay-verify.cfg" +OUT_DIR="$PROJECT_ROOT/build/lib" +BIN_OUT="$OUT_DIR/nistcurves-p256-verify.bin" +SIZES_OUT="$OUT_DIR/nistcurves-p256-verify.sizes.txt" +LABELS_OUT="$PROJECT_ROOT/build/labels-p256-verify.txt" +MAP_OUT="$OUT_DIR/nistcurves-p256-verify.map" +DBG_OUT="${BIN_OUT%.bin}.dbg" + +# Live UCI CRYPTO_OVERLAY slot size: $1E00 = 7,680 B. The .bin is +# truncated / padded to exactly this many bytes so it DMAs cleanly +# into the live slot. +SLOT_BYTES=7680 + +LD65="${LD65:-ld65}" +AR65="${AR65:-ar65}" + +if [ ! -f "$ARCHIVE" ]; then + echo "ERROR: archive missing -- run tools/integration/build_nistcurves_p256.sh first" >&2 + echo " missing: $ARCHIVE" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" + +# Extract archive members; ld65 wants plain .o files on the command line. +scratch="$OUT_DIR/p256_bin_scratch" +rm -rf "$scratch" +mkdir -p "$scratch" +cp "$ARCHIVE" "$scratch/" + +archive_basename=$(basename "$ARCHIVE") +members=$( (cd "$scratch" && "$AR65" t "$archive_basename") | tr -d '\r' ) +if [ -z "$members" ]; then + echo "ERROR: $archive_basename appears empty" >&2 + exit 1 +fi +(cd "$scratch" && "$AR65" x "$archive_basename" $members) + +obj_args=() +for m in $members; do + obj_args+=("$scratch/$m") +done + +# Symbol --defines. The P-256 minimal subset imports REU register +# equates (reu_reu_lo, reu_addr_ctrl from reu_equates_raw.s inside the +# archive) plus shared mul infrastructure (mul_cached_a, mul_dma_lo, +# mul_dma_hi, mul_8x8, poly_prod_lo/hi, reu_fetch_mul_row) provided by +# the main PRG's in-tree poly1305.s / data.s / boot.s. For the +# standalone overlay link we pin those imports at fixed addresses -- +# either resolved from build/labels.txt (the main PRG's runtime +# addresses, so the overlay's fp_mul ends up reading/writing the right +# cells when the .bin is DMA'd live) or stubbed. See the P-384 +# overlay script for the same pattern. +MAIN_LABELS="$PROJECT_ROOT/build/labels.txt" + +lookup_label () { + local name="$1" + local fallback="$2" + if [ ! -f "$MAIN_LABELS" ]; then + echo "$fallback" + return + fi + local hex + hex=$(grep -E " \.${name}\$" "$MAIN_LABELS" | head -n1 | awk '{print $2}' | sed 's|^C:||') + if [ -z "$hex" ]; then + echo "$fallback" + else + printf '$%s' "$hex" + fi +} + +# Stubs are $0000 (intentional -- the .bin will be regenerated on the +# second pass once build/labels.txt exists, mirroring the P-384 +# bootstrap workflow in the top-level Makefile). +DEF_MUL_CACHED_A=$(lookup_label mul_cached_a '$0000') +DEF_MUL_DMA_LO=$(lookup_label mul_dma_lo '$0000') +DEF_MUL_DMA_HI=$(lookup_label mul_dma_hi '$0000') +DEF_REU_FETCH_MUL_ROW=$(lookup_label reu_fetch_mul_row '$0000') +DEF_POLY_PROD_LO=$(lookup_label poly_prod_lo '$CFFE') +DEF_POLY_PROD_HI=$(lookup_label poly_prod_hi '$CFFF') +DEF_MUL_8X8=$(lookup_label mul_8x8 '$0000') +# ec_scalar_mul is provided by the shim in src/crypto/ecdsa_verify.s in +# the always-resident path -- for the standalone overlay link it is +# referenced from ecdsa256_raw.s but never called from any path we +# actually exercise (TLS uses ec_scalar_mul_var; ec_scalar_mul is the +# Lim-Lee fixed-base entry whose body was stripped). Pin to a stub +# address; the in-PRG link resolves it properly. Same for +# mul_src2_buf (lives in src/data.s under the main PRG). +DEF_EC_SCALAR_MUL=$(lookup_label ec_scalar_mul '$0000') +DEF_MUL_SRC2_BUF=$(lookup_label mul_src2_buf '$0000') + +# Link. Route the P-256 archive's segments into OVERLAY_REGION via +# the cfg (which lists CRYPTO_CODE / CRYPTO_RODATA / RODATA / CODE as +# overlay-bound and DATA / BSS / CRYPTO_BSS as RESIDENT-bound). The +# zp_config.o object emits ZP equates only -- nothing in the output +# image -- so no segment routing is needed for it. +# NB: the archive's reu_equates_raw.s already exports reu_reu_lo and +# reu_addr_ctrl (the two slots v0.2.0's defensive REU init touches in +# ec_scalar_mul_var). Defining them again here would conflict -- +# pass only the symbols the archive imports without providing. +"$LD65" \ + -C "$CFG" \ + -o "$BIN_OUT" \ + -Ln "$LABELS_OUT" \ + -m "$MAP_OUT" \ + --dbgfile "$DBG_OUT" \ + --define mul_cached_a="$DEF_MUL_CACHED_A" \ + --define mul_dma_lo="$DEF_MUL_DMA_LO" \ + --define mul_dma_hi="$DEF_MUL_DMA_HI" \ + --define poly_prod_lo="$DEF_POLY_PROD_LO" \ + --define poly_prod_hi="$DEF_POLY_PROD_HI" \ + --define reu_fetch_mul_row="$DEF_REU_FETCH_MUL_ROW" \ + --define mul_8x8="$DEF_MUL_8X8" \ + --define ec_scalar_mul="$DEF_EC_SCALAR_MUL" \ + --define mul_src2_buf="$DEF_MUL_SRC2_BUF" \ + "${obj_args[@]}" + +# Normalise labels to VICE format so c64-test-harness's +# Labels.from_file() reader accepts it identically to build/labels.txt. +sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$LABELS_OUT" + +# Compute the on-disk overlay segment size from the .map (this only +# captures the OVERLAY_P256_VERIFY segment by name; other segments +# routed into OVERLAY_REGION via the cfg add to the file size but +# don't show under the named segment). +seg_name="OVERLAY_P256_VERIFY" +overlay_hex=$(awk -v seg="$seg_name" ' + /^Segment list:/ { in_seg=1; next } + /^Exports list/ { in_seg=0 } + in_seg && $1 == seg { print $4; exit } +' "$MAP_OUT") +overlay_bytes="" +if [ -n "$overlay_hex" ]; then + overlay_bytes=$(printf '%d' "0x$overlay_hex") +fi + +# Truncate / pad to exactly $SLOT_BYTES so the .bin DMAs into the +# live UCI overlay slot ($1E00 = 7,680 B). +truncate -s "$SLOT_BYTES" "$BIN_OUT" + +size=$(wc -c < "$BIN_OUT") +if [ "$size" -ne "$SLOT_BYTES" ]; then + echo "ERROR: $BIN_OUT is $size bytes, expected $SLOT_BYTES" >&2 + exit 1 +fi + +{ + echo "# nistcurves-p256-verify overlay image (W3)" + echo "# slot size: $SLOT_BYTES B (\$1E00 -- UCI CRYPTO_OVERLAY)" + if [ -n "$overlay_bytes" ]; then + echo "# unpadded overlay: $overlay_bytes B" + echo "# padded .bin: $size B" + echo "# headroom: $((SLOT_BYTES - overlay_bytes)) B" + if [ "$overlay_bytes" -gt "$SLOT_BYTES" ]; then + echo "# *** OVERFLOW: overlay exceeds slot by $((overlay_bytes - SLOT_BYTES)) B ***" + fi + else + echo "# unpadded overlay: (unknown -- see $MAP_OUT)" + echo "# padded .bin: $size B" + fi +} > "$SIZES_OUT" + +if [ -n "$overlay_bytes" ] && [ "$overlay_bytes" -gt "$SLOT_BYTES" ]; then + echo "ERROR: $BIN_OUT overlay segment ($overlay_bytes B) exceeds 7,680 B slot by $((overlay_bytes - SLOT_BYTES)) B" >&2 + exit 1 +fi + +echo "built $BIN_OUT ($size B padded; overlay = ${overlay_bytes:-unknown} B)" +cat "$SIZES_OUT" diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh index 4190cb3..e834bbc 100755 --- a/tools/integration/build_nistcurves_p384.sh +++ b/tools/integration/build_nistcurves_p384.sh @@ -1,85 +1,48 @@ #!/usr/bin/env bash # ============================================================================= # tools/integration/build_nistcurves_p384.sh - Build c64-nist-curves P-384 -# overlay archives for the UCI backend smoke test. +# overlay archives for the UCI backend smoke test + Phase 5 production use. # -# Phase 1.5 split. Phase 1b's monolithic OVERLAY_P384 segment was 12,836 B -# and overflowed the live UCI CRYPTO_OVERLAY slot (7,680 B at $4200-$5FFF). -# This script now produces TWO archives, each fitting the 7.5 KB slot: +# Phase 1.5 split + W5 (library-ingestion architecture). Under the +# c64-lib-contract (libs/nistcurves v0.3.0), upstream publishes +# `make lib-p384-sha384` and `make lib-p384-verify` build targets. This +# script delegates the heavy lifting to upstream `make`, then performs +# adjustments before placing the archives at the locations the top-level +# Makefile expects: # -# build/lib/nistcurves-p384-sha384.a - SHA-384 streaming hash (sha384.s -# + the SHA-384 portion of the -# minimal data heredoc). -# Segment: OVERLAY_P384_SHA384. -# build/lib/nistcurves-p384-curve.a - fp384 + mod384 + points384 -# (post-strip) + curve384 + -# ecdsa384 (verify_384 ONLY - -# the verify_with_message_384 -# wrapper that imports -# sha384_init/update/final is -# stripped here; TLS drives SHA -# via the sha384 overlay) + the -# ec_scalar_mul_384 shim. -# Segment: OVERLAY_P384_CURVE. +# 1. Rebuild zp_config.o with c64-https's ZP-slot overrides. The +# sibling defaults sha_src/sha_len/sha_w_ptr/sha_w_ptr2 ($04/$06/ +# $08/$0a) collide with c64-https's $04-$09 = w32_* (ChaCha20/ +# Poly1305) and $0A-$0D = sha_temp1 (SHA-256). $3D-$44 is the +# lowest 8-byte contiguous free block above the canonical crypto +# ZP map and is dedicated to the SHA-384 call window. fp_mul_i / +# fp_mul_j also relocated ($39/$3a vs upstream $2c/$2d) to dodge +# the fe25519 claim. Other slots inherit upstream defaults. # -# Both archives also contribute disjoint subsets of data_raw.s into the -# resident DATA segment (CRYPTO_RESIDENT under the live cfg, at $C000 in -# the standalone overlay cfgs). The split is byte-for-byte identical to -# Phase 1b's combined data_raw.s so resident DATA growth stays at the -# Phase 1b figure (3,541 B); see the per-half data heredocs below. +# 2. Drop `mul_8x8.o` + `data_shared.o` from the curve archive +# (same conflict reasoning as build_nistcurves_p256.sh — c64-https +# provides them via src/crypto/poly1305.s + src/data.s). # -# Wrapper strip: -# ecdsa_verify_with_message_384 + ecdsa_verify_with_msg_384_tramp are -# physically removed from the curve archive's ecdsa384_raw.s so the -# archive does not import sha384_init/update/final (those live only in -# the OTHER half). TLS will call sha384_init / update / final -# directly from the sha384 overlay, then swap in the curve overlay, -# then call ecdsa_verify_384 with the digest pre-spliced into -# ecdsa_inputs_384[96..143]. See Phase 4a's TLS dispatcher work for -# the call sequencing. +# 3. Emit an ec_scalar_mul_384 shim. The upstream lib-p384-verify +# archive excludes points384_comb.s (the Lim-Lee fixed-base +# comb), so `ec_scalar_mul_384` is unresolved. We provide the +# symbol via a one-page shim that copies G into +# ec_base384_x/y and tail-calls ec_scalar_mul_var_384. Pattern +# mirrors c64-https's existing src/crypto/ecdsa_verify.s::ec_scalar_mul +# (Phase C.4 P-256 dispatcher). # -# ZP allocation (Phase 1.5): -# sha_src = $3D, sha_len = $3F, -# sha_w_ptr = $41, sha_w_ptr2 = $43. -# These supersede the sibling defaults ($04/$06/$08/$0A) which collide -# with c64-https's canonical $04-$09 = w32_* (ChaCha20/Poly1305) and -# $0A-$0D = sha_temp1 (SHA-256). $3D-$44 is the lowest 8-byte -# contiguous free block above the canonical crypto ZP map (ec_scalar_ptr -# ends at $3C; nothing in src/* claims $3D-$FA except the universal -# $FB-$FF general pointers). Verified by grep against -# src/constants.inc, src/crypto/shared/zp_canon.inc, and all .s files -# under src/. Safe during the SHA-384 call window because no other -# crypto / TLS path uses these slots. -# -# Excluded (same as Phase 1b — see comments inline): -# - ec_precompute_384 / ec_scalar_mul_384 (Lim-Lee body) — replaced by -# the in-staging shim that copies G into ec_base384_x/y and -# tail-calls ec_scalar_mul_var_384. -# - Lim-Lee anchor tables and comb-scalar state. -# - sha384_msg_buf (1024 B test scratch). -# - mul_8x8 / sqtab_init / mul_dma_lo/hi / mul_cached_a / mul_src2_buf / -# reu_fetch_mul_row / poly_prod_lo/hi / sqtab_lo/hi - resolved at link -# time by build_nistcurves_p384_bin.sh's --define stubs. -# - ecdsa_verify_with_message_384 + ecdsa_verify_with_msg_384_tramp -# (Phase 1.5 NEW — see "Wrapper strip" above). -# -# The script stages the sibling's .s files in build/lib/nistcurves_p384_staging/, -# applies sed-patches to override their `.segment` directives and rewrite -# them into the new dual-segment scheme. -# -# Usage (from top-level Makefile): -# bash tools/integration/build_nistcurves_p384.sh -# Produces: -# build/lib/nistcurves-p384-sha384.a -# build/lib/nistcurves-p384-curve.a -# build/lib/nistcurves-p384-sha384.sizes.txt -# build/lib/nistcurves-p384-curve.sizes.txt +# Outputs: +# build/lib/nistcurves-p384-sha384.a - SHA-384 overlay archive +# build/lib/nistcurves-p384-curve.a - curve verify overlay archive +# build/lib/nistcurves-p384-{sha384,curve}.sizes.txt # ============================================================================= set -eo pipefail # --- Paths --- PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -LIB_SRC="$PROJECT_ROOT/libs/nistcurves/src" +LIB_DIR="$PROJECT_ROOT/libs/nistcurves" +LIB_SRC="$LIB_DIR/src" +LIB_BUILD="$LIB_DIR/build" STAGING="$PROJECT_ROOT/build/lib/nistcurves_p384_staging" OUT_DIR="$PROJECT_ROOT/build/lib" ARCHIVE_SHA="$OUT_DIR/nistcurves-p384-sha384.a" @@ -90,333 +53,101 @@ SIZES_CURVE="$OUT_DIR/nistcurves-p384-curve.sizes.txt" CA65="${CA65:-ca65}" AR65="${AR65:-ar65}" -# --- Canonical ZP defines --- -# The sibling's zp_config.s wraps every ZP equate in .ifndef, so command-line -# -D values win over the defaults. We pin the sibling to c64-https's -# canonical ZP map (src/crypto/shared/zp_canon.inc) AND override the SHA-384 -# pointer slots to $3D-$44 (Phase 1.5). -# -# Why $3D-$44? The sibling's defaults sha_src=$04, sha_len=$06, -# sha_w_ptr=$08, sha_w_ptr2=$0a collide with c64-https's canonical -# $04-$09 = w32_* (ChaCha20/Poly1305) and $0A-$0D = sha_temp1 (SHA-256). -# $3D-$44 is the lowest 8-byte contiguous free range above the canonical -# crypto ZP map (ec_scalar_ptr ends at $3C); see this file's header for -# the full audit. Demonstrated free during the SHA-384 call window: -# - Not used by ip65 ($02-$1B), ChaCha20/Poly1305 ($04-$1D), -# SHA-256 ($0A-$13), TLS record layer ($1E-$21), fp_* ECDSA bignum -# ($22-$2B + $39-$3C), fe25519 ($2C-$35), or x25519 ($38-$3A). -# - $36-$37 was reserved for fe25519 future expansion (only 2 bytes, -# insufficient for the 8 bytes SHA-384 needs). -# -# Note: fp_mul_i / fp_mul_j overlap with x25_byte_idx / x25_bit_mask at -# $39/$3a. This is fine because x25519 and P-384 run at different times -# (different overlays; only one resident at a time) and the canonical -# map documents the time-sharing. -ZP_DEFINES=( - '-Dproc_port=$01' - '-Dzp_tmp1=$02' - '-Dzp_tmp2=$03' - '-Dzp_ptr1=$fb' - '-Dzp_ptr2=$fd' - '-Dfp_src1=$22' - '-Dfp_src2=$24' - '-Dfp_dst=$26' - '-Dfp_misc=$28' - '-Dfp_carry=$2a' - '-Dfp_loop=$2b' - '-Dfp_mul_i=$39' - '-Dfp_mul_j=$3a' - '-Dec_scalar_ptr=$3b' - '-Dpoly_i=$1a' - '-Dpoly_j=$1b' - '-Dpoly_carry=$1c' - '-Dpoly_tmp=$1d' - # SHA-384 streaming pointer slots (Phase 1.5 — moved out of the - # sibling's $04-$0B defaults to avoid the canonical w32_* / sha_temp1 - # collision; see header). - '-Dsha_src=$3d' - '-Dsha_len=$3f' - '-Dsha_w_ptr=$41' - '-Dsha_w_ptr2=$43' +# --- ZP-slot overrides (c64-https canonical map + SHA-384 isolated window) --- +ZP_OVERRIDES=( + '-D' 'zp_ptr2=$3d' + '-D' 'fp_mul_i=$39' + '-D' 'fp_mul_j=$3a' + # SHA-384 streaming pointer slots (moved out of $04-$0b defaults + # to avoid w32_* / sha_temp1 collision; see header). + '-D' 'sha_src=$3d' + '-D' 'sha_len=$3f' + '-D' 'sha_w_ptr=$41' + '-D' 'sha_w_ptr2=$43' ) +# Note: zp_ptr2 and sha_src both pin $3d. zp_ptr2 is curve-archive-only +# (ecdsa384.s imports it); sha_src is SHA-archive-only. They never share +# a call window — curve overlay and sha overlay are mutually exclusive +# in the live CRYPTO_OVERLAY slot. The defaults below feed BOTH archives' +# zp_config.o builds, but each archive's call window only consumes the +# slot relevant to its own bodies. Safe. + +# --- 1. Build upstream's lib-p384-sha384 + lib-p384-verify archives --- +# Same caveat as P-256: upstream's Makefile builds every module with the +# same recipe, so we can't pass -D overrides via `make CA65=...` (would +# error on .importzp redefinition in non-zp_config files). Build upstream +# with defaults, then rebuild zp_config.o ourselves below. +# +# Note on c64-lib-contract SPEC §8.1: only nistcurves v0.3.0's +# `mul_8x8.s` references sqtab_lo / sqtab_hi (via the local +# `.ifndef LIB_SHARED_SQTAB_BASE` equate in that file). Step 4 drops +# `mul_8x8.o` and `data_shared.o` from the curve archive entirely; +# the SHA-384 archive (separate compile path) never linked mul_8x8 +# in the first place since SHA-384 is multiply-free. So the upstream +# default LIB_SHARED_SQTAB_BASE baked into mul_8x8.o is discarded +# before it reaches either overlay's link — no override required at +# the nistcurves Makefile invocation. +echo "[p384] building libs/nistcurves lib-p384-sha384 + lib-p384-verify (upstream defaults)..." +make -s -C "$LIB_DIR" lib-p384-sha384 lib-p384-verify >/dev/null + +UPSTREAM_SHA_ARCHIVE="$LIB_BUILD/lib/nistcurves-p384-sha384.a" +UPSTREAM_CURVE_ARCHIVE="$LIB_BUILD/lib/nistcurves-p384-verify.a" +for f in "$UPSTREAM_SHA_ARCHIVE" "$UPSTREAM_CURVE_ARCHIVE"; do + if [ ! -f "$f" ]; then + echo "ERROR: upstream archive missing: $f" >&2 + exit 1 + fi +done -# --- Stage sources --- +# --- 2. Stage upstream object files --- rm -rf "$STAGING" -mkdir -p "$STAGING" +mkdir -p "$STAGING/sha" "$STAGING/curve" "$OUT_DIR" +cp "$UPSTREAM_SHA_ARCHIVE" "$STAGING/sha/upstream.a" +cp "$UPSTREAM_CURVE_ARCHIVE" "$STAGING/curve/upstream.a" +(cd "$STAGING/sha" && "$AR65" x upstream.a $( "$AR65" t upstream.a )) +(cd "$STAGING/curve" && "$AR65" x upstream.a $( "$AR65" t upstream.a )) -# constants.s and zp_config.s are shared between both halves. zp_config.s -# is .include'd transitively; we assemble it once with -D overrides and -# add the resulting .o to BOTH archives. -cp "$LIB_SRC"/constants.s "$STAGING/" -cp "$LIB_SRC"/zp_config.s "$STAGING/" -cp "$LIB_SRC"/fp384.s "$STAGING/fp384_raw.s" -cp "$LIB_SRC"/mod384.s "$STAGING/mod384_raw.s" -cp "$LIB_SRC"/points384.s "$STAGING/points384_raw.s" -cp "$LIB_SRC"/curve384.s "$STAGING/curve384_raw.s" -cp "$LIB_SRC"/sha384.s "$STAGING/sha384_raw.s" -cp "$LIB_SRC"/ecdsa384.s "$STAGING/ecdsa384_raw.s" - -# --- Strip points384.s of ec_precompute_384 and ec_scalar_mul_384 --- -# Same surgery as Phase 1b. Bodies between lines 787 and 1488 inclusive -# are physically removed; the related `.export` and `.import` lines are -# scrubbed below. ec_gx384 / ec_gy384 imports are KEPT (used by the shim). -# -# OPTION A choice (Phase 1b): the Lim-Lee body for ec_scalar_mul_384 is -# stripped; an in-staging shim file (ec_scalar_mul_384_shim_raw.s, emitted -# below) provides the symbol by copying G into ec_base384_x/y and -# tail-calling ec_scalar_mul_var_384. This avoids the ~24 KB Lim-Lee -# anchor table + ~100 s ec_precompute_384 boot drag. Pattern mirrors -# src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 for P-256). -# BSD-sed compat: macOS sed requires `-i ''` (empty extension). -sed -i '' '787,1488d' "$STAGING/points384_raw.s" -sed -i '' '/^\.export ec_precompute_384, ec_scalar_mul_384$/d' "$STAGING/points384_raw.s" -sed -i '' '/^\.import ec_anchor[1-8]_384_x/d' "$STAGING/points384_raw.s" -sed -i '' '/^\.import ec_anchor[1-8]_384_y/d' "$STAGING/points384_raw.s" -sed -i '' '/^\.import cm_k_384, mul_dma_lo$/d' "$STAGING/points384_raw.s" -sed -i '' '/^\.import ec384_sc_byte, ec384_sc_mask, ec384_precomp_i$/d' "$STAGING/points384_raw.s" - -# --- Strip ecdsa_verify_with_message_384 wrapper from the curve archive --- -# Phase 1.5 NEW. The wrapper imports sha384_init/update/final, which live -# in the OTHER overlay half (sha384 archive). TLS now drives the SHA -# overlay manually then swaps in the curve overlay and calls -# ecdsa_verify_384 directly with the digest pre-spliced into -# ecdsa_inputs_384[96..143]. +# --- 3. Rebuild zp_config.o with c64-https overrides (shared by both archives) --- +"$CA65" \ + --cpu 6502 \ + -g \ + -I "$LIB_SRC" \ + "${ZP_OVERRIDES[@]}" \ + -o "$STAGING/zp_config.o" \ + "$LIB_SRC/zp_config.s" + +# Distribute the overridden zp_config.o into both staging trees. +cp "$STAGING/zp_config.o" "$STAGING/sha/zp_config.o" +cp "$STAGING/zp_config.o" "$STAGING/curve/zp_config.o" + +# --- 4. Drop conflicting members from the curve archive --- +# Same reasoning as P-256: mul_8x8.o + data_shared.o collide with +# c64-https's in-tree src/crypto/poly1305.s + src/data.s exports. +rm -f "$STAGING/curve/mul_8x8.o" "$STAGING/curve/data_shared.o" + +# --- 5. Build ec_scalar_mul_384 shim (Option A: variable-base scalar-mul) --- +# The upstream lib-p384-verify archive excludes points384_comb.s (the +# Lim-Lee fixed-base comb), so `ec_scalar_mul_384` (the symbol ecdsa384.s +# imports at line 49) is unresolved. We provide it via this shim that +# copies G into ec_base384_x/y and tail-calls ec_scalar_mul_var_384. # -# In libs/nistcurves@90830c9 the wrapper + trampoline span lines 568-end -# of ecdsa384.s. We delete from line 568 to the end of file ("568,$d") -# and scrub: -# - the two wrapper .export lines (verify_with_message_384 + -# verify_with_msg_384_tramp) -# - the .import sha384_init/update/final line -# - the .import sha384_msg_buf reference (the test trampoline only) -# - the .import ecdsa384_msg_struct_ptr line (wrapper-only scratch) -# - the .import ecdsa_inputs_384, ecdsa_result_msg_384 line -# (test-trampoline only — the standalone curve archive doesn't need -# these symbols since the wrapper that consumed them is gone; ld65 -# would fail to resolve them if we left the .import in place since -# they live in the data heredoc as exports but nothing else references -# them after the wrapper is dropped — keep the .import to keep the -# symbol pulled in via .import-as-link-anchor; data_curve_raw.s still -# exports both for the harness driver path). -# We sed only on the curve copy AFTER making a separate sha-only copy is -# unnecessary because sha384_raw.s never sees ecdsa384_raw.s. -sed -i '' '568,$d' "$STAGING/ecdsa384_raw.s" -sed -i '' '/^\.export ecdsa_verify_with_message_384$/d' "$STAGING/ecdsa384_raw.s" -sed -i '' '/^\.export ecdsa_verify_with_msg_384_tramp$/d' "$STAGING/ecdsa384_raw.s" -sed -i '' '/^\.import sha384_init, sha384_update, sha384_final$/d' "$STAGING/ecdsa384_raw.s" -sed -i '' '/^\.import ecdsa384_msg_struct_ptr$/d' "$STAGING/ecdsa384_raw.s" - -# --- Drop test-only sha384_msg_buf import from sha384.s --- -# sha384.s `.import sha384_digest, sha384_msg_buf` at file scope but never -# references sha384_msg_buf in code. We drop the 1024-byte test scratch -# buffer from data_raw.s, so the import must go too. -sed -i '' 's/^\(\.import sha384_digest, sha384_msg_buf\)$/.import sha384_digest/' "$STAGING/sha384_raw.s" -# Same scrub on the curve-half ecdsa384_raw.s (the .import line is on a -# different line in ecdsa384.s; preserve only sha384_digest if the line is -# present after the wrapper-strip above — it should NOT be, since the -# import for sha384_init/update/final/digest/msg_buf is bundled together. -# Defensive: leave a no-op sed in case the upstream layout changes). -sed -i '' 's/^\(\.import sha384_digest, sha384_msg_buf\)$/.import sha384_digest/' "$STAGING/ecdsa384_raw.s" - -# --- Emit data_curve_raw.s (resident DATA exports for the curve archive) --- -# Hand-extracted from the sibling's data.s — non-SHA portion only. -# This is the SAME byte-for-byte content as Phase 1b's data_raw.s up to -# (but not including) the SHA-384 streaming state block. Land in DATA -# (= CRYPTO_RESIDENT in the live cfg, $C000 in the standalone cfgs). -cat > "$STAGING/data_curve_raw.s" <<'DATA_EOF' -; ============================================================================= -; data_curve_raw.s - Resident DATA exports for the curve / verify half of -; the split P-384 overlay (Phase 1.5). Non-SHA portion of Phase 1b's -; minimal data_raw.s. Lands in CRYPTO_RESIDENT under the live cfg. -; -; The 240 B BE input struct (ecdsa_inputs_384) is shared with the SHA -; archive's caller path -- TLS pre-stages r/s/Qx/Qy here, then drives -; sha384_init/update/final to populate the digest at struct[96..143], -; then swaps in this overlay and calls ecdsa_verify_384. -; ============================================================================= +# Pattern mirrors src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 +# P-256 dispatcher). Slower per-call than the real Lim-Lee comb but +# avoids the ~24 KB REU bank-2 anchor table + ~100 s +# ec_precompute_384 boot drag. +cat > "$STAGING/curve/ec_scalar_mul_384_shim.s" <<'SHIM_EOF' .setcpu "6502" -.segment "DATA" - -; --- P-384 field arithmetic working buffers (48 bytes each) --- -.export fp384_wide -fp384_wide: .res 96, 0 ; 768-bit product from multiply -.export fp384_tmp1 -fp384_tmp1: .res 48, 0 -.export fp384_tmp2 -fp384_tmp2: .res 48, 0 -.export fp384_tmp3 -fp384_tmp3: .res 48, 0 -.export fp384_tmp4 -fp384_tmp4: .res 48, 0 - -; --- P-384 result registers --- -.export fp384_r0 -fp384_r0: .res 48, 0 -.export fp384_r1 -fp384_r1: .res 48, 0 -.export fp384_r2 -fp384_r2: .res 48, 0 -.export fp384_r3 -fp384_r3: .res 48, 0 - -; --- P-384 modular inverse working space --- -.export fp384_inv_u -fp384_inv_u: .res 48, 0 -.export fp384_inv_v -fp384_inv_v: .res 48, 0 -.export fp384_inv_x1 -fp384_inv_x1: .res 48, 0 -.export fp384_inv_x2 -fp384_inv_x2: .res 48, 0 - -; --- P-384 point storage (Jacobian: X=48 + Y=48 + Z=48 = 144 bytes) --- -.export ec384_p1 -ec384_p1: .res 144, 0 -.export ec384_p2 -ec384_p2: .res 144, 0 -.export ec384_p3 -ec384_p3: .res 144, 0 - -; --- P-384 point math temporaries --- -.export ec384_t1 -ec384_t1: .res 48, 0 -.export ec384_t2 -ec384_t2: .res 48, 0 -.export ec384_t3 -ec384_t3: .res 48, 0 -.export ec384_t4 -ec384_t4: .res 48, 0 -.export ec384_t5 -ec384_t5: .res 48, 0 -.export ec384_t6 -ec384_t6: .res 48, 0 - -; --- P-384 affine output --- -.export ec384_affine_x -ec384_affine_x: .res 48, 0 -.export ec384_affine_y -ec384_affine_y: .res 48, 0 - -; --- Variable-base scalar-mul input (affine, 48 bytes each, LE). -; Consumed by ec_scalar_mul_var_384 (ECDSA-verify building block) and -; populated by the ec_scalar_mul_384 shim (G -> ec_base384_x/y). -.export ec_base384_x -ec_base384_x: .res 48, 0 -.export ec_base384_y -ec_base384_y: .res 48, 0 - -; --- P-384 Solinas reduction scratch --- -.export fp384_red_tmp -fp384_red_tmp: .res 49, 0 - -; --- ECDSA verify scratch (P-384). All 48-byte little-endian unless noted. --- -.export ecdsa384_r -ecdsa384_r: .res 48, 0 ; LE r (byte-reversed from BE input) -.export ecdsa384_s -ecdsa384_s: .res 48, 0 ; LE s -.export ecdsa384_h -ecdsa384_h: .res 48, 0 ; LE message hash -.export ecdsa384_qx -ecdsa384_qx: .res 48, 0 ; LE public-key affine X -.export ecdsa384_qy -ecdsa384_qy: .res 48, 0 ; LE public-key affine Y -.export ecdsa384_w -ecdsa384_w: .res 48, 0 ; LE w = s^-1 mod n -.export ecdsa384_u1 -ecdsa384_u1: .res 48, 0 ; LE u1 = h*w mod n -.export ecdsa384_u2 -ecdsa384_u2: .res 48, 0 ; LE u2 = r*w mod n -.export ecdsa384_u1_be -ecdsa384_u1_be: .res 48, 0 ; BE u1 (scalar_mul input) -.export ecdsa384_u2_be -ecdsa384_u2_be: .res 48, 0 ; BE u2 (scalar_mul_var input) -.export ecdsa384_u1g_x -ecdsa384_u1g_x: .res 48, 0 ; LE affine X of u1*G -.export ecdsa384_u1g_y -ecdsa384_u1g_y: .res 48, 0 ; LE affine Y of u1*G - -; --- fp_reverse48 staging buffer (one 48-byte scratch). --- -.export fp_rev_buf_384 -fp_rev_buf_384: .res 48, 0 - -; --- ECDSA verify test-driver staging buffer (240 B BE struct). -; The c64-test-harness jsr() helper cannot pass register arguments, so -; the BE input struct is staged here and the test trampoline points -; A/X at it. TLS pre-fills r|s|Qx|Qy here, then runs SHA over the -; handshake transcript, then writes the digest into struct[96..143], -; then swaps in the curve overlay and calls ecdsa_verify_384. -.export ecdsa_inputs_384 -ecdsa_inputs_384: .res 240, 0 ; r|s|h|Qx|Qy each 48 B BE - -; --- ECDSA result byte (test driver / dispatcher result) --- -.export ecdsa_result_msg_384 -ecdsa_result_msg_384: .byte 0 -DATA_EOF - -# --- Emit data_sha_raw.s (resident DATA exports for the SHA archive) --- -# Hand-extracted from the sibling's data.s — SHA-384 portion only. -# Same byte-for-byte content as Phase 1b's data_raw.s SHA-384 block. -cat > "$STAGING/data_sha_raw.s" <<'DATA_EOF' -; ============================================================================= -; data_sha_raw.s - Resident DATA exports for the SHA-384 half of the split -; P-384 overlay (Phase 1.5). SHA-384 portion of Phase 1b's minimal -; data_raw.s. Lands in CRYPTO_RESIDENT under the live cfg. -; -; Storage convention: each 64-bit word is held LITTLE-ENDIAN-WITHIN-WORD, -; matching 6502 ADC carry propagation. All buffers are owned exclusively -; by sha384.s. sha384_msg_buf (1 KB test scratch) is intentionally OMITTED -; (would inflate resident DATA by ~25%; not used by sha384.s itself). -; ============================================================================= -.setcpu "6502" - -.segment "DATA" - -.export sha_state -sha_state: .res 64, 0 ; H[0..7], 8 bytes each LE-within-word -.export sha_w -sha_w: .res 640, 0 ; W[0..79] message schedule, 8 B each LE -.export sha_abcdefgh -sha_abcdefgh: .res 64, 0 ; working a..h, 8 B each LE -.export sha_t -sha_t: .res 16, 0 ; T1 (8 B) + T2 (8 B), LE -.export sha_scratch -sha_scratch: .res 64, 0 ; 8x 8-byte scratch slots for round helpers -.export sha_block_buf -sha_block_buf: .res 128, 0 ; current 1024-bit block (wire order) -.export sha_block_len -sha_block_len: .byte 0 ; bytes used in sha_block_buf, 0..127 -.export sha_total_len -sha_total_len: .res 16, 0 ; 128-bit total bit count, LE on-chip -.export sha384_digest -sha384_digest: .res 48, 0 ; final BE digest output (read by curve - ; overlay's ecdsa_verify_384 path after - ; TLS splices it into ecdsa_inputs_384[96..143]) -DATA_EOF - -# --- Emit ec_scalar_mul_384 shim (Option A) --- -# Pattern mirrors src/crypto/ecdsa_verify.s::ec_scalar_mul (Phase C.4 P-256 -# dispatcher). Lives in OVERLAY_P384_CURVE alongside the rest of the curve -# code. ec_gx384 and ec_gy384 are each contiguous 48-byte slots in -# curve384.s RODATA, so a simple ldy #47 / lda src,y / sta dst,y / dey / -# bpl loop works (47 = $2F has bit 7 clear; DEY updates N flag based on -# the decremented Y, not the LDA byte). -cat > "$STAGING/ec_scalar_mul_384_shim_raw.s" <<'SHIM_EOF' ; ============================================================================= -; ec_scalar_mul_384_shim_raw.s -- Phase 1b shim for the stripped Lim-Lee -; fixed-base scalar-mul (Option A). Provides ec_scalar_mul_384 by copying -; G into ec_base384_x/y and tail-calling ec_scalar_mul_var_384. +; ec_scalar_mul_384_shim.s -- Option A shim for the Lim-Lee fixed-base +; scalar-mul (excluded from lib-p384-verify). Provides ec_scalar_mul_384 +; by copying G into ec_base384_x/y and tail-calling ec_scalar_mul_var_384. ; -; Mirrors the Phase C.4 P-256 dispatcher pattern in -; src/crypto/ecdsa_verify.s::ec_scalar_mul. Slower per-call than the real -; Lim-Lee comb (double-and-add vs. windowed comb) but avoids the ~24 KB -; REU bank-2 anchor table + ~100 s ec_precompute_384 boot drag. -; -; Phase 1.5: lives in OVERLAY_P384_CURVE (was OVERLAY_P384 in Phase 1b). +; Lives in the P384 code segment (same overlay slot as the rest of the +; curve archive). ; ============================================================================= -.setcpu "6502" -.segment "OVERLAY_P384_CURVE" +.segment "LIB_NISTCURVES_P384_CODE" .export ec_scalar_mul_384 @@ -425,7 +156,7 @@ cat > "$STAGING/ec_scalar_mul_384_shim_raw.s" <<'SHIM_EOF' .import ec_scalar_mul_var_384 ec_scalar_mul_384: - ; Copy G.x -> ec_base384_x (48 bytes; ldy #47, dey/bpl safe) + ; Copy G.x -> ec_base384_x (48 bytes; ldy #47 / bpl safe since 47 < 128) ldy #47 @cp_x: lda ec_gx384,y sta ec_base384_x,y @@ -437,141 +168,65 @@ ec_scalar_mul_384: sta ec_base384_y,y dey bpl @cp_y - jmp ec_scalar_mul_var_384 ; tail-call: result and clobbers passthrough + jmp ec_scalar_mul_var_384 ; tail-call SHIM_EOF -# --- Route CODE / RODATA segments into per-half OVERLAY segments --- -# Phase 1.5 split: each source goes into either OVERLAY_P384_SHA384 (just -# sha384) or OVERLAY_P384_CURVE (everything else). -# -# fp384_raw.s also has a `.segment "BSS"` block at the tail (53 B) for -# fp384_sqr_extra / mul_src2_buf_384 / fp384_sqr_pairs. Those land in -# CRYPTO_RESIDENT BSS via the canonical BSS segment name (no rewrite -# needed) since the overlay gets swapped out between calls. -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/fp384_raw.s" -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/mod384_raw.s" -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/points384_raw.s" -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/ecdsa384_raw.s" -# curve384.s uses RODATA -- route into OVERLAY_P384_CURVE (read-only constants). -sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384_CURVE"/' "$STAGING/curve384_raw.s" -# sha384.s: code (CODE) and IV/K[80] round constants (RODATA) both into -# the SHA-384 overlay. -sed -i '' 's/^\.segment "CODE"/.segment "OVERLAY_P384_SHA384"/' "$STAGING/sha384_raw.s" -sed -i '' 's/^\.segment "RODATA"/.segment "OVERLAY_P384_SHA384"/' "$STAGING/sha384_raw.s" - -# --- mod384.s curve constants (ec_p384, ec_n384) live in CODE segment in -# the sibling and are emitted inline with .byte directives. After the -# CODE->OVERLAY_P384_CURVE rewrite they flow into the curve overlay -# alongside the code that reads them; that is intentional -# (fp_mod_reduce384 reads ec_p384 and IS in the curve overlay). - -# --- Forbidden-symbol guard (curve archive only) --- -# After the strip, points384_raw.s must NOT reference any of the removed -# Lim-Lee comb / precompute symbols. ec_gx384 / ec_gy384 / cm_k_384 / -# ec_anchor*_384 patterns CAN appear as comments; we strip leading -# whitespace and a leading `;` before the grep so we only match active -# code. ec_gx384 / ec_gy384 are intentionally left LIVE in the staging -# tree (used by the shim). cm_k_384, ec_anchor[0-9]_384, ec384_sc_byte/ -# mask, ec384_precomp_i remain forbidden -- those bodies were physically -# removed. -if grep -v '^\s*;' "$STAGING/points384_raw.s" \ - | grep -qE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b'; then - echo "ERROR: stripped points384 still references removed-body symbols" >&2 - grep -v '^\s*;' "$STAGING/points384_raw.s" \ - | grep -nE '\bec384_sc_byte\b|\bec384_sc_mask\b|\bec384_precomp_i\b|\bcm_k_384\b|\bec_anchor[0-9]_384\b' \ - | head -5 >&2 - exit 1 -fi - -# --- Forbidden-symbol guard (Phase 1.5 wrapper-strip) --- -# After the wrapper-strip, ecdsa384_raw.s must NOT reference any of the -# SHA-384 entry points (those live in the OTHER overlay half) or the -# wrapper-only labels. Active-code grep only. -if grep -v '^\s*;' "$STAGING/ecdsa384_raw.s" \ - | grep -qE '\bsha384_init\b|\bsha384_update\b|\bsha384_final\b|\becdsa_verify_with_message_384\b|\becdsa_verify_with_msg_384_tramp\b|\becdsa384_msg_struct_ptr\b'; then - echo "ERROR: stripped ecdsa384 still references wrapper / SHA symbols" >&2 - grep -v '^\s*;' "$STAGING/ecdsa384_raw.s" \ - | grep -nE '\bsha384_init\b|\bsha384_update\b|\bsha384_final\b|\becdsa_verify_with_message_384\b|\becdsa_verify_with_msg_384_tramp\b|\becdsa384_msg_struct_ptr\b' \ - | head -5 >&2 - exit 1 -fi - -# --- Assemble each staged .s file --- -OBJ_DIR="$STAGING/obj" -rm -rf "$OBJ_DIR" -mkdir -p "$OBJ_DIR" "$OUT_DIR" - -# zp_config.s is the single point of truth for the library's ZP equates. -# We assemble it with `-D` overrides so the sibling's defaults are -# replaced by c64-https's canonical ZP map (with the Phase 1.5 SHA-384 -# slot moves). The other source files use `.importzp` to pull these -# equates from the linker-resolved zp_config.o. -# `-g` embeds cc65 debug info into each .o; the overlay ld65 invocations -# in build_nistcurves_p384_bin.sh merge it into build/lib/overlay-p384-*.dbg -# sidecars. Does not change emitted code bytes. "$CA65" \ + --cpu 6502 \ -g \ - -I "$STAGING" \ - -I "$PROJECT_ROOT/src/crypto/shared" \ - "${ZP_DEFINES[@]}" \ - -o "$OBJ_DIR/zp_config.o" "$STAGING/zp_config.s" - -# Other files: NO -D. Let `.importzp` resolve through the linker to -# zp_config.o's `.exportzp` declarations. If we passed -D here the -# assembler would treat the symbol as locally-defined absolute and -# conflict with the .importzp declaration. -for src in fp384_raw mod384_raw points384_raw curve384_raw \ - sha384_raw ecdsa384_raw ec_scalar_mul_384_shim_raw \ - data_curve_raw data_sha_raw; do - "$CA65" \ - -g \ - -I "$STAGING" \ - -I "$PROJECT_ROOT/src/crypto/shared" \ - -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" -done + -I "$LIB_SRC" \ + -o "$STAGING/curve/ec_scalar_mul_384_shim.o" \ + "$STAGING/curve/ec_scalar_mul_384_shim.s" -# --- Archive: nistcurves-p384-sha384.a (SHA-384 hash overlay half) --- -# Members: zp_config + sha384_raw + data_sha_raw. -# The SHA archive does NOT contain ANY curve code; ld65 link resolves -# only the SHA exports + the resident SHA DATA buffers. +# --- 6. Re-archive both halves --- +# SHA archive: zp_config + lib_version + lib_manifest + sha384 + data_sha. +# Self-contained — no curve / mul code. rm -f "$ARCHIVE_SHA" "$AR65" a "$ARCHIVE_SHA" \ - "$OBJ_DIR/zp_config.o" \ - "$OBJ_DIR/sha384_raw.o" \ - "$OBJ_DIR/data_sha_raw.o" - -# --- Archive: nistcurves-p384-curve.a (curve / verify overlay half) --- -# Members: zp_config + fp384 + mod384 + points384 + curve384 + -# ecdsa384 (verify_384 only) + shim + data_curve_raw. -# The curve archive does NOT contain ANY SHA code or SHA DATA exports; -# ld65 link resolves only ecdsa_verify_384 + the resident curve DATA -# buffers. + "$STAGING/sha/lib_version.o" \ + "$STAGING/sha/lib_manifest.o" \ + "$STAGING/sha/zp_config.o" \ + "$STAGING/sha/sha384.o" \ + "$STAGING/sha/data_sha.o" + +# Curve archive: zp_config + lib_version + lib_manifest + constants + +# reu_config + fp384 + mod384 + curve384 + points384_core + ecdsa384 + +# shim + data_p384. mul_8x8 + data_shared dropped (c64-https owns). rm -f "$ARCHIVE_CURVE" "$AR65" a "$ARCHIVE_CURVE" \ - "$OBJ_DIR/zp_config.o" \ - "$OBJ_DIR/fp384_raw.o" \ - "$OBJ_DIR/mod384_raw.o" \ - "$OBJ_DIR/points384_raw.o" \ - "$OBJ_DIR/curve384_raw.o" \ - "$OBJ_DIR/ecdsa384_raw.o" \ - "$OBJ_DIR/ec_scalar_mul_384_shim_raw.o" \ - "$OBJ_DIR/data_curve_raw.o" - -# --- Per-source byte counts --- + "$STAGING/curve/lib_version.o" \ + "$STAGING/curve/lib_manifest.o" \ + "$STAGING/curve/zp_config.o" \ + "$STAGING/curve/constants.o" \ + "$STAGING/curve/reu_config.o" \ + "$STAGING/curve/fp384.o" \ + "$STAGING/curve/mod384.o" \ + "$STAGING/curve/curve384.o" \ + "$STAGING/curve/points384_core.o" \ + "$STAGING/curve/ecdsa384.o" \ + "$STAGING/curve/ec_scalar_mul_384_shim.o" \ + "$STAGING/curve/data_p384.o" + +# --- 7. Per-source byte counts --- { echo "# nistcurves-p384-sha384.a per-source byte counts (ca65 .o file sizes)" - for src in zp_config sha384_raw data_sha_raw; do - bytes=$(wc -c < "$OBJ_DIR/$src.o") - printf '%-32s %d bytes (.o)\n' "$src" "$bytes" + for src in lib_version lib_manifest zp_config sha384 data_sha; do + if [ -f "$STAGING/sha/$src.o" ]; then + bytes=$(wc -c < "$STAGING/sha/$src.o") + printf '%-32s %d bytes (.o)\n' "$src" "$bytes" + fi done } > "$SIZES_SHA" { echo "# nistcurves-p384-curve.a per-source byte counts (ca65 .o file sizes)" - for src in zp_config fp384_raw mod384_raw points384_raw curve384_raw \ - ecdsa384_raw ec_scalar_mul_384_shim_raw data_curve_raw; do - bytes=$(wc -c < "$OBJ_DIR/$src.o") - printf '%-32s %d bytes (.o)\n' "$src" "$bytes" + for src in lib_version lib_manifest zp_config constants reu_config \ + fp384 mod384 curve384 points384_core ecdsa384 \ + ec_scalar_mul_384_shim data_p384; do + if [ -f "$STAGING/curve/$src.o" ]; then + bytes=$(wc -c < "$STAGING/curve/$src.o") + printf '%-32s %d bytes (.o)\n' "$src" "$bytes" + fi done } > "$SIZES_CURVE" diff --git a/tools/integration/build_nistcurves_p384_bin.sh b/tools/integration/build_nistcurves_p384_bin.sh index 9ebe7a5..8d6e50d 100755 --- a/tools/integration/build_nistcurves_p384_bin.sh +++ b/tools/integration/build_nistcurves_p384_bin.sh @@ -185,22 +185,20 @@ link_one () { local dbg_out dbg_out="${bin_out%.bin}.dbg" + # Under c64-lib-contract / libs/nistcurves cfa9085+, the upstream + # archive includes `constants.o` which `.export`s every REU register + # equate (`reu_status` / `reu_command` / `reu_c64_lo`-`hi` / + # `reu_reu_lo`-`hi` / `reu_reu_bank` / `reu_len_lo`-`hi` / + # `reu_addr_ctrl`). Pre-contract the .a was missing those exports + # and consumers patched them in via `ld65 --define`. Post-contract + # `--define`-ing them duplicates the symbol and ld65 errors with + # "Duplicate external identifier". "$LD65" \ -C "$cfg" \ -o "$bin_out" \ -Ln "$labels_out" \ -m "$map_out" \ --dbgfile "$dbg_out" \ - --define reu_status=\$df00 \ - --define reu_command=\$df01 \ - --define reu_c64_lo=\$df02 \ - --define reu_c64_hi=\$df03 \ - --define reu_reu_lo=\$df04 \ - --define reu_reu_hi=\$df05 \ - --define reu_reu_bank=\$df06 \ - --define reu_len_lo=\$df07 \ - --define reu_len_hi=\$df08 \ - --define reu_addr_ctrl=\$df0a \ --define mul_cached_a="$DEF_MUL_CACHED_A" \ --define mul_dma_lo="$DEF_MUL_DMA_LO" \ --define mul_dma_hi="$DEF_MUL_DMA_HI" \ diff --git a/tools/integration/build_x25519.sh b/tools/integration/build_x25519.sh index bd52fc9..f6efe72 100644 --- a/tools/integration/build_x25519.sh +++ b/tools/integration/build_x25519.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ============================================================================= -# tools/integration/build_x25519.sh - Build c64-x25519 v0.4.0 X25519 -# primitives as a resident .a archive linked into the main PRG. +# tools/integration/build_x25519.sh - Build c64-x25519 v0.6.0 +# X25519 primitives as a resident .a archive linked into the main PRG. # # Optional sibling-library integration (Phase C.5). Produces # build/lib/x25519.a containing: @@ -12,6 +12,16 @@ # - data buffers (x25_*, fe25519_tmp*, mul_*, sqr_*, a24_*, fe_p) # - util (vic_blank, vic_unblank, bench helpers — pulled in if referenced) # +# Submodule pin: v0.6.0 (95fdd70) — adopts c64-lib-contract §8.1 (the +# canonical shared 1 KB quarter-square table) plus RAM reclamation in +# x25519_init.s (bank-2 stash removed) and bench rehab (bench_start/stop +# php/plp shape so jiffy-based benches measure real cycles again). +# Earlier contract-§1/§2/§3/§5 adoption landed in v0.4.0-7-g4d1c752 and +# remains in place: every ZP slot is `.exportzp`-ed (zp_config.s), +# LIB_VERSION_*/LIB_ABI_VERSION absolute exports (lib_version.s), +# X25519_REU_BANK configurable REU base (reu_config.s), and the +# LIB_X25519_* aggregate manifest equates. +# # Activated only when `make USE_X25519_SIBLING=1`. Default is OFF; the # in-tree src/crypto/fe25519.s + src/crypto/x25519.s remain the # default implementation until the supervisor + validator sign off on @@ -20,14 +30,21 @@ # # Excluded (replaced by in-tree equivalents): # - src/mul_8x8.s: in-tree src/crypto/poly1305.s already exports -# mul_8x8 / sqtab_init / poly_prod_lo / poly_prod_hi. Including -# the sibling's would duplicate symbols. The two implementations -# are calling-convention-compatible (A=multiplicand, X=multiplier -# → poly_prod_lo/hi). The in-tree variant uses a small branch on -# the sum-page byte; the sibling's is CT-clean via SMC patching. -# Using in-tree's is a CT regression for the X25519 mul path; the -# supervisor's plan accepts this for the integration smoke and -# defers a CT clean-up to a follow-up. +# mul_8x8 / sqtab_init / poly_prod_lo / poly_prod_hi / sqtab_lo / +# sqtab_hi. Including the sibling's would duplicate symbols. The +# two implementations are calling-convention-compatible +# (A=multiplicand, X=multiplier → poly_prod_lo/hi). The in-tree +# variant uses a small branch on the sum-page byte; the sibling's +# is CT-clean via SMC patching. Using in-tree's is a CT regression +# for the X25519 mul path; the supervisor's plan accepts this for +# the integration smoke and defers a CT clean-up to a follow-up. +# Under v0.6.0 §8.1 the sibling's mul_8x8 + the mult66 path inside +# fe25519_sqr both resolve `sqtab_lo` / `sqtab_hi` against the +# LIB_SHARED_SQTAB_BASE equate set via -D below; the equate is +# `.ifndef`-guarded in libs/x25519/src/constants.s so passing +# SHARED_SQTAB_INIT collapses the duplicate init body but keeps +# the SHARED_SQTAB_BASE-derived loads pointing at c64-https's +# resident table. # - src/main.s: the sibling's BASIC stub / test harness entry. We # have our own boot.s entry point. # @@ -55,10 +72,11 @@ CA65="${CA65:-ca65}" AR65="${AR65:-ar65}" # --- Canonical ZP defines --- -# The sibling's constants.s wraps every library-owned ZP equate in -# `.ifndef ` (see libs/x25519/docs/LIBRARY.md §4.2). We use the -# sibling's defaults — they are byte-compatible with the in-tree map -# under the following time-sharing analysis: +# The sibling's src/zp_config.s now wraps every library-owned ZP equate +# in `.ifndef ` AND `.exportzp`-s the symbol (issue #44, closes +# c64-lib-contract §2). c64-https uses the sibling's defaults — they +# are byte-compatible with the in-tree map under the following +# time-sharing analysis: # # Sibling claim In-tree slot at same addr Time-share? # ------------- -------------------------- ----------- @@ -84,20 +102,80 @@ AR65="${AR65:-ar65}" # fe_wide pins here # via .assert # -# No -D overrides needed — sibling defaults are fine. +# No ZP -D overrides needed — sibling defaults match c64-https's map. ZP_DEFINES=() +# --- REU bank base pin --- +# c64-x25519 v0.4.0-7-g4d1c752 ships src/reu_config.s with a +# `.ifndef`-guarded X25519_REU_BANK equate (default $00) — issue #43, +# closes c64-lib-contract §3. The library claims six contiguous REU +# banks starting at X25519_REU_BANK (banks 0..5 at the default) for +# its precomputed mul / doubled / 17th-bit-carry tables. +# +# c64-https pins the base to bank 0, matching the in-tree layout in +# src/crypto/shared/reu_layout.inc: +# REU_X25519_MUL_TABLES_BASE = $00000 (bank 0) +# The in-tree comment block at the bottom of reu_layout.inc enumerates +# the theoretical bank-3/4/5 collision with P-256/P-384 precompute +# reservations under USE_X25519_SIBLING=1; that collision remains +# theoretical only under the current TLS path. Passing X25519_REU_BANK +# explicitly (rather than relying on the library default) defends +# against a future c64-x25519 release bumping its default base. +# +# ca65 takes `-D =` for assemble-time symbol definitions +# (the library docs use "--asm-define" in prose but ca65 only supports +# the `-D` short form per `ca65 --help`). +REU_DEFINES=(-D X25519_REU_BANK=0) + +# --- c64-lib-contract §8.1 shared sqtab adoption (v0.6.0) --- +# c64-https owns the canonical 1 KB quarter-square multiply table — +# `sqtab_lo` / `sqtab_hi` live at $BC00 / $BE00 in TABLES_BSS (see +# src/data.s), populated by src/crypto/poly1305.s::sqtab_init at boot. +# +# Pass LIB_SHARED_SQTAB_BASE so the sibling's mul_8x8.s + fe25519.s +# `mult66` path resolve `sqtab_lo` / `sqtab_hi` against the shared +# c64-https table rather than the sibling's $7800 default (which would +# fight c64-https's TABLES_BSS-resident copy at link time / runtime). +# The `.ifndef`-guarded equate in libs/x25519/src/constants.s +# (v0.6.0 §8.1 adoption) plus `.assert (sqtab_lo & $00ff) = 0` + +# `.assert sqtab_hi = sqtab_lo + $0200` catch a misconfigured base at +# assemble time rather than runtime. +# +# SHARED_SQTAB_INIT signals that the host program supplies the +# canonical `mul_tables_init` from a shared-primitives module +# (c64-https's poly1305.s::sqtab_init, aliased through +# src/crypto/shared/mul_tables.s). With the gate defined, the sibling's +# own `sqtab_init` body collapses to a no-op stub +# (libs/x25519/src/mul_8x8.s::sqtab_init .ifdef SHARED_SQTAB_INIT) so +# the two libs don't duplicate work. +SQTAB_DEFINES=( + -D 'LIB_SHARED_SQTAB_BASE=$BC00' + -D SHARED_SQTAB_INIT=1 +) + # --- Stage sources --- rm -rf "$STAGING" mkdir -p "$STAGING" cp "$LIB_SRC"/constants.s "$STAGING/" +# zp_config.s + reu_config.s — transitively .include'd from constants.s +# (v0.4.0-7-g4d1c752, contract §2 + §3). Both files set +# ZP_CONFIG_NO_EXPORTS / REU_CONFIG_NO_EXPORTS when included via +# constants.s, so the .exportzp / .export directives in them only fire +# once per archive (no duplicate-symbol risk). +cp "$LIB_SRC"/zp_config.s "$STAGING/" +cp "$LIB_SRC"/reu_config.s "$STAGING/" cp "$LIB_SRC"/fe25519.s "$STAGING/fe25519_raw.s" cp "$LIB_SRC"/x25519.s "$STAGING/x25519_raw.s" cp "$LIB_SRC"/x25519_init.s "$STAGING/x25519_init_raw.s" # util.s (bench_*, vic_blank/unblank) is NOT staged — c64-https has no # in-PRG user of those helpers; vic_blank-style display blanking is a # perf optimization for benchmarks, not a correctness requirement. +# lib_version.s (LIB_VERSION_* + LIB_X25519_*) is NOT staged for now — +# c64-https doesn't .import any of those symbols yet. Future +# assemble-time fit/collision checks against LIB_X25519_RESIDENT_BYTES +# / LIB_X25519_REU_BANKS_USED would require staging lib_version.s and +# adding the assertions in a cfg or include file. # Route all sibling data (BSS buffers + initialized rodata tables) to # the page-aligned TABLES_BSS segment. TABLES_BSS has `align = $100` @@ -333,6 +411,8 @@ for src in fe25519_raw x25519_raw x25519_init_raw data_x25519_bss_raw data_x2551 -g \ -I "$STAGING" \ "${ZP_DEFINES[@]}" \ + "${REU_DEFINES[@]}" \ + "${SQTAB_DEFINES[@]}" \ -o "$OBJ_DIR/$src.o" "$STAGING/$src.s" done @@ -356,3 +436,124 @@ rm -f "$ARCHIVE" echo "built $ARCHIVE" cat "$SIZES" + +# ============================================================================= +# W3 (library-ingestion architecture) -- emit a padded overlay .bin +# image of the sibling for documentation / CI parity checking. +# +# The .bin is the same byte image the linker places into CRYPTO_OVERLAY +# under c64-https's main UCI cfg + USE_X25519_SIBLING=1. Producing it +# as a standalone artefact: +# * lets a CI bot diff the in-PRG slot bytes against the .bin to +# detect cfg drift, +# * gives W1 a ready-to-DMA staging image when the cold-path overlay +# wiring lands (REU bank 3, REU_OVERLAY_X25519), +# * documents the sibling's PRG-load-time bytes in `git status`. +# +# Output: +# build/lib/x25519-scalarmult.bin (7,680 B padded) +# build/lib/x25519-scalarmult.sizes.txt +# +# Pad / truncate to exactly $SLOT_BYTES so the .bin matches the live +# UCI CRYPTO_OVERLAY slot. +# ============================================================================= +BIN_OUT="$OUT_DIR/x25519-scalarmult.bin" +SIZES_BIN_OUT="$OUT_DIR/x25519-scalarmult.sizes.txt" +LABELS_BIN_OUT="$PROJECT_ROOT/build/labels-x25519-scalarmult.txt" +MAP_BIN_OUT="$OUT_DIR/x25519-scalarmult.map" +DBG_BIN_OUT="${BIN_OUT%.bin}.dbg" +CFG_BIN="$PROJECT_ROOT/cfg/x25519-overlay-scalarmult.cfg" +SLOT_BYTES=7680 + +if [ ! -f "$CFG_BIN" ]; then + echo "WARN: $CFG_BIN missing -- skipping .bin emission" >&2 +else + LD65="${LD65:-ld65}" + + # ld65 needs the archive members as plain .o files. We already + # have them in $OBJ_DIR from the archive step above -- pass them + # directly. + OBJ_BIN_ARGS=( + "$OBJ_DIR/fe25519_raw.o" + "$OBJ_DIR/x25519_raw.o" + "$OBJ_DIR/x25519_init_raw.o" + "$OBJ_DIR/data_x25519_bss_raw.o" + "$OBJ_DIR/data_x25519_rodata_raw.o" + ) + + # Resolve sibling imports against the main PRG's labels.txt when + # available (mirrors the P-384 overlay .bin script's pattern). + MAIN_LABELS="$PROJECT_ROOT/build/labels.txt" + + bin_lookup_label () { + local name="$1" + local fallback="$2" + if [ ! -f "$MAIN_LABELS" ]; then + echo "$fallback" + return + fi + local hex + hex=$(grep -E " \.${name}\$" "$MAIN_LABELS" | head -n1 | awk '{print $2}' | sed 's|^C:||') + if [ -z "$hex" ]; then + echo "$fallback" + else + printf '$%s' "$hex" + fi + } + + DEF_MUL_8X8=$(bin_lookup_label mul_8x8 '$0000') + DEF_SQTAB_LO=$(bin_lookup_label sqtab_lo '$0000') + DEF_SQTAB_HI=$(bin_lookup_label sqtab_hi '$0000') + DEF_POLY_PROD_LO=$(bin_lookup_label poly_prod_lo '$CFFE') + DEF_POLY_PROD_HI=$(bin_lookup_label poly_prod_hi '$CFFF') + + "$LD65" \ + -C "$CFG_BIN" \ + -o "$BIN_OUT" \ + -Ln "$LABELS_BIN_OUT" \ + -m "$MAP_BIN_OUT" \ + --dbgfile "$DBG_BIN_OUT" \ + --define reu_status=\$df00 \ + --define reu_command=\$df01 \ + --define reu_c64_lo=\$df02 \ + --define reu_c64_hi=\$df03 \ + --define reu_reu_lo=\$df04 \ + --define reu_reu_hi=\$df05 \ + --define reu_reu_bank=\$df06 \ + --define reu_len_lo=\$df07 \ + --define reu_len_hi=\$df08 \ + --define reu_addr_ctrl=\$df0a \ + --define mul_8x8="$DEF_MUL_8X8" \ + --define sqtab_lo="$DEF_SQTAB_LO" \ + --define sqtab_hi="$DEF_SQTAB_HI" \ + --define poly_prod_lo="$DEF_POLY_PROD_LO" \ + --define poly_prod_hi="$DEF_POLY_PROD_HI" \ + "${OBJ_BIN_ARGS[@]}" \ + 2>"$OUT_DIR/x25519-bin-ld.err" \ + || { + # Standalone .bin link is best-effort -- if it fails (e.g. + # missing symbol on a sibling bump), surface a warning but + # don't break the archive build that the main PRG actually + # needs. The W1 follow-on tightens this when the cold-path + # overlay slot lands. + echo "WARN: standalone x25519-scalarmult.bin link failed:" >&2 + cat "$OUT_DIR/x25519-bin-ld.err" >&2 || true + rm -f "$BIN_OUT" + } + + if [ -f "$BIN_OUT" ]; then + # Pad / truncate to exactly $SLOT_BYTES. + truncate -s "$SLOT_BYTES" "$BIN_OUT" + sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' "$LABELS_BIN_OUT" 2>/dev/null || true + + size=$(wc -c < "$BIN_OUT") + { + echo "# x25519-scalarmult overlay image (W3)" + echo "# slot size: $SLOT_BYTES B (\$1E00 -- UCI CRYPTO_OVERLAY)" + echo "# padded .bin: $size B" + } > "$SIZES_BIN_OUT" + + echo "built $BIN_OUT ($size B padded)" + cat "$SIZES_BIN_OUT" + fi +fi