From d095b530e65ab64b3130ecbd41b7a5825680f6af Mon Sep 17 00:00:00 2001 From: HD Moore Date: Sat, 30 May 2026 22:44:03 -0500 Subject: [PATCH 1/2] crypto/rsa: bound public modulus size to prevent verify-path CPU-exhaustion DoS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkKeySize is intentionally permissive about the RSA modulus size so that weak/odd keys found in the wild can still be parsed and inspected. That leaves the modular-exponentiation cost during public-key operations — O(bitlen(E) · bitlen(N)^2) — otherwise unbounded: a malicious certificate/key presenting a multi-megabit modulus can force tens of seconds to minutes of CPU per signature check (e.g. ~60s for a 2,000,000-bit modulus), which during a TLS scan can pin a worker on a single connection — a CPU-exhaustion DoS. Add MaxPublicModulusBitLen (default 16384), enforced in checkPublicKeySize alongside the existing MaxPublicExponentBitLen, so VerifyPKCS1v15 / VerifyPSS / EncryptPKCS1v15 / EncryptOAEP reject an oversized modulus *before* the modexp. Parsing functions and crypto/x509 do not consult it, so inspecting an oversized key remains safe; setting it to 0 restores the previous unbounded behavior. Addresses runZero platform audit finding TLS-01. Co-Authored-By: Claude Opus 4.8 (1M context) --- crypto/rsa/rsa.go | 27 +++++++++++++++++++++++++ crypto/rsa/rsa_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/crypto/rsa/rsa.go b/crypto/rsa/rsa.go index c22a99a31a..fa7f09fa8a 100644 --- a/crypto/rsa/rsa.go +++ b/crypto/rsa/rsa.go @@ -328,6 +328,30 @@ func checkKeySize(size int) error { // operation under a sync mechanism of their choice. var MaxPublicExponentBitLen = 1024 +// MaxPublicModulusBitLen bounds the bit length of the RSA public modulus N +// accepted by public-key operations such as [EncryptPKCS1v15], [EncryptOAEP], +// [VerifyPKCS1v15], and [VerifyPSS]. Like [MaxPublicExponentBitLen], parsing +// functions in this package and in [crypto/x509] do not consult this variable — +// only operations that actually perform the modular exponentiation are gated — +// so it is always safe to parse and inspect a key that exceeds the bound. +// +// This bound exists because [checkKeySize] is intentionally permissive about +// the modulus size (so weak/odd keys found in the wild can still be parsed), +// which leaves the modular exponentiation cost — O(bitlen(E) · bitlen(N)²) — +// otherwise unbounded. A malicious certificate/key with a multi-megabit modulus +// can force tens of seconds to minutes of CPU per signature check; during a TLS +// scan that can pin a worker on a single connection (a CPU-exhaustion DoS). +// +// The default of 16384 comfortably exceeds every legitimate RSA modulus in use +// (4096-bit keys are already rare; 8192-bit are exotic) while capping the +// worst-case cost. Tighten it for DoS-sensitive deployments, or set it to 0 to +// disable the bound entirely (restoring the previous unbounded behavior). +// +// The variable is process-global. Callers that need different bounds for +// different operations should snapshot, set, and restore it around the +// operation under a sync mechanism of their choice. +var MaxPublicModulusBitLen = 16384 + func checkPublicKeySize(k *PublicKey) error { if k.N == nil { return errors.New("crypto/rsa: missing public modulus") @@ -338,6 +362,9 @@ func checkPublicKeySize(k *PublicKey) error { if MaxPublicExponentBitLen > 0 && k.E.BitLen() > MaxPublicExponentBitLen { return errors.New("crypto/rsa: public exponent exceeds MaxPublicExponentBitLen") } + if MaxPublicModulusBitLen > 0 && k.N.BitLen() > MaxPublicModulusBitLen { + return errors.New("crypto/rsa: public modulus exceeds MaxPublicModulusBitLen") + } return checkKeySize(k.N.BitLen()) } diff --git a/crypto/rsa/rsa_test.go b/crypto/rsa/rsa_test.go index f2659268c4..10250009d2 100644 --- a/crypto/rsa/rsa_test.go +++ b/crypto/rsa/rsa_test.go @@ -18,6 +18,7 @@ import ( "slices" "strings" "testing" + "time" "github.com/runZeroInc/excrypto/crypto" "github.com/runZeroInc/excrypto/crypto/internal/boring" @@ -1377,6 +1378,51 @@ func TestMaxPublicExponentBitLen(t *testing.T) { } } +// TestMaxPublicModulusBitLen verifies the DoS-mitigation knob: an oversized RSA +// modulus is rejected by the verify path *before* the expensive modular +// exponentiation runs, so a malicious certificate with a multi-megabit modulus +// cannot pin a scan worker. Parsing/constructing the key is unaffected. +func TestMaxPublicModulusBitLen(t *testing.T) { + prev := MaxPublicModulusBitLen + defer func() { MaxPublicModulusBitLen = prev }() + MaxPublicModulusBitLen = 16384 + + // Build a public key with a ~2,000,000-bit modulus. We never want the modexp + // to run on this, so the test must complete fast. + const nbits = 2_000_000 + N := new(big.Int).Lsh(big.NewInt(1), nbits) + N.SetBit(N, nbits-1, 1) // full bit length + N.SetBit(N, 0, 1) // odd + pub := &PublicKey{N: N, E: big.NewInt(65537)} + + hashed := make([]byte, 32) // fake SHA-256 digest + sig := make([]byte, (nbits+7)/8) + sig[0] = 0x01 + + start := time.Now() + err := VerifyPKCS1v15(pub, crypto.SHA256, hashed, sig) + elapsed := time.Since(start) + + if err == nil || !strings.Contains(err.Error(), "MaxPublicModulusBitLen") { + t.Fatalf("expected MaxPublicModulusBitLen error, got %v", err) + } + // The unmitigated modexp on a 2,000,000-bit modulus takes ~60s; the cap must + // reject it near-instantly. Allow generous slack for slow CI. + if elapsed > 2*time.Second { + t.Fatalf("oversized modulus was not rejected quickly: took %v", elapsed) + } + + // Disabling the bound (0) restores the previous unbounded behavior for the + // size gate. We use a small modulus so the test stays fast. + MaxPublicModulusBitLen = 0 + small := &PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 2047), E: big.NewInt(65537)} + small.N.SetBit(small.N, 0, 1) + if err := VerifyPKCS1v15(small, crypto.SHA256, hashed, make([]byte, 256)); err != nil && + strings.Contains(err.Error(), "MaxPublicModulusBitLen") { + t.Fatalf("MaxPublicModulusBitLen=0 should not gate: %v", err) + } +} + // TestPublicKeyDefensiveCopy verifies that mutating the caller's PublicKey // after performing an operation does not affect subsequent operations on the // same key via the precomputed FIPS state. From 53a60d498d9cb9eeb57bfadb78f723d6efcfcb80 Mon Sep 17 00:00:00 2001 From: HD Moore Date: Sat, 30 May 2026 22:50:00 -0500 Subject: [PATCH 2/2] crypto/rsa: trim MaxPublicModulusBitLen doc comment Co-Authored-By: Claude Opus 4.8 (1M context) --- crypto/rsa/rsa.go | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/crypto/rsa/rsa.go b/crypto/rsa/rsa.go index fa7f09fa8a..56313752e9 100644 --- a/crypto/rsa/rsa.go +++ b/crypto/rsa/rsa.go @@ -329,27 +329,9 @@ func checkKeySize(size int) error { var MaxPublicExponentBitLen = 1024 // MaxPublicModulusBitLen bounds the bit length of the RSA public modulus N -// accepted by public-key operations such as [EncryptPKCS1v15], [EncryptOAEP], -// [VerifyPKCS1v15], and [VerifyPSS]. Like [MaxPublicExponentBitLen], parsing -// functions in this package and in [crypto/x509] do not consult this variable — -// only operations that actually perform the modular exponentiation are gated — -// so it is always safe to parse and inspect a key that exceeds the bound. -// -// This bound exists because [checkKeySize] is intentionally permissive about -// the modulus size (so weak/odd keys found in the wild can still be parsed), -// which leaves the modular exponentiation cost — O(bitlen(E) · bitlen(N)²) — -// otherwise unbounded. A malicious certificate/key with a multi-megabit modulus -// can force tens of seconds to minutes of CPU per signature check; during a TLS -// scan that can pin a worker on a single connection (a CPU-exhaustion DoS). -// -// The default of 16384 comfortably exceeds every legitimate RSA modulus in use -// (4096-bit keys are already rare; 8192-bit are exotic) while capping the -// worst-case cost. Tighten it for DoS-sensitive deployments, or set it to 0 to -// disable the bound entirely (restoring the previous unbounded behavior). -// -// The variable is process-global. Callers that need different bounds for -// different operations should snapshot, set, and restore it around the -// operation under a sync mechanism of their choice. +// accepted by public-key operations (verify/encrypt), gating the modular +// exponentiation before it runs so an oversized modulus cannot cause a +// CPU-exhaustion DoS. Parsing is unaffected. Default 16384; set to 0 to disable. var MaxPublicModulusBitLen = 16384 func checkPublicKeySize(k *PublicKey) error {