diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index cefe191..c307c50 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -11,6 +11,7 @@ jobs: module: ["awsutil", "base62", "configutil", + "cryptoutil", "fileutil", "gatedwriter", "httputil", diff --git a/cryptoutil/go.mod b/cryptoutil/go.mod new file mode 100644 index 0000000..783424a --- /dev/null +++ b/cryptoutil/go.mod @@ -0,0 +1,14 @@ +module github.com/hashicorp/go-secure-stdlib/rsa + +go 1.22.0 + +require ( + github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cryptoutil/go.sum b/cryptoutil/go.sum new file mode 100644 index 0000000..130c18a --- /dev/null +++ b/cryptoutil/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 h1:kBoJV4Xl5FLtBfnBjDvBxeNSy2IRITSGs73HQsFUEjY= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6/go.mod h1:y+HSOcOGB48PkUxNyLAiCiY6rEENu+E+Ss4LG8QHwf4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cryptoutil/rsa.go b/cryptoutil/rsa.go new file mode 100644 index 0000000..3846160 --- /dev/null +++ b/cryptoutil/rsa.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package cryptoutil + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "io" + + "github.com/hashicorp/go-hmac-drbg/hmacdrbg" +) + +// Settable for testing +var platformReader = rand.Reader + +const maxReseeds = 10000 // 7500 * 10000 * 8 = 600mm bits + +// GenerateRSAKeyWithHMACDRBG generates an RSA key with a deterministic random bit generator, seeded +// with entropy from the provided random source. Some random bit sources are quite slow, for example +// HSMs with true RNGs can take 500ms to produce enough bits to generate a single number +// to test for primality, taking literally minutes to succeed in generating a key. As an example, when +// testing this function, one run took 921 attempts to generate a 2048 bit RSA key, which would have taken +// over 7 minutes on the HSM of the reporting customer. +// +// Instead, this function seeds a DRBG (specifically HMAC-DRBG from NIST SP800-90a) with +// entropy from a random source, then uses the output of that DRBG to generate candidate primes. +// This is still secure as the output of a DRBG is secure if the seed is sufficiently random, and +// an attacker cannot predict which numbers are chosen for primes if they don't have access to the seed. +// Additionally, the seed in this case is quite large indeed, 512 bits, well above what could be brute +// forced. +// +// This is a sanctioned approach from FIPS 186-5 (A.1.2) +func GenerateRSAKeyWithHMACDRBG(rand io.Reader, bits int) (*rsa.PrivateKey, error) { + seed := make([]byte, (2*256)/8) // 2x maximum security strength (256-bits) from SP 800-57, Table 2 + defer func() { + // This may not work due to the GC but worth a shot + for i := 0; i < len(seed); i++ { + seed[i] = 0 + } + }() + + // Pretty unlikely to need even one reseed, but better to avoid an infinite loop. + for i := 0; i < maxReseeds; i++ { + if _, err := rand.Read(seed); err != nil { + return nil, err + } + drbg := hmacdrbg.NewHmacDrbg(256, seed, []byte("generate-key-with-hmac-drbg")) + reader := hmacdrbg.NewHmacDrbgReader(drbg) + key, err := rsa.GenerateKey(reader, bits) + if err != nil { + if err.Error() == "MUST_RESEED" { + // Oops, ran out of bytes (pretty unlikely but just in case) + continue + } + return nil, err + } + return key, nil + } + return nil, fmt.Errorf("could not generate key after %d reseed of HMAC_DRBG", maxReseeds) +} + +// GenerateRSAKey tests whether the random source is rand.Reader, and uses it directly if so (as it will +// be a platform RNG and fast). If not, we assume it's some other slower source and use the HmacDRBG version. +func GenerateRSAKey(randomSource io.Reader, bits int) (*rsa.PrivateKey, error) { + if randomSource == platformReader { + return rsa.GenerateKey(randomSource, bits) + } + return GenerateRSAKeyWithHMACDRBG(randomSource, bits) +} diff --git a/cryptoutil/rsa_test.go b/cryptoutil/rsa_test.go new file mode 100644 index 0000000..3814ab9 --- /dev/null +++ b/cryptoutil/rsa_test.go @@ -0,0 +1,85 @@ +package cryptoutil + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type slowRand struct { + randomness *bytes.Buffer + randomBytes []byte + calls int +} + +func newSlowRand() *slowRand { + b := make([]byte, 10247680) + rand.Read(b) + sr := &slowRand{ + randomBytes: b, + } + sr.Reset() + return sr +} + +func (s *slowRand) Reset() { + s.calls = 0 + s.randomness = bytes.NewBuffer(s.randomBytes) +} + +var sr *slowRand + +func TestMain(m *testing.M) { + sr = newSlowRand() + m.Run() +} + +func (s *slowRand) Read(p []byte) (n int, err error) { + // First one is free + if s.calls > 0 { + time.Sleep(50 * time.Millisecond) + } + + n, _ = s.randomness.Read(p) + s.calls++ + return +} + +func TestGenerateKeyWithHMACDRBG(t *testing.T) { + key, err := GenerateRSAKeyWithHMACDRBG(rand.Reader, 2048) + require.NoError(t, err) + require.Equal(t, 2048/8, key.Size()) + key, err = GenerateRSAKey(rand.Reader, 2048) + require.NoError(t, err) + require.Equal(t, 2048/8, key.Size()) +} + +func BenchmarkRSAKeyGeneration(b *testing.B) { + sr.Reset() + for i := 0; i < b.N; i++ { + rsa.GenerateKey(sr, 2048) + b.Logf("%d calls to the RNG, b.N=%d", sr.calls, b.N) + } +} + +func BenchmarkConditionalRSAKeyGeneration(b *testing.B) { + platformReader = sr + sr.Reset() + for i := 0; i < b.N; i++ { + GenerateRSAKey(sr, 2048) + b.Logf("%d calls to the RNG, b.N=%d", sr.calls, b.N) + } +} + +func BenchmarkRSAKeyGenerationWithDRBG(b *testing.B) { + sr.Reset() + for i := 0; i < b.N; i++ { + sr.calls = 0 + GenerateRSAKeyWithHMACDRBG(sr, 2048) + b.Logf("%d calls to the RNG, b.N=%d", sr.calls, b.N) + } +}