Skip to content

crypto: RSA R^2 by squarings, NIST curves as compile-time types, Ed25519 constants, a register barrier (parity items 1-4) - #990

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

Japabu merged 5 commits into
mainfrom
wt/toyos-crypto-perf

Conversation

@Japabu

@Japabu Japabu commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

toyos-crypto's asymmetric rows are 5–70x behind ring on both benches (#932). Part of that is setup and constants recomputed on every call, and arithmetic that the shipped profile's overflow checks and per-row assertions slow down. This PR takes the cheap, safe wins of the parity analysis's top ten (items 2, 3, 4 and the non-erasure parts of item 1). The erasure contract is unchanged: every field element, residue and workspace is still erased as it is on main. The owner has since conditionally approved boundary-only erasure; that change comes with the 5x51 field in the next PR, with its M1 measurement and a written contract.

What changes

  • RSA verify (item 2). R^2 mod n starts from 2^(bits-1) < n, doubles in place up to 2^words * R and finishes with six Montgomery squarings: 33 doublings + 6 squarings for a 2048-bit key (starting at 2^(bits-1) rather than R mod n adds one) instead of 4096 doublings that each returned a 512-byte array by value. The exponentiation raises s to e - 1 from its top bit and finishes with one Montgomery multiplication by the unencoded s: 18 multiplications for 65537 instead of 36. The Montgomery multiplication interleaves product and reduction rows (CIOS), writes into a caller's array and has no overflow-checked operators or per-row assertion. All RSA data is public, so its final subtraction stays a branch.
  • NIST curves (item 4), typed from the start. Each modulus is a type implementing Modulus<N>, with N0, R mod p and R^2 mod p as compile-time associated constants; each curve is a type implementing Curve<N> with b and G in Montgomery form at compile time. Residue<M, N> is a residue modulo one modulus only, and Scalar<C, N> an integer below one curve's order, so a field element cannot meet an order residue or a P-384 value a P-256 one (stage 4's typed domains for these curves). Nothing is constructed per call; public_key keeps its point instead of encoding and re-parsing it; generation draws its four candidates in one fill on the same fixed schedule. Doubling is dbl-2001-b for a = -3 with small multiples as additions (3M + 5S, was 5S + 11M). Inversion multiplies only where the public exponent p - 2 has a set bit. The arithmetic carries with wrapping and overflowing operations, with no assertion per reduction row.
  • Ed25519 constants (item 3). F::from_bytes is a const fn; d, 2d, sqrt(-1) and B's extended coordinates are constants. The addition multiplies T1 T2 by 2d once and doubles Z1 Z2 by an addition, instead of a bit-serial decode of d and two multiplications by 2 per addition, and the base point is no longer decoded with a square root per use.
  • The constant-time barrier (item 1). ct::opaque is an empty asm! block taking the word inout(reg) with pure, nomem, nostack, preserves_flags, in arch/mod.rs beside the crate's other assembly, instead of a volatile stack read. Under Miri, which runs no assembly, the volatile read stands in.

Not in this PR: the 5x51 field rewrite, combs, windows, verify algorithms, and any change to per-temporary erasure.

Checks

