Skip to content

crypto: Ed25519 signing and X25519 keys by a fixed-base comb (parity item 7) - #999

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

Japabu merged 2 commits into
mainfrom
wt/toyos-crypto-perf-4

Conversation

@Japabu

@Japabu Japabu commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Builds on #997 (merged).

Problem

On the T14 at #997, ed25519/sign/64 was 6.66x ring (86.4 us against 12.9). M4 put X25519 key generation at 42.0 us against ring's 12.3. Signing and Ed25519 key generation multiplied the base point by 256 doublings and complete additions, with a select per bit. An X25519 public key was a second 255-step ladder from u = 9.

This is the analysis's item 7 for Ed25519 and X25519 (E2, X3).

The P-256 Booth comb (N4) moves to item 8, beside the P-256 field it needs. Its compile-time table needs a const Montgomery multiplication, and item 8 rewrites that field, so doing it there avoids building it twice.

What changes

  • Fixed-base comb, radix 16 with signed digits (Bernstein, Duif, Lange, Schwabe and Yang, "High-speed high-security signatures", 2012, section 4):
    • Recoding. A scalar below 2^255 becomes 64 digits in [-8, 8] by wrapping arithmetic alone. The digits are held in a Workspace and erased when the multiplication returns.
    • Table. j·16^(2i)·B for j = 1..=8 in 32 rows, affine (y + x, y − x, 2dxy), 30 KiB. It is built at compile time by the field's const fn arithmetic, with one simultaneous inversion. The builder is shared with crypto: Ed25519 verify by one joint multiplication, scalars by Barrett (parity items 6, E3, E4) #997's verification table.
    • Schedule. The odd digits' 32 entries are added, the sum is doubled four times, then the even digits' 32 entries are added: 64 mixed additions and 4 doublings in all.
    • Lookup. Each lookup reads all eight entries of its row and selects by mask. A negative digit swaps y + x with y − x and negates 2dxy under a mask.
  • Callers. Ed25519 signing (R = [r]B) and key generation use the comb. An X25519 public key is u = (1 + y)/(1 − y) of the comb's [s]B (RFC 7748 section 4.1), one inversion. The fixed-schedule Edwards double-and-add is deleted.
  • Erasure contract. secret.rs lists the comb's digits among the workspaces.

Generated code

Both ToyOS targets in [profile.toyos], from SigningKey::sign, SigningKey::from_seed and x25519::SecretKey::public_key; inspection in the evidence below.

  • Found and fixed. The first version recoded with += and -= on the secret i8 digits. The shipped profile's overflow checks compiled those to branches whose condition came from the digits: addb …; jo on x86-64, cmp w, w, sxtb; b.ne on aarch64. None was ever taken. They are wrapping operations now.
  • After the fix, every branch reached is one of these:
    • a loop counter (the recoding, the comb's rows and select's eight entries; row addresses advance by 960 bytes on the public row index);
    • a public message or buffer length;
    • the hash's round counter;
    • copy_nonoverlapping's debug overlap check, on stack addresses.

Checks

All at db5c6c5. Logs: https://github.com/ToyOSOrg/evidence/tree/04eb191a49232f539f929ac8bf58443913da5828/pr-999/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.
  • Builds for x86_64-unknown-uefi and aarch64-unknown-uefi: exit 0.
  • cargo test --test toyos-build -- crypto_instrument_prints_its_table (QEMU x86_64): exit 0.
  • These pass on the comb: the ring and ed25519-dalek sign differentials, RFC 8032's signing vectors, and RFC 7748's public-key vectors.
  • New tests:
  • Negative controls. Each is a checked patch applied, built (exit 0), tested with --features sign --release --no-fail-fast, and reverted in the same run:
control result
recoding threshold +8 made +9 red: recoding test, comb-vs-Straus, X25519 keys, differentials, RFC 7748/8032
recoding threshold +8 made +7 green: an equivalent mutant (digits land in [-7, 8], another valid recoding for an 8-entry table)
negative digits not negated red: comb-vs-Straus, X25519 keys, differentials, RFCs
lookup takes entry j instead of j + 1 red: as above
odd digits read at even positions red: as above
three doublings made two red: as above
table rows advanced by 2^7 instead of 2^8 (seven doublings, not eight) red: table test and the above
X25519 map inverted, (1 - y)/(1 + y) red: X25519 keys, X25519 differential, RFC 7748

Measurements

Readback staged at db5c6c5 (crypto_instrument, cryptocase; exit 2 = Staged, no machine touched). The rows this PR moves are ed25519/sign/64 and x25519/ecdh (key generation is in the row), plus m4's split if the probe is rerun on it.

  • Exploratory, development Mac under load, a profile like the shipped one, best of 7 batches of 200: sign 15.2 us against ring's 16.0 (0.95; was 88.3 us). This is not a bench measurement.

