libs/x25519 v0.6.0 -> v0.10.0, and USE_X25519_SIBLING=1 links + verifies for the first time - #102
Conversation
Bumps the pin to upstream latest (68ae0ef) and migrates the integration wrapper through the four upstream changes a bump has to cross. Along the way USE_X25519_SIBLING=1 links and produces correct results for the first time, on UCI. The pin mattered even though nothing links it: v0.6.0 carries upstream c64-x25519#64, where x25519_scalarmult returns a deterministically wrong shared secret for a peer u with bit 255 set (v0.4.0-v0.6.0, fixed v0.7.0). In-tree is not affected, so nothing shipped was at risk, but the pin was a landmine under the flag. v0.10.0 removes it and this change verifies the removal instead of trusting the changelog: RFC 7748 sec 5.2 vector 2 (u ends 0x93) PASSes against a sibling-linked PRG. Both default PRGs are byte-identical to master (ip65 417c7085..., UCI 741f0e8c...). Every source change is scoped to a flag no shipped configuration sets, verified by sha256 rather than by reading the diff. Five defects sat between "bump the pin" and "it works"; three are ours, and every one of them is silent at the point it goes wrong: 1. The wrapper's segment rewrite had quietly become a no-op. It matched `.segment "CODE"`, renamed to LIB_X25519_CODE in v0.8.0 (which also added LIB_X25519_INIT_CODE). A sed matching nothing assembles fine and only fails at ld65 as an unplaced segment. The check now greps for any surviving LIB_X25519_* name. 2. x25_x1, the buffer v0.7.0's #64 fix introduced, was missing from the hand-emitted BSS module. Costs zero bytes -- it lands in alignment padding that already existed. 3. ct_mul_8x8 / smc_sum_a_imm / smc_diff_a_imm went unresolved: from v0.7.0 the sibling imports the contract SS8.3 multiply body unconditionally. in-tree poly1305.s already has it behind .ifdef USE_NISTCURVES_ONCHIP; gate widened. This one was invisible until the placement problem was fixed -- ld65 aborts on memory-area overflow before it checks symbols. 4. LIB_SHARED_SQTAB_BASE was stale. data.s drops mul_dma_lo/hi (512 B) from TABLES_BSS under this flag, so sqtab_lo links at $B800, not the $BC00 the wrapper baked in. in-tree sqtab_init filled one address, the sibling read the other: no link error, no boot failure, just a wrong shared secret. Now guarded post-link in the $(PRG) recipe, mirroring what USE_NISTCURVES_ONCHIP already does. 5. An REU bank collision this repo had already analysed and dismissed. boot.s STASHes the 8 KB CRYPTO_OVERLAY slot to REU_OVERLAY_X25519 at boot, unconditionally under the flag; at $30000 that is bank 3 = X25519_REU_BANK + 3, the sibling's 17th-bit-carry table, clobbered right after reu_mul_init built it. reu_layout.inc called exactly this overlap THEORETICAL because no TLS caller invokes crypto_swap_to_x25519 -- true, and irrelevant: the damaging access is a write from boot, not a read from a swap. The analysis only asked who reads the reservation. Relocated to bank 6, scoped to the flag so the default PRGs do not move, and the stale note corrected in place rather than deleted. The signature is diagnostic and worth keeping: fe25519_mul correct, add/sub/mul_a24 correct, fe25519_sqr wrong. Banks 0/1 survive the stash; bank 3 is read only by fe25519_sqr's SQR_DMA_K>0 path. What the bump is worth in bytes (od65 on the staged objects): hot code 3,560 B against v0.6.0's 4,207 B. v0.8.0's cold-segment split moves 666 B of boot-only init out of the hot region, and that -647 B is the entire reason UCI fits. src/zp_config.s is byte-identical across the range, so the wrapper's zero-page time-sharing analysis needed no revisiting. ip65 still does not link, and the blocker is ours, not upstream's: X25519_RODATA overflows its 4,212 B CRYPTO_OVERLAY by 2,048 B, and those 2,304 B of tables are hand-emitted by this wrapper. No submodule move fixes it; it needs a memory-map change. No cfg/*.cfg touched. The sibling currently reaches CRYPTO_OVERLAY by borrowing the X25519_RODATA segment name for code, which works but is misnamed; the clean form is a cfg declaring the SPEC SS4 names, and both the sed and the two Makefile exports disappear when it lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sixth and last defect blocking USE_X25519_SIBLING=1. With it, the sibling build goes from 3/6 to 6/6 on tools/test_ecdsa_kat_oracle.py while keeping both RFC 7748 vectors green -- so the configuration is now correct across the whole crypto surface, not just X25519. boot.s STASHes the 8 KB CRYPTO_OVERLAY slot to REU under the sibling flag, and that stash is a six-register REU setup (c64 addr, reu addr, bank, len=$2000, addr_ctrl) running AFTER `jsr reu_mul_init`. `reu_fetch_mul_row` is a three-register primitive: it writes only reu_reu_hi / reu_reu_bank / reu_command and trusts the autoload latch for the rest. Left stomped, the next row fetch pulls $2000 bytes into $4200 instead of $0200 bytes into mul_dma_lo. The blast radius is asymmetric, which is the whole reason this was hard to see. fe25519 re-establishes the latch itself on every operation (the tail of reu_clear_wide), so X25519 was completely unaffected -- both RFC 7748 vectors passed against the broken build. libs/nistcurves' fp_mul does not; it relies on the latch boot left behind. So ECDSA P-256 was the single visible casualty of a fault that has nothing to do with ECDSA. Measured before: 3/6, with all three VALID CAVP vectors rejected (C=1) and all three invalid ones correctly rejected. No forged signature was ever accepted, so this was a correctness/availability failure and not a verification bypass. It presents identically to the "missing -reu" garbage-fp_mul mode documented in CLAUDE.md, and would almost certainly have been misdiagnosed as one. Measured after: 6/6. Per-vector wall-clock also drops from 180-270 s to 60-90 s, which is independent corroboration -- the broken build was moving 16x the bytes per row fetch. The fix calls reu_clear_wide, the library's own canonical latch restorer (its tail is documented upstream as one of the two establishers of this state), rather than open-coding the register writes and drifting from it on the next bump. Its ZP clear of fe_wide is incidental and harmless at boot. Entirely inside .ifdef USE_X25519_SIBLING: both default PRGs remain byte-identical to master (ip65 417c7085..., UCI 741f0e8c...), and USE_NISTCURVES_ONCHIP still builds. The default build never hits this because reu_p384_overlay_init's body is empty unless an overlay-embed flag is set, so nothing stomps the latch there. With this and the segment split from the previous commit, the documented command works with no environment variables: make BACKEND=uci USE_X25519_SIBLING=1 # links, 62,977 B (verified: that exact invocation produced the 6/6 build above.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JC-000
commented
Aug 14, 2026
Correction: the UCI sibling link no longer holds at master. Recording it here so the claim in this PR body is not read as current. This PR reported Both backends now fail this way — a different failure from the Nothing here was wrong when measured. This is an interaction between two independently-correct bumps: Not release-blocking, and v0.2.0 is unaffected: the sibling is opt-in, the default builds are clean, and no shipped artifact contains it. Two follow-ups worth tracking:
Found by the README audit (#104), which measured the claim rather than restating it from this PR body. |
… 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>
What this does
Bumps
libs/x25519from v0.6.0 (95fdd70) to v0.10.0 (68ae0ef) — upstream latest —and migrates
tools/integration/build_x25519.shthrough the upstream changes that a bumphas to cross.
It also makes
USE_X25519_SIBLING=1link and produce correct results for the firsttime, on the UCI backend. That was not the goal going in; it fell out of chasing the pin.
Why the pin mattered even though nothing links it
The pinned v0.6.0 carries upstream c64-x25519#64:
x25519_scalarmultreturns a deterministically wrong shared secret for any peeru-coordinate with bit 255 set (v0.4.0 → v0.6.0, fixed v0.7.0). Nothing shipped is affected —
the in-tree implementation writes the RFC 7748 mask back and is not vulnerable — but the pin
was a landmine for anyone who flipped the flag. v0.10.0 removes it, and this PR verifies
the removal rather than trusting the changelog.
Evidence bar (all four, from clean)
Both default PRG hashes are byte-identical to master's. Every source change here is
scoped to a flag that no shipped configuration sets, and that is verified by sha256 rather
than by reading the diff.
make BACKEND=uci USE_NISTCURVES_ONCHIP=1also still builds.Does
USE_X25519_SIBLING=1link now? — UCI yes, ip65 noUCI: links, and the KATs pass
Verified against the linked PRG in VICE (
-reu, viadefault_vice_config()):The ECDSA run matters independently, and it is where the sixth defect surfaced — see
below. Under this flag it is the sibling's
reu_mul_initthat populates REU banks 0/1,which
libs/nistcurves'fp_mulreads; 6/6 says it is a drop-in for what in-treeboot.sused to build.tools/test_x25519.pyitself cannot run against a sibling build — it requires in-tree labelnames (
fe_copy,fe_src1, …) that the sibling does not export (its names arefe25519_*). The vectors above were run through a standalone driver. Making that suitebackend-agnostic is a reasonable follow-up but is not attempted here.
ip65: still does not link — 2,048 B, and it is our own segment
X25519_RODATAis 2,304 B of lookup tables this repo's wrapper hand-emits, notanything upstream sizes, so no submodule move can fix it. ip65's
CRYPTO_OVERLAYis4,212 B and already carries
TLS_CODE+CRYPTO_AUX_CODE. This needs a memory-map change,not a version bump.
The six defects between "bump the pin" and "it works"
Four of the six are ours, and every one is silent — none produces a wrong answer at the
point where it goes wrong.
1 — the wrapper's segment rewrite had become a no-op. It rewrote
.segment "CODE",which v0.8.0 renamed to
LIB_X25519_CODE(plus a newLIB_X25519_INIT_CODE). A sed thatmatches nothing assembles perfectly; the failure surfaces at ld65 as an unplaced segment.
The post-rewrite check now greps for any surviving
LIB_X25519_*, so the next upstreamrename fails in one second with a message naming the fix.
2 —
x25_x1was missing. v0.7.0's #64 fix adds a buffer that the wrapper's hand-writtenBSS module has to declare. It costs zero bytes: it lands in alignment padding that already
existed. If it is ever dropped again the link fails on an unresolved import rather than
silently reverting to the bug.
3 —
ct_mul_8x8/smc_sum_a_imm/smc_diff_a_immunresolved. From v0.7.0 thesibling imports the c64-lib-contract §8.3 multiply body unconditionally. In-tree
poly1305.salready provides it, gated.ifdef USE_NISTCURVES_ONCHIP; widened to.or .defined(USE_X25519_SIBLING). The two arms are equivalent implementations of the samecontract, and the two flags are mutually exclusive at
Makefile:90, so this is inerteverywhere else. Note this defect was invisible until the placement problem was solved —
ld65 aborts on memory-area overflow before it reports unresolved externals.
4 —
LIB_SHARED_SQTAB_BASEwas stale:$BC00, actually$B800.src/data.sdropsmul_dma_lo/mul_dma_hi(512 B) fromTABLES_BSSunder this flag, shifting everythingafter them. In-tree
sqtab_initfills the table at $B800; the sibling read $BC00. Not alink error, not a boot failure — a wrong shared secret. Now paired with a post-link
assertion in the
$(PRG)recipe, mirroring the oneUSE_NISTCURVES_ONCHIPhas had allalong for
sqtab_reserved.5 — an REU bank collision that this repo had already analysed and dismissed.
src/boot.sSTASHes the whole 8 KBCRYPTO_OVERLAYslot toREU_OVERLAY_X25519at boot,unconditionally under the sibling flag. At
$30000that is bank 3 =X25519_REU_BANK + 3— the sibling's 17th-bit-carry table — clobbered right after
reu_mul_initbuilt it.reu_layout.inccarried a note calling exactly this overlap "THEORETICAL", reasoning thatno TLS caller ever invokes
crypto_swap_to_x25519. That is true and irrelevant: thedamaging access is a write from boot, not a read from a swap. The analysis asked only
who reads the reservation. Relocated to bank 6 (free here:
USE_OVERLAY_P384_EMBEDisforced off whenever this flag is set), scoped to the flag so both default PRGs stay
byte-identical, and the stale note has been corrected in place rather than deleted.
The symptom is worth recording because it is diagnostic:
fe25519_mulcorrect,add/sub/mul_a24correct,fe25519_sqrwrong. Banks 0/1 survive the stash; bank 3 isread only by
fe25519_sqr'sSQR_DMA_K > 0path.6 — the same stash also destroys the REU autoload latch, and only ECDSA noticed. The
stash is a six-register REU setup running after
jsr reu_mul_init.reu_fetch_mul_rowis a three-register primitive — it writesreu_reu_hi/reu_reu_bank/reu_commandand trusts the latch for the rest — so a stomped latchmakes the next row fetch pull $2000 bytes into $4200 instead of $0200 bytes into
mul_dma_lo.The blast radius is asymmetric, which is exactly why it hid.
fe25519re-establishes thelatch itself on every operation (
reu_clear_wide's tail), so X25519 was completelyunaffected and both RFC 7748 vectors passed against the broken build.
libs/nistcurves'fp_muldoes not — it relies on the latch boot left behind. So ECDSA P-256 was the singlevisible casualty of a fault that has nothing to do with ECDSA.
No forged signature was ever accepted, so this was a correctness/availability failure,
not a verification bypass. It presents identically to the "missing
-reu" garbage-fp_mulmode documented in CLAUDE.md and would very likely have been misdiagnosed as one. Per-vector
wall-clock also fell from 180–270 s to 60–90 s, independent corroboration that the broken
build was moving 16× the bytes per fetch.
Fixed by calling
reu_clear_wide— the library's own canonical latch restorer — rather thanopen-coding the register writes and drifting from it on the next bump.
The default build never hits this:
reu_p384_overlay_init's body is empty unless anoverlay-embed flag is set, so nothing stomps the latch there.
What the v0.10.0 bump is actually worth, in bytes
Measured with
od65 --dump-segmentson the staged objects:v0.8.0's cold-segment split moves 666 B of boot-only table init out of the hot region. That
−647 B is the entire practical reason UCI fits now; without it the sibling is 400 B over
(v0.6.0 was 381 B over — the +19 B is the ladder growth, exactly).
git diff v0.6.0 v0.10.0 -- src/zp_config.sis empty, so the wrapper's zero-pagetime-sharing analysis needed no revisiting.
Not done — deliberately
No
cfg/*.cfgchange. Lane N owns those. The sibling currently reachesCRYPTO_OVERLAYby borrowing theX25519_RODATAsegment name for executable code, whichworks but is misnamed. The clean form is a cfg declaring the SPEC §4 names:
with
LIB_X25519_INIT_CODElast among file-emitting segments in its area (upstream R5).At that point the wrapper's sed and the two
exportlines in the Makefile both go away —set
X25519_CODE_SEGMENT/X25519_INIT_SEGMENTto theLIB_names and the rewritebecomes an identity, which the sanity check already tolerates.
No default flip.
USE_X25519_SIBLINGstays off. Linking and passing KATs in VICE isnot the same as a handshake on hardware, and the sibling costs +791 B of hot code. What
changed is that flipping it is now a decision rather than a blocked one.
A note on the remaining REU reservations. Defect 5 slipped through because
reu_layout.incjustified the bank-3 overlap by asking who reads the reservation. Theother reservations in that file rest on the same argument. Treat them as unverified
rather than safe — a reservation with no reader can still have a writer.
No hardware run. VICE only. A UCI e2e is the obvious next step before anyone
considers the flip.