Skip to content

libs/nistcurves is pinned at v0.11.2, which predates the §8.2 REU settle — bumping is blocked by an APP_OWNED symbol collision #190

Description

@JC-000

Companion to #166, which records the c64-lib-contract §8.2 REU-settle gap for
libs/x25519 only. libs/nistcurves has the same gap, and unlike x25519
this code is linked into every PRG we ship. Nobody had written that down.

Assessment only — no bump proposed here. See "The blocker" for why the bump is
not a one-liner.

Revised 2026-09-05 after adversarial review. The first version of this
issue named byte pressure as the blocker and said uci-comb "would link".
That was wrong. The actual gate is a duplicate-external collision that
stops all three products, uci-comb included. Corrected below, reproduced
first-hand. The per-region byte split was also wrong in a way that made
uci-onchip look worse than it is. Both are fixed here.

What we pin, and what exists upstream

pinned libs/nistcurves v0.11.2 (58f4cf53b28eb35c4872c93b9b6d799d031ff892)
latest upstream v0.12.0, released 2026-08-30, tag dereferences to 63c9adc192e4ac30fc987ee9f0dd12b588e6ac7e
REU settle in the pinned tree absent

(ed7b7170 is one commit after the tag and touches only release notes. The
first version of this issue, and upstream c64-nist-curves#148, both call it
"v0.12.0". Harmless — the code is identical — but not literally true.)

Clone trap, worth recording: a local libs/nistcurves checkout did not have
v0.12.0 even after a forced tag fetch. Query GitHub
(gh release list --repo JC-000/c64-nist-curves) before concluding a pin is
current or that upstream has not fixed something. A freshly cloned submodule in
a new worktree does have it.

The defect