Risks

  • The 30 KiB table is read row by row on every signature. That cache cost is part of what M5 (4-bit against 5-bit layout) would measure; M5 is not measured.
  • The UEFI loader target builds, but this code has never run there.

🤖 Generated with Claude Code

@Japabu

Japabu commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #999, round 1 (head db5c6c5, stacked on #997)

I read the commit against cd7960d, each changed file in full at the head (ed25519.rs, x25519.rs, field25519.rs, secret.rs), ct.rs, spec §3.1–3.2 and §4, the PR body, and evidence 04eb191 (pr-999/r1: controls with patches and results.txt, the inspection README and both branch walks, and the CI and QEMU logs).

I did not rebuild, because this role runs no builds. The asm spot-check below uses the implementer's branch-walk listings of the shipped code. I did not read the analysis notes or any ring or BoringSSL source.

Architecture

  • Scope. This is spec E2 and X3, matching the paper's §4 construction.
  • X25519 keys through the Edwards map. This removes a second 255-step ladder. It costs one inversion, and public_keys_by_the_comb_are_the_ladders checks it against the ladder.
  • P-256 Booth comb moved to item 8. The reason is sound: its const table needs the const Montgomery multiply that item 8 rewrites. The body records the move.
  • Size. Net +307/−82, of which roughly 150 lines are tests. Static data grows by 30 KiB, in one static. It is linked only where base_mul is reachable.

Correctness, checked by hand

  • Table.
    • Row i: points[i][0] = row, and points[i][j] = points[i][j−1] + row gives (j+1)·row.
    • After each row, 8 doublings take row to 2^8·row = 16^(2(i+1))·B.
    • That is 32 × 8 entries, normalised by one simultaneous inversion over all 256.
    • Size: 256 × 3 × 40 B = 30 720 B, matching the claimed 30 KiB.
    • comb_table_holds_its_multiples checks every entry against run-time addition and doubling, a different code path.
  • Recoding.
    • Nibbles are 0..15. Each step adds the carry (0 or 1), takes carry = (d + 8) >> 4, which is 1 exactly when d ≥ 8, and subtracts 16·carry.
    • So e[0..63] lie in [−8, 7].
    • For a < 2^255 the top nibble is at most 7, so e[63] is at most 8.
    • Intermediate values stay within [−8, 24] and fit i8. >> on i8 is an arithmetic shift with a constant amount.
  • The +7 mutant. Its equivalence is confirmed. carry = (d + 7) >> 4 is 1 for d ≥ 9, which gives digits in [−7, 8] with the same sum. Every digit stays within the table's range, so the output is identical. The +9 control, whose digits reach −9, is red as it should be.
  • Lookup.
    • negative is the sign bit, and magnitude = (d ^ −n) + n = |d| ∈ [0, 8].
    • take = 1 ^ nonzero(|d| ^ (j+1)), then a Tight::select per coordinate through ct::mask, which applies the barrier.
    • The loop reads all eight entries, and d = 0 leaves the identity (1, 1, 0).
    • Negation is a masked swap of y±x and a masked select of −2dxy, which is correct for −(x, y) = (−x, y).
    • Row addresses depend only on the public row index.
  • Schedule.
    • Σ e[2i+1]·16^(2i)·B, then 4 doublings (three projective, one point, where T is needed), then Σ e[2i]·16^(2i)·B.
    • That equals Σ e[k]·16^k·B = a·B.
    • Starting from the identity is safe because the formulas are complete.
  • X25519 map.
    • u = (Z + Y)/(Z − Y) is (1 + y)/(1 − y). That is RFC 7748 §4.1's birational map, a group isomorphism taking B to u = 9.
    • Z − Y ≠ 0 because [s]B is not the identity. The comment's argument holds: a clamped s lies in [2^254, 2^255) and is divisible by 8. L is odd and 8L > 2^255, so no clamped s is a multiple of L.
    • base_mul needs a < 2^255, which clamping gives.
  • Callers.
    • Sign passes reduced r, and from_seed passes reduced a: both below L < 2^255.
    • X25519 passes the clamped scalar.
    • The Secret wrappers around those bytes are unchanged.

Constant time and digit erasure

  • Digits. The digits live in Workspace([i8; 64]), and recode writes into it in place. unsafe impl Zeroable for i8 is sound.
  • Generated code. The inspection found the +=/-= overflow-check branches on secret digits (addb; jo on x86-64, cmp …, sxtb; b.ne on aarch64). That is exactly what [profile.toyos] (overflow checks on) does to signed arithmetic. The wrapping rewrite is the right fix, and the comment at recode keeps the reason for the next editor.
  • Spot-check of the branch walks after the fix:
    • base_mul, x86-64: the recode counter (cmpq $63) and two row loops (addq $960; cmpq $65 / cmpq $32).
    • base_mul, aarch64: the same with cmp x9, #63, cmp x24, #65 and cmp x22, #64.
    • Niels::select: only its entry loop, cmpq $960, %r8 on x86-64, and b.ne with flags from subs x19, x19, #120 on aarch64.
    • add_niels, Completed::point, Projective::double and times: no conditional branch.
    • sign and from_seed: the remaining branches are hash lengths and round counters, update results and copy_nonoverlapping's overlap check on stack addresses.
    • x25519::SecretKey::public_key is walked on both targets.
  • No debug_assert! on a secret value. This is correct: in this profile such an assertion would itself become a branch on the secret. It is also why base_mul's a < 2^255 precondition is best left documented.

