Skip to content

chore(libs): bump nistcurves v0.6.0 -> v0.9.1 — both backends link, cfgs untouched - #94

Merged
JC-000 merged 3 commits into
masterfrom
chore/nistcurves-latest
Aug 14, 2026
Merged

chore(libs): bump nistcurves v0.6.0 -> v0.9.1 — both backends link, cfgs untouched#94
JC-000 merged 3 commits into
masterfrom
chore/nistcurves-latest

Conversation

@JC-000

Copy link
Copy Markdown
Owner

Takes libs/nistcurves from its v0.6.0 pin to v0.9.1 (upstream latest). This library is always-resident and provides the P-256 verify every TLS handshake depends on.

Why this bump is worth real effort

v0.7.0 added a FIPS 186-5 §3.3 public-key validation gate: Qx, Qy ∈ [0, p−1] plus the on-curve check Qy² ≡ Qx³ − 3·Qx + b (mod p), returning C=1before any scalar multiplication.

That is directly load-bearing for us. The Q handed to ecdsa_verify_256 is parsed straight out of an attacker-supplied certificatesrc/tls_cert.secdsa_pubkey_x / ecdsa_pubkey_ysrc/crypto/ecdsa_verify.s — and c64-https performs no range or on-curve check of its own. Before this pin, nothing in the tree checked that the certificate's public key was even a point on P-256.

The CRYPTO_HOT overflow is gone, and no cfg change was needed

The recorded blocker was real: at v0.7.0/v0.8.0, LIB_NISTCURVES_P256_RODATA overflowed CRYPTO_HOT by 207 B. Measured at v0.9.1 from build/c64-https.map:

segmentv0.6.0v0.9.1delta
LIB_NISTCURVES_P256_CODE$1FB4 (8,116)$2084 (8,324)+208 — the validation gate
LIB_NISTCURVES_P256_RODATA$01E0 (480)$00C0 (192)−288 — dead RFC 6979 vectors
LIB_NISTCURVES_P256_BSS$0520 (1,312)$0520 (1,312)0

UCI CRYPTO_HOT last byte used: $9FFE (1 B free) → $9FAE (81 B free).

