Skip to content

libs/x25519 v0.6.0 -> v0.10.0, and USE_X25519_SIBLING=1 links + verifies for the first time - #102

Merged
JC-000 merged 2 commits into
masterfrom
chore/x25519-latest
Aug 14, 2026
Merged

libs/x25519 v0.6.0 -> v0.10.0, and USE_X25519_SIBLING=1 links + verifies for the first time#102
JC-000 merged 2 commits into
masterfrom
chore/x25519-latest

Conversation

@JC-000

Copy link
Copy Markdown
Owner

What this does

Bumps libs/x25519 from v0.6.0 (95fdd70) to v0.10.0 (68ae0ef) — upstream latest —
and migrates tools/integration/build_x25519.sh through the upstream changes that a bump
has to cross.

It also makes USE_X25519_SIBLING=1link and produce correct results for the first
time
, 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_scalarmult returns a deterministically wrong shared secret for any peer
u-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)

$ make clean && make # ip65
→ build/c64-https.prg 47,105 B
417c70859411e8b5b9d2ef7a89575c2122d263cb0a622b8fc336693a907f2444
$ make clean && make BACKEND=uci # UCI
→ build/c64-https.prg 62,977 B
741f0e8cd99470a2d2f76b21d0603235eb625cded32f9ad80b9f7c93f7462e70
$ python3 tools/test_ecdsa_kat_oracle.py
→ Passed: 6/6 (3 valid accepted, 3 invalid CAVP vectors rejected)
$ python3 tools/test_x509.py
→ Passed: 11/11
$ python3 tools/test_x25519.py # the lane's regression gate
→ RESULTS: 73/73 passed, 0/73 failed
incl. RFC 7748 §5.2 vector 1 and vector 2 (u ends 0x93, bit 255 set)

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=1 also still builds.

Does USE_X25519_SIBLING=1 link now? — UCI yes, ip65 no

UCI: links, and the KATs pass

make clean && make BACKEND=uci USE_X25519_SIBLING=1 # no env vars needed
→ 62,977 B, links clean

Verified against the linked PRG in VICE (-reu, via default_vice_config()):

fe25519 add / sub / mul x2 / sqr x2 / mul_a24 7/7 PASS
RFC 7748 §5.2 vector 1 PASS
RFC 7748 §5.2 vector 2 (bit-255 u, #64 gate) PASS
x25_u returned unmutated; x25_x1 == u with bit 255 cleared
tools/test_ecdsa_kat_oracle.py 6/6 PASS

The ECDSA run matters independently, and it is where the sixth defect surfaced — see
below. Under this flag it is the sibling'sreu_mul_init that populates REU banks 0/1,
which libs/nistcurves' fp_mul reads; 6/6 says it is a drop-in for what in-tree
boot.s used to build.

tools/test_x25519.py itself cannot run against a sibling build — it requires in-tree label
names (fe_copy, fe_src1, …) that the sibling does not export (its names are
fe25519_*). The vectors above were run through a standalone driver. Making that suite
backend-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

default routing : X25519_RODATA over CRYPTO_OVERLAY by 2,048 B
+ LIB_NISTCURVES_P256_CODE over CRYPTO_RESIDENT by 122 B
with the split : the CRYPTO_RESIDENT overflow clears;
X25519_RODATA over CRYPTO_OVERLAY by 2,816 B

X25519_RODATA is 2,304 B of lookup tables this repo's wrapper hand-emits, not
anything upstream sizes, so no submodule move can fix it. ip65's CRYPTO_OVERLAY is
4,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 new LIB_X25519_INIT_CODE). A sed that
matches 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 upstream
rename fails in one second with a message naming the fix.

2 — x25_x1 was missing. v0.7.0's #64 fix adds a buffer that the wrapper's hand-written
BSS 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_imm unresolved. From v0.7.0 the
sibling imports the c64-lib-contract §8.3 multiply body unconditionally. In-tree
poly1305.s already provides it, gated .ifdef USE_NISTCURVES_ONCHIP; widened to
.or .defined(USE_X25519_SIBLING). The two arms are equivalent implementations of the same
contract, and the two flags are mutually exclusive at Makefile:90, so this is inert
everywhere 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_BASE was stale: $BC00, actually $B800.src/data.s drops
mul_dma_lo/mul_dma_hi (512 B) from TABLES_BSS under this flag, shifting everything
after them. In-tree sqtab_init fills the table at $B800; the sibling read $BC00. Not a
link error, not a boot failure — a wrong shared secret. Now paired with a post-link
assertion in the $(PRG) recipe, mirroring the one USE_NISTCURVES_ONCHIP has had all
along for sqtab_reserved.

