Skip to content

docs: correct the x25519 sibling record; add pin-drift check; fix test_x25519 skip-as-pass - #81

Merged
JC-000 merged 3 commits into
docs/benchmark-refreshfrom
chore/x25519-alignment
Aug 13, 2026
Merged

docs: correct the x25519 sibling record; add pin-drift check; fix test_x25519 skip-as-pass#81
JC-000 merged 3 commits into
docs/benchmark-refreshfrom
chore/x25519-alignment

Conversation

@JC-000

@JC-000JC-000 commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Lane G — X25519 sibling alignment + upstream monitoring. Docs, one new tool, and one
test-coverage fix; no submodule bump, no cfg change — not a byte of any PRG moves.

That last clause is a claim, so here it is as a check rather than an assurance —
clean build at the base and at this branch's tip:

f0127a0 (base) db31111031e2f30c52c9116576d74f21e2cc6ddee025041a881345efa7ba8f60 47,105 B
54498cc (this branch) db31111031e2f30c52c9116576d74f21e2cc6ddee025041a881345efa7ba8f60 47,105 B

Byte-identical. (Lane C's fix/audit-f3-f7-ecdsa-coverage independently produces the
same PRG in a separate worktree, and the underlying ip65-build/ip65-c64.bin
6,951 B, sha256 cf1a5ff7809af4e4655e385b378b936054f41046ff2b7604828af3240c2d90dd
now has agreeing from-source builds in three worktrees plus a copy built 2026-05-06.
See #83 and #86.)

make clean && make is load-bearing in that reproduction, not decoration. This is
a property of the source tree, not of whatever is sitting in build/. BACKEND is
not a -D flag — it selects an include path (-I src/net/$(BACKEND)), and
src/tls13.s:30 does .include "net_tuning.inc". So a plain make after a
BACKEND=uci build finds tls13.o up to date by timestamp, never recompiles it, and
links an ip65 PRG carrying UCI's post-ServerHello drain budget (1x16 instead of
8x250) — issue #73's regression reintroduced by build order rather than by any code
change, in the backend where that budget is load-bearing. Reproduced here
deliberately after Lane E hit it by accident:

make clean && make BACKEND=uci then make -> d483d46f7b70… 47,105 B (4 objects recompiled, tls13.o not among them)
make clean && make -> db31111031e2… 47,105 B

Same size, no warning, and it would pass any size check. Lane E hit a second
outcome of the same trap on GNU Make 3.81 (what macOS ships): if the recompiles land
inside the same 1-second mtime granule as the previous link, make skips the link
entirely, exits 0, and leaves the other backend's PRG at the output path — wrong
backend, wrong size. So size catches that one, hash catches both, exit code catches
neither.

Do not cite .o hashes as evidence — ca65 objects are not reproducible. I
originally quoted tls13.o hashes here as a proof chain and have removed them. Six
consecutive clean builds of the same source in the same worktree gave six different
tls13.o hashes. The cause is a build timestamp in the object header: a ca65
variable-length integer (7-bit groups, LSB first, continuation bit in bit 7) starting
at offset 100, which decodes to exactly date +%s of the build — confirmed twice
with delta = 0 s, most recently varlen@100 = 1786623518 against a captured
date +%s of 1786623518.

Two corrections worth recording, since both readings that preceded this one were
wrong in ways that looked right:

  • It is not a worktree-portability artifact. The embedded paths are relative
    (src/tls13.s, src/constants.inc, src/net/ip65/net_tuning.inc) and there are
    zero absolute paths, so two worktrees would otherwise agree. The objects are not
    deterministic in place.
  • The number of differing bytes is not fixed — a plain make clean && make moves
    two bytes here, other rebuilds up to five. That is the variable-length encoding: how
    many bytes change depends on how many 7-bit groups roll over between builds. Reading
    the first byte as "the low byte of the timestamp" holds only when bit 7 of the value
    is set (~50% of builds).
  • The header carries a second, different timestamp that is easy to mistake for the
    build stamp: a plain LE32 at offset 107 is the source file's mtime
    (LE32@107 = 1786619514 = stat -f %m src/tls13.s, constant across builds — verified,
    not inferred). It equals the build time only if you touch the source first, which
    is exactly how it can look like a build stamp under an incremental-build test. The
    build stamp is the varlen at 100; the mtime at 107 cannot explain non-determinism,
    since it does not change unless the source does.

ld65 does not propagate that field, which is exactly why the PRG hash above is
trustworthy: it held at db31111031e2… across every one of those builds. That
asymmetry is a checkable property of the toolchain, not a convention — and the whole
neutrality matrix rests on it.

