From 18ce6020afbd653c1cc6a42711dd7f9093e625c4 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:38:30 -0500 Subject: [PATCH 1/7] PQC P0: pin libs/mlkem v0.6.0 and place it behind PQ_HYBRID=1 Link-only: nothing calls ML-KEM. Code+rodata linked at $E000 (file="", not in the PRG: #288), LIB_MLKEM_POLY_BSS at $D000, LIB_MLKEM_BSS at $B100 over X25519_TABLES + hole + mul_dma, dk scratch in the TCP ring, ZP $40-$4F, shared sqtab at $BC00. Asserts in src/crypto/mlkem_link.s, scrub spans for all three secret homes, tools/test_mlkem_placement.py mutation-proves them. Unset, all seven profiles are byte-identical to master. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitmodules | 3 + CLAUDE.md | 36 ++- Makefile | 54 ++++- cfg/c64-https-uci-onchip-cold.cfg | 27 ++- cfg/c64-https-uci-onchip.cfg | 27 ++- cfg/c64-https-uci.cfg | 27 ++- docs/engineering-notes.md | 44 ++++ libs/mlkem | 1 + src/constants.inc | 16 +- src/crypto/mlkem_link.s | 216 ++++++++++++++++++ src/session_scrub.s | 46 ++++ tools/integration/build_mlkem.sh | 215 ++++++++++++++++++ tools/measure_margins.py | 11 + tools/test_mlkem_placement.py | 353 ++++++++++++++++++++++++++++++ tools/test_quit_basic_exit.py | 45 ++++ 15 files changed, 1114 insertions(+), 7 deletions(-) create mode 160000 libs/mlkem create mode 100644 src/crypto/mlkem_link.s create mode 100644 tools/integration/build_mlkem.sh create mode 100644 tools/test_mlkem_placement.py diff --git a/.gitmodules b/.gitmodules index c6a8ca3..2f6b51c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "libs/x25519"] path = libs/x25519 url = https://github.com/JC-000/c64-x25519.git +[submodule "libs/mlkem"] + path = libs/mlkem + url = https://github.com/JC-000/c64-mlkem.git diff --git a/CLAUDE.md b/CLAUDE.md index 9d7c30d..519cba4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,6 +142,13 @@ Variables: and an `$(error)` with `TRUST_STORE=1`) — both exist for uci only, so there is no ip65 comb build. Confirm from the `ld65 -C` line, not from the profile name. + - `PQ_HYBRID=1` — links `libs/mlkem` (c64-mlkem v0.6.0, ABI 3) for the + planned X25519MLKEM768 hybrid; **placement only, nothing calls it**. uci + + `USE_NISTCURVES_ONCHIP=1` or `_COMB=1`, with or without `TRUST_STORE`; + `$(error)` on ip65, uci-m3 and the uci REU profile. Unset = byte-identical + PRG on every profile. Layout in Memory layout; asserts in + `src/crypto/mlkem_link.s`, guard `tools/test_mlkem_placement.py`. The + code is **linked at $E000 but not in the PRG** (#288): not runnable yet. - `USE_X25519_SIBLING` — **retired, `$(error)`-guarded (#245)**: every build links the `libs/x25519` sibling; any value is refused. - `ENABLE_P384_VERIFY`, `USE_OVERLAY_P384_EMBED`, `KEEP_CRYPTO_SWAP`, @@ -234,6 +241,19 @@ fixed buffers in crypto BSS. `$1E-$2F`, `fe_wide` at ZP_WIDE `$40-$7F` — all time-shared with per-call slots (table in `build_x25519.sh`); they are not in `labels.txt`. + - ML-KEM (`libs/mlkem`, PQ_HYBRID only) zero page `$40-$4F` + (`mlkem_zp_src $40`, `_dst $42`, `_len $44`, `_tmp $46`, `_mul $48-$4F`; + -D values in `build_mlkem.sh`, pinned in `mlkem_link.s`). Upstream's + `$30-$3F` default hits `tls_hs_ptr $3E-$3F` and ECDSA/nistcurves. Only + co-tenant is X25519's per-call `fe_wide` ($40-$7F); ML-KEM never nests + in X25519, fp_mul or ECDSA, nor they in it. Other users of `$40-$7F`: none. + + | range | owner | + |---|---| + | `$22-$3C`, `$39/$3A/$3D` | ECDSA bignum; nistcurves fp_mul_i/j, ptr2 | + | `$3E-$3F` | `tls_hs_ptr` (live across handlers) | + | `$14-$16`,`$1C`,`$1E-$2F` | X25519 / ChaCha / Poly1305, time-shared | + | `$40-$7F` | X25519 `fe_wide`; ML-KEM `$40-$4F` | - c64-lib-contract is a **prose dependency, not a submodule** — nothing in `make` reads it, so a contract release can never break a build. Always cite a **tag** (newest: v1.1.0). v1.0.0 cut the SPEC by 7/8 and @@ -790,14 +810,26 @@ UCI (`cfg/c64-https-uci.cfg`, W1 hot/cold split — the reference): at $BA00 so sqtab lands at $BC00 (LIB_SHARED_SQTAB_BASE, asserted post-link) - $C000-$CFFF (no region) TCP ring by equate; not in the PRG, + $C000-$CFFF (no region) TCP ring by equate (PQ_HYBRID: first 2,400 B = ML-KEM dk scratch); not in the PRG, except comb's COLD_IMAGE (cold bank) — the PRG ends $BFFF; comb with the cold bank (COLD_BANK=1) ends $CFFF - $D000-$DFFF I/O never in the PRG: a KERNAL LOAD would + $D000-$DFFF I/O (PQ_HYBRID: LIB_MLKEM_POLY_BSS in the RAM + under it, $01=$34, file="") + never in the PRG: a KERNAL LOAD would store into the registers (#288, `tools/test_prg_io_range.py`) +PQ_HYBRID=1 adds three `file=""` areas to the uci cfgs (`cfg/c64-https-uci.cfg` +serves uci-onchip; `-onchip-cold.cfg` comb; `-onchip.cfg` comb COLD_BANK=0): +`MLKEM_CODE_RAM` $E000-$FDFF (code+rodata, $E000-$FD40 = 7,489 B of 7,680; KERNAL out), +`MLKEM_POLY_RAM` $D000-$DFFF (4,096 B polys), `MLKEM_BSS_RAM` $B100-$BBFF +(2,545 B of 2,816, over X25519_TABLES + the $B900 hole + mul_dma). Rules: +ML-KEM never interleaves with X25519 or fp_mul, and **every ML-KEM call group +needs `mlkem_arith_init` first** (its 1 KB of mod-q tables live in that +window, which X25519 rebuilds over); calls run under `sei` with `$01=$34`. +All pinned in `src/crypto/mlkem_link.s`; scrub spans in `session_scrub.s`. + ip65 (`cfg/c64-https-ip65.cfg`): $0801-$1FFF LOADER diff --git a/Makefile b/Makefile index 6a1c561..c566542 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ ifeq ($(BACKEND),uci-m3) M3_REFUSED_KNOBS := USE_NISTCURVES_ONCHIP USE_NISTCURVES_ONCHIP_COMB \ COLD_BANK TRUST_STORE TLS_STREAM_DEFRAME \ KEEP_X509_PARSE_CERT KEEP_WORD32_ALL HTTPS_PIN_WARN \ - HTTPS_SNI VIC_BLANK + HTTPS_SNI VIC_BLANK PQ_HYBRID $(foreach k,$(M3_REFUSED_KNOBS),$(if $(strip $($(k))),$(error $(k)=$($(k)) does not apply to BACKEND=uci-m3: it links no 6510 TLS or crypto (the ESP32 does the TLS); drop the variable))) ifneq ($(filter-out 0 1,$(M3_ALLOW_TLS12)),) $(error M3_ALLOW_TLS12 must be 1 (offer TLS 1.3 and hardened 1.2) or 0/unset (TLS 1.3 only)) @@ -432,6 +432,46 @@ SIBLING_LIB_ARCHIVES += build/lib/x25519.a CRYPTO_SRCS_EFFECTIVE := $(CRYPTO_SRCS_ALL) +# PQ_HYBRID=1 - link libs/mlkem (c64-mlkem v0.6.0 ML-KEM-768) for the +# planned X25519MLKEM768 hybrid key exchange (PQC plan, phase P0: placement +# only). Nothing calls ML-KEM and the protocol is unchanged. Unset, the +# archive, src/crypto/mlkem_link.s, the scrub spans and the -D are all gated +# here and the cfg segment lines are `optional = yes`, so every profile's PRG +# is byte-identical to a tree that has never heard of the knob. +# +# Allowed on exactly the profiles the product list could ship it on: +# BACKEND=uci with USE_NISTCURVES_ONCHIP=1 or USE_NISTCURVES_ONCHIP_COMB=1, +# with or without TRUST_STORE=1. Refused on: +# ip65 ~41 B of overlay, no streaming deframer: it cannot carry the +# ~1.2 KB multi-record hybrid ServerHello at all. +# uci-m3 links no 6510 TLS (M3_REFUSED_KNOBS above). +# uci REU (no USE_NISTCURVES_ONCHIP): not a shipped profile, and its +# fp_mul row-fetch DMA lands in mul_dma_lo/hi, which the ML-KEM +# BSS union claims; the "ML-KEM never interleaves with fp_mul" +# rule is cheaper to keep by not building it. +# The placement, the asserts that pin it and the zero-page map are in +# src/crypto/mlkem_link.s, cfg/c64-https-uci-onchip*.cfg (MLKEM_*_RAM) and +# tools/integration/build_mlkem.sh. +PQ_HYBRID ?= +ifneq ($(filter-out 0 1,$(PQ_HYBRID)),) +$(error PQ_HYBRID must be 1 (link libs/mlkem) or 0/unset) +endif +ifeq ($(PQ_HYBRID),1) +ifeq ($(BACKEND),ip65) +$(error PQ_HYBRID=1 is UCI-only: ip65 has no streaming deframer and ~41 B of overlay, so it cannot carry a hybrid ServerHello) +endif +ifneq ($(BACKEND),uci) +$(error PQ_HYBRID=1 needs BACKEND=uci (got BACKEND=$(BACKEND))) +endif +ifneq ($(USE_NISTCURVES_ONCHIP),1) +$(error PQ_HYBRID=1 needs USE_NISTCURVES_ONCHIP=1 or USE_NISTCURVES_ONCHIP_COMB=1: the uci REU profile does fp_mul row-fetch DMA into mul_dma_lo/hi, which the ML-KEM BSS union claims, and it is not a shipped product) +endif +CA65FLAGS += -D PQ_HYBRID=1 +SIBLING_LIB_ARCHIVES += build/lib/mlkem.a +else +CRYPTO_SRCS_EFFECTIVE := $(filter-out src/crypto/mlkem_link.s,$(CRYPTO_SRCS_EFFECTIVE)) +endif + # #155 phase 2 memory reclaims: code no shipped image reaches is assembled # out by default. Nothing is deleted; each knob re-arms its piece, and with # the knobs set the PRG is byte-identical to the pre-reclaim one on every @@ -862,6 +902,11 @@ $(eval $(call SIBLING_SRC_CHECK,build/lib/x25519.src.stamp,libs/x25519,\ $(eval $(call SIBLING_SRC_CHECK,build/lib/nistcurves.src.stamp,libs/nistcurves,\ tools/integration/build_nistcurves_p256.sh,\ build/lib/nistcurves-*)) +ifeq ($(PQ_HYBRID),1) +$(eval $(call SIBLING_SRC_CHECK,build/lib/mlkem.src.stamp,libs/mlkem,\ + tools/integration/build_mlkem.sh,\ + build/lib/mlkem.a build/lib/mlkem.sizes.txt build/lib/mlkem_staging)) +endif endif # src/boot.s pulls the HTTPS target hostname from this generated include @@ -993,6 +1038,13 @@ build/lib/x25519.a: tools/integration/build_x25519.sh $(FLAGS_STAMP) @mkdir -p build/lib bash tools/integration/build_x25519.sh +# libs/mlkem ML-KEM-768 archive (PQ_HYBRID=1 only). Rebuilt when the wrapper +# or the flags stamp changes; the SIBLING_SRC_CHECK above deletes it on any +# libs/mlkem source or pin change. +build/lib/mlkem.a: tools/integration/build_mlkem.sh $(FLAGS_STAMP) + @mkdir -p build/lib + bash tools/integration/build_mlkem.sh + # 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. diff --git a/cfg/c64-https-uci-onchip-cold.cfg b/cfg/c64-https-uci-onchip-cold.cfg index a897e1c..fffea65 100644 --- a/cfg/c64-https-uci-onchip-cold.cfg +++ b/cfg/c64-https-uci-onchip-cold.cfg @@ -143,7 +143,7 @@ MEMORY { ZP_CRYPTO: start = $0022, size = $001E, type = rw, define = yes; ZP_WIDE: start = $0040, size = $0040, type = rw, define = yes; - LOADADDR: start = $07FF, size = $0002, file = %O; + LOADADDR: start = $07FF, size = $0002, file = %O, define = yes; LOADER: start = $0801, size = $17FF, file = %O, define = yes, fill = yes, fillval = $00; # W1 partial: NET_CODE sized to fit its content, and its tail reclaimed # as NET_BSS_TAIL to spill BSS that does not fit in CRYPTO_HOT under the @@ -234,6 +234,24 @@ MEMORY { # asserted equal to tls_rec_buf in src/crypto/x25519_tables.s. X25519_SCRATCH: start = $A000, size = $0224, type = rw, define = yes; + # PQ_HYBRID=1 only (libs/mlkem, X25519MLKEM768; see PQ_HYBRID in the + # Makefile and src/crypto/mlkem_link.s, which asserts every number + # here). All three are file="": no PRG byte is spent on them, and with no + # LIB_MLKEM_* segment linked they are empty, so an unset build is + # byte-identical. + # MLKEM_CODE_RAM $E000-$FDFF, RAM under the KERNAL ROM (callers run + # ML-KEM with the KERNAL banked out, under sei). NOT in + # the PRG: a file-backed $E000 image would carry a + # $C000-$DFFF pad through the I/O page (#288). The + # code is therefore LINKED here but not delivered yet. + # MLKEM_BSS_RAM $B100-$BBFF = X25519_TABLES + the $B900 hole + + # mul_dma_lo/hi, all dead while ML-KEM runs (ML-KEM + # never interleaves with X25519 or fp_mul). + # MLKEM_POLY_RAM $D000-$DFFF, RAM under I/O ($01 banked to all-RAM). + MLKEM_CODE_RAM: start = $E000, size = $1E00, type = rw, file = "", define = yes; + MLKEM_BSS_RAM: start = $B100, size = $0B00, type = rw, file = "", define = yes; + MLKEM_POLY_RAM: start = $D000, size = $1000, type = rw, file = "", define = yes; + # Cold bank: the PRG's copy of the cold groups (src/net/uci/cold_bank.s), # back to back; boot stashes it into the REU before the first connect # reuses the range as the TCP ring. @@ -501,4 +519,11 @@ SEGMENTS { # anchor must be explicit. $BA00 + $600 = $C000 — flush to the # region end, exactly the historical placement. TABLES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, start = $BA00; + + # PQ_HYBRID=1 only (all optional). align = $40 on rodata and $100 on the + # BSS are the library's own rules (cfg/mlkem-example.cfg in libs/mlkem). + LIB_MLKEM_CODE: load = MLKEM_CODE_RAM, type = ro, optional = yes; + LIB_MLKEM_RODATA: load = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40; + LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; + LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/cfg/c64-https-uci-onchip.cfg b/cfg/c64-https-uci-onchip.cfg index 2c94376..8f3ee59 100644 --- a/cfg/c64-https-uci-onchip.cfg +++ b/cfg/c64-https-uci-onchip.cfg @@ -122,7 +122,7 @@ MEMORY { ZP_CRYPTO: start = $0022, size = $001E, type = rw, define = yes; ZP_WIDE: start = $0040, size = $0040, type = rw, define = yes; - LOADADDR: start = $07FF, size = $0002, file = %O; + LOADADDR: start = $07FF, size = $0002, file = %O, define = yes; LOADER: start = $0801, size = $17FF, file = %O, define = yes, fill = yes, fillval = $00; # W1 partial: NET_CODE sized to fit its content, and its tail reclaimed # as NET_BSS_TAIL to spill BSS that does not fit in CRYPTO_HOT under the @@ -213,6 +213,24 @@ MEMORY { # asserted equal to tls_rec_buf in src/crypto/x25519_tables.s. X25519_SCRATCH: start = $A000, size = $0224, type = rw, define = yes; + # PQ_HYBRID=1 only (libs/mlkem, X25519MLKEM768; see PQ_HYBRID in the + # Makefile and src/crypto/mlkem_link.s, which asserts every number + # here). All three are file="": no PRG byte is spent on them, and with no + # LIB_MLKEM_* segment linked they are empty, so an unset build is + # byte-identical. + # MLKEM_CODE_RAM $E000-$FDFF, RAM under the KERNAL ROM (callers run + # ML-KEM with the KERNAL banked out, under sei). NOT in + # the PRG: a file-backed $E000 image would carry a + # $C000-$DFFF pad through the I/O page (#288). The + # code is therefore LINKED here but not delivered yet. + # MLKEM_BSS_RAM $B100-$BBFF = X25519_TABLES + the $B900 hole + + # mul_dma_lo/hi, all dead while ML-KEM runs (ML-KEM + # never interleaves with X25519 or fp_mul). + # MLKEM_POLY_RAM $D000-$DFFF, RAM under I/O ($01 banked to all-RAM). + MLKEM_CODE_RAM: start = $E000, size = $1E00, type = rw, file = "", define = yes; + MLKEM_BSS_RAM: start = $B100, size = $0B00, type = rw, file = "", define = yes; + MLKEM_POLY_RAM: start = $D000, size = $1000, type = rw, file = "", define = yes; + # The PRG ends at $BFFF, CRYPTO_COLD_SHADOW's last byte (#288). # Nothing above it is file-backed. A zero pad used to run on to $DFFF # (it put the retired P-384 curve blob at $E000), so a KERNAL LOAD @@ -464,4 +482,11 @@ SEGMENTS { # anchor must be explicit. $BA00 + $600 = $C000 — flush to the # region end, exactly the historical placement. TABLES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, start = $BA00; + + # PQ_HYBRID=1 only (all optional). align = $40 on rodata and $100 on the + # BSS are the library's own rules (cfg/mlkem-example.cfg in libs/mlkem). + LIB_MLKEM_CODE: load = MLKEM_CODE_RAM, type = ro, optional = yes; + LIB_MLKEM_RODATA: load = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40; + LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; + LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/cfg/c64-https-uci.cfg b/cfg/c64-https-uci.cfg index 769f933..93e009c 100644 --- a/cfg/c64-https-uci.cfg +++ b/cfg/c64-https-uci.cfg @@ -77,7 +77,7 @@ MEMORY { ZP_CRYPTO: start = $0022, size = $001E, type = rw, define = yes; ZP_WIDE: start = $0040, size = $0040, type = rw, define = yes; - LOADADDR: start = $07FF, size = $0002, file = %O; + LOADADDR: start = $07FF, size = $0002, file = %O, define = yes; LOADER: start = $0801, size = $17FF, file = %O, define = yes, fill = yes, fillval = $00; # W1 partial: NET_CODE sized to fit its content, and its tail reclaimed # as NET_BSS_TAIL to spill BSS that does not fit in CRYPTO_HOT under the @@ -168,6 +168,24 @@ MEMORY { # asserted equal to tls_rec_buf in src/crypto/x25519_tables.s. X25519_SCRATCH: start = $A000, size = $0224, type = rw, define = yes; + # PQ_HYBRID=1 only (libs/mlkem, X25519MLKEM768; see PQ_HYBRID in the + # Makefile and src/crypto/mlkem_link.s, which asserts every number + # here). All three are file="": no PRG byte is spent on them, and with no + # LIB_MLKEM_* segment linked they are empty, so an unset build is + # byte-identical. + # MLKEM_CODE_RAM $E000-$FDFF, RAM under the KERNAL ROM (callers run + # ML-KEM with the KERNAL banked out, under sei). NOT in + # the PRG: a file-backed $E000 image would carry a + # $C000-$DFFF pad through the I/O page (#288). The + # code is therefore LINKED here but not delivered yet. + # MLKEM_BSS_RAM $B100-$BBFF = X25519_TABLES + the $B900 hole + + # mul_dma_lo/hi, all dead while ML-KEM runs (ML-KEM + # never interleaves with X25519 or fp_mul). + # MLKEM_POLY_RAM $D000-$DFFF, RAM under I/O ($01 banked to all-RAM). + MLKEM_CODE_RAM: start = $E000, size = $1E00, type = rw, file = "", define = yes; + MLKEM_BSS_RAM: start = $B100, size = $0B00, type = rw, file = "", define = yes; + MLKEM_POLY_RAM: start = $D000, size = $1000, type = rw, file = "", define = yes; + # The PRG ends at $BFFF, CRYPTO_COLD_SHADOW's last byte (#288). # Nothing above it is file-backed. A zero pad used to run on to $DFFF # (it put the retired P-384 curve blob at $E000), so a KERNAL LOAD @@ -420,4 +438,11 @@ SEGMENTS { # anchor must be explicit. $BA00 + $600 = $C000 — flush to the # region end, exactly the historical placement. TABLES_BSS: load = CRYPTO_COLD_SHADOW, type = bss, start = $BA00; + + # PQ_HYBRID=1 only (all optional). align = $40 on rodata and $100 on the + # BSS are the library's own rules (cfg/mlkem-example.cfg in libs/mlkem). + LIB_MLKEM_CODE: load = MLKEM_CODE_RAM, type = ro, optional = yes; + LIB_MLKEM_RODATA: load = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40; + LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; + LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 3ef78bc..9b5c604 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -4108,3 +4108,47 @@ happens at 64 MHz; and whether a slower or marginal CS8900a would survive where this one did. One device, one cartridge, one clock, one run. The shipped `c64-https-ip65-onchip.prg` is still a stock-C64 product and the rig still defaults to 1 MHz for that reason. + +## PQ_HYBRID P0: placing libs/mlkem (2026-10-16) + +Link-only. `PQ_HYBRID=1` links c64-mlkem v0.6.0 (ABI 3) and proves where it +lives; nothing calls it. Measured on the uci-onchip link: code+rodata +`$E000-$FD40` (7,489 B with alignment fill, 191 B spare in the 7,680 B +window), `LIB_MLKEM_BSS` `$B100-$BAF0` (2,545 B, 271 B spare before the +sqtab at `$BC00`), `LIB_MLKEM_POLY_BSS` `$D000-$DFFF` (exactly 4,096 B). +Scrub cost: 21 B of LOADER on comb-store (61 B left), 82 -> 61. + +Findings that changed the plan: + + - **The $E000 code is linked but not delivered.** #288 removed the only + file-backed way to reach `$E000` (the zero pad through the I/O page), and + staging 7.5 KB inside the PRG below `$D000` to copy up at boot needs file + bytes the images do not have: the free tails total ~2.7 KB, and + `$C000-$CFFF` (4 KB, free on uci, 1.7 KB free on comb) is the only + other file-backed area that can exist. So the cfgs put the code in a + `file=""` area and `mlkem_link.s` ldwarns on every link. Options for the + follow-up: relax #288 for DMA-only products (UCI loads by DMA), or + ship the code in the REU on comb, or shrink below 4 KB (not possible). + - **`mlkem_arith_init` builds 1 KB of reduction tables inside + `LIB_MLKEM_BSS`.** That window is X25519_TABLES, which X25519 rebuilds + per scalar mult, so every ML-KEM call group needs `mlkem_arith_init` + first, not "once at boot" as the library header says. + - **The library's sqtab image guard cannot pass `CRYPTO_COLD_SHADOW`**: + the window is c64-https's own table inside that area. `mlkem_link.s` + asserts the equivalent (the window is exactly the owner's lo+hi planes + and the area ends at the window's end). Every other area has the + library's one-line guard; `tools/test_mlkem_placement.py` fails on an + area with neither. + - **The `LIB_SHARED_SQTAB_BASE` the archive was built with is not + checkable.** The library never exports it (by contract) and the base is + baked into immediates; a wrapper edit to `0xB800` links clean. Same + blind spot as `build_x25519.sh` and `build_nistcurves_p256.sh`. + - **Dk scratch in the TCP ring.** The trust store and bundle also stage + their files in the ring (`TS_BUF`, `TB_BUF`), outside a connection, so + KeyGen must run after `trust_pre` and before the first server byte. + Comb's `COLD_IMAGE` sits at `$C000` but is stashed to the REU once at + boot and never read again (`cold_bank_init`), so it is dead by then. + - **NMI.** With the KERNAL out (`$01=$34`) the hardware vectors are RAM; a + RESTORE-key NMI during a call jumps through `$FFFA`. The scrub accepts + that window (it is followed by 'Q'); the real ML-KEM calls last seconds + and will need a vector stub in the free `$FE00-$FFFF`. diff --git a/libs/mlkem b/libs/mlkem new file mode 160000 index 0000000..a00444a --- /dev/null +++ b/libs/mlkem @@ -0,0 +1 @@ +Subproject commit a00444a1e44c8de7f6a750c719b27a88b8f9679a diff --git a/src/constants.inc b/src/constants.inc index ce83787..805e7a2 100644 --- a/src/constants.inc +++ b/src/constants.inc @@ -1,4 +1,4 @@ -; ============================================================================= +; ====================================================================== ; constants.inc - System equates, zero page, hardware addresses ; ; Converted from constants.asm (ACME, since removed) to ca65 include file. @@ -241,3 +241,17 @@ tcp_recv_buf = $c000 .ifdef TLS_STREAM_DEFRAME CH_SECTIONED = 1 .endif +======= +; ML-KEM-768 dk scratch (PQ_HYBRID=1, libs/mlkem). mlkem_keygen writes a +; 2,400 B dk that is discarded: Decaps uses only the 64 B seed +; (mlkem_decaps_seed). It lives in the TCP ring. ORDERING RULE, enforced +; where KeyGen is called (a later phase, not this one): KeyGen runs after +; trust_pre/TS_BUF/TB_BUF have finished with the ring (the trust store and +; bundle stage their files here, outside a connection) and BEFORE any +; server byte enters it (net_tcp_connect empties it, head = tail = 0), and +; the 2,400 B are zeroed immediately after KeyGen returns, before the first +; net_poll. Size >= 2,400 and the polys above it: src/crypto/mlkem_link.s. +; Comb: COLD_IMAGE is staged at $C000 but cold_bank_init stashes it to the +; REU once at boot and never reads it again, so the ring is dead by then. +mlkem_dk_scratch = tcp_recv_buf +MLKEM_DK_SIZE = 2400 diff --git a/src/crypto/mlkem_link.s b/src/crypto/mlkem_link.s new file mode 100644 index 0000000..4d678b0 --- /dev/null +++ b/src/crypto/mlkem_link.s @@ -0,0 +1,216 @@ +; ============================================================================= +; mlkem_link.s - PQ_HYBRID=1 link anchor and placement proof for libs/mlkem +; (c64-mlkem v0.6.0, ABI 3). PQC plan phase P0. +; +; Nothing calls ML-KEM yet: this object exists so ld65 pulls mlkem_keygen / +; mlkem_decaps / mlkem_decaps_seed (and through them every library member) +; into the image, and so the placement the cfgs give the library is checked +; at link time rather than trusted. The Makefile links this file only under +; PQ_HYBRID=1 (it is filtered out of the wildcard otherwise), so an unset +; build is byte-identical to one that has never heard of it. It emits NO +; bytes: every reference is inside an `.assert ..., lderror`. ca65 drops an +; .import nothing references, but an assert expression is a reference, and +; ld65 evaluates it after placement - which is what makes these both the +; force-link and the placement proof. +; +; PLACEMENT (cfg/c64-https-uci-onchip.cfg and ...-onchip-cold.cfg) +; +; LIB_MLKEM_CODE/RODATA MLKEM_CODE_RAM $E000-$FDFF RAM under the KERNAL +; ROM. file="" - NOT in the PRG (see HAZARD below). +; LIB_MLKEM_POLY_BSS MLKEM_POLY_RAM $D000-$DFFF RAM under I/O, the +; eight polynomials, exactly 4,096 B. +; LIB_MLKEM_BSS MLKEM_BSS_RAM $B100-$BBFF = X25519_TABLES +; ($B100-$B8FF) + the $B900 hole + mul_dma_lo/hi +; ($BA00-$BBFF). 2,816 B window, up to the sqtab. +; dk scratch the TCP ring at tcp_recv_buf = $C000 (below). +; +; LIFETIME RULES (what makes the unions legal - none is checkable at link): +; 1. ML-KEM never interleaves with X25519 or fp_mul. X25519_TABLES is +; rebuilt per scalar mult (x25519_*_fresh run x25519_tables_init), and +; mul_dma_lo/hi is the REU row-fetch buffer of the REU profile (refused +; under PQ_HYBRID). The converse also holds: LIB_MLKEM_BSS holds the +; two 512 B reduction tables mlkem_arith_init builds, so EVERY ML-KEM +; call group must be preceded by mlkem_arith_init once X25519 has run. +; 2. Both BSS segments hold secrets afterwards (POLY_BSS: s_hat after +; mlkem_keygen, secret-derived y/e1/e2 after Decaps; LIB_MLKEM_BSS: +; m', K', r'). src/session_scrub.s zeroes both under PQ_HYBRID. +; 3. $E000 code and the $D000 polys need $01 banked (KERNAL and I/O out, +; $01 = $34) under sei around every ML-KEM call. With the KERNAL out the +; hardware vectors are RAM: a RESTORE-key NMI during a call jumps through +; whatever sits at $FFFA. $FE00-$FFFF is left free for a vector stub. +; +; HAZARD (#288) - the code is LINKED at $E000 but NOT DELIVERED by the PRG. +; An ld65 file-backed $E000 image would carry a zero pad over $C000-$DFFF, +; and a KERNAL LOAD stores through the I/O page (tools/test_prg_io_range.py +; forbids it). Staging 7,541 B inside the PRG below $D000 and copying it up +; at boot needs ~7.5 KB of file space the images do not have (the only +; free file bytes are scattered 1-2 KB tails, plus a 4 KB $C000-$CFFF +; area). So the image links and every address below is proven, but the +; bytes are absent: an ML-KEM call is not runnable yet. The ldwarning below +; says so on every PQ_HYBRID link until the delivery question is decided. +; +; ZERO PAGE (tools/integration/build_mlkem.sh has the table and reasoning): +; $40-$4F, inside ZP_WIDE; the only co-tenant is the X25519 sibling's +; per-call fe_wide. Pinned here so the wrapper's -D values and the +; documented map cannot drift apart. +; ============================================================================= + +.ifndef PQ_HYBRID +.error "mlkem_link.s is PQ_HYBRID-only; the Makefile filters it out otherwise" +.endif + +.include "constants.inc" + +; The base libs/mlkem was assembled against (build_mlkem.sh: -D +; LIB_SHARED_SQTAB_BASE=0xBC00); the library's own default ($9000) is not ours. +LIB_SHARED_SQTAB_BASE = $BC00 + +; --- Public entries (the force-link) --------------------------------------- +.import mlkem_keygen, mlkem_decaps, mlkem_decaps_seed, mlkem_arith_init +.import keccak_state + +; --- Contract section 1 / 5 surface ---------------------------------------- +.import LIB_MLKEM_VERSION_MAJOR, LIB_MLKEM_VERSION_MINOR, LIB_MLKEM_ABI_VERSION +.import LIB_MLKEM_RESIDENT_BYTES, LIB_MLKEM_COLD_BYTES +.import LIB_MLKEM_ZP_USAGE_BYTES, LIB_MLKEM_REU_BANKS_USED +.import LIB_MLKEM_SHARED_PRIMITIVES, LIB_MLKEM_SHARED_CONSUMES + +; --- ZP slots (consumer-assembled; tools/integration/build_mlkem.sh) ------- +.importzp mlkem_zp_src, mlkem_zp_dst, mlkem_zp_len, mlkem_zp_tmp, mlkem_zp_mul + +; --- Regions and neighbours -------------------------------------------------- +.import __MLKEM_CODE_RAM_START__, __MLKEM_CODE_RAM_SIZE__, __MLKEM_CODE_RAM_LAST__ +.import __MLKEM_BSS_RAM_START__, __MLKEM_BSS_RAM_SIZE__, __MLKEM_BSS_RAM_LAST__ +.import __MLKEM_POLY_RAM_START__, __MLKEM_POLY_RAM_SIZE__, __MLKEM_POLY_RAM_LAST__ +.import __LIB_MLKEM_BSS_RUN__, __LIB_MLKEM_BSS_SIZE__ +.import __LIB_MLKEM_POLY_BSS_RUN__, __LIB_MLKEM_POLY_BSS_SIZE__ +.import sqtab_lo +.import mul38_lo_tab, mul38_hi_tab, a24_b3, mul_dma_lo, mul_dma_hi + +; --- Version and ABI --------------------------------------------------------- +; ABI 3 (v0.6.0) is the pin: LIB_MLKEM_POLY_BSS and mlkem_decaps_seed. A +; generation bump is the gate telling you to re-read libs/mlkem/src/mlkem.inc +; before re-pinning, exactly as for libs/nistcurves. +.assert (LIB_MLKEM_VERSION_MAJOR > 0) .or (LIB_MLKEM_VERSION_MINOR >= 6), lderror, "PQ_HYBRID needs c64-mlkem >= v0.6.0 (POLY_BSS split, mlkem_decaps_seed)" +.assert LIB_MLKEM_ABI_VERSION = 3, lderror, "c64-mlkem ABI generation moved: re-read libs/mlkem/src/mlkem.inc before re-pinning" + +; --- Section 8.0 / 8.1: one sqtab, c64-https owns it ------------------------ +; Both masks come from the archive's own manifest member, so this proves the +; CONTRACT_DEFINES reached the build. +.assert (LIB_MLKEM_SHARED_PRIMITIVES & $0001) = 0, lderror, "libs/mlkem claims sqtab ownership: SHARED_SQTAB_INIT did not reach the archive" +.assert (LIB_MLKEM_SHARED_CONSUMES & $0001) = $0001, lderror, "libs/mlkem no longer consumes the shared sqtab: re-check build_mlkem.sh" +; ONE table, at the base the library was assembled against (0xBC00). The +; library's NTT reads it; c64-https's boot sqtab_init fills it. +.assert sqtab_lo = LIB_SHARED_SQTAB_BASE, lderror, "sqtab_lo is not at $BC00, the LIB_SHARED_SQTAB_BASE libs/mlkem was built with" + +; No REU: onchip ships without one, and comb's bank 2 is the anchor table. +.assert LIB_MLKEM_REU_BANKS_USED = 0, lderror, "libs/mlkem claims REU banks" + +; --- Code + rodata: $E000, whole footprint, every entry in the slot --------- +.assert __MLKEM_CODE_RAM_START__ = $E000, lderror, "MLKEM_CODE_RAM is not at $E000" +.assert __MLKEM_CODE_RAM_SIZE__ = $1E00, lderror, "MLKEM_CODE_RAM is not 7,680 B ($E000-$FDFF; $FE00-$FFFF stays free for the vector stub)" +.assert LIB_MLKEM_RESIDENT_BYTES + LIB_MLKEM_COLD_BYTES <= __MLKEM_CODE_RAM_SIZE__, lderror, "c64-mlkem footprint exceeds MLKEM_CODE_RAM" +.assert __MLKEM_CODE_RAM_LAST__ <= __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__, lderror, "MLKEM_CODE_RAM overflows its area" +.assert mlkem_keygen >= __MLKEM_CODE_RAM_START__, lderror, "mlkem_keygen is not in MLKEM_CODE_RAM" +.assert mlkem_decaps >= __MLKEM_CODE_RAM_START__, lderror, "mlkem_decaps is not in MLKEM_CODE_RAM" +.assert mlkem_decaps_seed >= __MLKEM_CODE_RAM_START__, lderror, "mlkem_decaps_seed is not in MLKEM_CODE_RAM" +.assert mlkem_arith_init >= __MLKEM_CODE_RAM_START__, lderror, "mlkem_arith_init is not in MLKEM_CODE_RAM" +.assert mlkem_keygen < __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__, lderror, "mlkem_keygen is past the end of MLKEM_CODE_RAM" + +; --- Polynomials: $D000, exactly 4 KB --------------------------------------- +.assert __MLKEM_POLY_RAM_START__ = $D000, lderror, "MLKEM_POLY_RAM is not at $D000" +.assert __MLKEM_POLY_RAM_SIZE__ = $1000, lderror, "MLKEM_POLY_RAM is not 4 KB" +.assert __LIB_MLKEM_POLY_BSS_RUN__ = $D000, lderror, "LIB_MLKEM_POLY_BSS is not at $D000" +.assert __LIB_MLKEM_POLY_BSS_SIZE__ = $1000, lderror, "LIB_MLKEM_POLY_BSS is not 4,096 B" + +; --- Rest-BSS: the $B100 union ---------------------------------------------- +; Window = X25519_TABLES (8 pages from mul38_lo_tab) + the $B900 hole + +; mul_dma_lo/hi, ending at sqtab_lo. Each equation below pins one edge, so a +; reorder of X25519_TABLES or TABLES_BSS breaks the link by name. +.assert __MLKEM_BSS_RAM_START__ = mul38_lo_tab, lderror, "MLKEM_BSS_RAM must start on X25519_TABLES (mul38_lo_tab)" +.assert __LIB_MLKEM_BSS_RUN__ = mul38_lo_tab, lderror, "LIB_MLKEM_BSS must start on X25519_TABLES (mul38_lo_tab)" +.assert (__LIB_MLKEM_BSS_RUN__ & $00FF) = 0, lderror, "LIB_MLKEM_BSS must start on a page" +.assert (keccak_state & $00FF) = 0, lderror, "keccak_state must be page-aligned" +.assert mul38_hi_tab = mul38_lo_tab + $100, lderror, "X25519_TABLES layout moved (mul38_hi_tab)" +.assert a24_b3 + $100 = mul38_lo_tab + $800, lderror, "X25519_TABLES is no longer 2 KB: the union's first edge moved" +.assert mul_dma_lo = mul38_lo_tab + $900, lderror, "mul_dma_lo moved off $BA00 (the $B900 hole is no longer one page)" +.assert mul_dma_hi = mul_dma_lo + $100, lderror, "mul_dma_hi is not the page after mul_dma_lo" +.assert mul_dma_hi + $100 = sqtab_lo, lderror, "mul_dma is not adjacent to sqtab: the union's last edge moved" +.assert __MLKEM_BSS_RAM_SIZE__ = sqtab_lo - mul38_lo_tab, lderror, "MLKEM_BSS_RAM is not exactly [mul38_lo_tab, sqtab_lo)" +.assert __LIB_MLKEM_BSS_RUN__ + __LIB_MLKEM_BSS_SIZE__ <= sqtab_lo, lderror, "LIB_MLKEM_BSS runs into the sqtab" +.assert __MLKEM_BSS_RAM_LAST__ <= sqtab_lo, lderror, "MLKEM_BSS_RAM runs into the sqtab" + +; --- dk scratch: the TCP ring ----------------------------------------------- +; mlkem_keygen writes a 2,400 B dk that is then discarded (Decaps uses only +; the 64 B seed, mlkem_decaps_seed). It goes in the ring at tcp_recv_buf. +.assert mlkem_dk_scratch = $C000, lderror, "the dk scratch moved off the TCP ring base" +.assert MLKEM_DK_SIZE = 2400, lderror, "ML-KEM-768 dk is 2,400 B (FIPS 203 Table 3)" +.assert TCP_RECV_BUF_SIZE >= MLKEM_DK_SIZE, lderror, "the TCP ring is smaller than the 2,400 B dk it hosts" +.assert mlkem_dk_scratch + TCP_RECV_BUF_SIZE <= __MLKEM_POLY_RAM_START__, lderror, "the TCP ring runs into the $D000 polys" + +; --- Zero page -------------------------------------------------------------- +.assert mlkem_zp_src = $40, lderror, "mlkem_zp_src moved off $40" +.assert mlkem_zp_dst = $42, lderror, "mlkem_zp_dst moved off $42" +.assert mlkem_zp_len = $44, lderror, "mlkem_zp_len moved off $44" +.assert mlkem_zp_tmp = $46, lderror, "mlkem_zp_tmp moved off $46" +.assert mlkem_zp_mul = $48, lderror, "mlkem_zp_mul moved off $48" +.assert LIB_MLKEM_ZP_USAGE_BYTES = 16, lderror, "c64-mlkem ZP claim is no longer 16 B: re-plan $40-$4F" +; ...and wholly clear of every c64-https crypto slot ($22-$3F) and of the +; low pool: the only thing at $40-$7F is the X25519 sibling's fe_wide. +.assert mlkem_zp_src >= $40, lderror, "ML-KEM zero page overlaps ZP_CRYPTO ($22-$3F)" +.assert mlkem_zp_mul + 8 <= $50, lderror, "ML-KEM zero page runs past $4F" + +; --- Section 8.1 image guard: no area may overlap the sqtab window ---------- +; libs/mlkem's REQUIRED guard (cfg/mlkem-example.cfg): mul_tables_init writes +; the whole 1,024 B window at run time, so one assert per MEMORY area of the +; link, file-emitting or not. "Ends at or below the window, or starts at or +; above its end." Every area needs `define = yes` in the cfg, so a new area +; with no line here is a gap tools/test_mlkem_placement.py reports by name. +.macro SQTAB_GUARD nm, st, la + .assert ((la) <= LIB_SHARED_SQTAB_BASE) .or ((st) >= LIB_SHARED_SQTAB_BASE + $0400), lderror, .concat("area ", nm, " overlaps the sqtab window") +.endmacro +.import __ZP_CRYPTO_START__, __ZP_CRYPTO_LAST__ +.import __ZP_WIDE_START__, __ZP_WIDE_LAST__ +.import __LOADADDR_START__, __LOADADDR_LAST__ +.import __LOADER_START__, __LOADER_LAST__ +.import __NET_CODE_START__, __NET_CODE_LAST__ +.import __NET_BSS_TAIL_START__, __NET_BSS_TAIL_LAST__ +.import __UCI_BSS_REGION_START__, __UCI_BSS_REGION_LAST__ +.import __CRYPTO_OVERLAY_START__, __CRYPTO_OVERLAY_LAST__ +.import __P256_SCRATCH_UNION_START__, __P256_SCRATCH_UNION_LAST__ +.import __CRYPTO_HOT_START__, __CRYPTO_HOT_LAST__ +.import __X25519_SCRATCH_START__, __X25519_SCRATCH_LAST__ +SQTAB_GUARD "ZP_CRYPTO", __ZP_CRYPTO_START__, __ZP_CRYPTO_LAST__ +SQTAB_GUARD "ZP_WIDE", __ZP_WIDE_START__, __ZP_WIDE_LAST__ +SQTAB_GUARD "LOADADDR", __LOADADDR_START__, __LOADADDR_LAST__ +SQTAB_GUARD "LOADER", __LOADER_START__, __LOADER_LAST__ +SQTAB_GUARD "NET_CODE", __NET_CODE_START__, __NET_CODE_LAST__ +SQTAB_GUARD "NET_BSS_TAIL", __NET_BSS_TAIL_START__, __NET_BSS_TAIL_LAST__ +SQTAB_GUARD "UCI_BSS_REGION", __UCI_BSS_REGION_START__, __UCI_BSS_REGION_LAST__ +SQTAB_GUARD "CRYPTO_OVERLAY", __CRYPTO_OVERLAY_START__, __CRYPTO_OVERLAY_LAST__ +SQTAB_GUARD "P256_SCRATCH_UNION", __P256_SCRATCH_UNION_START__, __P256_SCRATCH_UNION_LAST__ +SQTAB_GUARD "CRYPTO_HOT", __CRYPTO_HOT_START__, __CRYPTO_HOT_LAST__ +SQTAB_GUARD "X25519_SCRATCH", __X25519_SCRATCH_START__, __X25519_SCRATCH_LAST__ +SQTAB_GUARD "MLKEM_CODE_RAM", __MLKEM_CODE_RAM_START__, __MLKEM_CODE_RAM_LAST__ +SQTAB_GUARD "MLKEM_BSS_RAM", __MLKEM_BSS_RAM_START__, __MLKEM_BSS_RAM_LAST__ +SQTAB_GUARD "MLKEM_POLY_RAM", __MLKEM_POLY_RAM_START__, __MLKEM_POLY_RAM_LAST__ +.ifdef COLD_BANK +.import __COLD_IMAGE_START__, __COLD_IMAGE_LAST__ +.import __COLD_RUN_UI_START__, __COLD_RUN_UI_LAST__ +.import __COLD_RUN_TRUST_START__, __COLD_RUN_TRUST_LAST__ +SQTAB_GUARD "COLD_IMAGE", __COLD_IMAGE_START__, __COLD_IMAGE_LAST__ +SQTAB_GUARD "COLD_RUN_UI", __COLD_RUN_UI_START__, __COLD_RUN_UI_LAST__ +SQTAB_GUARD "COLD_RUN_TRUST", __COLD_RUN_TRUST_START__, __COLD_RUN_TRUST_LAST__ +.endif +; CRYPTO_COLD_SHADOW ($A000-$BFFF) is the one area that CONTAINS the window, +; because the window is c64-https's own table (TABLES_BSS at $BA00, written +; by boot.s's sqtab_init, which is the section 8.1 owner here). The generic +; guard cannot pass it; the equivalent is that the table is exactly the +; area's tail and nothing else is allowed past it: +.import __CRYPTO_COLD_SHADOW_START__, __CRYPTO_COLD_SHADOW_LAST__ +.import sqtab_hi +.assert sqtab_hi + $200 = LIB_SHARED_SQTAB_BASE + $0400, lderror, "the sqtab window is not the owner's lo+hi planes" +.assert __CRYPTO_COLD_SHADOW_LAST__ = LIB_SHARED_SQTAB_BASE + $0400, lderror, "CRYPTO_COLD_SHADOW has a tenant past the sqtab window" + +; Link-only, and the linker says so (see HAZARD above). +.assert __MLKEM_CODE_RAM_LAST__ < __MLKEM_CODE_RAM_START__, ldwarning, "PQ_HYBRID: libs/mlkem is LINKED at $E000 but not delivered by the PRG (#288: no byte may load through $D000-$DFFF); an ML-KEM call is not runnable yet" diff --git a/src/session_scrub.s b/src/session_scrub.s index b7fb061..2c07b7f 100644 --- a/src/session_scrub.s +++ b/src/session_scrub.s @@ -33,6 +33,29 @@ ; a handshake that stopped before ; CertificateVerify) ; +; Under PQ_HYBRID=1 only (libs/mlkem, src/crypto/mlkem_link.s): +; $B100-$BBFF (mul38_lo_tab..sqtab-1) LIB_MLKEM_BSS's window: m', K', r' +; and the Keccak state. It REPLACES +; the mul_dma span (the window covers +; X25519_TABLES, the $B900 hole and +; mul_dma_lo/hi), so the same bytes +; are zeroed either way. +; LIB_MLKEM_POLY_BSS ($D000-$DFFF) s_hat from KeyGen, secret-derived +; y/e1/e2 from Decaps. RAM under I/O: +; the whole span loop runs with +; $01 = $34 (KERNAL, BASIC and I/O all +; out) under sei, restored on exit; +; every other span is plain RAM +; either way. With the KERNAL out the +; vectors at $FFFA/$FFFE are RAM, so a +; RESTORE-key NMI in this window (a +; few ms) would jump through garbage: +; accepted, the next line is 'Q'. +; tcp_recv_buf, 2,400 B the discarded ML-KEM dk if the +; caller forgot to zero it (the ring +; is otherwise ciphertext, not +; scrubbed). +; ; The multi-buffer spans zero whatever their module declares between the ; two ends, so each length is asserted exactly: a field added inside a span ; fails the link here, and whoever added it decides whether it is session @@ -82,6 +105,10 @@ .import zp_save_buf .import poly_prod_lo, poly_prod_hi .import mul_dma_lo, mul_dma_hi + .ifdef PQ_HYBRID + .import mul38_lo_tab + .import __LIB_MLKEM_POLY_BSS_RUN__, __LIB_MLKEM_POLY_BSS_SIZE__ + .endif .import drbg_init_entropy ; SPAN first, end, len: zero [first, end), which must be len bytes long. @@ -94,6 +121,14 @@ .segment "CODE" session_scrub: + .ifdef PQ_HYBRID + php ; I flag + sei ; $01 = $34 below: the IRQ vector is RAM + lda $01 + pha + lda #$34 ; all RAM: reaches $D000-$DFFF under I/O + sta $01 + .endif ldx #0 @span: lda scrub_spans,x @@ -126,6 +161,11 @@ session_scrub: inx cpx #scrub_spans_end - scrub_spans bne @span + .ifdef PQ_HYBRID + pla + sta $01 ; BASIC ROM back out, I/O back in + plp + .endif ; NOT redundant: this is the ONLY scrub of the DRBG's K (drbg_k, ; private to hmac_drbg.s, in no span). Re-instantiates K/V from ; fresh entropy and sets drbg_buf_idx = 32. See the header. @@ -142,5 +182,11 @@ scrub_spans: SPAN tls_rec_buf, tls_rec_buf + 548, 548 SPAN zp_save_buf, zp_save_buf + 26, 26 SPAN poly_prod_lo, poly_prod_hi + 1, 2 + .ifdef PQ_HYBRID + SPAN mul38_lo_tab, mul_dma_hi + 256, 2816 + SPAN __LIB_MLKEM_POLY_BSS_RUN__, __LIB_MLKEM_POLY_BSS_RUN__ + __LIB_MLKEM_POLY_BSS_SIZE__, 4096 + SPAN tcp_recv_buf, tcp_recv_buf + MLKEM_DK_SIZE, 2400 + .else SPAN mul_dma_lo, mul_dma_hi + 256, 512 + .endif scrub_spans_end: diff --git a/tools/integration/build_mlkem.sh b/tools/integration/build_mlkem.sh new file mode 100644 index 0000000..8eea372 --- /dev/null +++ b/tools/integration/build_mlkem.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# ============================================================================= +# tools/integration/build_mlkem.sh - Build the c64-mlkem v0.6.0 ML-KEM-768 +# archive for the PQ_HYBRID=1 link (PQC plan phase P0). +# +# Thin wrapper, the nistcurves shape (not the x25519 staging shape): it drives +# upstream's own `make lib` with consumer CONTRACT_DEFINES, then checks what +# came out before re-archiving it as build/lib/mlkem.a with OUR zero-page +# object added. No upstream source is staged or rewritten. +# +# build/lib/mlkem.a upstream members (discovered, never named) +# + mlkem_zp_config.o (our ZP slots) +# build/lib/mlkem.sizes.txt per-member LIB_MLKEM_* segment sizes (od65) +# +# CONTRACT_DEFINES (reach every archive member): +# SHARED_SQTAB_INIT=1 c64-https owns the §8.1 sqtab and its init +# (src/crypto/poly1305.s::sqtab_init, run by +# boot.s); the library's own mul_tables_init +# body collapses to an import. c64-https's +# `mul_tables_init` export +# (src/crypto/shared/mul_tables.s) is an RTS +# stub; the library never calls it itself +# (init is the consumer's job), so the import +# only has to resolve. +# LIB_SHARED_SQTAB_BASE=0xBC00 the address src/data.s's sqtab_lo lands at, +# asserted after every link by the Makefile's +# $(PRG) recipe. Same value as build_x25519.sh +# and build_nistcurves_p256.sh: ONE table. +# `$`-free on purpose (§2: an unquoted `$B` +# is a shell positional parameter). +# LIB_NO_BARE_EXPORTS=1 no deprecated unprefixed §1/§8.4 exports +# (c64-mlkem ships none anyway; belt+braces, +# they would collide with nistcurves'). +# +# Zero page: c64-mlkem uses the §6.2 consumer-assembled model — no archive +# member defines a slot; we assemble upstream's src/zp_config.s ourselves +# with -D overrides. Upstream's defaults ($30-$3F) are NOT usable here: +# +# default slot c64-https tenant at that address +# $30-$37 src/dst/len/tmp inside fe25519's declared $2C-$37 claim and the +# ECDSA bignum range $22-$3C +# $38-$3F mlkem_zp_mul fp_mul_i $39, fp_mul_j $3A, ec_scalar_ptr +# $3B-$3C, nistcurves_zp_ptr2 $3D, and +# tls_hs_ptr $3E-$3F — the W1 handshake-message +# base pointer, which is LIVE across the message +# handlers. P4 calls Decaps from the ServerHello +# path, so the default would clobber it. +# +# Chosen: ZP_WIDE $40-$4F (16 B, cfg ZP_WIDE $40-$7F). +# +# slot addr co-tenant at that address why it is free there +# mlkem_zp_src $40-$41 X25519 fe_wide ($40-$7F) fe_wide is per-call +# mlkem_zp_dst $42-$43 " scratch inside +# mlkem_zp_len $44-$45 " fe25519_mul/sqr; an +# mlkem_zp_tmp $46-$47 " X25519 call never +# mlkem_zp_mul $48-$4F " (8 B) nests in an ML-KEM +# call or vice versa +# +# Nothing of c64-https's own is placed in ZP_WIDE (src/constants.inc), so +# the ONLY co-tenant is another leaf crypto primitive, and every ML-KEM slot +# is per-call scratch too (mlkem.inc: "Clobbers ... every mlkem_zp_* slot"; +# the parameter block lives in LIB_MLKEM_BSS, not ZP). Lifetimes are +# disjoint, so time-sharing is safe; no TLS state is exposed. The values are +# re-asserted at link time against these exact addresses in +# src/crypto/mlkem_link.s, so this table cannot drift silently. +# +# Usage (from the Makefile, PQ_HYBRID=1 only): +# bash tools/integration/build_mlkem.sh +# ============================================================================= +set -eo pipefail + +PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LIB_ROOT="$PROJECT_ROOT/libs/mlkem" +STAGING="$PROJECT_ROOT/build/lib/mlkem_staging" +OUT_DIR="$PROJECT_ROOT/build/lib" +ARCHIVE="$OUT_DIR/mlkem.a" +SIZES="$OUT_DIR/mlkem.sizes.txt" + +CA65="${CA65:-ca65}" +AR65="${AR65:-ar65}" +OD65="${OD65:-od65}" +MAKE="${MAKE:-make}" + +# `$`-free values (contract §2). +CONTRACT_DEFINES="-D SHARED_SQTAB_INIT=1 -D LIB_SHARED_SQTAB_BASE=0xBC00 -D LIB_NO_BARE_EXPORTS=1" +ZP_DEFINES=( + -D mlkem_zp_src=0x40 + -D mlkem_zp_dst=0x42 + -D mlkem_zp_len=0x44 + -D mlkem_zp_tmp=0x46 + -D mlkem_zp_mul=0x48 +) + +if [ ! -f "$LIB_ROOT/src/kem.s" ]; then + echo "ERROR: libs/mlkem is not checked out (git submodule update --init --recursive)" >&2 + exit 1 +fi + +# --- 0. Preflight: the checkout must be new enough (the #124 shape) --- +# A source probe, not a tag comparison, for the same reason as +# build_nistcurves_p256.sh: what this wrapper and src/crypto/mlkem_link.s +# require is behavioural. v0.6.0 / ABI 3 is the first release with +# - the LIB_MLKEM_POLY_BSS segment (the cfgs' MLKEM_POLY_RAM at $D000 +# and the $B100 union both depend on the split), and +# - mlkem_decaps_seed (Decaps from the 64 B seed; no 2,400 B dk kept). +# A v0.5.x checkout would otherwise fail much later, at link, as +# "Missing memory area assignment" or as an unresolved symbol, which names +# neither the cause nor the remedy. +probe_fail() { + local head pin + head="$(git -C "$PROJECT_ROOT/libs/mlkem" rev-parse HEAD 2>/dev/null || echo unknown)" + pin="$(git -C "$PROJECT_ROOT" ls-tree HEAD libs/mlkem 2>/dev/null | awk '{print $3}' || true)" + cat >&2 </dev/null \ + || probe_fail "src/lib_version.s does not declare LIB_MLKEM_ABI_VERSION = 3 (need c64-mlkem >= v0.6.0)" +grep -q 'LIB_MLKEM_POLY_BSS' "$LIB_ROOT/src/kem.s" 2>/dev/null \ + || probe_fail "src/kem.s has no LIB_MLKEM_POLY_BSS segment (need c64-mlkem >= v0.6.0)" +grep -q 'mlkem_decaps_seed' "$LIB_ROOT/src/kem.s" 2>/dev/null \ + || probe_fail "src/kem.s has no mlkem_decaps_seed (need c64-mlkem >= v0.6.0)" + +# --- Upstream archive --- +# -B: upstream keeps its objects in libs/mlkem/build, outside `make clean`; +# its own config-sig guard already invalidates on a CONTRACT_DEFINES change, +# but forcing costs ~1 s and removes the question. +"$MAKE" -B -C "$LIB_ROOT" lib CA65="$CA65" AR65="$AR65" \ + CONTRACT_DEFINES="$CONTRACT_DEFINES" >/dev/null +UP_ARCHIVE="$LIB_ROOT/build/lib/mlkem.a" +[ -f "$UP_ARCHIVE" ] || { echo "ERROR: upstream make lib produced no $UP_ARCHIVE" >&2; exit 1; } + +rm -rf "$STAGING" +mkdir -p "$STAGING/members" "$OUT_DIR" + +# --- Discover members (never hardcode: the nistcurves v0.9.0 lesson) --- +MEMBERS=() +while IFS= read -r m; do + [ -n "$m" ] && MEMBERS+=("$m") +done < <("$AR65" t "$UP_ARCHIVE") +[ "${#MEMBERS[@]}" -gt 0 ] || { echo "ERROR: $UP_ARCHIVE has no members" >&2; exit 1; } +( cd "$STAGING/members" && "$AR65" x "$UP_ARCHIVE" "${MEMBERS[@]}" ) + +# --- od65 checks on every member --- +fail=0 +for m in "${MEMBERS[@]}"; do + obj="$STAGING/members/$m" + # 1. Only the four §4 segments may carry bytes; anything else has no + # home in our cfgs (and a bare CODE/BSS would land in c64-https's). + bad=$("$OD65" --dump-segsize "$obj" | awk -F: '/^ [A-Z_0-9]+:/ { + gsub(/ /,"",$1); gsub(/ /,"",$2); + if ($2 != "0" && $1 !~ /^LIB_MLKEM_(CODE|RODATA|BSS|POLY_BSS)$/) print $1"="$2 }') + if [ -n "$bad" ]; then + echo "ERROR: $m carries bytes outside LIB_MLKEM_{CODE,RODATA,BSS,POLY_BSS}: $bad" >&2; fail=1 + fi + exports=$("$OD65" --dump-exports "$obj" | sed -n 's/^ *Name: *"\(.*\)"$/\1/p') + # 2. §8.1 deferral took: no sqtab provider, no unprefixed table names. + # 3. §6.2 consumer-assembled ZP: no member defines a slot. + # 4. No test hooks (upstream issue #1 shape). + leak=$(printf '%s\n' "$exports" | grep -E '^(mul_tables_init|sqtab_lo|sqtab_hi|mlkem_zp_[a-z]+|LIB_VERSION_[A-Z]+|LIB_ABI_VERSION)$|_hook|^test_' || true) + if [ -n "$leak" ]; then + echo "ERROR: $m exports $leak (deferral/ZP model/test-hook check)" >&2; fail=1 + fi +done +# The deferring sqtab member must carry no code, proving SHARED_SQTAB_INIT +# reached it (owner build: 84 B of mul_tables_init). Its `.import +# mul_tables_init` is unreferenced, and ca65 emits no import record for an +# unreferenced .import, so an import check would be vacuous; size is not. +sq_code=$("$OD65" --dump-segsize "$STAGING"/members/*sqtab*.o 2>/dev/null \ + | awk -F: '/LIB_MLKEM_CODE/ {gsub(/ /,"",$2); s+=$2} END {print s+0}') +if [ "$sq_code" -ne 0 ]; then + echo "ERROR: the sqtab member carries $sq_code B of code: SHARED_SQTAB_INIT did not take" >&2; fail=1 +fi +[ "$fail" -eq 0 ] || exit 1 + +# --- Our ZP object (consumer-assembled, §6.2) --- +"$CA65" -t c64 -g -I "$LIB_ROOT/src" "${ZP_DEFINES[@]}" \ + -o "$STAGING/mlkem_zp_config.o" "$LIB_ROOT/src/zp_config.s" +zp_exports=$("$OD65" --dump-exports "$STAGING/mlkem_zp_config.o" | grep -c '"mlkem_zp_' || true) +if [ "$zp_exports" -ne 5 ]; then + echo "ERROR: mlkem_zp_config.o exports $zp_exports mlkem_zp_* slots, expected 5" >&2; exit 1 +fi + +# --- Archive --- +rm -f "$ARCHIVE" +OBJ_PATHS=() +for m in "${MEMBERS[@]}"; do OBJ_PATHS+=("$STAGING/members/$m"); done +"$AR65" a "$ARCHIVE" "${OBJ_PATHS[@]}" "$STAGING/mlkem_zp_config.o" + +{ + echo "# mlkem.a members and LIB_MLKEM_* segment sizes (od65 --dump-segsize)" + echo "# upstream: libs/mlkem $(git -C "$LIB_ROOT" describe --tags --always 2>/dev/null || echo unknown)" + for m in "${MEMBERS[@]}" ; do + printf '%-24s %s\n' "$m" "$("$OD65" --dump-segsize "$STAGING/members/$m" \ + | awk -F: '/LIB_MLKEM_/ {gsub(/ /,"",$1); gsub(/ /,"",$2); printf "%s=%s ", $1, $2}')" + done + printf '%-24s %s\n' mlkem_zp_config.o '(ZP equates only: $40-$4F)' +} > "$SIZES" + +echo "built $ARCHIVE" +cat "$SIZES" diff --git a/tools/measure_margins.py b/tools/measure_margins.py index 1016939..e38c3de 100644 --- a/tools/measure_margins.py +++ b/tools/measure_margins.py @@ -80,6 +80,17 @@ "HTTPS_HOST=en.wikipedia.org", "HTTPS_PATH=/wiki/Commodore_64", "HTTPS_BODY_TO_REU=1", "TRUST_STORE=1"), + # PQ_HYBRID=1 (libs/mlkem linked, nothing calling it): the four UCI + # products it is allowed on. ip65, uci-m3 and the uci REU profile refuse + # the knob, so they have no row here. + "uci-onchip-pq": ("BACKEND=uci", "USE_NISTCURVES_ONCHIP=1", + "PQ_HYBRID=1"), + "uci-comb-pq": ("BACKEND=uci", "USE_NISTCURVES_ONCHIP_COMB=1", + "PQ_HYBRID=1"), + "uci-onchip-store-pq": ("BACKEND=uci", "USE_NISTCURVES_ONCHIP=1", + "TRUST_STORE=1", "TRUST_RELEASE=1", "PQ_HYBRID=1"), + "uci-comb-store-pq": ("BACKEND=uci", "USE_NISTCURVES_ONCHIP_COMB=1", + "TRUST_STORE=1", "TRUST_RELEASE=1", "PQ_HYBRID=1"), } diff --git a/tools/test_mlkem_placement.py b/tools/test_mlkem_placement.py new file mode 100644 index 0000000..eb82a8f --- /dev/null +++ b/tools/test_mlkem_placement.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""PQ_HYBRID=1 placement guard (libs/mlkem, PQC plan phase P0). + +Nothing calls ML-KEM yet, so nothing at runtime can notice it landed on top +of something live. The whole proof is link-time, in src/crypto/mlkem_link.s +and the cfgs' MLKEM_*_RAM areas. This suite proves the proof: + + [1] cfg shape (no build): the three uci cfgs carry the three areas with + the exact addresses, every MEMORY area is `define = yes`, and every + area has an sqtab-window guard in mlkem_link.s (or is the one + documented exemption). A synthetic extra area is a red case: this is + the half of the sqtab rule ld65 cannot enforce for us. + [2] the knob matrix (make -n): allowed on uci onchip/comb, refused with + its own message on ip65, uci-m3, the uci REU profile and a bad value. + [3] green controls: uci-onchip and both comb cfgs LINK with PQ_HYBRID=1, + and the map holds the layout the asserts describe (nothing but + LIB_MLKEM_BSS, X25519_TABLES and TABLES_BSS in $B100-$BFFF; the + code at $E000 and the polys at $D000; the PRG still ends below $D000). + [4] mutations: each assert that guards a number is broken in turn and the + link must fail with THAT assert's text. A mutant that does not fail, + or fails with a different message, is a FAIL: the assert was not what + stopped it. Every mutated file is restored in a finally block and the + control re-linked at the end. + +The build steps run `make` in this checkout and overwrite build/. ~6 min. + + python3 tools/test_mlkem_placement.py # everything + python3 tools/test_mlkem_placement.py --static # [1] and [2] only +""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "tools")) +from _skip_policy import cannot_run, verdict # noqa: E402 +from check_prg_io_range import prg_io_violation # noqa: E402 + +CFGS = { + "c64-https-uci.cfg": False, # uci-onchip (and the REU profile) + "c64-https-uci-onchip.cfg": False, # comb, COLD_BANK=0 + "c64-https-uci-onchip-cold.cfg": True, # comb (cold bank) +} +LINK_S = REPO / "src" / "crypto" / "mlkem_link.s" +# The one area that CONTAINS the sqtab window by design: the window is +# c64-https's own table (TABLES_BSS). mlkem_link.s asserts the equivalent. +EXEMPT = {"CRYPTO_COLD_SHADOW"} +EXPECT_AREAS = { + "MLKEM_CODE_RAM": ("$E000", "$1E00"), + "MLKEM_BSS_RAM": ("$B100", "$0B00"), + "MLKEM_POLY_RAM": ("$D000", "$1000"), +} + +AREA_RE = re.compile(r"^\s*([A-Z0-9_]+):\s*(start\s*=.*?);", re.M) + + +class Tally: + def __init__(self): + self.passed = self.failed = 0 + + def check(self, ok, what, detail=""): + if ok: + self.passed += 1 + print(f" PASS {what}") + else: + self.failed += 1 + print(f" FAIL {what}" + (f": {detail}" if detail else "")) + return ok + + +def memory_areas(cfg_text): + """{name: attribute-string} for the MEMORY block (comments stripped).""" + body = re.search(r"^MEMORY\s*\{(.*?)^\}", cfg_text, re.M | re.S).group(1) + body = "\n".join(ln.split("#")[0] for ln in body.splitlines()) + return {m.group(1): m.group(2) for m in AREA_RE.finditer(body)} + + +def guarded_areas(link_text=None): + link_text = link_text if link_text is not None else LINK_S.read_text() + return set(re.findall(r'^SQTAB_GUARD\s+"([A-Z0-9_]+)"', link_text, re.M)) + + +def unguarded(cfg_text, cold, link_text=None): + """Areas of *cfg_text* with neither a guard nor the exemption.""" + guards = guarded_areas(link_text) + out = [] + for name in memory_areas(cfg_text): + if name in EXEMPT or name in guards: + continue + out.append(name) + return out + + +def no_define(cfg_text): + return [n for n, a in memory_areas(cfg_text).items() + if not re.search(r"define\s*=\s*yes", a)] + + +def run_make(args, extra_env=None, timeout=1800): + env = dict(os.environ, **(extra_env or {})) + r = subprocess.run(["make"] + args, cwd=REPO, capture_output=True, + text=True, env=env, timeout=timeout) + return r.returncode, r.stdout + r.stderr + + +def static_checks(t): + print("\n[1] cfg shape") + for cfg, cold in CFGS.items(): + text = (REPO / "cfg" / cfg).read_text() + areas = memory_areas(text) + for name, (start, size) in EXPECT_AREAS.items(): + a = areas.get(name, "") + t.check(a and f"start = {start}" in a and f"size = {size}" in a + and 'file = ""' in a and "define = yes" in a, + f"{cfg}: {name} = {start}/{size}, file=\"\", define", + a or "area missing") + t.check(not no_define(text), f"{cfg}: every MEMORY area is define = yes", + ", ".join(no_define(text))) + t.check(not unguarded(text, cold), + f"{cfg}: every MEMORY area has an sqtab-window guard", + ", ".join(unguarded(text, cold))) + for seg in ("LIB_MLKEM_CODE", "LIB_MLKEM_RODATA", "LIB_MLKEM_BSS", + "LIB_MLKEM_POLY_BSS"): + t.check(re.search(rf"^\s*{seg}:.*optional\s*=\s*yes", text, re.M), + f"{cfg}: {seg} is optional (unset builds stay identical)") + + # Red cases for the guard: it must see a new area and a missing define. + base = (REPO / "cfg" / "c64-https-uci.cfg").read_text() + anchor = " X25519_SCRATCH: start = $A000" + assert anchor in base + extra = base.replace(anchor, " ZZ_NEW: start = $BD00, size = $0100, " + "type = rw, file = \"\", define = yes;\n" + anchor) + t.check(unguarded(extra, False) == ["ZZ_NEW"], + "red: a new MEMORY area with no guard is reported", + str(unguarded(extra, False))) + nodef = base.replace("LOADADDR: start = $07FF, size = $0002, file = %O, " + "define = yes;", "LOADADDR: start = $07FF, " + "size = $0002, file = %O;") + t.check(no_define(nodef) == ["LOADADDR"], + "red: an area without define = yes is reported", str(no_define(nodef))) + link = LINK_S.read_text() + t.check("ZP_CRYPTO" in unguarded(base, False, link.replace( + 'SQTAB_GUARD "ZP_CRYPTO"', 'SQTAB_GUARD "XX"')), + "red: removing a guard line is reported") + + print("\n[2] the PQ_HYBRID knob matrix (make -n)") + cases = [ + (["BACKEND=ip65", "PQ_HYBRID=1"], False, "PQ_HYBRID=1 is UCI-only"), + (["BACKEND=ip65", "USE_NISTCURVES_ONCHIP=1", "PQ_HYBRID=1"], False, + "PQ_HYBRID=1 is UCI-only"), + (["BACKEND=uci-m3", "PQ_HYBRID=1"], False, "does not apply to BACKEND=uci-m3"), + (["BACKEND=uci", "PQ_HYBRID=1"], False, "needs USE_NISTCURVES_ONCHIP=1"), + (["BACKEND=uci", "USE_NISTCURVES_ONCHIP=1", "PQ_HYBRID=2"], False, + "PQ_HYBRID must be 1"), + (["BACKEND=uci", "USE_NISTCURVES_ONCHIP=1", "PQ_HYBRID=1"], True, ""), + (["BACKEND=uci", "USE_NISTCURVES_ONCHIP=1", "PQ_HYBRID=1", + "TRUST_STORE=1"], True, ""), + (["BACKEND=uci", "USE_NISTCURVES_ONCHIP_COMB=1", "PQ_HYBRID=1"], True, ""), + (["BACKEND=uci", "USE_NISTCURVES_ONCHIP_COMB=1", "PQ_HYBRID=1", + "TRUST_STORE=1"], True, ""), + (["BACKEND=uci", "USE_NISTCURVES_ONCHIP=1", "PQ_HYBRID=0"], True, ""), + ] + for args, ok, msg in cases: + rc, out = run_make(["-n"] + args) + label = " ".join(args) + if ok: + t.check(rc == 0, f"allowed: {label}", out.strip()[-200:]) + else: + t.check(rc != 0 and msg in out, f"refused: {label}", + f"rc={rc} {out.strip()[-200:]}") + + +def map_segments(map_path): + text = Path(map_path).read_text() + sec = text.split("Segment list:")[1].split("Exports list")[0] + segs = {} + for ln in sec.splitlines(): + m = re.match(r"^(\S+)\s+([0-9A-F]{6})\s+([0-9A-F]{6})\s+([0-9A-F]{6})\s+", ln) + if m: + segs[m.group(1)] = (int(m.group(2), 16), int(m.group(3), 16)) + return segs + + +def layout_checks(t, tag): + segs = map_segments(REPO / "build" / "c64-https.map") + inside = {n for n, (s, e) in segs.items() if s >= 0xB100 and s < 0xC000 + and n != "BSS"} + t.check(inside == {"LIB_MLKEM_BSS", "X25519_TABLES", "TABLES_BSS"}, + f"{tag}: only LIB_MLKEM_BSS, X25519_TABLES, TABLES_BSS start in " + f"$B100-$BFFF", str(sorted(inside))) + t.check(segs["LIB_MLKEM_BSS"][0] == 0xB100 and segs["LIB_MLKEM_BSS"][1] < 0xBC00, + f"{tag}: LIB_MLKEM_BSS = $B100-${segs['LIB_MLKEM_BSS'][1]:04X}, below sqtab") + t.check(segs["LIB_MLKEM_POLY_BSS"] == (0xD000, 0xDFFF), + f"{tag}: LIB_MLKEM_POLY_BSS = $D000-$DFFF") + t.check(segs["LIB_MLKEM_CODE"][0] == 0xE000 + and segs["LIB_MLKEM_RODATA"][1] <= 0xFDFF, + f"{tag}: code+rodata = $E000-${segs['LIB_MLKEM_RODATA'][1]:04X}, " + f"inside $E000-$FDFF") + prg = REPO / "build" / "c64-https.prg" + bad = prg_io_violation(prg.read_bytes()) + t.check(bad is None, f"{tag}: the PRG does not load into $D000-$DFFF", + str(bad)) + + +# (label, file, old, new, expected ld65/ca65 text) +MUTANTS = [ + ("polys moved off $D000", "cfg/c64-https-uci.cfg", + "MLKEM_POLY_RAM: start = $D000, size = $1000", + "MLKEM_POLY_RAM: start = $D100, size = $0F00", + "MLKEM_POLY_RAM is not at $D000"), + ("polys area shrunk", "cfg/c64-https-uci.cfg", + "MLKEM_POLY_RAM: start = $D000, size = $1000", + "MLKEM_POLY_RAM: start = $D000, size = $1100", + "MLKEM_POLY_RAM is not 4 KB"), + ("code moved off $E000", "cfg/c64-https-uci.cfg", + "MLKEM_CODE_RAM: start = $E000, size = $1E00", + "MLKEM_CODE_RAM: start = $E200, size = $1C00", + "MLKEM_CODE_RAM is not at $E000"), + ("code area shrunk below the footprint", "cfg/c64-https-uci.cfg", + "MLKEM_CODE_RAM: start = $E000, size = $1E00", + "MLKEM_CODE_RAM: start = $E000, size = $1800", + "MLKEM_CODE_RAM is not 7,680 B"), + ("rest-BSS moved off X25519_TABLES", "cfg/c64-https-uci.cfg", + "MLKEM_BSS_RAM: start = $B100, size = $0B00", + "MLKEM_BSS_RAM: start = $B000, size = $0C00", + "MLKEM_BSS_RAM must start on X25519_TABLES"), + ("rest-BSS grown over the sqtab", "cfg/c64-https-uci.cfg", + "MLKEM_BSS_RAM: start = $B100, size = $0B00", + "MLKEM_BSS_RAM: start = $B100, size = $0D00", + "MLKEM_BSS_RAM is not exactly [mul38_lo_tab, sqtab_lo)"), + ("an empty area parked on the sqtab", "cfg/c64-https-uci.cfg", + "UCI_BSS_REGION: start = $4000, size = $0000", + "UCI_BSS_REGION: start = $BD00, size = $0000", + "area UCI_BSS_REGION overlaps the sqtab window"), + ("dk ring smaller than 2,400 B", "src/constants.inc", + "TCP_RECV_BUF_SIZE = 4096", "TCP_RECV_BUF_SIZE = 2048", + "the TCP ring is smaller than the 2,400 B dk it hosts"), + ("dk size drifted", "src/constants.inc", + "MLKEM_DK_SIZE = 2400", "MLKEM_DK_SIZE = 2401", + "session_scrub: span at tcp_recv_buf is not 2400 bytes"), + ("dk scratch moved off the ring", "src/constants.inc", + "mlkem_dk_scratch = tcp_recv_buf", + "mlkem_dk_scratch = tcp_recv_buf + $100", + "the dk scratch moved off the TCP ring base"), + ("ZP slot moved ($40 -> $30)", "tools/integration/build_mlkem.sh", + "-D mlkem_zp_src=0x40", "-D mlkem_zp_src=0x30", + "mlkem_zp_src moved off $40"), + ("ZP mul block moved ($48 -> $50)", "tools/integration/build_mlkem.sh", + "-D mlkem_zp_mul=0x48", "-D mlkem_zp_mul=0x50", + "mlkem_zp_mul moved off $48"), + ("scrub window span length", "src/session_scrub.s", + "mul_dma_hi + 256, 2816", "mul_dma_hi + 256, 2815", + "session_scrub: span at mul38_lo_tab is not 2815 bytes"), + ("scrub poly span length", "src/session_scrub.s", + "__LIB_MLKEM_POLY_BSS_RUN__ + __LIB_MLKEM_POLY_BSS_SIZE__, 4096", + "__LIB_MLKEM_POLY_BSS_RUN__ + __LIB_MLKEM_POLY_BSS_SIZE__, 4095", + "session_scrub: span at __LIB_MLKEM_POLY_BSS_RUN__ is not 4095 bytes"), + ("scrub dk span length", "src/session_scrub.s", + "tcp_recv_buf + MLKEM_DK_SIZE, 2400", "tcp_recv_buf + MLKEM_DK_SIZE, 2399", + "session_scrub: span at tcp_recv_buf is not 2399 bytes"), +] + +PQ_ARGS = ["BACKEND=uci", "USE_NISTCURVES_ONCHIP=1", "PQ_HYBRID=1"] +COMB_ARGS = ["BACKEND=uci", "USE_NISTCURVES_ONCHIP_COMB=1", "PQ_HYBRID=1"] +COMB_NOCOLD = COMB_ARGS + ["COLD_BANK=0"] + + +def link_ok(t, tag, args): + subprocess.run(["make", "clean"], cwd=REPO, capture_output=True) + rc, out = run_make(args) + errs = [ln for ln in out.splitlines() if "rror" in ln and "ldwarning" not in ln] + ok = t.check(rc == 0, f"{tag}: links", "\n".join(errs[-5:]) or out[-400:]) + if ok: + warns = [ln for ln in out.splitlines() if ln.startswith("ld65: Warning")] + t.check(len(warns) == 1 and "LINKED at $E000" in warns[0], + f"{tag}: the only ld65 warning is the not-delivered notice", + "; ".join(warns)) + layout_checks(t, tag) + return ok + + +def build_checks(t): + print("\n[3] green controls") + if not link_ok(t, "uci-onchip", PQ_ARGS): + return + link_ok(t, "uci-onchip TRUST_STORE=1", PQ_ARGS + ["TRUST_STORE=1", "TRUST_RELEASE=1"]) + link_ok(t, "uci-comb", COMB_ARGS) + link_ok(t, "uci-comb TRUST_STORE=1", COMB_ARGS + ["TRUST_STORE=1", "TRUST_RELEASE=1"]) + link_ok(t, "uci-comb COLD_BANK=0", COMB_NOCOLD) + + print("\n[4] mutations: each must fail with its own assert") + subprocess.run(["make", "clean"], cwd=REPO, capture_output=True) + for label, f, old, new, expect in MUTANTS: + path = REPO / f + orig = path.read_text() + if old not in orig: + t.check(False, f"mutant {label!r}", f"anchor not found in {f}") + continue + try: + path.write_text(orig.replace(old, new, 1)) + subprocess.run(["make", "clean"], cwd=REPO, capture_output=True) + rc, out = run_make(PQ_ARGS) + finally: + path.write_text(orig) + if expect is None: + t.check(rc != 0, f"mutant {label!r} fails the build", + out.strip()[-300:]) + else: + t.check(rc != 0 and expect in out, + f"mutant {label!r} fails with {expect!r}", + f"rc={rc} " + "\n".join( + ln for ln in out.splitlines() if "rror" in ln)[-400:]) + + print("\n[5] the stale-checkout probe (libs/mlkem at v0.5.1)") + sub = REPO / "libs" / "mlkem" + head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=sub, + capture_output=True, text=True).stdout.strip() + try: + co = subprocess.run(["git", "checkout", "-q", "v0.5.1"], cwd=sub, + capture_output=True, text=True) + if t.check(co.returncode == 0, "checked out libs/mlkem v0.5.1", + co.stderr.strip()): + subprocess.run(["make", "clean"], cwd=REPO, capture_output=True) + rc, out = run_make(PQ_ARGS) + t.check(rc != 0 and "libs/mlkem checkout is unusable" in out + and "need c64-mlkem >= v0.6.0" in out, + "a v0.5.1 checkout fails the preflight by name", + out.strip()[-300:]) + finally: + subprocess.run(["git", "checkout", "-q", head], cwd=sub, + capture_output=True) + + print("\n[6] restored: control relinks") + link_ok(t, "uci-onchip (restored)", PQ_ARGS) + + +def main(): + t = Tally() + static_checks(t) + if "--static" not in sys.argv[1:]: + if shutil.which("ld65") is None or shutil.which("ca65") is None: + return cannot_run("cc65 not found", certifies="the PQ_HYBRID placement asserts") + build_checks(t) + print(f"\nRESULTS: {t.passed}/{t.passed + t.failed} passed, {t.failed} failed") + return verdict(t.passed, t.failed, certifies="the PQ_HYBRID placement asserts") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_quit_basic_exit.py b/tools/test_quit_basic_exit.py index 963fb6d..2cc6264 100644 --- a/tools/test_quit_basic_exit.py +++ b/tools/test_quit_basic_exit.py @@ -112,6 +112,42 @@ ("poly_prod_lo", 2), # poly_prod_hi follows (asserted in the span) ("mul_dma_lo", 256), ("mul_dma_hi", 256), ] +# PQ_HYBRID=1 builds (libs/mlkem) add three more secret homes, absent from +# labels.txt otherwise: (name, label, size). The polynomials sit in RAM under +# I/O ($D000), so planting and reading go through $01 = $34 (all RAM); the +# first span of the ML-KEM window ($B100-$BBFF) replaces the mul_dma one; +# the dk is the first 2,400 B of the TCP ring. +PQ_REGIONS = [ + ("ML-KEM rest-BSS window", "mul38_lo_tab", 2816), + ("ML-KEM polynomials", "__LIB_MLKEM_POLY_BSS_RUN__", 4096), + ("ML-KEM dk scratch (TCP ring)", 0xC000, 2400), # tcp_recv_buf: an equate, no label +] + + +def pq_regions(labels): + """The PQ_HYBRID regions this build has, as (name, addr, size, index).""" + out = [] + for i, (name, lab, size) in enumerate(PQ_REGIONS): + poly = labels.address("__LIB_MLKEM_POLY_BSS_RUN__") + addr = lab if isinstance(lab, int) else labels.address(lab) + if poly is not None and addr is not None: + out.append((name, addr, size, 100 + i)) + return out + + +def ram_rw(transport, addr, data=None, size=0): + """Read (data None) or write RAM at *addr* with $01 = $34 (all RAM), then + restore $01. Reaches RAM under I/O and under the ROMs.""" + old = read_bytes(transport, 1, 1) + write_bytes(transport, 1, b"\x34") + try: + if data is None: + return read_bytes(transport, addr, size) + write_bytes(transport, addr, data) + finally: + write_bytes(transport, 1, old) + + # The DRBG state: re-seeded on quit, not left zero (a zero K/V would hand a # rig that SYSes back in all-zero "random" bytes). Must hold neither the # planted pattern nor a bare instantiate value (K = 00.., V = 01..). @@ -244,6 +280,10 @@ def run_tests(transport, labels, seed=None): # puts it back to 32, so start it at 0 or the check below is vacuous. write_bytes(transport, labels["drbg_buf_idx"], b"\x00") + for name, addr, size, idx in pq_regions(labels): + ram_rw(transport, addr, pattern(idx, size)) + print(f" planted {size} B at ${addr:04X}: {name}") + print("\n[3] 'Q': state at quit_scrubbed, before BASIC ROM returns") scrub_pc = labels.address("quit_scrubbed") if t.check(scrub_pc is not None, "quit_scrubbed exists", @@ -265,6 +305,11 @@ def run_tests(transport, labels, seed=None): for name, size in ZEROED: got = read_bytes(transport, labels[name], size) t.check(not any(got), f"{name} zero", hx(got[:16])) + for name, addr, size, idx in pq_regions(labels): + got = ram_rw(transport, addr, size=size) + t.check(not any(got), f"{name} zero ({size} B at ${addr:04X})", + f"{sum(1 for x in got if x)} non-zero bytes, first " + + hx(got[:8])) for i, (name, size) in enumerate(RESEEDED, start=len(ZEROED)): if not t.check(labels.address(name) is not None, f"{name} exists", "not in labels.txt"): From e9fe957d5c122c5bf6f1b200edc44acd5d19f318 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:02:14 -0500 Subject: [PATCH 2/7] PQC P0: ML-KEM loads at $A000, boot copies it to $E000; pq_mlkem_call entry Implements the 2026-10-13 decision: LIB_MLKEM code/rodata LOADS in the PRG at $A000 (the file bytes under CRYPTO_COLD_SHADOW's BSS, which boot zeroes anyway) and RUNS at $E000. No #288 relaxation: the PRG still ends at $BFFF (or $CFFF on comb). - cfg: CRYPTO_COLD_SHADOW becomes the RAM view (file=""), COLD_FILE the file view over the same $A000-$BFFF; unset, the PRG is byte-identical (all seven profiles). LIB_MLKEM_CODE/RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM. - mlkem_link.s: load/run/size asserts (head of COLD_FILE, same layout as the run image, fits one 30-page block copy, ends inside the PRG's file content, below the $FFFA vectors); mlkem_boot_init (copy + RAM vectors -> RTI, skip unless fresh: flag AND nonzero stage); pq_mlkem_call, the only entry (banks $01=$34 under sei, runs mlkem_arith_init, jsrs the entry). - boot.s: jsr mlkem_boot_init right after sei, before the zeroing loop, with an assembler assert on the order. - tools/test_mlkem_linked.py: VICE boot of the PQ image, compares $E000.. to the PRG's $A000.. image, KeyGen and Decaps_seed against the reference across X25519 runs; three mutants go red. - measure_margins.py: RAM/file view aliasing, align_load, MLKEM_CODE_RAM row. - test_quit_basic_exit.py: the code and vectors survive 'Q'. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 10 +- cfg/c64-https-uci-onchip-cold.cfg | 27 ++- cfg/c64-https-uci-onchip.cfg | 27 ++- cfg/c64-https-uci.cfg | 27 ++- docs/engineering-notes.md | 27 ++- src/boot.s | 15 ++ src/crypto/mlkem_link.s | 131 +++++++++++- tools/measure_margins.py | 21 +- tools/test_measure_margins.py | 30 ++- tools/test_mlkem_linked.py | 331 ++++++++++++++++++++++++++++++ tools/test_mlkem_placement.py | 46 ++++- tools/test_quit_basic_exit.py | 17 ++ 12 files changed, 655 insertions(+), 54 deletions(-) create mode 100644 tools/test_mlkem_linked.py diff --git a/CLAUDE.md b/CLAUDE.md index 519cba4..89957bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,8 +147,10 @@ Variables: + `USE_NISTCURVES_ONCHIP=1` or `_COMB=1`, with or without `TRUST_STORE`; `$(error)` on ip65, uci-m3 and the uci REU profile. Unset = byte-identical PRG on every profile. Layout in Memory layout; asserts in - `src/crypto/mlkem_link.s`, guard `tools/test_mlkem_placement.py`. The - code is **linked at $E000 but not in the PRG** (#288): not runnable yet. + `src/crypto/mlkem_link.s`, guards `tools/test_mlkem_placement.py` and + `tools/test_mlkem_linked.py`. The code **loads at $A000** (the shadow + BSS's file bytes) and `mlkem_boot_init` copies it to $E000 before boot's + zeroing (#288 stays satisfied). The only entry is `pq_mlkem_call`. - `USE_X25519_SIBLING` — **retired, `$(error)`-guarded (#245)**: every build links the `libs/x25519` sibling; any value is refused. - `ENABLE_P384_VERIFY`, `USE_OVERLAY_P384_EMBED`, `KEEP_CRYPTO_SWAP`, @@ -827,7 +829,9 @@ serves uci-onchip; `-onchip-cold.cfg` comb; `-onchip.cfg` comb COLD_BANK=0): (2,545 B of 2,816, over X25519_TABLES + the $B900 hole + mul_dma). Rules: ML-KEM never interleaves with X25519 or fp_mul, and **every ML-KEM call group needs `mlkem_arith_init` first** (its 1 KB of mod-q tables live in that -window, which X25519 rebuilds over); calls run under `sei` with `$01=$34`. +window, which X25519 rebuilds over): `pq_mlkem_call` does both, and +`test_mlkem_placement.py` fails on any other reference to a library entry. +The `mlkem_arg_*` block is in that window too: fill it after the last X25519. All pinned in `src/crypto/mlkem_link.s`; scrub spans in `session_scrub.s`. ip65 (`cfg/c64-https-ip65.cfg`): diff --git a/cfg/c64-https-uci-onchip-cold.cfg b/cfg/c64-https-uci-onchip-cold.cfg index fffea65..4017d43 100644 --- a/cfg/c64-https-uci-onchip-cold.cfg +++ b/cfg/c64-https-uci-onchip-cold.cfg @@ -222,7 +222,18 @@ MEMORY { # comfortably; the 8 KB cold half has enough room for the four # largest BSS chunks routed there. CRYPTO_HOT: start = $6000, size = $4000, file = %O, define = yes, fill = yes, fillval = $00; - CRYPTO_COLD_SHADOW: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; + # PQ_HYBRID (libs/mlkem): the shadow's FILE bytes and its RAM bytes are + # two areas over the same $A000-$BFFF. CRYPTO_COLD_SHADOW is the RAM + # view every BSS segment is placed in (unchanged names, symbols and + # addresses); COLD_FILE is the file view, a zero-filled 8 KB image that + # is byte-for-byte what CRYPTO_COLD_SHADOW alone used to emit. With no + # staged segment the PRG is identical to before. The ML-KEM code+rodata + # LOADS here (the file bytes under the BSS exist anyway: boot zeroes + # them) and RUNS at $E000, copied by boot before the zeroing. A BSS + # segment cannot share an area with a ro one at the same addresses, which + # is why the two views are separate areas rather than one. + CRYPTO_COLD_SHADOW: start = $A000, size = $2000, type = rw, file = "", define = yes; + COLD_FILE: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; # X25519_SCRATCH overlaps tls_rec_buf (BSS_TAIL, 548 B) ON PURPOSE # (issue #245). It carries the X25519 sibling's 17 field buffers # (X25519_BSS, 544 B), which are live only inside the two scalar mults @@ -240,10 +251,9 @@ MEMORY { # LIB_MLKEM_* segment linked they are empty, so an unset build is # byte-identical. # MLKEM_CODE_RAM $E000-$FDFF, RAM under the KERNAL ROM (callers run - # ML-KEM with the KERNAL banked out, under sei). NOT in - # the PRG: a file-backed $E000 image would carry a - # $C000-$DFFF pad through the I/O page (#288). The - # code is therefore LINKED here but not delivered yet. + # ML-KEM with the KERNAL banked out, under sei). Not + # file-backed (#288: no PRG byte may reach $D000+): the + # code LOADS in COLD_FILE at $A000 and boot copies it up. # MLKEM_BSS_RAM $B100-$BBFF = X25519_TABLES + the $B900 hole + # mul_dma_lo/hi, all dead while ML-KEM runs (ML-KEM # never interleaves with X25519 or fp_mul). @@ -522,8 +532,11 @@ SEGMENTS { # PQ_HYBRID=1 only (all optional). align = $40 on rodata and $100 on the # BSS are the library's own rules (cfg/mlkem-example.cfg in libs/mlkem). - LIB_MLKEM_CODE: load = MLKEM_CODE_RAM, type = ro, optional = yes; - LIB_MLKEM_RODATA: load = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40; + # load = COLD_FILE ($A000, file bytes), run = MLKEM_CODE_RAM ($E000). + # align_load keeps the load image the same shape as the run image, so + # one block copy moves both (asserted in src/crypto/mlkem_link.s). + LIB_MLKEM_CODE: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; + LIB_MLKEM_RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40, align_load = $40, define = yes; LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/cfg/c64-https-uci-onchip.cfg b/cfg/c64-https-uci-onchip.cfg index 8f3ee59..15b6559 100644 --- a/cfg/c64-https-uci-onchip.cfg +++ b/cfg/c64-https-uci-onchip.cfg @@ -201,7 +201,18 @@ MEMORY { # comfortably; the 8 KB cold half has enough room for the four # largest BSS chunks routed there. CRYPTO_HOT: start = $6000, size = $4000, file = %O, define = yes, fill = yes, fillval = $00; - CRYPTO_COLD_SHADOW: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; + # PQ_HYBRID (libs/mlkem): the shadow's FILE bytes and its RAM bytes are + # two areas over the same $A000-$BFFF. CRYPTO_COLD_SHADOW is the RAM + # view every BSS segment is placed in (unchanged names, symbols and + # addresses); COLD_FILE is the file view, a zero-filled 8 KB image that + # is byte-for-byte what CRYPTO_COLD_SHADOW alone used to emit. With no + # staged segment the PRG is identical to before. The ML-KEM code+rodata + # LOADS here (the file bytes under the BSS exist anyway: boot zeroes + # them) and RUNS at $E000, copied by boot before the zeroing. A BSS + # segment cannot share an area with a ro one at the same addresses, which + # is why the two views are separate areas rather than one. + CRYPTO_COLD_SHADOW: start = $A000, size = $2000, type = rw, file = "", define = yes; + COLD_FILE: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; # X25519_SCRATCH overlaps tls_rec_buf (BSS_TAIL, 548 B) ON PURPOSE # (issue #245). It carries the X25519 sibling's 17 field buffers # (X25519_BSS, 544 B), which are live only inside the two scalar mults @@ -219,10 +230,9 @@ MEMORY { # LIB_MLKEM_* segment linked they are empty, so an unset build is # byte-identical. # MLKEM_CODE_RAM $E000-$FDFF, RAM under the KERNAL ROM (callers run - # ML-KEM with the KERNAL banked out, under sei). NOT in - # the PRG: a file-backed $E000 image would carry a - # $C000-$DFFF pad through the I/O page (#288). The - # code is therefore LINKED here but not delivered yet. + # ML-KEM with the KERNAL banked out, under sei). Not + # file-backed (#288: no PRG byte may reach $D000+): the + # code LOADS in COLD_FILE at $A000 and boot copies it up. # MLKEM_BSS_RAM $B100-$BBFF = X25519_TABLES + the $B900 hole + # mul_dma_lo/hi, all dead while ML-KEM runs (ML-KEM # never interleaves with X25519 or fp_mul). @@ -485,8 +495,11 @@ SEGMENTS { # PQ_HYBRID=1 only (all optional). align = $40 on rodata and $100 on the # BSS are the library's own rules (cfg/mlkem-example.cfg in libs/mlkem). - LIB_MLKEM_CODE: load = MLKEM_CODE_RAM, type = ro, optional = yes; - LIB_MLKEM_RODATA: load = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40; + # load = COLD_FILE ($A000, file bytes), run = MLKEM_CODE_RAM ($E000). + # align_load keeps the load image the same shape as the run image, so + # one block copy moves both (asserted in src/crypto/mlkem_link.s). + LIB_MLKEM_CODE: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; + LIB_MLKEM_RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40, align_load = $40, define = yes; LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/cfg/c64-https-uci.cfg b/cfg/c64-https-uci.cfg index 93e009c..83bb8a0 100644 --- a/cfg/c64-https-uci.cfg +++ b/cfg/c64-https-uci.cfg @@ -156,7 +156,18 @@ MEMORY { # comfortably; the 8 KB cold half has enough room for the four # largest BSS chunks routed there. CRYPTO_HOT: start = $6000, size = $4000, file = %O, define = yes, fill = yes, fillval = $00; - CRYPTO_COLD_SHADOW: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; + # PQ_HYBRID (libs/mlkem): the shadow's FILE bytes and its RAM bytes are + # two areas over the same $A000-$BFFF. CRYPTO_COLD_SHADOW is the RAM + # view every BSS segment is placed in (unchanged names, symbols and + # addresses); COLD_FILE is the file view, a zero-filled 8 KB image that + # is byte-for-byte what CRYPTO_COLD_SHADOW alone used to emit. With no + # staged segment the PRG is identical to before. The ML-KEM code+rodata + # LOADS here (the file bytes under the BSS exist anyway: boot zeroes + # them) and RUNS at $E000, copied by boot before the zeroing. A BSS + # segment cannot share an area with a ro one at the same addresses, which + # is why the two views are separate areas rather than one. + CRYPTO_COLD_SHADOW: start = $A000, size = $2000, type = rw, file = "", define = yes; + COLD_FILE: start = $A000, size = $2000, file = %O, define = yes, fill = yes, fillval = $00; # X25519_SCRATCH overlaps tls_rec_buf (BSS_TAIL, 548 B) ON PURPOSE # (issue #245). It carries the X25519 sibling's 17 field buffers # (X25519_BSS, 544 B), which are live only inside the two scalar mults @@ -174,10 +185,9 @@ MEMORY { # LIB_MLKEM_* segment linked they are empty, so an unset build is # byte-identical. # MLKEM_CODE_RAM $E000-$FDFF, RAM under the KERNAL ROM (callers run - # ML-KEM with the KERNAL banked out, under sei). NOT in - # the PRG: a file-backed $E000 image would carry a - # $C000-$DFFF pad through the I/O page (#288). The - # code is therefore LINKED here but not delivered yet. + # ML-KEM with the KERNAL banked out, under sei). Not + # file-backed (#288: no PRG byte may reach $D000+): the + # code LOADS in COLD_FILE at $A000 and boot copies it up. # MLKEM_BSS_RAM $B100-$BBFF = X25519_TABLES + the $B900 hole + # mul_dma_lo/hi, all dead while ML-KEM runs (ML-KEM # never interleaves with X25519 or fp_mul). @@ -441,8 +451,11 @@ SEGMENTS { # PQ_HYBRID=1 only (all optional). align = $40 on rodata and $100 on the # BSS are the library's own rules (cfg/mlkem-example.cfg in libs/mlkem). - LIB_MLKEM_CODE: load = MLKEM_CODE_RAM, type = ro, optional = yes; - LIB_MLKEM_RODATA: load = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40; + # load = COLD_FILE ($A000, file bytes), run = MLKEM_CODE_RAM ($E000). + # align_load keeps the load image the same shape as the run image, so + # one block copy moves both (asserted in src/crypto/mlkem_link.s). + LIB_MLKEM_CODE: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; + LIB_MLKEM_RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40, align_load = $40, define = yes; LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 9b5c604..3e47b1e 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -4116,19 +4116,26 @@ lives; nothing calls it. Measured on the uci-onchip link: code+rodata `$E000-$FD40` (7,489 B with alignment fill, 191 B spare in the 7,680 B window), `LIB_MLKEM_BSS` `$B100-$BAF0` (2,545 B, 271 B spare before the sqtab at `$BC00`), `LIB_MLKEM_POLY_BSS` `$D000-$DFFF` (exactly 4,096 B). -Scrub cost: 21 B of LOADER on comb-store (61 B left), 82 -> 61. +Costs on comb-store: LOADER 82 -> 58 B, NET_CODE 589 -> 493 B. Findings that changed the plan: - - **The $E000 code is linked but not delivered.** #288 removed the only - file-backed way to reach `$E000` (the zero pad through the I/O page), and - staging 7.5 KB inside the PRG below `$D000` to copy up at boot needs file - bytes the images do not have: the free tails total ~2.7 KB, and - `$C000-$CFFF` (4 KB, free on uci, 1.7 KB free on comb) is the only - other file-backed area that can exist. So the cfgs put the code in a - `file=""` area and `mlkem_link.s` ldwarns on every link. Options for the - follow-up: relax #288 for DMA-only products (UCI loads by DMA), or - ship the code in the REU on comb, or shrink below 4 KB (not possible). + - **Delivery of the $E000 code (user decision 2026-10-13).** #288 removed + the file-backed route to `$E000`, so the image LOADS at `$A000` and boot + copies it up. The file bytes at `$A000-$BFFF` are CRYPTO_COLD_SHADOW's + BSS fill, which exist anyway. ld65 cannot put a ro segment and the BSS + at the same addresses of one area, so the shadow is split into a RAM + view (`CRYPTO_COLD_SHADOW`, `file=""`, all the BSS, same names and + addresses) and a file view (`COLD_FILE`, `file=%O`, the zero fill; holds + the image's load address). Unset, the PRG is byte-identical. The copy + (`mlkem_boot_init`, 30 pages, whole-page so no length symbol is needed) + runs right after `sei` in boot.s, before the zeroing that would erase its + source; an assembler assert pins that order. 'Q' leaves `$E000` and the + RAM vectors alone (not secret). Re-entry: the copy runs only when a + PRG-data flag is clear AND `$A000` is nonzero, since the stage is zero + after the first boot and `tls_rec_buf` is scrubbed to zero on 'Q'. + The NMI vectors in `$FFFA-$FFFF` (RAM with the KERNAL out) point at one + RTI in NET_CODE; IRQ/BRK point there too, moot under `sei`. - **`mlkem_arith_init` builds 1 KB of reduction tables inside `LIB_MLKEM_BSS`.** That window is X25519_TABLES, which X25519 rebuilds per scalar mult, so every ML-KEM call group needs `mlkem_arith_init` diff --git a/src/boot.s b/src/boot.s index 121fb80..d504417 100644 --- a/src/boot.s +++ b/src/boot.s @@ -143,6 +143,9 @@ SINK_PROBE = 1 .import entropy_init .import drbg_init_entropy .import sqtab_init + .ifdef PQ_HYBRID + .import mlkem_boot_init + .endif .import crypto_init ; ---- imports: network (backend adapter — ip65 or uci) ---- @@ -240,11 +243,20 @@ start: sei ; disable interrupts during init +.ifdef PQ_HYBRID + ; libs/mlkem: copy the code image from its load address ($A000, the + ; shadow BSS's file bytes) up to $E000 BEFORE the zeroing below + ; erases it. BASIC ROM is already out, so $A000 reads RAM. The order + ; is asserted after the loop. +mlkem_copy_site: + jsr mlkem_boot_init +.endif ; Zero SHADOW_BSS ($A000-$BFFF, 8 KiB). PRG LOAD does not zero BSS; ; ca65 BSS segments in file-less regions start with whatever RAM ; happened to contain. Without this, `net_initialized` and similar ; boot guards read garbage and send us straight into ip65 code before ; ip65 has been initialised, crashing us back to BASIC READY. +boot_zbss_first: ldy #$00 ldx #$20 ; 32 pages = $2000 bytes lda #$A0 @@ -258,6 +270,9 @@ start: inc @zbss_store+2 dex bne @zbss_page +.ifdef PQ_HYBRID +.assert mlkem_copy_site < boot_zbss_first, error, "PQ_HYBRID: the ML-KEM boot copy must run BEFORE the $A000-$BFFF zeroing (it reads its source from there)" +.endif .ifdef SINK_PROBE ; The REU's size bounds the HTTP body sink's region (src/http.s). ; Probed before anything else writes the REU: it writes offset 0 diff --git a/src/crypto/mlkem_link.s b/src/crypto/mlkem_link.s index 4d678b0..dbe832b 100644 --- a/src/crypto/mlkem_link.s +++ b/src/crypto/mlkem_link.s @@ -39,15 +39,27 @@ ; hardware vectors are RAM: a RESTORE-key NMI during a call jumps through ; whatever sits at $FFFA. $FE00-$FFFF is left free for a vector stub. ; -; HAZARD (#288) - the code is LINKED at $E000 but NOT DELIVERED by the PRG. -; An ld65 file-backed $E000 image would carry a zero pad over $C000-$DFFF, -; and a KERNAL LOAD stores through the I/O page (tools/test_prg_io_range.py -; forbids it). Staging 7,541 B inside the PRG below $D000 and copying it up -; at boot needs ~7.5 KB of file space the images do not have (the only -; free file bytes are scattered 1-2 KB tails, plus a 4 KB $C000-$CFFF -; area). So the image links and every address below is proven, but the -; bytes are absent: an ML-KEM call is not runnable yet. The ldwarning below -; says so on every PQ_HYBRID link until the delivery question is decided. +; DELIVERY (#288): the PRG may not carry a byte at $D000+, so the code is not +; file-backed at $E000. It LOADS at $A000 (COLD_FILE: the file bytes under +; CRYPTO_COLD_SHADOW's BSS, which boot zeroes anyway) and RUNS at $E000; +; mlkem_boot_init copies it up from boot.s BEFORE the zeroing (writes to +; $E000 land in RAM under the KERNAL, and boot has already banked BASIC out +; so $A000 reads RAM). The ordering is asserted in boot.s and tested in +; tools/test_mlkem_linked.py (which also compares the copied bytes). +; +; ENTRY: pq_mlkem_call is the only way in. It banks ($01 = $34, under sei), +; runs mlkem_arith_init (RULE 1 above: the reduction tables live in a window +; X25519 rebuilds), jsrs the entry in A/X and restores. The library's own +; entries are not to be referenced anywhere else +; (tools/test_mlkem_placement.py greps for it). The parameter block +; (mlkem_arg_*) is ALSO in LIB_MLKEM_BSS: fill it after the last X25519 / +; fp_mul and before pq_mlkem_call, never across one. +; +; VECTORS: with the KERNAL out $FFFA-$FFFF are RAM. mlkem_boot_init points +; NMI, RESET and IRQ/BRK at one RTI (in NET_CODE, always RAM), so a +; RESTORE-key NMI during a call returns instead of jumping through +; garbage. IRQ and BRK are moot: every call runs under sei, and no BRK is +; ever executed; they point at the same RTI so a stray one is harmless. ; ; ZERO PAGE (tools/integration/build_mlkem.sh has the table and reasoning): ; $40-$4F, inside ZP_WIDE; the only co-tenant is the X25519 sibling's @@ -80,6 +92,9 @@ LIB_SHARED_SQTAB_BASE = $BC00 ; --- Regions and neighbours -------------------------------------------------- .import __MLKEM_CODE_RAM_START__, __MLKEM_CODE_RAM_SIZE__, __MLKEM_CODE_RAM_LAST__ +.import __COLD_FILE_START__, __COLD_FILE_SIZE__ +.import __LIB_MLKEM_CODE_LOAD__, __LIB_MLKEM_CODE_RUN__, __LIB_MLKEM_CODE_SIZE__ +.import __LIB_MLKEM_RODATA_LOAD__, __LIB_MLKEM_RODATA_RUN__, __LIB_MLKEM_RODATA_SIZE__ .import __MLKEM_BSS_RAM_START__, __MLKEM_BSS_RAM_SIZE__, __MLKEM_BSS_RAM_LAST__ .import __MLKEM_POLY_RAM_START__, __MLKEM_POLY_RAM_SIZE__, __MLKEM_POLY_RAM_LAST__ .import __LIB_MLKEM_BSS_RUN__, __LIB_MLKEM_BSS_SIZE__ @@ -117,6 +132,25 @@ LIB_SHARED_SQTAB_BASE = $BC00 .assert mlkem_arith_init >= __MLKEM_CODE_RAM_START__, lderror, "mlkem_arith_init is not in MLKEM_CODE_RAM" .assert mlkem_keygen < __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__, lderror, "mlkem_keygen is past the end of MLKEM_CODE_RAM" +; --- The load image: $A000, one block copy to $E000 ------------------------ +; mlkem_boot_init copies __MLKEM_CODE_RAM_SIZE__ bytes from $A000. So the +; image must start at the head of COLD_FILE, be laid out exactly like the run +; image (the same offsets, or the block copy mis-places the rodata), and end +; inside COLD_FILE, i.e. inside the PRG's file content (the file runs on to +; $BFFF there; comb's COLD_IMAGE follows it). Nothing past COLD_FILE's end is +; read, so the image cannot reach the I/O page. +MLKEM_LOAD_BASE = $A000 +.assert __COLD_FILE_START__ = MLKEM_LOAD_BASE, lderror, "COLD_FILE is not at $A000: the boot copy source moved" +.assert __COLD_FILE_START__ + __COLD_FILE_SIZE__ = $C000, lderror, "COLD_FILE no longer ends at $BFFF (the PRG's old end)" +.assert __LIB_MLKEM_CODE_LOAD__ = MLKEM_LOAD_BASE, lderror, "LIB_MLKEM_CODE does not load at $A000" +.assert __LIB_MLKEM_CODE_RUN__ = __MLKEM_CODE_RAM_START__, lderror, "LIB_MLKEM_CODE does not run at the head of MLKEM_CODE_RAM" +.assert __LIB_MLKEM_RODATA_LOAD__ - __LIB_MLKEM_CODE_LOAD__ = __LIB_MLKEM_RODATA_RUN__ - __LIB_MLKEM_CODE_RUN__, lderror, "the load image and the run image are laid out differently: one block copy would mis-place the rodata" +.assert __LIB_MLKEM_RODATA_LOAD__ + __LIB_MLKEM_RODATA_SIZE__ <= MLKEM_LOAD_BASE + __MLKEM_CODE_RAM_SIZE__, lderror, "the ML-KEM load image is longer than the block boot copies" +.assert __LIB_MLKEM_RODATA_LOAD__ + __LIB_MLKEM_RODATA_SIZE__ <= __COLD_FILE_START__ + __COLD_FILE_SIZE__, lderror, "the ML-KEM load image runs past the PRG's file content" +.assert __LIB_MLKEM_RODATA_RUN__ + __LIB_MLKEM_RODATA_SIZE__ <= __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__, lderror, "the ML-KEM run image overflows MLKEM_CODE_RAM" +.assert __MLKEM_CODE_RAM_SIZE__ .mod 256 = 0, lderror, "the boot copy moves whole pages: MLKEM_CODE_RAM is not a page multiple" +.assert __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__ <= $FFFA, lderror, "MLKEM_CODE_RAM overlaps the $FFFA vectors" + ; --- Polynomials: $D000, exactly 4 KB --------------------------------------- .assert __MLKEM_POLY_RAM_START__ = $D000, lderror, "MLKEM_POLY_RAM is not at $D000" .assert __MLKEM_POLY_RAM_SIZE__ = $1000, lderror, "MLKEM_POLY_RAM is not 4 KB" @@ -191,6 +225,7 @@ SQTAB_GUARD "CRYPTO_OVERLAY", __CRYPTO_OVERLAY_START__, __CRYPTO_OVERLAY SQTAB_GUARD "P256_SCRATCH_UNION", __P256_SCRATCH_UNION_START__, __P256_SCRATCH_UNION_LAST__ SQTAB_GUARD "CRYPTO_HOT", __CRYPTO_HOT_START__, __CRYPTO_HOT_LAST__ SQTAB_GUARD "X25519_SCRATCH", __X25519_SCRATCH_START__, __X25519_SCRATCH_LAST__ +; COLD_FILE is the file view of CRYPTO_COLD_SHADOW (same range, same exemption). SQTAB_GUARD "MLKEM_CODE_RAM", __MLKEM_CODE_RAM_START__, __MLKEM_CODE_RAM_LAST__ SQTAB_GUARD "MLKEM_BSS_RAM", __MLKEM_BSS_RAM_START__, __MLKEM_BSS_RAM_LAST__ SQTAB_GUARD "MLKEM_POLY_RAM", __MLKEM_POLY_RAM_START__, __MLKEM_POLY_RAM_LAST__ @@ -212,5 +247,79 @@ SQTAB_GUARD "COLD_RUN_TRUST", __COLD_RUN_TRUST_START__, __COLD_RUN_TRUST .assert sqtab_hi + $200 = LIB_SHARED_SQTAB_BASE + $0400, lderror, "the sqtab window is not the owner's lo+hi planes" .assert __CRYPTO_COLD_SHADOW_LAST__ = LIB_SHARED_SQTAB_BASE + $0400, lderror, "CRYPTO_COLD_SHADOW has a tenant past the sqtab window" -; Link-only, and the linker says so (see HAZARD above). -.assert __MLKEM_CODE_RAM_LAST__ < __MLKEM_CODE_RAM_START__, ldwarning, "PQ_HYBRID: libs/mlkem is LINKED at $E000 but not delivered by the PRG (#288: no byte may load through $D000-$DFFF); an ML-KEM call is not runnable yet" +; ============================================================================= +; Code (NET_CODE via LOADER_OVERFLOW; always RAM, so the SMC below is legal) +; ============================================================================= +.export mlkem_boot_init, pq_mlkem_call + +.segment "LOADER_OVERFLOW" + +; mlkem_boot_init - boot.s, right after `sei`, BEFORE it zeroes $A000-$BFFF. +; In: BASIC ROM already banked out ($01 bit 0 clear), so $A000 reads RAM. +; Out: the load image at $A000 copied to $E000 (30 pages); NMI/RESET/IRQ +; vectors in RAM point at an RTI. Clobbers A, X, Y. +; Skips the copy unless this is a fresh LOAD: mlkem_copied is PRG data (0 +; only after a LOAD) AND $A000 is nonzero (the stage is zeroed by the boot +; right after the copy, and 'Q' zeroes tls_rec_buf at the same address). Both +; must say "fresh": after 'Q' the stage is zeros, so a re-entry that copied +; would erase the code. $E000 and the vectors are not secret and 'Q' leaves +; them alone (session_scrub has no span there). +mlkem_boot_init: + lda mlkem_copied + bne @done + lda MLKEM_LOAD_BASE + beq @done + lda #>MLKEM_LOAD_BASE + sta @ld+2 + lda #>__MLKEM_CODE_RAM_START__ + sta @st+2 + ldx #>__MLKEM_CODE_RAM_SIZE__ + ldy #0 +@pg: +@ld: lda MLKEM_LOAD_BASE,y +@st: sta __MLKEM_CODE_RAM_START__,y + iny + bne @pg + inc @ld+2 + inc @st+2 + dex + bne @pg + ldy #5 +@vec: lda mlkem_vec_tab,y + sta $FFFA,y + dey + bpl @vec + inc mlkem_copied +@done: rts + +mlkem_vec_tab: ; NMI, RESET, IRQ/BRK -> the one RTI + .word mlkem_rti, mlkem_rti, mlkem_rti +mlkem_rti: + rti +mlkem_copied: + .byte 0 + +; pq_mlkem_call - the only entry into libs/mlkem. +; In: A/X = entry (mlkem_keygen, mlkem_decaps_seed, ...); the mlkem_arg_* +; block already filled. +; Out: A = the entry's A (its status); X, Y, C, Z, N clobbered; I restored. +; Banks $01 = $34 (KERNAL, BASIC and I/O out: code at $E000, polys at +; $D000) under sei, runs mlkem_arith_init FIRST on every call because its +; tables live in the window X25519 rebuilds, then the entry. +pq_mlkem_call: + sta @go+1 + stx @go+2 + php + sei + lda $01 + pha + lda #$34 + sta $01 + jsr mlkem_arith_init +@go: jsr mlkem_keygen ; operand patched above + sta @ra+1 + pla + sta $01 + plp +@ra: lda #0 + rts diff --git a/tools/measure_margins.py b/tools/measure_margins.py index e38c3de..bf03c93 100644 --- a/tools/measure_margins.py +++ b/tools/measure_margins.py @@ -148,6 +148,16 @@ def parse_cfg_memory(text): }) if not areas: raise ParseError("empty MEMORY block in cfg") + # A RAM view and a file view over the same extent (PQ_HYBRID's + # CRYPTO_COLD_SHADOW / COLD_FILE): the file area is reported under the + # RAM view's name, which is the one the docs and every BSS segment use. + # Its own name stays as `alias` so a segment that LOADS there is found. + for f in [a for a in areas if a["file"]]: + views = [r for r in areas if not r["file"] and + (r["start"], r["size"]) == (f["start"], f["size"])] + if views: + f["alias"], f["name"] = f["name"], views[0]["name"] + areas = [a for a in areas if a is not views[0]] return areas @@ -197,11 +207,13 @@ def parse_cfg_split_segments(text): attrs[key.strip().lower()] = val.strip() if "run" not in attrs or attrs["run"] == attrs.get("load"): continue - if {"align", "start", "offset"} & attrs.keys(): + if {"start", "offset"} & attrs.keys(): raise ParseError(f"segment {name.strip()}: load/run split with " "its own placement attribute") + al = attrs.get("align_load", attrs.get("align")) out.append({"name": name.strip(), "load": attrs["load"], - "run": attrs["run"]}) + "run": attrs["run"], + "align_load": _number(al) if al else 1}) return out @@ -234,10 +246,13 @@ def measure(areas, segments, split=()): placed = [] for sp in split: seg = by_name.get(sp["name"]) - area = next((a for a in areas if a["name"] == sp["load"]), None) + area = next((a for a in areas + if sp["load"] in (a["name"], a.get("alias"))), None) if seg is None or not seg["size"] or area is None: continue lo = cursor.get(area["name"], area["start"]) + al = sp.get("align_load", 1) + lo = (lo + al - 1) // al * al placed.append({"name": sp["name"], "start": lo, "end": lo + seg["size"] - 1, "size": seg["size"]}) cursor[area["name"]] = lo + seg["size"] diff --git a/tools/test_measure_margins.py b/tools/test_measure_margins.py index a33c8d6..1227cad 100644 --- a/tools/test_measure_margins.py +++ b/tools/test_measure_margins.py @@ -288,7 +288,7 @@ def test_split_segment_bytes_land_in_the_load_area_and_run_area_is_reported(): def test_split_segment_with_its_own_placement_is_refused(): bad = SPLIT_CFG.replace("T2: load = IMG, run = RUN,", - "T2: load = IMG, run = RUN, align = $100,") + "T2: load = IMG, run = RUN, start = $100,") assert _raises(mm.parse_cfg_split_segments, bad) @@ -315,6 +315,34 @@ def test_every_shipped_cfg_parses_with_the_regions_the_docs_name(): assert not missing, f"{cfg}: {sorted(missing)} not file-backed MEMORY areas" +VIEW_CFG = """ +MEMORY { + RAMVIEW: start = $A000, size = $2000, type = rw, file = "", define = yes; + FILEVIEW: start = $A000, size = $2000, file = %O, fill = yes; + RUNAREA: start = $E000, size = $1E00, type = rw, file = ""; +} +SEGMENTS { + BSS1: load = RAMVIEW, type = bss; + IMG1: load = FILEVIEW, run = RUNAREA, type = ro, align = $40, align_load = $40; +} +""" + + +def test_ram_view_and_file_view_report_as_one_region_and_align_load_counts(): + areas = mm.parse_cfg_memory(VIEW_CFG) + names = [a["name"] for a in areas] + assert names == ["RAMVIEW", "RUNAREA"], names + assert areas[0]["file"] and areas[0]["alias"] == "FILEVIEW", areas + split = mm.parse_cfg_split_segments(VIEW_CFG) + assert split == [{"name": "IMG1", "load": "FILEVIEW", "run": "RUNAREA", + "align_load": 0x40}], split + segs = [{"name": "BSS1", "start": 0xA000, "end": 0xB7FF, "size": 0x1800}, + {"name": "IMG1", "start": 0xE000, "end": 0xE0FF, "size": 0x100}] + rows = {r["region"]: r for r in mm.measure(areas, segs, split)} + assert rows["RAMVIEW"]["last_used"] == 0xB7FF, rows["RAMVIEW"] + assert rows["RUNAREA"]["last_used"] == 0xE0FF and rows["RUNAREA"]["tail"] == 0xFDFF - 0xE0FF, rows["RUNAREA"] + + def main() -> int: tests = [(n, f) for n, f in sorted(globals().items()) if n.startswith("test_") and callable(f)] diff --git a/tools/test_mlkem_linked.py b/tools/test_mlkem_linked.py new file mode 100644 index 0000000..56f0ccb --- /dev/null +++ b/tools/test_mlkem_linked.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""test_mlkem_linked.py -- the PQ_HYBRID image, run: copy, vectors, KeyGen, Decaps. + +tools/test_mlkem_placement.py proves where libs/mlkem LINKS. This proves it +is THERE and works, in a VICE boot of the linked PQ_HYBRID uci-onchip PRG: + + [1] build-time shape: the PRG carries the ML-KEM image at its load + address $A000 and ends below $D000. + [2] boot copy: after the menu appears, $E000.. holds exactly the bytes the + PRG carries at $A000.. (the image is compared span by span from the + linker's own LOAD/RUN/SIZE symbols), and the $FFFA/$FFFC/$FFFE RAM + vectors point at an RTI. + [3] end to end through pq_mlkem_call, the library's only entry: + X25519 (rebuilds the window the ML-KEM tables live in), then + mlkem_keygen on fixed seeds == the reference's ek and dk byte for + byte, then X25519 again, then mlkem_decaps_seed on the reference's + ciphertext == the reference's K. That is placement + banking + zero + page + the shared sqtab + the "arith_init before every call group" + rule, all at once. (The reference is libs/mlkem/tools/mlkem_ref.py, + the one the library's ACVP runs are checked against.) + [4] mutants, each must go red: (a) boot.s without the copy call (image + comparison fails), (b) the copy call moved after the zeroing (the + assembler assert fires), (c) the call wrapper without mlkem_arith_init + (KeyGen after X25519 is wrong). + +The onchip image needs no REU: set C64_VICE_NO_REU=1 to prove it (the +default config adds -reu). ~6 min (X25519 x2 + KeyGen + Decaps under warp). + + python3 tools/test_mlkem_linked.py # [1]-[4] + python3 tools/test_mlkem_linked.py --no-mutants +""" + +import os +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "tools")) +sys.path.insert(0, str(REPO / "libs" / "mlkem" / "tools")) + +from c64_test_harness import ( # noqa: E402 + Labels, ViceInstanceManager, jsr, read_bytes, write_bytes, wait_for_text, +) +from _vice_helpers import default_vice_config, menu_wait # noqa: E402 +from _skip_policy import cannot_run, verdict # noqa: E402 +from test_x25519 import SCALAR_1, U_1, EXPECTED_1 # noqa: E402 + +PRG = REPO / "build" / "c64-https.prg" +LABELS = REPO / "build" / "labels.txt" +MAKE_ARGS = ["BACKEND=uci", "USE_NISTCURVES_ONCHIP=1", "PQ_HYBRID=1"] + +# Page-3 scratch (jsr()'s trampoline is $0334-$0338), all free in the menu. +SEED = 0x0340 # d (32) then z (32), contiguous: decaps_seed's d||z +SEED_Z = SEED + 32 +KEY = 0x0380 +STUB = 0x03C0 +EK_AT = 0x4200 # cert_buf: idle in the menu, 2 KB +DK_AT = 0xC000 # the TCP ring: where the real dk will live +CT_AT = 0xC960 # past the 2,400 B dk, inside the ring + +D = bytes(range(1, 33)) +Z = bytes(range(101, 133)) +M = bytes(range(201, 233)) + + +class Tally: + def __init__(self): + self.passed = self.failed = 0 + + def check(self, ok, what, detail=""): + if ok: + self.passed += 1 + print(f" PASS {what}") + else: + self.failed += 1 + print(f" FAIL {what}" + (f": {detail}" if detail else "")) + return ok + + +def make(extra=()): + subprocess.run(["make", "clean"], cwd=REPO, capture_output=True) + r = subprocess.run(["make"] + MAKE_ARGS + list(extra), cwd=REPO, + capture_output=True, text=True) + return r.returncode, r.stdout + r.stderr + + +def prg_bytes_at(prg, addr, n): + off = addr - 0x0801 + 2 + return prg[off:off + n] + + +def image_spans(labels): + """[(name, load, run, size)] straight from the linker's symbols.""" + out = [] + for seg in ("LIB_MLKEM_CODE", "LIB_MLKEM_RODATA"): + out.append((seg, labels[f"__{seg}_LOAD__"], labels[f"__{seg}_RUN__"], + labels[f"__{seg}_SIZE__"])) + return out + + +def shape_checks(t, labels): + print("\n[1] the PRG carries the image at $A000") + prg = PRG.read_bytes() + t.check(len(prg) - 2 + 0x0801 - 1 < 0xD000, + "the PRG ends below $D000", f"{len(prg)} B") + first = prg_bytes_at(prg, 0xA000, 1) + t.check(first and first != b"\x00", + "the first image byte is nonzero (mlkem_boot_init's 'fresh' test" + " relies on it)", first.hex()) + for name, load, run, size in image_spans(labels): + t.check(load == 0xA000 or name.endswith("RODATA"), + f"{name} loads at ${load:04X}, runs at ${run:04X}, {size} B") + + +def ram_read(transport, addr, n): + """RAM at *addr* (the KERNAL ROM covers $E000+ in the CPU view): read + with $01 = $34, then put $01 back.""" + old = read_bytes(transport, 1, 1) + write_bytes(transport, 1, b"\x34") + try: + return read_bytes(transport, addr, n) + finally: + write_bytes(transport, 1, old) + + +def image_checks(t, transport, labels, tag): + print(f"\n[2] {tag}: the boot copy") + prg = PRG.read_bytes() + ok_all = True + for name, load, run, size in image_spans(labels): + want = prg_bytes_at(prg, load, size) + got = ram_read(transport, run, size) + ok = t.check(got == want and len(want) == size, + f"{name}: ${run:04X}..${run + size - 1:04X} == the PRG's " + f"${load:04X}.. ({size} B)", + f"first diff at +{next((i for i in range(size) if got[i] != want[i]), -1)}") + ok_all = ok_all and ok + vec = ram_read(transport, 0xFFFA, 6) + rti = labels["mlkem_rti"] + t.check(vec == bytes([rti & 255, rti >> 8]) * 3 and + read_bytes(transport, rti, 1) == b"\x40", + f"$FFFA/$FFFC/$FFFE -> an RTI at ${rti:04X}", vec.hex()) + return ok_all + + +def x25519_ok(t, transport, labels, tag): + write_bytes(transport, labels["tls_ecdhe_privkey"], SCALAR_1) + write_bytes(transport, labels["tls_server_pubkey"], U_1) + jsr(transport, labels["tls_ecdh_compute_shared"], timeout=600.0) + ss = read_bytes(transport, labels["tls_shared_secret"], 32) + return t.check(ss == EXPECTED_1, f"{tag}: X25519 = RFC 7748 vector 1", + ss.hex()) + + +def set_args(transport, labels, **ptrs): + for name, addr in ptrs.items(): + write_bytes(transport, labels[f"mlkem_arg_{name}"], + bytes([addr & 255, addr >> 8])) + + +def call(transport, labels, entry): + """pq_mlkem_call with A/X = entry, through a page-3 stub.""" + a = labels[entry] + stub = bytes([0xA9, a & 255, 0xA2, a >> 8, 0x20, + labels["pq_mlkem_call"] & 255, labels["pq_mlkem_call"] >> 8, + 0x60]) + write_bytes(transport, STUB, stub) + t0 = time.monotonic() + regs = jsr(transport, STUB, timeout=900.0) + return regs, time.monotonic() - t0 + + +def kat(t, transport, labels): + import mlkem_ref as R + print("\n[3] KeyGen and Decaps through pq_mlkem_call") + ek_ref, dk_ref = R.mlkem_keygen(D, Z) + K_ref, ct_ref = R.mlkem_encaps(ek_ref, M) + + # Re-running the boot copy (SYS re-entry) must not erase the code: the + # flag says "done", and with the flag cleared the stage is zero, which + # reads as "not fresh". Only a fresh LOAD (flag 0 AND a nonzero stage) + # copies. + prg = PRG.read_bytes() + img = lambda: ram_read(transport, labels["__LIB_MLKEM_CODE_RUN__"], 0x1900) # noqa: E731 + want = prg_bytes_at(prg, 0xA000, 0x1900) + jsr(transport, labels["mlkem_boot_init"], timeout=30.0) + t.check(img() == want, "re-running mlkem_boot_init leaves the image intact") + write_bytes(transport, labels["mlkem_copied"], b"\x00") + jsr(transport, labels["mlkem_boot_init"], timeout=30.0) + t.check(img() == want and read_bytes(transport, labels["mlkem_copied"], 1) == b"\x00", + "with the flag cleared but the stage zeroed it still does not copy") + write_bytes(transport, labels["mlkem_copied"], b"\x01") + + x25519_ok(t, transport, labels, "before") # rebuilds the ML-KEM window + + write_bytes(transport, SEED, D + Z) + write_bytes(transport, EK_AT, bytes([0xEE]) * 1184) + write_bytes(transport, DK_AT, bytes([0xEE]) * 2400) + set_args(transport, labels, seed=SEED, z=SEED_Z, ek=EK_AT, dk=DK_AT) + regs, dt = call(transport, labels, "mlkem_keygen") + ek = read_bytes(transport, EK_AT, 1184) + dk = read_bytes(transport, DK_AT, 2400) + t.check(regs["A"] == 0, f"mlkem_keygen returned A=0 ({dt:.0f} s)", + f"A={regs['A']:02X}") + t.check(ek == ek_ref, "mlkem_keygen ek == the reference's", + f"first diff at +{next((i for i in range(1184) if ek[i] != ek_ref[i]), -1)}") + t.check(dk == dk_ref, "mlkem_keygen dk == the reference's", + f"first diff at +{next((i for i in range(2400) if dk[i] != dk_ref[i]), -1)}") + + x25519_ok(t, transport, labels, "after") # rebuilds the tables again + + write_bytes(transport, CT_AT, ct_ref) + write_bytes(transport, KEY, bytes(32)) + set_args(transport, labels, seed=SEED, ct=CT_AT, key=KEY) + regs, dt = call(transport, labels, "mlkem_decaps_seed") + K = read_bytes(transport, KEY, 32) + t.check(K == K_ref, f"mlkem_decaps_seed K == the reference's ({dt:.0f} s)", + K.hex()) + # Implicit rejection through the same path: a flipped ct bit must NOT + # give K (and must still return A=0). + bad = bytearray(ct_ref) + bad[7] ^= 1 + write_bytes(transport, CT_AT, bytes(bad)) + call(transport, labels, "mlkem_decaps_seed") + K2 = read_bytes(transport, KEY, 32) + t.check(K2 != K_ref and K2 != bytes(32), + "a flipped ciphertext bit gives a different (rejection) key") + # $01 is back where the boot left it (BASIC out, KERNAL and I/O in). + port = read_bytes(transport, 1, 1)[0] + t.check(port & 7 == 6, "$01 restored by pq_mlkem_call", f"${port:02X}") + + +def boot_and_run(t, labels, tag, do_kat): + config = default_vice_config(prg_path=str(PRG), warp=True, ntsc=True, + sound=False) + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + tr = inst.transport + if wait_for_text(tr, "Q=QUIT", timeout=menu_wait(60), + verbose=False) is None: + t.check(False, f"{tag}: the menu appeared") + mgr.release(inst) + return False + ok = image_checks(t, tr, labels, tag) + if do_kat and ok: + kat(t, tr, labels) + mgr.release(inst) + return ok + + +def mutant(t, label, path, old, new, expect, labels_tag): + """Build with *old* -> *new* in *path*; return (rc, output).""" + p = REPO / path + orig = p.read_text() + assert old in orig, (path, old) + try: + p.write_text(orig.replace(old, new, 1)) + rc, out = make() + finally: + p.write_text(orig) + return rc, out + + +def main(): + t = Tally() + if shutil.which("ca65") is None: + return cannot_run("cc65 not found", certifies="the linked ML-KEM image") + rc, out = make() + if rc != 0: + print(out[-1500:]) + return cannot_run("the PQ_HYBRID build failed", certifies="the linked ML-KEM image") + labels = Labels.from_file(str(LABELS)) + shape_checks(t, labels) + ok = boot_and_run(t, labels, "green", True) + + if "--no-mutants" not in sys.argv[1:]: + print("\n[4] mutants") + # (a) no copy call at all: the image never reaches $E000 + rc, out = mutant(t, "no copy", "src/boot.s", + " jsr mlkem_boot_init\n", " nop\n nop\n nop\n", None, "") + if t.check(rc == 0, "mutant (a) no copy call: still links", out[-300:]): + lab = Labels.from_file(str(LABELS)) + t2 = Tally() + boot_and_run(t2, lab, "mutant (a)", False) + t.check(t2.failed > 0, "mutant (a) is RED: the $E000 image differs " + f"({t2.failed} image check(s) failed)") + # (b) copy moved after the zeroing: the assembler assert fires + # moving the call: put it after the zeroing loop's first label instead + p = REPO / "src/boot.s" + orig = p.read_text() + try: + late = orig.replace("mlkem_copy_site:\n jsr mlkem_boot_init\n", + "mlkem_copy_site:\n", 1) + late = late.replace("boot_zbss_first:\n", + "boot_zbss_first:\n jsr mlkem_boot_init\n", 1) + p.write_text(late) + rc, out = make() + finally: + p.write_text(orig) + t.check(rc != 0 and "the ML-KEM boot copy must run BEFORE" in out, + "mutant (b) copy after the zeroing: the assembler assert fires", + out[-300:]) + # (c) the wrapper without arith_init + rc, out = mutant(t, "no arith_init", "src/crypto/mlkem_link.s", + " jsr mlkem_arith_init\n@go:", + " nop\n nop\n nop\n@go:", None, "") + if t.check(rc == 0, "mutant (c) no arith_init: still links", out[-300:]): + lab = Labels.from_file(str(LABELS)) + t3 = Tally() + config = default_vice_config(prg_path=str(PRG), warp=True, + ntsc=True, sound=False) + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + if wait_for_text(inst.transport, "Q=QUIT", + timeout=menu_wait(60), verbose=False): + kat(t3, inst.transport, lab) + mgr.release(inst) + t.check(t3.failed > 0, "mutant (c) is RED: KeyGen after X25519 is " + f"wrong without arith_init ({t3.failed} check(s) failed)") + make() # restore the green build + + print(f"\nRESULTS: {t.passed}/{t.passed + t.failed} passed, {t.failed} failed") + return verdict(t.passed, t.failed, certifies="the linked ML-KEM image") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_mlkem_placement.py b/tools/test_mlkem_placement.py index eb82a8f..8d1cf6a 100644 --- a/tools/test_mlkem_placement.py +++ b/tools/test_mlkem_placement.py @@ -48,7 +48,7 @@ LINK_S = REPO / "src" / "crypto" / "mlkem_link.s" # The one area that CONTAINS the sqtab window by design: the window is # c64-https's own table (TABLES_BSS). mlkem_link.s asserts the equivalent. -EXEMPT = {"CRYPTO_COLD_SHADOW"} +EXEMPT = {"CRYPTO_COLD_SHADOW", "COLD_FILE"} EXPECT_AREAS = { "MLKEM_CODE_RAM": ("$E000", "$1E00"), "MLKEM_BSS_RAM": ("$B100", "$0B00"), @@ -100,6 +100,18 @@ def no_define(cfg_text): if not re.search(r"define\s*=\s*yes", a)] +ENTRY_RE = re.compile(r"\bmlkem_(keygen|encaps|decaps|decaps_seed|arith_init)\b") + + +def unwrapped_callers(extra=None): + """Source files other than mlkem_link.s that name a library entry.""" + files = {str(p.relative_to(REPO)): p.read_text() + for p in (REPO / "src").rglob("*.s") + if p.name != "mlkem_link.s"} + files.update(extra or {}) + return sorted(f for f, txt in files.items() if ENTRY_RE.search(txt)) + + def run_make(args, extra_env=None, timeout=1800): env = dict(os.environ, **(extra_env or {})) r = subprocess.run(["make"] + args, cwd=REPO, capture_output=True, @@ -118,6 +130,9 @@ def static_checks(t): and 'file = ""' in a and "define = yes" in a, f"{cfg}: {name} = {start}/{size}, file=\"\", define", a or "area missing") + t.check("file = %O" in areas.get("COLD_FILE", "") and + 'file = ""' in areas.get("CRYPTO_COLD_SHADOW", ""), + f"{cfg}: COLD_FILE is the file view, CRYPTO_COLD_SHADOW the RAM view") t.check(not no_define(text), f"{cfg}: every MEMORY area is define = yes", ", ".join(no_define(text))) t.check(not unguarded(text, cold), @@ -128,6 +143,14 @@ def static_checks(t): t.check(re.search(rf"^\s*{seg}:.*optional\s*=\s*yes", text, re.M), f"{cfg}: {seg} is optional (unset builds stay identical)") + # The entry rule: nothing but mlkem_link.s names a library entry, so + # pq_mlkem_call (which runs mlkem_arith_init) is the only way in. + stray = unwrapped_callers() + t.check(not stray, "only mlkem_link.s references the ML-KEM entries", + ", ".join(stray)) + t.check(unwrapped_callers({"src/zz.s": " jsr mlkem_keygen\n"}) == ["src/zz.s"], + "red: a direct jsr mlkem_keygen elsewhere is reported") + # Red cases for the guard: it must see a new area and a missing define. base = (REPO / "cfg" / "c64-https-uci.cfg").read_text() anchor = " X25519_SCRATCH: start = $A000" @@ -201,13 +224,28 @@ def layout_checks(t, tag): f"{tag}: code+rodata = $E000-${segs['LIB_MLKEM_RODATA'][1]:04X}, " f"inside $E000-$FDFF") prg = REPO / "build" / "c64-https.prg" - bad = prg_io_violation(prg.read_bytes()) + img = prg.read_bytes() + off = 0xA000 - 0x0801 + 2 + t.check(img[off] != 0 and any(img[off:off + 0x1D00]), + f"{tag}: the ML-KEM image is in the PRG at $A000 (first byte nonzero)") + bad = prg_io_violation(img) t.check(bad is None, f"{tag}: the PRG does not load into $D000-$DFFF", str(bad)) # (label, file, old, new, expected ld65/ca65 text) MUTANTS = [ + ("load image layout differs from the run layout", "cfg/c64-https-uci.cfg", + "align = $40, align_load = $40, define = yes;", "align = $40, define = yes;", + "the load image and the run image are laid out differently"), + ("load image moved off the head of COLD_FILE", "cfg/c64-https-uci.cfg", + "LIB_MLKEM_CODE: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes;", + "LIB_MLKEM_CODE: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes, start = $A100;", + None), + ("COLD_FILE shrunk below the image", "cfg/c64-https-uci.cfg", + "COLD_FILE: start = $A000, size = $2000", + "COLD_FILE: start = $A000, size = $1000", + "COLD_FILE no longer ends at $BFFF"), ("polys moved off $D000", "cfg/c64-https-uci.cfg", "MLKEM_POLY_RAM: start = $D000, size = $1000", "MLKEM_POLY_RAM: start = $D100, size = $0F00", @@ -276,9 +314,7 @@ def link_ok(t, tag, args): ok = t.check(rc == 0, f"{tag}: links", "\n".join(errs[-5:]) or out[-400:]) if ok: warns = [ln for ln in out.splitlines() if ln.startswith("ld65: Warning")] - t.check(len(warns) == 1 and "LINKED at $E000" in warns[0], - f"{tag}: the only ld65 warning is the not-delivered notice", - "; ".join(warns)) + t.check(not warns, f"{tag}: no ld65 warnings", "; ".join(warns)) layout_checks(t, tag) return ok diff --git a/tools/test_quit_basic_exit.py b/tools/test_quit_basic_exit.py index 2cc6264..29500b9 100644 --- a/tools/test_quit_basic_exit.py +++ b/tools/test_quit_basic_exit.py @@ -310,6 +310,23 @@ def run_tests(transport, labels, seed=None): t.check(not any(got), f"{name} zero ({size} B at ${addr:04X})", f"{sum(1 for x in got if x)} non-zero bytes, first " + hx(got[:8])) + if labels.address("__LIB_MLKEM_CODE_RUN__") is not None: + # The code is not secret and 'Q' must leave it (and the RAM + # vectors) alone: the image is still byte-identical to the PRG's. + with open(PRG_PATH, "rb") as f: + prg = f.read() + for seg in ("CODE", "RODATA"): + load = labels[f"__LIB_MLKEM_{seg}_LOAD__"] + run = labels[f"__LIB_MLKEM_{seg}_RUN__"] + size = labels[f"__LIB_MLKEM_{seg}_SIZE__"] + want = prg[load - 0x0801 + 2:load - 0x0801 + 2 + size] + got = ram_rw(transport, run, size=size) + t.check(got == want, f"ML-KEM {seg} at ${run:04X} survives 'Q' " + f"({size} B, not scrubbed)") + vec = ram_rw(transport, 0xFFFA, size=6) + t.check(vec == bytes([labels["mlkem_rti"] & 255, + labels["mlkem_rti"] >> 8]) * 3, + "ML-KEM RAM vectors survive 'Q'", vec.hex()) for i, (name, size) in enumerate(RESEEDED, start=len(ZEROED)): if not t.check(labels.address(name) is not None, f"{name} exists", "not in labels.txt"): From ff615b186e04ac1159907cd47a2705f30ba5b82e Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:21:51 -0500 Subject: [PATCH 3/7] PQ_HYBRID: rig_mlkem_hw.py proves the copied image and KeyGen/Decaps on hardware; _prg_load allows the staged image span The $A000-$BFFF stage cannot be read back before the run (BASIC ROM) nor $E000 after it (KERNAL), so rig_mlkem_hw.py parks the CPU with all RAM banked in and compares. _prg_load counts and names the staged bytes instead of refusing them; $D000+ and other non-zero tails still refuse. Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/test_device_prep.py | 3 +- tools/test_prg_load.py | 13 ++- tools/uci/_prg_load.py | 33 +++++- tools/uci/rig_mlkem_hw.py | 220 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 tools/uci/rig_mlkem_hw.py diff --git a/tools/test_device_prep.py b/tools/test_device_prep.py index 4354ef7..e9da0b4 100644 --- a/tools/test_device_prep.py +++ b/tools/test_device_prep.py @@ -650,7 +650,8 @@ def test_the_discovery_rule_finds_the_known_rigs() -> None: "rig_https_live.py", "rig_https_local.py", "rig_https_wiki.py", "rig_https_banner.py", "rig_close_retry.py", "rig_https_refetch.py", - "rig_trust_policy.py", "rig_https_m3.py"} + "rig_trust_policy.py", "rig_https_m3.py", + "rig_mlkem_hw.py"} assert names == expected, ( f"the discovery rule now selects {sorted(names)}, not " f"{sorted(expected)}. If a rig was added or renamed that is fine — " diff --git a/tools/test_prg_load.py b/tools/test_prg_load.py index a934b08..b1794ff 100644 --- a/tools/test_prg_load.py +++ b/tools/test_prg_load.py @@ -165,7 +165,7 @@ def test_the_c000_ram_window_is_carried_and_compared() -> None: def test_non_zero_bytes_outside_both_windows_still_refuse() -> None: - for addr in (0xA000, 0xBFFF, 0xD000, 0xFDFF): + for addr in (0xD000, 0xFDFF): try: pl.image_span(make_prg(tail_byte=0xEA, at=addr, window=COLD)) except pl.PrgLoadError as exc: @@ -174,6 +174,17 @@ def test_non_zero_bytes_outside_both_windows_still_refuse() -> None: raise AssertionError(f"a byte at ${addr:04X} would go unverified") +def test_the_staged_ml_kem_image_span_is_allowed_and_counted() -> None: + """$A000-$BFFF holds the ML-KEM load image under PQ_HYBRID: it cannot be + read back (BASIC ROM), so it is allowed, counted, and named in the report + (rig_mlkem_hw.py proves the copy). The bytes either side still refuse.""" + for addr in (0xA000, 0xBFFF): + prg = make_prg(tail_byte=0xEA, at=addr, window=COLD) + pl.image_span(prg) + assert pl.stage_bytes(prg) == addr - 0xA000 + 1, addr + assert pl.stage_bytes(make_prg()) == 0 + + def test_a_zeroed_head_is_rewritten_then_verified() -> None: c = FakeClient(zero_head_once=True) run(c, make_prg()) diff --git a/tools/uci/_prg_load.py b/tools/uci/_prg_load.py index 716e481..9421dd8 100644 --- a/tools/uci/_prg_load.py +++ b/tools/uci/_prg_load.py @@ -57,6 +57,14 @@ #: (src/net/uci/cold_bank.s) carries its image here; no other image reaches #: it since #288 (they end at $BFFF), so the window compares nothing there. RAM_WINDOW = (0xC000, 0xD000) +#: The ML-KEM load image (PQ_HYBRID): LIB_MLKEM_CODE/RODATA LOAD at $A000, in +#: the file bytes under CRYPTO_COLD_SHADOW, and boot copies them to $E000 and +#: then zeroes the stage. A host read of $A000-$BFFF returns the BASIC ROM and +#: of $E000 the KERNAL, so this span cannot be compared before the run or +#: after it. Non-zero bytes here are allowed (and counted in the report, loudly); +#: tools/uci/rig_mlkem_hw.py parks the CPU with all RAM banked in and compares +#: the copy at $E000 to the PRG's bytes - that is where it is proven. +STAGE_RANGE = (0xA000, 0xC000) #: The head-zeroing event lands 2-5 s after READY; re-check past it. HEAD_SETTLE_S = 7.0 #: Readback chunk; small enough that no firmware readmem cap is in play. @@ -87,6 +95,9 @@ def image_span(prg: bytes): tail = bytearray(body[verify_len:]) if w_len: tail[w_off - verify_len:w_off - verify_len + w_len] = bytes(w_len) + s_lo = max(STAGE_RANGE[0] - load_addr - verify_len, 0) + s_hi = max(STAGE_RANGE[1] - load_addr - verify_len, 0) + tail[s_lo:s_hi] = bytes(len(tail[s_lo:s_hi])) if any(tail): first = verify_len + next(i for i, b in enumerate(tail) if b) raise PrgLoadError( @@ -97,6 +108,16 @@ def image_span(prg: bytes): return load_addr, body, verify_len +def stage_bytes(prg: bytes) -> int: + """Non-zero extent (up to the last non-zero byte) of the $A000-$BFFF tail.""" + load_addr = prg[0] | (prg[1] << 8) + body = prg[2:] + lo = STAGE_RANGE[0] - load_addr + hi = min(STAGE_RANGE[1] - load_addr, len(body)) + seg = body[lo:hi] if lo >= 0 else b"" + return len(seg.rstrip(b"\x00")) + + def read_back(read_mem, addr: int, length: int) -> bytes: out = bytearray() while len(out) < length: @@ -167,12 +188,20 @@ def load_verified_and_run(client, prg: bytes, *, retries: int = 1, f"({attempts}); not starting a run on an image known to be wrong") window = (f" and {report['verified_ram_window']} ({w_len} B)" if w_len else "") + staged = stage_bytes(prg) + report["unverified_stage_bytes"] = staged + if staged: + print(f"NOTE: {staged} B of the $A000-$BFFF tail are the staged ML-KEM " + "image: NOT read back (a host read there returns the BASIC " + "ROM). tools/uci/rig_mlkem_hw.py proves the copy at $E000.", + file=out) print(f"PRG verified in RAM: {report['verified']} ({verify_len} B){window}; " f"the other {report['unverified_zero_tail']} B of the $A000+ tail " - f"are zero fill, not compared — SYS{entry}", file=out) + f"are zero fill (or the staged image noted above), not compared — SYS{entry}", file=out) client.send_text(f"SYS{entry}", finish_with_return=True) return report -__all__ = ["HEAD_SETTLE_S", "PrgLoadError", "RAM_WINDOW", "VERIFY_END", +__all__ = ["HEAD_SETTLE_S", "PrgLoadError", "RAM_WINDOW", "STAGE_RANGE", + "VERIFY_END", "stage_bytes", "image_span", "load_verified_and_run", "ram_window", "read_back"] diff --git a/tools/uci/rig_mlkem_hw.py b/tools/uci/rig_mlkem_hw.py new file mode 100644 index 0000000..b1e1edc --- /dev/null +++ b/tools/uci/rig_mlkem_hw.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""rig_mlkem_hw.py - the PQ_HYBRID ML-KEM image on a real Ultimate 64. + +Needs a PQ_HYBRID=1 uci build in build/ (BACKEND=uci USE_NISTCURVES_ONCHIP=1 +or the comb profile) and a U64E / C64U on the LAN. Nothing in the shipped +protocol calls ML-KEM yet, so this rig is where the loaded-at-$A000, +copied-to-$E000 image is proven on silicon: + + [1] IMAGE boot, 'Q' to BASIC, park the CPU with all RAM banked in + (sei / $01=$34 / jmp *), then read $E000.. and $FFFA through + the REST API (which otherwise returns the KERNAL ROM there) and + compare with the bytes the PRG carries at $A000.. (the span + _prg_load cannot read back before the run). + [2] KAT boot again, 'Q', then run mlkem_keygen and mlkem_decaps_seed + through pq_mlkem_call from a DMA-injected stub and compare ek, + dk and K with the libs/mlkem reference (the one the library's + ACVP runs are checked against). + +Environment: U64_HOST (default 192.168.1.81), BOOT_TIMEOUT (default 60; set +C64_INIT_WAIT-style larger values for the comb profile's boot precompute), +C64_PRG. Exit 0 = all checks passed, 1 = a check failed, 2 = could not run. +""" +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[1] +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(REPO / "tools")) +sys.path.insert(0, str(REPO / "libs" / "mlkem" / "tools")) + +from c64_test_harness.backends.device_lock import DeviceLock, DeviceLockTimeout # noqa: E402 +from c64_test_harness.backends.ultimate64 import Ultimate64Transport # noqa: E402 +from c64_test_harness.backends.ultimate64_client import Ultimate64Client # noqa: E402 +from c64_test_harness.keyboard import send_text # noqa: E402 +from c64_test_harness.labels import Labels # noqa: E402 +from c64_test_harness.uci_network import disable_uci, enable_uci # noqa: E402 +from _device_lock_helper import LockTimeoutConfigError, acquire_device_lock # noqa: E402 +from _device_prep import DevicePrepError, prepare_device # noqa: E402 +from _reu_preflight import ReuPreflightError, preflight_reu # noqa: E402 +from _prg_load import PrgLoadError, load_verified_and_run # noqa: E402 +from _skip_policy import cannot_run, verdict # noqa: E402 +from boot_check import decode_screen, screen_text # noqa: E402 + +HOST = os.environ.get("U64_HOST", "192.168.1.81") +BOOT_TIMEOUT = float(os.environ.get("BOOT_TIMEOUT", os.environ.get("C64_INIT_WAIT", "60"))) +PRG_PATH = Path(os.environ.get("C64_PRG", str(REPO / "build" / "c64-https.prg"))) +TURBO_MHZ = int(os.environ.get("TURBO_MHZ", "48")) +LABELS_PATH = Path(os.environ.get("C64_LABELS", str(REPO / "build" / "labels.txt"))) + +STUB = 0x0340 +SENT = 0x03F0 # +0 A result, +1 sentinel $A5 +SEED = 0xCE00 # d (32) then z (32), inside the TCP ring +KEY = 0x0380 +EK_AT = 0x4200 # cert_buf +DK_AT = 0xC000 # the ring: where the real dk will live +CT_AT = 0xC960 +D = bytes(range(1, 33)) +Z = bytes(range(101, 133)) +M = bytes(range(201, 233)) + +PARK = bytes([0x78, # sei + 0xA9, 0x34, 0x85, 0x01, # lda #$34 / sta $01 (all RAM) + 0x4C, (STUB + 5) & 255, STUB >> 8]) # jmp * (at STUB+5) + + +class Tally: + def __init__(self): + self.passed = self.failed = 0 + + def check(self, ok, what, detail=""): + if ok: + self.passed += 1 + print(f" PASS {what}") + else: + self.failed += 1 + print(f" FAIL {what}" + (f": {detail}" if detail else "")) + return ok + + +def boot_to_basic(client, transport, prg): + client.reset() + time.sleep(2.5) + load_verified_and_run(client, prg) + deadline = time.monotonic() + BOOT_TIMEOUT + while time.monotonic() < deadline: + if "Q=QUIT" in screen_text(decode_screen(bytes(client.read_mem(0x0400, 1000)))): + break + time.sleep(2.0) + else: + raise RuntimeError("the menu never appeared") + send_text(transport, "q\r") + for _ in range(30): + time.sleep(1.0) + if "READY." in screen_text(decode_screen(bytes(client.read_mem(0x0400, 1000)))): + return + raise RuntimeError("BASIC READY. never appeared after Q") + + +def run_stub(transport, client, code, timeout): + transport.write_memory(SENT, bytes(2)) + transport.write_memory(STUB, code) + send_text(transport, f"sys{STUB}\r") + t0 = time.monotonic() + while time.monotonic() - t0 < timeout: + if bytes(client.read_mem(SENT + 1, 1)) == b"\xA5": + return bytes(client.read_mem(SENT, 1))[0], time.monotonic() - t0 + time.sleep(1.0) + raise RuntimeError(f"the stub never finished within {timeout:.0f} s") + + +def call_stub(labels, entry): + e, c = labels[entry], labels["pq_mlkem_call"] + return bytes([0xA9, e & 255, 0xA2, e >> 8, 0x20, c & 255, c >> 8, # lda/ldx/jsr + 0x8D, SENT & 255, SENT >> 8, # sta SENT + 0xA9, 0xA5, 0x8D, (SENT + 1) & 255, (SENT + 1) >> 8, # sentinel + 0x60]) # rts: back to BASIC + + +def set_args(transport, labels, **ptrs): + for name, addr in ptrs.items(): + transport.write_memory(labels[f"mlkem_arg_{name}"], bytes([addr & 255, addr >> 8])) + + +def image_phase(t, client, transport, prg, labels): + print("\n[1] the copied image, read with the CPU parked and all RAM banked in") + boot_to_basic(client, transport, prg) + transport.write_memory(STUB, PARK) + send_text(transport, f"sys{STUB}\r") + time.sleep(3.0) + body = prg[2:] + for seg in ("CODE", "RODATA"): + load = labels[f"__LIB_MLKEM_{seg}_LOAD__"] + run = labels[f"__LIB_MLKEM_{seg}_RUN__"] + size = labels[f"__LIB_MLKEM_{seg}_SIZE__"] + want = body[load - 0x0801:load - 0x0801 + size] + got = b"".join(bytes(client.read_mem(run + o, min(2048, size - o))) + for o in range(0, size, 2048)) + t.check(got == want, f"{seg}: ${run:04X}..${run + size - 1:04X} == the PRG's " + f"${load:04X}.. ({size} B)", + f"first diff at +{next((i for i in range(size) if got[i] != want[i]), -1)}") + vec = bytes(client.read_mem(0xFFFA, 6)) + rti = labels["mlkem_rti"] + t.check(vec == bytes([rti & 255, rti >> 8]) * 3, + f"$FFFA/$FFFC/$FFFE -> the RTI at ${rti:04X}", vec.hex()) + + +def kat_phase(t, client, transport, prg, labels): + import mlkem_ref as R + print("\n[2] KeyGen and Decaps_seed through pq_mlkem_call") + ek_ref, dk_ref = R.mlkem_keygen(D, Z) + K_ref, ct_ref = R.mlkem_encaps(ek_ref, M) + boot_to_basic(client, transport, prg) + transport.write_memory(SEED, D + Z) + transport.write_memory(EK_AT, bytes([0xEE]) * 1184) + transport.write_memory(DK_AT, bytes([0xEE]) * 2400) + set_args(transport, labels, seed=SEED, z=SEED + 32, ek=EK_AT, dk=DK_AT) + a, dt = run_stub(transport, client, call_stub(labels, "mlkem_keygen"), 400) + ek = bytes(client.read_mem(EK_AT, 1184)) + dk = b"".join(bytes(client.read_mem(DK_AT + o, min(1200, 2400 - o))) + for o in range(0, 2400, 1200)) + t.check(a == 0, f"mlkem_keygen returned A=0 ({dt:.0f} s)", f"A={a:02X}") + t.check(ek == ek_ref, "ek == the reference's", + f"first diff at +{next((i for i in range(1184) if ek[i] != ek_ref[i]), -1)}") + t.check(dk == dk_ref, "dk == the reference's", + f"first diff at +{next((i for i in range(2400) if dk[i] != dk_ref[i]), -1)}") + transport.write_memory(CT_AT, ct_ref) + transport.write_memory(KEY, bytes(32)) + set_args(transport, labels, seed=SEED, ct=CT_AT, key=KEY) + a, dt = run_stub(transport, client, call_stub(labels, "mlkem_decaps_seed"), 600) + K = bytes(client.read_mem(KEY, 32)) + t.check(K == K_ref, f"mlkem_decaps_seed K == the reference's ({dt:.0f} s)", K.hex()) + + +def main(): + if not PRG_PATH.exists() or not LABELS_PATH.exists(): + return cannot_run(f"{PRG_PATH} / {LABELS_PATH} missing: build PQ_HYBRID=1 first", + certifies="the ML-KEM image on hardware") + labels = Labels.from_file(str(LABELS_PATH)) + if labels.address("pq_mlkem_call") is None: + return cannot_run("the build in build/ is not a PQ_HYBRID image", + certifies="the ML-KEM image on hardware") + prg = PRG_PATH.read_bytes() + print(f"PRG {PRG_PATH} sha256 " + f"{__import__('hashlib').sha256(prg).hexdigest()}") + lock = DeviceLock(HOST) + try: + acquire_device_lock(lock) + except (LockTimeoutConfigError, DeviceLockTimeout) as exc: + return cannot_run(f"DeviceLock({HOST}): {exc}", certifies="the ML-KEM image on hardware") + t = Tally() + client = None + try: + client = Ultimate64Client(host=HOST, timeout=15.0) + transport = Ultimate64Transport(host=HOST, timeout=15.0, client=client) + enable_uci(client) + import tempfile + prepare_device(client, LABELS_PATH, turbo_mhz=TURBO_MHZ, + artifact_dir=tempfile.mkdtemp(prefix="mlkem_hw_")) + preflight_reu(client, LABELS_PATH) + image_phase(t, client, transport, prg, labels) + kat_phase(t, client, transport, prg, labels) + except (PrgLoadError, RuntimeError, DevicePrepError, ReuPreflightError) as exc: + t.check(False, "rig ran to completion", str(exc)) + finally: + try: + if client is not None: + disable_uci(client) + finally: + lock.release() + print(f"\nRESULTS: {t.passed}/{t.passed + t.failed} passed, {t.failed} failed") + return verdict(t.passed, t.failed, certifies="the ML-KEM image on hardware") + + +if __name__ == "__main__": + sys.exit(main()) From 7281ac404020a0f1010d4a747adc141757ebe832 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:22:33 -0500 Subject: [PATCH 4/7] constants.inc: fix the rebase conflict resolution (stray marker line) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/constants.inc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/constants.inc b/src/constants.inc index 805e7a2..b9fab65 100644 --- a/src/constants.inc +++ b/src/constants.inc @@ -1,4 +1,4 @@ -; ====================================================================== +; ============================================================================= ; constants.inc - System equates, zero page, hardware addresses ; ; Converted from constants.asm (ACME, since removed) to ca65 include file. @@ -241,7 +241,7 @@ tcp_recv_buf = $c000 .ifdef TLS_STREAM_DEFRAME CH_SECTIONED = 1 .endif -======= + ; ML-KEM-768 dk scratch (PQ_HYBRID=1, libs/mlkem). mlkem_keygen writes a ; 2,400 B dk that is discarded: Decaps uses only the 64 B seed ; (mlkem_decaps_seed). It lives in the TCP ring. ORDERING RULE, enforced From 70a3e65854600ce699506b4650b937de0ad7456e Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:30:08 -0500 Subject: [PATCH 5/7] PQ_HYBRID review round: loader allows only the ML-KEM image span, named wrappers + map-based entry ban, tail marker, I-flag test S1 _prg_load: non-zero bytes allowed only in [CODE load, tail marker + 1) from labels.txt and only on a build with pq_mlkem_keygen; $A000/$B000/$BFFF refuse on knob-off images again. S2 pq_mlkem_keygen/decaps_seed wrappers; any source outside mlkem_link.s naming a symbol the map puts in $E000-$FDFF fails. S3 docs (IRQ: sei is load-bearing). N1 I-flag restore test + mutant. N2 tail marker (LIB_MLKEM_MARK) replaces the $A000 gate; a cut-short LOAD is refused (red border, A=$FF/C=1). N3 comment. N5 rig_mlkem_hw takes scratch from labels and the arbiter; test_rig_scratch covers it with PQ fixtures. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 8 ++- cfg/c64-https-uci-onchip-cold.cfg | 3 + cfg/c64-https-uci-onchip.cfg | 3 + cfg/c64-https-uci.cfg | 3 + docs/engineering-notes.md | 16 +++-- src/boot.s | 2 + src/constants.inc | 5 ++ src/crypto/mlkem_link.s | 109 +++++++++++++++++++++++------- src/session_scrub.s | 9 +-- tools/rig_scratch_profiles.json | 10 +++ tools/test_mlkem_linked.py | 99 ++++++++++++++++++++++++--- tools/test_mlkem_placement.py | 56 +++++++++++++++ tools/test_prg_load.py | 72 +++++++++++++++++--- tools/test_rig_scratch.py | 6 +- tools/uci/_prg_load.py | 74 ++++++++++++++------ tools/uci/rig_mlkem_hw.py | 50 ++++++++++---- 16 files changed, 434 insertions(+), 91 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 89957bb..dc8c53a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,8 @@ Variables: `src/crypto/mlkem_link.s`, guards `tools/test_mlkem_placement.py` and `tools/test_mlkem_linked.py`. The code **loads at $A000** (the shadow BSS's file bytes) and `mlkem_boot_init` copies it to $E000 before boot's - zeroing (#288 stays satisfied). The only entry is `pq_mlkem_call`. + zeroing (#288 stays satisfied); a tail marker refuses a cut-short LOAD. The + only entries are `pq_mlkem_keygen` / `pq_mlkem_decaps_seed` (`pq_mlkem_call`). - `USE_X25519_SIBLING` — **retired, `$(error)`-guarded (#245)**: every build links the `libs/x25519` sibling; any value is refused. - `ENABLE_P384_VERIFY`, `USE_OVERLAY_P384_EMBED`, `KEEP_CRYPTO_SWAP`, @@ -829,8 +830,9 @@ serves uci-onchip; `-onchip-cold.cfg` comb; `-onchip.cfg` comb COLD_BANK=0): (2,545 B of 2,816, over X25519_TABLES + the $B900 hole + mul_dma). Rules: ML-KEM never interleaves with X25519 or fp_mul, and **every ML-KEM call group needs `mlkem_arith_init` first** (its 1 KB of mod-q tables live in that -window, which X25519 rebuilds over): `pq_mlkem_call` does both, and -`test_mlkem_placement.py` fails on any other reference to a library entry. +window, which X25519 rebuilds over): the wrappers do both, and +`test_mlkem_placement.py` fails on any source outside `mlkem_link.s` naming a +symbol the map places in $E000-$FDFF. The `mlkem_arg_*` block is in that window too: fill it after the last X25519. All pinned in `src/crypto/mlkem_link.s`; scrub spans in `session_scrub.s`. diff --git a/cfg/c64-https-uci-onchip-cold.cfg b/cfg/c64-https-uci-onchip-cold.cfg index 4017d43..4f4bdde 100644 --- a/cfg/c64-https-uci-onchip-cold.cfg +++ b/cfg/c64-https-uci-onchip-cold.cfg @@ -537,6 +537,9 @@ SEGMENTS { # one block copy moves both (asserted in src/crypto/mlkem_link.s). LIB_MLKEM_CODE: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; LIB_MLKEM_RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40, align_load = $40, define = yes; + # One byte, last in the load image: boot checks it before copying, so a + # LOAD cut short (or a stage already zeroed) is refused, never half-copied. + LIB_MLKEM_MARK: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/cfg/c64-https-uci-onchip.cfg b/cfg/c64-https-uci-onchip.cfg index 15b6559..bac2933 100644 --- a/cfg/c64-https-uci-onchip.cfg +++ b/cfg/c64-https-uci-onchip.cfg @@ -500,6 +500,9 @@ SEGMENTS { # one block copy moves both (asserted in src/crypto/mlkem_link.s). LIB_MLKEM_CODE: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; LIB_MLKEM_RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40, align_load = $40, define = yes; + # One byte, last in the load image: boot checks it before copying, so a + # LOAD cut short (or a stage already zeroed) is refused, never half-copied. + LIB_MLKEM_MARK: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/cfg/c64-https-uci.cfg b/cfg/c64-https-uci.cfg index 83bb8a0..650fbec 100644 --- a/cfg/c64-https-uci.cfg +++ b/cfg/c64-https-uci.cfg @@ -456,6 +456,9 @@ SEGMENTS { # one block copy moves both (asserted in src/crypto/mlkem_link.s). LIB_MLKEM_CODE: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; LIB_MLKEM_RODATA: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, align = $40, align_load = $40, define = yes; + # One byte, last in the load image: boot checks it before copying, so a + # LOAD cut short (or a stage already zeroed) is refused, never half-copied. + LIB_MLKEM_MARK: load = COLD_FILE, run = MLKEM_CODE_RAM, type = ro, optional = yes, define = yes; LIB_MLKEM_BSS: load = MLKEM_BSS_RAM, type = bss, optional = yes, align = $100, define = yes; LIB_MLKEM_POLY_BSS: load = MLKEM_POLY_RAM, type = bss, optional = yes, align = $100, define = yes; } diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 3e47b1e..8a956ed 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -4116,7 +4116,6 @@ lives; nothing calls it. Measured on the uci-onchip link: code+rodata `$E000-$FD40` (7,489 B with alignment fill, 191 B spare in the 7,680 B window), `LIB_MLKEM_BSS` `$B100-$BAF0` (2,545 B, 271 B spare before the sqtab at `$BC00`), `LIB_MLKEM_POLY_BSS` `$D000-$DFFF` (exactly 4,096 B). -Costs on comb-store: LOADER 82 -> 58 B, NET_CODE 589 -> 493 B. Findings that changed the plan: @@ -4128,7 +4127,10 @@ Findings that changed the plan: view (`CRYPTO_COLD_SHADOW`, `file=""`, all the BSS, same names and addresses) and a file view (`COLD_FILE`, `file=%O`, the zero fill; holds the image's load address). Unset, the PRG is byte-identical. The copy - (`mlkem_boot_init`, 30 pages, whole-page so no length symbol is needed) + (`mlkem_boot_init`, 30 pages, whole-page so no length symbol is needed; a + one-byte tail marker, the last byte of the load image, is checked first, so + a LOAD cut short is refused with a red border and `pq_mlkem_call` + returning A=$FF/C=1, not half-copied) runs right after `sei` in boot.s, before the zeroing that would erase its source; an assembler assert pins that order. 'Q' leaves `$E000` and the RAM vectors alone (not secret). Re-entry: the copy runs only when a @@ -4155,7 +4157,9 @@ Findings that changed the plan: KeyGen must run after `trust_pre` and before the first server byte. Comb's `COLD_IMAGE` sits at `$C000` but is stashed to the REU once at boot and never read again (`cold_bank_init`), so it is dead by then. - - **NMI.** With the KERNAL out (`$01=$34`) the hardware vectors are RAM; a - RESTORE-key NMI during a call jumps through `$FFFA`. The scrub accepts - that window (it is followed by 'Q'); the real ML-KEM calls last seconds - and will need a vector stub in the free `$FE00-$FFFF`. + - **NMI and IRQ.** With the KERNAL out (`$01=$34`) the hardware vectors + are RAM. `mlkem_boot_init` points all three at one RTI. That is right for + NMI (the RESTORE key is edge-triggered, one event). For IRQ the `sei` in + `pq_mlkem_call` is load-bearing: CIA1's IRQ is level-triggered and, with + I/O banked out, nothing can acknowledge it, so an IRQ reaching the RTI + would re-enter at once and livelock the call. diff --git a/src/boot.s b/src/boot.s index d504417..43c94b8 100644 --- a/src/boot.s +++ b/src/boot.s @@ -271,6 +271,8 @@ boot_zbss_first: dex bne @zbss_page .ifdef PQ_HYBRID +; This compares LABELS, not the call itself: a jsr moved elsewhere still passes it. +; tools/test_mlkem_linked.py (the image compare and mutant b) is the real guard. .assert mlkem_copy_site < boot_zbss_first, error, "PQ_HYBRID: the ML-KEM boot copy must run BEFORE the $A000-$BFFF zeroing (it reads its source from there)" .endif .ifdef SINK_PROBE diff --git a/src/constants.inc b/src/constants.inc index b9fab65..bf12da2 100644 --- a/src/constants.inc +++ b/src/constants.inc @@ -253,5 +253,10 @@ CH_SECTIONED = 1 ; net_poll. Size >= 2,400 and the polys above it: src/crypto/mlkem_link.s. ; Comb: COLD_IMAGE is staged at $C000 but cold_bank_init stashes it to the ; REU once at boot and never reads it again, so the ring is dead by then. +; Also owed by the callers (later phases; no home exists yet): the 64 B d||z +; seed that mlkem_decaps_seed re-reads needs a BSS home outside the unions +; below AND a session_scrub span (P5); and ML-KEM never interleaves with +; X25519 or fp_mul (the BSS window is X25519_TABLES + mul_dma): call it only +; through pq_mlkem_keygen / pq_mlkem_decaps_seed (src/crypto/mlkem_link.s). mlkem_dk_scratch = tcp_recv_buf MLKEM_DK_SIZE = 2400 diff --git a/src/crypto/mlkem_link.s b/src/crypto/mlkem_link.s index dbe832b..2e78157 100644 --- a/src/crypto/mlkem_link.s +++ b/src/crypto/mlkem_link.s @@ -16,7 +16,7 @@ ; PLACEMENT (cfg/c64-https-uci-onchip.cfg and ...-onchip-cold.cfg) ; ; LIB_MLKEM_CODE/RODATA MLKEM_CODE_RAM $E000-$FDFF RAM under the KERNAL -; ROM. file="" - NOT in the PRG (see HAZARD below). +; ROM. file="" - NOT in the PRG (see DELIVERY below). ; LIB_MLKEM_POLY_BSS MLKEM_POLY_RAM $D000-$DFFF RAM under I/O, the ; eight polynomials, exactly 4,096 B. ; LIB_MLKEM_BSS MLKEM_BSS_RAM $B100-$BBFF = X25519_TABLES @@ -35,9 +35,9 @@ ; mlkem_keygen, secret-derived y/e1/e2 after Decaps; LIB_MLKEM_BSS: ; m', K', r'). src/session_scrub.s zeroes both under PQ_HYBRID. ; 3. $E000 code and the $D000 polys need $01 banked (KERNAL and I/O out, -; $01 = $34) under sei around every ML-KEM call. With the KERNAL out the -; hardware vectors are RAM: a RESTORE-key NMI during a call jumps through -; whatever sits at $FFFA. $FE00-$FFFF is left free for a vector stub. +; $01 = $34) under sei around every ML-KEM call; pq_mlkem_call does +; both. With the KERNAL out the hardware vectors are RAM, and +; mlkem_boot_init points all three at an RTI (VECTORS below). ; ; DELIVERY (#288): the PRG may not carry a byte at $D000+, so the code is not ; file-backed at $E000. It LOADS at $A000 (COLD_FILE: the file bytes under @@ -47,19 +47,24 @@ ; so $A000 reads RAM). The ordering is asserted in boot.s and tested in ; tools/test_mlkem_linked.py (which also compares the copied bytes). ; -; ENTRY: pq_mlkem_call is the only way in. It banks ($01 = $34, under sei), +; ENTRY: pq_mlkem_call (and its named wrappers pq_mlkem_keygen and +; pq_mlkem_decaps_seed) is the only way in. It banks ($01 = $34, under sei), ; runs mlkem_arith_init (RULE 1 above: the reduction tables live in a window -; X25519 rebuilds), jsrs the entry in A/X and restores. The library's own -; entries are not to be referenced anywhere else -; (tools/test_mlkem_placement.py greps for it). The parameter block +; X25519 rebuilds), jsrs the entry in A/X and restores. No symbol the linker +; places in $E000-$FDFF may be referenced outside this file +; (tools/test_mlkem_placement.py reads the map's export list). A client needs +; KeyGen and Decaps-from-seed only; Encaps and Decaps-with-dk have no wrapper +; because nothing here calls them. The parameter block ; (mlkem_arg_*) is ALSO in LIB_MLKEM_BSS: fill it after the last X25519 / ; fp_mul and before pq_mlkem_call, never across one. ; ; VECTORS: with the KERNAL out $FFFA-$FFFF are RAM. mlkem_boot_init points ; NMI, RESET and IRQ/BRK at one RTI (in NET_CODE, always RAM), so a -; RESTORE-key NMI during a call returns instead of jumping through -; garbage. IRQ and BRK are moot: every call runs under sei, and no BRK is -; ever executed; they point at the same RTI so a stray one is harmless. +; RESTORE-key NMI (edge-triggered, one event) returns instead of jumping +; through garbage. The sei in pq_mlkem_call is LOAD-BEARING for IRQ: CIA1's +; IRQ line is level-triggered and, with I/O banked out, nothing can +; acknowledge it, so an IRQ that reached the RTI would re-enter at once and +; livelock the call. No BRK is executed; its vector shares the RTI. ; ; ZERO PAGE (tools/integration/build_mlkem.sh has the table and reasoning): ; $40-$4F, inside ZP_WIDE; the only co-tenant is the X25519 sibling's @@ -95,6 +100,7 @@ LIB_SHARED_SQTAB_BASE = $BC00 .import __COLD_FILE_START__, __COLD_FILE_SIZE__ .import __LIB_MLKEM_CODE_LOAD__, __LIB_MLKEM_CODE_RUN__, __LIB_MLKEM_CODE_SIZE__ .import __LIB_MLKEM_RODATA_LOAD__, __LIB_MLKEM_RODATA_RUN__, __LIB_MLKEM_RODATA_SIZE__ +.import __LIB_MLKEM_MARK_LOAD__, __LIB_MLKEM_MARK_RUN__, __LIB_MLKEM_MARK_SIZE__ .import __MLKEM_BSS_RAM_START__, __MLKEM_BSS_RAM_SIZE__, __MLKEM_BSS_RAM_LAST__ .import __MLKEM_POLY_RAM_START__, __MLKEM_POLY_RAM_SIZE__, __MLKEM_POLY_RAM_LAST__ .import __LIB_MLKEM_BSS_RUN__, __LIB_MLKEM_BSS_SIZE__ @@ -148,6 +154,11 @@ MLKEM_LOAD_BASE = $A000 .assert __LIB_MLKEM_RODATA_LOAD__ + __LIB_MLKEM_RODATA_SIZE__ <= MLKEM_LOAD_BASE + __MLKEM_CODE_RAM_SIZE__, lderror, "the ML-KEM load image is longer than the block boot copies" .assert __LIB_MLKEM_RODATA_LOAD__ + __LIB_MLKEM_RODATA_SIZE__ <= __COLD_FILE_START__ + __COLD_FILE_SIZE__, lderror, "the ML-KEM load image runs past the PRG's file content" .assert __LIB_MLKEM_RODATA_RUN__ + __LIB_MLKEM_RODATA_SIZE__ <= __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__, lderror, "the ML-KEM run image overflows MLKEM_CODE_RAM" +.assert __LIB_MLKEM_MARK_SIZE__ = 1, lderror, "the tail marker is not one byte" +.assert __LIB_MLKEM_MARK_LOAD__ = __LIB_MLKEM_RODATA_LOAD__ + __LIB_MLKEM_RODATA_SIZE__, lderror, "the tail marker is not the last byte of the load image" +.assert __LIB_MLKEM_MARK_LOAD__ - __LIB_MLKEM_CODE_LOAD__ = __LIB_MLKEM_MARK_RUN__ - __LIB_MLKEM_CODE_RUN__, lderror, "the tail marker's load and run offsets differ" +.assert __LIB_MLKEM_MARK_LOAD__ < MLKEM_LOAD_BASE + __MLKEM_CODE_RAM_SIZE__, lderror, "the tail marker is past the block boot copies" +.assert __LIB_MLKEM_MARK_RUN__ < __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__, lderror, "the tail marker overflows MLKEM_CODE_RAM" .assert __MLKEM_CODE_RAM_SIZE__ .mod 256 = 0, lderror, "the boot copy moves whole pages: MLKEM_CODE_RAM is not a page multiple" .assert __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__ <= $FFFA, lderror, "MLKEM_CODE_RAM overlaps the $FFFA vectors" @@ -250,25 +261,36 @@ SQTAB_GUARD "COLD_RUN_TRUST", __COLD_RUN_TRUST_START__, __COLD_RUN_TRUST ; ============================================================================= ; Code (NET_CODE via LOADER_OVERFLOW; always RAM, so the SMC below is legal) ; ============================================================================= -.export mlkem_boot_init, pq_mlkem_call +.export mlkem_boot_init, pq_mlkem_call, pq_mlkem_keygen, pq_mlkem_decaps_seed .segment "LOADER_OVERFLOW" ; mlkem_boot_init - boot.s, right after `sei`, BEFORE it zeroes $A000-$BFFF. ; In: BASIC ROM already banked out ($01 bit 0 clear), so $A000 reads RAM. -; Out: the load image at $A000 copied to $E000 (30 pages); NMI/RESET/IRQ -; vectors in RAM point at an RTI. Clobbers A, X, Y. -; Skips the copy unless this is a fresh LOAD: mlkem_copied is PRG data (0 -; only after a LOAD) AND $A000 is nonzero (the stage is zeroed by the boot -; right after the copy, and 'Q' zeroes tls_rec_buf at the same address). Both -; must say "fresh": after 'Q' the stage is zeros, so a re-entry that copied -; would erase the code. $E000 and the vectors are not secret and 'Q' leaves -; them alone (session_scrub has no span there). +; Out: the load image at $A000 copied to $E000 (30 pages) and +; mlkem_copied = 1; NMI/RESET/IRQ vectors in RAM point at an RTI. +; Clobbers A, X, Y. +; Three cases, decided by the image's TAIL MARKER (LIB_MLKEM_MARK, the last +; byte of the load image, like cold_bank_init's COLD_MARK: a LOAD cut short +; before it never has it): +; 1. mlkem_copied already 1 (a SYS re-entry): nothing to do. +; 2. the marker is present at the load address: a fresh, complete LOAD: +; copy, set the vectors, mlkem_copied = 1. +; 3. no marker at the load address (the stage was zeroed by an earlier boot +; or the LOAD stopped short): if the RUN address already holds the marker +; the image is resident from an earlier boot (the PRG-data flag was +; clobbered, e.g. by BASIC after 'Q'): mlkem_copied = 1. Otherwise +; REFUSE: mlkem_copied stays 0, the border goes red, and pq_mlkem_call +; returns A=$FF with C=1 and runs nothing. Never a silent skip. +; $E000 and the vectors are not secret and 'Q' leaves them alone +; (session_scrub has no span there). +MLKEM_MARK = $5C mlkem_boot_init: lda mlkem_copied bne @done - lda MLKEM_LOAD_BASE - beq @done + lda __LIB_MLKEM_MARK_LOAD__ + cmp #MLKEM_MARK + bne @resident lda #>MLKEM_LOAD_BASE sta @ld+2 lda #>__MLKEM_CODE_RAM_START__ @@ -291,6 +313,22 @@ mlkem_boot_init: bpl @vec inc mlkem_copied @done: rts +@resident: + lda $01 + pha + lda #$34 ; all RAM: $E000+ would read the KERNAL + sta $01 + ldx __LIB_MLKEM_MARK_RUN__ + pla + sta $01 + cpx #MLKEM_MARK + bne @refuse + inc mlkem_copied + rts +@refuse: + lda #2 ; red border: the image is not there + sta $D020 + rts mlkem_vec_tab: ; NMI, RESET, IRQ/BRK -> the one RTI .word mlkem_rti, mlkem_rti, mlkem_rti @@ -302,12 +340,19 @@ mlkem_copied: ; pq_mlkem_call - the only entry into libs/mlkem. ; In: A/X = entry (mlkem_keygen, mlkem_decaps_seed, ...); the mlkem_arg_* ; block already filled. -; Out: A = the entry's A (its status); X, Y, C, Z, N clobbered; I restored. +; Out: C=0: A = the entry's A (its status), X/Y/N/Z clobbered, the caller's I +; flag restored. C=1, A=$FF: refused (the image is not resident, see +; mlkem_boot_init); nothing ran. ; Banks $01 = $34 (KERNAL, BASIC and I/O out: code at $E000, polys at ; $D000) under sei, runs mlkem_arith_init FIRST on every call because its ; tables live in the window X25519 rebuilds, then the entry. pq_mlkem_call: - sta @go+1 + ldy mlkem_copied + bne @run + lda #$FF + sec + rts +@run: sta @go+1 stx @go+2 php sei @@ -322,4 +367,20 @@ pq_mlkem_call: sta $01 plp @ra: lda #0 + clc rts + +; Named entries: all a client calls (KeyGen to send ek; Decaps from the +; 64 B seed). Encaps and Decaps-with-dk are not wrapped: nothing calls them. +pq_mlkem_keygen: + lda #mlkem_keygen + jmp pq_mlkem_call +pq_mlkem_decaps_seed: + lda #mlkem_decaps_seed + jmp pq_mlkem_call + +.segment "LIB_MLKEM_MARK" +mlkem_mark: + .byte MLKEM_MARK diff --git a/src/session_scrub.s b/src/session_scrub.s index 2c07b7f..2b44902 100644 --- a/src/session_scrub.s +++ b/src/session_scrub.s @@ -47,10 +47,11 @@ ; out) under sei, restored on exit; ; every other span is plain RAM ; either way. With the KERNAL out the -; vectors at $FFFA/$FFFE are RAM, so a -; RESTORE-key NMI in this window (a -; few ms) would jump through garbage: -; accepted, the next line is 'Q'. +; vectors at $FFFA/$FFFE are RAM; they +; point at an RTI (mlkem_boot_init), so +; a RESTORE-key NMI in this window +; returns. The sei above is what keeps +; an IRQ out (see mlkem_link.s). ; tcp_recv_buf, 2,400 B the discarded ML-KEM dk if the ; caller forgot to zero it (the ring ; is otherwise ciphertext, not diff --git a/tools/rig_scratch_profiles.json b/tools/rig_scratch_profiles.json index 774118f..8229ce4 100644 --- a/tools/rig_scratch_profiles.json +++ b/tools/rig_scratch_profiles.json @@ -23,5 +23,15 @@ "make": "BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 TRUST_STORE=1 HTTPS_HOST=en.wikipedia.org HTTPS_PATH=/w/index.php?title=Commodore_64&action=raw HTTPS_BODY_TO_REU=1", "prg_sha256": "7607586f5d440415dc97a4684907162519a2046ce2d081fac375873db8337e20", "labels": "al C:0004 .LIB_NISTCURVES_REU_BANKS_USED\nal C:C95F .__COLD_IMAGE_LAST__\nal C:1000 .__COLD_IMAGE_SIZE__\nal C:C000 .__COLD_IMAGE_START__\nal C:49B5 .__COLD_RUN_TRUST_LAST__\nal C:0800 .__COLD_RUN_TRUST_SIZE__\nal C:4200 .__COLD_RUN_TRUST_START__\nal C:43AA .__COLD_RUN_UI_LAST__\nal C:0800 .__COLD_RUN_UI_SIZE__\nal C:4200 .__COLD_RUN_UI_START__\nal C:0001 .__COLD_TAIL_TRUST_SIZE__\nal C:0001 .__COLD_TAIL_UI_SIZE__\nal C:C000 .__CRYPTO_COLD_SHADOW_LAST__\nal C:2000 .__CRYPTO_COLD_SHADOW_SIZE__\nal C:A000 .__CRYPTO_COLD_SHADOW_START__\nal C:9FBA .__CRYPTO_HOT_LAST__\nal C:4000 .__CRYPTO_HOT_SIZE__\nal C:6000 .__CRYPTO_HOT_START__\nal C:5FFB .__CRYPTO_OVERLAY_LAST__\nal C:1E00 .__CRYPTO_OVERLAY_SIZE__\nal C:4200 .__CRYPTO_OVERLAY_START__\nal C:1FE7 .__LOADER_LAST__\nal C:17FF .__LOADER_SIZE__\nal C:0801 .__LOADER_START__\nal C:3CB5 .__NET_BSS_TAIL_LAST__\nal C:069A .__NET_BSS_TAIL_SIZE__\nal C:3B66 .__NET_BSS_TAIL_START__\nal C:39D2 .__NET_CODE_LAST__\nal C:1B66 .__NET_CODE_SIZE__\nal C:2000 .__NET_CODE_START__\nal C:4722 .__P256_SCRATCH_UNION_LAST__\nal C:0800 .__P256_SCRATCH_UNION_SIZE__\nal C:4200 .__P256_SCRATCH_UNION_START__\nal C:01A9 .__TARGET_PROMPT_CODE_SIZE__\nal C:C1AA .__TRUST_STORE_CODE_LOAD__\nal C:4200 .__TRUST_STORE_CODE_RUN__\nal C:04B0 .__TRUST_STORE_CODE_SIZE__\nal C:4000 .__UCI_BSS_REGION_LAST__\nal C:0000 .__UCI_BSS_REGION_SIZE__\nal C:4000 .__UCI_BSS_REGION_START__\nal C:A220 .__X25519_SCRATCH_LAST__\nal C:0224 .__X25519_SCRATCH_SIZE__\nal C:A000 .__X25519_SCRATCH_START__\nal C:0022 .__ZP_CRYPTO_LAST__\nal C:001E .__ZP_CRYPTO_SIZE__\nal C:0022 .__ZP_CRYPTO_START__\nal C:0040 .__ZP_WIDE_LAST__\nal C:0040 .__ZP_WIDE_SIZE__\nal C:0040 .__ZP_WIDE_START__\nal C:0800 .cert_buf_size\nal C:1C57 .cold_call\nal C:1DD5 .cold_err\nal C:49B4 .cold_marker_trust\nal C:5718 .dos_len\nal C:4200 .dos_open\nal C:42CA .dos_write\nal C:9BE9 .ec_precompute_256\nal C:AE40 .ecdsa_curve_id\nal C:AE81 .ecdsa_hash\nal C:4C73 .ecdsa_hash_len\nal C:AEA1 .ecdsa_pubkey_x\nal C:AEC1 .ecdsa_pubkey_y\nal C:4C74 .ecdsa_sig_len\nal C:AE41 .ecdsa_sig_r\nal C:AE61 .ecdsa_sig_s\nal C:6B4D .ecdsa_verify\nal C:A85C .http_body_sink\nal C:A859 .http_body_total\nal C:0D5C .http_get\nal C:1041 .http_get_plain\nal C:A528 .http_host_len\nal C:A526 .http_host_ptr\nal C:5638 .http_host_target\nal C:A832 .http_parse_state\nal C:A52B .http_path_len\nal C:A529 .http_path_ptr\nal C:4C70 .http_port\nal C:A630 .http_resp_buf\nal C:A830 .http_resp_len\nal C:A52C .http_status\nal C:227C .net_dhcp_acquire\nal C:257A .net_dns_resolve\nal C:2000 .net_init\nal C:A227 .net_initialized\nal C:B07A .net_last_error\nal C:B072 .net_local_ip\nal C:2054 .net_poll\nal C:A249 .net_poll_entry_count\nal C:A24B .net_poll_return_count\nal C:25A9 .net_recv_byte\nal C:B07C .net_send_len\nal C:2532 .net_tcp_close\nal C:2319 .net_tcp_connect\nal C:23EF .net_tcp_send\nal C:B07B .net_tcp_state\nal C:0A7D .print_resp_body\nal C:AEF6 .reu_dma_timeout\nal C:C000 .tcp_recv_buf\nal C:A244 .tcp_recv_head\nal C:A248 .tcp_recv_overflow\nal C:A246 .tcp_recv_tail\nal C:A862 .tls_app_len\nal C:A860 .tls_app_ptr\nal C:A3F8 .tls_app_read_iv\nal C:A3D8 .tls_app_read_key\nal C:A3CC .tls_app_write_iv\nal C:A3AC .tls_app_write_key\nal C:AF49 .tls_c_hs_secret\nal C:A254 .tls_client_random\nal C:A4C6 .tls_early_secret\nal C:A294 .tls_ecdhe_privkey\nal C:A2B4 .tls_ecdhe_pubkey\nal C:AFC9 .tls_finished_key\nal C:A4E6 .tls_handshake_secret\nal C:A3A0 .tls_hs_read_iv\nal C:A380 .tls_hs_read_key\nal C:A374 .tls_hs_write_iv\nal C:A354 .tls_hs_write_key\nal C:A24E .tls_last_state\nal C:A506 .tls_master_secret\nal C:A24F .tls_reached_connected\nal C:A40C .tls_read_seq\nal C:A000 .tls_rec_buf\nal C:A414 .tls_rec_header\nal C:A41A .tls_rec_len\nal C:A419 .tls_rec_type\nal C:15DE .tls_recv_count\nal C:A252 .tls_recv_poll_count\nal C:A250 .tls_recv_progress\nal C:15DD .tls_recv_state\nal C:A251 .tls_recv_sub_progress\nal C:A2D4 .tls_server_pubkey\nal C:A274 .tls_server_random\nal C:A2F4 .tls_shared_secret\nal C:A24D .tls_state\nal C:A314 .tls_transcript\nal C:AFA9 .tls_verify_data\nal C:A404 .tls_write_seq\nal C:397F .tp_mode\nal C:3981 .tp_ovr_armed\nal C:1C41 .trust_post\nal C:1C3E .trust_pre\nal C:1C24 .trust_store_load\nal C:1C2E .trust_store_save\nal C:1C29 .trust_store_stage\nal C:5732 .ts_found\nal C:5720 .ts_gen\nal C:5722 .ts_reason\nal C:5744 .ts_rec\nal C:571F .ts_slot\nal C:5723 .ts_slot_st\nal C:571E .ts_state\nal C:26A5 .uci_begin_cmd\nal C:6183 .uci_host_buf\nal C:26CF .uci_push_wait\nal C:26BA .uci_put_byte\nal C:628F .uci_socket_id\nal C:62A2 .uci_status_buf\nal C:62B6 .uci_status_force\nal C:62B2 .uci_status_len\nal C:62BB .viewer_enter\nal C:0AB8 .https_target_prompt\nal C:A227 .net_initialized\nal C:5638 .http_host_target\nal C:0A7D .print_resp_body\nal C:3981 .tp_ovr_armed\nal C:397F .tp_mode\nal C:AEC1 .ecdsa_pubkey_y\nal C:AEA1 .ecdsa_pubkey_x\nal C:AE61 .ecdsa_sig_s\nal C:AE41 .ecdsa_sig_r\nal C:AE81 .ecdsa_hash\nal C:AE40 .ecdsa_curve_id\nal C:A862 .tls_app_len\nal C:A860 .tls_app_ptr\nal C:A85C .http_body_sink\nal C:A859 .http_body_total\nal C:A832 .http_parse_state\nal C:A830 .http_resp_len\nal C:A630 .http_resp_buf\nal C:A52C .http_status\nal C:A52B .http_path_len\nal C:A529 .http_path_ptr\nal C:A528 .http_host_len\nal C:A526 .http_host_ptr\nal C:A506 .tls_master_secret\nal C:A4E6 .tls_handshake_secret\nal C:A4C6 .tls_early_secret\nal C:A000 .tls_rec_buf\nal C:A41A .tls_rec_len\nal C:A419 .tls_rec_type\nal C:A414 .tls_rec_header\nal C:A40C .tls_read_seq\nal C:A404 .tls_write_seq\nal C:A3F8 .tls_app_read_iv\nal C:A3D8 .tls_app_read_key\nal C:A3CC .tls_app_write_iv\nal C:A3AC .tls_app_write_key\nal C:A3A0 .tls_hs_read_iv\nal C:A380 .tls_hs_read_key\nal C:A374 .tls_hs_write_iv\nal C:A354 .tls_hs_write_key\nal C:A314 .tls_transcript\nal C:A2F4 .tls_shared_secret\nal C:A2D4 .tls_server_pubkey\nal C:A2B4 .tls_ecdhe_pubkey\nal C:A294 .tls_ecdhe_privkey\nal C:A274 .tls_server_random\nal C:A254 .tls_client_random\nal C:A252 .tls_recv_poll_count\nal C:A251 .tls_recv_sub_progress\nal C:A250 .tls_recv_progress\nal C:A24F .tls_reached_connected\nal C:A24E .tls_last_state\nal C:A24D .tls_state\nal C:A24B .net_poll_return_count\nal C:A249 .net_poll_entry_count\nal C:A248 .tcp_recv_overflow\nal C:A246 .tcp_recv_tail\nal C:A244 .tcp_recv_head\nal C:4C74 .ecdsa_sig_len\nal C:4C73 .ecdsa_hash_len\nal C:4C70 .http_port\nal C:1041 .http_get_plain\nal C:0D5C .http_get\nal C:AEF6 .reu_dma_timeout\nal C:AF69 .tls_s_hs_secret\nal C:AF89 .tls_derived_tmp\nal C:AFC9 .tls_finished_key\nal C:AF49 .tls_c_hs_secret\nal C:AFA9 .tls_verify_data\nal C:15DE .tls_recv_count\nal C:15DD .tls_recv_state\nal C:62BB .viewer_enter\nal C:6B4D .ecdsa_verify\nal C:629A .uci_read_hdr\nal C:629E .uci_req_len\nal C:628F .uci_socket_id\nal C:6183 .uci_host_buf\nal C:B07C .net_send_len\nal C:25A9 .net_recv_byte\nal C:B07B .net_tcp_state\nal C:B07A .net_last_error\nal C:B072 .net_local_ip\nal C:257A .net_dns_resolve\nal C:2532 .net_tcp_close\nal C:23EF .net_tcp_send\nal C:2319 .net_tcp_connect\nal C:227C .net_dhcp_acquire\nal C:2054 .net_poll\nal C:2000 .net_init\nal C:26CF .uci_push_wait\nal C:26BA .uci_put_byte\nal C:26A5 .uci_begin_cmd\nal C:62B6 .uci_status_force\nal C:62B2 .uci_status_len\nal C:62A2 .uci_status_buf\nal C:5718 .dos_len\nal C:42CA .dos_write\nal C:4200 .dos_open\nal C:46AE .ts_letter\nal C:46A2 .ts_name\nal C:462F .ts_set_name\nal C:5731 .ts_target\nal C:5732 .ts_found\nal C:5744 .ts_rec\nal C:5723 .ts_slot_st\nal C:5720 .ts_gen\nal C:571F .ts_slot\nal C:5722 .ts_reason\nal C:571E .ts_state\nal C:1C2E .trust_store_save\nal C:1C29 .trust_store_stage\nal C:1C24 .trust_store_load\nal C:1C41 .trust_post\nal C:1C3E .trust_pre\nal C:49B4 .cold_marker_trust\nal C:1DD5 .cold_err\nal C:1C57 .cold_call\nal C:7B0E .gen_mul_row\nal C:9BE9 .ec_precompute_256\n" + }, + "uci-onchip-pq": { + "make": "BACKEND=uci USE_NISTCURVES_ONCHIP=1 PQ_HYBRID=1", + "prg_sha256": "85d3e80a89adb9a7340def0c8676970c41b02bad134e51b64db2a6ae2b27c36a", + "labels": "al C:0000 .LIB_NISTCURVES_REU_BANKS_USED\nal C:BD42 .__COLD_FILE_LAST__\nal C:2000 .__COLD_FILE_SIZE__\nal C:A000 .__COLD_FILE_START__\nal C:C000 .__CRYPTO_COLD_SHADOW_LAST__\nal C:2000 .__CRYPTO_COLD_SHADOW_SIZE__\nal C:A000 .__CRYPTO_COLD_SHADOW_START__\nal C:9E03 .__CRYPTO_HOT_LAST__\nal C:4000 .__CRYPTO_HOT_SIZE__\nal C:6000 .__CRYPTO_HOT_START__\nal C:55EE .__CRYPTO_OVERLAY_LAST__\nal C:1E00 .__CRYPTO_OVERLAY_SIZE__\nal C:4200 .__CRYPTO_OVERLAY_START__\nal C:09F1 .__LIB_MLKEM_BSS_SIZE__\nal C:A000 .__LIB_MLKEM_CODE_LOAD__\nal C:19A0 .__LIB_MLKEM_CODE_SIZE__\nal C:BD41 .__LIB_MLKEM_MARK_LOAD__\nal C:0001 .__LIB_MLKEM_MARK_SIZE__\nal C:1000 .__LIB_MLKEM_POLY_BSS_SIZE__\nal C:0381 .__LIB_MLKEM_RODATA_SIZE__\nal C:0801 .__LOADADDR_LAST__\nal C:0002 .__LOADADDR_SIZE__\nal C:07FF .__LOADADDR_START__\nal C:1DB0 .__LOADER_LAST__\nal C:17FF .__LOADER_SIZE__\nal C:0801 .__LOADER_START__\nal C:BAF1 .__MLKEM_BSS_RAM_LAST__\nal C:0B00 .__MLKEM_BSS_RAM_SIZE__\nal C:B100 .__MLKEM_BSS_RAM_START__\nal C:FD42 .__MLKEM_CODE_RAM_LAST__\nal C:1E00 .__MLKEM_CODE_RAM_SIZE__\nal C:E000 .__MLKEM_CODE_RAM_START__\nal C:E000 .__MLKEM_POLY_RAM_LAST__\nal C:1000 .__MLKEM_POLY_RAM_SIZE__\nal C:D000 .__MLKEM_POLY_RAM_START__\nal C:3CA1 .__NET_BSS_TAIL_LAST__\nal C:069A .__NET_BSS_TAIL_SIZE__\nal C:3B66 .__NET_BSS_TAIL_START__\nal C:39E1 .__NET_CODE_LAST__\nal C:1B66 .__NET_CODE_SIZE__\nal C:2000 .__NET_CODE_START__\nal C:4722 .__P256_SCRATCH_UNION_LAST__\nal C:0800 .__P256_SCRATCH_UNION_SIZE__\nal C:4200 .__P256_SCRATCH_UNION_START__\nal C:4000 .__UCI_BSS_REGION_LAST__\nal C:0000 .__UCI_BSS_REGION_SIZE__\nal C:4000 .__UCI_BSS_REGION_START__\nal C:A220 .__X25519_SCRATCH_LAST__\nal C:0224 .__X25519_SCRATCH_SIZE__\nal C:A000 .__X25519_SCRATCH_START__\nal C:0022 .__ZP_CRYPTO_LAST__\nal C:001E .__ZP_CRYPTO_SIZE__\nal C:0022 .__ZP_CRYPTO_START__\nal C:0040 .__ZP_WIDE_LAST__\nal C:0040 .__ZP_WIDE_SIZE__\nal C:0040 .__ZP_WIDE_START__\nal C:4200 .cert_buf\nal C:0800 .cert_buf_size\nal C:AE3D .ecdsa_curve_id\nal C:AE7E .ecdsa_hash\nal C:6253 .ecdsa_hash_len\nal C:AE9E .ecdsa_pubkey_x\nal C:AEBE .ecdsa_pubkey_y\nal C:6254 .ecdsa_sig_len\nal C:AE3E .ecdsa_sig_r\nal C:AE5E .ecdsa_sig_s\nal C:6942 .ecdsa_verify\nal C:A859 .http_body_sink\nal C:A856 .http_body_total\nal C:0CCD .http_get\nal C:0FB2 .http_get_plain\nal C:A525 .http_host_len\nal C:A523 .http_host_ptr\nal C:52F6 .http_host_target\nal C:A82F .http_parse_state\nal C:A528 .http_path_len\nal C:A526 .http_path_ptr\nal C:6250 .http_port\nal C:A62D .http_resp_buf\nal C:A82D .http_resp_len\nal C:6419 .http_sink_full\nal C:A529 .http_status\nal C:F857 .mlkem_decaps_seed\nal C:F633 .mlkem_keygen\nal C:2227 .net_dhcp_acquire\nal C:2525 .net_dns_resolve\nal C:2000 .net_init\nal C:A224 .net_initialized\nal C:B07E .net_last_error\nal C:B076 .net_local_ip\nal C:2043 .net_poll\nal C:A246 .net_poll_entry_count\nal C:A248 .net_poll_return_count\nal C:2554 .net_recv_byte\nal C:B080 .net_send_len\nal C:24DD .net_tcp_close\nal C:22C4 .net_tcp_connect\nal C:239A .net_tcp_send\nal C:B07F .net_tcp_state\nal C:28CE .pq_mlkem_call\nal C:28F7 .pq_mlkem_keygen\nal C:0A14 .print_resp_body\nal C:AEF2 .reu_dma_timeout\nal C:080D .start\nal C:C000 .tcp_recv_buf\nal C:A241 .tcp_recv_head\nal C:A245 .tcp_recv_overflow\nal C:A243 .tcp_recv_tail\nal C:A85F .tls_app_len\nal C:A85D .tls_app_ptr\nal C:A3F5 .tls_app_read_iv\nal C:A3D5 .tls_app_read_key\nal C:A3C9 .tls_app_write_iv\nal C:A3A9 .tls_app_write_key\nal C:AF51 .tls_c_hs_secret\nal C:A251 .tls_client_random\nal C:A4C3 .tls_early_secret\nal C:A291 .tls_ecdhe_privkey\nal C:A2B1 .tls_ecdhe_pubkey\nal C:AFD1 .tls_finished_key\nal C:A4E3 .tls_handshake_secret\nal C:A39D .tls_hs_read_iv\nal C:A37D .tls_hs_read_key\nal C:A371 .tls_hs_write_iv\nal C:A351 .tls_hs_write_key\nal C:A24B .tls_last_state\nal C:A503 .tls_master_secret\nal C:A24C .tls_reached_connected\nal C:A409 .tls_read_seq\nal C:A000 .tls_rec_buf\nal C:A411 .tls_rec_header\nal C:A417 .tls_rec_len\nal C:A416 .tls_rec_type\nal C:1A76 .tls_recv_count\nal C:A24F .tls_recv_poll_count\nal C:A24D .tls_recv_progress\nal C:1A75 .tls_recv_state\nal C:A24E .tls_recv_sub_progress\nal C:A2D1 .tls_server_pubkey\nal C:A271 .tls_server_random\nal C:A2F1 .tls_shared_secret\nal C:A24A .tls_state\nal C:A311 .tls_transcript\nal C:AFB1 .tls_verify_data\nal C:A401 .tls_write_seq\nal C:262E .uci_begin_cmd\nal C:641D .uci_host_buf\nal C:2658 .uci_push_wait\nal C:2643 .uci_put_byte\nal C:6529 .uci_socket_id\nal C:653C .uci_status_buf\nal C:6550 .uci_status_force\nal C:654C .uci_status_len\nal C:530A .https_target_prompt\nal C:A224 .net_initialized\nal C:52F6 .http_host_target\nal C:0A14 .print_resp_body\nal C:080D .start\nal C:AEBE .ecdsa_pubkey_y\nal C:AE9E .ecdsa_pubkey_x\nal C:AE5E .ecdsa_sig_s\nal C:AE3E .ecdsa_sig_r\nal C:AE7E .ecdsa_hash\nal C:AE3D .ecdsa_curve_id\nal C:A85F .tls_app_len\nal C:A85D .tls_app_ptr\nal C:A859 .http_body_sink\nal C:A856 .http_body_total\nal C:A82F .http_parse_state\nal C:A82D .http_resp_len\nal C:A62D .http_resp_buf\nal C:A529 .http_status\nal C:A528 .http_path_len\nal C:A526 .http_path_ptr\nal C:A525 .http_host_len\nal C:A523 .http_host_ptr\nal C:A503 .tls_master_secret\nal C:A4E3 .tls_handshake_secret\nal C:A4C3 .tls_early_secret\nal C:A000 .tls_rec_buf\nal C:A417 .tls_rec_len\nal C:A416 .tls_rec_type\nal C:A411 .tls_rec_header\nal C:A409 .tls_read_seq\nal C:A401 .tls_write_seq\nal C:A3F5 .tls_app_read_iv\nal C:A3D5 .tls_app_read_key\nal C:A3C9 .tls_app_write_iv\nal C:A3A9 .tls_app_write_key\nal C:A39D .tls_hs_read_iv\nal C:A37D .tls_hs_read_key\nal C:A371 .tls_hs_write_iv\nal C:A351 .tls_hs_write_key\nal C:A311 .tls_transcript\nal C:A2F1 .tls_shared_secret\nal C:A2D1 .tls_server_pubkey\nal C:A2B1 .tls_ecdhe_pubkey\nal C:A291 .tls_ecdhe_privkey\nal C:A271 .tls_server_random\nal C:A251 .tls_client_random\nal C:A24F .tls_recv_poll_count\nal C:A24E .tls_recv_sub_progress\nal C:A24D .tls_recv_progress\nal C:A24C .tls_reached_connected\nal C:A24B .tls_last_state\nal C:A24A .tls_state\nal C:A248 .net_poll_return_count\nal C:A246 .net_poll_entry_count\nal C:A245 .tcp_recv_overflow\nal C:A243 .tcp_recv_tail\nal C:A241 .tcp_recv_head\nal C:6254 .ecdsa_sig_len\nal C:6253 .ecdsa_hash_len\nal C:6250 .http_port\nal C:4200 .cert_buf\nal C:6419 .http_sink_full\nal C:0FB2 .http_get_plain\nal C:0CCD .http_get\nal C:AEF2 .reu_dma_timeout\nal C:AF71 .tls_s_hs_secret\nal C:AF91 .tls_derived_tmp\nal C:AFD1 .tls_finished_key\nal C:AF51 .tls_c_hs_secret\nal C:AFB1 .tls_verify_data\nal C:1A76 .tls_recv_count\nal C:1A75 .tls_recv_state\nal C:6942 .ecdsa_verify\nal C:28CC .mlkem_rti\nal C:28F7 .pq_mlkem_keygen\nal C:28CE .pq_mlkem_call\nal C:6534 .uci_read_hdr\nal C:6538 .uci_req_len\nal C:6529 .uci_socket_id\nal C:641D .uci_host_buf\nal C:B080 .net_send_len\nal C:2554 .net_recv_byte\nal C:B07F .net_tcp_state\nal C:B07E .net_last_error\nal C:B076 .net_local_ip\nal C:2525 .net_dns_resolve\nal C:24DD .net_tcp_close\nal C:239A .net_tcp_send\nal C:22C4 .net_tcp_connect\nal C:2227 .net_dhcp_acquire\nal C:2043 .net_poll\nal C:2000 .net_init\nal C:2658 .uci_push_wait\nal C:2643 .uci_put_byte\nal C:262E .uci_begin_cmd\nal C:6550 .uci_status_force\nal C:654C .uci_status_len\nal C:653C .uci_status_buf\nal C:7DC4 .gen_mul_row\nal C:E6DA .done\nal C:E6AC .done\nal C:EDB2 .done\nal C:EF69 .done\nal C:F6BF .ok\nal C:F630 .bad\nal C:F32C .done\nal C:F857 .mlkem_decaps_seed\nal C:F633 .mlkem_keygen\n" + }, + "uci-comb-pq": { + "make": "BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 PQ_HYBRID=1", + "prg_sha256": "40b2825706b05ce05b32e0fd8785bec358559c1a8a4af5f30226b9e53e5ace86", + "labels": "al C:0004 .LIB_NISTCURVES_REU_BANKS_USED\nal C:BD42 .__COLD_FILE_LAST__\nal C:2000 .__COLD_FILE_SIZE__\nal C:A000 .__COLD_FILE_START__\nal C:C1AA .__COLD_IMAGE_LAST__\nal C:1000 .__COLD_IMAGE_SIZE__\nal C:C000 .__COLD_IMAGE_START__\nal C:4200 .__COLD_RUN_TRUST_LAST__\nal C:0800 .__COLD_RUN_TRUST_SIZE__\nal C:4200 .__COLD_RUN_TRUST_START__\nal C:43AA .__COLD_RUN_UI_LAST__\nal C:0800 .__COLD_RUN_UI_SIZE__\nal C:4200 .__COLD_RUN_UI_START__\nal C:0001 .__COLD_TAIL_UI_SIZE__\nal C:C000 .__CRYPTO_COLD_SHADOW_LAST__\nal C:2000 .__CRYPTO_COLD_SHADOW_SIZE__\nal C:A000 .__CRYPTO_COLD_SHADOW_START__\nal C:9A95 .__CRYPTO_HOT_LAST__\nal C:4000 .__CRYPTO_HOT_SIZE__\nal C:6000 .__CRYPTO_HOT_START__\nal C:5EBB .__CRYPTO_OVERLAY_LAST__\nal C:1E00 .__CRYPTO_OVERLAY_SIZE__\nal C:4200 .__CRYPTO_OVERLAY_START__\nal C:09F1 .__LIB_MLKEM_BSS_SIZE__\nal C:A000 .__LIB_MLKEM_CODE_LOAD__\nal C:19A0 .__LIB_MLKEM_CODE_SIZE__\nal C:BD41 .__LIB_MLKEM_MARK_LOAD__\nal C:0001 .__LIB_MLKEM_MARK_SIZE__\nal C:1000 .__LIB_MLKEM_POLY_BSS_SIZE__\nal C:0381 .__LIB_MLKEM_RODATA_SIZE__\nal C:0801 .__LOADADDR_LAST__\nal C:0002 .__LOADADDR_SIZE__\nal C:07FF .__LOADADDR_START__\nal C:1F51 .__LOADER_LAST__\nal C:17FF .__LOADER_SIZE__\nal C:0801 .__LOADER_START__\nal C:BAF1 .__MLKEM_BSS_RAM_LAST__\nal C:0B00 .__MLKEM_BSS_RAM_SIZE__\nal C:B100 .__MLKEM_BSS_RAM_START__\nal C:FD42 .__MLKEM_CODE_RAM_LAST__\nal C:1E00 .__MLKEM_CODE_RAM_SIZE__\nal C:E000 .__MLKEM_CODE_RAM_START__\nal C:E000 .__MLKEM_POLY_RAM_LAST__\nal C:1000 .__MLKEM_POLY_RAM_SIZE__\nal C:D000 .__MLKEM_POLY_RAM_START__\nal C:3CA1 .__NET_BSS_TAIL_LAST__\nal C:069A .__NET_BSS_TAIL_SIZE__\nal C:3B66 .__NET_BSS_TAIL_START__\nal C:39E9 .__NET_CODE_LAST__\nal C:1B66 .__NET_CODE_SIZE__\nal C:2000 .__NET_CODE_START__\nal C:4722 .__P256_SCRATCH_UNION_LAST__\nal C:0800 .__P256_SCRATCH_UNION_SIZE__\nal C:4200 .__P256_SCRATCH_UNION_START__\nal C:01A9 .__TARGET_PROMPT_CODE_SIZE__\nal C:4000 .__UCI_BSS_REGION_LAST__\nal C:0000 .__UCI_BSS_REGION_SIZE__\nal C:4000 .__UCI_BSS_REGION_START__\nal C:A220 .__X25519_SCRATCH_LAST__\nal C:0224 .__X25519_SCRATCH_SIZE__\nal C:A000 .__X25519_SCRATCH_START__\nal C:0022 .__ZP_CRYPTO_LAST__\nal C:001E .__ZP_CRYPTO_SIZE__\nal C:0022 .__ZP_CRYPTO_START__\nal C:0040 .__ZP_WIDE_LAST__\nal C:0040 .__ZP_WIDE_SIZE__\nal C:0040 .__ZP_WIDE_START__\nal C:4200 .cert_buf\nal C:0800 .cert_buf_size\nal C:1BEB .cold_call\nal C:1D3F .cold_err\nal C:96C4 .ec_precompute_256\nal C:AE40 .ecdsa_curve_id\nal C:AE81 .ecdsa_hash\nal C:4C73 .ecdsa_hash_len\nal C:AEA1 .ecdsa_pubkey_x\nal C:AEC1 .ecdsa_pubkey_y\nal C:4C74 .ecdsa_sig_len\nal C:AE41 .ecdsa_sig_r\nal C:AE61 .ecdsa_sig_s\nal C:6628 .ecdsa_verify\nal C:A85C .http_body_sink\nal C:A859 .http_body_total\nal C:0D10 .http_get\nal C:0FF5 .http_get_plain\nal C:A528 .http_host_len\nal C:A526 .http_host_ptr\nal C:5630 .http_host_target\nal C:A832 .http_parse_state\nal C:A52B .http_path_len\nal C:A529 .http_path_ptr\nal C:4C70 .http_port\nal C:A630 .http_resp_buf\nal C:A830 .http_resp_len\nal C:60FF .http_sink_full\nal C:A52C .http_status\nal C:F857 .mlkem_decaps_seed\nal C:F633 .mlkem_keygen\nal C:2227 .net_dhcp_acquire\nal C:2525 .net_dns_resolve\nal C:2000 .net_init\nal C:A227 .net_initialized\nal C:B082 .net_last_error\nal C:B07A .net_local_ip\nal C:2043 .net_poll\nal C:A249 .net_poll_entry_count\nal C:A24B .net_poll_return_count\nal C:2554 .net_recv_byte\nal C:B084 .net_send_len\nal C:24DD .net_tcp_close\nal C:22C4 .net_tcp_connect\nal C:239A .net_tcp_send\nal C:B083 .net_tcp_state\nal C:28CE .pq_mlkem_call\nal C:28F7 .pq_mlkem_keygen\nal C:0A31 .print_resp_body\nal C:AEF6 .reu_dma_timeout\nal C:080D .start\nal C:C000 .tcp_recv_buf\nal C:A244 .tcp_recv_head\nal C:A248 .tcp_recv_overflow\nal C:A246 .tcp_recv_tail\nal C:A862 .tls_app_len\nal C:A860 .tls_app_ptr\nal C:A3F8 .tls_app_read_iv\nal C:A3D8 .tls_app_read_key\nal C:A3CC .tls_app_write_iv\nal C:A3AC .tls_app_write_key\nal C:AF55 .tls_c_hs_secret\nal C:A254 .tls_client_random\nal C:A4C6 .tls_early_secret\nal C:A294 .tls_ecdhe_privkey\nal C:A2B4 .tls_ecdhe_pubkey\nal C:AFD5 .tls_finished_key\nal C:A4E6 .tls_handshake_secret\nal C:A3A0 .tls_hs_read_iv\nal C:A380 .tls_hs_read_key\nal C:A374 .tls_hs_write_iv\nal C:A354 .tls_hs_write_key\nal C:A24E .tls_last_state\nal C:A506 .tls_master_secret\nal C:A24F .tls_reached_connected\nal C:A40C .tls_read_seq\nal C:A000 .tls_rec_buf\nal C:A414 .tls_rec_header\nal C:A41A .tls_rec_len\nal C:A419 .tls_rec_type\nal C:1AB9 .tls_recv_count\nal C:A252 .tls_recv_poll_count\nal C:A250 .tls_recv_progress\nal C:1AB8 .tls_recv_state\nal C:A251 .tls_recv_sub_progress\nal C:A2D4 .tls_server_pubkey\nal C:A274 .tls_server_random\nal C:A2F4 .tls_shared_secret\nal C:A24D .tls_state\nal C:A314 .tls_transcript\nal C:AFB5 .tls_verify_data\nal C:A404 .tls_write_seq\nal C:262E .uci_begin_cmd\nal C:6103 .uci_host_buf\nal C:2658 .uci_push_wait\nal C:2643 .uci_put_byte\nal C:620F .uci_socket_id\nal C:6222 .uci_status_buf\nal C:6236 .uci_status_force\nal C:6232 .uci_status_len\nal C:0A6C .https_target_prompt\nal C:A227 .net_initialized\nal C:5630 .http_host_target\nal C:0A31 .print_resp_body\nal C:080D .start\nal C:AEC1 .ecdsa_pubkey_y\nal C:AEA1 .ecdsa_pubkey_x\nal C:AE61 .ecdsa_sig_s\nal C:AE41 .ecdsa_sig_r\nal C:AE81 .ecdsa_hash\nal C:AE40 .ecdsa_curve_id\nal C:A862 .tls_app_len\nal C:A860 .tls_app_ptr\nal C:A85C .http_body_sink\nal C:A859 .http_body_total\nal C:A832 .http_parse_state\nal C:A830 .http_resp_len\nal C:A630 .http_resp_buf\nal C:A52C .http_status\nal C:A52B .http_path_len\nal C:A529 .http_path_ptr\nal C:A528 .http_host_len\nal C:A526 .http_host_ptr\nal C:A506 .tls_master_secret\nal C:A4E6 .tls_handshake_secret\nal C:A4C6 .tls_early_secret\nal C:A000 .tls_rec_buf\nal C:A41A .tls_rec_len\nal C:A419 .tls_rec_type\nal C:A414 .tls_rec_header\nal C:A40C .tls_read_seq\nal C:A404 .tls_write_seq\nal C:A3F8 .tls_app_read_iv\nal C:A3D8 .tls_app_read_key\nal C:A3CC .tls_app_write_iv\nal C:A3AC .tls_app_write_key\nal C:A3A0 .tls_hs_read_iv\nal C:A380 .tls_hs_read_key\nal C:A374 .tls_hs_write_iv\nal C:A354 .tls_hs_write_key\nal C:A314 .tls_transcript\nal C:A2F4 .tls_shared_secret\nal C:A2D4 .tls_server_pubkey\nal C:A2B4 .tls_ecdhe_pubkey\nal C:A294 .tls_ecdhe_privkey\nal C:A274 .tls_server_random\nal C:A254 .tls_client_random\nal C:A252 .tls_recv_poll_count\nal C:A251 .tls_recv_sub_progress\nal C:A250 .tls_recv_progress\nal C:A24F .tls_reached_connected\nal C:A24E .tls_last_state\nal C:A24D .tls_state\nal C:A24B .net_poll_return_count\nal C:A249 .net_poll_entry_count\nal C:A248 .tcp_recv_overflow\nal C:A246 .tcp_recv_tail\nal C:A244 .tcp_recv_head\nal C:4C74 .ecdsa_sig_len\nal C:4C73 .ecdsa_hash_len\nal C:4C70 .http_port\nal C:4200 .cert_buf\nal C:60FF .http_sink_full\nal C:0FF5 .http_get_plain\nal C:0D10 .http_get\nal C:AEF6 .reu_dma_timeout\nal C:AF75 .tls_s_hs_secret\nal C:AF95 .tls_derived_tmp\nal C:AFD5 .tls_finished_key\nal C:AF55 .tls_c_hs_secret\nal C:AFB5 .tls_verify_data\nal C:1AB9 .tls_recv_count\nal C:1AB8 .tls_recv_state\nal C:6628 .ecdsa_verify\nal C:28CC .mlkem_rti\nal C:28F7 .pq_mlkem_keygen\nal C:28CE .pq_mlkem_call\nal C:621A .uci_read_hdr\nal C:621E .uci_req_len\nal C:620F .uci_socket_id\nal C:6103 .uci_host_buf\nal C:B084 .net_send_len\nal C:2554 .net_recv_byte\nal C:B083 .net_tcp_state\nal C:B082 .net_last_error\nal C:B07A .net_local_ip\nal C:2525 .net_dns_resolve\nal C:24DD .net_tcp_close\nal C:239A .net_tcp_send\nal C:22C4 .net_tcp_connect\nal C:2227 .net_dhcp_acquire\nal C:2043 .net_poll\nal C:2000 .net_init\nal C:2658 .uci_push_wait\nal C:2643 .uci_put_byte\nal C:262E .uci_begin_cmd\nal C:6236 .uci_status_force\nal C:6232 .uci_status_len\nal C:6222 .uci_status_buf\nal C:1D3F .cold_err\nal C:1BEB .cold_call\nal C:75E9 .gen_mul_row\nal C:96C4 .ec_precompute_256\nal C:E6DA .done\nal C:E6AC .done\nal C:EDB2 .done\nal C:EF69 .done\nal C:F6BF .ok\nal C:F630 .bad\nal C:F32C .done\nal C:F857 .mlkem_decaps_seed\nal C:F633 .mlkem_keygen\n" } } diff --git a/tools/test_mlkem_linked.py b/tools/test_mlkem_linked.py index 56f0ccb..eb106de 100644 --- a/tools/test_mlkem_linked.py +++ b/tools/test_mlkem_linked.py @@ -162,16 +162,37 @@ def set_args(transport, labels, **ptrs): bytes([addr & 255, addr >> 8])) +RES = 0x03B0 # +0 A, +1 P as the stub saw them after the call + + def call(transport, labels, entry): - """pq_mlkem_call with A/X = entry, through a page-3 stub.""" - a = labels[entry] - stub = bytes([0xA9, a & 255, 0xA2, a >> 8, 0x20, - labels["pq_mlkem_call"] & 255, labels["pq_mlkem_call"] >> 8, + """Call a named wrapper (pq_mlkem_keygen / pq_mlkem_decaps_seed) through + a page-3 stub that records A and P after the call.""" + w = labels["pq_" + entry] + stub = bytes([0x20, w & 255, w >> 8, # jsr wrapper + 0x8D, RES & 255, RES >> 8, # sta RES + 0x08, 0x68, # php / pla + 0x8D, (RES + 1) & 255, RES >> 8, # sta RES+1 0x60]) write_bytes(transport, STUB, stub) t0 = time.monotonic() - regs = jsr(transport, STUB, timeout=900.0) - return regs, time.monotonic() - t0 + jsr(transport, STUB, timeout=900.0) + a, p = read_bytes(transport, RES, 2) + return {"A": a, "P": p}, time.monotonic() - t0 + + +def iflag_checks(t, transport, labels): + """The caller's I flag comes back as it went in (mutant d: no php/plp).""" + print(" [3a] the I flag is restored") + e, c = labels["mlkem_arith_init"], labels["pq_mlkem_call"] + for name, op, want in (("clear (cli)", 0x58, 0), ("set (sei)", 0x78, 4)): + stub = bytes([op, 0xA9, e & 255, 0xA2, e >> 8, 0x20, c & 255, c >> 8, + 0x08, 0x68, 0x8D, RES & 255, RES >> 8, 0x60]) + write_bytes(transport, STUB, stub) + jsr(transport, STUB, timeout=60.0) + p = read_bytes(transport, RES, 1)[0] + t.check(p & 4 == want, f"I flag {name} on entry -> unchanged on return", + f"P=${p:02X}") def kat(t, transport, labels): @@ -189,11 +210,14 @@ def kat(t, transport, labels): want = prg_bytes_at(prg, 0xA000, 0x1900) jsr(transport, labels["mlkem_boot_init"], timeout=30.0) t.check(img() == want, "re-running mlkem_boot_init leaves the image intact") + # The PRG-data flag clobbered (BASIC after 'Q'): the stage is zero, so the + # tail marker is absent at $A000, but it IS at $E000: resident, flag set + # again, image untouched. write_bytes(transport, labels["mlkem_copied"], b"\x00") jsr(transport, labels["mlkem_boot_init"], timeout=30.0) - t.check(img() == want and read_bytes(transport, labels["mlkem_copied"], 1) == b"\x00", - "with the flag cleared but the stage zeroed it still does not copy") - write_bytes(transport, labels["mlkem_copied"], b"\x01") + t.check(img() == want and read_bytes(transport, labels["mlkem_copied"], 1) == b"\x01", + "flag cleared, stage gone: the marker at $E000 re-arms it, no copy") + iflag_checks(t, transport, labels) x25519_ok(t, transport, labels, "before") # rebuilds the ML-KEM window @@ -252,6 +276,37 @@ def boot_and_run(t, labels, tag, do_kat): return ok +def refusal_phase(t, labels): + """A LOAD cut short before the tail marker is REFUSED, not half-copied.""" + import tempfile + print("\n[2b] a PRG without the tail marker (a LOAD cut short)") + prg = bytearray(PRG.read_bytes()) + off = labels["__LIB_MLKEM_MARK_LOAD__"] - 0x0801 + 2 + assert prg[off] == 0x5C, "the marker is not where the labels say" + prg[off] = 0 + tmp = Path(tempfile.mkdtemp(prefix="mlkem_cut_")) / "cut.prg" + tmp.write_bytes(bytes(prg)) + config = default_vice_config(prg_path=str(tmp), warp=True, ntsc=True, sound=False) + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + tr = inst.transport + if wait_for_text(tr, "Q=QUIT", timeout=menu_wait(60), verbose=False) is None: + t.check(False, "the cut-short image still reached the menu") + mgr.release(inst) + return + t.check(read_bytes(tr, labels["mlkem_copied"], 1) == b"\x00", + "mlkem_copied stays 0 (no copy, no silent skip)") + t.check(read_bytes(tr, 0xD020, 1)[0] & 15 == 2, "the border is red") + want = prg_bytes_at(bytes(prg), labels["__LIB_MLKEM_CODE_LOAD__"], 64) + t.check(ram_read(tr, labels["__LIB_MLKEM_CODE_RUN__"], 64) != want, + "nothing was copied to $E000") + r, _ = call(tr, labels, "mlkem_keygen") + t.check(r["A"] == 0xFF and r["P"] & 1 == 1, + "pq_mlkem_keygen refuses: A=$FF, C=1, nothing ran", + f"A={r['A']:02X} P={r['P']:02X}") + mgr.release(inst) + + def mutant(t, label, path, old, new, expect, labels_tag): """Build with *old* -> *new* in *path*; return (rc, output).""" p = REPO / path @@ -276,6 +331,7 @@ def main(): labels = Labels.from_file(str(LABELS)) shape_checks(t, labels) ok = boot_and_run(t, labels, "green", True) + refusal_phase(t, labels) if "--no-mutants" not in sys.argv[1:]: print("\n[4] mutants") @@ -321,6 +377,31 @@ def main(): mgr.release(inst) t.check(t3.failed > 0, "mutant (c) is RED: KeyGen after X25519 is " f"wrong without arith_init ({t3.failed} check(s) failed)") + # (d) pq_mlkem_call without php/plp: the caller's I flag is not restored + p = REPO / "src/crypto/mlkem_link.s" + orig = p.read_text() + try: + m = orig.replace("@run: sta @go+1\n stx @go+2\n php\n", + "@run: sta @go+1\n stx @go+2\n nop\n", 1) + m = m.replace(" sta $01\n plp\n@ra:", " sta $01\n nop\n@ra:", 1) + assert m != orig + p.write_text(m) + rc, out = make() + finally: + p.write_text(orig) + if t.check(rc == 0, "mutant (d) no php/plp: still links", out[-300:]): + lab = Labels.from_file(str(LABELS)) + t4 = Tally() + config = default_vice_config(prg_path=str(PRG), warp=True, + ntsc=True, sound=False) + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + if wait_for_text(inst.transport, "Q=QUIT", + timeout=menu_wait(60), verbose=False): + iflag_checks(t4, inst.transport, lab) + mgr.release(inst) + t.check(t4.failed > 0, "mutant (d) is RED: the I flag is not " + f"restored ({t4.failed} check(s) failed)") make() # restore the green build print(f"\nRESULTS: {t.passed}/{t.passed + t.failed} passed, {t.failed} failed") diff --git a/tools/test_mlkem_placement.py b/tools/test_mlkem_placement.py index 8d1cf6a..429e604 100644 --- a/tools/test_mlkem_placement.py +++ b/tools/test_mlkem_placement.py @@ -37,6 +37,7 @@ REPO = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO / "tools")) +sys.path.insert(0, str(REPO / "tools" / "uci")) from _skip_policy import cannot_run, verdict # noqa: E402 from check_prg_io_range import prg_io_violation # noqa: E402 @@ -112,6 +113,39 @@ def unwrapped_callers(extra=None): return sorted(f for f, txt in files.items() if ENTRY_RE.search(txt)) +EXPORT_RE = re.compile(r"(\S+)\s+([0-9A-F]{6})\s+[RL][EL]?[A-Z]?(?:\s|$)") + + +def rom_shadowed_symbols(map_path): + """Names the linker puts in $E000-$FDFF (the ML-KEM run image), from the + map's Exports list - not a name regex, so a library addition is covered.""" + text = Path(map_path).read_text() + sec = text.split("Exports list by name:")[1].split("Imports list")[0] + out = {} + for name, addr in EXPORT_RE.findall(sec): + a = int(addr, 16) + if 0xE000 <= a <= 0xFDFF and not name.startswith(("LIB_", "__")): + out[name] = a + return out + + +def banned_references(symbols, extra=None): + """{file: [symbols]} for every source file except mlkem_link.s that names + a symbol in the run image: such a reference is an unbanked call into + KERNAL ROM (or skips mlkem_arith_init).""" + files = {str(p.relative_to(REPO)): p.read_text() + for p in (REPO / "src").rglob("*.s") + if p.name != "mlkem_link.s"} + files.update(extra or {}) + hits = {} + for f, txt in files.items(): + txt = "\n".join(ln.split(";")[0] for ln in txt.splitlines()) + found = sorted(s for s in symbols if re.search(rf"\b{re.escape(s)}\b", txt)) + if found: + hits[f] = found + return hits + + def run_make(args, extra_env=None, timeout=1800): env = dict(os.environ, **(extra_env or {})) r = subprocess.run(["make"] + args, cwd=REPO, capture_output=True, @@ -223,8 +257,30 @@ def layout_checks(t, tag): and segs["LIB_MLKEM_RODATA"][1] <= 0xFDFF, f"{tag}: code+rodata = $E000-${segs['LIB_MLKEM_RODATA'][1]:04X}, " f"inside $E000-$FDFF") + syms = rom_shadowed_symbols(REPO / "build" / "c64-https.map") + t.check(len(syms) >= 20 and "mlkem_keygen" in syms and "keccak_f1600" in syms, + f"{tag}: the map's export list has {len(syms)} symbols in $E000-$FDFF") + hits = banned_references(syms) + t.check(not hits, f"{tag}: no source outside mlkem_link.s names one of them", + str(hits)) + t.check(banned_references(syms, {"src/zz.s": " jsr mlkem_poly_ntt\n"}) == + {"src/zz.s": ["mlkem_poly_ntt"]}, + f"{tag}: red: a direct jsr into the image is reported") + t.check(set(syms) >= {"mlkem_keygen", "mlkem_decaps_seed", "mlkem_arith_init"}, + f"{tag}: red: the symbol set covers the entries, not a name regex") + allowed = banned_references(syms, {"src/zz.s": " jsr pq_mlkem_keygen\n jsr pq_mlkem_decaps_seed\n lda # None: def test_non_zero_bytes_outside_both_windows_still_refuse() -> None: - for addr in (0xD000, 0xFDFF): + for addr in (0xA000, 0xB000, 0xBFFF, 0xD000, 0xFDFF): try: pl.image_span(make_prg(tail_byte=0xEA, at=addr, window=COLD)) except pl.PrgLoadError as exc: @@ -174,17 +175,70 @@ def test_non_zero_bytes_outside_both_windows_still_refuse() -> None: raise AssertionError(f"a byte at ${addr:04X} would go unverified") -def test_the_staged_ml_kem_image_span_is_allowed_and_counted() -> None: - """$A000-$BFFF holds the ML-KEM load image under PQ_HYBRID: it cannot be - read back (BASIC ROM), so it is allowed, counted, and named in the report - (rig_mlkem_hw.py proves the copy). The bytes either side still refuse.""" - for addr in (0xA000, 0xBFFF): - prg = make_prg(tail_byte=0xEA, at=addr, window=COLD) - pl.image_span(prg) - assert pl.stage_bytes(prg) == addr - 0xA000 + 1, addr +PQ_LABELS = """al C:28B1 .pq_mlkem_call +al C:A000 .__LIB_MLKEM_CODE_LOAD__ +al C:BD40 .__LIB_MLKEM_MARK_LOAD__ +""" + + +def pq_labels(tmp: Path, text: str = PQ_LABELS) -> Path: + p = tmp / "labels.txt" + p.write_text(text) + return p + + +def test_the_staged_ml_kem_image_span_is_allowed_only_for_a_pq_build() -> None: + """The ML-KEM load image ([CODE load, tail marker + 1) in labels.txt) + cannot be read back (BASIC ROM), so it is allowed, counted and named in + the report (rig_mlkem_hw.py proves the copy), but ONLY on a build whose + labels link pq_mlkem_call, and ONLY that span.""" + import tempfile + with tempfile.TemporaryDirectory() as d: + stage = pl.stage_from_labels(pq_labels(Path(d))) + assert stage == (0xA000, 0xBD41), stage + for addr in (0xA000, 0xB123, 0xBD40): # the image: accepted + prg = make_prg(tail_byte=0xEA, at=addr, window=COLD) + pl.image_span(prg, stage) + assert pl.stage_bytes(prg, stage) == addr - 0xA000 + 1, addr + for addr in (0xBD41, 0xBFFF): # past its end: refused + try: + pl.image_span(make_prg(tail_byte=0xEA, at=addr, window=COLD), stage) + except pl.PrgLoadError as exc: + assert f"${addr:04X}" in str(exc), exc + else: + raise AssertionError(f"a byte at ${addr:04X}, past the image, was accepted") + # a knob-off build (no pq_mlkem_call, or no labels at all): refused + # at $A000 exactly as before + no_pq = pq_labels(Path(d), PQ_LABELS.replace("pq_mlkem_call", "other")) + assert pl.stage_from_labels(no_pq) is None + assert pl.stage_from_labels(Path(d) / "missing.txt") is None + for st in (None, pl.stage_from_labels(no_pq)): + try: + pl.image_span(make_prg(tail_byte=0xEA, at=0xA000, window=COLD), st) + except pl.PrgLoadError as exc: + assert "$A000" in str(exc), exc + else: + raise AssertionError("a knob-off image with a byte at $A000 was accepted") assert pl.stage_bytes(make_prg()) == 0 +def test_run_refuses_a_planted_byte_past_the_pq_image_and_accepts_the_image() -> None: + import tempfile + with tempfile.TemporaryDirectory() as d: + lab = pq_labels(Path(d)) + ok = FakeClient() + run(ok, make_prg(tail_byte=0xEA, at=0xA000, window=COLD), labels_path=lab) + assert ok.typed == ["SYS2061"], ok.typed + bad = FakeClient() + try: + run(bad, make_prg(tail_byte=0xEA, at=0xBE00, window=COLD), labels_path=lab) + except pl.PrgLoadError: + pass + else: + raise AssertionError("a byte past the image end was accepted") + assert bad.typed == [], "a run was started on an image known to be wrong" + + def test_a_zeroed_head_is_rewritten_then_verified() -> None: c = FakeClient(zero_head_once=True) run(c, make_prg()) diff --git a/tools/test_rig_scratch.py b/tools/test_rig_scratch.py index d914be2..5c37192 100644 --- a/tools/test_rig_scratch.py +++ b/tools/test_rig_scratch.py @@ -349,6 +349,9 @@ def test_no_rig_calls_the_page3_harness_writers() -> None: "uci-onchip": "BACKEND=uci USE_NISTCURVES_ONCHIP=1", "uci-comb": "BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1", "uci-comb-trust": "BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 TRUST_STORE=1", + # PQ_HYBRID links (rig_mlkem_hw.py runs only against these). + "uci-onchip-pq": "BACKEND=uci USE_NISTCURVES_ONCHIP=1 PQ_HYBRID=1", + "uci-comb-pq": "BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 PQ_HYBRID=1", "uci-comb-demo-trust": ( "BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 TRUST_STORE=1 " "HTTPS_HOST=en.wikipedia.org " @@ -363,6 +366,7 @@ def test_no_rig_calls_the_page3_harness_writers() -> None: REQUIRES = { "rig_trust_store": "NEEDED", # TRUST_STORE=1 "rig_https_wiki": "CONTRACT_LABELS", # HTTPS_BODY_TO_REU=1 + "rig_mlkem_hw": "NEEDED", # PQ_HYBRID=1 } #: The allocators that hand out a tail of a linked region — what broke @@ -495,7 +499,7 @@ def test_master_comb_tail_cannot_hold_the_old_allocations() -> None: old allocator handed out cannot hold the smallest old request (the bench's 320 B stub) on any comb profile.""" for prof, text in _profile_fixtures().items(): - if "comb" not in prof: + if "comb" not in prof or prof.endswith("-pq"): # -pq: not a master link continue with tempfile.TemporaryDirectory() as td: labels = Path(td) / "labels.txt" diff --git a/tools/uci/_prg_load.py b/tools/uci/_prg_load.py index 9421dd8..775cd5e 100644 --- a/tools/uci/_prg_load.py +++ b/tools/uci/_prg_load.py @@ -41,6 +41,7 @@ from __future__ import annotations +import re import sys import time from pathlib import Path @@ -57,14 +58,39 @@ #: (src/net/uci/cold_bank.s) carries its image here; no other image reaches #: it since #288 (they end at $BFFF), so the window compares nothing there. RAM_WINDOW = (0xC000, 0xD000) -#: The ML-KEM load image (PQ_HYBRID): LIB_MLKEM_CODE/RODATA LOAD at $A000, in -#: the file bytes under CRYPTO_COLD_SHADOW, and boot copies them to $E000 and -#: then zeroes the stage. A host read of $A000-$BFFF returns the BASIC ROM and -#: of $E000 the KERNAL, so this span cannot be compared before the run or -#: after it. Non-zero bytes here are allowed (and counted in the report, loudly); -#: tools/uci/rig_mlkem_hw.py parks the CPU with all RAM banked in and compares -#: the copy at $E000 to the PRG's bytes - that is where it is proven. -STAGE_RANGE = (0xA000, 0xC000) +#: The ML-KEM load image (PQ_HYBRID): LIB_MLKEM_CODE/RODATA/MARK LOAD at +#: $A000, in the file bytes under CRYPTO_COLD_SHADOW, and boot copies them to +#: $E000 and then zeroes the stage. A host read of $A000-$BFFF returns the +#: BASIC ROM and of $E000 the KERNAL, so the stage cannot be compared before +#: the run or after it. Exactly that span - [__LIB_MLKEM_CODE_LOAD__, tail +#: marker + 1) from the build's labels.txt, and only when the build links +#: pq_mlkem_call - may be non-zero (counted in the report, loudly); +#: tools/uci/rig_mlkem_hw.py parks the CPU with all RAM banked in and +#: compares the copy at $E000 to the PRG's bytes. Every other byte of the +#: tail is refused exactly as before, knob-off images included. +DEFAULT_LABELS = Path(__file__).resolve().parents[2] / "build" / "labels.txt" +_LABEL = re.compile(r"^al\s+C:([0-9A-Fa-f]+)\s+\.(\S+)\s*$") + + +def stage_from_labels(labels_path) -> "tuple[int, int] | None": + """-> (first, end_exclusive) of the ML-KEM load image, or None. + + None unless the labels name pq_mlkem_call (a PQ_HYBRID build) and the + image symbols: a knob-off build has no staged span, so nothing is allowed. + """ + try: + text = Path(labels_path).read_text() + except OSError: + return None + sym = {} + for line in text.splitlines(): + m = _LABEL.match(line) + if m: + sym[m.group(2)] = int(m.group(1), 16) + need = ("pq_mlkem_call", "__LIB_MLKEM_CODE_LOAD__", "__LIB_MLKEM_MARK_LOAD__") + if any(n not in sym for n in need): + return None + return sym["__LIB_MLKEM_CODE_LOAD__"], sym["__LIB_MLKEM_MARK_LOAD__"] + 1 #: The head-zeroing event lands 2-5 s after READY; re-check past it. HEAD_SETTLE_S = 7.0 #: Readback chunk; small enough that no firmware readmem cap is in play. @@ -82,10 +108,11 @@ def ram_window(load_addr: int, body: bytes): return lo - load_addr, max(0, hi - lo) -def image_span(prg: bytes): +def image_span(prg: bytes, stage=None): """-> (load_addr, body, verify_len). Raises PrgLoadError on a PRG whose unverifiable tail (past verify_len, outside RAM_WINDOW) holds anything - but zero fill.""" + but zero fill. *stage* = (first, end_exclusive) from stage_from_labels: + the one span allowed to be non-zero.""" if len(prg) < 3: raise PrgLoadError(f"PRG is {len(prg)} bytes; nothing to load") load_addr = prg[0] | (prg[1] << 8) @@ -95,9 +122,10 @@ def image_span(prg: bytes): tail = bytearray(body[verify_len:]) if w_len: tail[w_off - verify_len:w_off - verify_len + w_len] = bytes(w_len) - s_lo = max(STAGE_RANGE[0] - load_addr - verify_len, 0) - s_hi = max(STAGE_RANGE[1] - load_addr - verify_len, 0) - tail[s_lo:s_hi] = bytes(len(tail[s_lo:s_hi])) + if stage is not None: + s_lo = max(stage[0] - load_addr - verify_len, 0) + s_hi = max(stage[1] - load_addr - verify_len, 0) + tail[s_lo:s_hi] = bytes(len(tail[s_lo:s_hi])) if any(tail): first = verify_len + next(i for i, b in enumerate(tail) if b) raise PrgLoadError( @@ -108,12 +136,14 @@ def image_span(prg: bytes): return load_addr, body, verify_len -def stage_bytes(prg: bytes) -> int: - """Non-zero extent (up to the last non-zero byte) of the $A000-$BFFF tail.""" +def stage_bytes(prg: bytes, stage=None) -> int: + """Bytes of the allowed stage span that are non-zero (up to the last one).""" + if stage is None: + return 0 load_addr = prg[0] | (prg[1] << 8) body = prg[2:] - lo = STAGE_RANGE[0] - load_addr - hi = min(STAGE_RANGE[1] - load_addr, len(body)) + lo = stage[0] - load_addr + hi = min(stage[1] - load_addr, len(body)) seg = body[lo:hi] if lo >= 0 else b"" return len(seg.rstrip(b"\x00")) @@ -127,6 +157,7 @@ def read_back(read_mem, addr: int, length: int) -> bytes: def load_verified_and_run(client, prg: bytes, *, retries: int = 1, + labels_path=DEFAULT_LABELS, clock=time.monotonic, sleep=time.sleep, out=None) -> dict: """``load_prg`` -> verify $0801-$9FFF -> ``SYS``. -> a report dict. @@ -138,7 +169,8 @@ def load_verified_and_run(client, prg: bytes, *, retries: int = 1, entry = parse_basic_sys_address(prg) if entry is None: raise PrgLoadError("the PRG carries no BASIC SYS stub to start it by") - load_addr, body, verify_len = image_span(prg) + stage = stage_from_labels(labels_path) + load_addr, body, verify_len = image_span(prg, stage) w_off, w_len = ram_window(load_addr, body) head = body[:2] attempts = [] @@ -188,7 +220,7 @@ def load_verified_and_run(client, prg: bytes, *, retries: int = 1, f"({attempts}); not starting a run on an image known to be wrong") window = (f" and {report['verified_ram_window']} ({w_len} B)" if w_len else "") - staged = stage_bytes(prg) + staged = stage_bytes(prg, stage) report["unverified_stage_bytes"] = staged if staged: print(f"NOTE: {staged} B of the $A000-$BFFF tail are the staged ML-KEM " @@ -202,6 +234,6 @@ def load_verified_and_run(client, prg: bytes, *, retries: int = 1, return report -__all__ = ["HEAD_SETTLE_S", "PrgLoadError", "RAM_WINDOW", "STAGE_RANGE", - "VERIFY_END", "stage_bytes", +__all__ = ["HEAD_SETTLE_S", "PrgLoadError", "RAM_WINDOW", "DEFAULT_LABELS", + "VERIFY_END", "stage_bytes", "stage_from_labels", "image_span", "load_verified_and_run", "ram_window", "read_back"] diff --git a/tools/uci/rig_mlkem_hw.py b/tools/uci/rig_mlkem_hw.py index b1e1edc..ea09b9f 100644 --- a/tools/uci/rig_mlkem_hw.py +++ b/tools/uci/rig_mlkem_hw.py @@ -16,6 +16,9 @@ dk and K with the libs/mlkem reference (the one the library's ACVP runs are checked against). +Both phases run AFTER 'Q' (BASIC owns the machine), so no in-session call +(KeyGen between the menu and a connection) is proven here - that is P4's job. + Environment: U64_HOST (default 192.168.1.81), BOOT_TIMEOUT (default 60; set C64_INIT_WAIT-style larger values for the comb profile's boot precompute), C64_PRG. Exit 0 = all checks passed, 1 = a check failed, 2 = could not run. @@ -42,30 +45,48 @@ from _device_lock_helper import LockTimeoutConfigError, acquire_device_lock # noqa: E402 from _device_prep import DevicePrepError, prepare_device # noqa: E402 from _reu_preflight import ReuPreflightError, preflight_reu # noqa: E402 +from _memory_policy import TCP_RING_DEFAULT, _label_value, build_policy_and_low_ram_arbiter # noqa: E402 from _prg_load import PrgLoadError, load_verified_and_run # noqa: E402 from _skip_policy import cannot_run, verdict # noqa: E402 from boot_check import decode_screen, screen_text # noqa: E402 +#: Labels a build must carry for this rig to run (tools/test_rig_scratch.py). +NEEDED = ("pq_mlkem_keygen",) + HOST = os.environ.get("U64_HOST", "192.168.1.81") BOOT_TIMEOUT = float(os.environ.get("BOOT_TIMEOUT", os.environ.get("C64_INIT_WAIT", "60"))) PRG_PATH = Path(os.environ.get("C64_PRG", str(REPO / "build" / "c64-https.prg"))) TURBO_MHZ = int(os.environ.get("TURBO_MHZ", "48")) LABELS_PATH = Path(os.environ.get("C64_LABELS", str(REPO / "build" / "labels.txt"))) -STUB = 0x0340 -SENT = 0x03F0 # +0 A result, +1 sentinel $A5 -SEED = 0xCE00 # d (32) then z (32), inside the TCP ring -KEY = 0x0380 -EK_AT = 0x4200 # cert_buf -DK_AT = 0xC000 # the ring: where the real dk will live -CT_AT = 0xC960 +# Scratch comes from build/labels.txt and tools/uci/_memory_policy.py, never +# hardcoded: page 3 (arbiter) for the stub, sentinel, seed and key; cert_buf +# for ek; the TCP ring (the real dk's home) for dk and ct. Set by layout(). +STUB = SENT = SEED = KEY = EK_AT = DK_AT = CT_AT = None + + +def layout(labels): + global STUB, SENT, SEED, KEY, EK_AT, DK_AT, CT_AT + _, arb = build_policy_and_low_ram_arbiter(LABELS_PATH, PRG_PATH) + STUB = arb.alloc(24, name="stub") + SENT = arb.alloc(2, name="result+sentinel") + SEED = arb.alloc(64, name="d||z seed") + KEY = arb.alloc(32, name="shared key") + EK_AT = labels["cert_buf"] + assert labels["cert_buf_size"] >= 1184 + ring = _label_value(LABELS_PATH, "tcp_recv_buf", TCP_RING_DEFAULT) + DK_AT = ring # 2,400 B + CT_AT = ring + 2400 # 1,088 B: ends at ring + 3,488 < ring + 4 KB + + D = bytes(range(1, 33)) Z = bytes(range(101, 133)) M = bytes(range(201, 233)) -PARK = bytes([0x78, # sei - 0xA9, 0x34, 0x85, 0x01, # lda #$34 / sta $01 (all RAM) - 0x4C, (STUB + 5) & 255, STUB >> 8]) # jmp * (at STUB+5) +def park_stub(): + return bytes([0x78, # sei + 0xA9, 0x34, 0x85, 0x01, # lda #$34 / sta $01 (all RAM) + 0x4C, (STUB + 5) & 255, STUB >> 8]) # jmp * (at STUB+5) class Tally: @@ -114,8 +135,8 @@ def run_stub(transport, client, code, timeout): def call_stub(labels, entry): - e, c = labels[entry], labels["pq_mlkem_call"] - return bytes([0xA9, e & 255, 0xA2, e >> 8, 0x20, c & 255, c >> 8, # lda/ldx/jsr + c = labels["pq_" + entry] # pq_mlkem_keygen / _decaps_seed + return bytes([0x20, c & 255, c >> 8, # jsr wrapper 0x8D, SENT & 255, SENT >> 8, # sta SENT 0xA9, 0xA5, 0x8D, (SENT + 1) & 255, (SENT + 1) >> 8, # sentinel 0x60]) # rts: back to BASIC @@ -129,7 +150,7 @@ def set_args(transport, labels, **ptrs): def image_phase(t, client, transport, prg, labels): print("\n[1] the copied image, read with the CPU parked and all RAM banked in") boot_to_basic(client, transport, prg) - transport.write_memory(STUB, PARK) + transport.write_memory(STUB, park_stub()) send_text(transport, f"sys{STUB}\r") time.sleep(3.0) body = prg[2:] @@ -181,9 +202,10 @@ def main(): return cannot_run(f"{PRG_PATH} / {LABELS_PATH} missing: build PQ_HYBRID=1 first", certifies="the ML-KEM image on hardware") labels = Labels.from_file(str(LABELS_PATH)) - if labels.address("pq_mlkem_call") is None: + if labels.address("pq_mlkem_keygen") is None: return cannot_run("the build in build/ is not a PQ_HYBRID image", certifies="the ML-KEM image on hardware") + layout(labels) # before the lock, like every rig prg = PRG_PATH.read_bytes() print(f"PRG {PRG_PATH} sha256 " f"{__import__('hashlib').sha256(prg).hexdigest()}") From dfa6753fc34a08e655d7a6aad20e3055f7624301 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:19:24 -0500 Subject: [PATCH 6/7] PQ_HYBRID review round 2: bind labels to the PRG, scan .inc and __ symbols, comb refusal border, C=0 and sqtab-collision checks SF1 loader keeps the stage span only if the PRG carries the tail marker at the labels' address; boot_check, refetch and rig_mlkem_hw pass their own labels. SF2 entry ban scans *.inc and exempts nothing by prefix. SF3 boot.s re-asserts the red refusal border after the comb precompute (VICE-tested, red without the fix). SF4 success C=0 asserted, mutant (e). N5 marker address vs sqtab bytes (link assert + collision check). N7 vectors written first. N6 docs. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 +- docs/engineering-notes.md | 26 ++++++----- src/boot.s | 9 +++- src/crypto/mlkem_link.s | 25 ++++++---- tools/test_mlkem_linked.py | 85 +++++++++++++++++++++++++++++++--- tools/test_mlkem_placement.py | 41 ++++++++++++++-- tools/test_prg_load.py | 56 +++++++++++++++++++--- tools/uci/_prg_load.py | 25 +++++++++- tools/uci/boot_check.py | 2 +- tools/uci/rig_https_refetch.py | 2 +- tools/uci/rig_mlkem_hw.py | 2 +- 11 files changed, 235 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dc8c53a..ea30e25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -825,9 +825,9 @@ UCI (`cfg/c64-https-uci.cfg`, W1 hot/cold split — the reference): PQ_HYBRID=1 adds three `file=""` areas to the uci cfgs (`cfg/c64-https-uci.cfg` serves uci-onchip; `-onchip-cold.cfg` comb; `-onchip.cfg` comb COLD_BANK=0): -`MLKEM_CODE_RAM` $E000-$FDFF (code+rodata, $E000-$FD40 = 7,489 B of 7,680; KERNAL out), +`MLKEM_CODE_RAM` $E000-$FDFF (code+rodata; KERNAL out), `MLKEM_POLY_RAM` $D000-$DFFF (4,096 B polys), `MLKEM_BSS_RAM` $B100-$BBFF -(2,545 B of 2,816, over X25519_TABLES + the $B900 hole + mul_dma). Rules: +(over X25519_TABLES + the $B900 hole + mul_dma). Rules: ML-KEM never interleaves with X25519 or fp_mul, and **every ML-KEM call group needs `mlkem_arith_init` first** (its 1 KB of mod-q tables live in that window, which X25519 rebuilds over): the wrappers do both, and diff --git a/docs/engineering-notes.md b/docs/engineering-notes.md index 8a956ed..89ca9d1 100644 --- a/docs/engineering-notes.md +++ b/docs/engineering-notes.md @@ -4112,10 +4112,8 @@ rig still defaults to 1 MHz for that reason. ## PQ_HYBRID P0: placing libs/mlkem (2026-10-16) Link-only. `PQ_HYBRID=1` links c64-mlkem v0.6.0 (ABI 3) and proves where it -lives; nothing calls it. Measured on the uci-onchip link: code+rodata -`$E000-$FD40` (7,489 B with alignment fill, 191 B spare in the 7,680 B -window), `LIB_MLKEM_BSS` `$B100-$BAF0` (2,545 B, 271 B spare before the -sqtab at `$BC00`), `LIB_MLKEM_POLY_BSS` `$D000-$DFFF` (exactly 4,096 B). +lives; nothing calls it. Measured sizes live in `tools/measure_margins.py` (the PQ profiles), not +here. Findings that changed the plan: @@ -4133,11 +4131,17 @@ Findings that changed the plan: returning A=$FF/C=1, not half-copied) runs right after `sei` in boot.s, before the zeroing that would erase its source; an assembler assert pins that order. 'Q' leaves `$E000` and the - RAM vectors alone (not secret). Re-entry: the copy runs only when a - PRG-data flag is clear AND `$A000` is nonzero, since the stage is zero - after the first boot and `tls_rec_buf` is scrubbed to zero on 'Q'. + RAM vectors alone (not secret). Re-entry: the copy runs only if the PRG + data flag is clear AND the tail marker is at the load address (the stage + is zero after the first boot, and after 'Q' the marker address holds live + sqtab bytes, so `test_mlkem_placement.py` checks the marker value never + equals the table byte there). Known limit: a "resident" marker at `$E000` + cannot tell which build left the image. The NMI vectors in `$FFFA-$FFFF` (RAM with the KERNAL out) point at one - RTI in NET_CODE; IRQ/BRK point there too, moot under `sei`. + RTI in NET_CODE, written first on every path of `mlkem_boot_init`. IRQ/BRK + point there too, which is only safe because every call runs under `sei` + (an RTI does not acknowledge CIA1's level-triggered IRQ; the `sei` is + what prevents the livelock). - **`mlkem_arith_init` builds 1 KB of reduction tables inside `LIB_MLKEM_BSS`.** That window is X25519_TABLES, which X25519 rebuilds per scalar mult, so every ML-KEM call group needs `mlkem_arith_init` @@ -4160,6 +4164,6 @@ Findings that changed the plan: - **NMI and IRQ.** With the KERNAL out (`$01=$34`) the hardware vectors are RAM. `mlkem_boot_init` points all three at one RTI. That is right for NMI (the RESTORE key is edge-triggered, one event). For IRQ the `sei` in - `pq_mlkem_call` is load-bearing: CIA1's IRQ is level-triggered and, with - I/O banked out, nothing can acknowledge it, so an IRQ reaching the RTI - would re-enter at once and livelock the call. + `pq_mlkem_call` is load-bearing: CIA1's IRQ is level-triggered and an RTI + stub acknowledges nothing (I/O in or out), so an IRQ reaching it would + re-enter at once and livelock the call. The `sei` prevents that. diff --git a/src/boot.s b/src/boot.s index 43c94b8..7f5d1de 100644 --- a/src/boot.s +++ b/src/boot.s @@ -144,7 +144,7 @@ SINK_PROBE = 1 .import drbg_init_entropy .import sqtab_init .ifdef PQ_HYBRID - .import mlkem_boot_init + .import mlkem_boot_init, mlkem_copied .endif .import crypto_init @@ -409,6 +409,13 @@ boot_zbss_first: jsr vic_unblank lda #BORDER_DEFAULT sta border_color +.ifdef PQ_HYBRID + lda mlkem_copied ; the refusal's red border (mlkem_boot_init) + bne @pq_ok ; was just overwritten above: re-assert it + lda #2 + sta border_color +@pq_ok: +.endif .endif .endif ; BACKEND_UCI_M3 diff --git a/src/crypto/mlkem_link.s b/src/crypto/mlkem_link.s index 2e78157..8cefd00 100644 --- a/src/crypto/mlkem_link.s +++ b/src/crypto/mlkem_link.s @@ -62,9 +62,10 @@ ; NMI, RESET and IRQ/BRK at one RTI (in NET_CODE, always RAM), so a ; RESTORE-key NMI (edge-triggered, one event) returns instead of jumping ; through garbage. The sei in pq_mlkem_call is LOAD-BEARING for IRQ: CIA1's -; IRQ line is level-triggered and, with I/O banked out, nothing can -; acknowledge it, so an IRQ that reached the RTI would re-enter at once and -; livelock the call. No BRK is executed; its vector shares the RTI. +; IRQ line is level-triggered and an RTI stub acknowledges nothing (with I/O +; in or out), so an IRQ that reached the RTI would re-enter at once and +; livelock the call. The sei is what prevents that. No BRK is executed; its +; vector shares the RTI. ; ; ZERO PAGE (tools/integration/build_mlkem.sh has the table and reasoning): ; $40-$4F, inside ZP_WIDE; the only co-tenant is the X25519 sibling's @@ -154,6 +155,13 @@ MLKEM_LOAD_BASE = $A000 .assert __LIB_MLKEM_RODATA_LOAD__ + __LIB_MLKEM_RODATA_SIZE__ <= MLKEM_LOAD_BASE + __MLKEM_CODE_RAM_SIZE__, lderror, "the ML-KEM load image is longer than the block boot copies" .assert __LIB_MLKEM_RODATA_LOAD__ + __LIB_MLKEM_RODATA_SIZE__ <= __COLD_FILE_START__ + __COLD_FILE_SIZE__, lderror, "the ML-KEM load image runs past the PRG's file content" .assert __LIB_MLKEM_RODATA_RUN__ + __LIB_MLKEM_RODATA_SIZE__ <= __MLKEM_CODE_RAM_START__ + __MLKEM_CODE_RAM_SIZE__, lderror, "the ML-KEM run image overflows MLKEM_CODE_RAM" +; After boot the marker's LOAD address holds live sqtab bytes (the window +; $BC00-$BFFF), not the marker, unless the table happens to have the same +; byte there: mlkem_boot_init case 2 would then re-copy stale RAM over a good +; $E000 image. Keep the marker inside the table's lo/hi planes, where +; tools/test_mlkem_placement.py computes the byte (n*n/4) and fails on a +; collision. Image growth/shrink that moves it out of the planes fails here. +.assert (__LIB_MLKEM_MARK_LOAD__ >= sqtab_lo) .and (__LIB_MLKEM_MARK_LOAD__ < sqtab_lo + $0400), lderror, "the tail marker's load address left the sqtab window: re-derive the collision check in tools/test_mlkem_placement.py" .assert __LIB_MLKEM_MARK_SIZE__ = 1, lderror, "the tail marker is not one byte" .assert __LIB_MLKEM_MARK_LOAD__ = __LIB_MLKEM_RODATA_LOAD__ + __LIB_MLKEM_RODATA_SIZE__, lderror, "the tail marker is not the last byte of the load image" .assert __LIB_MLKEM_MARK_LOAD__ - __LIB_MLKEM_CODE_LOAD__ = __LIB_MLKEM_MARK_RUN__ - __LIB_MLKEM_CODE_RUN__, lderror, "the tail marker's load and run offsets differ" @@ -261,6 +269,7 @@ SQTAB_GUARD "COLD_RUN_TRUST", __COLD_RUN_TRUST_START__, __COLD_RUN_TRUST ; ============================================================================= ; Code (NET_CODE via LOADER_OVERFLOW; always RAM, so the SMC below is legal) ; ============================================================================= +.export mlkem_copied .export mlkem_boot_init, pq_mlkem_call, pq_mlkem_keygen, pq_mlkem_decaps_seed .segment "LOADER_OVERFLOW" @@ -286,6 +295,11 @@ SQTAB_GUARD "COLD_RUN_TRUST", __COLD_RUN_TRUST_START__, __COLD_RUN_TRUST ; (session_scrub has no span there). MLKEM_MARK = $5C mlkem_boot_init: + ldy #5 ; the vectors first, on every path: every later +@vec: lda mlkem_vec_tab,y ; $01=$34 window (this routine's, the scrub's, + sta $FFFA,y ; pq_mlkem_call's) then has a valid RTI target + dey + bpl @vec lda mlkem_copied bne @done lda __LIB_MLKEM_MARK_LOAD__ @@ -306,11 +320,6 @@ mlkem_boot_init: inc @st+2 dex bne @pg - ldy #5 -@vec: lda mlkem_vec_tab,y - sta $FFFA,y - dey - bpl @vec inc mlkem_copied @done: rts @resident: diff --git a/tools/test_mlkem_linked.py b/tools/test_mlkem_linked.py index eb106de..45a4d11 100644 --- a/tools/test_mlkem_linked.py +++ b/tools/test_mlkem_linked.py @@ -18,10 +18,14 @@ page + the shared sqtab + the "arith_init before every call group" rule, all at once. (The reference is libs/mlkem/tools/mlkem_ref.py, the one the library's ACVP runs are checked against.) + [2b] a PRG whose tail marker is zeroed (a LOAD cut short) is REFUSED, on + uci-onchip here and, with --comb, on the comb image (the precompute's + border restore must not erase the red border). [4] mutants, each must go red: (a) boot.s without the copy call (image comparison fails), (b) the copy call moved after the zeroing (the assembler assert fires), (c) the call wrapper without mlkem_arith_init - (KeyGen after X25519 is wrong). + (KeyGen after X25519 is wrong), (d) without php/plp (the caller's I flag + is lost), (e) without the clc (success returns C=1). The onchip image needs no REU: set C64_VICE_NO_REU=1 to prove it (the default config adds -reu). ~6 min (X25519 x2 + KeyGen + Decaps under warp). @@ -81,9 +85,9 @@ def check(self, ok, what, detail=""): return ok -def make(extra=()): +def make(extra=(), args=None): subprocess.run(["make", "clean"], cwd=REPO, capture_output=True) - r = subprocess.run(["make"] + MAKE_ARGS + list(extra), cwd=REPO, + r = subprocess.run(["make"] + (args or MAKE_ARGS) + list(extra), cwd=REPO, capture_output=True, text=True) return r.returncode, r.stdout + r.stderr @@ -186,13 +190,15 @@ def iflag_checks(t, transport, labels): print(" [3a] the I flag is restored") e, c = labels["mlkem_arith_init"], labels["pq_mlkem_call"] for name, op, want in (("clear (cli)", 0x58, 0), ("set (sei)", 0x78, 4)): - stub = bytes([op, 0xA9, e & 255, 0xA2, e >> 8, 0x20, c & 255, c >> 8, + stub = bytes([0x38, op, 0xA9, e & 255, 0xA2, e >> 8, 0x20, c & 255, c >> 8, 0x08, 0x68, 0x8D, RES & 255, RES >> 8, 0x60]) write_bytes(transport, STUB, stub) jsr(transport, STUB, timeout=60.0) p = read_bytes(transport, RES, 1)[0] t.check(p & 4 == want, f"I flag {name} on entry -> unchanged on return", f"P=${p:02X}") + t.check(p & 1 == 0, f"success returns C=0 (I flag {name}; C was set on entry)", + f"P=${p:02X}") def kat(t, transport, labels): @@ -228,8 +234,9 @@ def kat(t, transport, labels): regs, dt = call(transport, labels, "mlkem_keygen") ek = read_bytes(transport, EK_AT, 1184) dk = read_bytes(transport, DK_AT, 2400) - t.check(regs["A"] == 0, f"mlkem_keygen returned A=0 ({dt:.0f} s)", - f"A={regs['A']:02X}") + t.check(regs["A"] == 0 and regs["P"] & 1 == 0, + f"mlkem_keygen returned A=0, C=0 ({dt:.0f} s)", + f"A={regs['A']:02X} P={regs['P']:02X}") t.check(ek == ek_ref, "mlkem_keygen ek == the reference's", f"first diff at +{next((i for i in range(1184) if ek[i] != ek_ref[i]), -1)}") t.check(dk == dk_ref, "mlkem_keygen dk == the reference's", @@ -307,6 +314,46 @@ def refusal_phase(t, labels): mgr.release(inst) +def comb_refusal(t): + """SF3: on the comb image the boot's ec_precompute block restores the + border AFTER mlkem_boot_init set it red; boot.s re-asserts it. Needs the + REU, and the precompute runs in VICE: slow, so opt-in with --comb.""" + import tempfile + print("\n[2c] comb PQ image, marker zeroed: the red border survives the precompute") + rc, out = make(args=["BACKEND=uci", "USE_NISTCURVES_ONCHIP_COMB=1", "PQ_HYBRID=1"]) + if not t.check(rc == 0, "the comb PQ image links", out[-300:]): + return + lab = Labels.from_file(str(LABELS)) + prg = bytearray(PRG.read_bytes()) + off = lab["__LIB_MLKEM_MARK_LOAD__"] - 0x0801 + 2 + assert prg[off] == 0x5C + prg[off] = 0 + tmp = Path(tempfile.mkdtemp(prefix="mlkem_comb_cut_")) / "cut.prg" + tmp.write_bytes(bytes(prg)) + saved = os.environ.pop("C64_VICE_NO_REU", None) # comb needs -reu + try: + config = default_vice_config(prg_path=str(tmp), warp=True, ntsc=True, sound=False) + t0 = time.monotonic() + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + tr = inst.transport + if not t.check(wait_for_text(tr, "Q=QUIT", timeout=2400, verbose=False) + is not None, "the comb image reached the menu " + f"({time.monotonic() - t0:.0f} s)"): + mgr.release(inst) + return + t.check(read_bytes(tr, lab["mlkem_copied"], 1) == b"\x00", + "mlkem_copied stays 0") + border = read_bytes(tr, 0xD020, 1)[0] & 15 + t.check(border == 2, "the border is red after the precompute", + f"$D020={border:X}") + mgr.release(inst) + finally: + if saved is not None: + os.environ["C64_VICE_NO_REU"] = saved + make() # back to the onchip build + + def mutant(t, label, path, old, new, expect, labels_tag): """Build with *old* -> *new* in *path*; return (rc, output).""" p = REPO / path @@ -332,6 +379,9 @@ def main(): shape_checks(t, labels) ok = boot_and_run(t, labels, "green", True) refusal_phase(t, labels) + if "--comb" in sys.argv[1:]: + comb_refusal(t) + labels = Labels.from_file(str(LABELS)) if "--no-mutants" not in sys.argv[1:]: print("\n[4] mutants") @@ -402,6 +452,29 @@ def main(): mgr.release(inst) t.check(t4.failed > 0, "mutant (d) is RED: the I flag is not " f"restored ({t4.failed} check(s) failed)") + # (e) pq_mlkem_call without the clc: success no longer returns C=0 + p = REPO / "src/crypto/mlkem_link.s" + orig = p.read_text() + try: + m = orig.replace("@ra: lda #0\n clc\n", "@ra: lda #0\n nop\n", 1) + assert m != orig + p.write_text(m) + rc, out = make() + finally: + p.write_text(orig) + if t.check(rc == 0, "mutant (e) no clc: still links", out[-300:]): + lab = Labels.from_file(str(LABELS)) + t5 = Tally() + config = default_vice_config(prg_path=str(PRG), warp=True, + ntsc=True, sound=False) + with ViceInstanceManager(config=config) as mgr: + inst = mgr.acquire() + if wait_for_text(inst.transport, "Q=QUIT", + timeout=menu_wait(60), verbose=False): + iflag_checks(t5, inst.transport, lab) + mgr.release(inst) + t.check(t5.failed > 0, "mutant (e) is RED: success returns C=1 " + f"({t5.failed} check(s) failed)") make() # restore the green build print(f"\nRESULTS: {t.passed}/{t.passed + t.failed} passed, {t.failed} failed") diff --git a/tools/test_mlkem_placement.py b/tools/test_mlkem_placement.py index 429e604..36dd484 100644 --- a/tools/test_mlkem_placement.py +++ b/tools/test_mlkem_placement.py @@ -118,13 +118,16 @@ def unwrapped_callers(extra=None): def rom_shadowed_symbols(map_path): """Names the linker puts in $E000-$FDFF (the ML-KEM run image), from the - map's Exports list - not a name regex, so a library addition is covered.""" + map's Exports list - not a name regex, so a library addition is covered. + Nothing is exempt by prefix: __LIB_MLKEM_CODE_RUN__ is a way in too. + + Known gap, accepted: a literal address (`jsr $E123`) names no symbol.""" text = Path(map_path).read_text() sec = text.split("Exports list by name:")[1].split("Imports list")[0] out = {} for name, addr in EXPORT_RE.findall(sec): a = int(addr, 16) - if 0xE000 <= a <= 0xFDFF and not name.startswith(("LIB_", "__")): + if 0xE000 <= a <= 0xFDFF: out[name] = a return out @@ -134,7 +137,7 @@ def banned_references(symbols, extra=None): a symbol in the run image: such a reference is an unbanked call into KERNAL ROM (or skips mlkem_arith_init).""" files = {str(p.relative_to(REPO)): p.read_text() - for p in (REPO / "src").rglob("*.s") + for pat in ("*.s", "*.inc") for p in (REPO / "src").rglob(pat) if p.name != "mlkem_link.s"} files.update(extra or {}) hits = {} @@ -146,6 +149,25 @@ def banned_references(symbols, extra=None): return hits +SQTAB_BASE = 0xBC00 + + +def sqtab_byte_at(addr, base=SQTAB_BASE): + """The byte c64-https's boot leaves at *addr* inside the sqtab window + (lo plane base..base+511 = floor(n*n/4) & $FF, hi plane = >> 8).""" + n = addr - base + if 0 <= n < 512: + return ((n * n) // 4) & 0xFF + if 512 <= n < 1024: + m = n - 512 + return ((m * m) // 4) >> 8 + raise ValueError(f"${addr:04X} is outside the sqtab window") + + +def marker_collides(addr, value=0x5C): + return sqtab_byte_at(addr) == value + + def run_make(args, extra_env=None, timeout=1800): env = dict(os.environ, **(extra_env or {})) r = subprocess.run(["make"] + args, cwd=REPO, capture_output=True, @@ -266,11 +288,24 @@ def layout_checks(t, tag): t.check(banned_references(syms, {"src/zz.s": " jsr mlkem_poly_ntt\n"}) == {"src/zz.s": ["mlkem_poly_ntt"]}, f"{tag}: red: a direct jsr into the image is reported") + t.check("src/zz.inc" in banned_references(syms, {"src/zz.inc": ".import mlkem_poly_ntt\n"}), + f"{tag}: red: a reference in an .inc is reported") + t.check("src/zz.s" in banned_references(syms, {"src/zz.s": " jsr __LIB_MLKEM_CODE_RUN__\n"}), + f"{tag}: red: a __LIB_MLKEM_* run-address reference is reported") t.check(set(syms) >= {"mlkem_keygen", "mlkem_decaps_seed", "mlkem_arith_init"}, f"{tag}: red: the symbol set covers the entries, not a name regex") allowed = banned_references(syms, {"src/zz.s": " jsr pq_mlkem_keygen\n jsr pq_mlkem_decaps_seed\n lda # Path: return p +def with_bytes(prg: bytes, **at) -> bytes: + """prg with bytes planted at absolute addresses: with_bytes(p, A000=0xEA).""" + out = bytearray(prg) + for addr, val in at.items(): + out[int(addr, 16) - 0x0801 + 2] = val + return bytes(out) + + +def pq_prg(extra: dict | None = None) -> bytes: + """A PQ-shaped image: first image byte at $A000, tail marker at $BD40.""" + return with_bytes(make_prg(window=COLD), A000=0xA9, BD40=pl.MLKEM_MARK, + **(extra or {})) + + def test_the_staged_ml_kem_image_span_is_allowed_only_for_a_pq_build() -> None: """The ML-KEM load image ([CODE load, tail marker + 1) in labels.txt) cannot be read back (BASIC ROM), so it is allowed, counted and named in @@ -207,8 +221,6 @@ def test_the_staged_ml_kem_image_span_is_allowed_only_for_a_pq_build() -> None: assert f"${addr:04X}" in str(exc), exc else: raise AssertionError(f"a byte at ${addr:04X}, past the image, was accepted") - # a knob-off build (no pq_mlkem_call, or no labels at all): refused - # at $A000 exactly as before no_pq = pq_labels(Path(d), PQ_LABELS.replace("pq_mlkem_call", "other")) assert pl.stage_from_labels(no_pq) is None assert pl.stage_from_labels(Path(d) / "missing.txt") is None @@ -222,21 +234,53 @@ def test_the_staged_ml_kem_image_span_is_allowed_only_for_a_pq_build() -> None: assert pl.stage_bytes(make_prg()) == 0 -def test_run_refuses_a_planted_byte_past_the_pq_image_and_accepts_the_image() -> None: +def test_labels_are_bound_to_the_prg_they_describe() -> None: + """SF1: labels.txt is a separate file. The PRG must carry the tail marker + at the labels' marker address, or no span is allowed at all.""" import tempfile with tempfile.TemporaryDirectory() as d: lab = pq_labels(Path(d)) + # green: a PQ image + its own labels ok = FakeClient() - run(ok, make_prg(tail_byte=0xEA, at=0xA000, window=COLD), labels_path=lab) + run(ok, pq_prg(), labels_path=lab) assert ok.typed == ["SYS2061"], ok.typed + # red 1: knob-off image with a planted byte + PQ labels (no marker) + for planted in ("A000", "B000", "BFFF"): + bad = FakeClient() + try: + run(bad, with_bytes(make_prg(window=COLD), **{planted: 0xEA}), labels_path=lab) + except pl.PrgLoadError: + pass + else: + raise AssertionError(f"knob-off image with ${planted} + PQ labels was accepted") + assert bad.typed == [] + # red 2: labels name a marker address this PRG does not carry + other = pq_labels(Path(d), PQ_LABELS.replace("BD40", "BC00")) bad = FakeClient() try: - run(bad, make_prg(tail_byte=0xEA, at=0xBE00, window=COLD), labels_path=lab) + run(bad, pq_prg(), labels_path=other) + except pl.PrgLoadError: + pass + else: + raise AssertionError("labels for another image bound to this PRG") + # red 3: a PQ image with knob-off labels is refused (the false red the + # rigs avoid by passing the labels that belong to their PRG) + nolab = FakeClient() + try: + run(nolab, pq_prg()) + except pl.PrgLoadError: + pass + else: + raise AssertionError("a staged image was accepted with no labels") + # red 4: a byte just past the image end, marker present + past = FakeClient() + try: + run(past, pq_prg({"BD41": 0xEA}), labels_path=lab) except pl.PrgLoadError: pass else: raise AssertionError("a byte past the image end was accepted") - assert bad.typed == [], "a run was started on an image known to be wrong" + assert past.typed == [] def test_a_zeroed_head_is_rewritten_then_verified() -> None: diff --git a/tools/uci/_prg_load.py b/tools/uci/_prg_load.py index 775cd5e..cd63896 100644 --- a/tools/uci/_prg_load.py +++ b/tools/uci/_prg_load.py @@ -136,6 +136,27 @@ def image_span(prg: bytes, stage=None): return load_addr, body, verify_len +#: LIB_MLKEM_MARK's value (src/crypto/mlkem_link.s MLKEM_MARK). +MLKEM_MARK = 0x5C + + +def bind_stage(prg: bytes, stage): + """Keep *stage* only if THIS PRG is the build the labels describe. + + labels.txt is a separate file: a stale or foreign one must not widen what + a PRG may hide in $A000-$BFFF. The PRG itself must carry the tail marker + at the labels' marker address (the last byte of the image); otherwise no + span is allowed and any non-zero byte there is refused as on a knob-off + image.""" + if stage is None or len(prg) < 3: + return None + off = stage[1] - 1 - (prg[0] | (prg[1] << 8)) + body = prg[2:] + if 0 <= off < len(body) and body[off] == MLKEM_MARK: + return stage + return None + + def stage_bytes(prg: bytes, stage=None) -> int: """Bytes of the allowed stage span that are non-zero (up to the last one).""" if stage is None: @@ -169,7 +190,7 @@ def load_verified_and_run(client, prg: bytes, *, retries: int = 1, entry = parse_basic_sys_address(prg) if entry is None: raise PrgLoadError("the PRG carries no BASIC SYS stub to start it by") - stage = stage_from_labels(labels_path) + stage = bind_stage(prg, stage_from_labels(labels_path)) load_addr, body, verify_len = image_span(prg, stage) w_off, w_len = ram_window(load_addr, body) head = body[:2] @@ -235,5 +256,5 @@ def load_verified_and_run(client, prg: bytes, *, retries: int = 1, __all__ = ["HEAD_SETTLE_S", "PrgLoadError", "RAM_WINDOW", "DEFAULT_LABELS", - "VERIFY_END", "stage_bytes", "stage_from_labels", + "VERIFY_END", "bind_stage", "stage_bytes", "stage_from_labels", "image_span", "load_verified_and_run", "ram_window", "read_back"] diff --git a/tools/uci/boot_check.py b/tools/uci/boot_check.py index 86fd3c9..c899084 100644 --- a/tools/uci/boot_check.py +++ b/tools/uci/boot_check.py @@ -307,7 +307,7 @@ def main() -> int: print("load_prg(PRG), verify, SYS (#199)...") try: - load_verified_and_run(client, prg) + load_verified_and_run(client, prg, labels_path=PRG_PATH.parent / "labels.txt") except PrgLoadError as exc: print(f"[fatal] {exc}", file=sys.stderr) return 4 diff --git a/tools/uci/rig_https_refetch.py b/tools/uci/rig_https_refetch.py index 97be7ea..8bf2d3d 100644 --- a/tools/uci/rig_https_refetch.py +++ b/tools/uci/rig_https_refetch.py @@ -197,7 +197,7 @@ def main() -> int: client.reset() time.sleep(2.5) try: - load_verified_and_run(client, prg) + load_verified_and_run(client, prg, labels_path=LABELS_PATH) except PrgLoadError as exc: print(f"[fatal] {exc}", file=sys.stderr) return 4 diff --git a/tools/uci/rig_mlkem_hw.py b/tools/uci/rig_mlkem_hw.py index ea09b9f..c45599c 100644 --- a/tools/uci/rig_mlkem_hw.py +++ b/tools/uci/rig_mlkem_hw.py @@ -106,7 +106,7 @@ def check(self, ok, what, detail=""): def boot_to_basic(client, transport, prg): client.reset() time.sleep(2.5) - load_verified_and_run(client, prg) + load_verified_and_run(client, prg, labels_path=LABELS_PATH) deadline = time.monotonic() + BOOT_TIMEOUT while time.monotonic() < deadline: if "Q=QUIT" in screen_text(decode_screen(bytes(client.read_mem(0x0400, 1000)))): From bea1e6828c49b40018b7dadbb0e9abd16fc21867 Mon Sep 17 00:00:00 2001 From: JC-000 <3798556+JC-000@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:50:59 -0500 Subject: [PATCH 7/7] PQ_HYBRID review round 3: marker address from labels, Imports-list entry check, m3 labels, docstring Co-Authored-By: Claude Opus 5.5 (1M context) --- tools/test_mlkem_linked.py | 5 +-- tools/test_mlkem_placement.py | 65 +++++++++++++++++++++++++++++++++-- tools/test_prg_load.py | 16 ++++----- tools/uci/rig_https_m3.py | 3 +- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/tools/test_mlkem_linked.py b/tools/test_mlkem_linked.py index 45a4d11..879cfb0 100644 --- a/tools/test_mlkem_linked.py +++ b/tools/test_mlkem_linked.py @@ -19,8 +19,9 @@ rule, all at once. (The reference is libs/mlkem/tools/mlkem_ref.py, the one the library's ACVP runs are checked against.) [2b] a PRG whose tail marker is zeroed (a LOAD cut short) is REFUSED, on - uci-onchip here and, with --comb, on the comb image (the precompute's - border restore must not erase the red border). + uci-onchip here and, ONLY under --comb, on the comb image (the + precompute's border restore must not erase the red border; the default + run does not guard that path). [4] mutants, each must go red: (a) boot.s without the copy call (image comparison fails), (b) the copy call moved after the zeroing (the assembler assert fires), (c) the call wrapper without mlkem_arith_init diff --git a/tools/test_mlkem_placement.py b/tools/test_mlkem_placement.py index 36dd484..5f48865 100644 --- a/tools/test_mlkem_placement.py +++ b/tools/test_mlkem_placement.py @@ -121,7 +121,10 @@ def rom_shadowed_symbols(map_path): map's Exports list - not a name regex, so a library addition is covered. Nothing is exempt by prefix: __LIB_MLKEM_CODE_RUN__ is a way in too. - Known gap, accepted: a literal address (`jsr $E123`) names no symbol.""" + Known gaps, accepted: a literal address (`jsr $E123`) and symbol + arithmetic on a non-banned symbol (`__LIB_MLKEM_CODE_LOAD__+$4000`) name + no banned symbol; the Imports check below closes the spellings the source + scan misses (`.ident(.concat(...))`, a ';' inside a string).""" text = Path(map_path).read_text() sec = text.split("Exports list by name:")[1].split("Imports list")[0] out = {} @@ -132,6 +135,34 @@ def rom_shadowed_symbols(map_path): return out +def map_importers(map_path): + """{symbol: {importing module.o}} from the ld65 map's Imports list.""" + text = Path(map_path).read_text() + sec = text.split("Imports list:")[1].split("Exports list")[0] \ + if "Exports list" in text.split("Imports list:")[1] else text.split("Imports list:")[1] + out, cur = {}, None + for ln in sec.splitlines(): + m = re.match(r"^(\S+) \(\S+\):\s*$", ln) + if m: + cur = m.group(1) + out.setdefault(cur, set()) + elif cur and ln.startswith(" "): + out[cur].add(ln.split()[0]) + return out + + +def library_members(): + r = subprocess.run(["ar65", "t", str(REPO / "build" / "lib" / "mlkem.a")], + capture_output=True, text=True) + return set(r.stdout.split()) | {"mlkem_link.o"} + + +def illegal_importers(symbols, importers, allowed): + """{symbol: modules} where a module outside *allowed* imports a banned symbol.""" + return {s: sorted(m - allowed) for s, m in importers.items() + if s in symbols and m - allowed} + + def banned_references(symbols, extra=None): """{file: [symbols]} for every source file except mlkem_link.s that names a symbol in the run image: such a reference is an unbanked call into @@ -285,6 +316,19 @@ def layout_checks(t, tag): hits = banned_references(syms) t.check(not hits, f"{tag}: no source outside mlkem_link.s names one of them", str(hits)) + imps = map_importers(REPO / "build" / "c64-https.map") + allowed = library_members() + bad_imp = illegal_importers(syms, imps, allowed) + t.check(not bad_imp and any(s in imps for s in syms), + f"{tag}: the map's Imports list: only mlkem_link.o and the library's own " + "members import a banned symbol", str(bad_imp)) + probe = {"mlkem_poly_ntt": {"boot.o"}} + t.check(illegal_importers(syms, probe, allowed) == {"mlkem_poly_ntt": ["boot.o"]}, + f"{tag}: red: a foreign module importing a banned symbol is reported") + spelled = 'jsr .ident(.concat("mlkem_", "keygen")) ; ";" in a string\n' + t.check("src/zz.s" not in banned_references(syms, {"src/zz.s": spelled}), + f"{tag}: (the source scan cannot see .ident(.concat(...)): the Imports " + "check above is what catches it)") t.check(banned_references(syms, {"src/zz.s": " jsr mlkem_poly_ntt\n"}) == {"src/zz.s": ["mlkem_poly_ntt"]}, f"{tag}: red: a direct jsr into the image is reported") @@ -304,7 +348,7 @@ def layout_checks(t, tag): f"{tag}: the tail marker's load address ${maddr:04X} holds sqtab byte " f"${sqtab_byte_at(maddr):02X}, not the marker") t.check(marker_collides(0xBC67) and marker_collides(0xBD99) - and not marker_collides(0xBD40), + and not marker_collides(maddr), f"{tag}: red: the known collisions $BC67/$BD99 are reported") prg = REPO / "build" / "c64-https.prg" img = prg.read_bytes() @@ -442,6 +486,23 @@ def build_checks(t): f"rc={rc} " + "\n".join( ln for ln in out.splitlines() if "rror" in ln)[-400:]) + print("\n[4b] a probe module that reaches a banned symbol by .ident(.concat(...))") + probe = REPO / "src" / "zz_probe.s" + try: + probe.write_text('.import .ident(.concat("mlkem_", "keygen"))\n.segment "CODE"\n jsr .ident(.concat("mlkem_", "keygen"))\n') + subprocess.run(["make", "clean"], cwd=REPO, capture_output=True) + rc, out = run_make(PQ_ARGS) + if t.check(rc == 0, "the probe links (the source scan cannot see it)", out[-300:]): + syms = rom_shadowed_symbols(REPO / "build" / "c64-https.map") + t.check("src/zz_probe.s" not in banned_references(syms, {"src/zz_probe.s": probe.read_text()}), + "the source scan misses .ident(.concat(...))") + bad = illegal_importers(syms, map_importers(REPO / "build" / "c64-https.map"), + library_members()) + t.check("zz_probe.o" in bad.get("mlkem_keygen", []), + "the Imports check reports zz_probe.o importing mlkem_keygen", str(bad)) + finally: + probe.unlink(missing_ok=True) + print("\n[5] the stale-checkout probe (libs/mlkem at v0.5.1)") sub = REPO / "libs" / "mlkem" head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=sub, diff --git a/tools/test_prg_load.py b/tools/test_prg_load.py index c477e92..06d35d2 100644 --- a/tools/test_prg_load.py +++ b/tools/test_prg_load.py @@ -177,7 +177,7 @@ def test_non_zero_bytes_outside_both_windows_still_refuse() -> None: PQ_LABELS = """al C:28B1 .pq_mlkem_call al C:A000 .__LIB_MLKEM_CODE_LOAD__ -al C:BD40 .__LIB_MLKEM_MARK_LOAD__ +al C:BD41 .__LIB_MLKEM_MARK_LOAD__ """ @@ -196,8 +196,8 @@ def with_bytes(prg: bytes, **at) -> bytes: def pq_prg(extra: dict | None = None) -> bytes: - """A PQ-shaped image: first image byte at $A000, tail marker at $BD40.""" - return with_bytes(make_prg(window=COLD), A000=0xA9, BD40=pl.MLKEM_MARK, + """A PQ-shaped image: first image byte at $A000, tail marker at $BD41.""" + return with_bytes(make_prg(window=COLD), A000=0xA9, BD41=pl.MLKEM_MARK, **(extra or {})) @@ -209,12 +209,12 @@ def test_the_staged_ml_kem_image_span_is_allowed_only_for_a_pq_build() -> None: import tempfile with tempfile.TemporaryDirectory() as d: stage = pl.stage_from_labels(pq_labels(Path(d))) - assert stage == (0xA000, 0xBD41), stage - for addr in (0xA000, 0xB123, 0xBD40): # the image: accepted + assert stage == (0xA000, 0xBD42), stage + for addr in (0xA000, 0xB123, 0xBD41): # the image: accepted prg = make_prg(tail_byte=0xEA, at=addr, window=COLD) pl.image_span(prg, stage) assert pl.stage_bytes(prg, stage) == addr - 0xA000 + 1, addr - for addr in (0xBD41, 0xBFFF): # past its end: refused + for addr in (0xBD42, 0xBFFF): # past its end: refused try: pl.image_span(make_prg(tail_byte=0xEA, at=addr, window=COLD), stage) except pl.PrgLoadError as exc: @@ -255,7 +255,7 @@ def test_labels_are_bound_to_the_prg_they_describe() -> None: raise AssertionError(f"knob-off image with ${planted} + PQ labels was accepted") assert bad.typed == [] # red 2: labels name a marker address this PRG does not carry - other = pq_labels(Path(d), PQ_LABELS.replace("BD40", "BC00")) + other = pq_labels(Path(d), PQ_LABELS.replace("BD41", "BC00")) bad = FakeClient() try: run(bad, pq_prg(), labels_path=other) @@ -275,7 +275,7 @@ def test_labels_are_bound_to_the_prg_they_describe() -> None: # red 4: a byte just past the image end, marker present past = FakeClient() try: - run(past, pq_prg({"BD41": 0xEA}), labels_path=lab) + run(past, pq_prg({"BD42": 0xEA}), labels_path=lab) except pl.PrgLoadError: pass else: diff --git a/tools/uci/rig_https_m3.py b/tools/uci/rig_https_m3.py index a034c08..ceb6f3b 100644 --- a/tools/uci/rig_https_m3.py +++ b/tools/uci/rig_https_m3.py @@ -502,7 +502,8 @@ def timed_attempt(client, name, bdir: Path, page: str, run_dir: Path) -> dict: ref = host_reference(host, path) client.reset() time.sleep(2.5) - load_verified_and_run(client, (bdir / "c64-https.prg").read_bytes()) + load_verified_and_run(client, (bdir / "c64-https.prg").read_bytes(), + labels_path=bdir / "labels.txt") m, lines = wait_for(client, ["Q=QUIT"], INIT_WAIT) if not m: dump(lines, "boot")