5 — an REU bank collision that this repo had already analysed and dismissed.
src/boot.s STASHes the whole 8 KB CRYPTO_OVERLAY slot to REU_OVERLAY_X25519 at boot,
unconditionally under the sibling 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 carried a note calling exactly this overlap "THEORETICAL", reasoning that
no TLS caller ever invokes crypto_swap_to_x25519. That is true and irrelevant: the
damaging 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_EMBED is
forced 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_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.

6 — the same stash also destroys the REU autoload latch, and only ECDSA noticed. The
stash is a six-register REU setup running afterjsr reu_mul_init.
reu_fetch_mul_row is a three-register primitive — it writes reu_reu_hi /
reu_reu_bank / reu_command and trusts the latch for the rest — so a stomped latch
makes 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. fe25519 re-establishes the
latch itself on every operation (reu_clear_wide's tail), so X25519 was completely
unaffected and 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.

before: Passed 3/6 — all three VALID CAVP vectors rejected (C=1),
all three invalid correctly rejected
after: Passed 6/6

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_mul
mode 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 than
open-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 an
overlay-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-segments on the staged objects:

fe25519 2,711 B hot (v0.6.0: 2,711 — unchanged)
x25519 717 B hot (v0.6.0: 698 — +19)
x25519_init 132 B hot + 666 B init (v0.6.0: 798, all hot)
------------------------------------------------------------
hot total 3,560 B (v0.6.0: 4,207)

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.s is empty, so the wrapper's zero-page
time-sharing analysis needed no revisiting.

Not done — deliberately

  • No cfg/*.cfg change. Lane N owns those. The sibling currently reaches
    CRYPTO_OVERLAY by borrowing the X25519_RODATA segment name for executable code, which
    works but is misnamed. The clean form is a cfg declaring the SPEC §4 names:

    LIB_X25519_CODE: load = CRYPTO_HOT, type = ro, optional = yes;
    LIB_X25519_INIT_CODE: load = CRYPTO_OVERLAY, type = ro, optional = yes;
    LIB_X25519_DATA: load = CRYPTO_OVERLAY, type = ro, optional = yes, align = $100;
    

    with LIB_X25519_INIT_CODE last among file-emitting segments in its area (upstream R5).
    At that point the wrapper's sed and the two export lines in the Makefile both go away —
    set X25519_CODE_SEGMENT / X25519_INIT_SEGMENT to the LIB_ names and the rewrite
    becomes an identity, which the sanity check already tolerates.

  • No default flip.USE_X25519_SIBLING stays off. Linking and passing KATs in VICE is
    not 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.inc justified the bank-3 overlap by asking who reads the reservation. The
    other 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.

JC-000and others added 2 commits August 14, 2026 09:42
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
JC-000 merged commit d064cbb into masterAug 14, 2026
@JC-000

Copy link
Copy Markdown
OwnerAuthor

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 USE_X25519_SIBLING=1 linking and verifying on UCI for the first time. Measured just now on master fc82ad5 with libs/x25519 at v0.10.0:

make clean && make BACKEND=uci USE_X25519_SIBLING=1
ld65: Error: Duplicate external identifier: 'reu_mul_tables_init'

Both backends now fail this way — a different failure from the X25519_RODATA/CRYPTO_OVERLAY overflow this PR and CLAUDE.md describe.

Nothing here was wrong when measured. This is an interaction between two independently-correct bumps: libs/nistcurves v0.9.x ships reu_mul_init in its default archives (noted during that bump, where it was harmless because ld65 never pulled the member), and libs/x25519 v0.10.0 exports the same symbol. Each lane measured its own bump in isolation and neither could have seen the collision; they only met on master.

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:

  1. CLAUDE.md is now stale on this. It still records the v0.6.0-era overflow numbers as the reason the sibling does not link. The README was corrected in docs: audit README against the tree and correct every stale claim #104 with the measured error; CLAUDE.md has not been.
  2. The reu_mul_init / reu_mul_tables_init duplication is exactly the shape of c64-lib-contract#43 (a consumer linking two contract libraries and hitting Duplicate external identifier), which the contract's answer to is -D LIB_NO_BARE_EXPORTS=1. Worth checking whether that mechanism applies here, since it would turn a blocker into a build flag.

Found by the README audit (#104), which measured the claim rather than restating it from this PR body.

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