The +208 reconciles the old failure exactly — 1 − 208 = −207, the "overflows CRYPTO_HOT by 207 bytes" recorded in CLAUDE.md. What changed is the other term: v0.9.0 (upstream #91) deleted 288 B of dead RFC 6979 self-test vectors from curve256.o, an object our archive ships.

So the shelved remedy — routing LIB_NISTCURVES_P256_RODATA into CRYPTO_OVERLAY — is not adopted. Both cfg/*.cfg are byte-for-byte untouched. CRYPTO_OVERLAY stays entirely free, the three mutually exclusive flags that contend for it (USE_X25519_SIBLING, EMBED_P256_OVERLAY, USE_OVERLAY_P384_EMBED) keep exactly the contention they had, and tools/uci/_memory_policy.py sees an unchanged $4200-$5FFF — nothing for the packaging lane to absorb.

One caveat for the next bump: 81 B is the whole margin, and 288 B of it was a one-off recovery of dead weight that cannot be recovered twice.

Upstream's notes say 384 B of vectors removed; we measure 288 B because only curve256.o is in our archive — the other 96 B are curve384.o, P-384 only. Not a discrepancy.

The defect this bump actually surfaced

v0.9.0 (upstream #90) gave each of the nine archives its own per-variant ZP object, so nistcurves-p256-verify.a now ships zp_config_p256verify.o; zp_config.o no longer exists in it.

tools/integration/build_nistcurves_p256.sh step 3 rebuilt a file hardcoded as zp_config.o with c64-https's ZP-slot overrides, and step 5 re-archives strictly by ar65 t upstream.a. The override object would therefore have been silently dropped and the upstream-default member archived in its place, restoring exactly the collisions the overrides exist to prevent:

  • zp_ptr2$3d$fd, colliding with c64-https zp_temp / zp_countduring certificate parsing
  • fp_mul_i / fp_mul_j$39/$3a$2c/$2d, inside the fe25519 claim $2c-$37

There is no link error for this. It is runtime memory corruption surfacing several layers from its cause — the class of bug this repo has spent the week eliminating.

Both wrappers now discover the member name, apply the matching upstream -D variant gate (it selects which slots get .exportzp), hard-error on an absent / ambiguous / unrecognised name, and post-check the emitted object with od65 so the override is proven rather than assumed. Verified in the linked image via build/labels.txt: fp_mul_i=$39, fp_mul_j=$3A, zp_ptr2=$3D.

ABI-break audit — measured, not inferred

v0.9.0 bumps LIB_ABI_VERSION 0 → 1 and removes exported symbols. Rather than infer safety from green tests, I dumped every export of the staged archive at both pins and every import of c64-https's own objects:

  • 31 symbols left the archive (upstream's 17, plus 14 more narrowed away by the new per-variant zp_config_p256verify.o / precalc_manifest_p256verify.o)
  • intersection with what c64-https imports: empty
  • our entire consumed surface is four symbols: ec_base_x, ec_gx256, ec_scalar_mul_var, ecdsa_verify_256

The removed zero-page slots (proc_port, fp_loop, the four poly_*) are doubly moot: c64-https contains zero .importzp directives and defines every slot locally in src/constants.inc / src/crypto/shared/zp_canon.inc.

Merge note for Lane C: whichever of our two PRs lands second must change src/lib_contract_asserts.s's §1 gate from .assert LIB_ABI_VERSION = 0 to = 1. That is the gate doing its job — do not delete it. This PR does not contain or touch that file.

Contract §4 attribute audit (v0.8.0 clause)

Checked v0.9.1's declared obligations against our SEGMENTS{} lines in both cfgs:

  • align = $100 preserved on LIB_NISTCURVES_TABLES and LIB_NISTCURVES_SHA384_TABLES
  • type = rw preserved on LIB_NISTCURVES_P384_DATA_BSS and on LIB_NISTCURVES_P256_LIMLEE_BSS (onchip cfg) ✅
  • our type = bss on LIB_NISTCURVES_P256_BSS is safe and verified: libs/nistcurves/src/data_p256.s is 32 .res directives with no .byte/.word, so there are no initialised bytes for the silent rwbss drop to lose ✅

One benign deviation recorded in CLAUDE.md: we declare LIB_NISTCURVES_BSS as bss where upstream declares rw, and data_shared.s carries a single .byte 0 there. Inert for us — the wrapper drops data_shared.o — and zero-valued regardless.

LIB_NISTCURVES_SHARED_PRIMITIVES is still $0007 at v0.9.1 (and SHARED_CONSUMES is $0007 too), so the wrapper's mul_8x8.o / data_shared.o drops remain correct and necessary. Lane C's caveat stands: that equate describes the upstream archive, and our member surgery is invisible to it.

Version floor assert, negative-tested

src/crypto/ecdsa_verify.s — the file that consumes the library — now carries .assert LIB_NISTCURVES_VERSION_MINOR >= 9, lderror. A silent downgrade below the validation gate would otherwise pass every test we own, because all our KAT vectors carry well-formed public keys.

Proven to fire, not just present. Flipping it to >= 10:

ld65: Error: src/crypto/ecdsa_verify.s(51): libs/nistcurves: pin is older than v0.9.0
make: *** [build/c64-https.prg] Error 1

…and no PRG is produced. It costs zero PRG bytes (hashes identical with and without it) and, from v0.9.0, zero ld65 warnings — the version equates are exported :abs (upstream #95/#96).

P-384 failure point held steady

The bump initially moved make p384-overlay's failure earlier: the same per-variant rename turned lib_manifest.o into lib_manifest_sha384.o, which the P-384 wrapper's hardcoded archive list could not find. Applied the same discover-by-name fix, and verified the chain again stops at the pre-existing, documented blocker, unchanged:

ld65: Warning: cfg/p384-overlay-sha384.cfg(29): Segment 'LIB_NISTCURVES_SHA384_TABLES'
overflows memory area 'OVERLAY_REGION' by 1536 bytes

No P-384 regression from this bump. P-384 remains unbuildable for its own reasons.

Evidence bar — 4/4

$ make clean && make
ip65 47,105 B d522e68469b8fc09709f34e64fd5573a0a72465349374a26257194bd5ed96cca
$ make clean && make BACKEND=uci
UCI 62,977 B 66e37037deb9b295d3594a50b2bacfe95d09bb7e62f3dfc401c51b03c2461fed
$ python3 tools/test_ecdsa_kat_oracle.py
Passed: 6/6
Failed: 0/6
[+] ECDSA KAT oracle: ALL 6 VECTORS PASSED (3 valid accepted, 3 invalid rejected)
$ python3 tools/test_x509.py
Passed: 11/11
Failed: 0/11
[+] X.509/ECDSA: ALL 11 TESTS PASSED

Control, expected to be unaffected and was:

$ python3 tools/test_x25519.py
RESULTS: 73/73 passed, 0/73 failed

The three non-default profiles also link (packaging ships two of them):

make BACKEND=uci USE_NISTCURVES_ONCHIP=1 62,977 B 518ee446e57af1c5…
make USE_NISTCURVES_ONCHIP=1 47,105 B 132685878cae506d…
make BACKEND=uci USE_NISTCURVES_ONCHIP_COMB=1 2982865332107f69…

Sizes are region-padded, so identical size with a different hash is expected and is why only the PRG hash is quoted.

Explicitly not done

No hardware run, and no wall-clock re-measurement. Every timing figure in CLAUDE.md was measured at the v0.6.0 pin; this PR labels them as such so nobody quotes them as v0.9.1 numbers. The expected drift is small but its sign is a regression, not a speedup: v0.7.0's gate adds 2 fp_cmp + 3 mod-p muls + 4 mod-p add/subs per verify (noise against a multi-second scalar multiplication), and nothing else in v0.7.0–v0.9.1 touches a hot path — the rest is manifest equates, dead-data removal and export hygiene. Re-run tools/uci/bench_ecdsa_u64e.py on hardware to replace them.

🤖 Generated with Claude Code

JC-000and others added 3 commits August 14, 2026 09:29
…gs untouched
Takes the P-256 ECDSA verify library to upstream latest. The headline reason
is security: v0.7.0 added a FIPS 186-5 §3.3 public-key validation gate
(Qx,Qy in [0,p-1] plus on-curve Qy^2 = Qx^3 - 3Qx + b mod p, C=1 before any
scalar multiplication). c64-https hands ecdsa_verify_256 a Q parsed straight
out of an attacker-supplied certificate (src/tls_cert.s -> ecdsa_pubkey_x/y)
and performs no range or on-curve check of its own, so this closes a real gap
on our side.
No cfg change. The v0.7.0/v0.8.0 CRYPTO_HOT overflow is gone:
segment v0.6.0 v0.9.1 delta
LIB_..._P256_CODE $1FB4 (8116) $2084 (8324) +208 FIPS gate
LIB_..._P256_RODATA $01E0 (480) $00C0 (192) -288 dead vectors
LIB_..._P256_BSS $0520 (1312) $0520 (1312) 0
UCI CRYPTO_HOT last byte used: $9FFE (1 B free) -> $9FAE (81 B free)
The +208 reconciles the recorded failure exactly (1 - 208 = -207, the
"overflows CRYPTO_HOT by 207 bytes" in CLAUDE.md); v0.9.0's deletion of 288 B
of dead RFC 6979 self-test vectors from curve256.o covers it. The shelved
remedy of routing LIB_NISTCURVES_P256_RODATA into CRYPTO_OVERLAY is therefore
NOT adopted: the slot stays free for the three flags that contend for it, and
tools/uci/_memory_policy.py sees an unchanged $4200-$5FFF.
Fixes a latent silent-corruption trap in our own wrapper. v0.9.0 gave each
archive a per-variant zp_config member, so nistcurves-p256-verify.a now ships
zp_config_p256verify.o. build_nistcurves_p256.sh rebuilt a hardcoded
zp_config.o with c64-https's ZP overrides and re-archived strictly by
`ar65 t upstream.a`, so the override object would have been dropped without a
word and the upstream default archived instead -- restoring zp_ptr2 = $fd
(collides with zp_temp/zp_count during cert parsing) and fp_mul_i/j = $2c/$2d
(inside the fe25519 claim $2c-$37). No link error; runtime corruption. Both
wrappers now discover the member name, apply the matching upstream -D variant
gate, hard-error on an absent/ambiguous/unknown name, and post-check the
emitted object with od65.
Adds a link-time version floor (.assert LIB_NISTCURVES_VERSION_MINOR >= 9) in
src/crypto/ecdsa_verify.s so a silent downgrade below the validation gate is a
loud ld65 error rather than a quietly reopened gap -- our KAT vectors all carry
well-formed public keys, so the tests would not notice. Negative-tested:
flipping it to >= 10 fails the link with no PRG. Costs zero PRG bytes and,
from v0.9.0, zero ld65 warnings (the version equates export :abs).
ABI-break audit, measured rather than inferred. v0.9.0 bumps LIB_ABI_VERSION
0 -> 1 and removes exports. Diffing every export of the staged archive at both
pins against every import of c64-https's own objects: 31 symbols left the
archive, and the intersection with what we import is EMPTY. Our entire consumed
surface is four symbols -- ec_base_x, ec_gx256, ec_scalar_mul_var,
ecdsa_verify_256. The removed zero-page slots are doubly moot: c64-https
contains no .importzp directives at all and defines every slot locally.
Also keeps `make p384-overlay` at its pre-existing blocker. The bump initially
moved that failure earlier (the same per-variant manifest rename); with the
member-discovery fix the chain again stops exactly at LIB_NISTCURVES_SHA384_-
TABLES overflowing OVERLAY_REGION by 1536 bytes, unchanged.
Evidence:
make clean && make ip65 47,105 B d522e68469b8fc09709f34e64fd5573a0a72465349374a26257194bd5ed96cca
make clean && make BACKEND=uci UCI 62,977 B 66e37037deb9b295d3594a50b2bacfe95d09bb7e62f3dfc401c51b03c2461fed
tools/test_ecdsa_kat_oracle.py 6/6
tools/test_x509.py 11/11
tools/test_x25519.py 73/73 (control, unmoved)
Plus the three non-default profiles link: uci+onchip, ip65+onchip,
uci+onchip-comb.
Not measured: wall-clock. Every timing figure in CLAUDE.md was taken at the
v0.6.0 pin and is now labelled as such. Expected drift is a small regression
(the gate adds 2 fp_cmp + 3 mod-p muls + 4 add/subs per verify, noise against a
multi-second scalar mul); nothing else in v0.7.0-v0.9.1 touches a hot path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#92's §1 gate asserts `LIB_ABI_VERSION = 0`. nistcurves v0.9.0 bumped it
to 1 (the same release that removed 31 exports), so merging the two PRs
fires the gate by design:
ld65: Error: src/lib_contract_asserts.s(135): libs/nistcurves:
exported-surface generation changed (LIB_ABI_VERSION != 0) ...
That is the assert working — a named error with file, line and the
remedy, rather than a silent surface change. Flipped the expected value
to 1 as the message instructs; the assert stays.
Safety of the new generation is established by the link itself: an
import of any of the 31 removed symbols would be an unresolved external.
Both backends link, so we reference none of them.
Verified after the flip: ip65 47,105 B sha d522e684…, uci 62,977 B,
ECDSA KAT 6/6 including the three negative CAVP vectors.
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