Every hash and test result in this PR comes from a make clean build; the one
measurement that could conceivably have been affected — the od65 segment sizes below
— is provably immune, since fe25519.s/x25519.s include only constants.inc, which
neither backend directory shadows. Documented by #86 at the top of the Variables block;
CLAUDE.md previously carried the rule only in Packaging and only about flags.

Three commits: fb8ba56 (docs + check_upstream_pins.py), 0080274 (the
test_x25519.py coverage fix, added after Lane C confirmed the finding and declined
the handoff — see the last section), and 54498cc (docstring only, below).

54498cc — turning the finding on my own file.check_upstream_pins.py's
docstring claimed a full run is "a couple of seconds". True, but unmeasured — the
same shape as the ~100 min each comment this branch deletes: a cost assertion
sitting in prose where no run can contradict it. Measured: 2.1 s wall-clock,
0.15 s CPU
(3 submodules, warm DNS), and verified by stubbing subprocess.run
that it really is one git ls-remote --tags + one git ls-tree per submodule.
All three claims held; none needed correcting. What changed is that they are now
specific, dated, and paired with the command that falsifies them.

Base docs/benchmark-refresh @ f0127a0. All measurements 2026-08-13, fresh
submodule clones, cc65 from homebrew, VICE via default_vice_config() (-reu).


Preamble — the pin discrepancy that started this, and its resolution

The lane was opened because git submodule status reports libs/x25519 as
v0.5.0-5-g95fdd70 while CLAUDE.md says the pin is v0.6.0.

CLAUDE.md was right. The pinned gitlink is
95fdd705b0f7d780cada3dee08158084d327c3f9, identical on
origin/docs/benchmark-refresh, local docs/benchmark-refresh, and
origin/master, and it is exactly the v0.6.0 tag commit:

$ git cat-file -t v0.6.0 -> commit (LIGHTWEIGHT tag)
$ git rev-parse v0.6.0^{commit} -> 95fdd705b0f7... (== the gitlink)
$ git describe 95fdd705 -> v0.5.0-5-g95fdd70 (annotated-only, skips v0.6.0)
$ git describe --tags 95fdd705 -> v0.6.0

v0.5.0/v0.7.0/v0.8.0 are annotated; v0.6.0 is lightweight. git describe
without --tags walks past it, and git submodule status renders through exactly
that call. A tooling artefact, not a stale clone and not a doc bug — but one that
will recur, so it is now written down and the new script is immune to it.


Defect 1 — USE_X25519_SIBLING=1 links on neither backend

CLAUDE.md said in four places that ip65 is broken and "UCI remains the supported
sibling-on path". UCI is broken too, and that failure was nowhere on record.

A/B, verbatim:

$ make USE_X25519_SIBLING=1 # ip65 — matches the documented failure
ld65: Warning: cfg/c64-https-ip65.cfg(103): Segment 'X25519_RODATA' overflows memory area 'CRYPTO_OVERLAY' by 2048 bytes
ld65: Warning: cfg/c64-https-ip65.cfg(106): Segment 'LIB_NISTCURVES_P256_CODE' overflows memory area 'CRYPTO_RESIDENT' by 103 bytes
ld65: Error: Cannot generate most of the files due to memory area overflows
make: *** [build/c64-https.prg] Error 1
$ make BACKEND=uci USE_X25519_SIBLING=1 # UCI — NOT PREVIOUSLY DOCUMENTED
ld65: Warning: cfg/c64-https-uci.cfg(128): Segment 'LIB_NISTCURVES_P256_CODE' overflows memory area 'CRYPTO_HOT' by 381 bytes
ld65: Error: Cannot generate most of the files due to memory area overflow
make: *** [build/c64-https.prg] Error 1
$ make BACKEND=uci # control
ld65 -C cfg/c64-https-uci.cfg ... -o build/c64-https.prg ...
sed -i '' 's/^al 00\([0-9a-fA-F]\{4\}\) /al C:\1 /' build/labels.txt
# links clean, exit 0

The ip65 pair (2048 / 103) reproduces CLAUDE.md's 2026-07-29 measurement exactly —
good sign for the rest of that record. The UCI 381 B is new.

Cause, measured (od65 --dump-segsize) rather than asserted:

modulesegmentbytes
sibling fe25519_raw.o + x25519_raw.o + x25519_init_raw.oCRYPTO_CODE2,711 + 698 + 798 = 4,207
in-tree fe25519.o + x25519.oCRYPTO_CODE2,093 + 676 = 2,769
sibling data_x25519_rodata_raw.oX25519_RODATA2,304
sibling data_x25519_bss_raw.oX25519_BSS1,536

+1,438 B of CRYPTO_CODE into a CRYPTO_HOT with ~1,057 B of slack. ip65 additionally
cannot fit 2,304 B of X25519_RODATA into its 4,212 B CRYPTO_OVERLAY beside
TLS_CODE + CRYPTO_AUX_CODE.

Blast radius: no shipped or buildable artifact contains the x25519 sibling, so
anything downstream of "the sibling is in use" is moot today. That is reassuring for
Defect 2.

Defect 2 — the pinned v0.6.0 carries an upstream correctness bug (recorded, not fixed)

c64-x25519 #64, fixed in v0.7.0: x25519_scalarmult returns deterministically
wrong results for a peer u-coordinate with bit 255 set, across v0.4.0–v0.6.0
— our pin. v0.4.0 stopped writing the RFC 7748 decodeUCoordinate mask back into
x25_u but left the ladder's z_3 = x_1 * (DA-CB)^2 site reading the unmasked
buffer; 2^255 == 19 (mod p), so x1 and x3 disagree by 19.

Verified in source at both tags, not taken from release notes:

v0.6.0 src/x25519.s:511 lda #<(x25_u) <- z_3 reads UNMASKED x25_u
v0.7.0 src/x25519.s:537 lda #<(x25_x1) <- reads the masked snapshot

The in-tree implementation is not affected.src/crypto/x25519.s:116 still does
sta x25_u+31 after the and #$7f, so its x_1 read at :441 sees the masked value —
c64-https never took v0.4.0's no-mutation change, so it never acquired the bug.

A/B, in-tree positive control:

$ C64_SKIP_BUILD=1 python3 tools/test_x25519.py --slow
--- x25519 RFC 7748 vector 1 ---
RFC 7748 vector 1... PASS
--- x25519 RFC 7748 vector 2 ---
RFC 7748 vector 2... PASS
RESULTS: 73/73 passed, 0/73 failed

U_2 is ...c715a493 — last byte 0x93, bit 255 set: precisely the vector that
caught #64 upstream. So our own suite would catch the sibling bug the moment the
sibling links. Coverage exists; the build does not.

In TLS terms the peer u is the server's ServerHello key share; conforming servers
send canonical keys (u < p), so this is interop/conformance, not secret leakage. But
it is a hard blocker on the flag-flip decision CLAUDE.md records as pending: do not
default the sibling on at v0.6.0.

Defect 3 — a cited build target that does not exist

CLAUDE.md pointed at make -C libs/x25519 lib-x25519-scalarmult.

$ git -C libs/x25519 grep -n '^lib' v0.6.0 -- Makefile
Makefile:138:lib: Makefile:167:lib-verify: Makefile:212:lib-x25519-1764:
$ git -C libs/x25519 grep -n '^lib' v0.8.0 -- Makefile
Makefile:138:lib: Makefile:208:lib-verify: Makefile:260:lib-x25519-onchip: Makefile:279:lib-x25519-1764:

No such target at any tag. And build_x25519.sh does not call make -C at all — it
stages three sources, sed-rewrites .segment "CODE" to CRYPTO_CODE, and
hand-emits the X25519_RODATA / X25519_BSS modules. Documented as it actually is,
because that is what a version bump has to be migrated through.
(docs/library-ingestion-architecture.md:294 proposes such a target in the future
tense — the likely origin of the error — and was left alone.)

Defect 4 — the file contradicted itself about the nistcurves pin

Crypto ABI section said libs/nistcurves@v0.3.0; the gitlink is 00d2626ce… == the
v0.6.0 tag, and every benchmark table below it is v0.6.0 data. Same shape as audit
finding F8. Corrected, and the stale v0.3.0 wall-clock paragraph relabelled as the
historical REU baseline it is. Found by the new script, not by reading.


New tool — tools/check_upstream_pins.py

Stdlib + git only. No gh, no tokens, no HTTP libraries. One
git ls-remote --tags per submodule; a full run is a couple of seconds.

$ tools/check_upstream_pins.py
ip65
pinned 25a9c5aa9480 (no tag points here)
upstream (no semver tags found)
libs/nistcurves
pinned 00d2626ceb6b v0.6.0
upstream 8a3b00279fe5 v0.8.0 <-- DRIFT, 2 release(s) behind
libs/x25519
pinned 95fdd705b0f7 v0.6.0
upstream 28e3e7487d89 v0.8.0 <-- DRIFT, 2 release(s) behind
$ tools/check_upstream_pins.py --strict --submodule libs/x25519 ; echo $?
1

Also --json and --ref. Two load-bearing design points:

  • Tag resolution is lightweight/annotated agnostic — prefers
    refs/tags/<n>^{} when the peeled ref exists, falls back to the bare ref, so the
    value is always a commit SHA. This is exactly the trap that produced the preamble's
    discrepancy: the tool that raised the false alarm would not have.
  • Pins come from git ls-tree <ref> -- <path>, not the working copy — correct for
    a submodule that was never --init'd (this worktree started that way) and immune
    to a dirty checkout.

--strict exits 1 on drift, so it drops into cron/CI unchanged. Suggested cadence
weekly; needs network, no credentials.


Also recorded in CLAUDE.md (no code change)

  • The v0.8.0 bump is a scoped project, not a submodule move. v0.8.0 renames
    CODE/DATA -> LIB_X25519_CODE/LIB_X25519_DATA and adds
    LIB_X25519_INIT_CODE (which must be the last file-emitting segment before any
    bss-type segment); our sed then matches nothing silently. v0.7.0's docs: confirm U64E comb 48 MHz point at n=6 — 18.39 s median #64 fix
    adds an x25_x1 buffer our hand-emitted BSS stub does not export.
  • v0.8.0's LIB_X25519_RESIDENT_BYTES 9224 -> 8383 is not a shrink — 826 B of it
    moved to reclaimable COLD_BYTES (8383 + 826 == v0.7.0's 9209; net ~15 B), and our
    wrapper stages only three of the sibling's sources anyway, so upstream manifest
    deltas do not subtract from the overflows above. Easy to read the wrong way.
  • v0.8.0's X25519_ONCHIP_MUL ships LIB_X25519_REU_BANKS_USED = 0 with the
    whole §8.2 export surface gated out (src/reu_config.s:199), including
    reu_fetch_mul_row — which is the stated reason for the
    USE_NISTCURVES_ONCHIP / USE_X25519_SIBLING mutual exclusion at Makefile:90.
    Recorded as a lead; whether the combination links is unverified.

Not fixed here, reported instead

  • A bump. The evidence bar (both backends link clean + crypto tests pass) cannot
    be met: ip65's 2,048 B X25519_RODATA overflow is ours, not upstream's — those
    tables are hand-emitted by build_x25519.sh — so no submodule move touches it.
  • Memory note onchip_x25519_reu_dependent is stale and should be retired: PR fix(crypto): REU-less X25519 under USE_NISTCURVES_ONCHIP — fe_mul rows via og_common #69
    (in this baseline) gates the reu_fetch_mul_row import out entirely under
    USE_NISTCURVES_ONCHIP (src/crypto/fe25519.s:68-78), importing og_common /
    og_src_ld instead, so the REU-DMA path is unlinkable rather than merely unused —
    and PR feat(e2e): hardware-free REU-less HTTPS e2e in VICE + fix(tls): post-ServerHello drain #71's REU-less ip65+onchip VICE e2e exercises a full handshake with no REU.
    The shipped "no REU required" claim is true at HEAD.
  • tools/run_all_tests.py --skip-slow drops the whole x509 suite — a wider
    instance of the same class, untouched here. Flagged for Lane C.

Commit 2 (0080274) — tools/test_x25519.py was F3-shaped

Added after Lane C independently confirmed the mechanism and asked me to keep it here
rather than split one file across two PRs. Lane C's reading of the mechanism:
the skip is announced once inside run_tests, the skipped groups were never added to
test_groups, and the summary reads only the counters — so skipped assertions leave
the denominator instead of counting against it
.

Before:

$ C64_SKIP_BUILD=1 python3 tools/test_x25519.py
(x25519 scalarmult tests skipped -- use --slow to enable, ~100 min each)
...
RESULTS: 71/71 passed, 0/71 failed

The two RFC 7748 vectors are the only end-to-end x25519_scalarmult coverage in the
file — everything else in that 71 is field arithmetic. The default invocation
certified a clean pass without ever multiplying a scalar.

The ~100 min each comment was the whole justification for the gate, and it is
wrong.
Measured under VICE warp on the in-tree ip65 build:

invocationwall-clock
full suite with both vectors37.9 s
full suite without them4.8 s

~16.5 s per vector — off by a factor of ~360. The gate was buying 33 seconds while
hiding the only test that matters. So both of Lane C's halves apply:

  1. The vectors now run by default.--fast skips them; --slow is kept as an
    accepted no-op so existing invocations don't break.
  2. Any skipped group is named in the verdict — an unqualified clean pass is no
    longer printable over a group that did not run.

A/B, verbatim:

$ python3 tools/test_x25519.py # default — now runs them
--- x25519 RFC 7748 vector 1 --- RFC 7748 vector 1... PASS
--- x25519 RFC 7748 vector 2 --- RFC 7748 vector 2... PASS
RESULTS: 73/73 passed, 0/73 failed (38.2 s)
$ python3 tools/test_x25519.py --fast # skip is now loud
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.
$ python3 tools/test_x25519.py --slow # back-compat no-op
RESULTS: 73/73 passed, 0/73 failed

Exit code deliberately 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. Lane C and I converged on one rule covering test_x509.py,
test_x25519.py and run_all_tests.py, with the exit-code difference falling out of
it rather than reading as two lanes disagreeing:

An involuntary skip is a failure. An explicit skip is allowed, but must never be
silent.

(Wording is Lane C's, from #83.) A missing label is an unrequested environmental
failure and goes red; --fast / --skip-slow are operator choices and stay green,
but neither may print an unqualified clean pass.

Merge with #83 — confirmed by running it, not by reading line numbers. Both PRs
touch tools/run_all_tests.py in different hunks (I changed the x25519 import branch
~line 90; #83 changed the suite-list construction and the summary block). Trial merge
of fix/audit-f3-f7-ecdsa-coverage into this branch: Automatic merge went well, and
the merged tree behaves correctly in both directions —

# merged, --skip-slow (both changes visible in one verdict)
TOTAL: 255/255 passed, 0 failed -- 1 suite(s) SKIPPED: x509
OK x25519 73/ 73 (34.8s)
SKIP x509 --- did not run: --skip-slow (X.509 DER parsing + ECDSA P-256 verify)
WARNING: the suite(s) above did not run. ...
# merged, full
TOTAL: 266/266 passed, 0 failed
OK x25519 73/ 73 (35.2s)
OK x509 11/ 11 (122.2s)

Note for whoever lands second: #83's illustrative totals (264/264 full,
253/253 under --skip-slow) are correct on its own branch but become 266/266 and
255/255 once this PR's +2 x25519 assertions land. The numbers drift, the behaviour
does not.

Blast radius, found and handled:run_tests now returns
(passed, failed, skipped_groups). tools/run_all_tests.py:90 is the only in-tree
importer and unpacked a 2-tuple — it would have broken. It now 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:

$ python3 tools/run_all_tests.py --skip-slow --workers 4
[PASS] x25519: 73/73 (34.8s) # was 71/71
TOTAL: 255/255 passed, 0 failed

Also corrected at the vectors: they 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 v0.6.0. Noted inline so
nobody retires it as redundant: per Defect 2 above, it is the regression gate for
exactly the bump this audit is about to authorise.

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>
@JC-000

Copy link
Copy Markdown
OwnerAuthor

Spot-checked by the supervising session — including a correction to my own earlier claim.

The pin "discrepancy" was mine, not the docs'. I opened this lane telling the team that CLAUDE.md's libs/x25519@v0.6.0 didn't match the submodule. Reproduced here, this branch is right and I was wrong:

git cat-file -t v0.6.0 -> commit (LIGHTWEIGHT tag)
git rev-parse v0.6.0^{commit} -> 95fdd705b0f7d780cada3d...
git ls-tree HEAD libs/x25519 -> 95fdd705b0f7d780cada3d... (identical)
git describe 95fdd705 -> v0.5.0-5-g95fdd70
git describe --tags 95fdd705 -> v0.6.0

git describe skips lightweight tags without --tags, and git submodule status renders through that call. The pin was correct all along. Worth stating plainly in the docs so the next person doesn't re-file it — a tooling artefact that looks exactly like documentation drift.

The bigger finding reproduces exactly.make BACKEND=uci USE_X25519_SIBLING=1:

ld65: Warning: Segment 'LIB_NISTCURVES_P256_CODE' overflows memory area 'CRYPTO_HOT' by 381 bytes
ld65: Error: Cannot generate most of the files due to memory area overflow

with make BACKEND=uci linking clean at 62,977 B as control. CLAUDE.md says in four places that ip65 is broken and UCI is the supported sibling-on path; in fact neither backend links with the sibling, and the UCI failure was nowhere on record. That is the same class as F8 — documentation asserting a capability that the tree does not have.

On not bumping: correct call, and the reasoning holds up. v0.8.0's 9224→8383 being a cold-section move rather than a real shrink, and ip65's 2,048 B X25519_RODATA overflow originating in our own wrapper rather than upstream, are both reasons a bump would not have fixed what it appears to fix. Reporting beat guessing here.

The upstream correctness bug in the pinned version (c64-x25519#64, wrong results when peer u has bit 255 set, fixed in v0.7.0) is the most consequential item for planning: it makes the pending USE_X25519_SIBLING flag-flip a hard blocker rather than a preference, and the in-tree implementation being unaffected is why nothing shipped is at risk.

tools/check_upstream_pins.py finding a fourth doc/pin contradiction on its first run — CLAUDE.md's Crypto ABI section saying nistcurves v0.3.0 while the pin is v0.6.0 — is a good argument for scheduling it.

…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>
@JC-000JC-000 changed the title docs: correct the x25519 sibling record + add tools/check_upstream_pins.pydocs: correct the x25519 sibling record; add pin-drift check; fix test_x25519 skip-as-passAug 13, 2026
@JC-000

Copy link
Copy Markdown
OwnerAuthor

Second commit verified by the supervising session.

Vectors run by default now — confirmed here:73/73 passed in 38 s wall-clock, matching the measured 37.9 s. The ~100 min each comment this gate rested on was off by a factor of ~360, so there was never a trade-off to weigh: the gate bought ~33 seconds while hiding the only end-to-end x25519_scalarmult coverage in the file. Measuring the premise rather than arguing about the policy was the right move.

The near-miss is worth recording as much as the fix. Changing run_tests to a 3-tuple would have broken tools/run_all_tests.py:87, which unpacks two values — verified, that is the only in-tree importer. Catching it by grepping for importers before committing, and then making the aggregate raise if the third value is ever non-empty, converts a latent breakage into a guard: a future gate cannot quietly drop coverage from the aggregate verdict. The aggregate also gained the vectors as a side effect (x25519 71/71 → 73/73, TOTAL 255/255).

Your new finding reproduces exactly, and it is the same defect one level up:

run_all_tests.py:133-134 if not skip_slow: suites.insert(0, "x509")
run_all_tests.py:180 print(f"TOTAL: ...")
run_all_tests.py:188 sys.exit(0 if total_failed == 0 else 1)

--skip-slow removes the entire x509 suite from the list, and the runner then prints a TOTAL and exits 0. The aggregate reports success having silently dropped the very ECDSA group F3 is about. Tracked as follow-up.

On the exit-code divergence from #83: I agree with your call, keep it. A missing label is a broken environment and must be an error; --fast is an operator explicitly asking for less. The requirement is that it cannot be silent, and naming the skipped groups plus a warning line satisfies that. Consistency between the two suites is not worth conflating "you asked for less" with "something is wrong".

The vector-labelling correction is the sharpest detail in this PR. Confirmed independently: RFC 7748's second scalarmult vector has U_2 ending …a493, so byte 31 is 0x93 and bit 255 is set — exactly the input class upstream c64-x25519#64 miscomputes in our pinned v0.6.0. The test that was being skipped while the suite reported a clean 71/71 is the regression gate for the bug blocking the bump it was hiding. Noting that inline so nobody retires it as redundant is what makes the fix durable.

JC-000 added a commit that referenced this pull request Aug 13, 2026
…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>
@JC-000
JC-000 merged commit 1371282 into docs/benchmark-refreshAug 13, 2026
JC-000 added a commit that referenced this pull request Aug 13, 2026
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>
@JC-000
JC-000 deleted the chore/x25519-alignment branch August 13, 2026 13:29
JC-000 added a commit that referenced this pull request Aug 15, 2026
… regression
Archaeology from the deferred-followups lane, re-verified here before
landing. #102's 'the sibling links' evidence was honest on its own
branch: at 76d876c the nistcurves pin was still v0.6.0, and the two
bumps were parallel branches neither of which could see the other.
One clause tightened rather than pasted. The contributed wording said
v0.6.0 'does not export reu_mul_tables_init at all'; it does, at
src/main.s:254. What is true — and is the actual mechanism — is that
main.o is excluded from every lib-* archive target, and c64-https links
archives only. Upstream #81 moved the provider into reu_mul_init.s so it
would ship to consumers, which is what put it in our link at v0.9.1. A
claim a reader can disprove with one grep is worse than no claim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@JC-000