diff --git a/CLAUDE.md b/CLAUDE.md index 51d3e7b..c7f3df3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -331,7 +331,11 @@ Scripts under `tools/uci/` require a U64E (default 192.168.1.81, overridable via the `U64_HOST` environment variable) and use `DeviceLock` + `enable_uci`/`disable_uci`: - - `boot_check.py` — verify UCI firmware detection and boot banner + - `boot_check.py` — boot the PRG and assert the backend banner + (`BACKEND=uci|ip65`, default uci), the + absence of any `FAILED` line, and that the + menu was reached. `C64_PRG` overrides the + image; `BOOT_TIMEOUT` the menu budget. - `phase2_check.py` — DHCP acquire + local IP readback - `phase3_tcp_echo.py` — TCP connect/send/recv against a local echo server - `test_http_local.py` — HTTP GET against a local test server @@ -426,7 +430,9 @@ and writes the 48 B P-384 pubkey into the dedicated 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 +offers exactly one cipher suite, TLS_CHACHA20_POLY1305_SHA256 +(0x1303, `src/tls_handshake.s:85`, echo-verified at :380), whose +hash is SHA-256 — 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 @@ -493,7 +499,12 @@ Five latent bugs and three new ones were cleared to get here: - `net_tcp_set_recv_cb` is an RTS stub (no callers in-tree). - Boot banner line 03 still says "rr-net" under ip65 build even though Phase 2 made it backend-aware — this is correct/expected - behavior. Under UCI it says "ULTIMATE 64 ELITE (UCI)". + behavior. Under UCI it says "UCI NETWORKING". Those two strings + are the whole of `net_banner_str` + (`src/net/ip65/net_banner.s` / `src/net/uci/net.s`), and + `tools/uci/boot_check.py` asserts against them, so keep the two + in step. (This entry used to claim the UCI line read + "ULTIMATE 64 ELITE (UCI)" — it never did.) - The delay-loop fence adds ~2.5 ms overhead per UCI register access at 1 MHz (negligible for networking, but visible in tight loops). - `http_resp_buf` is rendered through `ascii_chrout` (a small @@ -576,6 +587,12 @@ Five latent bugs and three new ones were cleared to get here: spelling out `ViceConfig(extra_args=["-reu", "-reusize", "512"])` by hand. The UCI path is unaffected because the U64E hardware has REU enabled by default; the symptom was VICE-only. + The single deliberate exception is `C64_VICE_NO_REU=1`, which makes + `default_vice_config()` drop the REU flags (and say so on stderr). + It exists so the shipped onchip PRG's "no REU required" claim has a + runnable test — see the packaging validation record for the exact + invocation. Never set it for a REU-profile build: that is precisely + the silent-garbage case above. ### ECDSA P-256 verify wall-clock @@ -1055,6 +1072,19 @@ Validation record (2026-07-27, HEAD cb6eab4): (and with, as control) — the no-REU claim is verified, and boot.s's unconditional reu_mul_init is harmless with no REU attached. Both D64 files boot to banner in VICE. + Reproduce it with the `C64_VICE_NO_REU` opt-out (no patching, and + `-reu` stays the default everywhere else): + + make clean && make BACKEND=uci USE_NISTCURVES_ONCHIP=1 + C64_SKIP_BUILD=1 C64_VICE_NO_REU=1 \ + python3 tools/test_ecdsa_kat_oracle.py # 3/3, exit 0 + C64_SKIP_BUILD=1 python3 tools/test_ecdsa_kat_oracle.py + # control, 3/3 + + The flag is only meaningful on an onchip image. Run it against a + REU-profile build and all three valid vectors verify as C=1 with + no error message — that silent-wrong-answer failure mode is why + `-reu` is the default (see "VICE harness gotcha"). - Full shipped chain (zip listener + freshly generated certs + sha-verified dist PRGs, `EXTERNAL_LISTENER=1`), all HTTP 200 + canonical body over TLS_CHACHA20_POLY1305_SHA256: diff --git a/src/crypto/ecdsa_verify_384.s b/src/crypto/ecdsa_verify_384.s index 934abf3..1c66c22 100644 --- a/src/crypto/ecdsa_verify_384.s +++ b/src/crypto/ecdsa_verify_384.s @@ -33,9 +33,9 @@ ; ; 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 +; suite's hash function — c64-https offers exactly one suite, +; TLS_CHACHA20_POLY1305_SHA256 (0x1303), 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 @@ -49,7 +49,9 @@ ; 6. crypto_swap_to_p384_curve -> ecdsa_verify_384. ; C=0 valid / C=1 invalid -- propagated to caller. ; -; Phase 5 note: c64-https only negotiates TLS_AES_128_GCM_SHA256, so +; Phase 5 note: c64-https offers exactly one cipher suite, +; TLS_CHACHA20_POLY1305_SHA256 (0x1303) — see src/tls_handshake.s:85 +; and the ServerHello echo check at :380 — and its hash is SHA-256, 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 @@ -249,7 +251,7 @@ ecdsa_verify_384_tls: ; [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 + ; (SHA-256 via TLS_CHACHA20_POLY1305_SHA256), independent of the ; signature_algorithm's hash (SHA-384 here). Padding to 48 B ; would feed the verifier a different message than the server ; signed. diff --git a/tools/_vice_helpers.py b/tools/_vice_helpers.py index 2a7ef95..d97be7d 100644 --- a/tools/_vice_helpers.py +++ b/tools/_vice_helpers.py @@ -8,12 +8,37 @@ See user memory ``vice_reu_required_for_p256`` and the project's "VICE harness gotcha" note in ``CLAUDE.md`` for the canonical motivation. + +Opt-in no-REU mode +------------------ +Setting ``C64_VICE_NO_REU=1`` in the environment drops the REU flags, so +the packaging claim "the onchip PRG passes the ECDSA KAT without an REU" +has a runnable test instead of requiring a monkeypatched copy of the +script. It is deliberately opt-in and noisy: a no-REU run of a +*REU-profile* build does not error, it silently computes wrong answers +(a valid signature verifies as C=1). Only use it on +``USE_NISTCURVES_ONCHIP=1`` images. """ from __future__ import annotations +import os +import sys + from c64_test_harness import ViceConfig +#: Environment variable that opts a run out of the mandatory REU flags. +NO_REU_ENV = "C64_VICE_NO_REU" + + +def no_reu_requested(env: dict | None = None) -> bool: + """Return True when the environment opts out of the REU flags. + + :param env: mapping to inspect (defaults to ``os.environ``). + """ + src = os.environ if env is None else env + return str(src.get(NO_REU_ENV, "")).strip().lower() in ("1", "true", "yes", "on") + def default_vice_config( *, @@ -35,6 +60,20 @@ def default_vice_config( options (e.g. ``-warp``, custom monitor flags) without losing the REU enablement. + Setting ``C64_VICE_NO_REU=1`` omits the REU flags (and announces it on + stderr). That mode exists to test the REU-less onchip profile — the + shipped ``c64-https-uci-onchip.prg`` claims "no REU required", and this + is how that claim is reproduced: + + .. code-block:: sh + + make clean && make BACKEND=uci USE_NISTCURVES_ONCHIP=1 + C64_SKIP_BUILD=1 C64_VICE_NO_REU=1 \\ + python3 tools/test_ecdsa_kat_oracle.py + + On any other build the same invocation returns wrong answers without + complaining, which is exactly why REU stays the default. + Remaining keyword arguments are forwarded verbatim to ``ViceConfig``; typical callers pass ``prg_path``, ``warp``, ``ntsc``, ``sound`` etc. @@ -44,7 +83,17 @@ def default_vice_config( :param kwargs: forwarded to :class:`c64_test_harness.ViceConfig`. :returns: a configured ``ViceConfig`` instance. """ - base_args = ["-reu", "-reusize", "512"] + if no_reu_requested(): + print( + f"[{NO_REU_ENV}] VICE launching WITHOUT -reu — valid only for " + "USE_NISTCURVES_ONCHIP builds; any REU-profile image will " + "silently compute wrong results.", + file=sys.stderr, + flush=True, + ) + base_args: list[str] = [] + else: + base_args = ["-reu", "-reusize", "512"] if extra_args: base_args = base_args + list(extra_args) return ViceConfig(extra_args=base_args, **kwargs) diff --git a/tools/package/listener/README.md b/tools/package/listener/README.md index e81ae66..74cf787 100644 --- a/tools/package/listener/README.md +++ b/tools/package/listener/README.md @@ -12,8 +12,8 @@ verbatim so the Commodore 64 client sees exactly what it expects. ## What it does - Serves **TLS 1.3 only** (min = max pinned to TLS 1.3). The C64 advertises - a single cipher suite, `TLS_AES_128_GCM_SHA256`, which the stdlib server - offers among its TLS 1.3 defaults and selects. + a single cipher suite, `TLS_CHACHA20_POLY1305_SHA256` (0x1303), which the + stdlib server offers among its TLS 1.3 defaults and selects. - Presents a self-signed **ECDSA P-256** (`secp256r1`, `ecdsa-with-SHA256`) leaf certificate. The C64 verifies the CertificateVerify signature against this leaf key, so a freshly generated self-signed cert is sufficient — diff --git a/tools/package/listener/listener.py b/tools/package/listener/listener.py index 3c680f0..e0bb33e 100755 --- a/tools/package/listener/listener.py +++ b/tools/package/listener/listener.py @@ -10,8 +10,9 @@ protocol; the C64 client parses a fixed shape): * TLS 1.3 ONLY (``ssl.PROTOCOL_TLS_SERVER`` pinned min = max = TLSv1_3). - The C64 advertises a single cipher suite, TLS_AES_128_GCM_SHA256; the - stdlib server offers it among its TLS 1.3 defaults and picks it. + The C64 advertises a single cipher suite, + TLS_CHACHA20_POLY1305_SHA256 (0x1303); the stdlib server offers it + among its TLS 1.3 defaults and picks it. * ECDSA P-256 leaf cert (auto-generated by gen_certs.py if ./certs is missing). The C64 verifies the CertificateVerify signature against the leaf key, so a fresh self-signed P-256 cert works. diff --git a/tools/uci/boot_check.py b/tools/uci/boot_check.py index f3ca7db..5dae3f6 100644 --- a/tools/uci/boot_check.py +++ b/tools/uci/boot_check.py @@ -4,19 +4,37 @@ Uploads build/c64-https.prg (assumed to have been built with `make BACKEND=uci`) to the U64E (default 192.168.1.81, overridable via -U64_HOST), waits for the PRG to boot, reads screen RAM at $0400 -(40x25 = 1000 bytes), decodes the Commodore screen-code bytes to -ASCII, and prints the non-empty lines. +U64_HOST), waits for the PRG to reach its main menu, reads screen RAM at +$0400 (40x25 = 1000 bytes), decodes the Commodore screen-code bytes to +ASCII, and asserts the boot actually succeeded. -Pass criterion: screen contains printable text (not a uniform field -of spaces or garbage). This only verifies the PRG loads and runs on -real hardware — no UCI commands are exercised. +Pass criteria (all must hold): + + 1. The PRG image on disk carries the *expected backend's* banner string + (checked before the device is touched — catches a stale + `build/c64-https.prg` from a different BACKEND=). + 2. The screen shows the common banner `C64-HTTPS CLIENT V0.1`. + 3. The screen shows the expected backend's network line, and not the + other backend's. + 4. No `FAILED` anywhere on the screen (`NETWORK INIT FAILED`, + `DHCP FAILED`, ...). + 5. The main menu (`Q=QUIT`) was reached, i.e. boot ran to completion. + +The old criterion — "screen has some text and >= 3 distinct byte values" — +only distinguished a booted machine from a blank screen. An ip65/RR-Net +PRG booted on a U64E draws its banner and then `NETWORK INIT FAILED`, and +that criterion returned PASS (audit finding F4). Usage: python3 tools/uci/boot_check.py Environment: - U64_HOST — U64E address (default 192.168.1.81) + U64_HOST — U64E address (default 192.168.1.81) + BACKEND — expected backend, `uci` (default) or `ip65`. The banner + is backend-aware, so the assertion has to know which + build it is checking. + C64_PRG — override the PRG path (default build/c64-https.prg) + BOOT_TIMEOUT — seconds to wait for the main menu (default 60) """ from __future__ import annotations @@ -27,9 +45,29 @@ from c64_test_harness.backends.device_lock import DeviceLock from c64_test_harness.backends.ultimate64_client import Ultimate64Client +from c64_test_harness.uci_network import disable_uci, enable_uci HOST = os.environ.get("U64_HOST", "192.168.1.81") -PRG_PATH = Path(__file__).resolve().parents[2] / "build" / "c64-https.prg" +BACKEND = os.environ.get("BACKEND", "uci").strip().lower() +BOOT_TIMEOUT = float(os.environ.get("BOOT_TIMEOUT", "60")) +PRG_PATH = Path( + os.environ.get( + "C64_PRG", + str(Path(__file__).resolve().parents[2] / "build" / "c64-https.prg"), + ) +) + +# Backend-specific banner line printed by boot.s between the common +# front-matter and the menu (`net_banner_str`): +# src/net/ip65/net_banner.s -> "RR-NET (CS8900A) ETHERNET" +# src/net/uci/net.s -> "UCI NETWORKING" +BACKEND_BANNERS = { + "uci": "UCI NETWORKING", + "ip65": "RR-NET (CS8900A) ETHERNET", +} + +COMMON_BANNER = "C64-HTTPS CLIENT V0.1" +MENU_MARKER = "Q=QUIT" # Commodore screen-code -> ASCII (uppercase/graphics mode, codes $00-$3F @@ -60,7 +98,114 @@ def decode_screen(mem: bytes) -> list[str]: return lines +def screen_text(lines: list[str]) -> str: + """Join the decoded rows into one uppercase haystack. + + Rows are joined with a space rather than concatenated so a string can + never be manufactured across a row boundary. + """ + return " ".join(lines).upper() + + +def evaluate_screen(lines: list[str], backend: str) -> list[tuple[str, bool, str]]: + """Return [(check name, ok, detail)] for a decoded screen. + + Pure function — no device access — so it can be exercised against a + captured screen dump. + """ + text = screen_text(lines) + expected = BACKEND_BANNERS[backend] + others = [v for k, v in BACKEND_BANNERS.items() if k != backend] + + results: list[tuple[str, bool, str]] = [] + + results.append( + ( + "common banner", + COMMON_BANNER in text, + f"expected {COMMON_BANNER!r}", + ) + ) + results.append( + ( + f"{backend} backend banner", + expected in text, + f"expected {expected!r}", + ) + ) + wrong = [o for o in others if o in text] + results.append( + ( + "no foreign backend banner", + not wrong, + f"found {wrong!r} — wrong-backend PRG?" if wrong else "none present", + ) + ) + results.append( + ( + "no FAILED on screen", + "FAILED" not in text, + "screen reports a failure" + if "FAILED" in text + else "no failure message", + ) + ) + results.append( + ( + "main menu reached", + MENU_MARKER in text, + f"expected {MENU_MARKER!r}", + ) + ) + return results + + +def check_prg_image(prg: bytes, backend: str) -> list[tuple[str, bool, str]]: + """Verify the PRG on disk was built for the expected backend. + + `net_banner_str` sits in RODATA as plain ASCII, so the built image is + self-identifying. This runs before the device is touched: a stale + artifact from a different `BACKEND=` is caught without burning a + hardware slot. + """ + expected = BACKEND_BANNERS[backend].encode("ascii") + others = [ + (k, v.encode("ascii")) for k, v in BACKEND_BANNERS.items() if k != backend + ] + results = [ + ( + f"image carries {backend} banner", + expected in prg, + f"expected bytes {BACKEND_BANNERS[backend]!r} in the PRG", + ) + ] + found = [k for k, v in others if v in prg] + results.append( + ( + "image free of foreign banner", + not found, + f"image looks like a {found!r} build" if found else "none present", + ) + ) + return results + + +def report(results: list[tuple[str, bool, str]]) -> bool: + ok = True + for name, passed, detail in results: + print(f" [{'PASS' if passed else 'FAIL'}] {name}: {detail}") + ok = ok and passed + return ok + + def main() -> int: + if BACKEND not in BACKEND_BANNERS: + print( + f"ERROR: BACKEND={BACKEND!r} unknown; expected one of " + f"{sorted(BACKEND_BANNERS)}", + file=sys.stderr, + ) + return 2 if not PRG_PATH.is_file(): print(f"ERROR: PRG not found at {PRG_PATH}", file=sys.stderr) print("Run: make BACKEND=uci clean && make BACKEND=uci", file=sys.stderr) @@ -68,62 +213,91 @@ def main() -> int: prg = PRG_PATH.read_bytes() print(f"Loaded {len(prg)} bytes from {PRG_PATH}") + print(f"Expected backend: {BACKEND} ({BACKEND_BANNERS[BACKEND]!r})") + + print("\n--- PRG image checks ---") + image_ok = report(check_prg_image(prg, BACKEND)) + if not image_ok: + print( + "WARNING: the PRG does not look like a " + f"{BACKEND} build — running it anyway so the on-device " + "verdict is recorded too.", + file=sys.stderr, + ) 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})") + print(f"\nAcquired DeviceLock({HOST})") + client: Ultimate64Client | None = None + uci_enabled = False try: client = Ultimate64Client(host=HOST, timeout=15.0) + if BACKEND == "uci": + # Without enable_uci the $DF1D identifier register never + # answers $C9, so net_init reports UCI_ERR_NOT_PRESENT and the + # banner ends in NETWORK INIT FAILED. + print("Enabling UCI (Command Interface)...") + enable_uci(client) + uci_enabled = True + print("Resetting machine...") client.reset() time.sleep(2.5) # let KERNAL boot print("run_prg(PRG)...") client.run_prg(prg) - time.sleep(3.0) # let the PRG boot, draw its banner - print("Reading screen RAM at $0400 (1000 bytes)...") - mem = client.read_mem(0x0400, 1000) + # Boot does entropy + sqtab + reu_mul_init (~15-18 s on the U64E) + # before do_net_init and the menu, so poll for the menu rather + # than guessing a sleep. + print(f"Waiting up to {BOOT_TIMEOUT:.0f}s for the main menu...") + deadline = time.monotonic() + BOOT_TIMEOUT + mem = b"" + lines: list[str] = [] + while True: + mem = bytes(client.read_mem(0x0400, 1000)) + lines = decode_screen(mem) + if MENU_MARKER in screen_text(lines): + print(" main menu reached") + break + if time.monotonic() >= deadline: + print(" main menu never appeared within the budget") + break + time.sleep(2.0) + if len(mem) != 1000: print( f"WARNING: read_mem returned {len(mem)} bytes, expected 1000", file=sys.stderr, ) - lines = decode_screen(mem) print("\n--- screen RAM decoded (non-empty lines) ---") - any_text = False for i, line in enumerate(lines): if line.strip(): - any_text = True print(f"{i:02d}: {line}") print("--- end screen ---\n") - # Sanity: non-uniform bytes, and contains at least one printable letter - unique = len(set(mem)) - has_text = any( - (0x01 <= (b & 0x7F) <= 0x1A) or (0x21 <= (b & 0x7F) <= 0x3F) - for b in mem - ) - print(f"Unique screen bytes: {unique}") - print(f"Contains printable text: {has_text}") - if not any_text: - print("FAIL: screen RAM decoded to nothing printable", file=sys.stderr) - return 1 - if unique < 3: - print( - f"FAIL: screen looks uniform ({unique} unique bytes)", - file=sys.stderr, - ) - return 1 - print("PASS: PRG booted and drew a banner") - return 0 + print("--- boot checks ---") + screen_ok = report(evaluate_screen(lines, BACKEND)) + + if image_ok and screen_ok: + print(f"\nPASS: {BACKEND} PRG booted cleanly to the menu") + return 0 + print(f"\nFAIL: boot check failed for expected backend {BACKEND}", + file=sys.stderr) + return 1 finally: + if uci_enabled and client is not None: + print("Disabling UCI...") + try: + disable_uci(client) + except Exception as exc: # pragma: no cover - diagnostics only + print(f"WARNING: disable_uci failed: {exc}") lock.release() print(f"Released DeviceLock({HOST})")