Identical to #166's, same root cause, same upstream ticket
(c64-lib-contract#144): on a turbo-clocked U64E at fw 3.15 the REU's
post-transfer restore outlasts the CPU, so the REU register write that follows
an execute is lost or misapplied — wrong multiply row, wrong field products,
silent wrong crypto with no error path.

v0.12.0 fences all thirteen sta reu_command sites: six hot fp_mul /
fp_sqr row fetches get an inline REU_DMA_CONFIRM (bit $DF00 / bvs), and
seven sites — the two reu_mul_init stashes, four comb stash/fetch
routines
, and the exported reu_fetch_mul_row — get
jsr nistcurves_reu_dma_wait, a bounded spin plus
LIB_NISTCURVES_REU_SETTLE_ITER × 9 cycles.

Why this is a bigger surface than #166

libs/x25519 contributes zero bytes to every shipped PRG
(USE_X25519_SIBLING defaults to 0, no PACKAGE_VARIANTS row sets it, and it
links under neither backend). libs/nistcurves is in all three, measured from
build/c64-https.map for ip65-onchip at dc06095:

LIB_NISTCURVES_P256_CODE    $7E2D-$9E9C   8,304 B
LIB_NISTCURVES_P256_RODATA  $9E9D-$9F5C     192 B
LIB_NISTCURVES_MUL_CODE     $9F5D-$9FD7     123 B

Exposure is not uniform across the three shipped products:

product REU DMA issued from nistcurves code exposed
c64-https-ip65-onchip none — LIB_NISTCURVES_REU_BANKS_USED = $0000 in the map; no turbo either no
c64-https-uci-onchip none — same equate reads $0000; REU off no
c64-https-uci-comb LIB_NISTCURVES_REU_BANKS_USED = $0004 (bank 2): boot precompute stash, plus sm256_reu_fetch_affine on every comb step yes

Read the equate, not the profile name — that is the reliable discriminator, and
it is in every map we build.

The blocker: a duplicate external, not bytes

Reproduced here. libs/nistcurves checked out at 63c9adc (v0.12.0),
make clean before each, all three PACKAGE_VARIANTS configurations. All
three fail identically, at the link, with no PRG produced:

ld65: Error: Duplicate external identifier: 'nistcurves_mul_dma_hi'
make: *** [build/c64-https.prg] Error 1
BACKEND=ip65 USE_NISTCURVES_ONCHIP=1        rc=2
BACKEND=uci  USE_NISTCURVES_ONCHIP=1        rc=2
BACKEND=uci  USE_NISTCURVES_ONCHIP_COMB=1   rc=2

Mechanism, traced with od65 over the members our own wrapper stages:

  1. At v0.12.0 mul_8x8_onchip.o newly imports nistcurves_reu_dma_timeout
    and nistcurves_reu_wait_cnt — the §8.2 plumbing:

    $ od65 --dump-imports mul_8x8_onchip.o
    Name:  "nistcurves_reu_dma_timeout"
    Name: "nistcurves_reu_wait_cnt"
    
  2. Those live only in data_shared.o, which also exports the multiply-row
    buffers:

    $ od65 --dump-exports data_shared.o
    Name:   "nistcurves_mul_dma_hi"
    Name:   "nistcurves_mul_dma_lo"
    Name:  "nistcurves_reu_dma_timeout"
    Name: "nistcurves_reu_wait_cnt"
    
  3. At v0.11.2 ld65 never has a reason to pull data_shared.o at all. At
    v0.12.0 it must, and it drags those two exports in with it.

  4. src/crypto/shared/mul_tables.s:88 exports the same pair, because they are
    APP_OWNED here — we provide the multiply tables, per §8.3:

    .export nistcurves_mul_dma_lo, nistcurves_mul_dma_hi
    nistcurves_mul_dma_lo   = mul_dma_lo
    nistcurves_mul_dma_hi   = mul_dma_hi
    

So this is a c64-lib-contract §8.0 / §8.3 APP_OWNED ownership problem, not a
byte problem. It needs an upstream knob (a CONTRACT_DEFINES-style suppression
that lets the settle state live in data_shared.o without dragging the
APP_OWNED buffer exports along), or a wrapper change on our side. Bytes are the
second gate, not the first.

This matters for how the issue gets picked up. Anyone who reads "the
blocker is bytes", frees ~332 B by retiring the P-384 lane, and bumps the pin
will hit a link error this issue had told them they had already cleared.

The second gate: bytes, corrected

Per-segment deltas, measured through our own wrapper with our
CONTRACT_DEFINES and summed with od65 (not read off the changelog):

segment Δ onchip Δ comb notes
LIB_NISTCURVES_P256_CODE +58 +64
LIB_NISTCURVES_MUL_CODE +39 +39 all of it nistcurves_reu_dma_wait
LIB_NISTCURVES_*_BSS +3 +3
total +97 +103 vs the changelog's +100 / +106

The correction that matters: MUL_CODE is ~40% of the delta, and on both UCI
builds it lives in CRYPTO_OVERLAY, not CRYPTO_HOT.
Re-derived against
free space measured from the three maps at dc06095:

product CRYPTO_HOT/RESIDENT free Δ landing there CRYPTO_OVERLAY free Δ landing there verdict
ip65-onchip 40 B ($9FD7) +97 (all three segments are in this region) 22 B 0 short 57 B
uci-onchip 36 B ($9FDB) +58 2,035 B +39 short 22 B
uci-comb 193 B ($9F3E) +64 159 B +39 fits

So on bytes alone it is still two-of-three, but uci-onchip is short by 22 B,
not the ~64 the first version implied. That is close enough that ordinary
churn could open or close it.

Note the onchip archives grow even though they issue no REU DMA — their share
is the profile-independent fp_mod_inv / ec_jacobian_to_affine guards. There
is no "REU-free variants pay nothing" escape.

(CLAUDE.md's standing "CRYPTO_HOT margin under UCI is 81 B" line matches
neither variant measured today — 36 B onchip, 193 B comb. Tracked in #193.)

Bounding the hazard honestly

uci-comb is structurally exposed: it is the one shipped product issuing
crypto-path REU DMA, none of it observes §8.2, and there is no error path.

But it is unobserved on our device. The §8.2 hardware row has since been
measured on our U64E (601A96) at core 1.4F — 22/22 cells, ~2100 fetches,
zero wrong bytes, including an unmitigated control that also passed. The
recorded failure was core 1.4E.

The discriminating oracle, for whoever picks this up: run the §8.2 probe on
a core 1.4E U64E, or a real 17xx REU, mitigated against unmitigated. Only
that separates "the settle fixed it" from "1.4F removed the hazard". A green
uci-comb run on our current device discriminates nothing and should not be
offered as evidence either way.

Not urgent. Should not stay unrecorded. The failure mode is silent wrong
crypto, which is the bad direction.

Also in v0.12.0, and not a defect for us

Two public entry points could lock the machine — fp_mod_inv[_384] on input
0 or on the modulus, and ec_jacobian_to_affine[_384] on Z = 0, which is
the library's own point-at-infinity encoding. Upstream states both are not
reachable through ecdsa_verify_256 / ecdsa_verify_384
. Our import surface
is ecdsa_verify_256, ec_scalar_mul_var, ec_base_x, ec_gx256 — we never
call either guarded routine. Not a live defect here; recorded so nobody
re-derives it.

Additive things worth knowing when we do bump:

  • nistcurves_reu_dma_timeout (1 B, sticky) — upstream notes that consumers
    whose linker config makes that BSS bss-typed must zero it before init.
    boot.s already zeroes $A000-$BFFF; re-confirm against the map rather than
    assume.
  • LIB_NISTCURVES_REU_SETTLE_ITER defaults to 8, and upstream says to raise it
    if claiming conformance at 64 MHz. We run the C64U at 64 MHz, so that knob is
    ours to set deliberately, not to inherit.
  • LIB_NISTCURVES_ABI_VERSION stays 2; no symbol removed or renamed.

c64-nist-curves#148 is NOT fixed by v0.12.0

The conditional ECDSA forgery filed upstream — the comb path accepts an
all-zero table slot as a point, u1*G collapses to infinity, and verify fails
open — is still OPEN, and no commit after the v0.12.0 tag touches it.
Bumping does not close it.

It stays unreachable on a healthy device (it needs a corrupt or never-written
comb table), but it lives in the same profile that carries the §8.2 exposure
above, so the two travel together.

Adjacent, tracked separately

Filed as #191: our own REU code has the same unfenced shape — 12
sta reu_command sites across src/boot.s, src/http.s, src/viewer.s and
src/crypto/shared/crypto_swap.s, none of which reads $DF00 bit 6 or
observes a settle. No upstream bump can fix that for us.

Suggested action

  1. Keep the pin at v0.11.2. Record the exposure (this issue).
  2. Resolve the nistcurves_mul_dma_lo/hi collision first — it is the gate.
    Upstream §8.0 APP_OWNED knob request, or a wrapper change here.
  3. Only then worry about bytes: ip65-onchip short 57 B, uci-onchip short
    22 B. Candidates: retiring the P-384 lane (~332 B, per the 2026-08-30 note),
    and the contiguous-region cfg restructure CLAUDE.md lists as a TODO.
  4. On the bump: set LIB_NISTCURVES_REU_SETTLE_ITER deliberately for 64 MHz,
    and confirm nistcurves_reu_dma_timeout lands in zeroed BSS.
  5. Chase upstream uci: capture the firmware's STATUS line instead of discarding it (#147) #148 independently of the pin.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions