Skip to content

rsa: sign and decrypt through the CRT form of the key (port of #59 to main) - #61

Open
nevindra wants to merge 1 commit into
ianic:mainfrom
nevindra:rsa-crt-main
Open

nevindra wants to merge 1 commit into
ianic:mainfrom
nevindra:rsa-crt-main

Conversation

@nevindra

@nevindra nevindra commented Oct 8, 2026

Copy link
Copy Markdown

The same change as #59, which landed on zig-0.16.x, cherry-picked onto main (Zig 0.17) without conflicts. The description below is #59's, unchanged except for the test line.


An RSA private-key operation is currently one full-size exponentiation, pow(m, d) on ff.Modulus(4096). The parser reads the key's primes, their exponents and the coefficient, then discards them (_ = .{ exp1, exp2, coeff };). For a server with an RSA certificate, that signature is most of every TLS handshake.

This PR keeps them and uses the Chinese Remainder Theorem (RFC 8017, section 5.1.2, step 2.b): two exponentiations with half-size moduli and half-size exponents, joined with qInv.

What changes

  • SecretKey gets an optional crt: ?Crt, filled by both KeyPair.fromDer (PKCS coding style not following zig guideline #1) and PrivateKey.parseDer (PKCS Accept the async handler type as-is. #8). It defaults to null, so existing struct literals and every caller keep compiling.
  • Crt.init checks n = p * q and q * qInv = 1 (mod p) at load. If the values are missing or do not describe the key, it returns null and the key signs with d as before. No key that loads today stops loading.
  • All four secret-key operations (encrypt, which both signers use, plus decryptPkcsv1_5 and decryptOaep) go through one KeyPair.powSecret.
  • Fault check: every CRT result is verified against the public exponent before it is returned. A fault inside a CRT computation would otherwise leak a prime of the key (Boneh, DeMillo and Lipton, 1997). A result that fails the check is discarded and recomputed with d.
  • The d fallback is faster too. It now passes d at the modulus's byte length through powWithEncodedExponent. Modulus.pow encodes the exponent at the capacity of the type (512 bytes for Modulus(4096)), so a 2048-bit key's d was half leading zeros, and the exponentiation worked through all of them. The CRT exponents are passed the same way. That is also why one Modulus(2048) type serves the primes of every key size: a 1024-bit prime in it costs the same as in Modulus(1024).

Numbers

Ryzen 7 9700X, Zig 0.16.0, ReleaseFast, -Dcpu=x86_64_v3+aes+pclmul. The server is a TLS 1.3 HTTP server on this library (nilo), 8 threads pinned to 4 physical cores. The client is pinned to the other 4. The certificate is RSA-2048. Before and after were interleaved, two rounds each, and the rounds agreed to the digit shown.

before after
server CPU per handshake 13.6-13.7 ms 2.57 ms
512 connections, 10 KB POST echoed at 50,000 req/s: rate held 0.83 0.96
same run: p99 1,047-1,088 ms 29 ms
same run: server CPU per request 77.4 µs 23.7 µs
4,096 keep-alive connections, 5 s (wrk): requests completed 0 2.24 M

The last row is the handshakes alone: 4,096 of them at 13.7 ms each is more CPU than 8 threads have in 5 s.

Where the time goes, one 2048-bit private-key operation, single thread:

ms
as today: pow on Modulus(4096) 15-23
d at the modulus's length 11.5-12
CRT, dP/dQ at their prime's length 3.0-3.5
OpenSSL speed rsa2048 sign, for scale 0.22

What is left against OpenSSL is std.crypto.ff's exponentiation itself, which this PR does not touch.

Tests

  • zig build test on Zig 0.17.0: 91 passed, 1 skipped. (On zig-0.16.x, rsa: sign and decrypt through the CRT form of the key #59 ran it in Debug, ReleaseSafe and ReleaseFast.) Three tests are new:
    • the CRT computation agrees with pow(x, d), and PKCS1-v1_5 signatures from both paths are byte-identical;
    • a corrupted dP still gives a valid signature, because the fault check falls back to d;
    • Crt.init refuses a prime that does not divide n, and a coefficient that is not q^-1 mod p.
  • zig build integration: 7 of 7 in Debug and ReleaseSafe, which includes client auth with RSA 2048, 3072 and 4096 keys.

Binary size of a server built on it goes up by 35 KB, the second ff.Modulus instantiation. Memory per idle connection is unchanged (9,411 bytes at 2,000 connections, before and after).

main

The same patch applies to main cleanly (git apply --check), but I have not built it there, because main needs Zig 0.17-dev. Happy to open a second PR against main if you want it there too.

The parser read p, q, dP, dQ and qInv and dropped them, so every private-key
operation was one full-size exponentiation. Keep them and use two half-size
ones instead, with each result checked against e before it is returned. A
2048-bit TLS handshake goes from 13.7 to 2.6 ms of server CPU.

Keys without usable CRT values fall back to d, which is now passed at the
modulus's length rather than at the capacity of Modulus(4096).
nevindra pushed a commit to nevindra/nilo that referenced this pull request Oct 8, 2026
v0.7.0 and every tag before it build on Zig 0.16.0; from here nilo needs
0.17.0. zio is pinned to its main, the only line ported to 0.17, and
tls.zig and pg.zig (with xsync under it) to forks until ianic/tls.zig#61
and #62, lalinsky/pg.zig#23 and lalinsky/xsync.zig#1 land.

build.zig's own checks are a program in checks/, since 0.17 has no step
with a function of its own. nilo-dev builds with -fincremental unless
given --no-incremental: 0.56-0.70 s from a save to the new server on two
cores, against 3.6-4.5 s rebuilding (ADR 190).
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