diff --git a/.gitignore b/.gitignore index 3b2c97c..873a02e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ tools/https_e2e/certs/* tools/diag_4de0_*.py tools/diag_read_live.py .serena/ +.ca65-ls/ +.mcp.json diff --git a/CLAUDE.md b/CLAUDE.md index c9aa2ce..746e6b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,31 @@ Dependencies: - GNU make - VICE (`x64sc`) only for `make run` / the test harness +First build in a fresh clone or worktree (ip65 backend only — the UCI +backend needs none of this): + + git submodule update --init --recursive + make ip65-libs # once per clone + make + +`ip65-build/ip65-c64.bin` is a **gitignored local build artifact** +(`.gitignore` line `ip65-build/*.bin`; `git ls-files ip65-build/` returns +only `ip65.cfg` and `ip65_stub.s`), *not* a committed file. A plain `make` +does try to build it, but the link step consumes ip65 `.lib` archives that +the submodule does not ship — it ships the sources for them — so without +`make ip65-libs` first it dies with: + + ld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not found + +`make clean` only removes `build/`, so once built the blob survives and is +never rebuilt; that persistence, not a committed file, is why the rebuild +targets are normally invisible. The rebuild is deterministic: 6,951 B, +sha256 `cf1a5ff7809af4e4655e385b378b936054f41046ff2b7604828af3240c2d90dd` +— rebuilt byte-identically in three independent worktrees on 2026-08-13, +and identical to a local copy built 2026-05-06. Three months and four +artifacts agree, so a stale blob is not a failure mode worth designing +around; a missing one is. + Targets: - `make` — default, produces `build/c64-https.prg`, `build/labels.txt` (VICE label format), and @@ -23,17 +48,70 @@ Targets: 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 - (only needed if the ip65 submodule changes) + - `make ip65-libs` — build ip65's object libraries from the + submodule. Required once per fresh clone (see + above), and again whenever the ip65 submodule + changes. - `make ip65-blob` — rebuild `ip65-build/ip65-c64.bin` from those - libraries (the committed blob is normally reused) + libraries. A plain `make` already builds the + blob on demand and then reuses it, so this + target is only for forcing a rebuild. + +**`make clean` when you change `BACKEND=` or any flag.** make tracks +source timestamps, not the command line, so an object built for the +other backend counts as up to date. This is not only about `-D` flags: +`BACKEND=` also selects the `-I src/net/$(BACKEND)` include path, and +`src/tls13.s` pulls `net_tuning.inc` from there. Both failure modes were +observed in one worktree on 2026-08-13: + + - **Mixed link.** An ip65 PRG built from a UCI-compiled `tls13.o` + carries drain budget 1x16 instead of 8x250 — issue #73's regression, + silently reintroduced. Same 47,105 B as the clean image; only the + content differs (`d483d46f…` vs the correct `db311110…`), and the + build output is a bare `ld65` line. + - **No link at all.** macOS ships **GNU Make 3.81**, which compares + mtimes at 1-second resolution. Objects recompiled inside the same + second as the previous link count as older (measured: 39 ms newer, + make said "Prerequisite ... is older than target"), so `make` + exits 0 having left the *other backend's* PRG in place — a + 62,977 B UCI image where an ip65 build was asked for. + +So neither exit code nor file size distinguishes a good build from a bad +one here. After any flag or `BACKEND` change, `make clean`; if a build +matters, check the **PRG's** sha256. + +Specifically the PRG's, not an object's: **ca65 stamps the build's +wall-clock time into every `.o` header**, so two clean builds of +identical source produce different object hashes and a `.o` hash is not +evidence of anything. `ld65` does not propagate that field, so the PRG +*is* deterministic: `build/c64-https.prg` held at `db31111031e2…` across +every rebuild while every `.o` changed hash each time. That asymmetry is +what makes PRG-hash comparison a usable check — a property of the +toolchain, not a convention. + +No byte offset is quoted here on purpose: it is a cc65-version detail, +and three people reading three different offsets out of the same effect +is how a checkable finding turns into a disputed one. The reproduction +is `make clean && make` twice and comparing hashes, which holds whatever +the layout. + +Fresh-checkout gotcha: right after `git submodule update --init ip65`, +plain `make` tries to *relink the blob* — the freshly checked-out +`ip65-build/ip65_stub.s` is newer than the committed +`ip65-build/ip65-c64.bin`, so the `$(IP65_BIN)` rule fires and dies on +`ld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not found`. +`touch ip65-build/ip65-c64.bin` restores the intended "committed blob +is reused" path; `make ip65-libs` is the alternative if you actually +want to rebuild it. Variables: - `BACKEND=ip65|uci` — select networking backend cfg - (`cfg/c64-https-$(BACKEND).cfg`; default ip65) + (`cfg/c64-https-$(BACKEND).cfg`; default ip65). + Changing it requires `make clean` — see above. - `USE_X25519_SIBLING=1` — swap the in-tree X25519 for the - `libs/x25519@v0.6.0` sibling (UCI only — ip65 - has a tracked BSS overflow; see "Known issues") + `libs/x25519@v0.6.0` sibling. **Does not link + on either backend at present** — both + overflow, differently; see "Known issues" - `EMBED_P256_OVERLAY=1` — stage the P-256 verify image into the CRYPTO_OVERLAY slot at PRG-load (UCI; mutually exclusive with USE_X25519_SIBLING / @@ -59,8 +137,10 @@ Variables: Test harness expectations: - Most `tools/test_*.py` scripts run `make clean && make` themselves before launching VICE. Set `C64_SKIP_BUILD=1` in the environment to - reuse the already-built PRG (7 scripts currently honor the var — - see the "Honor C64_SKIP_BUILD" commit for the list). + reuse the already-built PRG. 14 scripts honor it as of 2026-08-13 + (13 under `tools/`, plus `tests/test_vice_https_macos.py`); the + current list is `grep -ln 'environ.*C64_SKIP_BUILD' tools/test_*.py + tests/test_*.py` rather than a number that goes stale here. - Use the `c64-test-harness` Python package to launch VICE; never run `x64sc` directly from tests. @@ -79,7 +159,9 @@ 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.6.0` via `make USE_X25519_SIBLING=1` - (UCI backend only — see Known issues for the ip65 fit limitation). + — **currently unbuildable on both backends**, and the pinned + v0.6.0 additionally carries an upstream correctness bug; see + Known issues before relying on either fact. The v0.6.0 pin is c64-lib-contract-aligned (SPEC §8.1) and adds the bank-2 drop + RAM-reclaim work; older v0.4.0 pin is historical only. Sibling and in-tree both expose the same ABI: @@ -94,7 +176,7 @@ 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@v0.3.0` sibling, + ECDSA P-256 (`libs/nistcurves@v0.6.0` sibling, c64-lib-contract SPEC §1-§8.1 aligned) ecdsa_verify_256 — TLS dispatcher in src/crypto/ecdsa_verify.s packs the BE struct + calls the sibling entry @@ -119,12 +201,30 @@ below for the post-W1 hot/cold split): - Large BSS (page-aligned tables etc.) lands in `CRYPTO_COLD_SHADOW` at **$A000-$BFFF** (file-backed zero-fill, CPU port $01 = $36 selects RAM under BASIC ROM). - - Sibling-library segments follow the c64-lib-contract SPEC §8.1 + - Sibling-library segments follow the c64-lib-contract SPEC §4 naming (`LIB_NISTCURVES_P256_CODE`, `LIB_NISTCURVES_P256_RODATA`, `LIB_NISTCURVES_P256_BSS`, etc.); the consumer cfg places them by - name. See [c64-lib-contract](https://github.com/JC-000/c64-lib-contract) + name. (§8.1 is the shared `sqtab` table, a different clause — the + old §8.1 citation here was a miscite.) See + [c64-lib-contract](https://github.com/JC-000/c64-lib-contract) for the contract spec and `docs/library-ingestion-architecture.md` for the c64-https rollout plan. + - **Read `SPEC.md` on `main`, not the latest git tag.** The contract's + tags lag badly: newest tag is v0.4.0 while `main` is **v0.7.2** + (checked 2026-08-13). Sections added since v0.4.0 that bind a + *consumer* rather than an adopter: **§13 Network backend ABI** + (v0.6.0 — written from c64-https's own net surface; our intake + issue [#70](https://github.com/JC-000/c64-https/issues/70) is + OPEN), **§8.0 three-state shared-primitive semantics + + `LIB__SHARED_CONSUMES`** with a consumer-side coverage assert + (v0.5.0; c64-https is the `APP_OWNED` case — `src/boot.s` + `reu_mul_init`, `src/crypto/shared/mul_tables.s` `mul_tables_init`), + and **§1/§5 library-prefixed manifest exports** gated on + `ca65 -D LIB_NO_BARE_EXPORTS=1` (v0.7.0), which is the sanctioned + replacement for the hand-dropped `lib_version.o` workaround in + `tools/integration/build_x25519.sh`. c64-https imports **no** + contract manifest equate today, so none of these are enforced here + yet. - 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). @@ -330,12 +430,22 @@ 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 - `test_http_live.py` — HTTP GET against a real internet host (requires internet access from the U64E) + - `test_https_bad_finished.py` — the client must ABORT on a forged server + Finished. Uses the hand-rolled + `tools/https_e2e/evil_listener.py` rather than + stock `ssl`. `FINISHED_MODE=good` is the control + and must be run first. See "Negative-path + coverage — the server Finished" under Smoke tests. - `test_https_local.py` — HTTPS e2e scaffolding against a local TLS 1.3 listener (ECDSA-P256 cert from `tools/https_e2e/certs/`). DMAs a 6502 stub @@ -425,7 +535,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 @@ -492,7 +604,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 @@ -518,15 +635,79 @@ Five latent bugs and three new ones were cleared to get here: "Memory layout" below). Earlier-pin caveats (Phase C.1 hang, v0.3.0 retry rollback, v0.4.0 H2 defensive REU re-inits) are all superseded by v0.6.0; the file-level history lives in the c64-x25519 - repo's CHANGELOG. **ip65 backend currently overflows - `LIB_NISTCURVES_P256_BSS` placement into CRYPTO_COLD_SHADOW by - 1,662 bytes** when the bumped library is linked under ip65 (the W1 - hot/cold split closed the analogous UCI-side gap but ip65's blob is - larger, so it stays at limit); fix is tracked at - [c64-nist-curves#54](https://github.com/JC-000/c64-nist-curves/issues/54) - (minimal-archive split). UCI is the supported sibling-on path - today. See `tools/integration/build_x25519.sh` for the - `make -C libs/x25519 lib-x25519-scalarmult` wrapper. + repo's release notes. The ip65-side `LIB_NISTCURVES_P256_BSS` + overflow that used to be recorded here was the *default* build's + overflow and is fixed (see the CRYPTO_COLD_SHADOW entry under + "Memory layout"). + + **`USE_X25519_SIBLING=1` links on NEITHER backend** (re-measured + 2026-08-13 at f0127a0, fresh submodules, cc65 from homebrew). The + two failures are different and independent: + + make USE_X25519_SIBLING=1 # ip65 + X25519_RODATA overflows CRYPTO_OVERLAY by 2048 bytes + LIB_NISTCURVES_P256_CODE overflows CRYPTO_RESIDENT by 103 bytes + + make BACKEND=uci USE_X25519_SIBLING=1 # UCI + LIB_NISTCURVES_P256_CODE overflows CRYPTO_HOT by 381 bytes + + make BACKEND=uci # control: links clean + + Earlier revisions of this file called UCI "the supported + sibling-on path"; that was wrong — nothing links the sibling + today, and no shipped artifact contains it. The UCI overflow is + simply the sibling's larger code claim: sibling CRYPTO_CODE is + 4,207 B (fe25519 2,711 + x25519 698 + x25519_init 798) against the + in-tree pair's 2,769 B (fe25519 2,093 + x25519 676), i.e. +1,438 B + into a CRYPTO_HOT with ~1,057 B of slack. ip65 additionally has + only a 4,212 B `CRYPTO_OVERLAY` (vs UCI's 7.5 KB) to hold + `X25519_RODATA` (2,304 B) + `X25519_BSS` (1,536 B) on top of + TLS_CODE + CRYPTO_AUX_CODE. + + **The pinned v0.6.0 also carries an upstream correctness bug.** + c64-x25519 #64 (fixed in v0.7.0): `x25519_scalarmult` returns + deterministically wrong results for a peer u-coordinate with bit + 255 set, across v0.4.0-v0.6.0. v0.4.0 stopped writing the RFC 7748 + `decodeUCoordinate` mask back into `x25_u` but left the ladder's + `z_3 = x_1 * (DA-CB)^2` site reading the unmasked buffer, so + x1 = x3 + 19 (mod p). **The in-tree implementation is NOT + affected** — `src/crypto/x25519.s` still writes the mask back + (`sta x25_u+31`), so its x_1 read sees the masked value; + `tools/test_x25519.py --slow` RFC 7748 vector 2 (whose u ends + `0x93`, bit 255 set) PASSes on the in-tree build, 73/73. + That same vector would catch the sibling bug the moment the + sibling links, so **do not flip the sibling default at the v0.6.0 + pin** — bump to >= v0.7.0 first. + + Upstream is at v0.8.0. A bump is not a one-line submodule move: + v0.8.0 renames `CODE`/`DATA` to `LIB_X25519_CODE`/`LIB_X25519_DATA` + and adds `LIB_X25519_INIT_CODE`, all three of which the consumer + cfgs must declare (`LIB_X25519_INIT_CODE` must be the last + file-emitting segment before any bss-type segment in a file-backed + area), and v0.7.0's #64 fix adds an `x25_x1` buffer that + `build_x25519.sh`'s hand-written BSS stub does not yet export. + v0.8.0 also ships an `X25519_ONCHIP_MUL` profile with no REU + surface at all (`LIB_X25519_REU_BANKS_USED = 0`, no + `reu_fetch_mul_row` export) — which would dissolve the + `USE_NISTCURVES_ONCHIP` / `USE_X25519_SIBLING` mutual exclusion at + `Makefile:90`, whose stated reason is that both archives export + that symbol. Note that v0.8.0's headline + `LIB_X25519_RESIDENT_BYTES` drop (9224 -> 8383) is **not** a + shrink: 826 B of it simply moved to the reclaimable + `LIB_X25519_COLD_BYTES`, and our wrapper stages only three of the + sibling's sources anyway, so upstream manifest deltas do not + subtract from the overflows above. + + `tools/integration/build_x25519.sh` is the integration wrapper. It + does **not** call `make -C libs/x25519` — it stages three sibling + sources, sed-rewrites `.segment "CODE"` to `CRYPTO_CODE`, and + hand-emits the `X25519_RODATA` / `X25519_BSS` data modules. (An + earlier revision of this file pointed at a + `make -C libs/x25519 lib-x25519-scalarmult` target; no such target + exists upstream at any tag. The real targets are `lib`, + `lib-verify`, `lib-x25519-1764`, and — from v0.8.0 — + `lib-x25519-onchip`.) Both the sed rewrite and the hand-written + data modules are what a version bump has to be migrated through. - **CRYPTO_OVERLAY collisions are now caught by MemoryPolicy.** All `tools/uci/*.py` test scripts derive their scratch DMA addresses from a `MemoryArbiter` backed by a c64-https-aware `MemoryPolicy` @@ -538,20 +719,54 @@ Five latent bugs and three new ones were cleared to get here: rather than hardcoding addresses. The migration left `unknown_policy=WARN` so writes outside declared segments surface as `UserWarning`; tightening to `DENY` is a follow-up. - - **All P-384 build targets are broken at the v0.6.0 pin** - (verified 2026-07-26): both `make p384-overlay` and `make - BACKEND=uci USE_OVERLAY_P384_EMBED=1` fail in - `tools/integration/build_nistcurves_p384.sh` at the `ar65` - staging step (`nistcurves_p384_staging/curve/ecdsa384.o` never - produced — the upstream layout drifted under the v0.5.0/v0.6.0 - bumps). Behind that likely still lurk the earlier v0.3.0-era - SHA-384 LUT overlay overflow (1536 B over the 7.5 KB slot) and - the historical `ec_base384_x/y` unresolved-symbol bug — neither - is reachable until the wrapper is fixed. The target has never - built cleanly. Issues #32 and #45 were closed as stale on this - basis; file fresh issues against the current failure chain when - P-384 enablement resumes. TLS-level P-384 verify remains stubbed - regardless (see `project_p384_stubbed`). + - **P-384 build targets are still broken, but one link fewer.** + The `ar65` staging failure (`nistcurves_p384_staging/curve/ + ecdsa384.o` never produced) was **ours, not upstream's**: + `build_nistcurves_p384.sh` hardcoded the archive member name + `ecdsa384.o`, while upstream renamed it to `ecdsa384_nocomb.o` in + commit `64b313d` (c64-nist-curves issue #61), released in + **v0.5.0** — i.e. before our own v0.6.0 pin, and unchanged by + v0.7.0/v0.8.0. Upstream's `Makefile` builds every P-384 archive + from `LIB_P384_VERIFY_OBJS = ... $(BUILD_DIR)/ecdsa384_nocomb.o`. + Fixed 2026-08-13; both `nistcurves-p384-sha384.a` and + `nistcurves-p384-curve.a` now build. The chain then hits the + **next** blocker, which is exactly the one predicted here: + `LIB_NISTCURVES_SHA384_TABLES` overflows `OVERLAY_REGION` by + **1536 B** (`cfg/p384-overlay-sha384.cfg(29)`; the slot is + $4200-$5FFF = 7,680 B). A third, independent defect blocks the + embed variant: `make BACKEND=uci USE_OVERLAY_P384_EMBED=1` from + clean dies with `No rule to make target 'build/labels.txt'` — + the overlay rules take an order-only `| build/labels.txt` but + that file is only a side effect of the main link, whose bootstrap + rule is gated off under this flag. Also now dead: the wrapper's + `ec_scalar_mul_384_shim` — `od65 --dump-imports` shows + `ecdsa384_nocomb.o` imports `ec_scalar_mul_var_384`, not + `ec_scalar_mul_384`, so the `ECDSA_NO_COMB` variant already does + the variable-base fallback the shim was written to supply (it is + never pulled, so it is harmless; retire it with the next P-384 + pass). No P-384 target has ever built cleanly end-to-end. Issues + #32 and #45 were closed as stale; file fresh issues against this + chain when P-384 enablement resumes. TLS-level P-384 verify + remains stubbed regardless (see `project_p384_stubbed`). + - **A `libs/nistcurves` bump to v0.7.0/v0.8.0 does not link under + UCI** (measured 2026-08-13). `LIB_NISTCURVES_P256_RODATA` + overflows `CRYPTO_HOT` by **207 B**, because CRYPTO_HOT is + already *one byte* from full at the v0.6.0 pin (`build/ + c64-https.map`: rodata `009E1F..009FFE`, region ends `$9FFF`) and + v0.7.0's FIPS 186-5 §3.3 public-key validation gate adds +512 B. + ip65 links fine at v0.8.0, and both `tools/test_ecdsa_kat_oracle.py` + (3/3) and `tools/test_x509.py` (11/11) pass there — so the blocker + is placement, not function. A link-verified remedy exists (route + `cfg/c64-https-uci.cfg` `LIB_NISTCURVES_P256_RODATA` to the + otherwise-unused `CRYPTO_OVERLAY`; v0.8.0 UCI then links at the + same 62,977 B with 7,200 B of the slot still free) but it has had + no hardware run and it changes what `tools/uci/_memory_policy.py` + sees at $4200-$5FFF, so it needs a UCI e2e before it ships. The + bump is worth taking eventually: the gate validates the + attacker-supplied certificate public key that `src/tls_cert.s` + feeds to `ecdsa_verify_256`, which c64-https does not check + itself. No export was renamed or removed in v0.7.0/v0.8.0 — + `LIB_ABI_VERSION` is still 0. - **VICE harness gotcha**: any test that exercises sibling `libs/nistcurves` P-256 primitives (`fp_mul`, `fp_inv`, `ec_scalar_mul_var`, `ecdsa_verify_256`, ...) MUST launch VICE with @@ -570,6 +785,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 @@ -592,11 +813,14 @@ a verify-path bug — see "VICE harness gotcha" in the Known issues list. With `-reu` enabled, `tools/test_x509.py` 3c PASSes cleanly in ~60 s wall-clock under VICE warp. -Under the current `libs/nistcurves@v0.3.0` pin (post-PR #55, -c64-lib-contract-aligned) the U64E 48 MHz handshake measures **82.1 s** +Under the then-current `libs/nistcurves@v0.3.0` pin (post-PR #55, +c64-lib-contract-aligned; the pin is v0.6.0 today) the U64E 48 MHz +handshake measured **82.1 s** end-to-end (verified 2026-05-20 against the local listener; the prior v0.2.0 measurement was 86.7 s, and the pre-Phase-C.4 in-tree path was -~110 s). +~110 s). The pin has since moved to **v0.6.0** — the tables below are +the current numbers; the v0.3.0 row is kept only as the REU-profile +baseline. On the **C64 Ultimate** (10.53.21.158, see "C64 Ultimate notes"), measured 2026-07-19 with the INNER=217 fence and boot-at-speed flow: @@ -645,13 +869,82 @@ C64U, fits T(f)=D+C/f, residuals <=4.1%): v0.6.0 onchip 51.0 s 39.7 s v0.6.0 onchip+comb 38.4 s **31.0 s** + Those rows are the 2026-07-20 campaign state. **Current HEAD is + faster** — see "Post-#74 e2e numbers" below; the onchip rows in + particular improved by ~6 s once #69 landed, because on-chip + fe25519 rows beat wall-clock-anchored REU DMA above the crossover. + 31.0 s @ 64 MHz sits at the top edge of a typical 10-30 s internet-server handshake window — the first configuration where a real-server TLS connection is plausible. Remaining spend: ~12.4 s verify + ~18.6 s of everything else (X25519, SHA-256 transcript+HMACs, record I/O, UCI firmware/network latency) — the non-verify side is now the bigger half and the next - profiling target. + profiling target. (Comb has not been re-measured post-#69/#74; + extrapolating the ~6 s onchip gain and the ~0.65 s residual drain + it should land meaningfully under 31 s — worth confirming.) + +#### Post-#74 e2e numbers (2026-07-29, HEAD) + +Two merged changes moved these numbers in opposite directions, so the +2026-07-20 rows above are no longer HEAD: + + - **#69** (`USE_NISTCURVES_ONCHIP` fe25519 rows via `og_common`) is a + **speedup at turbo**, not a cost. It is easy to get the sign wrong: + the profile carries a large penalty at 1 MHz, but only because REU + DMA is cheap relative to the CPU down there. On-chip generation + scales with the clock while REU DMA stays anchored to the ~1 MHz + bus, so above the crossover it wins — the same mechanism behind + FP_ONCHIP_MUL's ~22 MHz P-256 crossover. Worth ~6 s at 48-64 MHz. + It only affects onchip builds (the change is inside + `.ifdef USE_NISTCURVES_ONCHIP`), which is what makes the REU rows + below a clean control. + - **#71's post-ServerHello drain**, unconditional at 2000 `net_poll` + calls, cost UCI **~80 s** — see the drain note in "Design note" + below. **#74** made the budget per-backend and restored it. + +Measured end-to-end (handshake + GET, local listener): + + device profile clock pre-#71 post-#71 post-#74 baseline + C64U onchip 48 MHz 51.0 s 125.4 s **44.6 s** 51.0 s + C64U onchip 64 MHz 39.7 s (unmeas.) **33.7 s** 39.7 s + U64E REU 48 MHz 82.1 s 161.0 s **82.1 s** 82.1 s + + - Both onchip rows land **below** their pre-regression baselines + (-6.4 s @48, -6.0 s @64) — that is #69. + - The REU row lands **exactly at** baseline, because a REU build + cannot contain #69. Two profiles, one with the change and one + without, behaving as the model predicts: this is the cleanest + confirmation of #69's sign we have. + - Drain cost cross-checks to a clock- and device-invariant + **~40 ms per UCI `net_poll`** from three directions: 80.8 s/1984 + polls (C64U onchip), 78.9 s/1984 (U64E REU), and an independent + per-poll derivation. See `uci_net_poll_cost` in memory. + +#### ip65 / stock-C64 wall-clock (hardware-free VICE rig) + +First measurements of the ip65 backend end-to-end, from the macOS +feth/pcap rig (see "VICE ip65 rig" under Smoke tests). These are the +**REU-less stock-C64 story** — no REU, no turbo, RR-Net networking: + + build mode G -> CONNECTION CLOSED + ip65 + onchip, no REU honest 1 MHz **2,159.7 s (36.0 min)** + ip65 + onchip, no REU ~1.2x accelerated 1,813.9 s + ip65 + REU profile ~1.2x accelerated 988.9 s + + Honest-1 MHz phase breakdown (seconds after 'G'): + TCP CONNECTED 3.0 | CH 329.2 | SH 700.7 | PROC 718.9 | + FIN 2,135.5 | REQUEST SENT 2,153.6 | CLOSED 2,159.7 + + - The verify stretch measured **1,416.7 s** against the v0.6.0 + onchip fit's 1,397 s prediction (+1.4%) — the T(f)=D+C/f model + holds at 1 MHz, three orders of magnitude from where it was fit. + - X25519 scalarmults measured 326 s / ~356 s vs ~324 s analytical. + - ip65's drain budget is **unchanged by #74** (the ip65 PRG is + byte-identical across it), so these numbers stand at HEAD. + - VICE 3.10 SDL2 has no usable runtime warp: its `Speed` resource + caps at ~1.2x and `WarpMode` is gone, so "accelerated" runs are + only ~1.2x. Divide accelerated figures by ~1.2 for honest 1 MHz. **U64E lane (2026-07-25)** — same sweep protocol on the U64E (10.43.23.81), 16/32/48 MHz only (no 64 MHz enum on the U64E), all @@ -698,9 +991,10 @@ here as a submodule bump without touching TLS call sites. ### ECDSA P-384 verify wall-clock -Not yet measured end-to-end, and currently UNMEASURABLE: the P-384 -embed build does not build at the v0.6.0 pin (fails in the sibling -wrapper's `ar65` staging step — see "Known issues"), so +Not yet measured end-to-end, and still UNMEASURABLE: the `ar65` +staging failure is fixed, but the P-384 build now stops on the +SHA-384 overlay table overflow (and the embed variant on a separate +`build/labels.txt` ordering defect) — see "Known issues", so `tools/uci/test_https_local_p384.py` has no P-384 PRG to run and would just boot the default P-256 image. The May-2026 hw attempts that predate the build breakage died at EncryptedExtensions decrypt @@ -774,6 +1068,47 @@ 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. +### Design note — the post-ServerHello drain, and why its budget is per-backend + +`tls13.s` drains the network right after parsing ServerHello, before +the multi-minute ECDHE + verify stalls. It exists because of an ip65 +property: **ip65 sends no MSS option in its SYN** (so peers may segment +small — macOS defaults to 512 B, splitting the ~690 B server flight) +**and ACKs only when the consumer pumps `net_poll`**. Without the +drain, the flight tail sits unACKed while the C64 computes, and an +impatient peer drops the connection (macOS: hard drop after 13 +retransmits, ~54 s on a LAN). The failure is nasty to diagnose: the +C64 goes on to verify the *entire buffered flight* correctly, offline, +and only dies minutes later when it sends client Finished into a +socket that was RST long ago (fingerprint: `tls_state=$FF`, +`tls_read_seq=4`). Linux servers hid it (15-30 min of retransmits) and +UCI hid it (firmware ACKs autonomously); **real internet servers sit +between those**, so this is a prerequisite for any real-server story. + +The budget lives in a per-backend `src/net//net_tuning.inc` +(`NET_SH_DRAIN_OUTER/INNER`), resolved through the existing +`-I src/net/$(BACKEND)` include path so `tls13.s` stays +backend-agnostic. This is load-bearing, not tidiness: **an ip65 +`net_poll` is a cheap NIC pump, but a UCI `net_poll` is a full +firmware command round-trip** (SOCKET_READ + waits + drains + ack, +~25 fenced register accesses plus FPGA turnaround) measured at +**~40 ms**, of which only ~3 ms is fence time — the rest is +clock-invariant, so turbo does not amortize it. Shipping ip65's +2000-poll budget unconditionally cost UCI ~80 s and regressed the +handshake 51.0 s -> 125.4 s (#73, fixed by #74). Current values: +ip65 8x250 = 2000 (validated; do not shrink without re-running the +VICE e2e), UCI 1x16 = 16 (~0.6 s hedge — the drain has nothing to buy +where firmware ACKs on its own; keep it non-zero, since the loop's +`dex`/`bne` shape turns an INNER of 0 into 256 iterations). + +Two follow-ups are open. Flights larger than the TCP window need +polling *inside* the long crypto, not just before it — that is the +real-server cert-chain case. And the principled version of this +bound is wall-clock/idle-based (poll until the ring stops growing) +rather than iteration-counted, which is exactly what the rule above +says; it needs care around CIA1 TOD latch interaction with the UCI +adapter's own TOD waits. + ## Memory layout Defined in `cfg/c64-https-ip65.cfg` (W1 partial split) and @@ -840,14 +1175,27 @@ Tight regions (post-W1): Under UCI the W1 split moved big BSS into CRYPTO_COLD_SHADOW, opening enough slack to absorb the v0.3.0 nistcurves bump cleanly. - **CRYPTO_COLD_SHADOW** ($A000-$BFFF, 8 KB) holds the bulk of BSS. - Under ip65 the total c64-https + libs/nistcurves BSS claim exceeds - 8 KB by 1,662 B; ld65 surfaces this at link time as a `BSS overflows - CRYPTO_COLD_SHADOW by 1662 bytes` warning. Cfg-only relief is - exhausted under the bumped library — resolution requires a - library-side minimal-archive variant, tracked at - [c64-nist-curves#54](https://github.com/JC-000/c64-nist-curves/issues/54). - UCI builds clean; ip65 still builds clean today only when the - sibling X25519 flag is off (which is the default). + Under ip65 the total c64-https + libs/nistcurves BSS claim exceeded + 8 KB, and ld65 refused the link (`BSS overflows CRYPTO_COLD_SHADOW + by 1406 bytes` at the v0.6.0 pin). **Resolved by the #68 refit**: + the gap was exactly `LIB_NISTCURVES_P256_BSS` (1,312 B of + verify-time-only scratch), which now overlays `cert_buf` through + the `SCRATCH_UNION` region — the two lifetimes are disjoint + (cert_buf is dead once the Certificate handler has extracted the + pubkey; the lib scratch is live only inside `ecdsa_verify`). + `cert_buf` is pinned at $A000 with a link-time `.assert`, the + union is capped at cert_buf's span so future growth is a link + error rather than silent corruption, and `TABLES_BSS` is declared + last so it packs at $BA00 (keeping `sqtab_reserved` at $BC00 for + the onchip bake invariant). Both backends now link clean, and the + union is exercised live by `tools/test_x509.py` 3c/3d and by every + ip65 e2e run. c64-nist-curves#54 (verify-path BSS trim) is + **CLOSED as COMPLETED (2026-07-16)**: the 261 B trim shipped in + commit `7cb59f7` before upstream v0.4.0, so it has been inside our + v0.6.0 pin all along and is not available as future headroom. + `LIB_NISTCURVES_P256_BSS` measures $0520 (1,312 B) at v0.6.0 and + is unchanged at v0.8.0, so the union's cap is not threatened by a + bump. - **CRYPTO_OVERLAY** under UCI doubles as P-384 SHA-384/curve overlay paging slot, the W3 P-256 overlay embed slot, AND the USE_X25519_SIBLING=1 X25519 sibling rodata + BSS slot. Mutually @@ -911,18 +1259,39 @@ Scripts live in `tools/package/` (`build_prgs.sh`, `build_d64.sh`, (flag changes are not tracked by make). Builds are deterministic — `make package` reproduces the validated hashes at the same HEAD. -**ip65 is NOT packaged**: plain `make BACKEND=ip65` does not link at -the current nistcurves pin (`BSS overflows CRYPTO_COLD_SHADOW by 1406 -bytes` — c64-nist-curves#54; error captured to -`dist/ip65-link-error.txt` at package time). The comb profile is also -deliberately excluded (REU bank 2 residency + ~40 min boot precompute -at 1 MHz make it wrong for a general release). +**ip65 is not packaged yet, but it now LINKS.** The historical blocker +(`BSS overflows CRYPTO_COLD_SHADOW by 1406 bytes`) was closed by the +#68 refit — the overflow was exactly `LIB_NISTCURVES_P256_BSS`, which +now time-shares `cert_buf`'s RAM via the `SCRATCH_UNION` region (their +lifetimes are disjoint; see the cfg comment block and the lifetime +contract at `cert_buf` in `src/der_decode.s`). Both ip65 profiles +build, and the REU-less ip65+onchip image is validated end-to-end in +VICE (see "ip65 / stock-C64 wall-clock"). Adding it to `make package` +is a live option — it is the only artifact that serves a stock C64 + +RR-Net cartridge, which today has no shipped PRG at all. Note +c64-nist-curves#54 is CLOSED-as-completed and already inside our pin, +so it is not headroom in reserve. The comb profile stays deliberately excluded (REU +bank 2 residency + ~40 min boot precompute at 1 MHz make it wrong for +a general release). Validation record (2026-07-27, HEAD cb6eab4): - onchip PRG passes the 3-vector ECDSA KAT in VICE **without** REU (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: @@ -951,13 +1320,112 @@ the TLS state machine. For a quick sanity check after a build: - `tools/test_tls_handshake.py` — full handshake state machine - `tools/test_http.py` — HTTP request/response build + parse - `tools/test_x509.py` — X.509 parser + - `tools/test_finished_verify.py` — server-Finished **rejection** path + (18 cases, 2 vector sets; see below) All 7 pass as of the ca65-conversion branch (97/97 assertions). +### Negative-path coverage — the server Finished + +`tools/test_finished_verify.py` and `tools/uci/test_https_bad_finished.py` +exist because an audit found the client's Finished-mismatch abort had **no +test at all**: inverting the mismatch branch (`sec` -> `clc` in +`tls_verify_finished`, `src/tls_keyschedule.s`) left the full hardware e2e +reaching HTTP 200 with the correct body. Every listener the suite talks to +sends a *correct* Finished, so nothing ever exercised the reject. + + - `tools/test_finished_verify.py` (VICE) drives `tls_verify_finished` + directly over DMA with a 6502 carry-latching stub — no P-register read, + and an unwritten latch is reported as inconclusive, never a pass. Two + (secret, transcript) vector sets x 9 cases each, including the two + realistic attacks: a valid HMAC under the wrong secret, and one over the + wrong transcript. + - `tools/uci/test_https_bad_finished.py` (U64E/C64U) is the end-to-end + version, against `tools/https_e2e/evil_listener.py` — a hand-rolled TLS 1.3 + server (real X25519, real key schedule, real ChaCha20-Poly1305 records, + real P-256 CertificateVerify) that flips **one bit** of the server + Finished `verify_data` before encryption. Corrupting the *ciphertext* + instead would break the Poly1305 tag and get rejected at `aead_decrypt`, + never reaching the Finished comparison — which is why stock `ssl` cannot + produce this test case and the server side is written out by hand. + `FINISHED_MODE=good` runs the identical server with a correct Finished and + is the mandatory control; `FINISHED_MODE=bad` (default) is the test. + The oracle uses `tls_last_state`, which `src/tls13.s:@error` stashes on + abort: `tls_state=$FF` + `tls_last_state=6 (FINISHED)` proves the abort + happened at Finished rather than earlier at Certificate (4) or + CertificateVerify (5). Server-side evidence (`client_accepted_finished`) + is asserted too. + +Both flip under the mutant: 18/18 -> 2/18 in VICE, PASS -> FAIL on the U64E. +Note `evil_listener.py` is a test fixture, not a TLS stack — it has no +hardening and belongs nowhere near production. + The `tools/uci/` scripts cover the UCI backend on U64E hardware (see the "UCI test scripts" subsection above). +### Upstream pin drift — `tools/check_upstream_pins.py` + +Reports, for every submodule in `.gitmodules`, which release the pin +corresponds to and what upstream has tagged since. Stdlib + `git` +only, one `git ls-remote --tags` per submodule, so it is fast and +schedulable. + + tools/check_upstream_pins.py # table + tools/check_upstream_pins.py --json # machine-readable + tools/check_upstream_pins.py --strict # exit 1 on drift + tools/check_upstream_pins.py --submodule libs/x25519 + +**Do not read pins off `git submodule status`.** It renders versions +via `git describe` *without* `--tags`, which considers annotated tags +only, so a lightweight tag is invisible to it. c64-x25519 tagged +`v0.6.0` lightweight while `v0.5.0` and `v0.7.0` are annotated — +so `git submodule status` renders the exactly-on-`v0.6.0` pin as +`v0.5.0-5-g95fdd70`, which reads as "five commits past a release" and +has already been mistaken for a stale pin. `check_upstream_pins.py` +resolves `refs/tags/^{}` when the peeled ref exists and the bare +ref otherwise, so both tag kinds behave identically. It also reads the +gitlink from `git ls-tree` rather than the working copy, so it is +correct for a submodule that was never `--init`'d. + +### VICE ip65 rig (hardware-free e2e) + +`tests/test_vice_https_macos.py` runs the **full HTTPS handshake + GET +over the ip65 backend with no hardware at all** — emulated RR-Net +(cs8900a) in VICE talking to a host-side TLS 1.3 listener. This is how +the REU-less stock-C64 numbers above were measured. Knobs: +`E2E_PROFILE=reu|onchip`, `E2E_NO_WARP=1` (honest 1 MHz timing), +`E2E_TIMEOUT`, `HTTPS_PORT` (the PRG's port is a build knob — +`make HTTPS_PORT=4433` — so the listener can run unprivileged). + +Three prerequisites that are easy to lose: + + - **An ip65 PRG that builds at all.** This is the one backend that + needs the ip65 blob, which is a gitignored artifact — on a fresh + clone `make` fails at the blob link until `make ip65-libs` has run + once. See "First build in a fresh clone" under Build. + - **An ethernet-capable VICE.** Stock macOS VICE binaries (official + and Homebrew) compile ethernet in but gate the pcap driver on + `geteuid()==0`, so unprivileged `-ethernetiodriver pcap` is + rejected and enabling the cart segfaults on a null driver table. + This bench uses a patched 3.10 SDL2 build at + `~/opt/vice-eth/bin/x64sc` (patch + rebuild script alongside it); + see `vice_eth_build` in memory and c64-test-harness#144. + - **The rig**: `sudo bash tools/rig-up-macos.sh` creates the feth + pair, puts the host at 10.0.65.1, opens `/dev/bpf*`, and starts + dnsmasq. `/dev/bpf*` permissions **reset on every reboot** — a + "pcap not valid for option" error means re-run the script, not a + broken binary. The test's preflight checks all of this and says so. + +Also worth knowing: macOS's Local Network privacy gate can silently +block the listener's sockets until a one-time GUI prompt is approved; +the test self-probes for that, because the symptom otherwise looks +like a C64-side TCP failure. + End-to-end HTTPS against a real server (`www.foo.bar` via the local -bridge rig — never a real internet domain) is still blocked on an -upstream ip65 bug unchanged by this refactor; see -`project_phase3_handoff` in memory. +bridge rig — never a real internet domain) was historically blocked on +an "upstream ip65 bug" recorded only in a since-lost memory note. Part +of that story is now understood and fixed: ip65 sends no MSS option and +ACKs only when polled, so peers less patient than Linux drop the +connection during multi-minute crypto stalls (see the drain note in +"Design note — bounded timeouts"). Whether anything else remains needs +a fresh repro rather than trust in the old note. diff --git a/Makefile b/Makefile index c67cec4..f425912 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,8 @@ # make — default, produces build/c64-https.prg + build/labels.txt # make clean — remove build artifacts # make run — launch the PRG in VICE x64sc -# make ip65-libs — rebuild ip65 object libraries from the submodule +# make ip65-libs — build ip65 object libraries from the submodule +# (required once per fresh clone, BACKEND=ip65 only) # make ip65-blob — rebuild ip65-build/ip65-c64.bin (requires ip65-libs first) # # Variables: @@ -418,13 +419,16 @@ 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/. +# Build ip65 object libraries from the submodule. Required once per fresh +# clone — the submodule ships sources, and the ip65-blob link below needs +# the .lib archives this target produces. Re-run when the submodule moves. ip65-libs: cd $(IP65_DIR) && $(MAKE) -C ip65 && $(MAKE) -C drivers -# Build the ip65 binary blob (ip65-build/ip65-c64.bin). The resulting file is -# committed to the repo so a normal `make` does not need to rebuild it. +# Build the ip65 binary blob (ip65-build/ip65-c64.bin). The file is a +# gitignored build artifact (.gitignore: ip65-build/*.bin), NOT committed. +# `all` depends on it under BACKEND=ip65, so a normal `make` builds it once +# and then reuses it — `clean` only removes build/, so it survives. ip65-blob: $(IP65_BIN) $(IP65_BIN): $(IP65_BUILD)/ip65_stub.s $(IP65_BUILD)/ip65.cfg 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/src/net/uci/net_tuning.inc b/src/net/uci/net_tuning.inc index 39a56b1..8ea107a 100644 --- a/src/net/uci/net_tuning.inc +++ b/src/net/uci/net_tuning.inc @@ -17,10 +17,10 @@ ; uci_wait_not_busy, uci_begin_cmd, 4x uci_put_byte, uci_push_wait, ; uci_check_err, header read, uci_drain_resp + uci_drain_status + ; uci_ack — ~25 fenced register accesses plus FPGA turnaround). -; Measured on a C64 Ultimate at 48 MHz: ~37 ms per poll, of which only -; ~2.8 ms is fence time — the remainder is clock-invariant firmware +; Measured on a C64 Ultimate at 48 MHz: ~40 ms per poll, of which only +; ~3 ms is fence time — the remainder is clock-invariant firmware ; turnaround, so turbo does not help. ip65's 2000-poll budget therefore -; cost ~70 s and regressed the shipped handshake from 51.0 s to 125.4 s +; cost ~80 s and regressed the shipped handshake from 51.0 s to 125.4 s ; (issue #73). ; ; 16 polls (~0.6 s at 48 MHz) is a deliberate small hedge rather than 0: diff --git a/src/tls13.s b/src/tls13.s index d5f3cde..15687c8 100644 --- a/src/tls13.s +++ b/src/tls13.s @@ -471,9 +471,9 @@ tls_recv_server_hello: ; The budget is BACKEND-SENSITIVE and therefore lives in the ; per-backend net_tuning.inc (issue #73): an ip65 net_poll is a ; cheap NIC pump, but a UCI net_poll is a full firmware command - ; round-trip (~37 ms measured at 48 MHz, mostly clock-invariant + ; round-trip (~40 ms measured at 48 MHz, mostly clock-invariant ; FPGA turnaround). Sizing this loop on ip65's poll cost alone - ; cost UCI ~70 s of pure wall-clock — and UCI firmware ACKs + ; cost UCI ~80 s of pure wall-clock — and UCI firmware ACKs ; autonomously, so the drain has nothing to buy there anyway. ; See each backend's net_tuning.inc for the values + rationale. ldy #NET_SH_DRAIN_OUTER 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/check_upstream_pins.py b/tools/check_upstream_pins.py new file mode 100755 index 0000000..fb11cfc --- /dev/null +++ b/tools/check_upstream_pins.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""tools/check_upstream_pins.py — report submodule pin drift against upstream tags. + +Answers one question for every submodule in ``.gitmodules``: *what release is +this pinned to, and what has upstream shipped since?* + +Dependency-free (stdlib + ``git`` only), no network libraries, no API tokens, +no GitHub CLI. Exactly one ``git ls-remote --tags`` and one ``git ls-tree`` per +submodule. Safe to schedule. + +Cost, measured rather than asserted (2026-08-13, 3 submodules, warm DNS): +**2.1 s wall-clock, 0.15 s of it CPU** — i.e. network-bound, and it scales +with submodule count, not repo size. Re-check with ``time +tools/check_upstream_pins.py``; the git-call count is verifiable by stubbing +``subprocess.run``. Stated concretely on purpose: this whole script exists +because of a claim nobody could execute, and a vague-but-true cost note is +one refactor away from a false one. + + tools/check_upstream_pins.py # human-readable table + tools/check_upstream_pins.py --json # machine-readable + tools/check_upstream_pins.py --strict # exit 1 if any pin has drifted + tools/check_upstream_pins.py --submodule libs/x25519 + +Why this exists rather than ``git submodule status`` +---------------------------------------------------- +``git submodule status`` renders its version via ``git describe`` **without** +``--tags``, which considers *annotated* tags only. A lightweight tag is +invisible to it. c64-x25519 tagged ``v0.6.0`` lightweight while ``v0.5.0`` and +``v0.7.0`` are annotated, so ``git submodule status`` renders the exactly-on- +``v0.6.0`` pin as ``v0.5.0-5-g95fdd70`` — which reads as "five commits past a +release" and is how a correct pin came to look like a documentation bug. + +This script resolves tags through ``refs/tags/^{}`` when the peeled ref +is present and the bare ref otherwise, so lightweight and annotated tags are +treated identically. + +It also reads the pinned SHA out of the git tree (``git ls-tree HEAD ``) +rather than the working copy, so it is correct for a submodule that has never +been ``git submodule update --init``'d, and immune to a dirty local checkout. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Matches the common vX.Y.Z / X.Y.Z shapes; anything else sorts as non-semver +# and is reported but never treated as "latest". +_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") + + +def _git(*args: str, cwd: str = REPO_ROOT) -> str: + """Run git, return stdout stripped. Raises CalledProcessError on failure.""" + return subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def parse_gitmodules(path: str) -> list[dict]: + """Parse .gitmodules into [{name, path, url}] without a config library.""" + mods: list[dict] = [] + current: dict | None = None + try: + with open(path, encoding="utf-8") as fh: + lines = fh.readlines() + except FileNotFoundError: + return mods + + for raw in lines: + line = raw.strip() + header = re.match(r'^\[submodule\s+"(.+)"\]$', line) + if header: + current = {"name": header.group(1), "path": None, "url": None} + mods.append(current) + continue + if current is None or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + if key in ("path", "url"): + current[key] = value.strip() + + return [m for m in mods if m["path"] and m["url"]] + + +def pinned_sha(sub_path: str, ref: str = "HEAD") -> str | None: + """The gitlink SHA recorded in the tree — not the working copy.""" + try: + out = _git("ls-tree", ref, "--", sub_path) + except subprocess.CalledProcessError: + return None + # Format: "160000 commit \t" + for line in out.splitlines(): + fields = line.split() + if len(fields) >= 3 and fields[1] == "commit": + return fields[2] + return None + + +def remote_tags(url: str) -> dict[str, str]: + """Map tag name -> commit SHA for every tag on the remote. + + Handles annotated and lightweight tags uniformly: ``git ls-remote --tags`` + emits ``refs/tags/`` for every tag plus ``refs/tags/^{}`` carrying the + *dereferenced commit* for annotated ones. The peeled entry wins when both + are present, so the value is always a commit SHA, never a tag-object SHA. + """ + try: + out = _git("ls-remote", "--tags", url) + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"git ls-remote failed for {url}: {exc.stderr.strip()}") from exc + + bare: dict[str, str] = {} + peeled: dict[str, str] = {} + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) != 2: + continue + sha, ref = parts[0].strip(), parts[1].strip() + if not ref.startswith("refs/tags/"): + continue + name = ref[len("refs/tags/"):] + if name.endswith("^{}"): + peeled[name[:-3]] = sha + else: + bare[name] = sha + + return {name: peeled.get(name, sha) for name, sha in bare.items()} + + +def semver_key(tag: str) -> tuple[int, int, int] | None: + m = _SEMVER_RE.match(tag) + return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None + + +def inspect(mod: dict, ref: str) -> dict: + result = { + "name": mod["name"], + "path": mod["path"], + "url": mod["url"], + "pinned_sha": None, + "pinned_tag": None, + "latest_tag": None, + "latest_sha": None, + "releases_behind": None, + "drifted": False, + "error": None, + } + + sha = pinned_sha(mod["path"], ref) + if sha is None: + result["error"] = f"no gitlink for {mod['path']} at {ref}" + return result + result["pinned_sha"] = sha + + try: + tags = remote_tags(mod["url"]) + except RuntimeError as exc: + result["error"] = str(exc) + return result + + for name, tag_sha in sorted(tags.items()): + if tag_sha == sha: + result["pinned_tag"] = name + break + + semver_tags = sorted( + ((semver_key(n), n) for n in tags if semver_key(n)), + key=lambda pair: pair[0], + ) + if semver_tags: + latest_key, latest_name = semver_tags[-1] + result["latest_tag"] = latest_name + result["latest_sha"] = tags[latest_name] + result["drifted"] = tags[latest_name] != sha + pinned_key = semver_key(result["pinned_tag"]) if result["pinned_tag"] else None + if pinned_key is not None: + result["releases_behind"] = sum(1 for k, _ in semver_tags if k > pinned_key) + elif latest_key: + result["releases_behind"] = None # untagged pin: distance undefined + + return result + + +def render(rows: list[dict]) -> str: + lines = [] + for row in rows: + lines.append(f"{row['path']}") + if row["error"]: + lines.append(f" ERROR: {row['error']}") + continue + pin = row["pinned_tag"] or "(no tag points here)" + lines.append(f" pinned {row['pinned_sha'][:12]} {pin}") + if row["latest_tag"]: + marker = " <-- DRIFT" if row["drifted"] else " (current)" + behind = row["releases_behind"] + behind_txt = f", {behind} release(s) behind" if behind else "" + lines.append( + f" upstream {row['latest_sha'][:12]} {row['latest_tag']}" + f"{marker}{behind_txt}" + ) + else: + lines.append(" upstream (no semver tags found)") + return "\n".join(lines) + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--json", action="store_true", help="emit JSON instead of a table") + ap.add_argument( + "--strict", + action="store_true", + help="exit 1 when any pin is behind the latest upstream semver tag", + ) + ap.add_argument( + "--ref", + default="HEAD", + help="git ref whose pins to inspect (default: HEAD)", + ) + ap.add_argument( + "--submodule", + action="append", + metavar="PATH", + help="restrict to this submodule path (repeatable)", + ) + args = ap.parse_args(argv) + + mods = parse_gitmodules(os.path.join(REPO_ROOT, ".gitmodules")) + if args.submodule: + wanted = set(args.submodule) + mods = [m for m in mods if m["path"] in wanted] + if not mods: + print("no submodules to check", file=sys.stderr) + return 2 + + rows = [inspect(m, args.ref) for m in mods] + + if args.json: + print(json.dumps(rows, indent=2)) + else: + print(render(rows)) + + if any(r["error"] for r in rows): + return 2 + if args.strict and any(r["drifted"] for r in rows): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/https_e2e/evil_listener.py b/tools/https_e2e/evil_listener.py new file mode 100644 index 0000000..f2d0488 --- /dev/null +++ b/tools/https_e2e/evil_listener.py @@ -0,0 +1,634 @@ +"""evil_listener.py — a hand-rolled TLS 1.3 server that can lie. + +Why this is not `ssl.SSLContext` +-------------------------------- +Audit finding F2: nothing in the repo exercised the client's *rejection* of a +bad server Finished. To exercise it, a server has to emit a structurally valid, +correctly-encrypted handshake flight whose Finished verify_data is wrong. +Python's `ssl` module cannot be made to do that — the handshake is entirely +inside OpenSSL and there is no hook between "compute verify_data" and "put it +on the wire". Bit-flipping the ciphertext from outside does not work either: +that breaks the Poly1305 tag, so the client rejects at the AEAD layer and never +reaches the Finished comparison, which would be a false pass for F2. + +So the server side is written out by hand here. That is much less work than it +sounds, because the c64-https client is extremely constrained: + + * exactly one cipher suite, TLS_CHACHA20_POLY1305_SHA256 (0x1303) + * exactly one group, x25519 (0x001d) + * no SNI, empty legacy_session_id, no PSK, no early data, no HRR + * no client certificates + +This module implements only what that client (and, for self-validation, a +stock OpenSSL client) needs. It is a **test fixture, not a TLS stack** — it has +no security review, no state machine hardening, and no business anywhere near +production. + +Modes +----- +``mode="good"`` + A fully correct handshake, then one HTTP response. Used as the control: the + same code that produces the bad flight must also be able to produce a + working one, otherwise a client abort proves nothing about *where* the + client aborted. + +``mode="bad_finished"`` + Identical in every byte except one: a single bit is flipped in the server + Finished ``verify_data`` before it is encrypted. Everything else — the + record layer, the AEAD tag, the certificate, the CertificateVerify + signature, the transcript — is correct, so a conforming client must get all + the way to the Finished HMAC comparison and reject *there*. The server then + records what the client actually did, in ``client_accepted_finished``: a + client that goes on to send its own Finished did not check ours. + +Self-validation +--------------- +``python3 tools/https_e2e/evil_listener.py --selftest`` runs both modes against +Python's own `ssl` client: ``good`` must complete the handshake and return the +body, ``bad_finished`` must raise an SSL error mentioning a bad MAC / decrypt +error. If that self-test does not pass, no conclusion drawn from a C64 run +against this server is worth anything. +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import socket +import struct +import sys +import threading +import time + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, x25519 +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 +from cryptography.x509 import load_pem_x509_certificate + +# Record / handshake constants +CT_CHANGE_CIPHER_SPEC = 20 +CT_ALERT = 21 +CT_HANDSHAKE = 22 +CT_APPLICATION_DATA = 23 + +HS_CLIENT_HELLO = 1 +HS_SERVER_HELLO = 2 +HS_ENCRYPTED_EXTENSIONS = 8 +HS_CERTIFICATE = 11 +HS_CERTIFICATE_VERIFY = 15 +HS_FINISHED = 20 + +TLS_CHACHA20_POLY1305_SHA256 = 0x1303 +GROUP_X25519 = 0x001D +SIG_ECDSA_SECP256R1_SHA256 = 0x0403 + +EXT_SUPPORTED_GROUPS = 0x000A +EXT_SUPPORTED_VERSIONS = 0x002B +EXT_KEY_SHARE = 0x0033 + +HASH_LEN = 32 + +DEFAULT_BODY = "HELLO FROM TLS SERVER" + + +class TlsFixtureError(Exception): + """Something about the peer's flight was not what this fixture supports.""" + + +# --------------------------------------------------------------------------- +# Key schedule (RFC 8446 Section 7.1) +# --------------------------------------------------------------------------- + +def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes: + if not salt: + salt = b"\x00" * HASH_LEN + return hmac.new(salt, ikm, hashlib.sha256).digest() + + +def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes: + out = b"" + t = b"" + counter = 1 + while len(out) < length: + t = hmac.new(prk, t + info + bytes([counter]), hashlib.sha256).digest() + out += t + counter += 1 + return out[:length] + + +def hkdf_expand_label(secret: bytes, label: bytes, context: bytes, + length: int) -> bytes: + info = struct.pack(">H", length) + info += bytes([6 + len(label)]) + b"tls13 " + label + info += bytes([len(context)]) + context + return _hkdf_expand(secret, info, length) + + +def derive_secret(secret: bytes, label: bytes, transcript_hash: bytes) -> bytes: + return hkdf_expand_label(secret, label, transcript_hash, HASH_LEN) + + +def _sha256(data: bytes) -> bytes: + return hashlib.sha256(data).digest() + + +class TrafficKeys: + """One direction's AEAD state: key, iv, and a sequence number.""" + + def __init__(self, secret: bytes): + self.secret = secret + self.key = hkdf_expand_label(secret, b"key", b"", 32) + self.iv = hkdf_expand_label(secret, b"iv", b"", 12) + self.aead = ChaCha20Poly1305(self.key) + self.seq = 0 + + def _nonce(self) -> bytes: + seq = self.seq.to_bytes(12, "big") + return bytes(a ^ b for a, b in zip(self.iv, seq)) + + def encrypt(self, inner_plaintext: bytes) -> bytes: + length = len(inner_plaintext) + 16 + aad = bytes([CT_APPLICATION_DATA, 0x03, 0x03]) + struct.pack(">H", length) + ct = self.aead.encrypt(self._nonce(), inner_plaintext, aad) + self.seq += 1 + return aad + ct + + def decrypt(self, record: bytes) -> tuple[int, bytes]: + """*record* is a complete TLSCiphertext incl. its 5-byte header.""" + aad = record[:5] + ct = record[5:] + pt = self.aead.decrypt(self._nonce(), ct, aad) + self.seq += 1 + # Strip zero padding, then the inner content type. + i = len(pt) - 1 + while i >= 0 and pt[i] == 0: + i -= 1 + if i < 0: + raise TlsFixtureError("decrypted record is all padding") + return pt[i], pt[:i] + + +# --------------------------------------------------------------------------- +# Wire helpers +# --------------------------------------------------------------------------- + +def _u24(n: int) -> bytes: + return bytes([(n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF]) + + +def _handshake(msg_type: int, body: bytes) -> bytes: + return bytes([msg_type]) + _u24(len(body)) + body + + +def _plaintext_record(content_type: int, payload: bytes) -> bytes: + return bytes([content_type, 0x03, 0x03]) + struct.pack(">H", len(payload)) + payload + + +class RecordReader: + """Reassembles TLS records from a stream socket.""" + + def __init__(self, sock: socket.socket): + self.sock = sock + self.buf = bytearray() + + def read_record(self, timeout: float) -> bytes | None: + """Return one complete record (header included), or None on EOF.""" + deadline = time.monotonic() + timeout + while True: + if len(self.buf) >= 5: + length = struct.unpack(">H", self.buf[3:5])[0] + if len(self.buf) >= 5 + length: + rec = bytes(self.buf[: 5 + length]) + del self.buf[: 5 + length] + return rec + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("timed out waiting for a TLS record") + self.sock.settimeout(remaining) + chunk = self.sock.recv(4096) + if not chunk: + return None + self.buf += chunk + + +def parse_client_hello(msg: bytes) -> dict: + """Extract what the server needs from a ClientHello handshake message.""" + if not msg or msg[0] != HS_CLIENT_HELLO: + raise TlsFixtureError( + f"expected ClientHello, got handshake type {msg[0] if msg else 'EOF'}" + ) + body = msg[4:] + p = 0 + p += 2 # legacy_version + client_random = body[p:p + 32] + p += 32 + sid_len = body[p] + p += 1 + session_id = body[p:p + sid_len] + p += sid_len + cs_len = struct.unpack(">H", body[p:p + 2])[0] + p += 2 + suites = [ + struct.unpack(">H", body[p + i:p + i + 2])[0] for i in range(0, cs_len, 2) + ] + p += cs_len + comp_len = body[p] + p += 1 + comp_len + ext_total = struct.unpack(">H", body[p:p + 2])[0] + p += 2 + end = p + ext_total + + key_share = None + while p < end: + ext_type = struct.unpack(">H", body[p:p + 2])[0] + ext_len = struct.unpack(">H", body[p + 2:p + 4])[0] + data = body[p + 4:p + 4 + ext_len] + p += 4 + ext_len + if ext_type == EXT_KEY_SHARE: + q = 2 # client_shares list length + while q < len(data): + group = struct.unpack(">H", data[q:q + 2])[0] + klen = struct.unpack(">H", data[q + 2:q + 4])[0] + if group == GROUP_X25519: + key_share = data[q + 4:q + 4 + klen] + break + q += 4 + klen + + if TLS_CHACHA20_POLY1305_SHA256 not in suites: + raise TlsFixtureError( + "client did not offer TLS_CHACHA20_POLY1305_SHA256 (0x1303); " + f"offered {[hex(s) for s in suites]}" + ) + if key_share is None: + raise TlsFixtureError("client sent no x25519 key_share") + + return { + "client_random": client_random, + "session_id": session_id, + "key_share": key_share, + } + + +# --------------------------------------------------------------------------- +# The server +# --------------------------------------------------------------------------- + +class EvilTls13Server: + """One-shot TLS 1.3 server flight, optionally with a corrupted Finished. + + *mode* is ``"good"`` or ``"bad_finished"``. The two modes run the *same* + code path from end to end; they differ only in whether one bit of the + server Finished ``verify_data`` is flipped before encryption. + + Deliberately, the server folds the Finished it actually sent into its own + transcript. A client that wrongly accepts the corrupted Finished therefore + stays in lockstep with the server and completes the handshake normally, + ending at HTTP 200 — so a broken client fails loudly and quickly rather + than hanging and being written off as a flaky timeout. + """ + + def __init__(self, cert_path: str, key_path: str, *, + mode: str = "good", + body: str = DEFAULT_BODY): + if mode not in ("good", "bad_finished"): + raise ValueError(f"unknown mode {mode!r}") + self.mode = mode + self.body = body + + with open(cert_path, "rb") as f: + pem = f.read() + self.cert_der = load_pem_x509_certificate(pem).public_bytes( + serialization.Encoding.DER + ) + with open(key_path, "rb") as f: + self.key = serialization.load_pem_private_key(f.read(), password=None) + if not isinstance(self.key, ec.EllipticCurvePrivateKey): + raise TlsFixtureError("this fixture only signs with ECDSA P-256") + + self.result: dict = { + "mode": mode, + "listening": False, + "client_hello_seen": False, + "server_flight_sent": False, + "finished_corrupted": False, + # The load-bearing one: did the client go on to send its own + # Finished after our (possibly corrupted) Finished? A client that + # checks the server Finished MUST NOT. + "client_accepted_finished": None, + "client_reaction": None, + "client_finished_valid": None, + "request": None, + "response_sent": False, + "client_alert": None, + "error": None, + } + + # -- handshake message builders ---------------------------------------- + + def _server_hello(self, ch: dict, server_pub: bytes) -> bytes: + ext = b"" + ext += struct.pack(">HH", EXT_SUPPORTED_VERSIONS, 2) + b"\x03\x04" + ks = struct.pack(">HH", GROUP_X25519, len(server_pub)) + server_pub + ext += struct.pack(">HH", EXT_KEY_SHARE, len(ks)) + ks + + body = b"\x03\x03" + body += os.urandom(32) + body += bytes([len(ch["session_id"])]) + ch["session_id"] + body += struct.pack(">H", TLS_CHACHA20_POLY1305_SHA256) + body += b"\x00" + body += struct.pack(">H", len(ext)) + ext + return _handshake(HS_SERVER_HELLO, body) + + def _certificate(self) -> bytes: + entry = _u24(len(self.cert_der)) + self.cert_der + b"\x00\x00" + body = b"\x00" + _u24(len(entry)) + entry + return _handshake(HS_CERTIFICATE, body) + + def _certificate_verify(self, transcript_hash: bytes) -> bytes: + signed = b"\x20" * 64 + signed += b"TLS 1.3, server CertificateVerify" + signed += b"\x00" + signed += transcript_hash + sig = self.key.sign(signed, ec.ECDSA(hashes.SHA256())) + body = struct.pack(">H", SIG_ECDSA_SECP256R1_SHA256) + body += struct.pack(">H", len(sig)) + sig + return _handshake(HS_CERTIFICATE_VERIFY, body) + + def _finished(self, secret: bytes, transcript_hash: bytes) -> tuple[bytes, bool]: + finished_key = hkdf_expand_label(secret, b"finished", b"", HASH_LEN) + verify_data = hmac.new(finished_key, transcript_hash, hashlib.sha256).digest() + corrupted = False + if self.mode == "bad_finished": + # One bit, in the last byte. The message stays the right length and + # the right shape; only the MAC is wrong, so the client must reach + # the HMAC comparison to notice. + verify_data = verify_data[:31] + bytes([verify_data[31] ^ 0x01]) + corrupted = True + return _handshake(HS_FINISHED, verify_data), corrupted + + # -- the flight --------------------------------------------------------- + + def serve_one(self, sock: socket.socket, timeout: float) -> dict: + reader = RecordReader(sock) + + rec = reader.read_record(timeout) + if rec is None: + raise TlsFixtureError("client closed before sending ClientHello") + if rec[0] != CT_HANDSHAKE: + raise TlsFixtureError(f"expected handshake record, got type {rec[0]}") + ch_msg = rec[5:] + ch = parse_client_hello(ch_msg) + self.result["client_hello_seen"] = True + + server_priv = x25519.X25519PrivateKey.generate() + server_pub = server_priv.public_key().public_bytes_raw() + shared = server_priv.exchange( + x25519.X25519PublicKey.from_public_bytes(ch["key_share"]) + ) + + sh_msg = self._server_hello(ch, server_pub) + sock.sendall(_plaintext_record(CT_HANDSHAKE, sh_msg)) + + transcript = ch_msg + sh_msg + + early = _hkdf_extract(b"", b"\x00" * HASH_LEN) + derived = derive_secret(early, b"derived", _sha256(b"")) + handshake_secret = _hkdf_extract(derived, shared) + c_hs = derive_secret(handshake_secret, b"c hs traffic", _sha256(transcript)) + s_hs = derive_secret(handshake_secret, b"s hs traffic", _sha256(transcript)) + s_keys = TrafficKeys(s_hs) + c_keys = TrafficKeys(c_hs) + + def send_hs(msg: bytes) -> None: + # One handshake message per record: the C64 client's + # tls_recv_encrypted dispatches on tls_rec_buf[0] and handles + # exactly one message per decrypted record. + sock.sendall(s_keys.encrypt(msg + bytes([CT_HANDSHAKE]))) + + ee = _handshake(HS_ENCRYPTED_EXTENSIONS, b"\x00\x00") + send_hs(ee) + transcript += ee + + cert = self._certificate() + send_hs(cert) + transcript += cert + + cv = self._certificate_verify(_sha256(transcript)) + send_hs(cv) + transcript += cv + + fin, corrupted = self._finished(s_hs, _sha256(transcript)) + send_hs(fin) + self.result["finished_corrupted"] = corrupted + self.result["server_flight_sent"] = True + + # Fold the Finished we actually SENT. A client that accepts the + # corrupted Finished folds the same bytes, so its transcript still + # agrees with ours and the rest of the handshake would succeed. That + # is deliberate: it means a client with a broken check does not merely + # stall, it sails through to HTTP 200 — a fast, unambiguous failure + # signal instead of a test timeout. + transcript += fin + + master = _hkdf_extract( + derive_secret(handshake_secret, b"derived", _sha256(b"")), + b"\x00" * HASH_LEN, + ) + ap_transcript_hash = _sha256(transcript) + c_ap = derive_secret(master, b"c ap traffic", ap_transcript_hash) + s_ap = derive_secret(master, b"s ap traffic", ap_transcript_hash) + + expected_cf_key = hkdf_expand_label(c_hs, b"finished", b"", HASH_LEN) + expected_cf = hmac.new( + expected_cf_key, _sha256(transcript), hashlib.sha256 + ).digest() + + # --- What does the client do with our Finished? ------------------- + # This is the whole experiment. Whatever comes back next is recorded + # as server-side evidence; the client cannot fabricate it. + while True: + try: + rec = reader.read_record(timeout) + except (TimeoutError, socket.timeout, OSError) as exc: + self.result["client_accepted_finished"] = False + self.result["client_reaction"] = f"no response ({type(exc).__name__})" + return self.result + if rec is None: + self.result["client_accepted_finished"] = False + self.result["client_reaction"] = "closed connection" + return self.result + if rec[0] == CT_CHANGE_CIPHER_SPEC: + continue + if rec[0] == CT_ALERT: + self.result["client_accepted_finished"] = False + self.result["client_reaction"] = f"plaintext alert {rec[5:].hex()}" + return self.result + try: + ctype, pt = c_keys.decrypt(rec) + except Exception as exc: # noqa: BLE001 — fixture + self.result["client_accepted_finished"] = False + self.result["client_reaction"] = ( + f"undecryptable record ({type(exc).__name__})" + ) + return self.result + if ctype == CT_ALERT: + self.result["client_accepted_finished"] = False + self.result["client_reaction"] = f"encrypted alert {pt.hex()}" + return self.result + if ctype != CT_HANDSHAKE or not pt or pt[0] != HS_FINISHED: + self.result["client_accepted_finished"] = False + self.result["client_reaction"] = ( + f"unexpected record: inner type {ctype}, " + f"first byte {pt[0] if pt else None}" + ) + return self.result + self.result["client_accepted_finished"] = True + self.result["client_reaction"] = "sent its own Finished" + self.result["client_finished_valid"] = hmac.compare_digest( + pt[4:36], expected_cf + ) + break + + c_app = TrafficKeys(c_ap) + s_app = TrafficKeys(s_ap) + + req = b"" + while b"\r\n\r\n" not in req: + try: + rec = reader.read_record(timeout) + except (TimeoutError, socket.timeout, OSError): + break + if rec is None: + break + if rec[0] == CT_CHANGE_CIPHER_SPEC: + continue + ctype, pt = c_app.decrypt(rec) + if ctype == CT_APPLICATION_DATA: + req += pt + elif ctype == CT_ALERT: + self.result["client_alert"] = pt.hex() + break + self.result["request"] = req + + payload = self.body.encode() + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: " + str(len(payload)).encode() + b"\r\n" + b"Connection: close\r\n" + b"\r\n" + payload + ) + sock.sendall(s_app.encrypt(response + bytes([CT_APPLICATION_DATA]))) + self.result["response_sent"] = True + time.sleep(1.0) + return self.result + +def serve_one_connection(srv: socket.socket, cert_path: str, key_path: str, *, + mode: str, body: str, timeout: float, + result: dict) -> None: + """Accept exactly one connection and run the flight. Fills *result*.""" + conn = None + try: + srv.settimeout(timeout) + srv.listen(1) + result["listening"] = True + conn, addr = srv.accept() + result["client_addr"] = addr + server = EvilTls13Server(cert_path, key_path, mode=mode, body=body) + result.update(server.result) + result["client_addr"] = addr + result["listening"] = True + try: + server.serve_one(conn, timeout) + finally: + result.update(server.result) + result["client_addr"] = addr + result["listening"] = True + except Exception as exc: # noqa: BLE001 — fixture + result["error"] = f"{type(exc).__name__}: {exc}" + finally: + for s in (conn, srv): + try: + if s is not None: + s.close() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Self-test against Python's own ssl client +# --------------------------------------------------------------------------- + +def _selftest() -> int: + import ssl + + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from https_listener import _ensure_certs_p256 # noqa: PLC0415 + + cert_path, key_path = _ensure_certs_p256() + failures = 0 + + for mode, expect in (("good", "handshake completes"), + ("bad_finished", "client rejects")): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", 0)) + port = srv.getsockname()[1] + + result: dict = {} + t = threading.Thread( + target=serve_one_connection, + args=(srv, cert_path, key_path), + kwargs=dict(mode=mode, body=DEFAULT_BODY, timeout=20.0, + result=result), + daemon=True, + ) + t.start() + time.sleep(0.2) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_verify_locations(cafile=cert_path) + ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + + ok = False + detail = "" + try: + raw = socket.create_connection(("127.0.0.1", port), timeout=20.0) + with ctx.wrap_socket(raw, server_hostname="www.foo.bar") as tls: + tls.sendall(b"GET / HTTP/1.1\r\nHost: www.foo.bar\r\n\r\n") + data = tls.recv(4096) + if mode == "good": + ok = b"200 OK" in data and DEFAULT_BODY.encode() in data + detail = repr(data[:80]) + else: + detail = f"handshake COMPLETED — server never rejected: {data[:60]!r}" + except ssl.SSLError as exc: + detail = f"{type(exc).__name__}: {exc}" + if mode == "bad_finished": + ok = True + except Exception as exc: # noqa: BLE001 + detail = f"{type(exc).__name__}: {exc}" + + t.join(timeout=25.0) + + verdict = "PASS" if ok else "FAIL" + print(f" {verdict}: mode={mode:<13} expect {expect}") + print(f" client saw : {detail}") + print(f" server saw : {result}") + if not ok: + failures += 1 + + print() + if failures: + print(f" [-] evil_listener self-test: {failures} FAILED") + else: + print(" [+] evil_listener self-test: ALL PASSED") + return 1 if failures else 0 + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(_selftest()) + print(__doc__) + print("Run with --selftest to validate against Python's ssl client.") diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh index e834bbc..b9a043d 100755 --- a/tools/integration/build_nistcurves_p384.sh +++ b/tools/integration/build_nistcurves_p384.sh @@ -203,7 +203,7 @@ rm -f "$ARCHIVE_CURVE" "$STAGING/curve/mod384.o" \ "$STAGING/curve/curve384.o" \ "$STAGING/curve/points384_core.o" \ - "$STAGING/curve/ecdsa384.o" \ + "$STAGING/curve/ecdsa384_nocomb.o" \ "$STAGING/curve/ec_scalar_mul_384_shim.o" \ "$STAGING/curve/data_p384.o" @@ -221,7 +221,7 @@ rm -f "$ARCHIVE_CURVE" { echo "# nistcurves-p384-curve.a per-source byte counts (ca65 .o file sizes)" for src in lib_version lib_manifest zp_config constants reu_config \ - fp384 mod384 curve384 points384_core ecdsa384 \ + fp384 mod384 curve384 points384_core ecdsa384_nocomb \ ec_scalar_mul_384_shim data_p384; do if [ -f "$STAGING/curve/$src.o" ]; then bytes=$(wc -c < "$STAGING/curve/$src.o") 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/run_all_tests.py b/tools/run_all_tests.py index cf5403f..44dbc19 100644 --- a/tools/run_all_tests.py +++ b/tools/run_all_tests.py @@ -88,7 +88,17 @@ def run_test_suite(name, transport, labels, seed): elif name == "x25519": from test_x25519 import run_tests as x25519_run - passed, failed = x25519_run(transport, labels, seed=seed) + # run_tests also reports groups it skipped. This caller never + # sets test_x25519.FAST, so the RFC 7748 scalarmult vectors + # always run here (+~33 s) and the list is empty; assert it + # rather than dropping it, so a future gate cannot silently + # remove coverage from the aggregate verdict. + passed, failed, x25519_skipped = x25519_run( + transport, labels, seed=seed) + if x25519_skipped: + raise AssertionError( + "x25519 suite skipped groups in the aggregate run: " + + ", ".join(x25519_skipped)) except Exception as e: import traceback @@ -130,7 +140,19 @@ def main(): suites = ["entropy", "net", "sha256", "crypto", "hkdf", "keyschedule", "http", "tls_record", "tls_handshake", "x25519"] - if not skip_slow: + + # Suites deliberately not run this session, as (name, reason). --skip-slow + # used to drop x509 by simply never adding it to the list, so the aggregate + # printed a TOTAL and exited 0 with no trace that the entire X.509/ECDSA + # suite had not run. That is audit finding F3's shape one level up: the + # skipped assertions left the denominator instead of being accounted for. + # An explicit operator flag is a legitimate reason to skip; it is not a + # licence to report an unqualified clean pass. + skipped_suites = [] + if skip_slow: + skipped_suites.append( + ("x509", "--skip-slow (X.509 DER parsing + ECDSA P-256 verify)")) + else: suites.insert(0, "x509") config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False, @@ -176,13 +198,25 @@ def run_suite_in_own_instance(mgr, suite_name): total_failed = sum(r[2] for r in results) total_tests = total_passed + total_failed + skipped_note = "" + if skipped_suites: + skipped_note = (f" -- {len(skipped_suites)} suite(s) SKIPPED: " + + ", ".join(n for n, _ in skipped_suites)) + print(f"\n{'='*60}") print(f"TOTAL: {total_passed}/{total_tests} passed, " - f"{total_failed} failed") + f"{total_failed} failed{skipped_note}") for name, passed, failed, duration in sorted(results): status = "OK" if failed == 0 else "FAIL" print(f" {status:4s} {name:20s} {passed:3d}/{passed+failed:3d} " f"({duration:.1f}s)") + for name, reason in skipped_suites: + print(f" SKIP {name:20s} --- did not run: {reason}") + if skipped_suites: + print("\n WARNING: the suite(s) above did not run. This aggregate " + "result does not") + print(" certify them, and their assertions are absent from " + "the TOTAL.") print(f"{'='*60}") sys.exit(0 if total_failed == 0 else 1) diff --git a/tools/test_ecdsa_kat_oracle.py b/tools/test_ecdsa_kat_oracle.py index 1ac6334..415899c 100644 --- a/tools/test_ecdsa_kat_oracle.py +++ b/tools/test_ecdsa_kat_oracle.py @@ -1,24 +1,51 @@ #!/usr/bin/env python3 """test_ecdsa_kat_oracle.py - Library-side KAT oracle for ECDSA P-256 verify. -Runs additional known-VALID P-256/SHA-256 signature vectors against the -C64's `ecdsa_verify` routine (the c64-https dispatcher over the -libs/nistcurves sibling). Mirrors the structure of -`tools/test_x509.py` group 3 subtest [3c] (call `setup_ecdsa_verify(...)`, -then `jsr_with_carry(... labels["ecdsa_verify"] ...)`, assert C=0) but -exercises 3 additional vectors so we can distinguish a primitive bug -from a [3c]-specific test-setup bug: - - - [3e] CAVP SigVer P-256/SHA-256 valid #1 (Result = P record) - - [3f] CAVP SigVer P-256/SHA-256 valid #2 - - [3g] CAVP SigVer P-256/SHA-256 valid #3 - +Runs CAVP P-256/SHA-256 signature vectors against the C64's `ecdsa_verify` +routine (the c64-https dispatcher over the libs/nistcurves sibling). Mirrors +the structure of `tools/test_x509.py` group 3 subtest [3c] (call +`setup_ecdsa_verify(...)`, then `jsr_with_carry(... labels["ecdsa_verify"] ...)`, +assert the carry) but exercises additional vectors so we can distinguish a +primitive bug from a [3c]-specific test-setup bug: + + - [3e] CAVP SigVer P-256/SHA-256 valid #1 (Result = P) -> expect C=0 + - [3f] CAVP SigVer P-256/SHA-256 valid #2 (Result = P) -> expect C=0 + - [3g] CAVP SigVer P-256/SHA-256 valid #3 (Result = P) -> expect C=0 + - [3h] CAVP SigVer P-256/SHA-256 invalid #1 (Result = F, S changed) -> C=1 + - [3i] CAVP SigVer P-256/SHA-256 invalid #2 (Result = F, R changed) -> C=1 + - [3j] CAVP SigVer P-256/SHA-256 invalid #3 (Result = F, Msg changed) -> C=1 + +Negative vectors (audit finding F7) +----------------------------------- +This oracle originally ran three vectors, all valid, all expecting C=0. +That cannot distinguish a working verifier from one that reports "valid" +unconditionally: against an `ecdsa_verify` stubbed to `clc; rts` it happily +reported 3/3. An oracle with no negative case does not test verification, +it tests that the routine returns. + +The three `Result = F` records above close that. They are genuine CAVP +records, not signatures manufactured by mutating a valid one and not +anything produced by running this implementation. + +Every `Result = F` record in the file has Q on the curve and r, s in +[1, n-1] (checked host-side, see below), so none of them can be rejected by +a cheap range or point-validity gate — each one forces the full verify math +and compares the recovered R.x against r. One record per CAVP modification +class is included: 3 (S changed), 2 (R changed), 1 (Message changed). + +Provenance +---------- Vectors are extracted verbatim from `libs/nistcurves/tools/vectors/nist_p256_sigver.rsp` (NIST CAVP SigVer, -P-256/SHA-256 section), specifically the records flagged `Result = P`. -For each vector the hash is `SHA-256(Msg)`; r/s/Qx/Qy are taken straight -from the .rsp file in big-endian wire order, matching the BE struct ABI -of the sibling's `ecdsa_verify_256`. +P-256/SHA-256 section). For each vector the hash is `SHA-256(Msg)`; +r/s/Qx/Qy are taken straight from the .rsp file in big-endian wire order, +matching the BE struct ABI of the sibling's `ecdsa_verify_256`. + +Every vector below — positive and negative — was independently confirmed +host-side against OpenSSL via the `cryptography` package before being added +here: all 15 records in the .rsp agreed with their Result column, with Q on +curve and r, s in range. The `expect_carry` field encodes the .rsp Result +column (P -> 0, F -> 1), never an observed C64 result. Usage: python3 tools/test_ecdsa_kat_oracle.py [--verbose] @@ -71,13 +98,19 @@ # --------------------------------------------------------------------------- -# Hardcoded P-256 known-VALID KAT vectors (CAVP SigVer, Result = P records) +# Hardcoded P-256 KAT vectors (CAVP SigVer, both Result = P and Result = F) +# +# `expect_carry` mirrors the .rsp Result column: P (valid) -> C=0, +# F (invalid) -> C=1. See the module docstring for provenance and for why +# the negative records are load-bearing (audit finding F7). # --------------------------------------------------------------------------- KAT_VECTORS = [ + # --- Result = P (valid) -------------------------------------------- # CAVP SigVer P-256/SHA-256 valid record #1 dict( tag="CAVP SigVer P-256/SHA-256 valid #1", + expect_carry=0, hash=bytes.fromhex( "d1b8ef21eb4182ee270638061063a3f3" "c16c114e33937f69fb232cc833965a94"), @@ -97,6 +130,7 @@ # CAVP SigVer P-256/SHA-256 valid record #2 dict( tag="CAVP SigVer P-256/SHA-256 valid #2", + expect_carry=0, hash=bytes.fromhex( "b9336a8d1f3e8ede001d19f41320bc76" "72d772a3d2cb0e435fff3c27d6804a2c"), @@ -116,6 +150,7 @@ # CAVP SigVer P-256/SHA-256 valid record #3 dict( tag="CAVP SigVer P-256/SHA-256 valid #3", + expect_carry=0, hash=bytes.fromhex( "41007876926a20f821d72d9c6f2c9dae" "6c03954123ea6e6939d7e6e669438891"), @@ -132,9 +167,76 @@ "9b52672742d637a32add056dfd6d8792" "f2a33c2e69dafabea09b960bc61e230a"), ), + + # --- Result = F (invalid) ------------------------------------------ + # These are what make this file an oracle rather than a smoke test: + # a verify that answers "valid" unconditionally passes every vector + # above and fails every vector below. Q is on the curve and r, s are in + # [1, n-1] for all three, so none is rejectable by a cheap gate. + # + # CAVP SigVer P-256/SHA-256 invalid record, "Result = F (3 - S changed)" + dict( + tag="CAVP SigVer P-256/SHA-256 invalid #1 (F: S changed)", + expect_carry=1, + hash=bytes.fromhex( + "a82c31412f537135d1c418bd7136fb5f" + "de9426e70c70e7c2fb11f02f30fdeae2"), + r=bytes.fromhex( + "d19ff48b324915576416097d2544f7cb" + "df8768b1454ad20e0baac50e211f23b0"), + s=bytes.fromhex( + "a3e81e59311cdfff2d4784949f7a2cb5" + "0ba6c3a91fa54710568e61aca3e847c6"), + qx=bytes.fromhex( + "87f8f2b218f49845f6f10eec38771362" + "69f5c1a54736dbdf69f89940cad41555"), + qy=bytes.fromhex( + "e15f369036f49842fac7a86c8a2b0557" + "609776814448b8f5e84aa9f4395205e9"), + ), + # CAVP SigVer P-256/SHA-256 invalid record, "Result = F (2 - R changed)" + dict( + tag="CAVP SigVer P-256/SHA-256 invalid #2 (F: R changed)", + expect_carry=1, + hash=bytes.fromhex( + "5984eab8854d0a9aa5f0c70f96deeb51" + "0e5f9ff8c51befcdc3c41bac53577f22"), + r=bytes.fromhex( + "dc23d130c6117fb5751201455e99f36f" + "59aba1a6a21cf2d0e7481a97451d6693"), + s=bytes.fromhex( + "d6ce7708c18dbf35d4f8aa7240922dc6" + "823f2e7058cbc1484fcad1599db5018c"), + qx=bytes.fromhex( + "5cf02a00d205bdfee2016f7421807fc3" + "8ae69e6b7ccd064ee689fc1a94a9f7d2"), + qy=bytes.fromhex( + "ec530ce3cc5c9d1af463f264d685afe2" + "b4db4b5828d7e61b748930f3ce622a85"), + ), + # CAVP SigVer P-256/SHA-256 invalid record, "Result = F (1 - Message changed)" + dict( + tag="CAVP SigVer P-256/SHA-256 invalid #3 (F: Message changed)", + expect_carry=1, + hash=bytes.fromhex( + "d80e9933e86769731ec16ff31e682153" + "1bcf07fcbad9e2ac16ec9e6cb343a870"), + r=bytes.fromhex( + "288f7a1cd391842cce21f00e6f15471c" + "04dc182fe4b14d92dc18910879799790"), + s=bytes.fromhex( + "247b3c4e89a3bcadfea73c7bfd361def" + "43715fa382b8c3edf4ae15d6e55e9979"), + qx=bytes.fromhex( + "69b7667056e1e11d6caf6e45643f8b21" + "e7a4bebda463c7fdbc13bc98efbd0214"), + qy=bytes.fromhex( + "d3f9b12eb46c7c6fda0da3fc85bc1fd8" + "31557f9abc902a3be3cb3e8be7d1aa2f"), + ), ] -SUBTEST_LABELS = ["3e", "3f", "3g"] +SUBTEST_LABELS = ["3e", "3f", "3g", "3h", "3i", "3j"] # --------------------------------------------------------------------------- @@ -209,7 +311,9 @@ def run_kat_oracle(transport, labels): for idx, vec in enumerate(KAT_VECTORS): sub = SUBTEST_LABELS[idx] tag = vec["tag"] - print(f"\n [{sub}] ECDSA verify: {tag} (expected C=0)") + want = vec["expect_carry"] + want_word = "valid" if want == 0 else "INVALID" + print(f"\n [{sub}] ECDSA verify: {tag} (expected C={want}, {want_word})") if VERBOSE: print(f" hash = {vec['hash'][:8].hex()}... r = {vec['r'][:8].hex()}...") print(f" s = {vec['s'][:8].hex()}... Qx = {vec['qx'][:8].hex()}... Qy = {vec['qy'][:8].hex()}...") @@ -240,12 +344,19 @@ def run_kat_oracle(transport, labels): carry = jsr_with_carry(transport, labels["ecdsa_verify"], timeout=2400.0, poll_interval=30.0) elapsed = time.time() - t0 - if carry == 0: + got_word = "valid" if carry == 0 else "invalid" + if carry == want: passed += 1 - print(f" PASS: ecdsa_verify returned C=0 (valid) [{elapsed:.0f}s]") + print(f" PASS: ecdsa_verify returned C={carry} " + f"({got_word}) [{elapsed:.0f}s]") else: failed += 1 - print(f" FAIL: ecdsa_verify returned C=1 (invalid) [{elapsed:.0f}s]") + print(f" FAIL: ecdsa_verify returned C={carry} ({got_word}), " + f"expected C={want} ({want_word}) [{elapsed:.0f}s]") + if want == 1: + print(" A CAVP Result=F vector was accepted. The " + "verifier is reporting") + print(" signatures valid that NIST says are not.") print(f" hash: {c64_hash.hex()}") print(f" r: {c64_r.hex()}") print(f" s: {c64_s.hex()}") @@ -288,8 +399,21 @@ def main(): print("\nFATAL: ECDSA verify labels missing; nothing to test.") sys.exit(1) + n_pos = sum(1 for v in KAT_VECTORS if v["expect_carry"] == 0) + n_neg = sum(1 for v in KAT_VECTORS if v["expect_carry"] == 1) + + # Structural guard against audit finding F7 regressing: an oracle made up + # entirely of valid signatures cannot fail against a verifier that always + # answers "valid", so it is not an oracle. + if n_neg == 0: + print("\nFATAL: KAT_VECTORS contains no Result=F (expect_carry=1) vector.") + print(" An all-positive vector set passes against a verify stubbed") + print(" to 'clc; rts' and therefore proves nothing. See F7.") + sys.exit(1) + print(f"\n Labels loaded from {LABELS_PATH}") - print(f" Vectors to run: {len(KAT_VECTORS)} (CAVP SigVer P-256/SHA-256 valid)") + print(f" Vectors to run: {len(KAT_VECTORS)} CAVP SigVer P-256/SHA-256 " + f"({n_pos} valid / {n_neg} invalid)") print(f" Per-vector wallclock budget: 2400 s (VICE warp; typical ~5-16 min)") config = default_vice_config(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False) @@ -318,7 +442,8 @@ def main(): print(f" sqtab_init FAILED: {e}") sys.exit(1) - print(f"\n=== ECDSA P-256 KAT oracle ({len(KAT_VECTORS)} valid vectors) ===") + print(f"\n=== ECDSA P-256 KAT oracle " + f"({n_pos} valid + {n_neg} invalid vectors) ===") passed, failed = run_kat_oracle(transport, labels) mgr.release(inst) @@ -329,6 +454,18 @@ def main(): print(f"{'='*60}") print(f" Passed: {passed}/{total}") print(f" Failed: {failed}/{total}") + if total != len(KAT_VECTORS): + # Every declared vector must produce a verdict; a vector that silently + # dropped out is the same class of defect as F3's skipped group. + print(f"\n [-] ECDSA KAT oracle: only {total} of " + f"{len(KAT_VECTORS)} declared vectors produced a verdict") + sys.exit(1) + if failed == 0: + print(f"\n [+] ECDSA KAT oracle: ALL {total} VECTORS PASSED " + f"({n_pos} valid accepted, {n_neg} invalid rejected)") + else: + print(f"\n [-] ECDSA KAT oracle: {failed} VECTOR(S) FAILED") + print(f"{'='*60}") sys.exit(0 if failed == 0 else 1) diff --git a/tools/test_finished_verify.py b/tools/test_finished_verify.py new file mode 100755 index 0000000..1969aa8 --- /dev/null +++ b/tools/test_finished_verify.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""test_finished_verify.py - negative + positive coverage for tls_verify_finished. + +Why this exists +--------------- +``tls_verify_finished`` (src/tls_keyschedule.s) is the *only* thing standing +between the client and a forged server Finished: it recomputes the expected +``verify_data`` and constant-time-compares it with the 32 bytes the server sent +at ``tls_rec_buf+4``. On mismatch it returns C=1, which ``tls13.s`` turns into +a handshake abort:: + + src/tls13.s jsr tls_verify_finished + bcs @enc_error -> sec/rts out of tls_recv_encrypted + src/tls13.s jsr tls_recv_encrypted + bcs -> @error -> handshake aborted + +Before this test, *nothing in the repo exercised the mismatch path*. A +mutation audit confirmed it: inverting the mismatch branch (``sec`` -> ``clc`` +in ``tls_verify_finished``) let the full hardware end-to-end handshake still +reach HTTP 200 with the correct body, undetected, because every listener the +suite ever talks to sends a *correct* Finished. + +This test drives the routine directly over DMA with hand-built inputs, so it +can present a Finished the client must reject. It is deliberately narrow: it +tests one branch, but it tests it for real. + +Coverage +-------- +For each of two independent (server_hs_secret, transcript) vector sets: + + positive correct verify_data -> expect C=0 + flip_first_byte correct, one bit flipped in byte 0 -> expect C=1 + flip_last_byte correct, one bit flipped in byte 31 -> expect C=1 + all_zeros 32 x 0x00 -> expect C=1 + all_ones 32 x 0xFF -> expect C=1 + truncated first 31 correct bytes then 0x00 -> expect C=1 + rotated correct bytes rotated left by one -> expect C=1 + wrong_secret valid HMAC under a *different* secret -> expect C=1 + wrong_transcript valid HMAC over a *different* transcript -> expect C=1 + +The last two are the realistic attacks: an active attacker who cannot derive +the server handshake traffic secret, and one who tries to substitute a +different transcript. ``truncated`` and ``rotated`` specifically catch a +compare loop that stops early or is off by one. + +The positive case additionally asserts that the C64's *computed* +``tls_verify_data`` equals an independent Python computation, so a routine that +learned to always return C=0 without doing the HMAC cannot pass. + +Reference implementation +------------------------ +``hkdf_expand_label`` / HMAC-SHA256 are recomputed here in plain Python. That +reference is itself pinned to RFC 8448 by ``tools/test_hkdf.py`` and +``tools/test_keyschedule_steps.py``; this file reuses RFC 8448 Section 3's +server handshake traffic secret as vector set A so the inputs are not +self-invented. + +Usage: + python3 tools/test_finished_verify.py [--verbose] + +Env: + C64_SKIP_BUILD=1 reuse the already-built PRG + +Requires: Python 3.10+, c64_test_harness, VICE x64sc +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import struct +import subprocess +import sys + +from c64_test_harness import ( + Labels, + ViceInstanceManager, + read_bytes, + write_bytes, + jsr, + wait_for_text, +) + +PROJECT_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from _vice_helpers import default_vice_config # noqa: E402 + +PRG_PATH = os.path.join(PROJECT_ROOT, "build", "c64-https.prg") +LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") + +VERBOSE = False + +REQUIRED_LABELS = [ + "tls_verify_finished", + "tls_verify_data", + "tls_s_hs_secret", + "tls_transcript", + "tls_rec_buf", +] + +# Cassette buffer. $033C-$03FB is free once BASIC has booted. The harness's +# own jsr() trampoline lives at $0334 (5 bytes) and run_subroutine's U64 +# trampoline at $0360 (14 bytes) with flags at $03F0/$03F1 — $0340 and $034C +# collide with none of them. +CARRY_STUB_ADDR = 0x0340 +CARRY_RESULT_ADDR = 0x034C + + +# --------------------------------------------------------------------------- +# Python reference (see module docstring for provenance) +# --------------------------------------------------------------------------- + +def hkdf_expand_label(secret: bytes, label: bytes, context: bytes, + length: int) -> bytes: + """TLS 1.3 HKDF-Expand-Label (RFC 8446 Section 7.1). L <= 32 only.""" + assert length <= 32 + info = struct.pack(">H", length) + info += bytes([6 + len(label)]) + b"tls13 " + label + info += bytes([len(context)]) + context + return hmac.new(secret, info + b"\x01", hashlib.sha256).digest()[:length] + + +def finished_verify_data(traffic_secret: bytes, transcript: bytes) -> bytes: + """RFC 8446 Section 4.4.4 verify_data.""" + finished_key = hkdf_expand_label(traffic_secret, b"finished", b"", 32) + return hmac.new(finished_key, transcript, hashlib.sha256).digest() + + +# --------------------------------------------------------------------------- +# Vectors +# --------------------------------------------------------------------------- + +# RFC 8448 Section 3 server handshake traffic secret (same value the existing +# key-schedule test pins the C64 against). +SECRET_A = bytes.fromhex( + "b67b7d690cc16c4e75e54213cb2d37b4" + "e9c912bcded9105d42befd59d391ad38" +) +# An arbitrary but fixed transcript hash. Any 32 bytes is a legal input here; +# the HMAC is defined over whatever the running hash produced. +TRANSCRIPT_A = hashlib.sha256(b"c64-https lane B transcript A").digest() + +# A second, independent vector set, so a routine that happens to be correct +# for one input pair cannot coast. +SECRET_B = hashlib.sha256(b"c64-https lane B secret B").digest() +TRANSCRIPT_B = hashlib.sha256(b"c64-https lane B transcript B").digest() + +# Used only to build "valid HMAC, wrong key/context" forgeries. +DECOY_SECRET = hashlib.sha256(b"c64-https lane B decoy secret").digest() +DECOY_TRANSCRIPT = hashlib.sha256(b"c64-https lane B decoy transcript").digest() + +VECTOR_SETS = [ + ("A (RFC 8448 s_hs_traffic)", SECRET_A, TRANSCRIPT_A), + ("B (independent)", SECRET_B, TRANSCRIPT_B), +] + + +def build_cases(secret: bytes, transcript: bytes): + """Return [(name, received_verify_data, expect_carry), ...].""" + good = finished_verify_data(secret, transcript) + + flip_first = bytes([good[0] ^ 0x01]) + good[1:] + flip_last = good[:31] + bytes([good[31] ^ 0x80]) + truncated = good[:31] + b"\x00" + rotated = good[1:] + good[:1] + wrong_secret = finished_verify_data(DECOY_SECRET, transcript) + wrong_transcript = finished_verify_data(secret, DECOY_TRANSCRIPT) + + cases = [ + ("positive", good, 0), + ("flip_first_byte", flip_first, 1), + ("flip_last_byte", flip_last, 1), + ("all_zeros", b"\x00" * 32, 1), + ("all_ones", b"\xff" * 32, 1), + ("truncated", truncated, 1), + ("rotated", rotated, 1), + ("wrong_secret", wrong_secret, 1), + ("wrong_transcript", wrong_transcript, 1), + ] + + # Sanity: every negative vector must genuinely differ from the correct one, + # otherwise the "case" is not a negative case at all. Guards against a + # degenerate vector (e.g. rotated == good for an all-same-byte digest). + for name, vd, expect in cases: + assert len(vd) == 32, f"{name}: verify_data must be 32 bytes" + if expect == 1: + assert vd != good, f"{name}: negative vector is not actually wrong" + else: + assert vd == good, f"{name}: positive vector is not the correct value" + + return cases, good + + +# --------------------------------------------------------------------------- +# C64 plumbing +# --------------------------------------------------------------------------- + +def install_carry_stub(transport, target_addr: int) -> None: + """Install a stub that calls *target_addr* and latches the carry flag. + + JSR target 20 lo hi + LDA #$00 A9 00 + ROL A 2A ; carry -> bit 0 + STA result 8D lo hi + RTS 60 + + Reading the P register back over the monitor is unreliable across + backends; latching the flag into RAM from 6502 code is not. The stub is + written once and reused for every case. + """ + lo, hi = target_addr & 0xFF, (target_addr >> 8) & 0xFF + rlo, rhi = CARRY_RESULT_ADDR & 0xFF, (CARRY_RESULT_ADDR >> 8) & 0xFF + stub = bytes([0x20, lo, hi, 0xA9, 0x00, 0x2A, 0x8D, rlo, rhi, 0x60]) + write_bytes(transport, CARRY_STUB_ADDR, stub) + readback = read_bytes(transport, CARRY_STUB_ADDR, len(stub)) + if readback != stub: + raise RuntimeError( + f"carry stub readback mismatch at ${CARRY_STUB_ADDR:04X}: " + f"wrote {stub.hex()}, read {readback.hex()}" + ) + + +def call_verify_finished(transport, labels, secret: bytes, transcript: bytes, + received: bytes) -> tuple[int, bytes]: + """Set up inputs, run tls_verify_finished, return (carry, computed_vd).""" + write_bytes(transport, labels["tls_s_hs_secret"], secret) + write_bytes(transport, labels["tls_transcript"], transcript) + write_bytes(transport, labels["tls_rec_buf"] + 4, received) + + # Poison the output buffer and the carry latch so a routine that never + # runs cannot be mistaken for one that ran and agreed with us. + write_bytes(transport, labels["tls_verify_data"], b"\xa5" * 32) + write_bytes(transport, CARRY_RESULT_ADDR, b"\xa5") + + jsr(transport, CARRY_STUB_ADDR, timeout=60.0) + + carry = read_bytes(transport, CARRY_RESULT_ADDR, 1)[0] + if carry not in (0, 1): + raise RuntimeError( + f"carry latch never written (read ${carry:02X}) — the stub did " + f"not complete; treat this run as inconclusive, not a pass" + ) + computed = read_bytes(transport, labels["tls_verify_data"], 32) + return carry, computed + + +# --------------------------------------------------------------------------- +# Test driver +# --------------------------------------------------------------------------- + +def run_tests(transport, labels) -> tuple[int, int]: + passed = failed = 0 + + install_carry_stub(transport, labels["tls_verify_finished"]) + + for set_name, secret, transcript in VECTOR_SETS: + print(f"\n--- Vector set {set_name} ---") + cases, good = build_cases(secret, transcript) + + for name, received, expect_carry in cases: + carry, computed = call_verify_finished( + transport, labels, secret, transcript, received + ) + + ok = carry == expect_carry + detail = "" + + # The positive case also proves the routine actually computed the + # HMAC rather than short-circuiting to "accept". + if expect_carry == 0 and ok: + if computed != good: + ok = False + detail = ( + f"\n computed verify_data mismatch" + f"\n expected {good.hex()}" + f"\n got {computed.hex()}" + ) + + verdict = "PASS" if ok else "FAIL" + want = "C=0 accept" if expect_carry == 0 else "C=1 reject" + got = "C=0 accept" if carry == 0 else "C=1 reject" + print(f" {verdict}: {name:<17} want {want}, got {got}{detail}") + if VERBOSE: + print(f" received {received.hex()}") + print(f" computed {computed.hex()}") + + if ok: + passed += 1 + else: + failed += 1 + + return passed, failed + + +def main() -> int: + global VERBOSE + os.chdir(PROJECT_ROOT) + + if "--verbose" in sys.argv: + VERBOSE = True + + if os.environ.get("C64_SKIP_BUILD"): + print("\n=== Building (skipped: C64_SKIP_BUILD set) ===") + else: + print("\n=== Building ===") + subprocess.run(["make", "clean"], capture_output=True) + result = subprocess.run(["make"], capture_output=True, text=True) + if result.returncode != 0: + print(f"Build failed:\n{result.stderr}") + return 1 + print(" Build OK") + + if not os.path.exists(PRG_PATH): + print(f"FATAL: {PRG_PATH} not found") + return 1 + + labels = Labels.from_file(LABELS_PATH) + missing = [n for n in REQUIRED_LABELS if labels.address(n) is None] + if missing: + # A missing label means the routine under test moved or was renamed. + # That is a failure, never a skip — see audit finding F3. + print(f"FATAL: required label(s) not found: {', '.join(missing)}") + return 1 + + print("\n=== Labels ===") + for name in REQUIRED_LABELS: + print(f" {name:<22} = ${labels[name]:04X}") + + print("\n=== Starting VICE ===") + config = default_vice_config( + prg_path=PRG_PATH, + warp=True, + ntsc=True, + sound=False, + ) + + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + transport = inst.transport + print(f" VICE PID={inst.pid}, port={inst.port}") + + print(" Waiting for main menu...") + grid = wait_for_text(transport, "Q=QUIT", timeout=120.0, verbose=False) + if grid is None: + print("FATAL: Main menu did not appear") + mgr.release(inst) + return 1 + print(" Main menu ready") + + print("\n=== tls_verify_finished ===") + try: + passed, failed = run_tests(transport, labels) + finally: + mgr.release(inst) + + total = passed + failed + print("\n" + "=" * 60) + print("RESULTS") + print("=" * 60) + print(f" Passed: {passed}/{total}") + print(f" Failed: {failed}/{total}") + if failed == 0: + print(f"\n [+] Finished verify: ALL {total} TESTS PASSED") + else: + print(f"\n [-] Finished verify: {failed} TEST(S) FAILED") + print("=" * 60) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_tls_handshake.py b/tools/test_tls_handshake.py index b224697..4e3c2fd 100644 --- a/tools/test_tls_handshake.py +++ b/tools/test_tls_handshake.py @@ -22,7 +22,6 @@ from c64_test_harness import ( Labels, - ViceConfig, ViceInstanceManager, read_bytes, write_bytes, @@ -34,6 +33,8 @@ wait_for_text, ) +from _vice_helpers import default_vice_config + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -207,6 +208,33 @@ def check_labels(labels, names): return True +# A routine that is still `clc; rts` is unimplemented: it reports success +# without doing anything. +STUB_CLC_RTS = bytes([0x18, 0x60]) + + +def report_if_stub(transport, addr, name): + """Announce (loudly) that *name* is a ``clc; rts`` stub. + + Returns True when the routine at *addr* is a stub. + + Callers must NOT skip on a stub. This file used to answer a detected + stub with ``return 0, 0``, which dropped the whole group out of both + the passed and failed counters -- the suite then reported success + while testing nothing, and the stub (a routine that claims success + unconditionally) sailed through. A stub has to FAIL its group's + tests: the diagnostic below explains why they fail, and the tests + still run so the failures land in the denominator (audit finding + F10, same shape as F3). + """ + if read_bytes(transport, addr, 2) == STUB_CLC_RTS: + print(f"\n STUB: {name} is a `clc; rts` stub -- unimplemented. " + "The tests below still run and are expected to FAIL; a stub " + "is never a skip and never a pass.") + return True + return False + + # --------------------------------------------------------------------------- # C64 helper functions # --------------------------------------------------------------------------- @@ -598,6 +626,10 @@ def test_server_hello_parse(transport, labels, rng): hs_buf = labels["tls_rec_buf"] hs_len_addr = labels["tls_rec_len"] + # A stub is reported, not skipped -- all three tests below then run and + # fail on their own merits. + report_if_stub(transport, parse_sh, "tls_parse_server_hello") + # --- Test 3a: Valid ServerHello with x25519 --- print("\n [3a] ServerHello: valid parse (x25519, cipher 0x1303)") server_random = bytes(rng.getrandbits(8) for _ in range(32)) @@ -619,12 +651,15 @@ def test_server_hello_parse(transport, labels, rng): [len(hs_msg) & 0xFF, (len(hs_msg) >> 8) & 0xFF]) try: - regs = jsr(transport, parse_sh, timeout=120.0) - - # Check carry flag (C=0 means success) - carry = 0 - if regs and "P" in regs: - carry = regs["P"] & 0x01 + # Read the carry through the jsr_check_carry trampoline (LDA #0 / + # ROL A / STA), which captures C on the 6502 itself. The previous + # code read it out of the harness register dict under the key "P"; + # VICE's binary monitor names the status register "FL", so the + # lookup never matched, `carry` stayed pinned at 0, and the error + # branch below was unreachable (audit finding F10, same root cause + # as F1 in test_tls_record.py). The trampoline has no dependency + # on register naming at all. + carry = jsr_check_carry(transport, parse_sh, timeout=120.0) if carry == 0: # Verify server_random was extracted @@ -649,24 +684,12 @@ def test_server_hello_parse(transport, labels, rng): print(f" expected: {server_pubkey[:8].hex()}...") print(f" got: {got_pubkey[:8].hex()}...") else: - # Parser returned error but it might be a stub - # Check if the implementation is a stub (just clc; rts) - code = read_bytes(transport, parse_sh, 2) - if code == bytes([0x18, 0x60]): # CLC; RTS - print(" SKIP: tls_parse_server_hello is a stub (clc; rts)") - return 0, 0 failed += 1 print(" FAIL: parser returned C=1 (error) for valid ServerHello") except Exception as e: failed += 1 print(f" FAIL: {e}") - # Check if this is a stub before running error tests - code = read_bytes(transport, parse_sh, 3) - if code[:2] == bytes([0x18, 0x60]): # CLC; RTS - print(" SKIP: remaining ServerHello tests (stub implementation)") - return 0, 0 - # --- Test 3b: Wrong cipher suite -> C=1 --- print(" [3b] ServerHello: wrong cipher suite -> error") sh_body_bad_cipher = build_server_hello( @@ -871,11 +894,9 @@ def test_key_schedule(transport, labels): derive_hs = labels["tls_derive_handshake_keys"] - # Check if this is a stub (clc; rts) - code = read_bytes(transport, derive_hs, 2) - if code == bytes([0x18, 0x60]): - print("\n SKIP: tls_derive_handshake_keys is a stub (clc; rts)") - return 0, 0 + # Same rule as the ServerHello group: a stub is reported, not skipped. + # The five tests below then fail against the RFC 8448 vectors. + report_if_stub(transport, derive_hs, "tls_derive_handshake_keys") # Set up inputs: shared secret and transcript hash write_bytes(transport, labels["tls_shared_secret"], SHARED_SECRET) @@ -1264,7 +1285,10 @@ def main(): f"{found_optional}/{len(optional_labels)} optional TLS labels found") # Launch VICE - config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False) + # default_vice_config() applies the mandatory -reu/-reusize=512 flags; + # see tools/_vice_helpers.py for the rationale. + config = default_vice_config(prg_path=PRG_PATH, warp=True, ntsc=True, + sound=False) print(f"\n=== Starting VICE ===") with ViceInstanceManager(config=config) as mgr: diff --git a/tools/test_tls_record.py b/tools/test_tls_record.py index df8b527..8cf2419 100644 --- a/tools/test_tls_record.py +++ b/tools/test_tls_record.py @@ -21,7 +21,6 @@ from c64_test_harness import ( Labels, - ViceConfig, ViceInstanceManager, read_bytes, write_bytes, @@ -33,6 +32,8 @@ wait_for_text, ) +from _vice_helpers import default_vice_config + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -75,6 +76,32 @@ "tls_seq_increment", ] +# Names the 6502 processor-status register can appear under in a +# harness register dict. VICE's binary monitor calls it "FL"; other +# backends use "P" / "FLAGS" / "SR". The original code only looked for +# "P", so on VICE the lookup never matched and test 4b silently fell +# through to a weaker oracle. +STATUS_REG_NAMES = ("FL", "P", "FLAGS", "SR") + + +def carry_from_regs(regs): + """Return the carry flag (0/1) from a harness register dict. + + Returns ``None`` when no processor-status register is present. A + caller that cannot read the carry has *not* observed the routine's + accept/reject decision, so ``None`` must be treated as a failed + test, never as a pass: the tag-comparison fallback this replaces + asserted only that the computed tag differed from the record's tag, + which is true of any tampered input whether or not ``aead_decrypt`` + rejected it (audit finding F1). + """ + if not regs: + return None + for name in STATUS_REG_NAMES: + if name in regs: + return regs[name] & 0x01 + return None + # --------------------------------------------------------------------------- # Python reference implementations @@ -487,27 +514,35 @@ def test_record_decrypt(transport, labels, rng): regs = jsr(transport, labels["tls_record_decrypt"], timeout=120.0) - # Expect carry flag set (C=1) indicating AEAD failure - # The carry flag is bit 0 of the status register (P) - if regs and "P" in regs: - carry = regs["P"] & 0x01 - if carry: - passed += 1 - print(" PASS: decrypt returned C=1 (tag mismatch)") - else: - failed += 1 - print(" FAIL: decrypt returned C=0 (should be C=1 " - "for tampered data)") + # The ONLY sound oracle here is the carry flag returned by + # tls_record_decrypt: C=1 means the record was rejected. There is + # deliberately no fallback oracle -- see carry_from_regs() and the + # note above it. + carry = carry_from_regs(regs) + if carry is None: + failed += 1 + print(" FAIL: could not read the 6502 status register " + f"(register names seen: {sorted(regs) if regs else 'none'}; " + f"looked for {'/'.join(STATUS_REG_NAMES)}). The tamper " + "rejection could not be evaluated, which is not a pass.") + elif carry: + passed += 1 + print(" PASS: decrypt returned C=1 (tag mismatch)") else: - # If we can't read P, check if tag comparison area differs + failed += 1 + print(" FAIL: decrypt returned C=0 (should be C=1 " + "for tampered data)") + # Diagnostic only -- never a pass criterion. Differing tags + # say the tamper was *detectable*, not that decrypt rejected + # the record. c64_tag = read_bytes(transport, labels["poly1305_tag"], 16) aead_tag = read_bytes(transport, labels["aead_tag"], 16) if c64_tag != aead_tag: - passed += 1 - print(" PASS: tags differ (tamper detected)") + print(" (computed tag != record tag, so the " + "tamper was detectable but not rejected)") else: - failed += 1 - print(" FAIL: tags match despite tampered ciphertext") + print(" (computed tag == record tag: the tag " + "was never recomputed over the tampered ciphertext)") except Exception as e: failed += 1 print(f" FAIL: {e}") @@ -765,7 +800,10 @@ def main(): print(f" Labels loaded: {len(REQUIRED_LABELS)} required labels verified") # Launch VICE - config = ViceConfig(prg_path=PRG_PATH, warp=True, ntsc=True, sound=False) + # default_vice_config() applies the mandatory -reu/-reusize=512 flags; + # see tools/_vice_helpers.py for the rationale. + config = default_vice_config(prg_path=PRG_PATH, warp=True, ntsc=True, + sound=False) print(f"\n=== Starting VICE ===") with ViceInstanceManager(config=config) as mgr: diff --git a/tools/test_x25519.py b/tools/test_x25519.py index fd41de7..d7b3ac3 100644 --- a/tools/test_x25519.py +++ b/tools/test_x25519.py @@ -2,14 +2,29 @@ """test_x25519.py -- fe25519 field arithmetic and X25519 key exchange tests. Tests fe_add, fe_sub, fe_mul, fe_sqr, fe_inv, fe_cswap, fe_mul_a24, -fe_copy, fe_zero, fe_one, x25519_clamp, and (with --slow) x25519_scalarmult -against Python reference implementations and RFC 7748 test vectors. +fe_copy, fe_zero, fe_one, x25519_clamp, and x25519_scalarmult against +Python reference implementations and RFC 7748 test vectors. + +The two RFC 7748 scalarmult vectors run BY DEFAULT. They are the only +end-to-end `x25519_scalarmult` coverage in this file -- everything else +is field arithmetic -- so a run that omits them certifies nothing about +X25519 itself. They used to be gated behind `--slow` on the strength of +a "~100 min each" comment; measured under VICE warp on the in-tree ip65 +build they cost **~16.5 s each** (full suite 37.9 s with them, 4.8 s +without). The gate was buying 33 seconds and hiding the only test that +matters. `--fast` still skips them, and any skip is now named in the +summary line rather than silently leaving the denominator. Uses the binary monitor test harness -- jsr() is event-based via checkpoints, so no polling or retry wrappers are needed. Usage: - python3 tools/test_x25519.py [--seed S] [--verbose] [--slow] + python3 tools/test_x25519.py [--seed S] [--verbose] [--fast] + + --fast skip the RFC 7748 scalarmult vectors (~33 s). The summary + line then reports them as SKIPPED. + --slow accepted and ignored; the vectors it used to enable are + now the default. """ import os @@ -29,7 +44,7 @@ LABELS_PATH = os.path.join(PROJECT_ROOT, "build", "labels.txt") VERBOSE = False -SLOW = False +FAST = False # p = 2^255 - 19 P = (1 << 255) - 19 @@ -77,7 +92,13 @@ def clamp_ref(scalar): return bytes(s) -# RFC 7748 Section 6.1 test vectors +# RFC 7748 Section 5.2 test vectors (the scalarmult vectors -- Section 6.1 +# is the Alice/Bob Diffie-Hellman pair, which these are not). +# +# U_2 ends 0x93, i.e. bit 255 of the u-coordinate is SET. That makes +# vector 2 the RFC 7748 decodeUCoordinate MSB-masking regression test: +# it is the vector that caught upstream c64-x25519 #64, the bug present +# in our pinned libs/x25519 v0.6.0. Do not drop it as "redundant". SCALAR_1 = bytes.fromhex( "a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") U_1 = bytes.fromhex( @@ -634,16 +655,21 @@ def run_tests(transport, labels, seed): lambda: test_x25519_clamp(transport, labels, rng)), ] - if SLOW: - test_groups += [ - ("x25519 RFC 7748 vector 1", - lambda: test_x25519_rfc7748_vector1(transport, labels)), - ("x25519 RFC 7748 vector 2", - lambda: test_x25519_rfc7748_vector2(transport, labels)), - ] + skipped_groups = [] + scalarmult_groups = [ + ("x25519 RFC 7748 vector 1", + lambda: test_x25519_rfc7748_vector1(transport, labels)), + ("x25519 RFC 7748 vector 2", + lambda: test_x25519_rfc7748_vector2(transport, labels)), + ] + if FAST: + # A skipped group must not silently leave the denominator: record + # it so the verdict can name it. These two are the only end-to-end + # x25519_scalarmult coverage in the file. + skipped_groups += [name for name, _ in scalarmult_groups] + print("\n (--fast: skipping x25519 scalarmult vectors, ~33 s)") else: - print("\n (x25519 scalarmult tests skipped -- " - "use --slow to enable, ~100 min each)") + test_groups += scalarmult_groups for name, test_fn in test_groups: print(f"\n--- {name} ---") @@ -659,11 +685,11 @@ def run_tests(transport, labels, seed): import traceback traceback.print_exc() - return total_passed, total_failed + return total_passed, total_failed, skipped_groups def main(): - global VERBOSE, SLOW + global VERBOSE, FAST os.chdir(PROJECT_ROOT) seed = random.randint(0, 2**32 - 1) @@ -676,8 +702,12 @@ def main(): elif args[i] == "--verbose": VERBOSE = True i += 1 + elif args[i] == "--fast": + FAST = True + i += 1 elif args[i] == "--slow": - SLOW = True + # Back-compat no-op: the vectors --slow used to enable now + # run by default. Kept so existing invocations don't break. i += 1 else: i += 1 @@ -747,13 +777,23 @@ def main(): # after jsr() returns (prevents crash when BASIC ROM is banked out) write_bytes(transport, 0x0339, bytes([0x4C, 0x39, 0x03])) - passed, failed = run_tests(transport, labels, seed) + passed, failed, skipped_groups = run_tests(transport, labels, seed) mgr.release(inst) total = passed + failed print(f"\n{'='*60}") - print(f"RESULTS: {passed}/{total} passed, {failed}/{total} failed") + summary = f"RESULTS: {passed}/{total} passed, {failed}/{total} failed" + if skipped_groups: + # Never print an unqualified clean pass over a group that did not + # run. Skipped assertions leave the denominator entirely, so the + # counters alone cannot express the gap -- name it explicitly. + summary += (f" -- {len(skipped_groups)} group(s) SKIPPED: " + + ", ".join(skipped_groups)) + print(summary) + if skipped_groups: + print("WARNING: end-to-end x25519_scalarmult coverage did NOT run; " + "this run does not certify X25519.") print(f"{'='*60}") sys.exit(0 if failed == 0 else 1) diff --git a/tools/test_x509.py b/tools/test_x509.py index 238ede6..b78c2c6 100644 --- a/tools/test_x509.py +++ b/tools/test_x509.py @@ -10,6 +10,22 @@ python3 tools/test_x509.py [--seed S] [--verbose] Requires: Python 3.10+, c64_test_harness, VICE x64sc, cryptography + +Skip policy (audit finding F3) +------------------------------ +This suite used to treat a missing label as a benign skip: dropping the +single symbol ``ecdsa_verify`` from ``build/labels.txt`` silently deleted the +whole ECDSA group, and the run still reported "ALL 7 TESTS PASSED" and exited +0. The skipped assertions left the denominator instead of counting against it. + +The realistic trigger is a ``libs/nistcurves`` bump renaming an export, which +is precisely the change this suite exists to catch. + +Policy now: every group listed in ``REQUIRED_LABEL_SETS`` is required. If its +labels are missing, ``main()`` aborts before launching VICE (exit 1, summary +names the group), and ``run_tests()`` — which ``tools/run_all_tests.py`` calls +directly — counts each such group as a failure. Optional/unwired label sets +(``CV_LABELS``) are deliberately not in that list. """ import datetime @@ -78,13 +94,27 @@ "sqtab_init", ] -# Labels for CertificateVerify tests +# Labels for CertificateVerify tests. +# NOTE: no group currently drives these — the CertificateVerify tests are not +# wired up (see run_tests()). They are therefore NOT in REQUIRED_LABEL_SETS: +# a group that does not exist cannot be silently skipped. CV_LABELS = [ "tls_handle_cert_verify", "tls_rec_buf", "tls_rec_len", "tls_transcript", ] +# Every test group this suite claims to run, with the labels it needs. +# +# These are REQUIRED, not optional. If a build stops exporting one of these +# symbols — the realistic trigger being a libs/nistcurves bump that renames an +# export — the affected group must be reported as a FAILURE, never quietly +# dropped from the denominator. See the module docstring's "silent skip" note. +REQUIRED_LABEL_SETS = [ + ("DER parser (groups 1-2)", DER_LABELS), + ("ECDSA P-256 verify (group 3)", ECDSA_LABELS), +] + # --------------------------------------------------------------------------- # Helpers @@ -156,20 +186,40 @@ def jsr_with_carry(transport, addr, timeout=120.0, poll_interval=0.5): return result[0] -def check_label(labels, name): - """Return True if label exists, print skip message if not.""" - if labels.address(name) is None: - print(f" SKIP: label '{name}' not found (routine not yet implemented)") - return False - return True +# Groups that could not run this session because labels were missing. +# Entries are (group_name, [missing label names]). run_tests() resets this. +SKIPPED_GROUPS = [] + +def missing_labels(labels, label_list): + """Return the subset of label_list that the build does not export.""" + return [name for name in label_list if labels.address(name) is None] -def check_labels(labels, label_list): - """Return True if all labels in the list exist.""" - for name in label_list: - if labels.address(name) is None: - print(f" SKIP: label '{name}' not found -- skipping test group") - return False + +def preflight_required_labels(labels): + """Return [(group_name, [missing labels])] for declared groups that can't run.""" + broken = [] + for group_name, label_list in REQUIRED_LABEL_SETS: + missing = missing_labels(labels, label_list) + if missing: + broken.append((group_name, missing)) + return broken + + +def check_labels(labels, label_list, group="unnamed group"): + """Return True if all labels exist; otherwise record the group as skipped. + + A missing label is NOT a benign skip. It means the build no longer exports + a symbol this suite depends on, so the group's assertions never execute. + Recording the group here is what keeps it out of the "everything passed" + denominator — run_tests() turns each recorded entry into a failure. + """ + missing = missing_labels(labels, label_list) + if missing: + print(f" SKIP: {group}: label(s) {', '.join(missing)} not found " + f"-- group CANNOT RUN (counted as a failure)") + SKIPPED_GROUPS.append((group, missing)) + return False return True @@ -269,7 +319,7 @@ def test_der_parser_p256(transport, labels): passed = 0 failed = 0 - if not check_labels(labels, DER_LABELS): + if not check_labels(labels, DER_LABELS, "Group 1: DER Parser P-256"): return 0, 0 print("\n Generating P-256 self-signed certificate...") @@ -396,7 +446,7 @@ def test_der_parser_p384(transport, labels): passed = 0 failed = 0 - if not check_labels(labels, DER_LABELS): + if not check_labels(labels, DER_LABELS, "Group 2: DER Parser P-384"): return 0, 0 print("\n Generating P-384 self-signed certificate...") @@ -408,16 +458,22 @@ def test_der_parser_p384(transport, labels): # Load certificate to C64 load_cert_to_c64(transport, labels, cert_der) - # Parse the certificate + # Parse the certificate. + # A parse failure here is a FAILURE, not a skip. This used to report + # "SKIP: may not be supported yet" and return (0, 0), which would have + # hidden a P-384 DER regression completely — same shape as F3. P-384 + # certificate *parsing* works today (group 2 passes 2/2); it is only + # P-384 ECDSA *verify* that is stubbed at the TLS layer. try: carry = jsr_with_carry(transport, labels["x509_parse_cert"], timeout=120.0, poll_interval=0.5) if carry != 0: - print(" SKIP: P-384 parse returned C=1 (may not be supported yet)") - return 0, 0 + print(" [2!] FAIL: x509_parse_cert returned C=1 on a P-384 " + "certificate") + return 0, 1 except Exception as e: - print(f" SKIP: P-384 parse raised {e}") - return 0, 0 + print(f" [2!] FAIL: x509_parse_cert raised {e}") + return 0, 1 # --- Test 1: Curve ID correct --- print("\n [2a] DER parse P-384: curve_id = 1 (P-384)") @@ -500,7 +556,7 @@ def test_ecdsa_verify_p256(transport, labels): passed = 0 failed = 0 - if not check_labels(labels, ECDSA_LABELS): + if not check_labels(labels, ECDSA_LABELS, "Group 3: ECDSA P-256 Verify"): return 0, 0 print("\n Using hardcoded P-256 test vector (pre-verified in Python)") @@ -633,9 +689,16 @@ def run_tests(transport, labels): Order: DER parser first (fast, validates VICE), then ECDSA verify. CertificateVerify tests skipped until core verify is proven. + + A declared group that cannot run because its labels are missing is counted + as ONE FAILURE, and named in SKIPPED_GROUPS. That is deliberate: this + function is called directly by tools/run_all_tests.py, which only sees + (passed, failed), so a group vanishing from the denominator would otherwise + be indistinguishable from a clean run there too. """ total_passed = 0 total_failed = 0 + SKIPPED_GROUPS.clear() # --- DER parser tests (fast, ~seconds) --- test_groups = [ @@ -663,7 +726,7 @@ def run_tests(transport, labels): traceback.print_exc() # --- ECDSA verify tests (slow, minutes each) --- - ecdsa_ok = check_labels(labels, ECDSA_LABELS) + ecdsa_ok = check_labels(labels, ECDSA_LABELS, "Group 3: ECDSA P-256 Verify") if ecdsa_ok: # One-time sqtab_init before any ECDSA tests print(f"\n{'='*60}") @@ -696,6 +759,19 @@ def run_tests(transport, labels): # CertificateVerify tests skipped for now # (re-enable after core ECDSA verify is proven) + # A group that could not run is a failure, not a hole in the denominator. + if SKIPPED_GROUPS: + print(f"\n{'='*60}") + print(" GROUPS THAT COULD NOT RUN (counted as failures)") + print(f"{'='*60}") + for group, missing in SKIPPED_GROUPS: + print(f" [-] {group}: missing label(s) {', '.join(missing)}") + print(" A missing label means the build stopped exporting a symbol " + "this suite\n depends on (e.g. a libs/nistcurves bump renamed " + "an export). The group's\n assertions never executed, so this " + "run proves nothing about it.") + total_failed += len(SKIPPED_GROUPS) + return total_passed, total_failed @@ -745,17 +821,36 @@ def main(): labels = Labels.from_file(LABELS_PATH) # Check which test groups can run - der_ok = all(labels.address(n) is not None for n in DER_LABELS) - ecdsa_ok = all(labels.address(n) is not None for n in ECDSA_LABELS) - cv_ok = all(labels.address(n) is not None for n in CV_LABELS) + der_ok = not missing_labels(labels, DER_LABELS) + ecdsa_ok = not missing_labels(labels, ECDSA_LABELS) + cv_ok = not missing_labels(labels, CV_LABELS) print(f" Labels loaded from {LABELS_PATH}") print(f" DER parser labels: {'OK' if der_ok else 'MISSING'}") print(f" ECDSA verify labels: {'OK' if ecdsa_ok else 'MISSING'}") - print(f" CertificateVerify labels: {'OK' if cv_ok else 'MISSING'}") - - if not (der_ok or ecdsa_ok or cv_ok): - print("\nFATAL: No test group has all required labels. Nothing to test.") + print(f" CertificateVerify labels: " + f"{'OK' if cv_ok else 'MISSING'} (informational; no group uses these yet)") + + # Fail closed: every group in REQUIRED_LABEL_SETS is one this suite claims + # to run. If the build no longer exports the symbols it needs, the group's + # assertions cannot execute — and a suite that cannot execute its + # assertions has not passed. Abort here, before spending a VICE session + # producing a green result that covers less than it claims. + broken = preflight_required_labels(labels) + if broken: + names = "; ".join(f"{g} [missing: {', '.join(m)}]" for g, m in broken) + print(f"\n{'='*60}") + print("RESULTS") + print(f"{'='*60}") + print(f" Passed: 0/0") + print(f" Failed: 0/0") + print(f"\n [-] X.509/ECDSA: ABORTED -- declared test group(s) " + f"cannot run: {names}") + print(" A missing label means the build stopped exporting a symbol") + print(" this suite depends on (e.g. a libs/nistcurves bump renamed") + print(" an export). Skipping the group would report success while") + print(" testing nothing, so this is a failure.") + print(f"{'='*60}") sys.exit(1) # Estimate test duration @@ -793,16 +888,24 @@ def main(): mgr.release(inst) # Summary + no_tests_ran = (passed + failed) == 0 + if no_tests_ran: + # A suite that executed no assertions has not passed. + failed = 1 total = passed + failed print(f"\n{'='*60}") print("RESULTS") print(f"{'='*60}") print(f" Passed: {passed}/{total}") print(f" Failed: {failed}/{total}") - if total == 0: - print("\n [?] No tests ran (routines not yet implemented?)") + skipped = "; ".join(f"{g} [missing: {', '.join(m)}]" for g, m in SKIPPED_GROUPS) + if no_tests_ran: + print("\n [-] X.509/ECDSA: NO TESTS RAN -- nothing was verified") elif failed == 0: print(f"\n [+] X.509/ECDSA: ALL {total} TESTS PASSED") + elif skipped: + print(f"\n [-] X.509/ECDSA: {failed} TEST(S) FAILED " + f"-- group(s) could not run: {skipped}") else: print(f"\n [-] X.509/ECDSA: {failed} TEST(S) FAILED") print(f"{'='*60}") 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})") diff --git a/tools/uci/test_https_bad_finished.py b/tools/uci/test_https_bad_finished.py new file mode 100755 index 0000000..004296c --- /dev/null +++ b/tools/uci/test_https_bad_finished.py @@ -0,0 +1,641 @@ +#!/usr/bin/env python3 +"""test_https_bad_finished.py — the client must refuse a forged server Finished. + +Audit finding F2: the client verifies the server's Finished HMAC and aborts on +mismatch (``tls_verify_finished`` in ``src/tls_keyschedule.s``, ``bcs +@enc_error`` in ``src/tls13.s``), but nothing in the repo ever exercised the +abort. Every listener the suite talks to sends a *correct* Finished, so the +mismatch branch was dead weight as far as the tests were concerned — confirmed +by mutation: inverting it (``sec`` -> ``clc``) left the full hardware e2e +reaching HTTP 200 with the correct body. + +This test closes that hole end-to-end on real hardware. It points the C64 at +``tools/https_e2e/evil_listener.py``, a hand-rolled TLS 1.3 server that emits a +completely valid flight — real X25519 ECDHE, real key schedule, real +ChaCha20-Poly1305 records, real P-256 CertificateVerify — with exactly one bit +flipped in the server Finished ``verify_data`` before encryption. The AEAD tag +is correct, so the client cannot bail out at the record layer; it has to reach +the HMAC comparison to notice anything is wrong. + +Why not just corrupt the ciphertext: that breaks the Poly1305 tag, the client +rejects at ``aead_decrypt``, and the Finished comparison never runs. That would +pass this test while proving nothing about F2. + +Two modes, selected by ``FINISHED_MODE``: + + ``bad`` (default) the server sends the corrupted Finished. PASS requires the + client to abort *at Finished*. + ``good`` the identical server sends a correct Finished. PASS requires a + complete handshake and HTTP 200. This is the control: it proves the + hand-rolled server is a working TLS 1.3 server, so an abort in + ``bad`` mode is attributable to the one flipped bit and not to a + fixture that simply cannot talk to the client. + +Run ``good`` before trusting a ``bad`` result. + +Oracle +------ +Both directions are asserted, from both sides of the wire. + +C64 side — ``src/tls13.s:@error`` stashes the state it died in: + + bad : tls_state == $FF (ERROR) and tls_last_state == 6 (FINISHED) + and http_status != 200 + good : tls_state != $FF (ERROR) and http_status == 200 and the body + +(Not ``tls_state == CONNECTED`` for the good run: ``http_get``'s success path +calls ``tls_close``, which puts the state back to IDLE.) + +``tls_last_state`` is what makes this precise rather than merely negative: it +distinguishes "aborted at Finished" from "aborted earlier at Certificate (4) or +CertificateVerify (5)". A test that only checked "handshake failed" would pass +for a fixture that produced a broken certificate. + +Server side — evidence the client cannot fabricate, recorded in +``server_result.json``: + + bad : client_accepted_finished is False (the client never sent its own + Finished), and finished_corrupted is True + good : client_accepted_finished is True, client_finished_valid is True, + response_sent is True + +Note the server folds the Finished it actually sent into its own transcript, so +a client that wrongly *accepts* the corrupted Finished stays in lockstep and +sails on to HTTP 200. A broken client therefore fails fast and unambiguously +instead of hanging until the sentinel timeout. + +Environment +----------- + U64_HOST U64E / C64U address (default 192.168.1.81) + FINISHED_MODE bad (default) | good + TURBO_MHZ C64 CPU MHz (default 48); timeouts auto-scale + HTTPS_PORT listener port (default 4433) + SENTINEL_POLL_TIMEOUT / ACCEPT_TIMEOUT per-test overrides, seconds + C64_INIT_WAIT boot/auto-init wait before triggering (default 22 s, + scaled); comb-profile builds need 90+ + UCI_DEBUG_DIR artifact base dir (default /tmp/uci_bad_finished) + +Exit codes: 0 pass, 1 fail, 2 setup error, 3 device wedged. +""" +from __future__ import annotations + +import datetime +import json +import os +import socket +import sys +import threading +import time +from pathlib import Path + +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, + runner_health_check, + Ultimate64RunnerStuckError, + CAT_U64_SPECIFIC, + cpu_speed_enum, +) +from c64_test_harness.uci_network import enable_uci, disable_uci +from c64_test_harness.keyboard import send_text +from c64_test_harness.labels import Labels + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _memory_policy import ( # noqa: E402 + build_policy_and_arbiter_with_overlay_carveout, +) + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT / "tools" / "https_e2e")) +from evil_listener import ( # noqa: E402 + DEFAULT_BODY, + serve_one_connection, +) +from https_listener import _ensure_certs_p256 # noqa: E402 + +HOST = os.environ.get("U64_HOST", "192.168.1.81") +PRG_PATH = REPO_ROOT / "build" / "c64-https.prg" +LABELS_PATH = REPO_ROOT / "build" / "labels.txt" + +MODE_ENV = os.environ.get("FINISHED_MODE", "bad").lower() +if MODE_ENV not in ("bad", "good"): + print(f"ERROR: FINISHED_MODE must be 'bad' or 'good', got {MODE_ENV!r}", + file=sys.stderr) + sys.exit(2) +SERVER_MODE = "bad_finished" if MODE_ENV == "bad" else "good" + +TURBO_MHZ = int(os.environ.get("TURBO_MHZ", "48")) +_TIMEOUT_SCALE = max(1.0, 48.0 / float(TURBO_MHZ)) +SENTINEL_POLL_TIMEOUT = float( + os.environ.get("SENTINEL_POLL_TIMEOUT", str(600.0 * _TIMEOUT_SCALE)) +) +ACCEPT_TIMEOUT = float( + os.environ.get("ACCEPT_TIMEOUT", str(600.0 * _TIMEOUT_SCALE)) +) +HTTPS_PORT = int(os.environ.get("HTTPS_PORT", "4433")) +ARTIFACT_BASE = Path(os.environ.get("UCI_DEBUG_DIR", "/tmp/uci_bad_finished")) + +SENTINEL_VALUE = 0xAA + +# TLS_STATE_* from src/constants.inc +TLS_STATE_CERTIFICATE = 4 +TLS_STATE_CERT_VERIFY = 5 +TLS_STATE_FINISHED = 6 +TLS_STATE_CONNECTED = 7 +TLS_STATE_ERROR = 0xFF + +_STATE_NAMES = { + 0: "IDLE", 1: "CLIENT_HELLO", 2: "SERVER_HELLO", 3: "ENCRYPTED_EXT", + 4: "CERTIFICATE", 5: "CERT_VERIFY", 6: "FINISHED", 7: "CONNECTED", + 0xFF: "ERROR", +} + + +def _state_name(v: int) -> str: + return f"{_STATE_NAMES.get(v, '?')} (${v:02X})" + + +# Arbiter-assigned; see the long note in test_https_local.py about why these +# must never be hardcoded. +ROUTINE_ADDR = HOST_STR_ADDR = PATH_STR_ADDR = -1 +SENTINEL_ADDR = PROGRESS_ADDR = CARRY_FLAG_ADDR = -1 + + +def _detect_local_ip(target: str) -> str: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect((target, 80)) + return s.getsockname()[0] + finally: + s.close() + + +def _try_bind(bind_ip: str, port: int) -> socket.socket | None: + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + srv.bind((bind_ip, port)) + except OSError: + srv.close() + return None + return srv + + +def _build_http_routine(labels: dict[str, int], port: int) -> tuple[bytes, int]: + """6502 stub: set up http_get's inputs, call it, latch carry, signal done. + + Mirrors tools/uci/test_https_local.py's routine — same real code path + (``http_get`` -> ``tls_connect``), so the only thing this test changes + relative to the passing e2e is what the server puts on the wire. + """ + code = bytearray() + + def emit(*bs: int) -> None: + code.extend(bs) + + def emit_lda_imm(v: int) -> None: + emit(0xA9, v & 0xFF) + + def emit_sta_abs(a: int) -> None: + emit(0x8D, a & 0xFF, (a >> 8) & 0xFF) + + def emit_lda_abs(a: int) -> None: + emit(0xAD, a & 0xFF, (a >> 8) & 0xFF) + + def emit_jsr(a: int) -> None: + emit(0x20, a & 0xFF, (a >> 8) & 0xFF) + + def emit_progress(step: int) -> None: + emit_lda_imm(step) + emit_sta_abs(PROGRESS_ADDR) + + # Bank BASIC ROM out so $A000-$BFFF reads as RAM (crypto/TLS BSS lives + # there; without this the later DMA reads would return ROM bytes). + emit_lda_abs(0x0001) + emit(0x29, 0xFE) + emit_sta_abs(0x0001) + + emit_lda_imm(0x00) + emit_sta_abs(SENTINEL_ADDR) + emit_sta_abs(PROGRESS_ADDR) + emit_sta_abs(CARRY_FLAG_ADDR) + + emit_progress(0x01) + emit_jsr(labels["net_init"]) + + emit_lda_imm(0x00) + emit_sta_abs(labels["tcp_recv_head"]) + emit_sta_abs(labels["tcp_recv_head"] + 1) + emit_sta_abs(labels["tcp_recv_tail"]) + emit_sta_abs(labels["tcp_recv_tail"] + 1) + + emit_progress(0x02) + + emit_lda_imm(HOST_STR_ADDR & 0xFF) + emit_sta_abs(labels["http_host_ptr"]) + emit_lda_imm((HOST_STR_ADDR >> 8) & 0xFF) + emit_sta_abs(labels["http_host_ptr"] + 1) + + host_len_patch_offset = len(code) + 1 + emit_lda_imm(0x00) + emit_sta_abs(labels["http_host_len"]) + + emit_lda_imm(PATH_STR_ADDR & 0xFF) + emit_sta_abs(labels["http_path_ptr"]) + emit_lda_imm((PATH_STR_ADDR >> 8) & 0xFF) + emit_sta_abs(labels["http_path_ptr"] + 1) + emit_lda_imm(1) + emit_sta_abs(labels["http_path_len"]) + + emit_lda_imm(port & 0xFF) + emit_sta_abs(labels["http_port"]) + emit_lda_imm((port >> 8) & 0xFF) + emit_sta_abs(labels["http_port"] + 1) + + emit_progress(0x03) + emit_jsr(labels["http_get"]) + + # Latch the carry into RAM rather than reading the CPU status register + # over the wire. PHP/PLA puts the whole P register in A; bit 0 is C. + emit(0x08) # PHP + emit(0x68) # PLA + emit_sta_abs(CARRY_FLAG_ADDR) + + emit_progress(0x04) + emit_lda_imm(SENTINEL_VALUE) + emit_sta_abs(SENTINEL_ADDR) + emit_progress(0x05) + + park = ROUTINE_ADDR + len(code) + emit(0x4C, park & 0xFF, (park >> 8) & 0xFF) + return bytes(code), host_len_patch_offset + + +def _decode_screen_ram(data: bytes) -> str: + """Screen codes -> ASCII, 40 columns.""" + out = [] + for row in range(min(25, len(data) // 40)): + line = [] + for col in range(40): + c = data[row * 40 + col] + if c == 0x20 or c == 0x00: + line.append(" ") + elif 0x01 <= c <= 0x1A: + line.append(chr(ord("A") + c - 1)) + elif 0x30 <= c <= 0x39: + line.append(chr(c)) + elif c == 0x2E: + line.append(".") + elif c == 0x2D: + line.append("-") + elif c == 0x3A: + line.append(":") + elif c == 0x2F: + line.append("/") + else: + line.append(".") + out.append("".join(line).rstrip()) + return "\n".join(out) + + +def _read_c64_state(transport, labels) -> dict: + def rd(name: str, n: int = 1) -> bytes: + return bytes(transport.read_memory(labels[name], n)) + + resp_len_raw = rd("http_resp_len", 2) + resp_len = resp_len_raw[0] | (resp_len_raw[1] << 8) + status_raw = rd("http_status", 2) + read_len = min(resp_len, 200) if resp_len > 0 else 64 + return { + "tls_state": rd("tls_state")[0], + "tls_last_state": rd("tls_last_state")[0], + "http_status": status_raw[0] | (status_raw[1] << 8), + "http_resp_len": resp_len, + "http_resp_buf": bytes( + transport.read_memory(labels["http_resp_buf"], read_len) + ), + "net_last_error": ( + rd("net_last_error")[0] if "net_last_error" in labels else None + ), + } + + +def _write_artifacts(run_dir: Path, *, server_result: dict, c64: dict, + screen_text: str, mode: str, outcome: str, + reasons: list[str]) -> None: + run_dir.mkdir(parents=True, exist_ok=True) + serialisable = dict(server_result) + for k, v in list(serialisable.items()): + if isinstance(v, (bytes, bytearray)): + serialisable[k] = v.decode("latin-1") + elif isinstance(v, tuple): + serialisable[k] = list(v) + (run_dir / "server_result.json").write_text( + json.dumps(serialisable, indent=2, default=str) + ) + c64_json = dict(c64) + if isinstance(c64_json.get("http_resp_buf"), (bytes, bytearray)): + c64_json["http_resp_buf"] = c64_json["http_resp_buf"].decode( + "ascii", errors="replace" + ) + (run_dir / "c64_state.json").write_text(json.dumps(c64_json, indent=2)) + (run_dir / "screen.txt").write_text(screen_text) + (run_dir / "run_info.txt").write_text( + f"mode : {mode}\n" + f"outcome : {outcome}\n" + f"host : {HOST}\n" + f"turbo_mhz : {TURBO_MHZ}\n" + f"reasons :\n" + "".join(f" - {r}\n" for r in reasons) + ) + + +def _evaluate(mode: str, server_result: dict, c64: dict, + screen_text: str) -> tuple[bool, list[str]]: + """Return (passed, reasons). Every criterion is reported, pass or fail.""" + reasons: list[str] = [] + ok = True + + def check(cond: bool, msg: str) -> None: + nonlocal ok + reasons.append(("OK " if cond else "FAIL ") + msg) + if not cond: + ok = False + + err = server_result.get("error") + check(not err, f"server reported no error (error={err!r})") + check(bool(server_result.get("client_hello_seen")), + "server received a ClientHello") + check(bool(server_result.get("server_flight_sent")), + "server sent its full handshake flight") + + body = c64.get("http_resp_buf", b"").decode("ascii", errors="replace") + + if mode == "bad": + check(bool(server_result.get("finished_corrupted")), + "server actually corrupted the Finished verify_data") + check(server_result.get("client_accepted_finished") is False, + "client did NOT send its own Finished " + f"(reaction: {server_result.get('client_reaction')!r})") + check(c64["tls_state"] == TLS_STATE_ERROR, + f"tls_state is ERROR (got {_state_name(c64['tls_state'])})") + check(c64["tls_last_state"] == TLS_STATE_FINISHED, + "abort happened AT Finished, not earlier " + f"(tls_last_state = {_state_name(c64['tls_last_state'])})") + check(c64["http_status"] != 200, + f"no HTTP 200 was parsed (http_status={c64['http_status']})") + check(DEFAULT_BODY not in body, + "response body was not received") + check(DEFAULT_BODY.upper() not in screen_text.upper(), + "response body did not reach the screen either") + else: + check(server_result.get("client_accepted_finished") is True, + "client sent its own Finished") + check(server_result.get("client_finished_valid") is True, + "client Finished verified against the server's expectation") + check(bool(server_result.get("response_sent")), + "server sent the HTTP response") + req = server_result.get("request") or b"" + if isinstance(req, str): + req = req.encode("latin-1") + check(req.startswith(b"GET "), + f"server decrypted a GET request ({req[:40]!r})") + # NOT `== CONNECTED`: on the success path http_get calls tls_close, + # which sets tls_state back to IDLE (src/tls13.s:tls_close). CONNECTED + # is only observable mid-flight. What matters here is that the + # handshake never took the error path — measured on hardware, where + # the naive CONNECTED assertion failed a genuinely passing run. + check(c64["tls_state"] != TLS_STATE_ERROR, + f"tls_state is not ERROR (got {_state_name(c64['tls_state'])})") + check(c64["http_status"] == 200, + f"http_status is 200 (got {c64['http_status']})") + check(DEFAULT_BODY in body, + f"http_resp_buf holds the expected body ({body[:40]!r})") + + return ok, reasons + + +def main() -> int: + if not PRG_PATH.is_file() or not LABELS_PATH.is_file(): + print(f"ERROR: build artifacts missing; run `make BACKEND=uci` first", + file=sys.stderr) + return 2 + + labels = dict(Labels.from_file(LABELS_PATH)) + required = [ + "http_get", "http_host_ptr", "http_host_len", + "http_path_ptr", "http_path_len", "http_port", + "net_init", "net_initialized", + "tcp_recv_head", "tcp_recv_tail", + "http_resp_buf", "http_resp_len", "http_status", + "tls_state", "tls_last_state", + ] + missing = [n for n in required if n not in labels] + if missing: + # A missing label is a broken test, not a skippable one (finding F3). + print(f"ERROR: missing labels: {missing}", file=sys.stderr) + return 2 + + print(f"=== HTTPS bad-Finished e2e ({MODE_ENV.upper()} mode) ===") + print(f"Device : {HOST} @ {TURBO_MHZ} MHz") + print(f"Server mode : {SERVER_MODE}") + print(f"PRG : {PRG_PATH}") + + global ROUTINE_ADDR, HOST_STR_ADDR, PATH_STR_ADDR + global SENTINEL_ADDR, PROGRESS_ADDR, CARRY_FLAG_ADDR + 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") + SENTINEL_ADDR = arbiter.alloc(1, name="sentinel") + PROGRESS_ADDR = arbiter.alloc(1, name="progress") + CARRY_FLAG_ADDR = arbiter.alloc(1, name="carry_flag") + for base, last, note in arbiter.allocations: + print(f" ${base:04X}-${last:04X} {note}") + + cert_path, key_path = _ensure_certs_p256() + test_host_ip = _detect_local_ip(HOST) + srv = _try_bind(test_host_ip, HTTPS_PORT) + if srv is None: + print(f"ERROR: could not bind {test_host_ip}:{HTTPS_PORT}", + file=sys.stderr) + return 2 + print(f"Listener : {test_host_ip}:{HTTPS_PORT} (cert {cert_path})") + + server_result: dict = {} + server_thread = threading.Thread( + target=serve_one_connection, + args=(srv, cert_path, key_path), + kwargs=dict(mode=SERVER_MODE, body=DEFAULT_BODY, + timeout=ACCEPT_TIMEOUT, result=server_result), + daemon=True, + ) + server_thread.start() + for _ in range(100): + if server_result.get("listening"): + break + time.sleep(0.05) + else: + print("ERROR: listener failed to come up", file=sys.stderr) + return 2 + + routine_raw, host_len_patch = _build_http_routine(labels, HTTPS_PORT) + routine = bytearray(routine_raw) + host_bytes = test_host_ip.encode("ascii") + routine[host_len_patch] = len(host_bytes) + routine = bytes(routine) + + prg = PRG_PATH.read_bytes() + run_dir = ARTIFACT_BASE / datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + lock = DeviceLock(HOST) + try: + lock.acquire_or_raise(timeout=300.0) + except DeviceLockTimeout as exc: + print(f"[fatal] DeviceLock({HOST}): {exc}", file=sys.stderr) + return 2 + print(f"Acquired DeviceLock({HOST})") + + client: Ultimate64Client | None = None + uci_enabled = False + try: + client = Ultimate64Client(host=HOST, timeout=15.0) + transport = Ultimate64Transport(host=HOST, timeout=15.0, client=client) + transport.memory_policy = memory_policy + + print("Enabling UCI...") + enable_uci(client) + uci_enabled = True + + try: + runner_health_check(client) + except Ultimate64RunnerStuckError as exc: + print(f"[fatal] runner wedged at {HOST}: {exc}", file=sys.stderr) + return 3 + + # Set turbo BEFORE boot, and skip a redundant write — the config write + # itself is what glitches the UCI bridge on a C64U (see the long note + # in test_https_local.py and the c64u_starlight_device memory). + try: + cat = client.get_config_category(CAT_U64_SPECIFIC) + inner = cat.get(CAT_U64_SPECIFIC, cat) + cur_speed, cur_turbo = inner.get("CPU Speed"), inner.get("Turbo Control") + except Exception as exc: + print(f" (turbo probe failed: {exc}; writing anyway)") + cur_speed = cur_turbo = None + if str(cur_speed) == str(cpu_speed_enum(TURBO_MHZ)) and cur_turbo == "Manual": + print(f"Turbo already {TURBO_MHZ} MHz — skipping config write") + else: + print(f"Setting turbo {cur_turbo}/{cur_speed} -> {TURBO_MHZ} MHz") + set_turbo_mhz(client, TURBO_MHZ) + time.sleep(float(os.environ.get("TURBO_SETTLE", "3.0"))) + + print("Resetting machine...") + client.reset() + time.sleep(2.5) + + print("run_prg(PRG)...") + client.run_prg(prg) + time.sleep(float(os.environ.get("C64_INIT_WAIT", "22")) * _TIMEOUT_SCALE) + + init_flag = transport.read_memory(labels["net_initialized"], 1)[0] + print(f"net_initialized = ${init_flag:02X}") + if init_flag == 0: + print("WARNING: net_initialized is 0 — auto-init may have failed") + + print("Sending 'Q' to exit main_loop...") + send_text(transport, "q\r") + time.sleep(2.0 * _TIMEOUT_SCALE) + + for i in range(0, len(routine), 64): + transport.write_memory(ROUTINE_ADDR + i, routine[i:i + 64]) + transport.write_memory(HOST_STR_ADDR, (host_bytes + b"\x00").ljust(32, b"\x00")) + transport.write_memory(PATH_STR_ADDR, b"/\x00".ljust(8, b"\x00")) + transport.write_memory(SENTINEL_ADDR, bytes(16)) + + print(f"Triggering: sys{ROUTINE_ADDR}") + send_text(transport, f"sys{ROUTINE_ADDR}\r") + + deadline = time.time() + SENTINEL_POLL_TIMEOUT + start = time.time() + last_progress = -1 + completed = False + while time.time() < deadline: + time.sleep(0.5) + blob = transport.read_memory(SENTINEL_ADDR, 2) + if blob[1] != last_progress: + print(f" [{time.time() - start:6.1f}s] progress=0x{blob[1]:02X}") + last_progress = blob[1] + if blob[0] == SENTINEL_VALUE: + completed = True + print(f" sentinel set after {time.time() - start:.1f}s") + break + + server_thread.join(timeout=10.0) + + c64 = _read_c64_state(transport, labels) + screen_text = _decode_screen_ram( + bytes(transport.read_memory(0x0400, 1000)) + ) + + print("\n--- C64 state ---") + print(f" tls_state = {_state_name(c64['tls_state'])}") + print(f" tls_last_state = {_state_name(c64['tls_last_state'])}") + print(f" http_status = {c64['http_status']}") + print(f" http_resp_len = {c64['http_resp_len']}") + print(f" http_resp_buf = " + f"{c64['http_resp_buf'][:48].decode('ascii', 'replace')!r}") + print("\n--- server saw ---") + for k, v in server_result.items(): + print(f" {k:26s} = {v!r}") + print("\n--- screen ---") + print(screen_text) + + if not completed: + # The 6502 stub never signalled completion, so http_get is still + # running or wedged. We cannot say what the client decided — + # inconclusive is a failure, never a pass. + reasons = [f"FAIL routine did not complete within " + f"{SENTINEL_POLL_TIMEOUT:.0f}s " + f"(progress=0x{last_progress:02X}) — inconclusive"] + passed = False + else: + passed, reasons = _evaluate(MODE_ENV, server_result, c64, screen_text) + + outcome = "PASS" if passed else "FAIL" + print(f"\n--- criteria ({MODE_ENV} mode) ---") + for r in reasons: + print(f" {r}") + _write_artifacts(run_dir, server_result=server_result, c64=c64, + screen_text=screen_text, mode=MODE_ENV, + outcome=outcome, reasons=reasons) + print(f"\nArtifacts: {run_dir}") + print(f"\n{outcome}: " + + ("client rejected the forged server Finished" + if passed and MODE_ENV == "bad" else + "handshake completed against the control listener" + if passed else + "see failed criteria above")) + return 0 if passed else 1 + + finally: + if uci_enabled and client is not None: + try: + disable_uci(client) + except Exception as exc: + print(f"WARNING: disable_uci failed: {exc}") + try: + lock.release() + except Exception: + pass + try: + srv.close() + except Exception: + pass + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/uci/test_https_local.py b/tools/uci/test_https_local.py index 6884128..d1817cf 100644 --- a/tools/uci/test_https_local.py +++ b/tools/uci/test_https_local.py @@ -47,7 +47,17 @@ - Writes a sentinel on completion 5. Trigger with SYS 16896 via keyboard buffer. 6. Poll sentinel for up to 120 s (handshake is ~13-15 s at 48 MHz). - 7. Assert response body contains "HELLO FROM TLS SERVER". + 7. Assert BOTH sides: + - C64 side: http_resp_buf holds the complete "HELLO FROM TLS SERVER" + body (screen RAM is diagnostic only — see _check_c64_result). + - Server side: the listener completed the handshake, recorded no TLS + error, and decrypted the GET the C64 was told to send (see + _check_server_result). Skipped under EXTERNAL_LISTENER=1, which by + contract has no inline listener. + +Offline re-check of an archived run (no hardware): + ./test_https_local.py --check-artifact [--host IP] [--path P] +re-runs the server-side criteria against that run's server_result.json. """ from __future__ import annotations @@ -288,6 +298,156 @@ def _run_https_server(srv: socket.socket, ctx: ssl.SSLContext, pass +def _check_c64_result(body_ascii: str, screen_text: str) -> list[str]: + """C64-side pass criteria. Returns a list of problems; empty ⇒ pass. + + The only accepted criterion is that ``http_resp_buf`` holds the complete + expected body. Screen RAM is diagnostic, never evidence. + + History (audit finding F5): this used to fall back to passing the run + whenever the 5 characters ``HELLO`` appeared anywhere in the 1000 bytes + of screen RAM. That branch was reachable *only* after the body assertion + had already failed, so it substituted a weaker criterion at exactly the + moment the strong one did not hold — a truncated body, a mis-decrypted + body that happened to keep its first word, or a stale ``HELLO`` left on + screen by an earlier run all passed. The fallback is gone; a screen-RAM + hit without the body is now reported as a *reason the run failed*. + """ + problems: list[str] = [] + if EXPECTED_BODY not in body_ascii: + problems.append( + f"http_resp_buf does not contain the expected body " + f"{EXPECTED_BODY!r} (got {body_ascii[:120]!r})" + ) + if "HELLO" in screen_text.upper(): + problems.append( + "screen RAM contains 'HELLO' but that is not a pass — only " + "the complete body in http_resp_buf counts (audit F5)" + ) + return problems + + +def _server_request_bytes(server_result: dict) -> bytes | None: + """Normalize the listener's ``request`` field to bytes, or None if absent. + + Accepts both shapes: the in-process dict (raw ``bytes``) and the + ``server_result.json`` on-disk form written by + ``_serialize_server_result`` (``{"__type__": "bytes-b64", ...}``), so the + same checker runs live and against an archived run directory. + """ + req = server_result.get("request") + if req is None: + return None + if isinstance(req, (bytes, bytearray)): + return bytes(req) + if isinstance(req, dict) and req.get("__type__") == "bytes-b64": + try: + return base64.b64decode(req.get("b64", "")) + except Exception: + return None + if isinstance(req, str): + return req.encode("utf-8", errors="replace") + return None + + +def _check_server_result(server_result: dict, *, + expect_host: str | None, + expect_path: str = "/") -> list[str]: + """Server-side pass criteria. Returns a list of problems; empty ⇒ pass. + + Audit finding F6: every run already recorded what the listener observed + into ``server_result.json`` — handshake completion, the decrypted + request, any TLS error — and the pass criteria never read a byte of it. + Every assertion was made against C64-side memory and screen RAM, i.e. + against state the client itself produces. The listener's record is the + one piece of evidence the client cannot fabricate, so it is now part of + the verdict. + + Which fields are load-bearing, and why these and not more: + + ``error`` — decisive. Set when the TLS handshake or the socket + failed on the server side. A run that reached + ``SSLEOFError: UNEXPECTED_EOF_WHILE_READING`` did not + complete a TLS session with this listener, whatever + the C64's RAM says afterwards. + ``listening`` — the listener reached ``accept()``. Absent ⇒ there was + no server for the C64 to have talked to. + ``client_addr`` — the listener accepted a connection. Absent ⇒ the C64 + never reached this listener; anything in http_resp_buf + is then stale or fabricated, not this run's evidence. + ``request`` — the decrypted request must be the one the C64 was + configured to send. This is the strongest link + between the two sides: the server can only produce + these plaintext bytes by having completed the + handshake and derived the same application keys. + + All three of ``listening`` / ``client_addr`` / ``request`` are required, + so the function fails closed: an empty record — listener thread never + started, crashed before recording anything, artifact absent — is an + inconclusive check and therefore a failure, never an implicit "no error + recorded". + + Deliberately NOT asserted, to avoid failing healthy runs: + + - Byte-exact request equality. Only the request line and the ``Host:`` + line are checked — both are values *this script* programmed into the + C64, so they cannot drift accidentally. Trailing headers + (``Connection: close``) are src/http.s's business; a future header + change should not fail the e2e oracle. + - ``request`` in EXTERNAL_LISTENER mode. There is no inline listener + then, ``server_result`` is empty by construction, and the documented + contract is that pass criteria come from C64 state only. The caller + skips this whole function in that mode and says so out loud. + - The listener sets ``request`` to ``b""`` when the + post-handshake ``recv`` times out; that is treated as a failure, not + as an absent field, because it means the session never carried the + GET. + """ + problems: list[str] = [] + + err = server_result.get("error") + if err: + problems.append(f"listener recorded an error: {err}") + + if not server_result.get("listening"): + problems.append( + "listener never reported `listening` — no server side to this run" + ) + + if server_result.get("client_addr") is None: + problems.append( + "listener never accepted a connection (`client_addr` absent) — " + "the C64 did not reach this listener" + ) + + req = _server_request_bytes(server_result) + if req is None: + problems.append( + "listener recorded no decrypted request — the TLS session never " + "carried the GET" + ) + elif req == b"": + problems.append( + "listener timed out waiting for the request after the handshake" + ) + else: + want_line = b"GET " + expect_path.encode("ascii") + b" HTTP/1." + if not req.startswith(want_line): + problems.append( + f"decrypted request does not start with {want_line!r} " + f"(got {req[:40]!r})" + ) + if expect_host is not None: + want_host = b"Host: " + expect_host.encode("ascii") + if want_host not in req: + problems.append( + f"decrypted request lacks {want_host!r} " + f"(got {req[:80]!r})" + ) + + return problems + + def _load_labels() -> dict[str, int]: # c64-test-harness Labels is a Mapping since 0.12.4 (JC-000/c64-test-harness#64) # and parses both C: and non-C (REU/bank) label lines since #62. @@ -1388,23 +1548,38 @@ def main() -> int: except Exception: pass - if EXPECTED_BODY in body_ascii: - print(f"\nPASS: http_resp_buf contains '{EXPECTED_BODY}'") - outcome = "PASS" - exit_code = 0 - return exit_code + problems = _check_c64_result(body_ascii, screen_text) - if "HELLO" in screen_text.upper(): - print(f"\nPASS: screen RAM contains HELLO " - f"(body in resp_buf may differ in encoding)") - outcome = "PASS" - exit_code = 0 + # Server-side corroboration (audit F6). The listener's record is the + # only evidence in this test the client cannot produce on its own, so + # a run passes only when both sides agree. Skipped — loudly — under + # EXTERNAL_LISTENER=1, where by contract there is no inline listener + # and no server_result to read. + if EXTERNAL_LISTENER: + print("\nNOTE: EXTERNAL_LISTENER=1 — server-side criteria " + "skipped (no inline listener); C64-side state only") + else: + server_problems = _check_server_result( + server_result, + expect_host=test_host_ip, + expect_path=path_str.rstrip(b"\x00").decode("ascii"), + ) + problems.extend(server_problems) + if not server_problems: + print("\nServer-side check: listener completed the handshake " + "and decrypted the expected GET") + + if problems: + print("\nFAIL: pass criteria not met:", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + outcome = "FAIL" + exit_code = 1 return exit_code - print(f"\nFAIL: expected '{EXPECTED_BODY}' not found in response" - f" or screen", file=sys.stderr) - outcome = "FAIL" - exit_code = 1 + print(f"\nPASS: http_resp_buf contains '{EXPECTED_BODY}'") + outcome = "PASS" + exit_code = 0 return exit_code finally: @@ -1499,5 +1674,71 @@ def main() -> int: print(f"Released DeviceLock({HOST})") +def _check_artifact_main(argv: list[str]) -> int: + """`--check-artifact [--host IP] [--path P]` + + Re-run the server-side pass criteria (audit F6) against an archived run + without touching hardware. Exists so the criteria are testable — and so a + stored run can be re-adjudicated after the criteria change. + """ + if not argv: + print("usage: test_https_local.py --check-artifact " + " [--host IP] [--path P]", + file=sys.stderr) + return 2 + target = Path(argv[0]) + host: str | None = None + path = "/" + rest = argv[1:] + while rest: + flag = rest.pop(0) + if flag == "--host" and rest: + host = rest.pop(0) + elif flag == "--path" and rest: + path = rest.pop(0) + else: + print(f"unknown argument: {flag}", file=sys.stderr) + return 2 + + if target.is_dir(): + target = target / "server_result.json" + + # Fail closed: an absent or unreadable record is an inconclusive check, + # never an implicit "no error recorded". Same defect shape as the F5/F6 + # fallbacks themselves, one layer up. + if not target.is_file(): + print(f"FAIL: no server_result.json at {target} — the server-side " + f"record is missing, which is not a pass", file=sys.stderr) + return 1 + try: + server_result = json.loads(target.read_text()) + except Exception as exc: + print(f"FAIL: could not read {target}: {exc}", file=sys.stderr) + return 1 + if not isinstance(server_result, dict): + print(f"FAIL: {target} is not a JSON object " + f"(got {type(server_result).__name__})", file=sys.stderr) + return 1 + print(f"server_result : {target}") + print(f" listening : {server_result.get('listening', False)}") + print(f" client_addr : {server_result.get('client_addr')}") + print(f" request : {_server_request_bytes(server_result)!r}") + print(f" error : {server_result.get('error', '')}") + if host is None: + print(" (no --host given; the Host-header check is skipped)") + + problems = _check_server_result( + server_result, expect_host=host, expect_path=path) + if problems: + print("\nFAIL: server-side criteria not met:", file=sys.stderr) + for p in problems: + print(f" - {p}", file=sys.stderr) + return 1 + print("\nPASS: server-side criteria met") + return 0 + + if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--check-artifact": + raise SystemExit(_check_artifact_main(sys.argv[2:])) raise SystemExit(main())