diff --git a/Makefile b/Makefile index 1e72760..b04ea76 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,19 @@ IP65_BUILD := ip65-build IP65_BIN := $(IP65_BUILD)/ip65-c64.bin CA65FLAGS := -I src -I src/inc -I src/crypto/shared -I src/net/$(BACKEND) -I build --debug-info +# Which backend is selected, as a ca65 define. Sources that must name a +# cfg-level symbol need this, because the two cfgs give the same region +# different names: the crypto code+rodata region is CRYPTO_HOT under UCI and +# CRYPTO_RESIDENT under ip65, so the ld65-published ___SIZE__ symbol an +# `.import` has to spell differs by backend (src/contract_footprint_asserts.s +# is the current consumer). The `-I src/net/$(BACKEND)` path above cannot +# serve this: it carries net tuning, and region naming is a cfg property, not +# a networking one. +ifeq ($(BACKEND),uci) +CA65FLAGS += -D BACKEND_UCI=1 +else +CA65FLAGS += -D BACKEND_IP65=1 +endif # Optional HTTPS target-port override (src/boot.s defaults to 443). # `make HTTPS_PORT=4433` lets a test rig's TLS listener bind unprivileged. ifdef HTTPS_PORT diff --git a/docs/library-ingestion-architecture.md b/docs/library-ingestion-architecture.md index 2405544..7c789dd 100644 --- a/docs/library-ingestion-architecture.md +++ b/docs/library-ingestion-architecture.md @@ -351,7 +351,7 @@ Suitable to file as GitHub issues against `JC-000/c64-https`. Each is sized for **Scope:** Split `cfg/c64-https-uci.cfg`'s `CRYPTO_RESIDENT` ($6000-$BFFF, 24 KB) into `CRYPTO_HOT` ($6000-$9FFF, 16 KB) and `CRYPTO_COLD_SHADOW` ($A000-$BFFF, 8 KB, under-BASIC-ROM RAM). Move ChaCha20-Poly1305 + SHA-256 + HKDF + TLS state machine + HTTP + record I/O into CRYPTO_HOT; move ECDSA-verify dispatcher, X25519 ECDH glue, per-curve scratch BSS into CRYPTO_COLD_SHADOW. CRYPTO_OVERLAY ($4200-$5FFF, 7.5 KB) absorbs all cold-path *code* via REU-paged overlays. -**Expected output:** `build/c64-https.prg` builds clean under `BACKEND=uci`. Smoke tests (`test_https_local.py`, `test_https_local_p384.py`) PASS. `build/labels.txt` shows CRYPTO_HOT under-allocated by ≥1 KB. +**Expected output:** `build/c64-https.prg` builds clean under `BACKEND=uci`. Smoke tests (`rig_https_local.py`, `rig_https_local_p384.py`) PASS. `build/labels.txt` shows CRYPTO_HOT under-allocated by ≥1 KB. **Dependencies:** None; this work is c64-https-internal. @@ -442,7 +442,7 @@ After all four cells build clean: **Tier 2 — U64E (slow, gated, ~10-90 minutes):** - Runs on self-hosted runner with LAN access to the U64E (probably `192.168.1.81` per the project memory). - Acquires DeviceLock via `c64-test-harness` (queue-aware). If queue depth exceeds threshold (W7), skip with `:warning: queue busy, skipping U64E tier; will retry in 6h`. -- `tools/uci/test_https_local.py`, `test_https_local_p384.py`, `phase3_tcp_echo.py`, `boot_check.py`. +- `tools/uci/rig_https_local.py`, `rig_https_local_p384.py`, `phase3_tcp_echo.py`, `boot_check.py`. - Wall-clock budgets per CLAUDE.md: ~82 s P-256 handshake, ~7 min P-384 handshake. ### 5.4 U64E hardware contention diff --git a/src/contract_footprint_asserts.s b/src/contract_footprint_asserts.s new file mode 100644 index 0000000..eb6d818 --- /dev/null +++ b/src/contract_footprint_asserts.s @@ -0,0 +1,119 @@ +; src/contract_footprint_asserts.s — c64-lib-contract SPEC §6.6 consumer +; footprint asserts. +; +; Emits no bytes. Companion to src/lib_contract_asserts.s, kept as a +; separate TU because §6.6 landed at contract v0.10.0 and its gating is +; profile-dependent in a way the §1/§3/§8.0/§13.3 gates are not (see the +; comb exclusion below). +; +; Contract: https://github.com/JC-000/c64-lib-contract — read SPEC.md on +; `main`, NOT the newest git tag (tags lag; main is v0.10.0 as of +; 2026-08-15). Clause referenced here: §6.6 (consumer footprint asserts). +; +; --------------------------------------------------------------------- +; WHAT §6.6 IS FOR +; --------------------------------------------------------------------- +; SPEC §6.6 exists because of a failure measured in THIS repo and filed +; upstream as c64-lib-contract#69: a MINOR library bump grows resident +; code or rodata, the consumer's region overflows at link time, and there +; is no advance signal that the bump was a spatial event. c64-https hit it +; twice — most recently v0.7.0's +208 B validation gate against CRYPTO_HOT's +; ~1 byte of slack, bisected across three tags to find. +; +; The clause's consumer pattern binds the library's declared footprint to +; a region budget: +; +; .import LIB_NISTCURVES_RESIDENT_BYTES +; .import LIB_NISTCURVES_COLD_BYTES +; .import __MAIN_SIZE__ ; cfg: MAIN: ... define = yes; +; .assert LIB_NISTCURVES_RESIDENT_BYTES + LIB_NISTCURVES_COLD_BYTES \ +; <= __MAIN_SIZE__, lderror, "..." +; +; No cfg change was needed to adopt it: every MEMORY area in both +; cfg/c64-https-uci.cfg and cfg/c64-https-ip65.cfg already carries +; `define = yes`, so ld65 publishes __CRYPTO_HOT_SIZE__ / +; __CRYPTO_RESIDENT_SIZE__ already (verified in build/labels.txt). +; +; --------------------------------------------------------------------- +; WHAT THIS ASSERT ACTUALLY CATCHES — AND WHAT IT DOES NOT +; --------------------------------------------------------------------- +; Stated plainly, because a gate whose reach is overestimated is worse +; than no gate. +; +; It does NOT tightly bound region pressure. CRYPTO_HOT is $4000 (16,384 B) +; and holds c64-https's own crypto as well as the library, so at the v0.9.1 +; pin the assert compares 8,700 + 430 = 9,130 against 16,384 — roughly +; 7 KB of slack, while the region's REAL free space is 81 bytes +; (__CRYPTO_HOT_LAST__ = $9FAF against a $A000 end, measured). A bump that +; grows the archive by 100 B still overflows the region without tripping +; this assert. Making it tight would require hardcoding a per-pin budget +; constant, which then needs maintenance on every bump and fires as a +; false alarm on any growth of c64-https's own code; that trade was +; considered and declined. +; +; What it DOES catch is a §6.4 per-variant-manifest regression, and that +; is not hypothetical — it is measured live in this tree on a sibling +; profile. §6.6's whole implication ("declared <= budget implies actual <= +; budget") rests on the manifest describing the archive we link. When it +; does not, the number is not conservative, it is meaningless. At the +; v0.9.1 pin, measured with od65 on the staged archives: +; +; profile manifest member RESIDENT COLD +; reu lib_manifest_p256verify.o 8700 430 +; onchip lib_manifest_p256verify_onchip.o 8700 240 +; onchip-comb lib_manifest_onchip.o 27000 1650 <- whole library +; +; 27,000 B is the whole-library figure against a 16,384 B region. That is +; the exact number that made §6.6 unadoptable before contract v0.9.0's +; §6.4, and it is still live for the comb profile — so if a future bump +; regresses `reu` or `onchip` the same way, this assert names it instead of +; leaving an opaque segment overflow to be bisected. +; +; --------------------------------------------------------------------- +; WHY THE COMB PROFILE IS EXCLUDED +; --------------------------------------------------------------------- +; The comb archive would FAIL this assert today (27,000 + 1,650 = 28,650 +; against 16,384) and the failure would be a false alarm: the comb PRG +; links and runs, so the declared number is wrong, not the build. +; +; The cause is ours, not upstream's. `tools/integration/build_nistcurves_p256.sh` +; builds the comb profile from upstream's FULL `lib-onchip` archive and +; then `rm -f`s ~7 members (fp384, mod384, curve384, points384_*, ...) to +; narrow it to the P-256 comb set. Upstream's `lib_manifest_onchip.o` +; legitimately describes the archive upstream shipped; it survives our +; member surgery still describing the pre-surgery set. The `reu` and +; `onchip` profiles are unaffected because they build from upstream's +; already-minimal `lib-p256-verify` / `lib-p256-verify-onchip` targets, +; which carry per-variant manifests. +; +; This is precisely the harm SPEC §6.1 names: "an archive whose member set +; a consumer has edited is outside every §5/§8.0 manifest claim it ships." +; The sanctioned remedy is §6.2 `CONTRACT_DEFINES` / `CONTRACT_ZP_DEFINES` +; plus §6.3's `lib-app-owned` target, so the configuration is reachable +; without surgery. Neither exists in the pinned v0.9.1; both ARE implemented +; on libs/nistcurves master (v0.10.1, measured). Retiring the surgery is +; therefore unblocked by the wave bump and is sequenced in c64-https#70 — +; when it lands, delete the .ifdef below and the comb profile is covered +; by the same assert as the other two. +; +; The comb profile is deliberately excluded from `make package`, so no +; shipped artifact is affected by the gap this exclusion leaves open. + +.ifndef USE_NISTCURVES_COMB + +.import LIB_NISTCURVES_RESIDENT_BYTES +.import LIB_NISTCURVES_COLD_BYTES + +; The code+rodata region differs by backend: CRYPTO_HOT under UCI, +; CRYPTO_RESIDENT under ip65. Both are $6000-$9FFF ($4000 bytes) and both +; are declared `define = yes`, so the published size symbol is the only +; thing that differs. BACKEND_UCI / BACKEND_IP65 come from the Makefile. +.ifdef BACKEND_UCI + .import __CRYPTO_HOT_SIZE__ + .assert LIB_NISTCURVES_RESIDENT_BYTES + LIB_NISTCURVES_COLD_BYTES <= __CRYPTO_HOT_SIZE__, lderror, "libs/nistcurves declared footprint (LIB_NISTCURVES_RESIDENT_BYTES + _COLD_BYTES) exceeds CRYPTO_HOT. Contract SPEC 6.6. Most likely cause is a 6.4 regression: the archive ships a whole-library manifest instead of a per-variant one (27000 B is the whole-library value). Check with od65 --dump-exports on the staged lib_manifest*.o before assuming the library really grew." +.else + .import __CRYPTO_RESIDENT_SIZE__ + .assert LIB_NISTCURVES_RESIDENT_BYTES + LIB_NISTCURVES_COLD_BYTES <= __CRYPTO_RESIDENT_SIZE__, lderror, "libs/nistcurves declared footprint (LIB_NISTCURVES_RESIDENT_BYTES + _COLD_BYTES) exceeds CRYPTO_RESIDENT. Contract SPEC 6.6. Most likely cause is a 6.4 regression: the archive ships a whole-library manifest instead of a per-variant one (27000 B is the whole-library value). Check with od65 --dump-exports on the staged lib_manifest*.o before assuming the library really grew." +.endif + +.endif ; USE_NISTCURVES_COMB diff --git a/tools/integration/build_nistcurves_p256.sh b/tools/integration/build_nistcurves_p256.sh index 1ddcebc..d349cf8 100755 --- a/tools/integration/build_nistcurves_p256.sh +++ b/tools/integration/build_nistcurves_p256.sh @@ -95,24 +95,49 @@ AR65="${AR65:-ar65}" # unused inside ZP_CRYPTO. # Other slots match upstream defaults — see libs/nistcurves/src/zp_config.s. # -# THE POINTER SLOT IS SPELLED `nistcurves_zp_ptr2` FROM v0.10.0, AND THE -# OLD SPELLING NOW HARD-ERRORS. The §6.5 rename window (upstream #107) -# made the four general-purpose scratch slots canonically -# `nistcurves_zp_{tmp1,tmp2,ptr1,ptr2}` and left the bare `zp_*` names as -# unconditional same-address aliases: +# THE POINTER SLOT IS SPELLED `nistcurves_zp_ptr2` FROM v0.10.0. The +# spelling is PROBED, never hardcoded, because both spellings are wrong at +# some pin this script has to build. +# +# c64-lib-contract SPEC §2 gained a ZP prefix registry at v0.9.0: a bare +# `zp_ptr2` is unregistered (three adopters had independently converged on +# bare zp_tmp1/zp_ptr1 — contract #83), so c64-nist-curves renamed its four +# general-scratch slots to the registered `nistcurves_zp_*` family and left +# the bare names as aliases for the §6.5 rename window, in the "loud-break" +# shape that clause ratifies: # # .ifndef nistcurves_zp_ptr2 # nistcurves_zp_ptr2 = $fd # .endif # zp_ptr2 = nistcurves_zp_ptr2 <- NOT .ifndef-guarded # -# so `-D zp_ptr2=$3d` no longer suppresses a guarded definition; it -# collides with the alias assignment and stops the build with -# `zp_config.s(56): Error: Symbol 'zp_ptr2' is already defined`. That is -# the good case — a loud failure, not a silently-dropped override. The -# `fp_*` names are documented override knobs and were not renamed. +# Measured, ca65 V2.18, against each pin's own zp_config.s: +# +# spelling v0.9.1 v0.10.1 +# -D 'zp_ptr2=$3d' overrides correctly Error: Symbol +# 'zp_ptr2' is +# already defined +# -D 'nistcurves_zp_ptr2=$3d' defines an unused overrides both +# symbol; real slot names to $3D +# stays at $fd +# +# Neither spelling is safe across both, so probe the library source and +# follow it. Note the v0.9.1 + canonical cell is a *wrong value*, not a +# silent one: the `check_zp_slot` guards below read the emitted object with +# od65, so that combination stops the build (as `$fd` != `$3d`, or as +# `` if the guard is aimed at the canonical name). The probe's value +# is being loud AND correct at both pins, rather than merely loud at one. +# +# fp_mul_i / fp_mul_j need no probe: `fp_` is a registered §2 prefix for +# c64-nist-curves, so those names are canonical already and keep their +# `.ifndef` guards across the migration (verified on v0.10.1). +if grep -qE '^[[:space:]]*\.ifndef[[:space:]]+nistcurves_zp_ptr2[[:space:]]*$' "$LIB_SRC/zp_config.s"; then + ZP_PTR2_SLOT='nistcurves_zp_ptr2' +else + ZP_PTR2_SLOT='zp_ptr2' +fi ZP_OVERRIDES=( - '-D' 'nistcurves_zp_ptr2=$3d' + '-D' "$ZP_PTR2_SLOT=\$3d" '-D' 'fp_mul_i=$39' '-D' 'fp_mul_j=$3a' ) @@ -189,14 +214,38 @@ check_zp_slot() { exit 1 fi } -# Check the CANONICAL name. The bare `zp_ptr2` alias is also exported in a -# default build and carries the same value, but it disappears under -# `-D LIB_NO_BARE_EXPORTS=1` (SPEC §6.5) and is removed outright at the -# next MAJOR — checking it would make this guard silently vacuous exactly -# when a consumer tightens the build. -check_zp_slot nistcurves_zp_ptr2 61 # $3d -check_zp_slot fp_mul_i 57 # $39 -check_zp_slot fp_mul_j 58 # $3a +# Same as check_zp_slot, but tolerates the symbol being absent. Only correct +# for a slot whose ABSENCE is legitimate — see the alias check below. +check_zp_slot_if_present() { + local name="$1" want="$2" got + got=$("${OD65:-od65}" --dump-exports "$STAGING/$ZP_MEMBER" \ + | awk -v n="\"$name\"" '$1=="Name:" && $2==n {f=1; next} f && $1=="Value:" {gsub(/[()]/,"",$3); print $3; exit}') + [ -z "$got" ] && return 0 + if [ "$got" != "$want" ]; then + echo "ERROR: $ZP_MEMBER exports $name = $got, expected $want (c64-https ZP override did not take)" >&2 + exit 1 + fi +} + +# The CANONICAL spelling is checked unconditionally — it is the name the +# library's own code reads, so its absence or disagreement is always a defect. +check_zp_slot "$ZP_PTR2_SLOT" 61 # $3d — canonical spelling for this pin + +# The deprecated bare alias is checked only WHEN PRESENT, and only when it is +# a distinct symbol. On a §2-migrated pin this proves the alias tracks the +# override rather than splitting one slot across two addresses (the silent +# outcome contract §6.5 forbids) — but the alias legitimately disappears under +# `-D LIB_NO_BARE_EXPORTS=1` (§1), and a hard check would then fail the build +# over a symbol the contract expects to be gone. Checking the bare name +# *instead* of the canonical one would be the worse error in the other +# direction: it is the spelling that goes away, so the guard would stop +# guarding exactly when a consumer tightens the build. +if [ "$ZP_PTR2_SLOT" != "zp_ptr2" ]; then + check_zp_slot_if_present zp_ptr2 61 # $3d — deprecated alias, if still emitted +fi + +check_zp_slot fp_mul_i 57 # $39 +check_zp_slot fp_mul_j 58 # $3a # --- 4. Drop conflicting members / rebuild the onchip mul object --- # `reu_mul_init.o` is the SPEC §8.2 `reu_mul` provider. c64-https is the diff --git a/tools/integration/build_nistcurves_p384.sh b/tools/integration/build_nistcurves_p384.sh index 6a9eed4..5b562b6 100755 --- a/tools/integration/build_nistcurves_p384.sh +++ b/tools/integration/build_nistcurves_p384.sh @@ -54,8 +54,20 @@ CA65="${CA65:-ca65}" AR65="${AR65:-ar65}" # --- ZP-slot overrides (c64-https canonical map + SHA-384 isolated window) --- +# zp_ptr2's SPELLING is pin-dependent and probed, never hardcoded — see the +# long rationale in build_nistcurves_p256.sh (contract SPEC §2 ZP prefix +# registry + §6.5's loud-break alias shape: on a migrated pin the bare +# spelling is an unguarded alias and `-D zp_ptr2=...` dies with +# "Symbol 'zp_ptr2' is already defined"). `fp_`/`sha_` are registered §2 +# prefixes for c64-nist-curves and keep their `.ifndef` guards, so only this +# one slot needs the probe. +if grep -qE '^[[:space:]]*\.ifndef[[:space:]]+nistcurves_zp_ptr2[[:space:]]*$' "$LIB_SRC/zp_config.s"; then + ZP_PTR2_SLOT='nistcurves_zp_ptr2' +else + ZP_PTR2_SLOT='zp_ptr2' +fi ZP_OVERRIDES=( - '-D' 'zp_ptr2=$3d' + '-D' "$ZP_PTR2_SLOT=\$3d" '-D' 'fp_mul_i=$39' '-D' 'fp_mul_j=$3a' # SHA-384 streaming pointer slots (moved out of $04-$0b defaults @@ -168,7 +180,15 @@ for tree in sha curve; do "${ZP_OVERRIDES[@]}" \ -o "$STAGING/$tree/$zp_member" \ "$LIB_SRC/zp_config.s" - check_zp_slot_if_present "$STAGING/$tree/$zp_member" zp_ptr2 61 # $3d + # Canonical spelling first (see build_nistcurves_p256.sh). Every check here + # is presence-tolerant because the sha and curve trees export disjoint slot + # subsets — the sha tree has no zp_ptr2 at all — so absence is legitimate + # per-tree and cannot be asserted away. The bare alias is additionally + # optional because `-D LIB_NO_BARE_EXPORTS=1` removes it (§1/§6.5). + check_zp_slot_if_present "$STAGING/$tree/$zp_member" "$ZP_PTR2_SLOT" 61 # $3d + if [ "$ZP_PTR2_SLOT" != "zp_ptr2" ]; then + check_zp_slot_if_present "$STAGING/$tree/$zp_member" zp_ptr2 61 # $3d — deprecated alias + fi check_zp_slot_if_present "$STAGING/$tree/$zp_member" fp_mul_i 57 # $39 check_zp_slot_if_present "$STAGING/$tree/$zp_member" fp_mul_j 58 # $3a done