diff --git a/ca/config/config.go b/ca/config/config.go index 2bee0d0f2..81823ac61 100644 --- a/ca/config/config.go +++ b/ca/config/config.go @@ -46,6 +46,11 @@ type CAConfig struct { // hashes of known easily enumerable keys. WeakKeyFile string + // BlockedKeyFile is the path to a YAML file containing Base64 encoded + // SHA256 hashes of DER encoded PKIX public keys that should be considered + // administratively blocked. + BlockedKeyFile string + SAService *cmd.GRPCClientConfig // Path to directory holding orphan queue files, if not provided an orphan queue diff --git a/cmd/boulder-ca/main.go b/cmd/boulder-ca/main.go index 1bdb2e79d..a45cfad49 100644 --- a/cmd/boulder-ca/main.go +++ b/cmd/boulder-ca/main.go @@ -156,7 +156,7 @@ func main() { issuers, err := loadIssuers(c) cmd.FailOnError(err, "Couldn't load issuers") - kp, err := goodkey.NewKeyPolicy(c.CA.WeakKeyFile) + kp, err := goodkey.NewKeyPolicy(c.CA.WeakKeyFile, c.CA.BlockedKeyFile) cmd.FailOnError(err, "Unable to create key policy") tlsConfig, err := c.CA.TLS.Load() diff --git a/cmd/boulder-ra/main.go b/cmd/boulder-ra/main.go index 084fc5638..9483ce287 100644 --- a/cmd/boulder-ra/main.go +++ b/cmd/boulder-ra/main.go @@ -64,6 +64,11 @@ type config struct { // hashes of known easily enumerable keys. WeakKeyFile string + // BlockedKeyFile is the path to a YAML file containing Base64 encoded + // SHA256 hashes of DER encoded PKIX public keys that should be considered + // administratively blocked. + BlockedKeyFile string + OrderLifetime cmd.ConfigDuration // CTLogGroups contains groupings of CT logs which we want SCTs from. @@ -199,7 +204,7 @@ func main() { pendingAuthorizationLifetime = time.Duration(c.RA.PendingAuthorizationLifetimeDays) * 24 * time.Hour } - kp, err := goodkey.NewKeyPolicy(c.RA.WeakKeyFile) + kp, err := goodkey.NewKeyPolicy(c.RA.WeakKeyFile, c.RA.BlockedKeyFile) cmd.FailOnError(err, "Unable to create key policy") if c.RA.MaxNames == 0 { diff --git a/cmd/boulder-wfe/main.go b/cmd/boulder-wfe/main.go index f3eca78b4..a5fa9a512 100644 --- a/cmd/boulder-wfe/main.go +++ b/cmd/boulder-wfe/main.go @@ -59,6 +59,11 @@ type config struct { // DirectoryWebsite is used for the /directory response's "meta" element's // "website" field. DirectoryWebsite string + + // BlockedKeyFile is the path to a YAML file containing Base64 encoded + // SHA256 hashes of DER encoded PKIX public keys that should be considered + // administratively blocked. + BlockedKeyFile string } Syslog cmd.SyslogConfig @@ -118,7 +123,8 @@ func main() { clk := cmd.Clock() - kp, err := goodkey.NewKeyPolicy("") // don't load any weak keys + // don't load any weak keys, but do load blocked keys + kp, err := goodkey.NewKeyPolicy("", c.WFE.BlockedKeyFile) cmd.FailOnError(err, "Unable to create key policy") rac, sac, rns, npm := setupWFE(c, logger, scope, clk) wfe, err := wfe.NewWebFrontEndImpl(scope, clk, kp, rns, npm, logger) diff --git a/cmd/boulder-wfe2/main.go b/cmd/boulder-wfe2/main.go index ca79f0455..7f93cd989 100644 --- a/cmd/boulder-wfe2/main.go +++ b/cmd/boulder-wfe2/main.go @@ -77,6 +77,11 @@ type config struct { // header of the WFE1 instance and the legacy 'reg' path component. This // will differ in configuration for production and staging. LegacyKeyIDPrefix string + + // BlockedKeyFile is the path to a YAML file containing Base64 encoded + // SHA256 hashes of DER encoded PKIX public keys that should be considered + // administratively blocked. + BlockedKeyFile string } Syslog cmd.SyslogConfig @@ -239,7 +244,8 @@ func main() { clk := cmd.Clock() - kp, err := goodkey.NewKeyPolicy("") // don't load any weak keys + // don't load any weak keys, but do load blocked keys + kp, err := goodkey.NewKeyPolicy("", c.WFE.BlockedKeyFile) cmd.FailOnError(err, "Unable to create key policy") rac, sac, rns, npm := setupWFE(c, logger, scope, clk) wfe, err := wfe2.NewWebFrontEndImpl(scope, clk, kp, certChains, rns, npm, logger) diff --git a/errors/errors.go b/errors/errors.go index 49d7545a6..33908ac8b 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -25,6 +25,7 @@ const ( Duplicate OrderNotReady DNS + BadPublicKey ) // BoulderError represents internal Boulder errors @@ -130,3 +131,7 @@ func OrderNotReadyError(msg string, args ...interface{}) error { func DNSError(msg string, args ...interface{}) error { return New(DNS, msg, args...) } + +func BadPublicKeyError(msg string, args ...interface{}) error { + return New(BadPublicKey, msg, args...) +} diff --git a/goodkey/blocked.go b/goodkey/blocked.go new file mode 100644 index 000000000..b77321196 --- /dev/null +++ b/goodkey/blocked.go @@ -0,0 +1,70 @@ +package goodkey + +import ( + "crypto" + "errors" + "io/ioutil" + + "github.com/letsencrypt/boulder/core" + + yaml "gopkg.in/yaml.v2" +) + +// blockedKeys is a type for maintaining a map of Base64 encoded SHA256 hashes +// of DER encoded PKIX public keys that should be considered blocked. +// blockedKeys are created by using loadBlockedKeysList. +type blockedKeys map[string]bool + +// blocked checks if the given public key is considered administratively +// blocked based on a Base64 encoded SHA256 hash of the DER encoded PKIX public +// key. Important: blocked should not be called except on a blockedKeys instance +// returned from loadBlockedKeysList. +// function should not be used until after `loadBlockedKeysList` has returned. +func (b blockedKeys) blocked(key crypto.PublicKey) (bool, error) { + hash, err := core.KeyDigest(key) + if err != nil { + // the bool result should be ignored when err is != nil but to be on the + // paranoid side return true anyway so that a key we can't compute the + // digest for will always be blocked even if a caller foolishly discards the + // err result. + return true, err + } + return b[hash], nil +} + +// loadBlockedKeysList creates a blockedKeys object that can be used to check if +// a key is blocked. It creates a lookup map from a list of Base64 encoded +// SHA256 digest of a DER encoded PKIX public key hashes in the input YAML file +// with the expected format: +// +// ``` +// blocked: +// - cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M= +// +// - Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE= +// ``` +// +// If no hashes are found in the input YAML an error is returned. +func loadBlockedKeysList(filename string) (*blockedKeys, error) { + yamlBytes, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + var list struct { + BlockedHashes []string `yaml:"blocked"` + } + if err := yaml.Unmarshal(yamlBytes, &list); err != nil { + return nil, err + } + + if len(list.BlockedHashes) == 0 { + return nil, errors.New("no blocked hashes in YAML") + } + + blockedKeys := make(blockedKeys, len(list.BlockedHashes)) + for _, hash := range list.BlockedHashes { + blockedKeys[hash] = true + } + return &blockedKeys, nil +} diff --git a/goodkey/blocked_test.go b/goodkey/blocked_test.go new file mode 100644 index 000000000..cb1a94e6a --- /dev/null +++ b/goodkey/blocked_test.go @@ -0,0 +1,99 @@ +package goodkey + +import ( + "crypto" + "io/ioutil" + "os" + "testing" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/web" + yaml "gopkg.in/yaml.v2" +) + +func TestBlockedKeys(t *testing.T) { + // Start with an empty list + var inList struct { + BlockedHashes []string `yaml:"blocked"` + } + + yamlList, err := yaml.Marshal(&inList) + test.AssertNotError(t, err, "error marshaling test blockedKeys list") + + yamlListFile, err := ioutil.TempFile("", "test-blocked-keys-list.*.yaml") + test.AssertNotError(t, err, "error creating test blockedKeys yaml file") + defer os.Remove(yamlListFile.Name()) + + err = ioutil.WriteFile(yamlListFile.Name(), yamlList, 0640) + test.AssertNotError(t, err, "error writing test blockedKeys yaml file") + + // Trying to load it should error + _, err = loadBlockedKeysList(yamlListFile.Name()) + test.AssertError(t, err, "expected error loading empty blockedKeys yaml file") + + // Load some test certs/keys - see ../test/block-a-key/test/README.txt + // for more information. + testCertA, err := core.LoadCert("../test/block-a-key/test/test.rsa.cert.pem") + test.AssertNotError(t, err, "error loading test.rsa.cert.pem") + testCertB, err := core.LoadCert("../test/block-a-key/test/test.ecdsa.cert.pem") + test.AssertNotError(t, err, "error loading test.ecdsa.cert.pem") + testJWKA, err := web.LoadJWK("../test/block-a-key/test/test.rsa.jwk.json") + test.AssertNotError(t, err, "error loading test.rsa.jwk.pem") + testJWKB, err := web.LoadJWK("../test/block-a-key/test/test.ecdsa.jwk.json") + test.AssertNotError(t, err, "error loading test.ecdsa.jwk.pem") + + // All of the above should be blocked + blockedKeys := []crypto.PublicKey{ + testCertA.PublicKey, + testCertB.PublicKey, + testJWKA.Key, + testJWKB.Key, + } + + // Now use a populated list - these values match the base64 digest of the + // public keys in the test certs/JWKs + inList.BlockedHashes = []string{ + "cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M=", + "Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE=", + } + + yamlList, err = yaml.Marshal(&inList) + test.AssertNotError(t, err, "error marshaling test blockedKeys list") + + yamlListFile, err = ioutil.TempFile("", "test-blocked-keys-list.*.yaml") + test.AssertNotError(t, err, "error creating test blockedKeys yaml file") + defer os.Remove(yamlListFile.Name()) + + err = ioutil.WriteFile(yamlListFile.Name(), yamlList, 0640) + test.AssertNotError(t, err, "error writing test blockedKeys yaml file") + + // Trying to load it should not error + outList, err := loadBlockedKeysList(yamlListFile.Name()) + test.AssertNotError(t, err, "unexpected error loading empty blockedKeys yaml file") + + // Create a test policy that doesn't reference the blocked list + testingPolicy := &KeyPolicy{ + AllowRSA: true, + AllowECDSANISTP256: true, + AllowECDSANISTP384: true, + } + + // All of the test keys should not be considered blocked + for _, k := range blockedKeys { + err := testingPolicy.GoodKey(k) + test.AssertNotError(t, err, "test key was blocked by key policy without block list") + } + + // Now update the key policy with the blocked list + testingPolicy.blockedList = outList + + // Now all of the test keys should be considered blocked, and with the correct + // type of error. + for _, k := range blockedKeys { + err := testingPolicy.GoodKey(k) + test.AssertError(t, err, "test key was not blocked by key policy with block list") + test.Assert(t, berrors.Is(err, berrors.BadPublicKey), "err was not BadPublicKey error") + } +} diff --git a/goodkey/good_key.go b/goodkey/good_key.go index 8d5f68dea..dc2c3017d 100644 --- a/goodkey/good_key.go +++ b/goodkey/good_key.go @@ -41,13 +41,17 @@ type KeyPolicy struct { AllowECDSANISTP256 bool // Whether ECDSA NISTP256 keys should be allowed. AllowECDSANISTP384 bool // Whether ECDSA NISTP384 keys should be allowed. weakRSAList *WeakRSAKeys + blockedList *blockedKeys } // NewKeyPolicy returns a KeyPolicy that allows RSA, ECDSA256 and ECDSA384. // weakKeyFile contains the path to a JSON file containing truncated modulus // hashes of known weak RSA keys. If this argument is empty RSA modulus hash -// checking will be disabled. -func NewKeyPolicy(weakKeyFile string) (KeyPolicy, error) { +// checking will be disabled. blockedKeyFile contains the path to a YAML file +// 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) (KeyPolicy, error) { kp := KeyPolicy{ AllowRSA: true, AllowECDSANISTP256: true, @@ -60,6 +64,13 @@ func NewKeyPolicy(weakKeyFile string) (KeyPolicy, error) { } kp.weakRSAList = keyList } + if blockedKeyFile != "" { + blocked, err := loadBlockedKeysList(blockedKeyFile) + if err != nil { + return KeyPolicy{}, err + } + kp.blockedList = blocked + } return kp, nil } @@ -68,6 +79,15 @@ func NewKeyPolicy(weakKeyFile string) (KeyPolicy, error) { // strength and algorithm checking. // TODO: Support JSONWebKeys once go-jose migration is done. func (policy *KeyPolicy) GoodKey(key crypto.PublicKey) error { + // If there is a blocked list configured then check if the public key is one + // that has been administratively blocked. + if policy.blockedList != nil { + if blocked, err := policy.blockedList.blocked(key); err != nil { + return berrors.InternalServerError("error checking blocklist for key: %v", key) + } else if blocked { + return berrors.BadPublicKeyError("public key is forbidden") + } + } switch t := key.(type) { case rsa.PublicKey: return policy.goodKeyRSA(t) diff --git a/test/block-a-key/main.go b/test/block-a-key/main.go new file mode 100644 index 000000000..e4bc093be --- /dev/null +++ b/test/block-a-key/main.go @@ -0,0 +1,104 @@ +// block-a-key is a small utility for creating key blocklist entries. +package main + +import ( + "crypto" + "errors" + "flag" + "fmt" + "log" + "os" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/web" +) + +const usageHelp = ` +block-a-key is utility tool for generating a Base64 encoded SHA256 digest of +a certificate or JWK public key in DER encoded PKIX subject public key form. + +The produced encoded digest can be used with Boulder's key blocklist to block +any ACME account creation or certificate requests that use the same public +key. + +installation: + go install github.com/letsencrypt/boulder/test/block-a-key/... + +usage: + block-a-key -cert + block-a-key -jwk + +output format: + # + - "" + +examples: + $> block-a-key -jwk ./test/block-a-key/test/test.ecdsa.jwk.json + ./test/block-a-key/test/test.ecdsa.jwk.json cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M= + $> block-a-key -cert ./test/block-a-key/test/test.rsa.cert.pem + ./test/block-a-key/test/test.rsa.cert.pem Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE= +` + +// keyFromCert returns the public key from a PEM encoded certificate located in +// pemFile or returns an error. +func keyFromCert(pemFile string) (crypto.PublicKey, error) { + c, err := core.LoadCert(pemFile) + if err != nil { + return nil, err + } + return c.PublicKey, nil +} + +// keyFromJWK returns the public key from a JSON encoded JOSE JWK located in +// jsonFile or returns an error. +func keyFromJWK(jsonFile string) (crypto.PublicKey, error) { + jwk, err := web.LoadJWK(jsonFile) + if err != nil { + return nil, err + } + return jwk.Key, nil +} + +func main() { + certFileArg := flag.String("cert", "", "path to a PEM encoded X509 certificate file") + jwkFileArg := flag.String("jwk", "", "path to a JSON encoded JWK file") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "%s\n\n", usageHelp) + fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + } + + flag.Parse() + + if *certFileArg == "" && *jwkFileArg == "" { + log.Fatalf("error: a -cert or -jwk argument must be provided") + } + + if *certFileArg != "" && *jwkFileArg != "" { + log.Fatalf("error: -cert and -jwk arguments are mutually exclusive") + } + + var file string + var key crypto.PublicKey + var err error + + if *certFileArg != "" { + file = *certFileArg + key, err = keyFromCert(file) + } else if *jwkFileArg != "" { + file = *jwkFileArg + key, err = keyFromJWK(file) + } else { + err = errors.New("unexpected command line state") + } + if err != nil { + log.Fatalf("error loading public key: %v", err) + } + + spkiHash, err := core.KeyDigest(key) + if err != nil { + log.Fatalf("error computing spki hash: %v", err) + } + fmt.Printf(" # %s\n - %s\n", file, spkiHash) +} diff --git a/test/block-a-key/main_test.go b/test/block-a-key/main_test.go new file mode 100644 index 000000000..c92cfb74b --- /dev/null +++ b/test/block-a-key/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "crypto" + "testing" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/test" +) + +func TestKeyBlocking(t *testing.T) { + testCases := []struct { + name string + certPath string + jwkPath string + expected string + }{ + // NOTE(@cpu): The JWKs and certificates were generated with the same + // keypair within an algorithm/parameter family. E.g. the RSA JWK public key + // matches the RSA certificate public key. The ECDSA JWK public key matches + // the ECDSA certificate public key. + { + name: "P-256 ECDSA JWK", + jwkPath: "test/test.ecdsa.jwk.json", + expected: "cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M=", + }, + { + name: "2048 RSA JWK", + jwkPath: "test/test.rsa.jwk.json", + expected: "Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE=", + }, + { + name: "P-256 ECDSA Certificate", + certPath: "test/test.ecdsa.cert.pem", + expected: "cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M=", + }, + { + name: "2048 RSA Certificate", + certPath: "test/test.rsa.cert.pem", + expected: "Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE=", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var key crypto.PublicKey + var err error + if tc.jwkPath != "" { + key, err = keyFromJWK(tc.jwkPath) + } else { + key, err = keyFromCert(tc.certPath) + } + test.AssertNotError(t, err, "error getting key from input file") + spkiHash, err := core.KeyDigest(key) + test.AssertNotError(t, err, "error computing spki hash") + test.AssertEquals(t, spkiHash, tc.expected) + }) + } +} diff --git a/test/block-a-key/test/README.txt b/test/block-a-key/test/README.txt new file mode 100644 index 000000000..9035a4a56 --- /dev/null +++ b/test/block-a-key/test/README.txt @@ -0,0 +1,7 @@ +The test files in this directory can be recreated with the following small program: + + https://gist.github.com/cpu/df50564a473b3e8556917eb80d99ea56 + +Crucially the public keys in the generated JWKs/Certs are shared within +algorithm/parameters. E.g. the ECDSA JWK has the same public key as the ECDSA +Cert. diff --git a/test/block-a-key/test/test.ecdsa.cert.pem b/test/block-a-key/test/test.ecdsa.cert.pem new file mode 100644 index 000000000..09bc304f1 --- /dev/null +++ b/test/block-a-key/test/test.ecdsa.cert.pem @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE----- +MIH1MIGboAMCAQICAQEwCgYIKoZIzj0EAwIwADAiGA8wMDAxMDEwMTAwMDAwMFoY +DzAwMDEwMTAxMDAwMDAwWjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4LqG +kzIYWSgmyTS+B9Eet1xx1wpCKiSklMPnHfFp8eSHr1uNk6ilWv/s4AoKHSvMNAb/ +1uPfxjlijEIjK2bOQKMCMAAwCgYIKoZIzj0EAwIDSQAwRgIhAJBK1/C1BYDnzSCu +cR2pE40d8dyrRuHKj8htO/fzRgCgAiEA0UG0Vda8w0Tp84AMlJpZHOx9QUbwExSl +oFEDADJ9WQM= +-----END CERTIFICATE----- diff --git a/test/block-a-key/test/test.ecdsa.jwk.json b/test/block-a-key/test/test.ecdsa.jwk.json new file mode 100644 index 000000000..364a666d2 --- /dev/null +++ b/test/block-a-key/test/test.ecdsa.jwk.json @@ -0,0 +1 @@ +{"kty":"EC","crv":"P-256","alg":"ECDSA","x":"4LqGkzIYWSgmyTS-B9Eet1xx1wpCKiSklMPnHfFp8eQ","y":"h69bjZOopVr_7OAKCh0rzDQG_9bj38Y5YoxCIytmzkA"} diff --git a/test/block-a-key/test/test.rsa.cert.pem b/test/block-a-key/test/test.rsa.cert.pem new file mode 100644 index 000000000..502f94f99 --- /dev/null +++ b/test/block-a-key/test/test.rsa.cert.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICgTCCAWmgAwIBAgIBATANBgkqhkiG9w0BAQsFADAAMCIYDzAwMDEwMTAxMDAw +MDAwWhgPMDAwMTAxMDEwMDAwMDBaMAAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQC+epImi+GdM4ypmQ7LeWSYbbX0AHeZJvRScp5+JvkVQNTIDjQGnYxw +7omOW1dkn0qGkQckFmvUmCHXuK6oF0GYOvRzEdOwb6KeTb+ONYQHGLirKU2bt+um +JxiB/9PMaV5yPwpyNVi0XV5Rr+BpHdV1i9lm542+4zwfWiYRKT1+tjpvicmyK0av +T/60U0kfeeSdAU0TcSFR4RDEw1fudXIRk7FPgd2GHjeJeAeMmLL4Vabr+uSecGpp +THdkbnPDV51WVPHcyoOV6rdicSEoqE9aoeMjQXZ6SntXGjY4pqlyuwjqocLZStEK +ztxp3D7eyeHub9nrCgp+UsxaWns1DtP3AgMBAAGjAjAAMA0GCSqGSIb3DQEBCwUA +A4IBAQA9sazSAm6umbleFWDrh3oyGaFBzYvRfeOAEquJky36qREjBWvrS2Yi66eX +L9Uoavr/CIk+U9qRPl81cHi5qsFBuDi+OKZzG32Uq7Rw8h+7f/9HVEUyVVy1p7v8 +iqZvygU70NeT0cT91eSl6LV88BdjhbjI6Hk1+AVF6UPAmzkgJIFAwwUWa2HUT+Ni +nMxzRThuLyPbYt4clz6bGzk26LIdoByJH4pYabXh05OwalBJjMVR/4ek9blrVMAg +b4a7Eq/WXq+CVwWnb3oholDOJo3l/KwNuG6HD90JU0Vu4fipFqmsXhBHYVNVu94y +wJWm+dAtEeAcp8KfOv/IBMCjDkyt +-----END CERTIFICATE----- diff --git a/test/block-a-key/test/test.rsa.jwk.json b/test/block-a-key/test/test.rsa.jwk.json new file mode 100644 index 000000000..958a78ba3 --- /dev/null +++ b/test/block-a-key/test/test.rsa.jwk.json @@ -0,0 +1 @@ +{"kty":"RSA","alg":"RS256","n":"vnqSJovhnTOMqZkOy3lkmG219AB3mSb0UnKefib5FUDUyA40Bp2McO6JjltXZJ9KhpEHJBZr1Jgh17iuqBdBmDr0cxHTsG-ink2_jjWEBxi4qylNm7frpicYgf_TzGlecj8KcjVYtF1eUa_gaR3VdYvZZueNvuM8H1omESk9frY6b4nJsitGr0_-tFNJH3nknQFNE3EhUeEQxMNX7nVyEZOxT4Hdhh43iXgHjJiy-FWm6_rknnBqaUx3ZG5zw1edVlTx3MqDleq3YnEhKKhPWqHjI0F2ekp7Vxo2OKapcrsI6qHC2UrRCs7cadw-3snh7m_Z6woKflLMWlp7NQ7T9w","e":"AQAB"} diff --git a/test/config-next/ca-a.json b/test/config-next/ca-a.json index 982d64ac8..0d59b73aa 100644 --- a/test/config-next/ca-a.json +++ b/test/config-next/ca-a.json @@ -5,6 +5,7 @@ "ecdsaProfile": "ecdsaEE", "debugAddr": ":8001", "weakKeyFile": "test/example-weak-keys.json", + "blockedKeyFile": "test/example-blocked-keys.yaml", "tls": { "caCertFile": "test/grpc-creds/minica.pem", "certFile": "test/grpc-creds/ca.boulder/cert.pem", diff --git a/test/config-next/ca-b.json b/test/config-next/ca-b.json index dcec0a93f..04f48c48a 100644 --- a/test/config-next/ca-b.json +++ b/test/config-next/ca-b.json @@ -5,6 +5,7 @@ "ecdsaProfile": "ecdsaEE", "debugAddr": ":8001", "weakKeyFile": "test/example-weak-keys.json", + "blockedKeyFile": "test/example-blocked-keys.yaml", "tls": { "caCertFile": "test/grpc-creds/minica.pem", "certFile": "test/grpc-creds/ca.boulder/cert.pem", diff --git a/test/config-next/ra.json b/test/config-next/ra.json index 1055397f4..97924bec1 100644 --- a/test/config-next/ra.json +++ b/test/config-next/ra.json @@ -10,6 +10,7 @@ "authorizationLifetimeDays": 30, "pendingAuthorizationLifetimeDays": 7, "weakKeyFile": "test/example-weak-keys.json", + "blockedKeyFile": "test/example-blocked-keys.yaml", "orderLifetime": "168h", "issuerCertPath": "test/test-ca2.pem", "tls": { diff --git a/test/config-next/wfe.json b/test/config-next/wfe.json index 625a592c3..e98c849d2 100644 --- a/test/config-next/wfe.json +++ b/test/config-next/wfe.json @@ -17,6 +17,7 @@ "debugAddr": ":8000", "directoryCAAIdentity": "happy-hacker-ca.invalid", "directoryWebsite": "https://github.com/letsencrypt/boulder", + "blockedKeyFile": "test/example-blocked-keys.yaml", "tls": { "caCertFile": "test/grpc-creds/minica.pem", "certFile": "test/grpc-creds/wfe.boulder/cert.pem", diff --git a/test/config-next/wfe2.json b/test/config-next/wfe2.json index 98ae0914b..5363099b1 100644 --- a/test/config-next/wfe2.json +++ b/test/config-next/wfe2.json @@ -18,6 +18,7 @@ "directoryCAAIdentity": "happy-hacker-ca.invalid", "directoryWebsite": "https://github.com/letsencrypt/boulder", "legacyKeyIDPrefix": "http://boulder:4000/reg/", + "blockedKeyFile": "test/example-blocked-keys.yaml", "tls": { "caCertFile": "test/grpc-creds/minica.pem", "certFile": "test/grpc-creds/wfe.boulder/cert.pem", diff --git a/test/example-blocked-keys.yaml b/test/example-blocked-keys.yaml new file mode 100644 index 000000000..08dd72256 --- /dev/null +++ b/test/example-blocked-keys.yaml @@ -0,0 +1,29 @@ +# +# List of blocked keys +# +# Each blocked entry is a Base64 encoded SHA256 digest of a public key encoded +# in DER form as a PKIX public key. +# +# Use the test/block-a-key utility to generate new additions. +# +# NOTE: This list is loaded all-at-once in-memory by Boulder and is intended +# to be used infrequently. Alternative mechanisms should be explored if +# large scale blocks are required. +# +blocked: + # test/test-ca2.pem + - F4j7m0doxdWXdKOzeYjL6onsVYLLU2jb7xr994zlFFg= + # test/test-ca.pem + - F4j7m0doxdWXdKOzeYjL6onsVYLLU2jb7xr994zlFFg= + # test/test-example.pem + - 6E/Drp3Lzo85pYykpzx/tZpQZXeovto8/ezq1DBiSCc= + # test/test-root.pem + - Jy5HDlBtUvKkLtEsGbdp0o9LvVJx1lYG3R+n5G/KgIo= + # test/block-a-key/test/test.ecdsa.cert.pem + - cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M= + # test/block-a-key/test/test.rsa.cert.pem + - Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE= + # test/block-a-key/test/test.ecdsa.jwk.json + - cuwGhNNI6nfob5aqY90e7BleU6l7rfxku4X3UTJ3Z7M= + # test/block-a-key/test/test.rsa.jwk.json + - Qebc1V3SkX3izkYRGNJilm9Bcuvf0oox4U2Rn+b4JOE= diff --git a/test/v2_integration.py b/test/v2_integration.py index e1c6af7d6..0009a3cb9 100644 --- a/test/v2_integration.py +++ b/test/v2_integration.py @@ -16,6 +16,7 @@ import OpenSSL from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization import chisel2 from helpers import * @@ -1086,5 +1087,67 @@ def test_ocsp_exp_unauth(): tries += 1 time.sleep(0.25) +def test_blocked_key_account(): + # Only config-next has a blocked keys file configured. + if not CONFIG_NEXT: + return + + with open("test/test-ca.key", "rb") as key_file: + key = serialization.load_pem_private_key(key_file.read(), password=None, backend=default_backend()) + + # Create a client with the JWK set to a blocked private key + jwk = josepy.JWKRSA(key=key) + client = chisel2.uninitialized_client(jwk) + email = "test@not-example.com" + + # Try to create an account + testPass = False + try: + client.new_account(messages.NewRegistration.from_data(email=email, + terms_of_service_agreed=True)) + except acme_errors.Error as e: + if e.typ != "urn:ietf:params:acme:error:badPublicKey": + raise Exception("problem did not have correct error type, had {0}".format(e.typ)) + if e.detail != "public key is forbidden": + raise Exception("problem did not have correct error detail, had {0}".format(e.detail)) + testPass = True + + if testPass is False: + raise Exception("expected account creation to fail with Error when using blocked key") + +def test_blocked_key_cert(): + # Only config-next has a blocked keys file configured. + if not CONFIG_NEXT: + return + + with open("test/test-ca.key", "r") as f: + pemBytes = f.read() + + domains = [random_domain(), random_domain()] + csr = acme_crypto_util.make_csr(pemBytes, domains, False) + + client = chisel2.make_client(None) + order = client.new_order(csr) + authzs = order.authorizations + + testPass = False + cleanup = chisel2.do_http_challenges(client, authzs) + try: + order = client.poll_and_finalize(order) + except acme_errors.Error as e: + # TODO(@cpu): this _should_ be type + # urn:ietf:params:acme:error:badPublicKey but this will require + # refactoring the `csr` package error handling. + # See https://github.com/letsencrypt/boulder/issues/4418 + if e.typ != "urn:ietf:params:acme:error:malformed": + raise Exception("problem did not have correct error type, had {0}".format(e.typ)) + if e.detail != "Error finalizing order :: invalid public key in CSR: public key is forbidden": + raise Exception("problem did not have correct error detail, had {0}".format(e.detail)) + testPass = True + + + if testPass is False: + raise Exception("expected cert creation to fail with Error when using blocked key") + def run(cmd, **kwargs): return subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT, **kwargs) diff --git a/web/jwk.go b/web/jwk.go new file mode 100644 index 000000000..10f785a51 --- /dev/null +++ b/web/jwk.go @@ -0,0 +1,19 @@ +package web + +import ( + "encoding/json" + "io/ioutil" + + jose "gopkg.in/square/go-jose.v2" +) + +// LoadJWK loads a JSON encoded JWK specified by filename or returns an error +func LoadJWK(filename string) (*jose.JSONWebKey, error) { + var jwk jose.JSONWebKey + if jsonBytes, err := ioutil.ReadFile(filename); err != nil { + return nil, err + } else if err = json.Unmarshal(jsonBytes, &jwk); err != nil { + return nil, err + } + return &jwk, nil +} diff --git a/web/probs.go b/web/probs.go index dc4ca221a..70329fc4b 100644 --- a/web/probs.go +++ b/web/probs.go @@ -35,6 +35,8 @@ func problemDetailsForBoulderError(err *berrors.BoulderError, msg string) *probs outProb = probs.ServerInternal("%s :: %s", msg, "Unable to meet CA SCT embedding requirements") case berrors.OrderNotReady: outProb = probs.OrderNotReady("%s :: %s", msg, err) + case berrors.BadPublicKey: + outProb = probs.BadPublicKey("%s :: %s", msg, err) default: // Internal server error messages may include sensitive data, so we do // not include it.