docs: benchmark refresh + ip65 status, LSP artifact cleanup, drain-budget fixes - #77
Merged
Conversation
…expect ~56 s Fixes#73. Regression introduced by #71 (merged), measured on C64 Ultimate hardware: the shipped UCI onchip handshake+GET went 51.0 s -> 125.4 s at 48 MHz, a ~2.5x wall-clock regression. Correctness was never affected. The drain compensates for a property only ip65 has: it ACKs inbound TCP data only when the consumer pumps net_poll, so without draining, the server's post-SH flight tail sits unACKed through the multi-minute ECDHE/verify stalls and impatient peers drop the connection. UCI firmware ACKs autonomously — which is exactly why that bug was never observable on Ultimate hardware — so on UCI the drain has nothing to buy. Its cost, though, is anything but backend-neutral. An ip65 net_poll is a cheap NIC pump; a UCI net_poll is a full firmware command round-trip (SOCKET_READ: uci_wait_not_busy, uci_begin_cmd, 4x uci_put_byte, uci_push_wait, uci_check_err, header read, uci_drain_resp + uci_drain_status + uci_ack — ~25 fenced register accesses plus FPGA turnaround). Measured ~37 ms/poll at 48 MHz, of which only ~2.8 ms is fence time; the rest is clock-invariant firmware turnaround, so turbo does not amortize it. 2000 polls = ~70 s. Move the budget into a per-backend net_tuning.inc, resolved through the existing `-I src/net/$(BACKEND)` include path so tls13.s stays backend-agnostic: ip65: 8 x 250 = 2000 polls (UNCHANGED — the validated figure) uci: 1 x 16 = 16 polls (~0.6 s; a deliberate small hedge rather than 0, so anything already queued still lands in the ring before the long stalls without relying on firmware autonomy being absolute. Non-zero matters: the loop's dex/bne shape turns an INNER of 0 into 256 iterations.) Verified in the assembled listing: ip65 emits A0 08 / A2 FA, uci emits A0 01 / A2 10. All five profiles link with unchanged sizes (47,105 B ip65, 62,977 B uci). The ip65 images are byte-identical to master, so the ip65 e2e evidence from #71 carries over untouched; the UCI side needs a hardware re-measure (expected ~56 s = 51.0 s baseline + ~4.5 s for #69's on-chip X25519 rows at this clock). Process note for the record: I approved #71's unconditional drain with an "~1 s on UCI turbo" estimate that counted fence time only and ignored firmware turnaround — off by ~70x. An iteration-count budget calibrated on one backend's poll cost is precisely the failure mode that c64-lib-contract SPEC §13.4 (bounded waits must be wall-clock-based) exists to prevent. A TOD-bounded idle drain (poll until the ring stops growing) would be the principled backend-agnostic variant; it needs care around CIA1 TOD latch interaction with the UCI adapter's own TOD waits, so it is left as a documented refinement rather than bundled here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The C64 Ultimate speed-switch quirk is sharper than CLAUDE.md records, and in a way that made 64 MHz unmeasurable: the bridge glitch is caused by the REST config WRITE itself, it SURVIVES the following reset, and it fires even when the written value equals the current one. Evidence (C64U 10.53.21.158, onchip UCI build, 2026-07-29): three 64 MHz attempts each wrote "64" while the device was ALREADY at 64 MHz, and each lost its first TCP_CONNECT — UCI_ERR_NO_SOCKET, net_tcp_state=CONNECT_FAIL, all TLS/HTTP state zero, ring head==tail==0, no SYN on the wire. The same PRG at the same 64 MHz setting passes under tools/uci/test_http_local.py, whose only material difference is that it performs no config write before its reset. At 48 MHz the identical pattern costs only the first attempt (fail, pass on retry), which is why this hid for so long. Fix: probe Turbo Control + CPU Speed first and skip the write entirely when they already match (the common case for repeat runs at one speed), and give a genuine change a 3.0 s settle instead of 0.5 s (TURBO_SETTLE overrides). The probe is best-effort — if it raises, we write as before. Both sides of the speed comparison are str()-normalised so a firmware type change cannot silently restore always-write. Not the wedge signature (that reaches ENC1 RX and stalls); this never opens a socket. Found by the hardware worker while validating #72/#73 — it blocked the 64 MHz headline number for PR #74. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…achable The redundant-write skip added in e044f51 never fired on the C64U: the probe read `get_config_item(...).get("value")`, but the REST config responses wrap items in a `<Category>` key and put each value directly under the ITEM name — there is no per-item "value" key. The probe therefore returned None/None on every device, `str(None) != "48"`, and the code always took the write path (log line: "Setting turbo to 48 MHz (from None/None)..."). Fix: mirror the harness's own get_reu_config — fetch the category, unwrap, index by item name — using public API only (no reliance on the private _unwrap). One request now covers both items instead of two. `.get(CAT, cat)` tolerates a response with or without the wrapper. Verified without hardware by parsing all three plausible shapes (wrapped, flat, int-typed CPU Speed): the skip decision comes out True for each, so a firmware shape or type change degrades to "write anyway" rather than to a silent wrong answer. Caught because the write path logs the probed values ("from None/None") — keeping the observed state in that message is what made an unreachable branch visible in a passing run. Worth remembering. Note the 3.0 s settle from e044f51 is doing real work independently: the first genuine speed change under it (64->48) connected on the first attempt, where every pre-fix genuine change cost a NO_SOCKET retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The benchmark tables predated three merged changes and one open fix, and several status claims had gone stale. Measured numbers only — no extrapolated figures. **e2e wall-clock.** The 2026-07-20 campaign rows are kept as history and labelled as such; a new "Post-#74 e2e numbers" block records HEAD: device profile clock pre-#71 post-#71 post-#74 C64U onchip 48 MHz 51.0 s 125.4 s 44.6 s C64U onchip 64 MHz 39.7 s (unmeas.) 33.7 s U64E REU 48 MHz 82.1 s 161.0 s 82.1 s Both onchip rows land BELOW their pre-regression baselines and the REU row lands exactly AT it — a REU build cannot contain #69 (its change is inside .ifdef USE_NISTCURVES_ONCHIP), so the pair is a clean control showing #69 is a SPEEDUP at turbo, not a cost. The doc now states that explicitly, including why the sign is easy to get wrong: the profile's 1 MHz penalty exists only because REU DMA is cheap relative to the CPU down there, and inverts above the crossover. (I got this backwards during the campaign; recording the reasoning so the next reader doesn't.) **New: ip65 / stock-C64 wall-clock**, the first ip65 e2e figures we have — 36.0 min honest 1 MHz REU-less, with the phase breakdown, plus the accelerated runs. Notes that the verify stretch came in 1.4% off the T(f)=D+C/f prediction three orders of magnitude from where that model was fit, and that ip65's drain budget is byte-identically unchanged by #74 so the numbers stand at HEAD. **Corrected stale claims:** - "ip65 is NOT packaged: does not link" — it links (#68). Explains the SCRATCH_UNION lifetime argument and its guards, notes packaging it is now a live option since a stock C64 + RR-Net has no shipped PRG today, and demotes c64-nist-curves#54 from blocker to optional headroom. - The CRYPTO_COLD_SHADOW "1,662 B overflow, cfg relief exhausted" entry, same fix. - The X25519-sibling entry claimed the old BSS overflow. Re-measured 2026-07-29: USE_X25519_SIBLING=1 under ip65 still fails, but on a DIFFERENT problem — X25519_RODATA over CRYPTO_OVERLAY by 2,048 B and LIB_NISTCURVES_P256_CODE over CRYPTO_RESIDENT by 103 B, i.e. code/rodata placement (ip65's overlay slot is 4,212 B vs UCI's 7.5 KB), not BSS. Better to state the measured failure than leave a fixed one on the page. **New design note** for the post-ServerHello drain: the ip65 property that motivates it (no MSS in SYN + ACK-only-when-polled), the offline-verify failure signature it prevents, why the budget must be per-backend (~40 ms per UCI net_poll vs a cheap ip65 pump — the #73 regression), current values, and the two open follow-ups (in-crypto polling for large flights; a wall-clock/idle bound instead of an iteration count, which is what the section's own rule actually demands). **New Smoke-tests subsection** for the hardware-free VICE ip65 rig, with the two prerequisites that are easiest to lose: the patched ethernet-capable VICE (stock macOS builds gate pcap on geteuid()==0) and the /dev/bpf permissions that reset every reboot. Also replaces the "blocked on an upstream ip65 bug (see lost memory note)" line with what is actually known now. Stacked on fix/drain-backend-budget: the post-#74 rows describe that PR's tree, not master's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
230 .ca65-ls/cache/*.pkl language-server pickles were committed by a4de513 (a TLS fix) because .gitignore had no .ca65-ls/ entry. They re-dirty on every LSP touch, so `git status` shows churn on a clean checkout. .mcp.json is per-developer tooling, not shared config: it hardcodes an absolute --with-editable path under one machine's home directory, so the serena MCP server fails to start for any other clone. Both are now gitignored and untracked (files kept on disk). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two doc-drift nits from the branch review: - net_tuning.inc and tls13.s quoted the per-UCI-net_poll cost as ~37 ms (~2.8 ms fence, ~70 s for ip65's 2000-poll budget). CLAUDE.md derives ~40 ms from the measured regression (80.8 s / 1984 polls C64U onchip, 78.9 s / 1984 U64E REU) and is authoritative; the .s comments were stale by one round of measurement. Anyone sizing NET_SH_DRAIN_INNER against a wall-clock target now reads one number. - CLAUDE.md's Variables entry for USE_X25519_SIBLING=1 still pointed at a "tracked BSS overflow", contradicting the Known-issues entry this branch rewrote: that BSS overflow was fixed by the #68 refit and the remaining ip65 failure is a code/rodata placement problem. Comment-only; both backends rebuild clean (ip65 47,105 B, uci 62,977 B). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it F5) test_https_local.py passed the run whenever the 5 characters "HELLO" appeared anywhere in the 1000 bytes of screen RAM. That branch was reachable only after the full-body assertion had already failed, so the test substituted a weaker criterion at exactly the moment the strong one did not hold: a truncated body, a mis-decrypted body that kept its first word, or a stale HELLO left on screen by an earlier run all passed. The C64-side criteria now live in `_check_c64_result()` — one rule, that http_resp_buf holds the complete expected body. Screen RAM stays diagnostic: a HELLO on screen without the body is reported as one of the reasons the run failed, not as a pass. A/B (host-side, same four inputs through the pre-fix block and the new function): input before after full body in resp_buf, HELLO on screen PASS PASS body truncated, HELLO still on screen PASS FAIL resp_buf empty, stale HELLO on screen PASS FAIL nothing anywhere FAIL FAIL Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/test_tls_record.py 4b asserted the carry flag only when the register dict contained a key named "P". VICE's binary monitor names the 6502 status register "FL", so that branch was dead on every VICE run and 4b always fell through to the fallback oracle, which asserted merely that the computed tag differed from the record's tag. Tags always differ on tampered input, so 4b passed whether or not aead_decrypt rejected the record: with the `bne @auth_fail` tag-rejection branch removed from src/crypto/aead.s the suite still reported 17/17. Fix: - carry_from_regs() looks the status register up under FL/P/FLAGS/SR, so the primary (and only sound) oracle actually fires. - The fallback pass path is gone. Not being able to read the carry means the tamper rejection could not be evaluated, which is a FAIL, not a pass. - The tag comparison survives only as diagnostic text printed on a C=0 failure -- it distinguishes "detectable but not rejected" from "tag never recomputed". - Launch VICE through _vice_helpers.default_vice_config() like every other in-tree VICE test, instead of a bare ViceConfig. A/B (VICE, ip65 default profile, --seed 20260813): pristine 17/17 pass, exit 0, 4b "PASS: decrypt returned C=1 (tag mismatch)" mutant (bne @auth_fail -> nop nop, verified in the linked PRG as the only two changed bytes, D0 31 -> EA EA at $67A9 = aead_decrypt+9) 16/17, 4b FAIL, exit 1 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ul boot The pass criterion was "screen RAM contains any printable text and >= 3 distinct byte values". That distinguishes a booted machine from a blank screen and nothing more: an ip65/RR-Net PRG booted on a U64E draws its banner, prints NETWORK INIT FAILED, and still returned PASS (audit F4, reproduced on hardware at 10.43.23.81). boot_check now asserts what the boot actually has to produce, and knows which build it is checking, because the banner is backend-aware (`net_banner_str`: "UCI NETWORKING" under uci, "RR-NET (CS8900A) ETHERNET" under ip65): - `BACKEND` env (default `uci`) selects the expected banner. - The PRG image on disk is checked for that banner before the device is touched, so a stale artifact from a different `BACKEND=` is caught without burning a hardware slot. - Screen checks: common banner present, expected backend banner present, the other backend's banner absent, no `FAILED` anywhere, and the main menu (`Q=QUIT`) reached. - Boot wait polls for the menu (default 60 s budget, `BOOT_TIMEOUT`) instead of a fixed 3 s sleep, which was shorter than boot's own reu_mul_init pass (~15-18 s on the U64E) — the old script routinely read a mid-boot screen. - `enable_uci` before reset when the expected backend is uci, matching phase2_check.py; without it $DF1D never answers $C9 and net_init reports NOT_PRESENT. - `C64_PRG` overrides the PRG path so a deliberate wrong-backend run can be staged. `evaluate_screen` / `check_prg_image` are pure functions so the criterion can be replayed against a captured screen dump. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md:429 said the client "negotiates only TLS_AES_128_GCM_SHA256".
It offers exactly one cipher suite, 0x1303 TLS_CHACHA20_POLY1305_SHA256
(src/tls_handshake.s:85, echo-verified against ServerHello at :380), and
there is no AES implementation anywhere in src/crypto/. CLAUDE.md's
packaging validation record already stated it correctly, so the document
contradicted itself.
The claim is load-bearing — it is the stated reason the P-384 path keeps
a SHA-256 transcript hash. That conclusion is unchanged and still
correct: RFC 8446 §4.4.1 ties the transcript hash to the negotiated
suite's hash function, and 0x1303's hash is also SHA-256. Only the
reason is corrected.
Repo-wide sweep; same error fixed in:
- src/crypto/ecdsa_verify_384.s (3 comment sites)
- tools/package/listener/{listener.py,README.md}
tools/test_tls_handshake.py:674 keeps its mention: 0x1301 genuinely is
TLS_AES_128_GCM_SHA256 there, used as a suite the client must reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>…dit F6) Every run already wrote server_result.json — whether the listener completed the handshake, the decrypted request, any TLS error — and no pass criterion ever read a byte of it. All assertions were against C64-side memory and screen RAM, i.e. against state the client produces itself. The listener's record is the one piece of evidence in this test the client cannot fabricate, and it was archived and discarded. A run now passes only when both sides agree. `_check_server_result()` fails on: a recorded `error`, `listening` unset, `client_addr` absent (the listener never accepted a connection), and a `request` that is missing, `<timeout>`, or not the GET this script programmed into the C64 (request line + `Host:` line — both values this script chose). Deliberately not asserted, to avoid failing healthy runs: byte-exact request equality (trailing headers are src/http.s's business), and anything at all under EXTERNAL_LISTENER=1, where by contract there is no inline listener and server_result is empty — that skip is printed. `--check-artifact <run-dir>` re-runs the server-side criteria against an archived run's server_result.json with no hardware, so the criteria are testable and stored runs can be re-adjudicated. A/B over the five captured runs under the audit scratchpad, each composed with a healthy C64 side (http_status=200, the full body) so the "C64 memory looks right" direction is the one under test: server_result fixture before after 214751 clean (listening/addr/GET) PASS PASS 215648 clean (listening/addr/GET) PASS PASS 215331 SSLEOFError UNEXPECTED_EOF PASS FAIL 220559 SSLEOFError UNEXPECTED_EOF PASS FAIL 215221 listening only, never accepted PASS FAIL Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md's packaging validation record says the shipped onchip PRG "passes the 3-vector ECDSA KAT in VICE without REU", but every VICE test routes through default_vice_config(), which hard-codes `-reu -reusize 512`. The claim was true and unreproducible from the repo: checking it required monkeypatching the helper. default_vice_config() now honours C64_VICE_NO_REU=1 by dropping the REU flags and saying so on stderr. `-reu` stays the default on purpose — a no-REU run of a REU-profile build does not error, it silently returns wrong answers, because fp_mul's REU row fetch no-ops and mul_dma_lo/hi keeps reu_mul_init's a=255 residue. Implemented in the shared helper rather than in the KAT script so the opt-out is available to every VICE test, and documented where the claim is made (packaging validation record) plus a cross-reference from the "VICE harness gotcha" note. Evidence, onchip build (make BACKEND=uci USE_NISTCURVES_ONCHIP=1), no patching: C64_SKIP_BUILD=1 C64_VICE_NO_REU=1 tools/test_ecdsa_kat_oracle.py 3/3 pass, rc=0 (x64sc launched with no -reu) C64_SKIP_BUILD=1 tools/test_ecdsa_kat_oracle.py 3/3 pass, rc=0 (control, x64sc has -reu -reusize 512) and, on a REU-profile build, the same no-REU invocation rejects all three valid vectors with C=1 — the silent-garbage mode that keeps REU the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md's Known-issues entry claimed the UCI build's banner line says "ULTIMATE 64 ELITE (UCI)". The string in src/net/uci/net.s is "UCI NETWORKING", and has been throughout — verified against a real U64E boot screen while validating boot_check.py. A maintainer following that line would have written the banner assertion against a string that never appears. Also updates the boot_check.py entry in the UCI test-script list to describe what it now asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-as-pass (audit F10) tools/test_tls_handshake.py had two interlocking defects in the ServerHello group, and neither is visible while the other stands. 1. Test 3a read the carry as `carry = 0; if regs and "P" in regs: carry = regs["P"] & 0x01`. VICE's binary monitor names the 6502 status register "FL", so the lookup never matched, `carry` stayed pinned at 0, and the C=1 error branch was unreachable. (Same root cause as F1 in test_tls_record.py.) 3a is now read through the file's own jsr_check_carry() trampoline (LDA #0 / ROL A / STA), which captures C on the 6502 itself and does not depend on register naming at all. 2. Behind that, both `clc; rts` stub checks answered a detected stub with `return 0, 0`, zeroing the group's passed AND failed counters so it vanished from the denominator — a routine that unconditionally claims success was reported as "ALL 18 TESTS PASSED", exit 0. Fixing (1) alone would have made that path reachable for the first time and traded a dead assertion for a silent skip, so both are fixed together: report_if_stub() prints a loud STUB: diagnostic naming the routine and the tests run anyway, failing on their own merits. The identical stub-skip on tls_derive_handshake_keys gets the same treatment, and the VICE launch now goes through _vice_helpers.default_vice_config() like the other in-tree VICE tests. A/B, `tls_parse_server_hello` replaced by a `clc; rts` stub (verified in the built PRG: bytes at $1491, the label address, read 18 60 where pristine reads A0 00): pristine 21/21 pass, exit 0 stub, fixed test 3a/3b/3c all FAIL, group 0/3, 18/21, exit 1, "STUB: tls_parse_server_hello is a `clc; rts` stub -- unimplemented" stub, baseline test (same binary) prints two FAIL lines, then "SKIP: remaining ServerHello tests" and reports "ALL 18 TESTS PASSED", exit 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ns.py Three documentation-integrity defects in the x25519/nistcurves pin record, all verified mechanically, plus the tool that would have caught the last one. 1. CLAUDE.md claimed in four places that UCI is "the supported sibling-on path" for USE_X25519_SIBLING=1. Neither backend links. Re-measured at f0127a0 with fresh submodules: make USE_X25519_SIBLING=1 (ip65) X25519_RODATA overflows CRYPTO_OVERLAY by 2048 bytes LIB_NISTCURVES_P256_CODE overflows CRYPTO_RESIDENT by 103 bytes make BACKEND=uci USE_X25519_SIBLING=1 (UCI) <- previously undocumented LIB_NISTCURVES_P256_CODE overflows CRYPTO_HOT by 381 bytes make BACKEND=uci (control) -> links clean Root cause recorded with measured segment sizes: the sibling's CRYPTO_CODE claim is 4,207 B against the in-tree pair's 2,769 B. 2. CLAUDE.md cited `make -C libs/x25519 lib-x25519-scalarmult` as the integration wrapper's entry point. No such target exists upstream at any tag, and build_x25519.sh does not call `make -C` at all — it stages three sources, sed-rewrites .segment "CODE", and hand-emits the X25519_RODATA / X25519_BSS modules. Documented as it is, since that is what a version bump must be migrated through. 3. The nistcurves pin was recorded as v0.3.0 in the Crypto ABI section while the actual gitlink is v0.6.0 and the benchmark tables below it are v0.6.0 numbers — the file contradicted itself. Also records that the pinned x25519 v0.6.0 carries upstream c64-x25519 #64 (RFC 7748 decodeUCoordinate MSB desync, fixed in v0.7.0), and that the in-tree implementation is NOT affected because it still writes the mask back into x25_u. Verified: tools/test_x25519.py --slow 73/73 on the in-tree build, including RFC 7748 vector 2 whose u has bit 255 set. tools/check_upstream_pins.py reports pin-vs-upstream-tag drift for every submodule. Stdlib + git only, one `git ls-remote --tags` per submodule. It exists because `git submodule status` renders versions through `git describe` without --tags — annotated tags only — and c64-x25519 tagged v0.6.0 lightweight, so an exactly-on-v0.6.0 pin renders as "v0.5.0-5-g95fdd70" and reads like a stale pin. The script resolves refs/tags/<n>^{} when present and the bare ref otherwise, and reads the gitlink from `git ls-tree` so it works on an uninitialised submodule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping the single symbol `ecdsa_verify` from build/labels.txt made tools/test_x509.py skip its entire ECDSA group, report "ALL 7 TESTS PASSED" and exit 0. The skipped assertions left the denominator instead of counting against it, so the suite reported success while verifying nothing about ECDSA. The realistic trigger is a libs/nistcurves bump renaming an export — exactly the change this suite exists to catch, and live risk today with the siblings two minor versions ahead of the pin. Every group the suite claims to run is now declared in REQUIRED_LABEL_SETS and is required: - main() fails closed in a preflight, before launching VICE: exit 1 with a summary line naming the group and its missing labels. - run_tests() counts each un-runnable group as a failure and names it. This matters independently: tools/run_all_tests.py calls run_tests() directly and only sees (passed, failed), so the arity is unchanged and a vanished group now turns that runner red too. - "no tests ran" is a failure rather than a shrug. - CV_LABELS is deliberately NOT required — no group drives it, and a group that does not exist cannot be silently skipped. Also converts the one other skip-as-pass in this file: a P-384 certificate that fails to parse reported "SKIP: may not be supported yet" and returned (0, 0), which would have hidden a P-384 DER regression. It is now a failure. P-384 cert *parsing* works today (group 2 passes 2/2 — measured, see below); only P-384 ECDSA *verify* is stubbed at the TLS layer. Acceptance (VICE, ip65 build, -reu via default_vice_config): ecdsa_verify absent from the labels view before: "[+] X.509/ECDSA: ALL 7 TESTS PASSED" EXIT_CODE=0 after: "[-] X.509/ECDSA: ABORTED -- declared test group(s) cannot run: ECDSA P-256 verify (group 3) [missing: ecdsa_verify]" EXIT_CODE=1 labels intact after: "[+] X.509/ECDSA: ALL 11 TESTS PASSED" EXIT_CODE=0 The denominator moving 7 -> 11 is the fix working: the ECDSA group now contributes its 4 assertions instead of evaporating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The oracle ran three CAVP vectors, all Result=P, all expecting C=0. That cannot distinguish a working verifier from one that answers "valid" unconditionally — against `ecdsa_verify` stubbed to `clc; rts` it reported 3/3 and exited 0. An oracle with no negative case does not test verification, it tests that the routine returns. Adds three genuine CAVP SigVer P-256/SHA-256 `Result = F` records, one per modification class: [3h] F (3 - S changed) [3i] F (2 - R changed) [3j] F (1 - Message changed) Every `Result = F` record in the file has Q on the curve and r, s in [1, n-1], so none of these can be rejected by a cheap range or point-validity gate — each forces the full verify math and the recovered-R.x comparison. Provenance, established before adding anything: all 15 records in libs/nistcurves/tools/vectors/nist_p256_sigver.rsp were checked host-side against OpenSSL (via `cryptography`) plus independent raw-int on-curve and range checks. Zero disagreements with the .rsp Result column. After transcription, each of the six vectors now in the file was matched byte-for-byte back to a .rsp record and re-verified through OpenSSL *as transcribed here* (prehashed), so a typo could not slip through. No vector was produced by running this implementation. Mechanically: each vector carries `expect_carry` (mirroring the .rsp Result column, never an observed C64 result); the runner compares against it and says plainly when a NIST-invalid signature was accepted. Two structural guards keep F7 from regressing — the run aborts if the vector set ever contains zero negatives, and it fails if any declared vector does not produce a verdict. Acceptance (VICE, ip65 build, -reu via default_vice_config): pristine "[+] ECDSA KAT oracle: ALL 6 VECTORS PASSED (3 valid accepted, 3 invalid rejected)" EXIT_CODE=0 mutant: `clc; rts` at the top of ecdsa_verify (always VALID) verified present in the built binary, not just in the source — `ecdsa_verify` resolved from build/labels.txt to $6B23, mapped through the PRG load address $0801 to file offset 25378, bytes there read `18 60 ...` = CLC, RTS " Passed: 3/6 / Failed: 3/6" "[-] ECDSA KAT oracle: 3 VECTOR(S) FAILED" EXIT_CODE=1 The mutant's three passes are exactly the three vectors this file used to contain — i.e. the old oracle would have reported a clean 3/3 against a verifier that validates everything. The mutation was reverted before commit and the rebuilt PRG hashes identical to the pristine build (db31111031e2f30c52c9116576d74f21e2cc6ddee025041a881345efa7ba8f60). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`tools/integration/build_nistcurves_p384.sh` hardcoded the archive member name `ecdsa384.o`. Upstream renamed it to `ecdsa384_nocomb.o` in c64-nist-curves commit 64b313d (their issue #61), released in v0.5.0 — i.e. before our own v0.6.0 pin, and unchanged by v0.7.0 and v0.8.0. Every P-384 target therefore died immediately at `ar65`, which CLAUDE.md recorded as opaque "upstream layout drift". Upstream builds every P-384 archive from LIB_P384_VERIFY_OBJS = $(LIB_P384_VERIFY_BASE_OBJS) \ $(BUILD_DIR)/ecdsa384_nocomb.o so `ar65 x` never produces `ecdsa384.o`; the staging dir at the point of failure contains `ecdsa384_nocomb.o` and no `ecdsa384.o`. A/B at the unchanged v0.6.0 pin, `make p384-overlay`: before: [p384] building libs/nistcurves lib-p384-sha384 + lib-p384-verify ... ar65: Error: Could not open '.../nistcurves_p384_staging/curve/ecdsa384.o': No such file or directory make: *** [build/lib/nistcurves-p384-sha384.a] Error 1 after: built .../build/lib/nistcurves-p384-sha384.a built .../build/lib/nistcurves-p384-curve.a ... ecdsa384_nocomb 10658 bytes (.o) ... ld65: Warning: cfg/p384-overlay-sha384.cfg(29): Segment 'LIB_NISTCURVES_SHA384_TABLES' overflows memory area 'OVERLAY_REGION' by 1536 bytes Both archives now build and the chain advances to the next, separate blocker — exactly the SHA-384 LUT overflow CLAUDE.md predicted, now confirmed at 1536 B. Two further P-384 blockers are documented rather than fixed here: the `USE_OVERLAY_P384_EMBED=1` build cannot resolve its order-only `build/labels.txt` prerequisite from clean, and the wrapper's `ec_scalar_mul_384_shim` is now dead (od65 shows `ecdsa384_nocomb.o` imports `ec_scalar_mul_var_384`, not `ec_scalar_mul_384`). Blast radius: none on the shipped builds. The script runs only under `make p384-overlay` / `USE_OVERLAY_P384_EMBED=1`. Re-verified after the change: `make` links at 47,105 B and `make BACKEND=uci` at 62,977 B, both unchanged. Also corrects documentation drift found while auditing our alignment against upstream c64-nist-curves and c64-lib-contract: - the `libs/nistcurves` pin is v0.6.0, not v0.3.0 (verified by fresh clone + `git fetch --tags --force`; `git describe` gives an exact tag, so the 2026-05 history rewrite is not confusing us) - segment naming is c64-lib-contract SPEC §4, not §8.1 (§8.1 is the shared `sqtab` table) - c64-nist-curves#54 is CLOSED as COMPLETED (2026-07-16) and shipped before upstream v0.4.0 — it has been inside our pin all along, so it is not headroom held in reserve. Two places said otherwise. - the contract's git tags lag `main` by four minor versions (newest tag v0.4.0, `main` is SPEC v0.7.2). Records which post-v0.4.0 clauses bind a *consumer*: §13 network backend ABI (our intake issue #70 is open), §8.0 `LIB_<X>_SHARED_CONSUMES`, and §1/§5 prefixed manifest exports with `-D LIB_NO_BARE_EXPORTS=1`. - records why a v0.7.0/v0.8.0 bump does not link under UCI today: `LIB_NISTCURVES_P256_RODATA` overflows `CRYPTO_HOT` by 207 B, because CRYPTO_HOT is one byte from full at the current pin (map: 009E1F..009FFE, region ends $9FFF) and v0.7.0's FIPS 186-5 §3.3 public-key validation gate adds +512 B. ip65 links at v0.8.0 and both VICE suites pass there (KAT 3/3, x509 11/11). - notes the fresh-submodule ip65 blob relink trap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client verifies the server's Finished HMAC and aborts on mismatch (tls_verify_finished in src/tls_keyschedule.s -> bcs @enc_error in src/tls13.s), but nothing exercised the abort. Mutation proved it: with the mismatch branch inverted (sec -> clc) the full hardware e2e still reached HTTP 200 with the correct body, undetected. Every listener the suite talks to sends a *correct* Finished, so the branch was invisible to the tests. Two new tests, one narrow and one end-to-end. tools/test_finished_verify.py (VICE) drives tls_verify_finished directly over DMA: two independent (secret, transcript) vector sets x 9 cases — positive, first/last-byte flips, all-zeros, all-ones, truncated, rotated, and the two realistic attacks (a valid HMAC under the wrong secret, and one over the wrong transcript). The carry is latched into RAM by a 6502 stub (JSR / LDA #0 / ROL A / STA) rather than read from the P register; the latch byte is poisoned beforehand and an unwritten latch is reported as inconclusive, never a pass. The positive case also checks the C64's computed tls_verify_data against an independent Python HKDF-Expand-Label + HMAC-SHA256, so "always accept" cannot pass either. tools/uci/test_https_bad_finished.py (U64E/C64U) is the end-to-end version. Python's ssl module cannot corrupt its own Finished, and flipping a bit in the ciphertext only breaks the Poly1305 tag — the client would then reject at aead_decrypt and never reach the Finished comparison, which would be a false pass. So the server side is written out by hand in tools/https_e2e/evil_listener.py: real X25519 ECDHE, real RFC 8446 key schedule, real ChaCha20-Poly1305 record layer, real P-256 CertificateVerify, with exactly one bit flipped in the server Finished verify_data before encryption. It self-validates against Python's own ssl client (--selftest) and carries a FINISHED_MODE=good control that must be run first. The oracle uses tls_last_state, which src/tls13.s:@error stashes on abort: tls_state=$FF plus tls_last_state=6 (FINISHED) proves the abort happened at Finished and not earlier at Certificate or CertificateVerify. Server-side evidence the client cannot fabricate is asserted too (client_accepted_finished). evil_listener.py is a test fixture, not a TLS stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…roup tools/test_x25519.py reported "RESULTS: 71/71 passed, 0/71 failed" while skipping both RFC 7748 scalarmult vectors. Those two are the only end-to-end x25519_scalarmult coverage in the file — everything else in the 71 is field arithmetic — so the default invocation certified a clean pass without ever multiplying a scalar. Skipped groups were never added to test_groups, so they left the denominator entirely and the counters could not express the gap. Same skip-as-pass shape as audit finding F3 (credit: Lane C confirmed the mechanism independently). The gate's justification was a "~100 min each" comment. Measured under VICE warp on the in-tree ip65 build: full suite with both vectors 37.9 s full suite without them 4.8 s ~16.5 s per vector — the comment was off by a factor of ~360, and the gate was buying 33 seconds while hiding the only test that matters. So both halves: - The vectors now run BY DEFAULT. `--fast` skips them; `--slow` is kept as an accepted no-op so existing invocations don't break. - Any skipped group is recorded and named in the verdict: RESULTS: 71/71 passed, 0/71 failed -- 2 group(s) SKIPPED: \ x25519 RFC 7748 vector 1, x25519 RFC 7748 vector 2 WARNING: end-to-end x25519_scalarmult coverage did NOT run; \ this run does not certify X25519. An unqualified clean pass is no longer printable over a group that did not run. Exit code is unchanged (0 iff nothing failed): unlike F3's missing-label case, `--fast` is an explicit operator choice, so it is not an error — but it can no longer be a silent one. run_tests now returns (passed, failed, skipped_groups). tools/run_all_tests.py is the only in-tree importer; it unpacks the third value and raises if it is ever non-empty, so a future gate cannot quietly drop coverage from the aggregate verdict. That run gains the vectors too: x25519 72/72 -> 73/73, aggregate 255/255, +34.8 s for the suite. Also corrected in passing: the vectors were labelled "RFC 7748 Section 6.1" but are the §5.2 scalarmult vectors (§6.1 is the Alice/Bob DH pair), and U_2 ends 0x93 — bit 255 set — which makes vector 2 the decodeUCoordinate MSB regression test that catches upstream c64-x25519 #64, the bug live in our pinned libs/x25519 v0.6.0. Noted at the vectors so nobody drops it as redundant. Verified: default -> 73/73, both vectors PASS, 38.2 s --fast -> 71/71 + both groups named + warning --slow -> 73/73 (back-compat no-op) run_all_tests.py --skip-slow -> x25519 73/73, TOTAL 255/255 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cond door) `tools/run_all_tests.py --skip-slow` omitted "x509" from the suite list by never adding it, so the aggregate printed a TOTAL and exited 0 with no trace that the whole X.509/ECDSA suite had not run. That is F3's shape one level up: the skipped assertions left the denominator instead of being accounted for, and the dropped suite is precisely the one F3 is about. The numbers make the failure mode concrete. A full aggregate run reports TOTAL: 264/264 with `x509 11/11`; `--skip-slow` reported TOTAL: 253/253. Same "everything passed" shape, 11 assertions lighter, and nothing in the output says which 11 or why. Skipped suites are now recorded as (name, reason) and surfaced three ways: named on the TOTAL line, given their own SKIP row in the per-suite table, and followed by an explicit warning that the aggregate does not certify them. Exit code stays 0. An operator passing --skip-slow made a deliberate choice, unlike F3's missing label, which is an unrequested environmental failure. The rule the two cases share is the one that matters: an involuntary skip is a failure, an explicit skip is allowed but must never be silent. Same convention Lane G adopted for test_x25519.py's --fast gate in PR #81. Acceptance (`--skip-slow --workers 4`, real aggregate runs): before TOTAL: 253/253 passed, 0 failed (no x509 row, no mention anywhere in the output) EXIT_CODE=0 after TOTAL: 253/253 passed, 0 failed -- 1 suite(s) SKIPPED: x509 ... SKIP x509 --- did not run: --skip-slow (X.509 DER parsing + ECDSA P-256 verify) WARNING: the suite(s) above did not run. This aggregate result does not certify them, and their assertions are absent from the TOTAL. EXIT_CODE=0 Reported by Lane G while reviewing the F3 fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-application of this branch's own finding. The script's docstring said a full run is "a couple of seconds" — true, but I had never measured it, which is the exact shape of the `~100 min each` comment in test_x25519.py that this branch just deleted: a cost assertion sitting in prose where no run can contradict it, quietly setting policy. Measured instead: 2.1 s wall-clock, 0.15 s CPU, 3 submodules, warm DNS. Network-bound, scales with submodule count rather than repo size. Also verified rather than assumed, by stubbing subprocess.run: exactly 3 `git ls-remote --tags` and 3 `git ls-tree HEAD` for 3 submodules, so the "one per submodule" claim is accurate. Imports are stdlib-only (__future__, argparse, json, os, re, subprocess, sys). All three claims held; none needed correcting. The change is that they are now specific, dated, and paired with the command that falsifies them — a vague-but-true cost note is one refactor away from a false one. Docstring only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md's Build section described `ip65-build/ip65-c64.bin` as "the committed blob" and `make ip65-libs` as "only needed if the ip65 submodule changes". Both are wrong, and they reinforce each other: the reader is told the artifact ships with the repo (so no rebuild) and that the library build is conditional (so skip it) — which routes them past the only step that works. The blob is gitignored (`.gitignore: ip65-build/*.bin`; `git ls-files ip65-build/` returns only ip65.cfg and ip65_stub.s). A fresh clone has no blob, `make` tries to build one, and the link step consumes ip65 `.lib` archives the submodule does not ship, so it dies with: ld65: Error: Input file '../ip65/ip65/ip65_tcp.lib' not found which reads as a broken checkout rather than a missing one-time step. Reproduced in two independent fresh worktrees; `make ip65-libs && make` then links clean and yields the 47,105 B ip65 PRG. The blob rebuild is deterministic — 6,951 B, sha256 cf1a5ff7…0c2d90dd in both worktrees. - Build gains a "first build in a fresh clone" block with the working order, and states what "normally reused" actually means: `clean` only removes `build/`, so a once-built blob persists. - ip65-libs / ip65-blob target descriptions corrected. - Same false claim removed from the Makefile's own comments (two sites), which is where it was being copied from. - The VICE ip65 rig's prerequisite list gains the blob step; it is the one rig that needs an ip65 PRG. - While in the section: the C64_SKIP_BUILD count said 7, it is 14 (13 under tools/, plus tests/test_vice_https_macos.py). Replaced the number with the grep that regenerates the list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… two Correction routed from Lane G, verified here before restating: the determinism evidence for ip65-c64.bin is stronger than the sentence claimed. Three independent worktrees rebuilt it byte-identically on 2026-08-13 (Lane C's, Lane G's, mine), and the 2026-05-06 copies carried in three repo snapshots hash the same cf1a5ff7...0c2d90dd — I hashed those three directly rather than take the report. The sentence carries the load of the whole section: if the rebuild is deterministic then nobody ever needed a committed copy, and the fix is to name the one-time prerequisite. Four agreeing artifacts across three months make that point better than two, so it is worth the accuracy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while verifying my own branch's PRG-neutrality: an incremental `make` after a `BACKEND=uci` build produced an ip65 PRG of exactly the right size (47,105 B) and the wrong contents — d483d46f… against the clean tree's db311110…, with no warning. The final make ran ld65 alone; every object was "up to date" by timestamp. The mechanism is worth naming because it is wider than the flag rule the Packaging section already states. BACKEND= is not a -D flag, it selects `-I src/net/$(BACKEND)`, and src/tls13.s includes net_tuning.inc from that path. So the stale object carries the *other* backend's post-ServerHello drain budget: an ip65 PRG built this way gets UCI's 1x16 instead of 8x250, which is precisely the #73 regression that #74 fixed — reintroduced by a build order rather than by a code change, in the one backend where the drain is load-bearing (ip65 sends no MSS and ACKs only when polled). Documented at the top of Variables, where someone about to type `make BACKEND=...` will read it, with the two hashes as evidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntirely Lane G reproduced the mixed link deliberately from a third worktree, so I went back to reproduce it cleanly here rather than rely on the accidental first sighting — and the deliberate run failed a different way, which is worth recording because it defeats the obvious check. make clean && make BACKEND=uci then plain make -> 4 ca65 lines, NO ld65 line, exit 0, build/c64-https.prg still the 62,977 B UCI image macOS ships GNU Make 3.81, which compares mtimes at 1-second resolution. The recompiled ip65 objects were 39 ms newer than the previous link; make's own -d output says "Prerequisite build/net/ip65/net.o is older than target build/c64-https.prg" and skips the link. A second `make` then says "Nothing to be done for `all'". So a BACKEND switch without clean gives you either a mixed PRG at the right size (the first mode, d483d46f... vs db311110...) or no rebuild at all and the wrong backend's PRG entirely — which of the two depends on whether the compile crosses a second boundary. Size catches the second, hash catches both, exit code catches neither. Object hashes are deliberately not cited: --debug-info embeds source paths, so .o hashes differ across worktrees even when the linked PRG is identical (confirmed — my clean ip65 tls13.o does not match Lane G's, while both link to db311110...). PRG hashes are the portable evidence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correcting my own reasoning in a0039b1. That commit said object hashes differ across worktrees because `--debug-info` embeds source paths. That is wrong, and I checked it this time instead of reasoning about it: `strings build/tls13.o` shows only relative paths (`src/tls13.s`, `src/net/ip65/net_tuning.inc`) and zero absolute ones, so two worktrees would agree. The real mechanism is worse for the conclusion's sake, and better for the advice. ca65 stamps the build's wall-clock time into the object header — a little-endian 32-bit field at offset 107 in build/tls13.o that matches `date +%s` of the build exactly (searched the header for a LE32 within +/-20 s of the build time; one hit, delta 0). So rebuilding identical source in the SAME worktree changes the object hash: 6cd91553... -> cfef8378... over a `touch src/tls13.s && make`, four bytes differing. Object hashes are therefore not weak evidence, they are not evidence: nobody can reproduce their own twice. The useful half is the asymmetry — ld65 does not propagate that field, and build/c64-https.prg stayed at db31111031e2... across every one of those rebuilds. PRG-hash comparison works because of a checkable toolchain property, which is worth stating where the neutrality checks in this session all quietly relied on it. Credit to Lane G for catching the wrong mechanism; the timestamp offset and the same-worktree reproduction are measured here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dc016a7 quoted "LE32 at offset 107 in build/tls13.o". The offset should not be in the doc: it is a cc65-version detail, and someone reproducing on another toolchain who finds nothing at 107 would reasonably conclude the whole finding was wrong. The reproduction that always works is two clean builds and a hash comparison, so that is what the section now says. For the record, since three readings of this effect are now in circulation (100, 100/101, 107): measured across three objects in one tree — build/tls13.o, build/boot.o, build/crypto/sha256.o — every build changes bytes at 0-based 100-101 AND 107-108, and in all three objects the LE32 at 107 equals `date +%s` of the build exactly. So the offsets were consistent across objects here, and the divergent readings look like different bytes of the same effect rather than an object-dependent layout. That detail is worth knowing and not worth documenting, which is the point. Conclusion unchanged and now confirmed three ways: object hashes are not evidence, the PRG hash is deterministic, and every neutrality claim in this remediation rests on the latter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(test): AEAD tamper oracle must fail closed (audit F1)
fix(test): ServerHello oracle — dead carry gate + stub skip-as-pass (audit F10)
audit F4/F8/F9: boot_check asserts a real boot; cipher-suite doc fix; runnable no-REU KAT
docs: the ip65 blob is not committed — document the fresh-clone build order
docs: correct the x25519 sibling record; add pin-drift check; fix test_x25519 skip-as-pass
fix(test/uci): make the HTTPS e2e oracle fail when it should (audit F5 + F6)
fix(test): F3 skip-as-pass in test_x509.py + F7 negative KAT vectors
test(tls): cover the server-Finished rejection path (audit F2)
Resolves the two CLAUDE.md conflicts by taking the better side of each rather than picking a branch: - Known-issues x25519 block: took the merged-stack version. It is a strict superset and this branch's side still asserted "UCI remains the supported sibling-on path", which #81 disproved by measurement (UCI overflows CRYPTO_HOT by 381 B; nothing links the sibling today). It also still cited a `make -C libs/x25519 lib-x25519-scalarmult` target that exists at no tag. - ECDSA wall-clock note: took this branch's version, which adds the "(the pin is v0.6.0 today)" clarification correcting the file's own v0.3.0-vs-v0.6.0 self-contradiction. The c64-nist-curves#54 CLOSED correction that lived in the dropped hunk survives via this branch's two non-conflicting hunks; verified both remaining mentions state CLOSED and no "optional headroom" phrasing is left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(p384): un-break the P-384 archive build + upstream contract-alignment doc corrections
master carried #74 (per-backend post-ServerHello drain budget) and #75 (span-input parser) independently of this branch. #74's change also exists here, so both copies of its comment block conflicted. Both conflicts are comment-only. Resolved in favour of this branch's text, which carries the corrected per-poll cost: ~40 ms, not ~37 ms. CLAUDE.md derives 40.7 ms (80.8 s / 1984 polls, C64U onchip) and 39.8 ms (78.9 s / 1984, U64E REU) from the measured regression; the ~37 ms figure on master is stale by one round of measurement. No code differences were involved. Verified the load-bearing constants are unchanged on both backends: UCI 1x16, ip65 8x250. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Baseline catch-up for work already on this branch, filed first so the audit-remediation PRs that follow keep minimal diffs.
Contents:
docs: refresh benchmarks + ip65 statusfor feat(cfg): ip65 refit — un-break BACKEND=ip65 via cert_buf/verify-scratch union #68/fix(crypto): REU-less X25519 under USE_NISTCURVES_ONCHIP — fe_mul rows via og_common #69/feat(e2e): hardware-free REU-less HTTPS e2e in VICE + fix(tls): post-ServerHello drain #71/fix(tls): per-backend post-ServerHello drain budget — restores UCI handshake wall-clock #74fix(tls): per-backend post-ServerHello drain budget— UCI regression 125.4 s -> 44.6 sfix(tools/uci): turbo probe read the wrong JSON shape; skip redundant turbo writechore: untrack 230.ca65-lscache pickles and.mcp.json(per-developer tooling with a machine-specific path); both gitignoreddocs: align the UCInet_pollcost (~40 ms, not ~37 ms) acrossnet_tuning.inc/tls13.s/ CLAUDE.md, and correct the ip65 x25519-sibling failure from "BSS overflow" to "code/rodata overflow"Verified: both backends rebuild clean (ip65 47,105 B, uci 62,977 B) and a UCI HTTPS e2e passes end-to-end on the U64E at 48 MHz.
🤖 Generated with Claude Code