Findings

No blockers.

SUGGESTION

  1. lib/crypto/src/secret.rs:17 — the new workspace line runs past 120 columns, while the rest of the header wraps at 80. Rewrap it.
  2. lib/crypto/src/x25519.rs:1–5 — the module header breaks mid-sentence ("Public inputs accept the" / "RFC's…"). Rewrap it.

Tests and evidence

  • Checks. --ci host exits 0. The three forms, both UEFI builds and the QEMU row (whose log now records its revision) also exit 0.
  • Controls. They ran on cd7960d plus the item-7 tree, before the contract comments, so the code is identical. All seven are red, each on the test that claims it.
    • The table-step control reds comb_table_holds_its_multiples specifically.
    • The map control reds the X25519 tests only, as expected.
  • New tests. Each is hermetic and seeded, with one claim.
    • The comb-against-Straus test covers reduced scalars.
    • The ladder test covers unreduced clamped scalars, including the all-0xff case that drives the top digit to its maximum.
    • Together they cover both of base_mul's input ranges.
  • Measurements. No bench claim is made. The Mac figure is labelled exploratory, and T14 metal is queued. The performance claim (sign at parity) awaits that row. Correctness does not depend on it.

Verdict: LAND (suggestions optional; the landing decision and the metal row are the orchestrator's).

Japabu and others added 2 commits October 22, 2026 05:31
Signing and Ed25519 key generation multiplied the base point by 256
doublings and complete additions with a select per bit; an X25519 public
key was a second 255-step ladder from u = 9.

The base point multiplication is now the radix-16 signed comb of
Bernstein, Duif, Lange, Schwabe and Yang ("High-speed high-security
signatures", 2012, section 4):
- a scalar below 2^255 becomes 64 digits in [-8, 8] by wrapping arithmetic
  alone, held in a Workspace and erased when the multiplication returns;
- the table, j 16^(2i) B for j = 1..=8 in 32 rows (30 KiB, affine), is
  built at compile time by the field's const fn arithmetic with one
  simultaneous inversion;
- the odd digits' 32 entries are added, the sum doubled four times, and
  the even digits' 32 added: 64 mixed additions and 4 doublings;
- each lookup reads all eight entries of its row and selects by mask, and
  a negative digit swaps y + x with y - x and negates 2dxy under a mask.

An X25519 public key is u = (1 + y) / (1 - y) of the comb's [s]B (RFC 7748
section 4.1), one inversion. The fixed-schedule double-and-add is gone.

Generated code: in [profile.toyos], the first version's recoding used +=
and -= on the secret digits, and the shipped profile's overflow checks
compiled them to branches on those digits (jo on x86-64, cmp/b.ne on
aarch64). They are wrapping operations now; the inspection after the
change finds only loop counters, public lengths and the debug overlap
check on stack addresses on both targets.

New tests: the table rows against run-time addition, the recoding's
digits and sum, the comb against the verification path's Straus
multiplication (a different table and recoding), and X25519 public keys
by the comb against the ladder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu
Japabu force-pushed the wt/toyos-crypto-perf-3 branch from 5314dcb to b824d27 Compare October 22, 2026 03:33
@Japabu
Japabu force-pushed the wt/toyos-crypto-perf-4 branch from 196ae3d to 264138f Compare October 22, 2026 03:33
@Japabu

Japabu commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #999, round 2 (head 264138f)

I checked the rebase and the new commit. I ran nothing.

Rebase

Rewraps

  • 264138f changes comments only. It touches secret.rs, where the workspace bullet is rewrapped at 80 columns, and the x25519.rs module header, which is rewrapped with its wording unchanged.
  • No code line changes.

Staged T14 image

  • The staged T14 image is of db5c6c5.
  • Since then, the only code difference is the two wipes on the signing path, which add ten volatile stores per barrett. That cannot materially move the sign row.
  • The metal result therefore still measures this code, with a fence-and-store error well under the row's spread.

My r1 verdict stands.

Verdict: LAND.

Base automatically changed from wt/toyos-crypto-perf-3 to main October 22, 2026 04:39
@Japabu
Japabu marked this pull request as ready for review October 22, 2026 04:40
@Japabu
Japabu enabled auto-merge October 22, 2026 04:40
@Japabu
Japabu added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 4427501 Oct 22, 2026
4 checks passed
@Japabu
Japabu deleted the wt/toyos-crypto-perf-4 branch October 22, 2026 05:15
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