Add close-primes detection via Fermat's factorization (#5853)

Add a new check to GoodKey which attempts to factor the public modulus
of the presented key using Fermat's factorization method. This method
will succeed if and only if the prime factors are very close to each
other -- i.e. almost certainly were not selected independently from a
random uniform distribution, but were instead calculated via some other
less secure method.

To support this new feature, add a new config flag to the RA, CA, and
WFE, which all use the GoodKey checks. As part of adding this new config
value, refactor the GoodKey config items into their own config struct
which can be re-used across all services.

If the new `FermatRounds` config value has not been set, it will default
to zero, causing no factorization to be attempted.

Fixes #5850
Part of #5851
This commit is contained in:
Aaron Gable 2021-12-14 09:19:33 -08:00 committed by GitHub
parent ddeaf7b99b
commit 89000bd61c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 199 additions and 32 deletions

View File

@ -59,13 +59,15 @@ type config struct {
// than the minTimeToExpiry field for the OCSP Updater.
LifespanOCSP cmd.ConfigDuration
// WeakKeyFile is the path to a JSON file containing truncated RSA modulus
// hashes of known easily enumerable keys.
// GoodKey is an embedded config stanza for the goodkey library.
GoodKey *goodkey.Config
// WeakKeyFile is DEPRECATED. Populate GoodKey.WeakKeyFile instead.
// TODO(#5851): Remove this.
WeakKeyFile string
// BlockedKeyFile is the path to a YAML file containing Base64 encoded
// SHA256 hashes of SubjectPublicKeyInfo's that should be considered
// administratively blocked.
// WeakKeyFile is DEPRECATED. Populate GoodKey.BlockedKeyFile instead.
// TODO(#5851): Remove this.
BlockedKeyFile string
// Path to directory holding orphan queue files, if not provided an orphan queue
@ -209,7 +211,17 @@ func main() {
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA")
sa := sapb.NewStorageAuthorityClient(conn)
kp, err := goodkey.NewKeyPolicy(c.CA.WeakKeyFile, c.CA.BlockedKeyFile, sa.KeyBlocked)
// TODO(#5851): Remove these fallbacks when the old config keys are gone.
if c.CA.GoodKey == nil {
c.CA.GoodKey = &goodkey.Config{}
}
if c.CA.GoodKey.WeakKeyFile == "" && c.CA.WeakKeyFile != "" {
c.CA.GoodKey.WeakKeyFile = c.CA.WeakKeyFile
}
if c.CA.GoodKey.BlockedKeyFile == "" && c.CA.BlockedKeyFile != "" {
c.CA.GoodKey.BlockedKeyFile = c.CA.BlockedKeyFile
}
kp, err := goodkey.NewKeyPolicy(c.CA.GoodKey, sa.KeyBlocked)
cmd.FailOnError(err, "Unable to create key policy")
var orphanQueue *goque.Queue

View File

@ -62,13 +62,15 @@ type config struct {
// you need to request a new challenge.
PendingAuthorizationLifetimeDays int
// WeakKeyFile is the path to a JSON file containing truncated RSA modulus
// hashes of known easily enumerable keys.
// GoodKey is an embedded config stanza for the goodkey library.
GoodKey *goodkey.Config
// WeakKeyFile is DEPRECATED. Populate GoodKey.WeakKeyFile instead.
// TODO(#5851): Remove this.
WeakKeyFile string
// BlockedKeyFile is the path to a YAML file containing Base64 encoded
// SHA256 hashes of SubjectPublicKeyInfo's that should be considered
// administratively blocked.
// WeakKeyFile is DEPRECATED. Populate GoodKey.BlockedKeyFile instead.
// TODO(#5851): Remove this.
BlockedKeyFile string
OrderLifetime cmd.ConfigDuration
@ -221,7 +223,17 @@ func main() {
pendingAuthorizationLifetime = time.Duration(c.RA.PendingAuthorizationLifetimeDays) * 24 * time.Hour
}
kp, err := goodkey.NewKeyPolicy(c.RA.WeakKeyFile, c.RA.BlockedKeyFile, sac.KeyBlocked)
// TODO(#5851): Remove these fallbacks when the old config keys are gone.
if c.RA.GoodKey == nil {
c.RA.GoodKey = &goodkey.Config{}
}
if c.RA.GoodKey.WeakKeyFile == "" && c.RA.WeakKeyFile != "" {
c.RA.GoodKey.WeakKeyFile = c.RA.WeakKeyFile
}
if c.RA.GoodKey.BlockedKeyFile == "" && c.RA.BlockedKeyFile != "" {
c.RA.GoodKey.BlockedKeyFile = c.RA.BlockedKeyFile
}
kp, err := goodkey.NewKeyPolicy(c.RA.GoodKey, sac.KeyBlocked)
cmd.FailOnError(err, "Unable to create key policy")
if c.RA.MaxNames == 0 {

View File

@ -153,7 +153,10 @@ func main() {
rac, sac, rns, npm := setupWFE(c, logger, stats, clk)
// don't load any weak keys, but do load blocked keys
kp, err := goodkey.NewKeyPolicy("", c.WFE.BlockedKeyFile, sac.KeyBlocked)
kp, err := goodkey.NewKeyPolicy(&goodkey.Config{
WeakKeyFile: "",
BlockedKeyFile: c.WFE.BlockedKeyFile,
}, sac.KeyBlocked)
cmd.FailOnError(err, "Unable to create key policy")
wfe, err := wfe.NewWebFrontEndImpl(stats, clk, kp, rns, npm, logger)
cmd.FailOnError(err, "Unable to create WFE")

View File

@ -103,9 +103,11 @@ type config struct {
// will differ in configuration for production and staging.
LegacyKeyIDPrefix string
// BlockedKeyFile is the path to a YAML file containing Base64 encoded
// SHA256 hashes of SubjectPublicKeyInfo's that should be considered
// administratively blocked.
// GoodKey is an embedded config stanza for the goodkey library.
GoodKey *goodkey.Config
// WeakKeyFile is DEPRECATED. Populate GoodKey.BlockedKeyFile instead.
// TODO(#5851): Remove this.
BlockedKeyFile string
// StaleTimeout determines how old should data be to be accessed via Boulder-specific GET-able APIs
@ -385,8 +387,16 @@ func main() {
clk := cmd.Clock()
rac, sac, rns, npm := setupWFE(c, logger, stats, clk)
// don't load any weak keys, but do load blocked keys
kp, err := goodkey.NewKeyPolicy("", c.WFE.BlockedKeyFile, sac.KeyBlocked)
// TODO(#5851): Remove these fallbacks when the old config keys are gone.
// The WFE does not do weak key checking, just blocked key checking.
if c.WFE.GoodKey == nil {
c.WFE.GoodKey = &goodkey.Config{}
}
if c.WFE.GoodKey.BlockedKeyFile == "" && c.WFE.BlockedKeyFile != "" {
c.WFE.GoodKey.BlockedKeyFile = c.WFE.BlockedKeyFile
}
kp, err := goodkey.NewKeyPolicy(c.WFE.GoodKey, sac.KeyBlocked)
cmd.FailOnError(err, "Unable to create key policy")
if c.WFE.StaleTimeout.Duration == 0 {

View File

@ -42,6 +42,22 @@ var (
smallPrimesProduct *big.Int
)
type Config struct {
// WeakKeyFile is the path to a JSON file containing truncated modulus hashes
// of known weak RSA keys. If this config value is empty, then RSA modulus
// hash checking will be disabled.
WeakKeyFile string
// BlockedKeyFile is the path to a YAML file containing base64-encoded SHA256
// hashes of PKIX Subject Public Keys that should be blocked. If this config
// value is empty, then blocked key checking will be disabled.
BlockedKeyFile string
// FermatRounds is an integer number of rounds of Fermat's factorization
// method that should be performed to attempt to detect keys whose modulus can
// be trivially factored because the two factors are very close to each other.
// If this config value is empty (0), no factorization will be attempted.
FermatRounds int
}
// ErrBadKey represents an error with a key. It is distinct from the various
// ways in which an ACME request can have an erroneous key (BadPublicKeyError,
// BadCSRError) because this library is used to check both JWS signing keys and
@ -65,6 +81,7 @@ type KeyPolicy struct {
AllowECDSANISTP384 bool // Whether ECDSA NISTP384 keys should be allowed.
weakRSAList *WeakRSAKeys
blockedList *blockedKeys
fermatRounds int
dbCheck BlockedKeyCheckFunc
}
@ -75,27 +92,31 @@ type KeyPolicy struct {
// containing Base64 encoded SHA256 hashes of pkix subject public keys that
// should be blocked. If this argument is empty then no blocked key checking is
// performed.
func NewKeyPolicy(weakKeyFile, blockedKeyFile string, bkc BlockedKeyCheckFunc) (KeyPolicy, error) {
func NewKeyPolicy(config *Config, bkc BlockedKeyCheckFunc) (KeyPolicy, error) {
kp := KeyPolicy{
AllowRSA: true,
AllowECDSANISTP256: true,
AllowECDSANISTP384: true,
dbCheck: bkc,
}
if weakKeyFile != "" {
keyList, err := LoadWeakRSASuffixes(weakKeyFile)
if config.WeakKeyFile != "" {
keyList, err := LoadWeakRSASuffixes(config.WeakKeyFile)
if err != nil {
return KeyPolicy{}, err
}
kp.weakRSAList = keyList
}
if blockedKeyFile != "" {
blocked, err := loadBlockedKeysList(blockedKeyFile)
if config.BlockedKeyFile != "" {
blocked, err := loadBlockedKeysList(config.BlockedKeyFile)
if err != nil {
return KeyPolicy{}, err
}
kp.blockedList = blocked
}
if config.FermatRounds < 0 {
return KeyPolicy{}, fmt.Errorf("Fermat factorization rounds cannot be negative: %d", config.FermatRounds)
}
kp.fermatRounds = config.FermatRounds
return kp, nil
}
@ -320,6 +341,13 @@ func (policy *KeyPolicy) goodKeyRSA(key *rsa.PublicKey) (err error) {
if rocacheck.IsWeak(key) {
return badKey("key generated by vulnerable Infineon-based hardware")
}
// Check if the key can be easily factored via Fermat's factorization method.
if policy.fermatRounds > 0 {
err := checkPrimeFactorsTooClose(modulus, policy.fermatRounds)
if err != nil {
return badKey("key generated with factors too close together: %w", err)
}
}
return nil
}
@ -351,3 +379,54 @@ func checkSmallPrimes(i *big.Int) bool {
result.GCD(nil, nil, i, smallPrimesProduct)
return result.Cmp(big.NewInt(1)) != 0
}
// Returns an error if the modulus n is able to be factored into primes p and q
// via Fermat's factorization method. This method relies on the two primes being
// very close together, which means that they were almost certainly not picked
// independently from a uniform random distribution. Basically, if we can factor
// the key this easily, so can anyone else.
func checkPrimeFactorsTooClose(n *big.Int, rounds int) error {
// Pre-allocate some big numbers that we'll use a lot down below.
one := big.NewInt(1)
bb := new(big.Int)
// Any odd integer is equal to a difference of squares of integers:
// n = a^2 - b^2 = (a + b)(a - b)
// Any RSA public key modulus is equal to a product of two primes:
// n = pq
// Here we try to find values for a and b, since doing so also gives us the
// prime factors p = (a + b) and q = (a - b).
// We start with a close to the square root of the modulus n, to start with
// two candidate prime factors that are as close together as possible and
// work our way out from there. Specifically, we set a = ceil(sqrt(n)), the
// first integer greater than the square root of n. Unfortunately, big.Int's
// built-in square root function takes the floor, so we have to add one to get
// the ceil.
a := new(big.Int)
a.Sqrt(n).Add(a, one)
// We calculate b2 to see if it is a perfect square (i.e. b^2), and therefore
// b is an integer. Specifically, b2 = a^2 - n.
b2 := new(big.Int)
b2.Mul(a, a).Sub(b2, n)
for i := 0; i < rounds; i++ {
// To see if b2 is a perfect square, we take its square root, square that,
// and check to see if we got the same result back.
bb.Sqrt(b2).Mul(bb, bb)
if b2.Cmp(bb) == 0 {
// b2 is a perfect square, so we've found integer values of a and b,
// and can easily compute p and q as their sum and difference.
bb.Sqrt(bb)
p := new(big.Int).Add(a, bb)
q := new(big.Int).Sub(a, bb)
return fmt.Errorf("public modulus n = pq factored into p: %s; q: %s", p, q)
}
// Set up the next iteration by incrementing a by one and recalculating b2.
a.Add(a, one)
b2.Mul(a, a).Sub(b2, n)
}
return nil
}

View File

@ -264,7 +264,7 @@ func TestDBBlocklistAccept(t *testing.T) {
return &sapb.Exists{Exists: false}, nil
}
policy, err := NewKeyPolicy("", "", testCheck)
policy, err := NewKeyPolicy(&Config{}, testCheck)
test.AssertNotError(t, err, "NewKeyPolicy failed")
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@ -278,7 +278,7 @@ func TestDBBlocklistReject(t *testing.T) {
return &sapb.Exists{Exists: true}, nil
}
policy, err := NewKeyPolicy("", "", testCheck)
policy, err := NewKeyPolicy(&Config{}, testCheck)
test.AssertNotError(t, err, "NewKeyPolicy failed")
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@ -299,3 +299,43 @@ func TestRSAStrangeSize(t *testing.T) {
test.AssertError(t, err, "expected GoodKey to fail")
test.AssertEquals(t, err.Error(), "key size not supported: 4")
}
func TestCheckPrimeFactorsTooClose(t *testing.T) {
// The prime factors of 5959 are 59 and 101. The values a and b calculated
// by Fermat's method will be 80 and 21. The ceil of the square root of 5959
// is 78. Therefore it takes 3 rounds of Fermat's method to find the factors.
n := big.NewInt(5959)
err := checkPrimeFactorsTooClose(n, 2)
test.AssertNotError(t, err, "factored n in too few iterations")
err = checkPrimeFactorsTooClose(n, 3)
test.AssertError(t, err, "failed to factor n")
test.AssertContains(t, err.Error(), "p: 101")
test.AssertContains(t, err.Error(), "q: 59")
// These factors differ only in their second-to-last digit. They're so close
// that a single iteration of Fermat's method is sufficient to find them.
p, ok := new(big.Int).SetString("12451309173743450529024753538187635497858772172998414407116324997634262083672423797183640278969532658774374576700091736519352600717664126766443002156788367", 10)
test.Assert(t, ok, "failed to create large prime")
q, ok := new(big.Int).SetString("12451309173743450529024753538187635497858772172998414407116324997634262083672423797183640278969532658774374576700091736519352600717664126766443002156788337", 10)
test.Assert(t, ok, "failed to create large prime")
n = n.Mul(p, q)
err = checkPrimeFactorsTooClose(n, 0)
test.AssertNotError(t, err, "factored n in too few iterations")
err = checkPrimeFactorsTooClose(n, 1)
test.AssertError(t, err, "failed to factor n")
test.AssertContains(t, err.Error(), fmt.Sprintf("p: %s", p))
test.AssertContains(t, err.Error(), fmt.Sprintf("q: %s", q))
// These factors differ by slightly more than 2^256.
p, ok = p.SetString("11779932606551869095289494662458707049283241949932278009554252037480401854504909149712949171865707598142483830639739537075502512627849249573564209082969463", 10)
test.Assert(t, ok, "failed to create large prime")
q, ok = q.SetString("11779932606551869095289494662458707049283241949932278009554252037480401854503793357623711855670284027157475142731886267090836872063809791989556295953329083", 10)
test.Assert(t, ok, "failed to create large prime")
n = n.Mul(p, q)
err = checkPrimeFactorsTooClose(n, 13)
test.AssertNotError(t, err, "factored n in too few iterations")
err = checkPrimeFactorsTooClose(n, 14)
test.AssertError(t, err, "failed to factor n")
test.AssertContains(t, err.Error(), fmt.Sprintf("p: %s", p))
test.AssertContains(t, err.Error(), fmt.Sprintf("q: %s", q))
}

View File

@ -84,8 +84,11 @@
"serialPrefix": 255,
"maxNames": 100,
"lifespanOCSP": "96h",
"weakKeyFile": "test/example-weak-keys.json",
"blockedKeyFile": "test/example-blocked-keys.yaml",
"goodkey": {
"weakKeyFile": "test/example-weak-keys.json",
"blockedKeyFile": "test/example-blocked-keys.yaml",
"fermatRounds": 100
},
"orphanQueueDir": "/tmp/orphaned-certificates-a",
"ocspLogMaxLength": 4000,
"ocspLogPeriod": "500ms",

View File

@ -84,8 +84,11 @@
"serialPrefix": 255,
"maxNames": 100,
"lifespanOCSP": "96h",
"weakKeyFile": "test/example-weak-keys.json",
"blockedKeyFile": "test/example-blocked-keys.yaml",
"goodkey": {
"weakKeyFile": "test/example-weak-keys.json",
"blockedKeyFile": "test/example-blocked-keys.yaml",
"fermatRounds": 100
},
"orphanQueueDir": "/tmp/orphaned-certificates-b",
"ocspLogMaxLength": 4000,
"ocspLogPeriod": "500ms",

View File

@ -8,8 +8,11 @@
"reuseValidAuthz": true,
"authorizationLifetimeDays": 30,
"pendingAuthorizationLifetimeDays": 7,
"weakKeyFile": "test/example-weak-keys.json",
"blockedKeyFile": "test/example-blocked-keys.yaml",
"goodkey": {
"weakKeyFile": "test/example-weak-keys.json",
"blockedKeyFile": "test/example-blocked-keys.yaml",
"fermatRounds": 100
},
"orderLifetime": "168h",
"issuerCerts": [
"/hierarchy/intermediate-cert-rsa-a.pem",

View File

@ -11,7 +11,9 @@
"directoryCAAIdentity": "happy-hacker-ca.invalid",
"directoryWebsite": "https://github.com/letsencrypt/boulder",
"legacyKeyIDPrefix": "http://boulder:4000/reg/",
"blockedKeyFile": "test/example-blocked-keys.yaml",
"goodkey": {
"blockedKeyFile": "test/example-blocked-keys.yaml"
},
"tls": {
"caCertFile": "test/grpc-creds/minica.pem",
"certFile": "test/grpc-creds/wfe.boulder/cert.pem",