Skip to content

rsa: sign and decrypt through the CRT form of the key - #59

Merged
ianic merged 1 commit into
ianic:zig-0.16.xfrom
nevindra:rsa-crt
Oct 3, 2026
Merged

ianic merged 1 commit into
ianic:zig-0.16.xfrom
nevindra:rsa-crt

Conversation

@nevindra

Copy link
Copy Markdown

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: 91 passed, 1 skipped, 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
nevindra marked this pull request as ready for review September 23, 2026 00:46
nevindra added a commit to nevindra/nilo that referenced this pull request Sep 23, 2026
tls.zig signed with the full-size exponent and dropped the CRT values it
parsed, so an RSA-2048 handshake cost 13.7 ms of server CPU. The pin moves to
a fork carrying the fix (ianic/tls.zig#59) until upstream merges it: 2.6 ms a
handshake, and the arena's 8gbit shape goes from a 1 s p99 to 29 ms.
@ianic
ianic merged commit ba3008f into ianic:zig-0.16.x Oct 3, 2026
2 checks passed
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.

2 participants