-
-
Notifications
You must be signed in to change notification settings - Fork 1
p256_perf
Benchmark results for substrate-level changes to P-256 scalar multiplication and modular inverse. All changes preserve the project's "no precomputed tables indexed by secret bits" architectural posture (SECURITY.md §Side-channel resistance) and "register-only logic with no data-dependent memory access on secret-derived values" rule.
- Date: 2026-05-22
- Hardware: Apple Silicon, arm64
- Runtime: Bun 1.3.13 (JavaScriptCore)
-
WASM:
build/p256.wasm, instantiated once per bench process - Method: 5 sequential runs per measurement, median reported; per-op iteration counts chosen so each measurement runs at least ~10 ms of wall clock to keep variance under ~3%
- Inputs: deterministic; same seed bytes across all measurements
This is a substrate-level micro-bench, not a browser-side end-to-end suite. Numbers reflect the AS / WASM toolchain in JSC; absolute numbers on V8 / SpiderMonkey will differ but the relative deltas should hold.
Naive Fermat scalarInv with constant-time always-compute, separate
pointMulBase(u1, G) + pointMul(u2, Q) + pointAdd for verify.
| op | median |
|---|---|
| feMul | 240 ns |
| feSqr | 237 ns |
| feInv | 123.3 µs |
| scalarMul | 20.4 µs |
| scalarInv | 10.54 ms |
| pointAdd | 3.63 µs |
| pointDouble | 3.32 µs |
| pointMul | 1.791 ms |
| pointMulBase | 1.789 ms |
| ecdsaSign | 14.41 ms |
| ecdsaVerify | 14.46 ms |
ECDSA call cost is dominated by scalarInv: 73% of sign, 72% of
verify. Substrate field arithmetic is small in comparison.
Hypothesis. A dedicated squaring routine exploiting
a_i · a_j = a_j · a_i should compute 28 off-diagonal + 8 diagonal
partial products instead of feMul's 64, saving ~30% on feSqr.
Two implementations tried:
- Comba column accumulator with 3 × u32 lanes (96-bit running column
sum). 28 off-diagonals computed once, added twice via
combaAdd(p); combaAdd(p)pattern. - Schoolbook with off-diagonal symmetry: row-by-row over
i < jonly, then in-place left-shift to double, then 8 diagonal adds with constant-time carry propagation.
Result. Both within ±5% of feMul(a, a) cost, neither faster:
| op | baseline | attempt 1 | attempt 2 |
|---|---|---|---|
| feSqr | 237 ns | 237 ns | 248 ns |
| feInv | 123.3 µs | 126.3 µs | 128.1 µs |
| pointDouble | 3.32 µs | 3.40 µs | 3.39 µs |
Why it doesn't win. The schoolbook inner step
t = pij + ai * bj + carry
is a 5-op pipeline (load + mul + add + store + shift). The multiply is
not the bottleneck; the load/store/carry-shift chain is. Saving 28/64
of the multiplications would save ~7-8% of feMul cost in theory, but
both implementations introduce structural overhead (a 3-lane carry
accumulator, or a separate phase-3 diagonal-add loop with fixed-length
constant-time carry propagation) that costs at least as much as it
saves.
For comparison, curve25519's feSqr does win, because its radix-2^51
representation gives 128-bit accumulator headroom that absorbs many
partial products without per-product carry propagation. P-256's
radix-2^32 saturated representation has no such headroom.
Decision. Rejected. The audit-surface cost of a dedicated feSqr
is not justified when the wall-clock saving is zero. feSqr remains
the one-liner feMul(out, a, a).
Implementation. New export pointMulDoubleVerify(u1, u2, Q, out)
in src/asm/p256/scalar_mult.ts. Interleaves the two ladders of
[u1]G + [u2]Q into a single 256-iteration loop:
- 4-entry table
T = {O, Q, G, G+Q}materialised once before the loop (G + Q is computed by onepointAdd) - Per bit pair
(b1, b2): one sharedpointDouble, one conditionalpointAddofT[2·b1 | b2]; skip when both bits are zero
ecdsaVerify replaces its pointMulBase + pointMul + pointAdd
triplet with a single call. The 4-entry table is indexed by PUBLIC
verify-input bits, not by any secret-derived value, so it remains
outside the architectural prohibition on secret-bit-indexed tables.
The function explicitly does not satisfy constant-time discipline
across the bit-pair selector; verify is already non-CT across reject
branches by design (see asm_p256.md §Verify timing).
Result. 5-run median, baseline → after change 2:
| op | baseline | after | delta |
|---|---|---|---|
| ecdsaVerify | 14.46 ms | 12.09 ms | −16.4% |
| ecdsaSign | 14.41 ms | (unchanged) | ~0% |
| pointMul | 1.79 ms | 1.78 ms | -0.7% |
| pointMulBase | 1.79 ms | 1.78 ms | -0.6% |
Larger than the original 13% estimate because the bench's ecdsaVerify
also captures pointAffinify / scalarReduce overhead that I had lumped
into "scalar mult portion"; actual savings versus the replaced cost
are ~50% of the replaced cost, which is ~16% of verify wall-clock.
Implementation. Replaced naive square-and-multiply in scalarInv
with fixed-window-4 exponentiation over the PUBLIC exponent (n-2):
- Precompute 15-entry table
a^1..a^15at FIELD_TMP slots 16..30 (free during scalarInv; onlypoint.tsuses these slots and is not on this call path). - Scan
(n-2)MSB-first as 64 4-bit windows. Per window: 4 unconditional squarings + 1 conditional multiply (skipped when the window value is 0).
Constant-time discipline preserved:
- The branch on
win == 0is on PUBLIC exponent bits (n-2 is a fixed constant from SP 800-186 §3.2.1.3). - The table lookup index
(win - 1) * 32is also derived from public exponent bits; no secret-bit-driven memory access. - The table CONTENTS are derived from the secret
a, but every entry is computed unconditionally in a fixed order. The architectural prohibition is on secret-bit-indexed access patterns; public-bit-indexed access to secret-derived storage is the same shape as Strauss-Shamir's 4-entry table inpointMulDoubleVerify.
Result. 5-run median, baseline → after change 3 (cumulative with #2):
| op | baseline | after #2 | after #3 | total delta |
|---|---|---|---|---|
| scalarInv | 10.54 ms | 10.08 ms | 6.49 ms | −38.4% |
| ecdsaSign | 14.41 ms | 13.99 ms | 10.41 ms | −27.7% |
| ecdsaVerify | 14.46 ms | 12.09 ms | 8.41 ms | −41.8% |
Why the win is bigger than my pre-implementation estimate (13%).
The OLD scalarInv ran unconditional scalarMul(tmp, acc, aCopy) on
every iteration and committed via constant-time mask-select on the bit.
That is ~512 scalarMul-equivalents (256 squarings + 256 unconditional
multiplies). The NEW windowed version, branching on PUBLIC exponent
bits, lands at 14 precompute + 256 squarings + (64 windows × 56/64
non-zero) multiplies = ~326 scalarMul-equivalents.
saving = (512 - 326) / 512 ≈ 36%
That matches the measured 38%. The under-estimate happened because I counted "naive Fermat = 256 squarings + 128 multiplies" rather than "constant-time naive Fermat = 256 squarings + 256 multiplies".
The branch-on-public-bit is the load-bearing move. Constant-time discipline on a SECRET exponent forces always-compute, which doubles the multiply count. Public exponents can branch freely.
Implementation. Replaced the windowed Fermat scalarInv with bit-by-bit Bernstein-Yang safegcd (eprint 2019/266 §11). 743 divsteps over a 9 × u32 LE-limb signed representation of (f, g), with (u, v) tracked modulo n in 8 × u32 LE limbs. Constant-time by construction:
- Every conditional in the divstep uses mask-driven selects on the two
per-step bits:
δ > 0andg & 1. Neither is branched on directly. - Per-divstep update of (u, v): 9-limb mask-driven mul-add of u into v
(with sign encoded via two's-complement: XOR with
maskNeg, AND withmaskUse, carry-in =swapCond), then reduce mod n by conditional add of n if negative + conditional sub of n if ≥ n, then modular halve by conditional add of n if odd + arithmetic shift right. - Fixed 743-iteration count from Theorem 11.2 (
iter(n) = ⌈(49n+80)/17⌉for n = 256). - At termination: if f's sign bit is set, the answer is
n - uinstead ofu(computed via constant-time conditional select).
The implementation works in LE u32 limbs internally, bypassing the byte-by-byte scalarMul / scalarReduce64 path entirely. BE↔LE conversion at the function boundary is the only byte-level work.
Result. 5-run median, baseline → after change 4 (cumulative with #2 and #3):
| op | baseline | after #3 (windowed) | after #4 (safegcd) | total delta |
|---|---|---|---|---|
| scalarInv | 10.54 ms | 6.49 ms | 32.4 µs | −99.69% (325× faster) |
| ecdsaSign | 14.41 ms | 10.41 ms | 3.92 ms | −72.8% (3.67× faster) |
| ecdsaVerify | 14.46 ms | 8.41 ms | 1.92 ms | −86.7% (7.53× faster) |
Why so much bigger than my pre-implementation estimate (5×). Three compounding effects:
- Per-divstep cost is much lower than I assumed. I estimated ~10 ns per u32 op based on conservative WASM/JSC numbers. Actual is closer to 1-2 ns per u32 op on JSC for this code shape (tight u32 work with excellent register allocation).
- No scalarMul calls at all. The windowed implementation made ~270 scalarMul calls each averaging 20 µs (5.4 ms total). Safegcd uses only 9-limb u32 additions and shifts.
- WASM JIT loves tight u32 loops. The divstep body is mostly register-resident operations on small operands; JSC optimizes the path aggressively.
The 743 × ~85 u32 ops × ~0.5 ns ≈ 32 µs matches the measurement.
Audit notes.
- Algorithm: eprint 2019/266 §11.3 (divstep), §11.2 (iteration bound),
§11.4 (magnitude bound). Independently transcribed per AGENTS.md §4;
cross-checked against RustCrypto
crypto-bigint::modular::safegcdand BoringSSLbn_mod_inverse_consttimefor algorithm shape only. - Correctness: 200 deterministic random + 5 edge-case inputs verified
via the
a · inv(a) ≡ 1 (mod n)substrate invariant (test/unit/p256/scalar.test.ts > scalarInv: stress test). Plus the RFC 6979 §A.2.5 KAT vectors implicitly verify k^{-1} via the reproduced (r, s) outputs. - Constant-time: branches on
δ > 0andg & 1use mask-driven selects throughout. The iteration count is the fixed public bound; the per-step work is fixed-shape regardless of secret inputs.
5-run median, baseline → after all four changes (#1 was rejected by measurement; #2, #3, #4 shipped):
| op | baseline | shipped | delta |
|---|---|---|---|
| feMul | 240 ns | 237 ns | -1.2% |
| feSqr | 237 ns | 237 ns | +0.1% |
| feInv | 123.3 µs | 129.4 µs | +5.0% (variance) |
| scalarMul | 20.4 µs | 20.3 µs | -0.3% |
| scalarInv | 10.54 ms | 32.4 µs | −99.7% (325× faster) |
| pointAdd | 3.63 µs | 3.63 µs | -0.1% |
| pointDouble | 3.32 µs | 3.38 µs | +1.8% (variance) |
| pointMul | 1.79 ms | 1.78 ms | -0.4% |
| pointMulBase | 1.79 ms | 1.80 ms | +0.5% |
| ecdsaSign | 14.41 ms | 3.92 ms | −72.8% (3.67× faster) |
| ecdsaVerify | 14.46 ms | 1.92 ms | −86.7% (7.53× faster) |
WASM binary growth: build/p256.wasm 19,084 → 20,288 bytes (+1,204
bytes, +6.3%). gzip-embedded delta: 6.1 → 6.6 KB.
After #2 + #3 + #4:
| component of cost | sign | verify |
|---|---|---|
| pointMulBase / pointMul / pointMulDoubleVerify | 1.80 ms (46%) | 1.80 ms (94%) |
| SHA-256 + HMAC-DRBG + scalarMul (RFC 6979 K-derivation) | ~1.9 ms (49%) | n/a |
| scalarInv | 32 µs (0.8%) | 32 µs (1.7%) |
| other (reductions, affinify, etc.) | ~0.2 ms | ~0.1 ms |
Sign is now dominated by SHA-256 + HMAC-DRBG + pointMulBase. Verify is
dominated entirely by pointMulDoubleVerify. Both ECDSA paths are now
in the low single-millisecond range.
The next-tier optimization targets:
-
Limb-level scalarMul. The byte-by-byte schoolbook in scalar.ts is ~20 µs per call; a limb-level radix-2^32 schoolbook (mirroring feMul's shape with a Barrett or Montgomery reduction mod n) would be ~250 ns per call, an 80× speedup. Affects the RFC 6979 K-derivation (~270 scalarMuls per sign) and the verify u1/u2 computation (2 muls). Estimated sign delta: ~−5 ms to ~−6 ms, possibly larger if HMAC-DRBG also shares the scalarMul time. Audit surface: moderate.
-
Pre-computed comb for
[scalar]G. Adds a precomputed table indexed by SECRET scalar bits, which is the architectural posture this library does not pursue (SECURITY.md §Side-channel resistance). Stays rejected.
A note on what was investigated and explicitly NOT pursued:
-
SIMD field arithmetic: rejected at module inception. The HMV
§2.4.1 Solinas reduction does not lane-pack; AS
v128does not expose paired64×64 → 128. See asm_p256.md §SIMD posture. - Comba feSqr: rejected by measurement (this doc, change 1).
-
RCB mixed addition (a = -3, Z₂ = 1): saves only 1 generic feMul
per add in the projective RCB representation (not the 4-5 from the
textbook Jacobian + affine quote). With pointMul / pointMulBase at
~1.8 ms each, the wall-clock saving would be ~256 feMul × 240 ns ≈
60 µs per ladder, ~3% of pointMul. Not worth the audit cost of a
second
pointAddMixedvariant.
| Document | Description |
|---|---|
| index | Project Documentation index |
| asm_p256 | WASM API reference, RCB formulas, Solinas reduction |
| ecdsa | TypeScript wrapper class |
| architecture | Module structure, side-channel posture |
- Sign Tools
-
SignatureSuite
- format-byte catalog, hybrid composite encodings, custom suite contract
- Serpent-256 TypeScript | WASM
-
Serpent,SerpentCtr,SerpentCbc,SerpentGenerator
-
- ChaCha20 TypeScript | WASM
-
ChaCha20,Poly1305,ChaCha20Poly1305,XChaCha20Poly1305,ChaCha20Generator
-
- AES TypeScript | WASM
-
AES,AESCbc,AESCtr,AESGCM,AESGCMSIV,AESGenerator
-
- ML-DSA TypeScript | WASM
- pure (FIPS 204):
MlDsa44,MlDsa65,MlDsa87 - pure-mode suites:
MlDsa44Suite,MlDsa65Suite,MlDsa87Suite - prehash suites:
MlDsa44PreHashSuite,MlDsa65PreHashSuite,MlDsa87PreHashSuite
- pure (FIPS 204):
- SLH-DSA TypeScript | WASM
- pure (FIPS 205):
SlhDsa128f,SlhDsa192f,SlhDsa256f - pure-mode suites:
SlhDsa128fSuite,SlhDsa192fSuite,SlhDsa256fSuite - prehash suites:
SlhDsa128fPreHashSuite,SlhDsa192fPreHashSuite,SlhDsa256fPreHashSuite
- pure (FIPS 205):
- Ed25519 TypeScript | WASM
-
Ed25519(pure + Ed25519ph),Ed25519Suite,Ed25519PreHashSuite
-
- ECDSA-P256 TypeScript | WASM
-
EcdsaP256(hedged + RFC 6979),EcdsaP256Suite - DER codec:
ecdsaSignatureToDer,ecdsaSignatureFromDer,encodeEcPrivateKey,decodeEcPrivateKey,pointDecompress
-
- Hybrid composites PQ-only | Classical+PQ
- PQ-only:
MlDsa44SlhDsa128fSuite,MlDsa65SlhDsa192fSuite,MlDsa87SlhDsa256fSuite - Classical+PQ:
MlDsa44Ed25519Suite,MlDsa65Ed25519Suite,MlDsa44EcdsaP256Suite,MlDsa65EcdsaP256Suite
- PQ-only:
- X25519 TypeScript | WASM
-
X25519,KeyAgreementError(RFC 7748)
-
- ML-KEM TypeScript | WASM
-
MlKem512,MlKem768,MlKem1024
-
-
Ratchet (SPQR)
-
KDFChain,ratchetInit,kemRatchetEncap,kemRatchetDecap,RatchetKeypair,SkippedKeyStore
-
- Hashing overview
- SHA-2 TypeScript | WASM
-
SHA256,SHA384,SHA512,SHA224,SHA512_224,SHA512_256 -
HMAC_SHA256,HMAC_SHA384,HMAC_SHA512,HKDF_SHA256,HKDF_SHA512
-
- SHA-3 TypeScript | WASM
-
SHA3_224,SHA3_256,SHA3_384,SHA3_512,SHAKE128,SHAKE256
-
- BLAKE3 TypeScript | WASM
-
BLAKE3,BLAKE3Stream,BLAKE3KeyedHash,BLAKE3KeyedHashStream -
BLAKE3DeriveKey,BLAKE3DeriveKeyStream,BLAKE3OutputReader,BLAKE3Hash
-
-
KMAC
-
CSHAKE128,CSHAKE256,KMAC128,KMAC256,KMACXOF128,KMACXOF256
-
-
Merkle
-
MerkleVerifier,MerkleLog -
SignedLog,Sha256Tree,Blake3Tree,MemoryStorage
-
-
Fortuna CSPRNG
-
Fortuna,SerpentGenerator,ChaCha20Generator,AESGenerator,SHA256Hash,SHA3_256Hash,BLAKE3Hash
-
- Utils TypeScript | WASM
-
constantTimeEqual,randomBytes,wipe, encoding helpers
-
-
TypeScript interfaces
-
Hash,KeyedHash,Blockcipher,Streamcipher,AEAD,Generator,HashFn
-