Skip to content

crypto: P-256 special reduction, Booth windows, joint ECDSA verify (parity item 8a) - #1000

Merged
Japabu merged 1 commit into
mainfrom
wt/toyos-crypto-perf-5
Oct 22, 2026
Merged

Japabu merged 1 commit into
mainfrom
wt/toyos-crypto-perf-5

Conversation

@Japabu

@Japabu Japabu commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Builds on #999 (merged).

Problem

On the T14 (#932's baseline, and still after #990's constants and doubling), P-256 ECDH was 8.56x ring, ECDSA P-256 verify 9.33x, P-384 ECDH 2.49x and ECDSA P-384 verify 2.57x.

  • Every multiplication was bit-serial: a doubling and a complete addition per bit, and the addition doubled internally.
  • ECDSA verify ran two of those multiplications, then an addition and a field inversion.

This is item 8a: the analysis's N1 (special P-256 reduction), N3 (Booth windows) and D1–D2 (joint variable-time verify, projective check), for P-256 and P-384 alike. These are left for 8b: N4 (the P-256 base-point comb, which also needs a const table), N5 (an addition-chain inversion), a static G table for verify, and a dedicated square.

What changes

  • Field. The CIOS core is a const fn shared by every modulus, so 8b's tables can use it. P-256's prime takes a special reduction step (Gueron and Krasnov 2015): the quotient digit is the low word, q·2^32 lands at word one from two shifts, and only q·p3 is multiplied. This is u128 code, with no asm! and no mulx, as you directed after M2.
  • Secret multiplication (ECDH, key generation). Booth's signed 5-bit windows over the table P, 2P, ..., 16P.
    • Each window is five doublings plus one complete, masked addition of a table read.
    • The read takes all 16 entries and negates Y by mask.
    • The digits come by arithmetic from public bit positions: d = (u + 1)/2 − 32·u₅ over the six bits 5i − 1 ..= 5i + 4.
    • That is 52 windows for P-256 and 77 for P-384, instead of 256 or 384 bits.
  • ECDSA verify. u1·G + u2·Q in one doubling chain over width-5 NAFs of both scalars (Straus), with odd-multiple tables built per call.
    • It runs in variable time, through a PublicScalar type that the secret path cannot pass.
    • Additions branch on the public points' values instead of masking.
    • The result is checked projectively: X = r·Z², or X = (r + n)·Z² where r + n < p. No inversion.
  • Shared NAF. The width-w NAF is now one module (naf.rs), used by Ed25519's verify too.

Generated code

Both ToyOS targets in [profile.toyos], from P-256 and P-384 agree and public_key.

  • Point::mul's branches are its window and doubling counters, plus booth's tests of its public bit position.
  • Point::lookup's only branch is its 16-entry loop counter.
  • Residue::mul branches only on its row counter.

Checks

All at d098a66. Logs: https://github.com/ToyOSOrg/evidence/tree/8b4c9443c9456133f72799484c323a2f434d112a/pr-1000/r1

  • cargo run -- --ci host: exit 0, 87 steps green.
  • cargo clippy -p toyos-crypto --all-targets (adopted lints, -D warnings) and cargo test -p toyos-crypto --release, in the three CI forms: exit 0 each.
  • cargo test --test toyos-build -- crypto_instrument_prints_its_table (QEMU x86_64): exit 0. The log's first line names the revision.
  • These still pass on the new code: Wycheproof ECDSA P-256 and P-384 (r + n cases included), the ring ECDH and ECDSA differentials, and the boundary scalars.
  • New tests:
    • booth_digits_recompose_the_scalar: both curves; digits in [−16, 16], with +16 and −16 both reached.
    • naf_digits_recompose_384_bit_scalars.
    • booth_multiplication_matches_the_public_path: the fixed schedule against the joint NAF path.
    • public_sums_of_equal_and_opposite_points.
  • Negative controls, each a checked patch applied, built (exit 0), tested with --features sign --release --no-fail-fast, and reverted in the same run:
control red
Booth's −32·u₅ term removed Booth test, cross-path test, differentials, boundary scalars
Booth's first window without bit −1 as above
one window too few as above
table read never negates cross-path test, differentials
table read off by one cross-path test, differentials
verify without the r + n check Wycheproof ECDSA
verify against r·Z instead of r·Z² differential, Wycheproof
public addition of equal points gives the identity public_sums_of_equal_and_opposite_points (green before that test was added)
joint path adds instead of subtracting for a negative digit cross-path test, differential
P-256 reduction shifts q by 31 integer oracle, cross-path test, differentials

Measurements

Readback staged at d098a66 (crypto_instrument, cryptocase; exit 2 = Staged). The rows this moves are p-256/ecdh, p-384/ecdh, and both ECDSA verify rows.

  • Exploratory, development Mac under load, shipped-like profile, best of 7 batches of 50, ours over ring:
row ratio ours ring
P-256 ECDH 3.55 162.3 us 45.8 us
P-256 verify 2.00 81.9 us 41.1 us
P-384 ECDH 0.95 550.4 us 576.8 us
P-384 verify 0.49 271.0 us 554.7 us

This is not a bench measurement.

Risks

  • P-256 is still short of ring. The Mac exploratory run puts ECDH at 3.55x and verify at 2.0x. Half of the ECDH row is key generation, which the base-point comb (8b) replaces. The secret addition's masked doubling (option (a) in the analysis) costs about 8 field operations per window.
  • The new path has not been timed on a bench.

🤖 Generated with Claude Code

…rify

P-256 and P-384 multiplied by a bit-serial double-and-add, each bit a
doubling and a complete addition that itself doubled, and ECDSA verify
ran two such multiplications, an addition and an inversion.

- Field: the CIOS core is a const fn shared by every modulus. P-256's
  prime takes its special step (Gueron and Krasnov 2015): the quotient
  digit is the low word, q 2^32 lands at word one from two shifts, and
  only q p3 is multiplied.
- Secret multiplication (ECDH, key generation): Booth's signed 5-bit
  windows over the table P, 2P, ..., 16P. Each window is five doublings
  and one complete addition of a masked table read, which reads all 16
  entries and negates Y by mask; the digits come from public bit positions
  by arithmetic, d = (u + 1) / 2 - 32 u_5. 52 windows for P-256, 77 for
  P-384, against 256 or 384 bits.
- ECDSA verify: u1 G + u2 Q in one doubling chain over width-5 NAFs of
  both (Straus), with odd-multiple tables built per call, in variable time
  through a PublicScalar type the secret path cannot pass. Additions branch
  on the public points' values instead of masking. The result is checked
  projectively, X = r Z^2 or X = (r + n) Z^2 where r + n < p, with no
  inversion.
- The width-w NAF is one module, shared with Ed25519's verification.

Generated code on both ToyOS targets in [profile.toyos]: the Booth path's
branches are its window and doubling counters and booth's public bit
positions, and the table read's loop counter.

New tests: Booth digits in [-16, 16] recomposing the scalar for both
curves, with +16 and -16 both reached; the NAF at 384 bits; the Booth
multiplication against the public joint path; and the public path's sums
of equal and opposite points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review of #1000, round 1 (head 2594dd3, item 8a)

What I read and did not run

  • Read: the commit; montgomery.rs, nist.rs and naf.rs in full at the head; the Ed25519 NAF caller; the boundary-scalar differential test; and evidence 8b4c944 and fca6eb8 (controls with patches and results.txt, the inspection README, the four branch walks, and both CI logs).
  • Rebase: git range-diff shows 2594dd3 = d098a66. git diff 196ae3d17 264138fdb -- lib/crypto is empty, so the controls and inspection measured exactly this crate.
  • Not run: no builds and no tests. I did not read the analysis notes or any ring or BoringSSL source.

Architecture

  • Scope. This is N1, N3 and D1–D2 for both curves, with 8b's items stated as deferred.
  • Reduction form. The special form is a compile-time Form on Modulus. It is matched on a constant, so it costs nothing for the generic moduli.
  • Mechanisms deleted. The bit-serial secret multiplication, the two-multiplication verify and verify's inversion are all gone.
  • Shared NAF. The NAF moves into one module with two real callers.
  • Size. Net +386/−75, of which about 130 lines are tests.
  • No asm, no mulx, as decided. The field stays u128 CIOS. That fits your M2 decision (mulx gains on the T14 and loses on the AMD bench) and keeps the soft-float loader path portable.

Correctness, checked by hand

  • The P-256 special step.
    • p₀ = 2^64 − 1, so N0 = −p⁻¹ mod 2^64 = 1 and q = t₀.
    • Word 0: t₀ + q·(2^64 − 1) = q·2^64, which carries q into word 1.
    • Word 1: t₁ + q·(2^32 − 1) + q = t₁ + q·2^32. The code adds q << 32 and carries q >> 32 into word 2.
    • Word 2: p₂ = 0, so only that carry is added.
    • Word 3: t₃ + q·p₃.
    • The step is exactly the generic step's result, so the t < 2p invariant and reduce are unchanged.
    • The "shift by 31" control is red on the integer oracle.
  • Booth.
    • With u = b₅ᵢ₋₁ + 2v, where v is bits 5i..5i+4, (u + 1) >> 1 = v + b₅ᵢ₋₁.
    • So d = v + b₅ᵢ₋₁ − 32·b₅ᵢ₊₄, which is the telescoping signed-window digit. The sum is Σ dᵢ·2^(5i) = k, with b₋₁ = 0, and dᵢ ∈ [−16, 16].
    • Window counts:
      • 52 windows reach bit 259, and b₂₅₉ = 0.
      • 77 windows reach bit 384, and b₃₈₄ = 0.
      • So the top window's −32 term vanishes, and its digit is non-negative.
    • Bit extraction:
      • bits crosses a word exactly when shift ≥ 59. It never shifts by 64, and its branches test only the public position.
      • << binds tighter than &, so bits(0) << 1 & 63 is right.
      • Every value is bounded by & 63 before + 1. The overflow check can therefore not fire, and the listings show it removed.
    • The tests reach both +16 and −16, on both curves.
  • Lookup and multiplication.
    • lookup:
      • It computes |d| without branching and reads all 16 entries through ct::nonzero and Residue::select (which applies the ct::mask barrier).
      • d = 0 yields the identity (0, 1, 0).
      • It negates Y by mask. Negating the identity's Y leaves Z = 0.
    • mul follows Horner over the windows: top digit, then five doublings and one complete masked add per window.
  • Joint verify.
    • It computes u₁·G + u₂·Q over width-5 NAFs, from odd-multiple tables (P, 3P, …, 15P).
    • add_public handles both infinities, equal points (doubling) and opposite points (identity).
    • 385 digits cover N = 6, and k < n < 2^(64N) − 2^7 meets naf's precondition.
    • The check: x(R) = X/Z² < p < 2n.
      • So x mod n = r exactly when x = r, or x = r + n and r + n < p.
      • The code requires the carry to be clear and from_integer to succeed, which together ensure r + n < p.
      • An infinite result is refused.
    • PublicScalar is private to nist, built only inside verify from public u₁ and u₂. Scalar has no conversion to it. joint accepts only PublicScalar.

Shipped-profile inspection

I spot-checked the branch walks.

  • x86-64:
    • Point::mul has its window and doubling counters and the cmpq $40 bit-position test.
    • lookup has a single branch: addq $96; cmpq $1624.
    • Residue::mul, add and double have only Residue::mul's row counter.
  • aarch64: the same.
    • lookup's b.ne takes its flags from subs x5, x5, #96, with no flag setter in between.
    • The Booth word load is x20 + 8·word, which depends only on the public window index.
  • Data-dependent branch: affine's single test on the product's Z. Its outcome is constant for validated inputs.

The claim holds on both targets.

Secret-dependent timing in #1000's P-256 path

At the code level, I see none.

  • No branch, loop bound or memory address depends on the scalar, the digits or the residues.
  • There is no division, no variable shift on secret data, and no overflow-check branch on secret arithmetic.
  • The only data instructions are 64×64→128 mul/mulq (mul/umulh on aarch64), add and subtract with carry, the logic operations and constant shifts.

What remains is instruction-level data dependence. This is inference, not established:

Controls

  • All ten are real patches, and red.
    • Booth sign term, first window, window count, both lookup mutations, the joint negation, both verify checks, and the reduction shift.
  • The equal-points control. It was green until public_sums_of_equal_and_opposite_points was added, then red. It covers add_public's doubling branch, the case joint needs for u₁·G + u₂·Q with Q = G.
  • No control removes the secret add's masked doubling. Such a control would be an equivalent mutant (suggestion 1).

Findings

No blockers.

SUGGESTION

  1. nist.rs:187 — the secret add's masked doubling is never selected. It is unreachable from Point::mul and from the table build.
    • Proof sketch. Booth's partial sums rᵢ = Σⱼ≥ᵢ dⱼ·2^(5(j−i)) are non-negative and at most k/2^(5i) + 1.
    • At window i ≥ 1, 32·rᵢ₊₁ ≡ ±dᵢ (mod n) forces rᵢ₊₁ = 0 and dᵢ = 0, which is the identity case the Z selects already handle.
    • At window 0, 32·r₁ = k − d₀. Equality mod n then needs k = n + 2d₀ with d₀ < 0 and d₀ ≡ −n (mod 32).
      • P-256: n ≡ 17 (mod 32), so d₀ ≡ 15.
      • P-384: n ≡ 19 (mod 32), so d₀ ≡ 13.
      • Neither residue has a negative representative in [−16, −1], so no such d₀ exists.
    • The table build adds P to jP only for j ≥ 2, so it never adds equal points either.
    • Cost. The doubling is computed on every add: 51 + 14 doublings per P-256 multiplication, about one in seven field operations.
    • Options. Remove it, with the argument at the site and a one-line test of n mod 32 per curve. Or keep it as defence in depth and say so. The analysis chose option (a), so this is your call.
  2. montgomery.rs — Form::P256 hard-codes N = 4 and N0 = 1. A const assertion in P256Field, such as assert!(Self::N0 == 1) together with the word shape, would turn a future misuse into a compile error.
  3. naf.rs:1–9 — the module header and the function doc repeat each other (the reference and "variable time, public"). Keep one.

Evidence

  • --ci host exits 0 at d098a66 and again at 2594dd3. The three forms and the QEMU row, whose log names its revision, exit 0.
  • No bench numbers are claimed. The Mac table is labelled exploratory, and the staged readback is of identical crypto code.

Verdict: LAND (suggestions optional; landing is the orchestrator's).

Base automatically changed from wt/toyos-crypto-perf-4 to main October 22, 2026 05:15
@Japabu
Japabu marked this pull request as ready for review October 22, 2026 05:15
@Japabu
Japabu enabled auto-merge October 22, 2026 05:15
@Japabu
Japabu added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 954b458 Oct 22, 2026
4 checks passed
@Japabu
Japabu deleted the wt/toyos-crypto-perf-5 branch October 22, 2026 05:50
Japabu added a commit that referenced this pull request Oct 22, 2026
…measure

russh's `p384` is on its `bignum` backend under #1005, so the fiat record
names the dependency as one no guest build selects. The T14's P-256
finding is re-measured on #1000's arithmetic, now on main, with a boot
that sets DOITM beside the one that does not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqpxbTfpvcbx1FJEy3Eny9
Japabu added a commit that referenced this pull request Oct 22, 2026
…measure

russh's `p384` is on its `bignum` backend under #1005, so the fiat record
names the dependency as one no guest build selects. The T14's P-256
finding is re-measured on #1000's arithmetic, now on main, with a boot
that sets DOITM beside the one that does not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqpxbTfpvcbx1FJEy3Eny9
Japabu added a commit that referenced this pull request Oct 22, 2026
M2 on both benches put a dual-carry-chain Montgomery row at 0.40x (T14)
and 0.43x (AMD bench) of the mulx-and-addcarry intrinsics form for a
2048-bit modulus, and RSA-2048 verify was 2.14x ring on the T14 at #1000,
its exponentiation the remaining cost.

The kernel is one asm! block per word of b: a product row and a reduction
row over the modulus's words, each four words per loop step, with mulx
for the products and the low and high halves added on two independent
carry chains, CF through adcx and OF through adox (Intel SDM Vol. 2A).
The loop counts with lea and jrcxz, which leave both flags alone. Rust
keeps the outer loop and the final subtraction; all RSA data is public.

CPUID leaf 7 EBX bit 19 now admits ADX beside BMI2, and the hosted x86-64
dispatch takes the kernel where both are present; elsewhere the portable
CIOS multiplication runs, now `mul_portable`. Keys use whole groups of
four words: the zero words above a modulus only enlarge R.

A unit test holds the dispatched multiplication to the portable one for
five modulus lengths and the largest residues; it compares two runs of
the portable code on CPUs without ADX, and so checks the kernel only on
x86-64 hosts that have it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXEk8BBMDT8tXu4AnENB9Q
Sign up for free to 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