All at f5fbc03 on the development Mac (aarch64-apple-darwin host). Logs: https://github.com/ToyOSOrg/evidence/tree/ae26e1f2ef9a28501c8d72472c14aa1c19229fac/pr-990/r1

  • cargo run -- --ci host: exit 0, 87 steps green. (An earlier run at b618d31f0 went red on clippy's needless_range_loop and chunks_exact_to_as_chunks in the new modules; fixed, and the rerun is this one.)
  • cargo test -p toyos-crypto --release in the three CI forms (dispatched features, --all-features, default): exit 0 each. Also --target x86_64-apple-darwin (Rosetta) with sign: exit 0.
  • cargo build -p toyos-crypto --release --target x86_64-unknown-uefi and aarch64-unknown-uefi: exit 0. RUSTFLAGS="--cfg miri" cargo check --features portable: exit 0 (the Miri arm compiles).
  • New unit tests: rsa::tests::power_and_r2_match_integer_oracle (R^2 and s^e against num-bigint, 7 modulus lengths from 256 to 512 bytes, e in {3, 17, 65537, 2^31+1, 2^32-1}, plus the s = n refusal); ed25519::tests::constants_match_their_definitions; nist_montgomery_domains_and_carries_match_integer_oracle now also holds N0, R mod p, R^2, squaring, from_integer's refusal of p and from_wide.
  • Negative controls, each a checked patch applied, built, tested with --features sign --release, and reverted in the same run (all builds exit 0; all tests exit 101):
control test that went red
R^2 doublings off by one rsa::tests::power_and_r2_match_integer_oracle
RSA range check s < n removed rsa::tests::power_and_r2_match_integer_oracle
SEC 1 on-curve check removed malformed_public_boundaries
field range check x < p removed nist_montgomery_domains_and_carries_match_integer_oracle
Montgomery final subtraction removed nist_montgomery_domains_and_carries_match_integer_oracle
zero scalar admitted nist_curve_boundary_scalars
doubling's 3(X-Z^2)(X+Z^2) made 2(...) nist_curves_reference_ecdh_and_ecdsa, nist_curve_boundary_scalars
one byte of B's T flipped ed25519::tests::constants_match_their_definitions
addition's 2d replaced by d ed25519_reference_verification

Review round 1 (78fdac6)

Measurements

The parity rows need the benches; the orchestrator runs them. Readback prepared: cargo test --test toyos-build -- --metal --metal-readback <dir> crypto_instrument at f5fbc03 staged the cryptocase image (exit 2, the harness's Staged verdict; no machine touched). No run was made of this image. The T14 probe boot at fb93764 measured this PR's NIST and RSA code unchanged, beneath #992; its rows are in #992.

Risks

  • The barrier's Miri arm is compiled (--cfg miri check) but not run under Miri here; the stage-4 Miri run covers it.
  • Constant time is unchanged in structure (fixed schedules, masked selects); no fixed-versus-random run has been made on this revision. The stage-4 harness covers it when it lands.

🤖 Generated with Claude Code

Japabu and others added 4 commits October 22, 2026 00:05
… products

Key construction made R^2 mod n by 2 * 64 * words modular doublings (4096
for a 2048-bit key), each returning a 512-byte array by value. It now
starts from 2^(bits-1) < n, doubles in place up to 2^words * R and finishes
with six Montgomery squarings, each of which takes 2^t * R to 2^(2t) * R.

The exponentiation walked all 32 bits of e and converted in and out of
Montgomery form. It now raises s to e - 1 left to right from its top bit and
finishes with one Montgomery multiplication by the unencoded s, which also
leaves the Montgomery domain: 18 multiplications for 65537 instead of 36.

The Montgomery multiplication interleaves each product row with its
reduction step (CIOS, Koc, Acar and Kaliski 1996), writes into a caller's
array, and carries with wrapping u128 and overflowing u64 operations, so
the shipped profile's overflow checks and the per-row assertion are gone.
All RSA data is public, so the final subtraction stays a branch.

A unit test holds R^2 and s^e against num-bigint for moduli of 256 to 512
bytes, partial top limbs included, and odd exponents up to 2^32 - 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every p256()/p384() call ran Curve::new: two Mod::new, each 2 * N * 64
modular doublings for R^2, and three conversions to Montgomery form. Key
parsing, generation and public_key each paid it, and public_key encoded its
point and parsed it again.

Each modulus is now a type implementing `Modulus<N>`, whose N0, R mod p and
R^2 mod p are associated constants evaluated at compile time, and each curve
is a type implementing `Curve<N>` with b and G already in Montgomery form.
A `Residue<M, N>` is a Montgomery residue modulo M alone, so a field element
cannot meet a residue modulo the order, nor a P-384 value a P-256 one; a
`Scalar<C, N>` is an integer below n of one curve. This is stage 4's typed
domains for these curves.

The arithmetic carries with wrapping u128 and overflowing u64 operations,
so the shipped profile's overflow checks and Mod::mul's per-row assertion
are gone. The multiplication interleaves each product row with its
reduction step (CIOS). Doubling is dbl-2001-b for a = -3 with the small
multiples formed by additions, 3M + 5S instead of 5S + 11M; infinity doubles
to Z = 0 without a select. Inversion squares and multiplies only where the
public exponent p - 2 has a set bit. Generation draws its four candidates in
one fill and keeps the same fixed schedule. public_key keeps the point it
computed.

The integer-oracle test now holds each modulus's constants, from_integer's
refusal of p, from_wide's single reduction and squaring against num-bigint.

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

Every point addition decoded d from bytes bit by bit, a 255-step loop, and
multiplied by F::small(2) twice; every use of the base point decoded it with
an inversion and a square root. F::from_bytes is now a const fn, so d, 2d,
sqrt(-1) and B's extended coordinates (RFC 8032 section 5.1) are constants.
The addition multiplies T1 T2 by 2d once and doubles Z1 Z2 by an addition.

A unit test holds the constants to their definitions: B to the decoding of
its encoding, 2d to d + d, d to -121665/121666 and sqrt(-1) squared to -1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ct::opaque was a volatile read of a stack local: a store and a load on
every mask and select. It is now an assembly block whose template is a
comment and which takes the word inout(reg) with pure, nomem, nostack and
preserves_flags. It emits no instruction, and LLVM still cannot see that the
output equals the input, so it cannot recover a mask's provenance and turn
a masked select into a branch. The block lives in the arch module beside the
crate's other assembly. Miri executes no assembly, so under Miri the
volatile read stands in.

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

Japabu commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #990, round 1 (head f5fbc03)

I read the four commits, the full diff against the base 2661b9e, each changed file at the head, the callers in ecc.rs and the tests, the spec's sections 2, 3.3–3.6 and 6, the evidence (controls/results.txt, ci-host-pr1b.log at f5fbc03) and the control patches. I ran nothing.

Architecture

This PR fits the approved plan (parity items 1–4) and keeps the erasure contract unchanged, as it says. Each modulus and curve is now a type with compile-time constants, which deletes the per-call Curve::new and the re-parse in public_key. Residue<M, N> and Scalar<C, N> give stage 4's typed domains for these curves, so a residue modulo one modulus cannot be passed where another is expected. The barrier sits in arch/mod.rs, next to the crate's other assembly. RSA stays public-only and variable-time, which its module contract allows. Net change: +791/−523 lines. Most of the growth is the typed Modulus/Curve machinery and two oracle tests. No dependency is added.

Arithmetic, checked by hand

  • RSA R². Start at x = 2^(bits−1) < n. Then do 64w − (bits−1) + w doublings to reach 2^w·R. Each of the six Montgomery squarings maps 2^t·R to 2^(2t)·R, ending at 2^(64w)·R = R² mod n. double handles the lost top bit correctly: 2x < 2n, and the subtraction wraps modulo 2^(64w). Correct.
  • RSA power. e is odd and at least 3, which from_components enforces. So e − 1 is even and at least 2, and its top bit is the starting base. Then come square-and-multiply and one multiplication by the unencoded s, which gives s^e. For 65537 that is 1 + 16 + 1 = 18 multiplications. Correct.
  • RSA CIOS. With a, b < n, t < 2n holds after every row, so t[n] ≤ 1. t[n+1] is assigned before it is read in every row, and its old value has already shifted into t[n]. The final subtraction condition is right. Correct.
  • NIST CIOS. high = overflow | carry is the exact top word, because t < 2p bounds it at 1. reduce and sub are correct. n0 takes six Newton steps (1 → 64 bits). shift_mod gives R and R² for x < p. The compile-time assert checks p's shape.
  • dbl-2001-b. It matches EFD term by term: alpha = 3(X−δ)(X+δ), X3 = α² − 8β, Z3 = (Y+Z)² − γ − δ, Y3 = α(4β − X3) − 8γ². That is 3M + 5S. Infinity doubles to Z = 0, and add's masked selects still cover P = Q, P = −Q and either input at infinity.
  • ECDSA verify. It is unchanged in logic. r and s are validated in [1, n−1], from_wide is valid because digest < 2^(64N) < 2n, and the affine x is reduced mod n before the compare.
  • Ed25519. The constants are tested against their definitions. d = zz + zz is the same element as before. A const of a Drop type is fine.

Barrier

It is asm!("/* {0} */", inout(reg) value, options(pure, nomem, nostack, preserves_flags)). It emits nothing, and LLVM cannot relate the output to the input. pure/nomem only allow CSE or removal of an unused barrier, and neither lets LLVM recover the mask's range. In #992's x86-64 listing it appears as an empty InlineAsm on rax followed by neg rax, with no memory round-trip. That is the intended effect. The Miri arm is only compiled (--cfg miri check), as the Risks section says.

Findings

BLOCKER

  1. lib/crypto/src/arch/mod.rs:1 — the module header is now false. It says "Each function here reports whether an instruction path ran; when none did, the caller computes the same result portably". value_barrier (:41, :52) dispatches nothing and has no portable alternative. A module contract that the new function contradicts misleads the next reader. Fix the header's scope in one clause, for example "The dispatch functions here report…".

SUGGESTION

  1. lib/crypto/src/rsa.rs:270 and :82 duplicate montgomery::mac and montgomery::n0 (montgomery.rs exports mac as pub(crate), but only it uses it). One copy of each would do.
  2. lib/crypto/src/nist.rs:57–75 — first_valid is new: one fill, take the first valid candidate, Entropy when none is valid. No test reaches its refusal or its selection order. Inspection establishes both, so a test is optional. If you add one, a four-candidate unit test is the cheapest, for example [n, 0, k1, k2] → k1, and four invalid → Entropy. The inner for i in 0..N (:65) shadows the outer i. Renaming it would read more easily.
  3. PR body: a 2048-bit key takes 33 doublings, not 32. Starting at 2^(bits−1) instead of R mod n adds one. This is for the orchestrator to correct.

Tests and evidence

  • The CI host log is at f5fbc03 and exits 0 with 87 green.
  • The nine controls are real patches, and each turns the claimed test red (results.txt, revision f5fbc03). They cover the R² count, the range checks, the final subtraction, the doubling and both Ed25519 constants.
  • The RSA oracle test covers full and partial top limbs at 2048–4096 bits, five exponents and the s = n refusal. It is hermetic and seeded.
  • No timing claim is made, and none is needed.

Context outside this PR

  • Neither PR dispatches on CPUID ADX. The only ADX check is in the unlanded probe's guest binary.
  • fiat-crypto is still a dev-dependency on main, for poly1305.rs's test, and the crypto track's stage-4 text still names it as the oracle. That conflicts with the owner's later ruling excluding fiat-crypto entirely. It is pre-existing and not this PR's to fix, but the orchestrator should record it.

Verdict: SEND BACK (one header clause, item 1). After that fix, this PR is fit to LAND.

- arch's module header scopes its dispatch contract to the dispatch
  functions; the value barrier dispatches nothing.
- RSA uses montgomery's mac and n0 instead of its own copies.
- A unit test holds generation's choice of the first valid of four
  candidates, and its refusal when none is valid; the selection loop's
  index no longer shadows the candidate's.

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

Japabu commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #990, round 2 (head 78fdac6)

I read the round-1 commit 78fdac6 against f5fbc03, the updated PR body, and the RSA and NIST call sites it touches. I ran nothing.

Round-1 items

  1. BLOCKER, arch/mod.rs header: fixed. The contract now reads "Each dispatch function here reports…". The value barrier is outside its scope, and the header is true again.

  2. RSA's duplicate mac/n0: taken. rsa.rs imports montgomery::{mac, n0}, and n0 is pub(crate) with a one-line contract ("for an odd p0"). That precondition holds. from_components refuses an even modulus (modulus[len-1] & 1 == 0) before it calls n0(m[0]).

  3. first_valid test and shadowing: taken. generation_takes_the_first_valid_candidate checks two things:

    • With [n, 0, k1, k2], selection takes k1. The test rejects n and 0 and skips the valid k2, so it pins both the validity test and the order of selection.
    • Four invalid candidates (n, 0, 0xff…ff > n, n) are refused with Entropy.

    The inner index is now j.

  4. 33 doublings: corrected in the body.

Evidence

No new findings.

Verdict: LAND.

@Japabu
Japabu marked this pull request as ready for review October 22, 2026 01:05
@Japabu
Japabu added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit c362532 Oct 22, 2026
8 checks passed
@Japabu
Japabu deleted the wt/toyos-crypto-perf branch October 22, 2026 01:24
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