diff --git a/Dockerfile b/Dockerfile index 5fa1d2603..68b8495aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,8 @@ # be used as the base of the bhsm container in boulder/docker-compose.yml FROM letsencrypt/boulder-tools:2017-05-25 -# Boulder exposes its web application at port TCP 4000 -EXPOSE 4000 4002 4003 4430 8053 8055 +# Boulder exposes its web application at port TCP 4000 and 4001 +EXPOSE 4000 4001 4002 4003 4430 4431 8053 8055 ENV PATH /usr/local/go/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin/ ENV GOPATH /go diff --git a/cmd/boulder-wfe2/main.go b/cmd/boulder-wfe2/main.go new file mode 100644 index 000000000..5e6059088 --- /dev/null +++ b/cmd/boulder-wfe2/main.go @@ -0,0 +1,181 @@ +package main + +import ( + "crypto/tls" + "flag" + "fmt" + "net/http" + "os" + + "github.com/facebookgo/httpdown" + "github.com/jmhodges/clock" + + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + bgrpc "github.com/letsencrypt/boulder/grpc" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + rapb "github.com/letsencrypt/boulder/ra/proto" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/wfe2" +) + +const clientName = "WFE2" + +type config struct { + WFE struct { + cmd.ServiceConfig + BaseURL string + ListenAddress string + TLSListenAddress string + + ServerCertificatePath string + ServerKeyPath string + + AllowOrigins []string + + CertCacheDuration cmd.ConfigDuration + CertNoCacheExpirationWindow cmd.ConfigDuration + IndexCacheDuration cmd.ConfigDuration + IssuerCacheDuration cmd.ConfigDuration + + ShutdownStopTimeout cmd.ConfigDuration + ShutdownKillTimeout cmd.ConfigDuration + + SubscriberAgreementURL string + + AcceptRevocationReason bool + AllowAuthzDeactivation bool + + TLS cmd.TLSConfig + + RAService *cmd.GRPCClientConfig + SAService *cmd.GRPCClientConfig + + Features map[string]bool + } + + SubscriberAgreementURL string + + Syslog cmd.SyslogConfig + + Common struct { + BaseURL string + IssuerCert string + } +} + +func setupWFE(c config, logger blog.Logger, stats metrics.Scope) (core.RegistrationAuthority, core.StorageAuthority) { + var tls *tls.Config + var err error + if c.WFE.TLS.CertFile != nil { + tls, err = c.WFE.TLS.Load() + cmd.FailOnError(err, "TLS config") + } + + raConn, err := bgrpc.ClientSetup(c.WFE.RAService, tls, stats) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA") + rac := bgrpc.NewRegistrationAuthorityClient(rapb.NewRegistrationAuthorityClient(raConn)) + + saConn, err := bgrpc.ClientSetup(c.WFE.SAService, tls, stats) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA") + sac := bgrpc.NewStorageAuthorityClient(sapb.NewStorageAuthorityClient(saConn)) + + return rac, sac +} + +func main() { + configFile := flag.String("config", "", "File path to the configuration file for this service") + flag.Parse() + if *configFile == "" { + flag.Usage() + os.Exit(1) + } + + var c config + err := cmd.ReadConfigFile(*configFile, &c) + cmd.FailOnError(err, "Reading JSON config file into config structure") + + err = features.Set(c.WFE.Features) + cmd.FailOnError(err, "Failed to set feature flags") + + scope, logger := cmd.StatsAndLogging(c.Syslog) + defer logger.AuditPanic() + logger.Info(cmd.VersionString(clientName)) + + kp, err := goodkey.NewKeyPolicy("") // don't load any weak keys + cmd.FailOnError(err, "Unable to create key policy") + wfe, err := wfe2.NewWebFrontEndImpl(scope, clock.Default(), kp, logger) + cmd.FailOnError(err, "Unable to create WFE") + rac, sac := setupWFE(c, logger, scope) + wfe.RA = rac + wfe.SA = sac + + // TODO: remove this check once the production config uses the SubscriberAgreementURL in the wfe section + if c.WFE.SubscriberAgreementURL != "" { + wfe.SubscriberAgreementURL = c.WFE.SubscriberAgreementURL + } else { + wfe.SubscriberAgreementURL = c.SubscriberAgreementURL + } + + wfe.AllowOrigins = c.WFE.AllowOrigins + wfe.AcceptRevocationReason = c.WFE.AcceptRevocationReason + wfe.AllowAuthzDeactivation = c.WFE.AllowAuthzDeactivation + + wfe.CertCacheDuration = c.WFE.CertCacheDuration.Duration + wfe.CertNoCacheExpirationWindow = c.WFE.CertNoCacheExpirationWindow.Duration + wfe.IndexCacheDuration = c.WFE.IndexCacheDuration.Duration + wfe.IssuerCacheDuration = c.WFE.IssuerCacheDuration.Duration + + wfe.IssuerCert, err = cmd.LoadCert(c.Common.IssuerCert) + cmd.FailOnError(err, fmt.Sprintf("Couldn't read issuer cert [%s]", c.Common.IssuerCert)) + + logger.Info(fmt.Sprintf("WFE using key policy: %#v", kp)) + + // Set up paths + wfe.BaseURL = c.Common.BaseURL + + logger.Info(fmt.Sprintf("Server running, listening on %s...\n", c.WFE.ListenAddress)) + srv := &http.Server{ + Addr: c.WFE.ListenAddress, + Handler: wfe.Handler(), + } + + go cmd.DebugServer(c.WFE.DebugAddr) + go cmd.ProfileCmd(scope) + + hd := &httpdown.HTTP{ + StopTimeout: c.WFE.ShutdownStopTimeout.Duration, + KillTimeout: c.WFE.ShutdownKillTimeout.Duration, + } + hdSrv, err := hd.ListenAndServe(srv) + cmd.FailOnError(err, "Error starting HTTP server") + + var hdTLSSrv httpdown.Server + if c.WFE.TLSListenAddress != "" { + cer, err := tls.LoadX509KeyPair(c.WFE.ServerCertificatePath, c.WFE.ServerKeyPath) + cmd.FailOnError(err, "Couldn't read WFE server certificate or key") + tlsConfig := &tls.Config{Certificates: []tls.Certificate{cer}} + + logger.Info(fmt.Sprintf("TLS Server running, listening on %s...\n", c.WFE.TLSListenAddress)) + TLSSrv := &http.Server{ + Addr: c.WFE.TLSListenAddress, + Handler: wfe.Handler(), + TLSConfig: tlsConfig, + } + hdTLSSrv, err = hd.ListenAndServe(TLSSrv) + cmd.FailOnError(err, "Error starting TLS server") + } + + go cmd.CatchSignals(logger, func() { + _ = hdSrv.Stop() + if hdTLSSrv != nil { + _ = hdTLSSrv.Stop() + } + }) + + forever := make(chan struct{}, 1) + <-forever +} diff --git a/cmd/boulder-wfe2/main_test.go b/cmd/boulder-wfe2/main_test.go new file mode 100644 index 000000000..06ab7d0f9 --- /dev/null +++ b/cmd/boulder-wfe2/main_test.go @@ -0,0 +1 @@ +package main diff --git a/docker-compose.yml b/docker-compose.yml index 737d00257..6da149d5d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,9 +17,11 @@ services: - boulder:127.0.0.1 ports: - 4000:4000 # ACME + - 4001:4001 # ACMEv2 - 4002:4002 # OCSP - 4003:4003 # OCSP - 4430:4430 # ACME via HTTPS + - 4431:4431 # ACMEv2 via HTTPS - 4500:4500 # ct-test-srv - 6000:6000 # gsb-test-srv - 8000:8000 # debug ports diff --git a/test/config-next/wfe2.json b/test/config-next/wfe2.json new file mode 100644 index 000000000..3d2570bb8 --- /dev/null +++ b/test/config-next/wfe2.json @@ -0,0 +1,50 @@ +{ + "wfe": { + "listenAddress": "0.0.0.0:4001", + "TLSListenAddress": "0.0.0.0:4431", + "serverCertificatePath": "test/wfe.pem", + "serverKeyPath": "test/wfe.key", + "requestTimeout": "10s", + "allowOrigins": ["*"], + "certCacheDuration": "6h", + "certNoCacheExpirationWindow": "96h", + "indexCacheDuration": "24h", + "issuerCacheDuration": "48h", + "shutdownStopTimeout": "10s", + "shutdownKillTimeout": "1m", + "subscriberAgreementURL": "http://boulder:4001/terms/v1", + "acceptRevocationReason": true, + "allowAuthzDeactivation": true, + "debugAddr": ":8013", + "tls": { + "caCertFile": "test/grpc-creds/minica.pem", + "certFile": "test/grpc-creds/wfe.boulder/cert.pem", + "keyFile": "test/grpc-creds/wfe.boulder/key.pem" + }, + "raService": { + "serverAddresses": ["ra.boulder:19094"], + "timeout": "15s" + }, + "saService": { + "serverAddresses": ["sa.boulder:19095"], + "timeout": "15s" + }, + "features": { + "AllowAccountDeactivation": true, + "AllowKeyRollover": true, + "UseAIAIssuerURL": true, + "RandomDirectoryEntry": true, + "DirectoryMeta": true + } + }, + + "syslog": { + "stdoutlevel": 6, + "sysloglevel": 4 + }, + + "common": { + "issuerCert": "test/test-ca.pem", + "dnsResolver": "127.0.0.1:8053" + } +} diff --git a/test/config/wfe2.json b/test/config/wfe2.json new file mode 100644 index 000000000..e17fe787b --- /dev/null +++ b/test/config/wfe2.json @@ -0,0 +1,54 @@ +{ + "wfe": { + "listenAddress": "0.0.0.0:4001", + "TLSListenAddress": "0.0.0.0:4431", + "serverCertificatePath": "test/wfe.pem", + "serverKeyPath": "test/wfe.key", + "requestTimeout": "10s", + "allowOrigins": ["*"], + "certCacheDuration": "6h", + "certNoCacheExpirationWindow": "96h", + "indexCacheDuration": "24h", + "issuerCacheDuration": "48h", + "shutdownStopTimeout": "10s", + "shutdownKillTimeout": "1m", + "subscriberAgreementURL": "http://boulder:4001/terms/v1", + "checkMalformedCSR": true, + "allowAuthzDeactivation": true, + "debugAddr": ":8013", + "tls": { + "caCertFile": "test/grpc-creds/minica.pem", + "certFile": "test/grpc-creds/wfe.boulder/cert.pem", + "keyFile": "test/grpc-creds/wfe.boulder/key.pem" + }, + "raService": { + "serverAddresses": ["ra.boulder:19094"], + "timeout": "15s" + }, + "saService": { + "serverAddresses": ["sa.boulder:19095"], + "timeout": "15s" + } + }, + "allowedSigningAlgos": { + "rsa": true, + "ecdsanistp256": true, + "ecdsanistp384": true, + "ecdsanistp521": false + }, + + "statsd": { + "server": "localhost:8125", + "prefix": "Boulder" + }, + + "subscriberAgreementURL": "http://boulder:4001/terms/v1", + + "syslog": { + "stdoutlevel": 6 + }, + + "common": { + "issuerCert": "test/test-ca.pem" + } +} diff --git a/test/run-docker.sh b/test/run-docker.sh index 77985cf2a..55f795e74 100755 --- a/test/run-docker.sh +++ b/test/run-docker.sh @@ -65,9 +65,11 @@ fi docker run --rm -it \ "${fake_dns_args[@]}" \ -p 4000:4000 \ + -p 4001:4001 \ -p 4002:4002 \ -p 4003:4003 \ -p 4430:4430 \ + -p 4431:4431 \ -p 8053:8053 \ -p 8055:8055 \ --name boulder \ diff --git a/test/startservers.py b/test/startservers.py index e6d3ed1d1..a2adb8654 100644 --- a/test/startservers.py +++ b/test/startservers.py @@ -64,6 +64,7 @@ def start(race_detection): 'gsb-test-srv -apikey my-voice-is-my-passport', 'boulder-sa --config %s' % os.path.join(default_config_dir, "sa.json"), 'boulder-wfe --config %s' % os.path.join(default_config_dir, "wfe.json"), + 'boulder-wfe2 --config %s' % os.path.join(default_config_dir, "wfe2.json"), 'boulder-ra --config %s' % os.path.join(default_config_dir, "ra.json"), 'boulder-ca --config %s' % os.path.join(default_config_dir, "ca.json"), 'boulder-va --config %s' % os.path.join(default_config_dir, "va.json"), @@ -100,10 +101,12 @@ def start(race_detection): # If one of the servers has died, quit immediately. if not check(): return False - ports = range(8000, 8005) + [4000, 4430] + ports = range(8000, 8005) + [4000, 4001, 4430, 4431] if default_config_dir.startswith("test/config-next"): # Add the two 'remote' VA debug ports ports.extend([8011, 8012]) + # Add the wfe v2 debug port + ports.extend([8013]) for debug_port in ports: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(('localhost', debug_port)) diff --git a/wfe2/README.md b/wfe2/README.md new file mode 100644 index 000000000..066c3684f --- /dev/null +++ b/wfe2/README.md @@ -0,0 +1,7 @@ +WFE v2 +============ + +The `wfe2` package is copied from the `wfe` package in order to implement the +["ACME v2"](https://letsencrypt.org/2017/06/14/acme-v2-api.html) API. This design choice +was made to facilitate a clean separation between v1 and v2 code and to support +running a separate API process on a different port alongside the v1 API process. diff --git a/wfe2/context.go b/wfe2/context.go new file mode 100644 index 000000000..56d78badd --- /dev/null +++ b/wfe2/context.go @@ -0,0 +1,90 @@ +package wfe2 + +import ( + "encoding/json" + "fmt" + "net/http" + + "golang.org/x/net/context" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/core" + blog "github.com/letsencrypt/boulder/log" +) + +type requestEvent struct { + ID string `json:",omitempty"` + RealIP string `json:",omitempty"` + Endpoint string `json:",omitempty"` + Method string `json:",omitempty"` + Errors []string `json:",omitempty"` + Requester int64 `json:",omitempty"` + Contacts *[]string `json:",omitempty"` + RequestNonce string `json:",omitempty"` + ResponseNonce string `json:",omitempty"` + UserAgent string `json:",omitempty"` + Code int + Payload string `json:",omitempty"` + Extra map[string]interface{} `json:",omitempty"` +} + +func (e *requestEvent) AddError(msg string, args ...interface{}) { + e.Errors = append(e.Errors, fmt.Sprintf(msg, args...)) +} + +type wfeHandlerFunc func(context.Context, *requestEvent, http.ResponseWriter, *http.Request) + +func (f wfeHandlerFunc) ServeHTTP(e *requestEvent, w http.ResponseWriter, r *http.Request) { + ctx := context.TODO() + f(ctx, e, w, r) +} + +type wfeHandler interface { + ServeHTTP(e *requestEvent, w http.ResponseWriter, r *http.Request) +} + +type topHandler struct { + wfe wfeHandler + log blog.Logger + clk clock.Clock +} + +func (th *topHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + logEvent := &requestEvent{ + ID: core.NewToken(), + RealIP: r.Header.Get("X-Real-IP"), + Method: r.Method, + UserAgent: r.Header.Get("User-Agent"), + Extra: make(map[string]interface{}, 0), + } + w.Header().Set("Boulder-Request-ID", logEvent.ID) + defer th.logEvent(logEvent) + + th.wfe.ServeHTTP(logEvent, w, r) +} + +func (th *topHandler) logEvent(logEvent *requestEvent) { + var msg string + if len(logEvent.Errors) != 0 { + msg = "Terminated request" + } else { + msg = "Successful request" + } + jsonEvent, err := json.Marshal(logEvent) + if err != nil { + th.log.AuditErr(fmt.Sprintf("%s - failed to marshal logEvent - %s", msg, err)) + return + } + th.log.Info(fmt.Sprintf("%s JSON=%s", msg, jsonEvent)) +} + +// Comma-separated list of HTTP clients involved in making this +// request, starting with the original requestor and ending with the +// remote end of our TCP connection (which is typically our own +// proxy). +func getClientAddr(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + return xff + "," + r.RemoteAddr + } + return r.RemoteAddr +} diff --git a/wfe2/jose.go b/wfe2/jose.go new file mode 100644 index 000000000..f6f04b987 --- /dev/null +++ b/wfe2/jose.go @@ -0,0 +1,58 @@ +package wfe2 + +import ( + "crypto/ecdsa" + "crypto/rsa" + "fmt" + + "gopkg.in/square/go-jose.v1" +) + +func algorithmForKey(key *jose.JsonWebKey) (string, error) { + switch k := key.Key.(type) { + case *rsa.PublicKey: + return string(jose.RS256), nil + case *ecdsa.PublicKey: + switch k.Params().Name { + case "P-256": + return string(jose.ES256), nil + case "P-384": + return string(jose.ES384), nil + case "P-521": + return string(jose.ES512), nil + } + } + return "", signatureValidationError("no signature algorithms suitable for given key type") +} + +const ( + noAlgorithmForKey = "WFE.Errors.NoAlgorithmForKey" + invalidJWSAlgorithm = "WFE.Errors.InvalidJWSAlgorithm" + invalidAlgorithmOnKey = "WFE.Errors.InvalidAlgorithmOnKey" +) + +// Check that (1) there is a suitable algorithm for the provided key based on its +// Golang type, (2) the Algorithm field on the JWK is either absent, or matches +// that algorithm, and (3) the Algorithm field on the JWK is present and matches +// that algorithm. Precondition: parsedJws must have exactly one signature on +// it. Returns stat name to increment if err is non-nil. +func checkAlgorithm(key *jose.JsonWebKey, parsedJws *jose.JsonWebSignature) (string, error) { + algorithm, err := algorithmForKey(key) + if err != nil { + return noAlgorithmForKey, err + } + jwsAlgorithm := parsedJws.Signatures[0].Header.Algorithm + if jwsAlgorithm != algorithm { + return invalidJWSAlgorithm, signatureValidationError(fmt.Sprintf( + "signature type '%s' in JWS header is not supported, expected one of RS256, ES256, ES384 or ES512", + jwsAlgorithm, + )) + } + if key.Algorithm != "" && key.Algorithm != algorithm { + return invalidAlgorithmOnKey, signatureValidationError(fmt.Sprintf( + "algorithm '%s' on JWK is unacceptable", + key.Algorithm, + )) + } + return "", nil +} diff --git a/wfe2/jose_test.go b/wfe2/jose_test.go new file mode 100644 index 000000000..3922e0f82 --- /dev/null +++ b/wfe2/jose_test.go @@ -0,0 +1,203 @@ +package wfe2 + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "testing" + + "gopkg.in/square/go-jose.v1" +) + +func TestRejectsNone(t *testing.T) { + wfe, _ := setupWFE(t) + _, _, _, prob := wfe.verifyPOST(ctx, newRequestEvent(), makePostRequest(` + { + "header": { + "alg": "none", + "jwk": { + "kty": "RSA", + "n": "vrjT", + "e": "AQAB" + } + }, + "payload": "aGkK", + "signature": "" + } + `), true, "foo") + if prob == nil { + t.Fatalf("verifyPOST did not reject JWS with alg: 'none'") + } + if prob.Detail != "signature type 'none' in JWS header is not supported, expected one of RS256, ES256, ES384 or ES512" { + t.Fatalf("verifyPOST rejected JWS with alg: 'none', but for wrong reason: %#v", prob) + } +} + +func TestRejectsHS256(t *testing.T) { + wfe, _ := setupWFE(t) + _, _, _, prob := wfe.verifyPOST(ctx, newRequestEvent(), makePostRequest(` + { + "header": { + "alg": "HS256", + "jwk": { + "kty": "RSA", + "n": "vrjT", + "e": "AQAB" + } + }, + "payload": "aGkK", + "signature": "" + } + `), true, "foo") + if prob == nil { + t.Fatalf("verifyPOST did not reject JWS with alg: 'HS256'") + } + expected := "signature type 'HS256' in JWS header is not supported, expected one of RS256, ES256, ES384 or ES512" + if prob.Detail != expected { + t.Fatalf("verifyPOST rejected JWS with alg: 'none', but for wrong reason: got '%s', wanted %s", prob, expected) + } +} + +func TestCheckAlgorithm(t *testing.T) { + testCases := []struct { + key jose.JsonWebKey + jws jose.JsonWebSignature + expectedErr string + expectedStat string + }{ + { + jose.JsonWebKey{ + Algorithm: "HS256", + }, + jose.JsonWebSignature{}, + "no signature algorithms suitable for given key type", + "WFE.Errors.NoAlgorithmForKey", + }, + { + jose.JsonWebKey{ + Key: &rsa.PublicKey{}, + }, + jose.JsonWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.JoseHeader{ + Algorithm: "HS256", + }, + }, + }, + }, + "signature type 'HS256' in JWS header is not supported, expected one of RS256, ES256, ES384 or ES512", + "WFE.Errors.InvalidJWSAlgorithm", + }, + { + jose.JsonWebKey{ + Algorithm: "HS256", + Key: &rsa.PublicKey{}, + }, + jose.JsonWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.JoseHeader{ + Algorithm: "HS256", + }, + }, + }, + }, + "signature type 'HS256' in JWS header is not supported, expected one of RS256, ES256, ES384 or ES512", + "WFE.Errors.InvalidJWSAlgorithm", + }, + { + jose.JsonWebKey{ + Algorithm: "HS256", + Key: &rsa.PublicKey{}, + }, + jose.JsonWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.JoseHeader{ + Algorithm: "RS256", + }, + }, + }, + }, + "algorithm 'HS256' on JWK is unacceptable", + "WFE.Errors.InvalidAlgorithmOnKey", + }, + } + for i, tc := range testCases { + stat, err := checkAlgorithm(&tc.key, &tc.jws) + if tc.expectedErr != "" && err.Error() != tc.expectedErr { + t.Errorf("TestCheckAlgorithm %d: Expected '%s', got '%s'", i, tc.expectedErr, err) + } + if tc.expectedStat != "" && stat != tc.expectedStat { + t.Errorf("TestCheckAlgorithm %d: Expected stat '%s', got '%s'", i, tc.expectedStat, stat) + } + } +} + +func TestCheckAlgorithmSuccess(t *testing.T) { + _, err := checkAlgorithm(&jose.JsonWebKey{ + Algorithm: "RS256", + Key: &rsa.PublicKey{}, + }, &jose.JsonWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.JoseHeader{ + Algorithm: "RS256", + }, + }, + }, + }) + if err != nil { + t.Errorf("RS256 key: Expected nil error, got '%s'", err) + } + _, err = checkAlgorithm(&jose.JsonWebKey{ + Key: &rsa.PublicKey{}, + }, &jose.JsonWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.JoseHeader{ + Algorithm: "RS256", + }, + }, + }, + }) + if err != nil { + t.Errorf("RS256 key: Expected nil error, got '%s'", err) + } + + _, err = checkAlgorithm(&jose.JsonWebKey{ + Algorithm: "ES256", + Key: &ecdsa.PublicKey{ + Curve: elliptic.P256(), + }, + }, &jose.JsonWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.JoseHeader{ + Algorithm: "ES256", + }, + }, + }, + }) + if err != nil { + t.Errorf("ES256 key: Expected nil error, got '%s'", err) + } + + _, err = checkAlgorithm(&jose.JsonWebKey{ + Key: &ecdsa.PublicKey{ + Curve: elliptic.P256(), + }, + }, &jose.JsonWebSignature{ + Signatures: []jose.Signature{ + { + Header: jose.JoseHeader{ + Algorithm: "ES256", + }, + }, + }, + }) + if err != nil { + t.Errorf("ES256 key: Expected nil error, got '%s'", err) + } +} diff --git a/wfe2/probs.go b/wfe2/probs.go new file mode 100644 index 000000000..1514e4e01 --- /dev/null +++ b/wfe2/probs.go @@ -0,0 +1,80 @@ +package wfe2 + +import ( + "fmt" + "net/http" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/probs" +) + +func problemDetailsForBoulderError(err *berrors.BoulderError, msg string) *probs.ProblemDetails { + switch err.Type { + case berrors.NotSupported: + return &probs.ProblemDetails{ + Type: probs.ServerInternalProblem, + Detail: fmt.Sprintf("%s :: %s", msg, err), + HTTPStatus: http.StatusNotImplemented, + } + case berrors.Malformed: + return probs.Malformed(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.Unauthorized: + return probs.Unauthorized(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.NotFound: + return probs.NotFound(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.RateLimit: + return probs.RateLimited(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.InternalServer: + // Internal server error messages may include sensitive data, so we do + // not include it. + return probs.ServerInternal(msg) + case berrors.RejectedIdentifier: + return probs.RejectedIdentifier(fmt.Sprintf("%s :: %s", msg, err)) + case berrors.InvalidEmail: + return probs.InvalidEmail(fmt.Sprintf("%s :: %s", msg, err)) + default: + // Internal server error messages may include sensitive data, so we do + // not include it. + return probs.ServerInternal(msg) + } +} + +// problemDetailsForError turns an error into a ProblemDetails with the special +// case of returning the same error back if its already a ProblemDetails. If the +// error is of an type unknown to ProblemDetailsForError, it will return a +// ServerInternal ProblemDetails. +func problemDetailsForError(err error, msg string) *probs.ProblemDetails { + switch e := err.(type) { + case *probs.ProblemDetails: + return e + case *berrors.BoulderError: + return problemDetailsForBoulderError(e, msg) + case signatureValidationError: + return probs.Malformed(fmt.Sprintf("%s :: %s", msg, err)) + case core.MalformedRequestError: + return probs.Malformed(fmt.Sprintf("%s :: %s", msg, err)) + case core.NotSupportedError: + return &probs.ProblemDetails{ + Type: probs.ServerInternalProblem, + Detail: fmt.Sprintf("%s :: %s", msg, err), + HTTPStatus: http.StatusNotImplemented, + } + case core.UnauthorizedError: + return probs.Unauthorized(fmt.Sprintf("%s :: %s", msg, err)) + case core.NotFoundError: + return probs.NotFound(fmt.Sprintf("%s :: %s", msg, err)) + case core.LengthRequiredError: + prob := probs.Malformed("missing Content-Length header") + prob.HTTPStatus = http.StatusLengthRequired + return prob + case core.RateLimitedError: + return probs.RateLimited(fmt.Sprintf("%s :: %s", msg, err)) + case core.BadNonceError: + return probs.BadNonce(fmt.Sprintf("%s :: %s", msg, err)) + default: + // Internal server error messages may include sensitive data, so we do + // not include it. + return probs.ServerInternal(msg) + } +} diff --git a/wfe2/probs_test.go b/wfe2/probs_test.go new file mode 100644 index 000000000..0ab01d5ac --- /dev/null +++ b/wfe2/probs_test.go @@ -0,0 +1,75 @@ +package wfe2 + +import ( + "fmt" + "reflect" + "testing" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/test" +) + +func TestProblemDetailsFromError(t *testing.T) { + // errMsg is used as the msg argument for `problemDetailsForError` and is + // always returned in the problem detail. + const errMsg = "testError" + // detailMsg is used as the msg argument for the individual error types and is + // sometimes not present in the produced problem's detail. + const detailMsg = "testDetail" + // fullDetail is what we expect the problem detail to look like when it + // contains both the error message and the detail message + fullDetail := fmt.Sprintf("%s :: %s", errMsg, detailMsg) + testCases := []struct { + err error + statusCode int + problem probs.ProblemType + detail string + }{ + // boulder/core error types: + // Internal server errors expect just the `errMsg` in detail. + {core.InternalServerError(detailMsg), 500, probs.ServerInternalProblem, errMsg}, + // Other errors expect the full detail message + {core.NotSupportedError(detailMsg), 501, probs.ServerInternalProblem, fullDetail}, + {core.MalformedRequestError(detailMsg), 400, probs.MalformedProblem, fullDetail}, + {core.UnauthorizedError(detailMsg), 403, probs.UnauthorizedProblem, fullDetail}, + {core.NotFoundError(detailMsg), 404, probs.MalformedProblem, fullDetail}, + {signatureValidationError(detailMsg), 400, probs.MalformedProblem, fullDetail}, + {core.RateLimitedError(detailMsg), 429, probs.RateLimitedProblem, fullDetail}, + {core.BadNonceError(detailMsg), 400, probs.BadNonceProblem, fullDetail}, + // The content length error has its own specific detail message + {core.LengthRequiredError(detailMsg), 411, probs.MalformedProblem, "missing Content-Length header"}, + // boulder/errors error types + // Internal server errors expect just the `errMsg` in detail. + {berrors.InternalServerError(detailMsg), 500, probs.ServerInternalProblem, errMsg}, + // Other errors expect the full detail message + {berrors.NotSupportedError(detailMsg), 501, probs.ServerInternalProblem, fullDetail}, + {berrors.MalformedError(detailMsg), 400, probs.MalformedProblem, fullDetail}, + {berrors.UnauthorizedError(detailMsg), 403, probs.UnauthorizedProblem, fullDetail}, + {berrors.NotFoundError(detailMsg), 404, probs.MalformedProblem, fullDetail}, + {berrors.RateLimitError(detailMsg), 429, probs.RateLimitedProblem, fullDetail}, + {berrors.InvalidEmailError(detailMsg), 400, probs.InvalidEmailProblem, fullDetail}, + {berrors.RejectedIdentifierError(detailMsg), 400, probs.RejectedIdentifierProblem, fullDetail}, + } + for _, c := range testCases { + p := problemDetailsForError(c.err, errMsg) + if p.HTTPStatus != c.statusCode { + t.Errorf("Incorrect status code for %s. Expected %d, got %d", reflect.TypeOf(c.err).Name(), c.statusCode, p.HTTPStatus) + } + if probs.ProblemType(p.Type) != c.problem { + t.Errorf("Expected problem urn %#v, got %#v", c.problem, p.Type) + } + if p.Detail != c.detail { + t.Errorf("Expected detailed message %q, got %q", c.detail, p.Detail) + } + } + + expected := &probs.ProblemDetails{ + Type: probs.MalformedProblem, + HTTPStatus: 200, + Detail: "gotcha", + } + p := problemDetailsForError(expected, "k") + test.AssertDeepEquals(t, expected, p) +} diff --git a/wfe2/test/178.crt b/wfe2/test/178.crt new file mode 100644 index 000000000..76b28b8aa --- /dev/null +++ b/wfe2/test/178.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDETCCAfmgAwIBAgICALIwDQYJKoZIhvcNAQELBQAwDjEMMAoGA1UEAxMDMTc4 +MB4XDTE3MDIwMzAzNDcyNloXDTE4MDIwMzAzNDcyNlowDjEMMAoGA1UEAxMDMTc4 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuXUn5n4NBLK6CqJXBR+t +dM4SVY911FKAwxI13k3aQvtBjaIPe6/CDiG7ZxGDsEB96pI6yYauhDQg6ELXcPN0 +dmRX4qxVMQ/ngS7bSc7FmlN1qkq9p1AxNmesCmsWg9/4yJNCmlTdGu2Mo60Iosxx +CnQP3faG7ZPrGwzYvX9rwNedD3GlrFarQuU8VzD91fSQIzbDBtlP/+bY4FUbDtzw +WGpuAorrSOeDxC0Y3Tmd6IJLczof+vFP3EYjX+fwjnSWe75zz3z2DhVYu0tiid3k +UFDLaI5pY9JYYG3/D59lVKxg48PQP5q4qqWzmFnuUW/GOFHJABFnmOoD9j4t2YLk +GwIDAQABo3kwdzAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw +DAYDVR0TAQH/BAIwADBCBggrBgEFBQcBAQQ2MDQwMgYIKwYBBQUHMAKGJmh0dHA6 +Ly9sb2NhbGhvc3Q6NDAwMC9hY21lL2lzc3Vlci1jZXJ0MA0GCSqGSIb3DQEBCwUA +A4IBAQBCeU7UGIDKqVJ3fG0GOGlz1JHDh51UIQ2w/KK3NRlqdtlQ3tcqBHYspVMz +YjliJuiVXi/hLEd9IyaTEfxqPnpla7rYo0PgChQ/Eg+IPMJm5t3HwnuNTsvJucX+ +gCA/vGKsqSZU58JeilBVo4jl6btUc1LYMCWQ1QRfBpei/9sV0EF3f3HosqYA5I0L +VYzmsLBd8uyttFazgQKfM7Y/h1FcWGGJkH0rsZI7h4OOl0dn2aM9SCHiergJj4Sz +S6hUp2+RR70GSuZejYc7NGqk7/g624c6jJETEqJEBPy6tvxSq+DlVT1K3gWmM+Mc +yJjiZCq2Lifrn0KxkKuxsqWEW2tO +-----END CERTIFICATE----- \ No newline at end of file diff --git a/wfe2/test/178.key b/wfe2/test/178.key new file mode 100644 index 000000000..2114e3bac --- /dev/null +++ b/wfe2/test/178.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAuXUn5n4NBLK6CqJXBR+tdM4SVY911FKAwxI13k3aQvtBjaIP +e6/CDiG7ZxGDsEB96pI6yYauhDQg6ELXcPN0dmRX4qxVMQ/ngS7bSc7FmlN1qkq9 +p1AxNmesCmsWg9/4yJNCmlTdGu2Mo60IosxxCnQP3faG7ZPrGwzYvX9rwNedD3Gl +rFarQuU8VzD91fSQIzbDBtlP/+bY4FUbDtzwWGpuAorrSOeDxC0Y3Tmd6IJLczof ++vFP3EYjX+fwjnSWe75zz3z2DhVYu0tiid3kUFDLaI5pY9JYYG3/D59lVKxg48PQ +P5q4qqWzmFnuUW/GOFHJABFnmOoD9j4t2YLkGwIDAQABAoIBAQCZ5kfbNUU2Xd6X +DoqqHNSDdrKuP+Om82QY/RaoyPBT309R6mdw27Rsp79tU5J1g786FmkkbViLKvsX +4sgH2nAOA00PNLVphmo1wJ2HTUibvaCKVYW2v4xnOncBGkbP3uAECngdvEjTnMe1 +19SvzHoOE6xLJNZpdvOGOg3uizvvBVJbLg2osrJXacoulOjjpd5YCVJQT9vDKhUa +aq7CmInYfOM2flcAo7nLHWP7Jr4FX4me50lrYuzBOaJWLHcQH/mZriTgI8cXCoJx +fk0Lav38z6BgYumREa0OOGDVkNuxde5KSdcFUUEEfvtPSnruVwdNHja3z3d4Y1Bx +ca1khx4JAoGBAMMFbbFRQwtxTED75dr8qXZE8kJNmIvPC1og3zRjsN0aVaUltRFl +pj+/HZXOAxU+uc4ac7vzD/5ysSHhp2rrzVglBveSlM7U88KcEYI+Yu2KzM12UMkT +lIWQDtfIpnvXPwnMsde9JzQvXeeyEhy6IBDwtRg3UaNdday/+V9bKUznAoGBAPNy +NPsFtdQuT0FU8W3ehPw7dkBZl8YGy3YQxMh8IcThx4NRJHkK0yTV3/zg8owW6WvN +EPhEIWQ4u9szf4zPoCHbckEScLeDYlc/hyf2hmQdlYQTyZXE+nou1VYawlv9mJJ1 +88Sct0ygmVcCcdCsi68abOijs+TJrGsI+cjKzTStAoGAd45vZeIMeQpXFguXKT31 +4aR44/7QAv3F1tYKIALxnUqUsK7CJ00qsy/Fwl3OdArFO73pr5Jd/r5vKvc8fIbc +lynz8HhzM61HVsn4zeDTIw8RaPAcrHiNd6gOAWln7snRQn+zky/Jxes35V+8TNIp +8FiwnIzlRoJ4LpRuG3A2jIcCgYEAqRroGoa4647Plv4+RqePkPZtCf4yI2iM5JJ5 +Xxp7CpwbTuiKgVo3mRrH4I0RbqZrtmpYI1yQJWITfAylyVZgUaRyFSmOCqvFH/4N +EIF6kQjL11c3bEXMCBuILaug3u2lkfdFQYnq+duFKJ+WF/IDhbrBdEhiqcY9coxl +lkjpinECgYEAkZO0XS0Z7KfFgSMQ2uFK8MO7naQM5kg+8H26HPuNWRmlkRDR7tOI +gQcwSbx7vqT5JJM/bSWI49b2Q19QncFJ0A8/P3/3dncFQ8QMsfMVzmhHvcn2SthU +Eh0aoOwi7rPYNiCTd/3y04x42a/hmo8rmcXOodZvnewOFbDu/s/m7ig= +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/wfe2/test/238.crt b/wfe2/test/238.crt new file mode 100644 index 000000000..275dc5ce1 --- /dev/null +++ b/wfe2/test/238.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgICAO4wDQYJKoZIhvcNAQELBQAwDjEMMAoGA1UEAwwDMjM4 +MB4XDTE1MDYxMzAwMTU1NVoXDTE2MDYxMjAwMTU1NVowDjEMMAoGA1UEAwwDMjM4 +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvP9z1YFDa1WD9hVI9W3K +lWQmUGfLW35x6xkgDm8o3OTWR2QoxjXratacKhm2VevV22QjCBvHXeHx3fxSp5w/ +p4CH+Ul76wCq3+WAPidO42YCP7SZdqYUR4GHKQ/oOyistRAKEamg4aPAbIs7l1Kn +T5YHFdSzCWpe6F2+ceoluvKEn6vFVloXKghaeEyTDKnnJKs3/04TdtZjVM5OObvQ +CGFlQlysDJxWahtVM93gylB8WYgyiekDAx1I3lCd3Vv0hF+x04xT3fwVRzmaKNzT +wN+znae643Qfg2oSSLV066K2WYepgzqKwv3IUdrLbes331AMs+FbdxHanMrOU1i+ +OQIDAQABo28wbTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSOiDuzx4mEC9Io +y+7rEdnE+eeOyDAfBgNVHSMEGDAWgBSOiDuzx4mEC9Ioy+7rEdnE+eeOyDAaBgNV +HREEEzARgg9iYWQuZXhhbXBsZS5jb20wDQYJKoZIhvcNAQELBQADggEBAJQA/7+n +S9AiB9YduVEs2TB7+62N59yACxd1y5qnmSLEeI9yXZnqQGugNxw7cl3CgFDWLNxB +8Q3hH5B0fYh2Ydqf8lrEYNH3ilsmqCQB3mHUlYtLLnVarzSPrFgxaBrRaGsAAaVd +neC5QCaxLFzzQI9gmyp6n7T2CATOk94vrrZJmfzpCMMRPHY7XgM15HDefXeH1+/Z +GESSM/YAD6rdojZVLwxTuzVVRm5+6NfnFG938SYir0aqYvFd0bxrdgTl1XR3sAip +iwuI3ku943Thbmyp/fEBUE2unvf+wbX+3Vzq52NadPcUrsNwJAR/kGdmTzcsiCIA +UL+BLF470rQo29w= +-----END CERTIFICATE----- diff --git a/wfe2/test/238.key b/wfe2/test/238.key new file mode 100644 index 000000000..4bff06b68 --- /dev/null +++ b/wfe2/test/238.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC8/3PVgUNrVYP2 +FUj1bcqVZCZQZ8tbfnHrGSAObyjc5NZHZCjGNetq1pwqGbZV69XbZCMIG8dd4fHd +/FKnnD+ngIf5SXvrAKrf5YA+J07jZgI/tJl2phRHgYcpD+g7KKy1EAoRqaDho8Bs +izuXUqdPlgcV1LMJal7oXb5x6iW68oSfq8VWWhcqCFp4TJMMqeckqzf/ThN21mNU +zk45u9AIYWVCXKwMnFZqG1Uz3eDKUHxZiDKJ6QMDHUjeUJ3dW/SEX7HTjFPd/BVH +OZoo3NPA37Odp7rjdB+DahJItXTrorZZh6mDOorC/chR2stt6zffUAyz4Vt3Edqc +ys5TWL45AgMBAAECggEAc1PSJCt/r2R8ZNJyNclsQCLfulrL3aXX7+TiCczM+5Xs +J543v1Oxtv0ESDBuchm54ulE8zK4QlKYm6PX8A1JTnYBAx5TLoC2xG8wBT1JRzu9 +DZCvwJXxc/zXNDhPtqHIWahS7Jo84NNinRmNIHbAP7FF241yPsGY7mQdzTdbFKrR +JH0l7VPCY4OG+CjxUJqoNuwkfrNh0hRh02IHU/rFlgR2Q7JP0XBwuufW1M6j7fYM +7PGZRA+6Ry72UcaCEVuOtGlz3wLrFq6CGTGWlUehQqch+nrTri0jMSH4Bd83mLz2 +8+X0y/EONQlirbHbJxXq+mLASHrp3KCtdpCiLKcX8QKBgQDr+TNqLa7PIOhlw29A +RftunKwEdsi9uAg3jFSpHC/jLxR4/fUiz2XZrAfHNxn7mOK72V/9pj9zshLnxeSm +jEelEB2bABX8RhD38SUxoHoiWmqpPVOtBSXvMSQEO0F/1hGlxndHwe9mE2Zyq3eV +9MoJVeExkCP3Bxk9tjZfj4WC9QKBgQDNCab2WjLy7T9Bfmh2RmWXckzUMphYCLpX +CGG2O5nH2zOPAOxUpyLFDq3/WkzPnCdWOveI/LlZmkcjdslWp3tizk5kE1zgaFbO +s+7o/cYVrU5J3+kIq563ba7/xZ7wpfkg58milUWStpjQrB0H5tSlUEoC7fJ/GjHd +5j1raKQrtQKBgF9elSgJlIgD/cj7JqBsaET5LxCSzWjX0wJYRfMfAD+qTHTl9sf9 +2GUUAQTDwU2NKb3QCdqi8SwaQUfJFDM3qNEOZVi6vSf7TWpX3Ldk61etAUSrE4Fu +/jjgvHS1WjCHXRSJ1LV8rPutRY98u1Uw3OLPAbedUNvK06m8VddjUwttAoGAAmca +jciA0Ff3Zc0VbE1m419zhwkQv/daN6rhekE4jB8Fe6eHHXbX8Xc6ksN8IvKxg1Et +lW1gvqwQKVo7Acj0qTPBt2qCrB6M5d817YULzTU6taLqGC/qrDuc0WJ/elJ3mOse +cclOB2ocYFWkAXOzCjzmoSIotVSZQQBxt9CCHAECgYEA01w8tKVCG2ucbC1GoCl0 +t2MRmLqiRqRrn53fJ6j56fDbdLmnRAaaD1slZ0jpLk7JoDKGmNG2Rl9UXuydPaNZ +8h1Lu+CnhG50uOF3A/OIXsBiRsAgI2ez4/Jb+lNe3l3UcPV5gyGejAiymqRigbkn +bcixOm4jdOWV5Bpfv65AivQ= +-----END PRIVATE KEY----- diff --git a/wfe2/test/not-an-example.com.crt b/wfe2/test/not-an-example.com.crt new file mode 100644 index 000000000..5c459194c --- /dev/null +++ b/wfe2/test/not-an-example.com.crt @@ -0,0 +1,29 @@ +Produced by: +js test.js --agree --email jsha@newview.org --domains not-an-example.com --certFile cert.der --certKey ../../wfe/test/178.key +openssl x509 -text -inform der -in cert.der -outform pem -out ../../wfe/test/not-an-example.com.crt +-----BEGIN CERTIFICATE----- +MIIEYzCCA0ugAwIBAgIRAP8AAAAAAAAOS09n2G6BjEYwDQYJKoZIhvcNAQELBQAw +HzEdMBsGA1UEAwwUaGFwcHkgaGFja2VyIGZha2UgQ0EwHhcNMTUwOTA5MjI1NjAw +WhcNMTUxMjA4MjI1NjAwWjAdMRswGQYDVQQDExJub3QtYW4tZXhhbXBsZS5jb20w +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCaqzue57mgXEoGTZZoVkkC +ZraebWgXI8irX2BgQB1A3iZa9onxGPMcWQMxhSuUisbEJi4UkMcVST12HX01rUwh +j41UuBxJvI1w4wvdstssTAaa9c9tsQ5+UED2bFRL1MsyBdbmCF/+pu3i+ZIYqWgi +KbjVBe3nlAVbo77zizwp3Y4Tp1/TBOwTAuFkHePmkNT63uPm9My/hNzsSm1o+Q51 +9Cf7ry+JQmOVgz/jIgFVGFYJ17EV3KUIpUuDShuyCFATBQspgJSN2DoXRUlQjXXk +NTj23OxxdT/cVLcLJjytyG6e5izME2R2aCkDBWIc1a4/sRJ0R396auPXG6KhJ7o/ +AgMBAAGjggGaMIIBljAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUH +AwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFBGkJtghSl97/bTR +dyy0TaneZhnDMB8GA1UdIwQYMBaAFPt4TxL5YBWDLJ8XfzQZsy426kGJMGoGCCsG +AQUFBwEBBF4wXDAmBggrBgEFBQcwAYYaaHR0cDovL2xvY2FsaG9zdDo0MDAyL29j +c3AwMgYIKwYBBQUHMAKGJmh0dHA6Ly9sb2NhbGhvc3Q6NDAwMC9hY21lL2lzc3Vl +ci1jZXJ0MB0GA1UdEQQWMBSCEm5vdC1hbi1leGFtcGxlLmNvbTAnBgNVHR8EIDAe +MBygGqAYhhZodHRwOi8vZXhhbXBsZS5jb20vY3JsMGMGA1UdIARcMFowCgYGZ4EM +AQIBMAAwTAYDKgMEMEUwIgYIKwYBBQUHAgEWFmh0dHA6Ly9leGFtcGxlLmNvbS9j +cHMwHwYIKwYBBQUHAgIwEwwRRG8gV2hhdCBUaG91IFdpbHQwDQYJKoZIhvcNAQEL +BQADggEBAJTSscrGO1ymwZ+rMF+mfVeHfplfyMzZ/6SZyvaYgO9DLr42KIETdHBg +Y9AZ6aOKboN/hY98kb9mQ0BpOCsSaCkgTsqCjw3szsRd/FMgUSVn36vFpbX2f5oD +gF40N/51EN5Efbe7aN4Oxmcgijh4IY2sczcskJixAd9T/hjVtv160LJ0xcHRrfji +u/Tc2E0q+E5k4V91D2HajwU6qcGbap02JI+pX/Oq4S36yfggIUyowmXQw4nm1cb0 +cFXwrMzg+XtDHj+Ex+yBlauq+MP1rjXiHrNIO2hIiyRU9jdxfITAE4DmqEzEBZKY +NORfB6suv4wLnAlsLbPJEdsraq4/IiU= +-----END CERTIFICATE----- diff --git a/wfe2/wfe.go b/wfe2/wfe.go new file mode 100644 index 000000000..8ee58bd54 --- /dev/null +++ b/wfe2/wfe.go @@ -0,0 +1,1599 @@ +package wfe2 + +import ( + "bytes" + "crypto/x509" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/jmhodges/clock" + "golang.org/x/net/context" + jose "gopkg.in/square/go-jose.v1" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/metrics/measured_http" + "github.com/letsencrypt/boulder/nonce" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/revocation" +) + +// Paths are the ACME-spec identified URL path-segments for various methods. +// NOTE: In metrics/measured_http we make the assumption that these are all +// lowercase plus hyphens. If you violate that assumption you should update +// measured_http. +const ( + directoryPath = "/directory" + newRegPath = "/acme/new-reg" + regPath = "/acme/reg/" + newAuthzPath = "/acme/new-authz" + authzPath = "/acme/authz/" + challengePath = "/acme/challenge/" + newCertPath = "/acme/new-cert" + certPath = "/acme/cert/" + revokeCertPath = "/acme/revoke-cert" + termsPath = "/terms" + issuerPath = "/acme/issuer-cert" + buildIDPath = "/build" + rolloverPath = "/acme/key-change" +) + +// WebFrontEndImpl provides all the logic for Boulder's web-facing interface, +// i.e., ACME. Its members configure the paths for various ACME functions, +// plus a few other data items used in ACME. Its methods are primarily handlers +// for HTTPS requests for the various ACME functions. +type WebFrontEndImpl struct { + RA core.RegistrationAuthority + SA core.StorageGetter + stats metrics.Scope + log blog.Logger + clk clock.Clock + + // URL configuration parameters + BaseURL string + + // Issuer certificate (DER) for /acme/issuer-cert + IssuerCert []byte + + // URL to the current subscriber agreement (should contain some version identifier) + SubscriberAgreementURL string + + // Register of anti-replay nonces + nonceService *nonce.NonceService + + // Key policy. + keyPolicy goodkey.KeyPolicy + + // Cache settings + CertCacheDuration time.Duration + CertNoCacheExpirationWindow time.Duration + IndexCacheDuration time.Duration + IssuerCacheDuration time.Duration + + // CORS settings + AllowOrigins []string + + // Maximum duration of a request + RequestTimeout time.Duration + + AcceptRevocationReason bool + AllowAuthzDeactivation bool +} + +// signatureValidationError indicates that the user's signature could not +// be verified, either through adversarial activity, or misconfiguration of +// the user client. +type signatureValidationError string + +func (e signatureValidationError) Error() string { return string(e) } + +// NewWebFrontEndImpl constructs a web service for Boulder +func NewWebFrontEndImpl( + stats metrics.Scope, + clk clock.Clock, + keyPolicy goodkey.KeyPolicy, + logger blog.Logger, +) (WebFrontEndImpl, error) { + nonceService, err := nonce.NewNonceService(stats) + if err != nil { + return WebFrontEndImpl{}, err + } + + return WebFrontEndImpl{ + log: logger, + clk: clk, + nonceService: nonceService, + stats: stats, + keyPolicy: keyPolicy, + }, nil +} + +// HandleFunc registers a handler at the given path. It's +// http.HandleFunc(), but with a wrapper around the handler that +// provides some generic per-request functionality: +// +// * Set a Replay-Nonce header. +// +// * Respond to OPTIONS requests, including CORS preflight requests. +// +// * Set a no cache header +// +// * Respond http.StatusMethodNotAllowed for HTTP methods other than +// those listed. +// +// * Set CORS headers when responding to CORS "actual" requests. +// +// * Never send a body in response to a HEAD request. Anything +// written by the handler will be discarded if the method is HEAD. +// Also, all handlers that accept GET automatically accept HEAD. +func (wfe *WebFrontEndImpl) HandleFunc(mux *http.ServeMux, pattern string, h wfeHandlerFunc, methods ...string) { + methodsMap := make(map[string]bool) + for _, m := range methods { + methodsMap[m] = true + } + if methodsMap["GET"] && !methodsMap["HEAD"] { + // Allow HEAD for any resource that allows GET + methods = append(methods, "HEAD") + methodsMap["HEAD"] = true + } + methodsStr := strings.Join(methods, ", ") + handler := http.StripPrefix(pattern, &topHandler{ + log: wfe.log, + clk: clock.Default(), + wfe: wfeHandlerFunc(func(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + // We do not propagate errors here, because (1) they should be + // transient, and (2) they fail closed. + nonce, err := wfe.nonceService.Nonce() + if err == nil { + response.Header().Set("Replay-Nonce", nonce) + logEvent.ResponseNonce = nonce + } else { + logEvent.AddError("unable to make nonce: %s", err) + } + + logEvent.Endpoint = pattern + if request.URL != nil { + logEvent.Endpoint = path.Join(logEvent.Endpoint, request.URL.Path) + } + + switch request.Method { + case "HEAD": + // Go's net/http (and httptest) servers will strip out the body + // of responses for us. This keeps the Content-Length for HEAD + // requests as the same as GET requests per the spec. + case "OPTIONS": + wfe.Options(response, request, methodsStr, methodsMap) + return + } + + // No cache header is set for all requests, succeed or fail. + addNoCacheHeader(response) + + if !methodsMap[request.Method] { + response.Header().Set("Allow", methodsStr) + wfe.sendError(response, logEvent, probs.MethodNotAllowed(), nil) + return + } + + wfe.setCORSHeaders(response, request, "") + + timeout := wfe.RequestTimeout + if timeout == 0 { + timeout = 5 * time.Minute + } + ctx, cancel := context.WithTimeout(ctx, timeout) + // TODO(riking): add request context using WithValue + + // Call the wrapped handler. + h(ctx, logEvent, response, request) + cancel() + }), + }) + mux.Handle(pattern, handler) +} + +func marshalIndent(v interface{}) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} + +func (wfe *WebFrontEndImpl) writeJsonResponse(response http.ResponseWriter, logEvent *requestEvent, status int, v interface{}) error { + jsonReply, err := marshalIndent(v) + if err != nil { + return err // All callers are responsible for handling this error + } + + response.Header().Set("Content-Type", "application/json") + response.WriteHeader(status) + _, err = response.Write(jsonReply) + if err != nil { + // Don't worry about returning this error because the caller will + // never handle it. + wfe.log.Warning(fmt.Sprintf("Could not write response: %s", err)) + logEvent.AddError(fmt.Sprintf("failed to write response: %s", err)) + } + return nil +} + +func (wfe *WebFrontEndImpl) relativeEndpoint(request *http.Request, endpoint string) string { + var result string + proto := "http" + host := request.Host + + // If the request was received via TLS, use `https://` for the protocol + if request.TLS != nil { + proto = "https" + } + + // Allow upstream proxies to specify the forwarded protocol. Allow this value + // to override our own guess. + if specifiedProto := request.Header.Get("X-Forwarded-Proto"); specifiedProto != "" { + proto = specifiedProto + } + + // Default to "localhost" when no request.Host is provided. Otherwise requests + // with an empty `Host` produce results like `http:///acme/new-authz` + if request.Host == "" { + host = "localhost" + } + + if wfe.BaseURL != "" { + result = fmt.Sprintf("%s%s", wfe.BaseURL, endpoint) + } else { + resultUrl := url.URL{Scheme: proto, Host: host, Path: endpoint} + result = resultUrl.String() + } + + return result +} + +const randomDirKeyExplanationLink = "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417" + +func (wfe *WebFrontEndImpl) relativeDirectory(request *http.Request, directory map[string]interface{}) ([]byte, error) { + // Create an empty map sized equal to the provided directory to store the + // relative-ized result + relativeDir := make(map[string]interface{}, len(directory)) + + // Copy each entry of the provided directory into the new relative map. If + // `wfe.BaseURL` != "", use the old behaviour and prefix each endpoint with + // the `BaseURL`. Otherwise, prefix each endpoint using the request protocol + // & host. + for k, v := range directory { + if features.Enabled(features.RandomDirectoryEntry) && v == randomDirKeyExplanationLink { + relativeDir[k] = v + continue + } + switch v := v.(type) { + case string: + // Only relative-ize top level string values, e.g. not the "meta" element + relativeDir[k] = wfe.relativeEndpoint(request, v) + default: + // If it isn't a string, put it into the results unmodified + relativeDir[k] = v + } + } + + directoryJSON, err := marshalIndent(relativeDir) + // This should never happen since we are just marshalling known strings + if err != nil { + return nil, err + } + + return directoryJSON, nil +} + +// Handler returns an http.Handler that uses various functions for +// various ACME-specified paths. +func (wfe *WebFrontEndImpl) Handler() http.Handler { + m := http.NewServeMux() + wfe.HandleFunc(m, directoryPath, wfe.Directory, "GET") + wfe.HandleFunc(m, newRegPath, wfe.NewRegistration, "POST") + wfe.HandleFunc(m, newAuthzPath, wfe.NewAuthorization, "POST") + wfe.HandleFunc(m, newCertPath, wfe.NewCertificate, "POST") + wfe.HandleFunc(m, regPath, wfe.Registration, "POST") + wfe.HandleFunc(m, authzPath, wfe.Authorization, "GET", "POST") + wfe.HandleFunc(m, challengePath, wfe.Challenge, "GET", "POST") + wfe.HandleFunc(m, certPath, wfe.Certificate, "GET") + wfe.HandleFunc(m, revokeCertPath, wfe.RevokeCertificate, "POST") + wfe.HandleFunc(m, termsPath, wfe.Terms, "GET") + wfe.HandleFunc(m, issuerPath, wfe.Issuer, "GET") + wfe.HandleFunc(m, buildIDPath, wfe.BuildID, "GET") + if features.Enabled(features.AllowKeyRollover) { + wfe.HandleFunc(m, rolloverPath, wfe.KeyRollover, "POST") + } + // We don't use our special HandleFunc for "/" because it matches everything, + // meaning we can wind up returning 405 when we mean to return 404. See + // https://github.com/letsencrypt/boulder/issues/717 + m.Handle("/", &topHandler{ + log: wfe.log, + clk: clock.Default(), + wfe: wfeHandlerFunc(wfe.Index), + }) + return measured_http.New(m, wfe.clk) +} + +// Method implementations + +// Index serves a simple identification page. It is not part of the ACME spec. +func (wfe *WebFrontEndImpl) Index(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + // http://golang.org/pkg/net/http/#example_ServeMux_Handle + // The "/" pattern matches everything, so we need to check + // that we're at the root here. + if request.URL.Path != "/" { + logEvent.AddError("Resource not found") + http.NotFound(response, request) + response.Header().Set("Content-Type", "application/problem+json") + return + } + + if request.Method != "GET" { + logEvent.AddError("Bad method") + response.Header().Set("Allow", "GET") + response.WriteHeader(http.StatusMethodNotAllowed) + return + } + + addNoCacheHeader(response) + response.Header().Set("Content-Type", "text/html") + response.Write([]byte(fmt.Sprintf(` + + This is an ACME + Certificate Authority running Boulder. + JSON directory is available at %s. + + + `, directoryPath, directoryPath))) +} + +func addNoCacheHeader(w http.ResponseWriter) { + w.Header().Add("Cache-Control", "public, max-age=0, no-cache") +} + +func addRequesterHeader(w http.ResponseWriter, requester int64) { + if requester > 0 { + w.Header().Set("Boulder-Requester", fmt.Sprintf("%d", requester)) + } +} + +// Directory is an HTTP request handler that provides the directory +// object stored in the WFE's DirectoryEndpoints member with paths prefixed +// using the `request.Host` of the HTTP request. +func (wfe *WebFrontEndImpl) Directory(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + directoryEndpoints := map[string]interface{}{ + "new-reg": newRegPath, + "new-authz": newAuthzPath, + "new-cert": newCertPath, + "revoke-cert": revokeCertPath, + } + + // Versions of Certbot pre-0.6.0 (named LetsEncryptPythonClient at the time) break when they + // encounter a directory containing elements they don't expect so we gate + // adding new directory fields for clients matching this UA. + clientDirChangeIntolerant := strings.HasPrefix(request.UserAgent(), "LetsEncryptPythonClient") + if features.Enabled(features.AllowKeyRollover) && !clientDirChangeIntolerant { + directoryEndpoints["key-change"] = rolloverPath + } + if features.Enabled(features.RandomDirectoryEntry) && !clientDirChangeIntolerant { + // Add a random key to the directory in order to make sure that clients don't hardcode an + // expected set of keys. This ensures that we can properly extend the directory when we + // need to add a new endpoint or meta element. + directoryEndpoints[core.RandomString(8)] = randomDirKeyExplanationLink + } + if features.Enabled(features.DirectoryMeta) && !clientDirChangeIntolerant { + // ACME since draft-02 describes an optional "meta" directory entry. The + // meta entry may optionally contain a "terms-of-service" URI for the + // current ToS. + directoryEndpoints["meta"] = map[string]string{ + "terms-of-service": wfe.SubscriberAgreementURL, + } + } + + response.Header().Set("Content-Type", "application/json") + + relDir, err := wfe.relativeDirectory(request, directoryEndpoints) + if err != nil { + marshalProb := probs.ServerInternal("unable to marshal JSON directory") + wfe.sendError(response, logEvent, marshalProb, nil) + return + } + + response.Write(relDir) +} + +const ( + unknownKey = "No registration exists matching provided key" +) + +func (wfe *WebFrontEndImpl) extractJWSKey(body string) (*jose.JsonWebKey, *jose.JsonWebSignature, error) { + parsedJws, err := jose.ParseSigned(body) + if err != nil { + wfe.stats.Inc("Errors.UnableToParseJWS", 1) + return nil, nil, errors.New("Parse error reading JWS") + } + + if len(parsedJws.Signatures) > 1 { + wfe.stats.Inc("Errors.TooManyJWSSignaturesInPOST", 1) + return nil, nil, errors.New("Too many signatures in POST body") + } + if len(parsedJws.Signatures) == 0 { + wfe.stats.Inc("Errors.JWSNotSignedInPOST", 1) + return nil, nil, errors.New("POST JWS not signed") + } + + key := parsedJws.Signatures[0].Header.JsonWebKey + if key == nil { + wfe.stats.Inc("Errors.NoJWKInJWSSignatureHeader", 1) + return nil, nil, errors.New("No JWK in JWS header") + } + + if !key.Valid() { + wfe.stats.Inc("Errors.InvalidJWK", 1) + return nil, nil, errors.New("Invalid JWK in JWS header") + } + + return key, parsedJws, nil +} + +// verifyPOST reads and parses the request body, looks up the Registration +// corresponding to its JWK, verifies the JWS signature, checks that the +// resource field is present and correct in the JWS protected header, and +// returns the JWS payload bytes, the key used to verify, and the corresponding +// Registration (or error). If regCheck is false, verifyPOST will still try to +// look up a registration object, and will return it if found. However, if no +// registration object is found, verifyPOST will attempt to verify the JWS using +// the key in the JWS headers, and return the key plus a dummy registration if +// successful. If a caller passes regCheck = false, it should plan on validating +// the key itself. verifyPOST also appends its errors to requestEvent.Errors so +// code calling it does not need to if they immediately return a response to the +// user. +func (wfe *WebFrontEndImpl) verifyPOST(ctx context.Context, logEvent *requestEvent, request *http.Request, regCheck bool, resource core.AcmeResource) ([]byte, *jose.JsonWebKey, core.Registration, *probs.ProblemDetails) { + // TODO: We should return a pointer to a registration, which can be nil, + // rather the a registration value with a sentinel value. + // https://github.com/letsencrypt/boulder/issues/877 + reg := core.Registration{ID: 0} + + if _, ok := request.Header["Content-Length"]; !ok { + wfe.stats.Inc("HTTP.ClientErrors.LengthRequiredError", 1) + logEvent.AddError("missing Content-Length header on POST") + return nil, nil, reg, probs.ContentLengthRequired() + } + + // Read body + if request.Body == nil { + wfe.stats.Inc("Errors.NoPOSTBody", 1) + logEvent.AddError("no body on POST") + return nil, nil, reg, probs.Malformed("No body on POST") + } + + bodyBytes, err := ioutil.ReadAll(request.Body) + if err != nil { + wfe.stats.Inc("Errors.UnableToReadRequestBody", 1) + logEvent.AddError("unable to read request body") + return nil, nil, reg, probs.ServerInternal("unable to read request body") + } + + body := string(bodyBytes) + + // Verify JWS + // NOTE: It might seem insecure for the WFE to be trusted to verify + // client requests, i.e., that the verification should be done at the + // RA. However the WFE is the RA's only view of the outside world + // *anyway*, so it could always lie about what key was used by faking + // the signature itself. + submittedKey, parsedJws, err := wfe.extractJWSKey(body) + if err != nil { + logEvent.AddError(err.Error()) + return nil, nil, reg, probs.Malformed(err.Error()) + } + + var key *jose.JsonWebKey + reg, err = wfe.SA.GetRegistrationByKey(ctx, submittedKey) + // Special case: If no registration was found, but regCheck is false, use an + // empty registration and the submitted key. The caller is expected to do some + // validation on the returned key. + if berrors.Is(err, berrors.NotFound) && !regCheck { + // When looking up keys from the registrations DB, we can be confident they + // are "good". But when we are verifying against any submitted key, we want + // to check its quality before doing the verify. + if err = wfe.keyPolicy.GoodKey(submittedKey.Key); err != nil { + wfe.stats.Inc("Errors.JWKRejectedByGoodKey", 1) + logEvent.AddError("JWK in request was rejected by GoodKey: %s", err) + return nil, nil, reg, probs.Malformed(err.Error()) + } + key = submittedKey + } else if err != nil { + // For all other errors, or if regCheck is true, return error immediately. + wfe.stats.Inc("Errors.UnableToGetRegistrationByKey", 1) + logEvent.AddError("unable to fetch registration by the given JWK: %s", err) + if berrors.Is(err, berrors.NotFound) { + return nil, nil, reg, probs.Unauthorized(unknownKey) + } + + return nil, nil, reg, probs.ServerInternal("Failed to get registration by key") + } else { + // If the lookup was successful, use that key. + key = reg.Key + logEvent.Requester = reg.ID + logEvent.Contacts = reg.Contact + } + + // Only check for validity if we are actually checking the registration + if regCheck && features.Enabled(features.AllowAccountDeactivation) && reg.Status != core.StatusValid { + return nil, nil, reg, probs.Unauthorized(fmt.Sprintf("Registration is not valid, has status '%s'", reg.Status)) + } + + if statName, err := checkAlgorithm(key, parsedJws); err != nil { + wfe.stats.Inc(statName, 1) + return nil, nil, reg, probs.Malformed(err.Error()) + } + + payload, err := parsedJws.Verify(key) + if err != nil { + wfe.stats.Inc("Errors.JWSVerificationFailed", 1) + n := len(body) + if n > 100 { + n = 100 + } + logEvent.AddError("verification of JWS with the JWK failed: %v; body: %s", err, body[:n]) + return nil, nil, reg, probs.Malformed("JWS verification error") + } + logEvent.Payload = string(payload) + + // Check that the request has a known anti-replay nonce + nonce := parsedJws.Signatures[0].Header.Nonce + logEvent.RequestNonce = nonce + if len(nonce) == 0 { + wfe.stats.Inc("Errors.JWSMissingNonce", 1) + logEvent.AddError("JWS is missing an anti-replay nonce") + return nil, nil, reg, probs.BadNonce("JWS has no anti-replay nonce") + } else if !wfe.nonceService.Valid(nonce) { + wfe.stats.Inc("Errors.JWSInvalidNonce", 1) + logEvent.AddError("JWS has an invalid anti-replay nonce: %s", nonce) + return nil, nil, reg, probs.BadNonce(fmt.Sprintf("JWS has invalid anti-replay nonce %v", nonce)) + } + + // Check that the "resource" field is present and has the correct value + var parsedRequest struct { + Resource string `json:"resource"` + } + err = json.Unmarshal([]byte(payload), &parsedRequest) + if err != nil { + wfe.stats.Inc("Errors.UnparseableJWSPayload", 1) + logEvent.AddError("unable to JSON parse resource from JWS payload: %s", err) + return nil, nil, reg, probs.Malformed("Request payload did not parse as JSON") + } + if parsedRequest.Resource == "" { + wfe.stats.Inc("Errors.NoResourceInJWSPayload", 1) + logEvent.AddError("JWS request payload does not specify a resource") + return nil, nil, reg, probs.Malformed("Request payload does not specify a resource") + } else if resource != core.AcmeResource(parsedRequest.Resource) { + wfe.stats.Inc("Errors.MismatchedResourceInJWSPayload", 1) + logEvent.AddError("JWS request payload does not match resource") + return nil, nil, reg, probs.Malformed("JWS resource payload does not match the HTTP resource: %s != %s", parsedRequest.Resource, resource) + } + + return []byte(payload), key, reg, nil +} + +// sendError sends an error response represented by the given ProblemDetails, +// and, if the ProblemDetails.Type is ServerInternalProblem, audit logs the +// internal ierr. +func (wfe *WebFrontEndImpl) sendError(response http.ResponseWriter, logEvent *requestEvent, prob *probs.ProblemDetails, ierr error) { + code := probs.ProblemDetailsToStatusCode(prob) + + // Record details to the log event + logEvent.AddError(fmt.Sprintf("%d :: %s :: %s", prob.HTTPStatus, prob.Type, prob.Detail)) + + // Only audit log internal errors so users cannot purposefully cause + // auditable events. + if prob.Type == probs.ServerInternalProblem { + if ierr != nil { + wfe.log.AuditErr(fmt.Sprintf("Internal error - %s - %s", prob.Detail, ierr)) + } else { + wfe.log.AuditErr(fmt.Sprintf("Internal error - %s", prob.Detail)) + } + } + + problemDoc, err := marshalIndent(prob) + if err != nil { + wfe.log.AuditErr(fmt.Sprintf("Could not marshal error message: %s - %+v", err, prob)) + problemDoc = []byte("{\"detail\": \"Problem marshalling error message.\"}") + } + + // Paraphrased from + // https://golang.org/src/net/http/server.go#L1272 + response.Header().Set("Content-Type", "application/problem+json") + response.WriteHeader(code) + response.Write(problemDoc) + + problemSegments := strings.Split(string(prob.Type), ":") + if len(problemSegments) > 0 { + wfe.stats.Inc(fmt.Sprintf("HTTP.ProblemTypes.%s", problemSegments[len(problemSegments)-1]), 1) + } +} + +func link(url, relation string) string { + return fmt.Sprintf("<%s>;rel=\"%s\"", url, relation) +} + +// NewRegistration is used by clients to submit a new registration/account +func (wfe *WebFrontEndImpl) NewRegistration(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + + body, key, _, prob := wfe.verifyPOST(ctx, logEvent, request, false, core.ResourceNewReg) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // verifyPOST handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + + if existingReg, err := wfe.SA.GetRegistrationByKey(ctx, key); err == nil { + response.Header().Set("Location", wfe.relativeEndpoint(request, fmt.Sprintf("%s%d", regPath, existingReg.ID))) + // TODO(#595): check for missing registration err + wfe.sendError(response, logEvent, probs.Conflict("Registration key is already in use"), err) + return + } + + var init core.Registration + err := json.Unmarshal(body, &init) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling JSON"), err) + return + } + if len(init.Agreement) > 0 && init.Agreement != wfe.SubscriberAgreementURL { + msg := fmt.Sprintf("Provided agreement URL [%s] does not match current agreement URL [%s]", init.Agreement, wfe.SubscriberAgreementURL) + wfe.sendError(response, logEvent, probs.Malformed(msg), nil) + return + } + init.Key = key + init.InitialIP = net.ParseIP(request.Header.Get("X-Real-IP")) + if init.InitialIP == nil { + host, _, err := net.SplitHostPort(request.RemoteAddr) + if err == nil { + init.InitialIP = net.ParseIP(host) + } else { + logEvent.AddError("Couldn't parse RemoteAddr: %s", request.RemoteAddr) + wfe.sendError(response, logEvent, probs.ServerInternal("couldn't parse the remote (that is, the client's) address"), nil) + return + } + } + + reg, err := wfe.RA.NewRegistration(ctx, init) + if err != nil { + logEvent.AddError("unable to create new registration: %s", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Error creating new registration"), err) + return + } + logEvent.Requester = reg.ID + addRequesterHeader(response, reg.ID) + logEvent.Contacts = reg.Contact + + // Use an explicitly typed variable. Otherwise `go vet' incorrectly complains + // that reg.ID is a string being passed to %d. + regURL := wfe.relativeEndpoint(request, fmt.Sprintf("%s%d", regPath, reg.ID)) + + response.Header().Add("Location", regURL) + response.Header().Add("Link", link(wfe.relativeEndpoint(request, newAuthzPath), "next")) + if len(wfe.SubscriberAgreementURL) > 0 { + response.Header().Add("Link", link(wfe.SubscriberAgreementURL, "terms-of-service")) + } + + err = wfe.writeJsonResponse(response, logEvent, http.StatusCreated, reg) + if err != nil { + // ServerInternal because we just created this registration, and it + // should be OK. + logEvent.AddError("unable to marshal registration: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling registration"), err) + return + } +} + +// NewAuthorization is used by clients to submit a new ID Authorization +func (wfe *WebFrontEndImpl) NewAuthorization(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + body, _, currReg, prob := wfe.verifyPOST(ctx, logEvent, request, true, core.ResourceNewAuthz) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // verifyPOST handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + // Any version of the agreement is acceptable here. Version match is enforced in + // wfe.Registration when agreeing the first time. Agreement updates happen + // by mailing subscribers and don't require a registration update. + if currReg.Agreement == "" { + wfe.sendError(response, logEvent, probs.Unauthorized("Must agree to subscriber agreement before any further actions"), nil) + return + } + + var init core.Authorization + if err := json.Unmarshal(body, &init); err != nil { + logEvent.AddError("unable to JSON unmarshal Authorization: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling JSON"), err) + return + } + logEvent.Extra["Identifier"] = init.Identifier + + // Create new authz and return + authz, err := wfe.RA.NewAuthorization(ctx, init, currReg.ID) + if err != nil { + logEvent.AddError("unable to create new authz: %s", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Error creating new authz"), err) + return + } + logEvent.Extra["AuthzID"] = authz.ID + + // Make a URL for this authz, then blow away the ID and RegID before serializing + authzURL := wfe.relativeEndpoint(request, authzPath+string(authz.ID)) + wfe.prepAuthorizationForDisplay(request, &authz) + + response.Header().Add("Location", authzURL) + response.Header().Add("Link", link(wfe.relativeEndpoint(request, newCertPath), "next")) + + err = wfe.writeJsonResponse(response, logEvent, http.StatusCreated, authz) + if err != nil { + // ServerInternal because we generated the authz, it should be OK + wfe.sendError(response, logEvent, probs.ServerInternal("Error marshaling authz"), err) + return + } +} + +func (wfe *WebFrontEndImpl) regHoldsAuthorizations(ctx context.Context, regID int64, names []string) (bool, error) { + authz, err := wfe.SA.GetValidAuthorizations(ctx, regID, names, wfe.clk.Now()) + if err != nil { + return false, err + } + if len(names) != len(authz) { + return false, nil + } + missingNames := false + for _, name := range names { + if _, present := authz[name]; !present { + missingNames = true + } + } + return !missingNames, nil +} + +// RevokeCertificate is used by clients to request the revocation of a cert. +func (wfe *WebFrontEndImpl) RevokeCertificate(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + // We don't ask verifyPOST to verify there is a corresponding registration, + // because anyone with the right private key can revoke a certificate. + body, requestKey, registration, prob := wfe.verifyPOST(ctx, logEvent, request, false, core.ResourceRevokeCert) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // verifyPOST handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + + type RevokeRequest struct { + CertificateDER core.JSONBuffer `json:"certificate"` + Reason *revocation.Reason `json:"reason"` + } + var revokeRequest RevokeRequest + if err := json.Unmarshal(body, &revokeRequest); err != nil { + logEvent.AddError(fmt.Sprintf("Couldn't unmarshal in revoke request %s", string(body))) + wfe.sendError(response, logEvent, probs.Malformed("Unable to JSON parse revoke request"), err) + return + } + providedCert, err := x509.ParseCertificate(revokeRequest.CertificateDER) + if err != nil { + logEvent.AddError("unable to parse revoke certificate DER: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Unable to parse certificate DER"), err) + return + } + + serial := core.SerialToString(providedCert.SerialNumber) + logEvent.Extra["ProvidedCertificateSerial"] = serial + cert, err := wfe.SA.GetCertificate(ctx, serial) + // TODO(#991): handle db errors better + if err != nil || !bytes.Equal(cert.DER, revokeRequest.CertificateDER) { + wfe.sendError(response, logEvent, probs.NotFound("No such certificate"), err) + return + } + parsedCertificate, err := x509.ParseCertificate(cert.DER) + if err != nil { + // InternalServerError because this is a failure to decode from our DB. + wfe.sendError(response, logEvent, probs.ServerInternal("invalid parse of stored certificate"), err) + return + } + logEvent.Extra["RetrievedCertificateSerial"] = core.SerialToString(parsedCertificate.SerialNumber) + logEvent.Extra["RetrievedCertificateDNSNames"] = parsedCertificate.DNSNames + logEvent.Extra["RetrievedCertificateEmailAddresses"] = parsedCertificate.EmailAddresses + logEvent.Extra["RetrievedCertificateIPAddresses"] = parsedCertificate.IPAddresses + + certStatus, err := wfe.SA.GetCertificateStatus(ctx, serial) + if err != nil { + logEvent.AddError("unable to get certificate status: %s", err) + // TODO(#991): handle db errors + wfe.sendError(response, logEvent, probs.NotFound("Certificate status not yet available"), err) + return + } + logEvent.Extra["CertificateStatus"] = certStatus.Status + + if certStatus.Status == core.OCSPStatusRevoked { + logEvent.AddError("Certificate already revoked: %#v", serial) + wfe.sendError(response, logEvent, probs.Conflict("Certificate already revoked"), nil) + return + } + + if !(core.KeyDigestEquals(requestKey, parsedCertificate.PublicKey) || registration.ID == cert.RegistrationID) { + valid, err := wfe.regHoldsAuthorizations(ctx, registration.ID, parsedCertificate.DNSNames) + if err != nil { + logEvent.AddError("regHoldsAuthorizations failed: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to retrieve authorizations for names in certificate"), err) + return + } + if !valid { + wfe.sendError(response, logEvent, + probs.Unauthorized("Revocation request must be signed by private key of cert to be revoked, by the "+ + "account key of the account that issued it, or by the account key of an account that holds valid "+ + "authorizations for all names in the certificate."), + nil) + return + } + } + + reason := revocation.Reason(0) + if revokeRequest.Reason != nil && wfe.AcceptRevocationReason { + if _, present := revocation.UserAllowedReasons[*revokeRequest.Reason]; !present { + logEvent.AddError("unsupported revocation reason code provided") + wfe.sendError(response, logEvent, probs.Malformed("unsupported revocation reason code provided"), nil) + return + } + reason = *revokeRequest.Reason + } + + err = wfe.RA.RevokeCertificateWithReg(ctx, *parsedCertificate, reason, registration.ID) + if err != nil { + logEvent.AddError("failed to revoke certificate: %s", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Failed to revoke certificate"), err) + } else { + wfe.log.Debug(fmt.Sprintf("Revoked %v", serial)) + response.WriteHeader(http.StatusOK) + } +} + +func (wfe *WebFrontEndImpl) logCsr(request *http.Request, cr core.CertificateRequest, registration core.Registration) { + var csrLog = struct { + ClientAddr string + CSR string + Registration core.Registration + }{ + ClientAddr: getClientAddr(request), + CSR: hex.EncodeToString(cr.Bytes), + Registration: registration, + } + wfe.log.AuditObject("Certificate request", csrLog) +} + +// NewCertificate is used by clients to request the issuance of a cert for an +// authorized identifier. +func (wfe *WebFrontEndImpl) NewCertificate(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + body, _, reg, prob := wfe.verifyPOST(ctx, logEvent, request, true, core.ResourceNewCert) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // verifyPOST handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + // Any version of the agreement is acceptable here. Version match is enforced in + // wfe.Registration when agreeing the first time. Agreement updates happen + // by mailing subscribers and don't require a registration update. + if reg.Agreement == "" { + wfe.sendError(response, logEvent, probs.Unauthorized("Must agree to subscriber agreement before any further actions"), nil) + return + } + + var rawCSR core.RawCertificateRequest + err := json.Unmarshal(body, &rawCSR) + if err != nil { + logEvent.AddError("unable to JSON unmarshal CertificateRequest: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling certificate request"), err) + return + } + // Assuming a properly formatted CSR there should be two four byte SEQUENCE + // declarations then a two byte integer declaration which defines the version + // of the CSR. If those two bytes (at offset 8 and 9) and equal to 2 and 0 + // then the CSR was generated by a pre-1.0.2 version of OpenSSL with a client + // which didn't explicitly set the version causing the integer to be malformed + // and encoding/asn1 will refuse to parse it. If this is the case exit early + // with a more useful error message. + if len(rawCSR.CSR) >= 10 && rawCSR.CSR[8] == 2 && rawCSR.CSR[9] == 0 { + logEvent.AddError("Pre-1.0.2 OpenSSL malformed CSR") + wfe.sendError( + response, + logEvent, + probs.Malformed("CSR generated using a pre-1.0.2 OpenSSL with a client that doesn't properly specify the CSR version. See https://community.letsencrypt.org/t/openssl-bug-information/19591"), + nil, + ) + return + } + + certificateRequest := core.CertificateRequest{Bytes: rawCSR.CSR} + certificateRequest.CSR, err = x509.ParseCertificateRequest(rawCSR.CSR) + if err != nil { + logEvent.AddError("unable to parse certificate request: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Error parsing certificate request: %s", err), err) + return + } + wfe.logCsr(request, certificateRequest, reg) + // Check that the key in the CSR is good. This will also be checked in the CA + // component, but we want to discard CSRs with bad keys as early as possible + // because (a) it's an easy check and we can save unnecessary requests and + // bytes on the wire, and (b) the CA logs all rejections as audit events, but + // a bad key from the client is just a malformed request and doesn't need to + // be audited. + if err := wfe.keyPolicy.GoodKey(certificateRequest.CSR.PublicKey); err != nil { + logEvent.AddError("CSR public key failed GoodKey: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Invalid key in certificate request :: %s", err), err) + return + } + logEvent.Extra["CSRDNSNames"] = certificateRequest.CSR.DNSNames + logEvent.Extra["CSREmailAddresses"] = certificateRequest.CSR.EmailAddresses + logEvent.Extra["CSRIPAddresses"] = certificateRequest.CSR.IPAddresses + + // Create new certificate and return + // TODO IMPORTANT: The RA trusts the WFE to provide the correct key. If the + // WFE is compromised, *and* the attacker knows the public key of an account + // authorized for target site, they could cause issuance for that site by + // lying to the RA. We should probably pass a copy of the whole request to the + // RA for secondary validation. + cert, err := wfe.RA.NewCertificate(ctx, certificateRequest, reg.ID) + if err != nil { + logEvent.AddError("unable to create new cert: %s", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Error creating new cert"), err) + return + } + + // Make a URL for this certificate. + // We use only the sequential part of the serial number, because it should + // uniquely identify the certificate, and this makes it easy for anybody to + // enumerate and mirror our certificates. + parsedCertificate, err := x509.ParseCertificate([]byte(cert.DER)) + if err != nil { + logEvent.AddError("unable to parse certificate: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Unable to parse certificate"), err) + return + } + serial := parsedCertificate.SerialNumber + certURL := wfe.relativeEndpoint(request, certPath+core.SerialToString(serial)) + + // TODO Content negotiation + response.Header().Add("Location", certURL) + if features.Enabled(features.UseAIAIssuerURL) { + if err = wfe.addIssuingCertificateURLs(response, parsedCertificate.IssuingCertificateURL); err != nil { + logEvent.AddError("unable to parse IssuingCertificateURL: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("unable to parse IssuingCertificateURL"), err) + return + } + } else { + relativeIssuerPath := wfe.relativeEndpoint(request, issuerPath) + response.Header().Add("Link", link(relativeIssuerPath, "up")) + } + response.Header().Set("Content-Type", "application/pkix-cert") + response.WriteHeader(http.StatusCreated) + if _, err = response.Write(cert.DER); err != nil { + logEvent.AddError(err.Error()) + wfe.log.Warning(fmt.Sprintf("Could not write response: %s", err)) + } +} + +// Challenge handles POST requests to challenge URLs. Such requests are clients' +// responses to the server's challenges. +func (wfe *WebFrontEndImpl) Challenge( + ctx context.Context, + logEvent *requestEvent, + response http.ResponseWriter, + request *http.Request) { + + notFound := func() { + wfe.sendError(response, logEvent, probs.NotFound("No such challenge"), nil) + } + + // Challenge URIs are of the form /acme/challenge//. + // Here we parse out the id components. + slug := strings.Split(request.URL.Path, "/") + if len(slug) != 2 { + notFound() + return + } + authorizationID := slug[0] + challengeID, err := strconv.ParseInt(slug[1], 10, 64) + if err != nil { + notFound() + return + } + logEvent.Extra["AuthorizationID"] = authorizationID + logEvent.Extra["ChallengeID"] = challengeID + + authz, err := wfe.SA.GetAuthorization(ctx, authorizationID) + if err != nil { + if err == sql.ErrNoRows { + notFound() + } else { + wfe.sendError(response, logEvent, probs.ServerInternal("Problem getting authorization"), err) + } + return + } + + // After expiring, challenges are inaccessible + if authz.Expires == nil || authz.Expires.Before(wfe.clk.Now()) { + logEvent.AddError("Authorization %v expired in the past (%v)", authz.ID, *authz.Expires) + wfe.sendError(response, logEvent, probs.NotFound("Expired authorization"), nil) + return + } + + // Check that the requested challenge exists within the authorization + challengeIndex := authz.FindChallenge(challengeID) + if challengeIndex == -1 { + notFound() + return + } + challenge := authz.Challenges[challengeIndex] + + logEvent.Extra["ChallengeType"] = challenge.Type + logEvent.Extra["AuthorizationRegistrationID"] = authz.RegistrationID + logEvent.Extra["AuthorizationIdentifier"] = authz.Identifier + logEvent.Extra["AuthorizationStatus"] = authz.Status + logEvent.Extra["AuthorizationExpires"] = authz.Expires + + switch request.Method { + case "GET", "HEAD": + wfe.getChallenge(ctx, response, request, authz, &challenge, logEvent) + + case "POST": + wfe.postChallenge(ctx, response, request, authz, challengeIndex, logEvent) + } +} + +// prepChallengeForDisplay takes a core.Challenge and prepares it for display to +// the client by filling in its URI field and clearing its ID field. +// TODO: Come up with a cleaner way to do this. +// https://github.com/letsencrypt/boulder/issues/761 +func (wfe *WebFrontEndImpl) prepChallengeForDisplay(request *http.Request, authz core.Authorization, challenge *core.Challenge) { + challenge.URI = wfe.relativeEndpoint(request, fmt.Sprintf("%s%s/%d", challengePath, authz.ID, challenge.ID)) + // 0 is considered "empty" for the purpose of the JSON omitempty tag. + challenge.ID = 0 +} + +// prepAuthorizationForDisplay takes a core.Authorization and prepares it for +// display to the client by clearing its ID and RegistrationID fields, and +// preparing all its challenges. +func (wfe *WebFrontEndImpl) prepAuthorizationForDisplay(request *http.Request, authz *core.Authorization) { + for i := range authz.Challenges { + wfe.prepChallengeForDisplay(request, *authz, &authz.Challenges[i]) + } + authz.ID = "" + authz.RegistrationID = 0 +} + +func (wfe *WebFrontEndImpl) getChallenge( + ctx context.Context, + response http.ResponseWriter, + request *http.Request, + authz core.Authorization, + challenge *core.Challenge, + logEvent *requestEvent) { + + wfe.prepChallengeForDisplay(request, authz, challenge) + + authzURL := wfe.relativeEndpoint(request, authzPath+string(authz.ID)) + response.Header().Add("Location", challenge.URI) + response.Header().Add("Link", link(authzURL, "up")) + + err := wfe.writeJsonResponse(response, logEvent, http.StatusAccepted, challenge) + if err != nil { + // InternalServerError because this is a failure to decode data passed in + // by the caller, which got it from the DB. + logEvent.AddError("unable to marshal challenge: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal challenge"), err) + return + } +} + +func (wfe *WebFrontEndImpl) postChallenge( + ctx context.Context, + response http.ResponseWriter, + request *http.Request, + authz core.Authorization, + challengeIndex int, + logEvent *requestEvent) { + body, _, currReg, prob := wfe.verifyPOST(ctx, logEvent, request, true, core.ResourceChallenge) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // verifyPOST handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + // Any version of the agreement is acceptable here. Version match is enforced in + // wfe.Registration when agreeing the first time. Agreement updates happen + // by mailing subscribers and don't require a registration update. + if currReg.Agreement == "" { + wfe.sendError(response, logEvent, probs.Unauthorized("Registration didn't agree to subscriber agreement before any further actions"), nil) + return + } + + // Check that the registration ID matching the key used matches + // the registration ID on the authz object + if currReg.ID != authz.RegistrationID { + logEvent.AddError("User registration id: %d != Authorization registration id: %v", currReg.ID, authz.RegistrationID) + wfe.sendError(response, + logEvent, + probs.Unauthorized("User registration ID doesn't match registration ID in authorization"), + nil, + ) + return + } + + var challengeUpdate core.Challenge + if err := json.Unmarshal(body, &challengeUpdate); err != nil { + logEvent.AddError("error JSON unmarshaling challenge response: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling challenge response"), err) + return + } + + // Ask the RA to update this authorization + updatedAuthorization, err := wfe.RA.UpdateAuthorization(ctx, authz, challengeIndex, challengeUpdate) + if err != nil { + logEvent.AddError("unable to update challenge: %s", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Unable to update challenge"), err) + return + } + + // assumption: UpdateAuthorization does not modify order of challenges + challenge := updatedAuthorization.Challenges[challengeIndex] + wfe.prepChallengeForDisplay(request, authz, &challenge) + + authzURL := wfe.relativeEndpoint(request, authzPath+string(authz.ID)) + response.Header().Add("Location", challenge.URI) + response.Header().Add("Link", link(authzURL, "up")) + + err = wfe.writeJsonResponse(response, logEvent, http.StatusAccepted, challenge) + if err != nil { + // ServerInternal because we made the challenges, they should be OK + logEvent.AddError("failed to marshal challenge: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal challenge"), err) + return + } +} + +// Registration is used by a client to submit an update to their registration. +func (wfe *WebFrontEndImpl) Registration(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + + body, _, currReg, prob := wfe.verifyPOST(ctx, logEvent, request, true, core.ResourceRegistration) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + // verifyPOST handles its own setting of logEvent.Errors + wfe.sendError(response, logEvent, prob, nil) + return + } + + // Requests to this handler should have a path that leads to a known + // registration + idStr := request.URL.Path + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + logEvent.AddError("registration ID must be an integer, was %#v", idStr) + wfe.sendError(response, logEvent, probs.Malformed("Registration ID must be an integer"), err) + return + } else if id <= 0 { + msg := fmt.Sprintf("Registration ID must be a positive non-zero integer, was %d", id) + logEvent.AddError(msg) + wfe.sendError(response, logEvent, probs.Malformed(msg), nil) + return + } else if id != currReg.ID { + logEvent.AddError("Request signing key did not match registration key: %d != %d", id, currReg.ID) + wfe.sendError(response, logEvent, probs.Unauthorized("Request signing key did not match registration key"), nil) + return + } + + var update core.Registration + err = json.Unmarshal(body, &update) + if err != nil { + logEvent.AddError("unable to JSON parse registration: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling registration"), err) + return + } + + // People *will* POST their full registrations to this endpoint, including + // the 'valid' status, to avoid always failing out when that happens only + // attempt to deactivate if the provided status is different from their current + // status. + // + // If a user tries to send both a deactivation request and an update to their + // contacts or subscriber agreement URL the deactivation will take place and + // return before an update would be performed. + if features.Enabled(features.AllowAccountDeactivation) && (update.Status != "" && update.Status != currReg.Status) { + if update.Status != core.StatusDeactivated { + wfe.sendError(response, logEvent, probs.Malformed("Invalid value provided for status field"), nil) + return + } + wfe.deactivateRegistration(ctx, currReg, response, request, logEvent) + return + } + + // If a user POSTs their registration object including a previously valid + // agreement URL but that URL has since changed we will fail out here + // since the update agreement URL doesn't match the current URL. To fix that we + // only fail if the sent URL doesn't match the currently valid agreement URL + // and it doesn't match the URL currently stored in the registration + // in the database. The RA understands the user isn't actually trying to + // update the agreement but since we do an early check here in order to prevent + // extraneous requests to the RA we have to add this bypass. + if len(update.Agreement) > 0 && update.Agreement != currReg.Agreement && + update.Agreement != wfe.SubscriberAgreementURL { + msg := fmt.Sprintf("Provided agreement URL [%s] does not match current agreement URL [%s]", update.Agreement, wfe.SubscriberAgreementURL) + logEvent.AddError(msg) + wfe.sendError(response, logEvent, probs.Malformed(msg), nil) + return + } + + // Registration objects contain a JWK object which are merged in UpdateRegistration + // if it is different from the existing registration key. Since this isn't how you + // update the key we just copy the existing one into the update object here. This + // ensures the key isn't changed and that we can cleanly serialize the update as + // JSON to send via RPC to the RA. + update.Key = currReg.Key + + updatedReg, err := wfe.RA.UpdateRegistration(ctx, currReg, update) + if err != nil { + logEvent.AddError("unable to update registration: %s", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Unable to update registration"), err) + return + } + + response.Header().Add("Link", link(wfe.relativeEndpoint(request, newAuthzPath), "next")) + if len(wfe.SubscriberAgreementURL) > 0 { + response.Header().Add("Link", link(wfe.SubscriberAgreementURL, "terms-of-service")) + } + + err = wfe.writeJsonResponse(response, logEvent, http.StatusAccepted, updatedReg) + if err != nil { + // ServerInternal because we just generated the reg, it should be OK + logEvent.AddError("unable to marshal updated registration: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal registration"), err) + return + } +} + +func (wfe *WebFrontEndImpl) deactivateAuthorization(ctx context.Context, authz *core.Authorization, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) bool { + body, _, reg, prob := wfe.verifyPOST(ctx, logEvent, request, true, core.ResourceAuthz) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return false + } + if reg.ID != authz.RegistrationID { + logEvent.AddError("registration ID doesn't match ID for authorization") + wfe.sendError(response, logEvent, probs.Unauthorized("Registration ID doesn't match ID for authorization"), nil) + return false + } + var req struct { + Status core.AcmeStatus + } + err := json.Unmarshal(body, &req) + if err != nil { + wfe.sendError(response, logEvent, probs.Malformed("Error unmarshaling JSON"), err) + return false + } + if req.Status != core.StatusDeactivated { + logEvent.AddError("invalid status value") + wfe.sendError(response, logEvent, probs.Malformed("Invalid status value"), err) + return false + } + err = wfe.RA.DeactivateAuthorization(ctx, *authz) + if err != nil { + logEvent.AddError("unable to deactivate authorization", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Error deactivating authorization"), err) + return false + } + // Since the authorization passed to DeactivateAuthorization isn't + // mutated locally by the function we must manually set the status + // here before displaying the authorization to the user + authz.Status = core.StatusDeactivated + return true +} + +// Authorization is used by clients to submit an update to one of their +// authorizations. +func (wfe *WebFrontEndImpl) Authorization(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + // Requests to this handler should have a path that leads to a known authz + id := request.URL.Path + authz, err := wfe.SA.GetAuthorization(ctx, id) + if err != nil { + logEvent.AddError("No such authorization at id %s", id) + // TODO(#1199): handle db errors + wfe.sendError(response, logEvent, probs.NotFound("Unable to find authorization"), err) + return + } + logEvent.Extra["AuthorizationID"] = authz.ID + logEvent.Extra["AuthorizationRegistrationID"] = authz.RegistrationID + logEvent.Extra["AuthorizationIdentifier"] = authz.Identifier + logEvent.Extra["AuthorizationStatus"] = authz.Status + logEvent.Extra["AuthorizationExpires"] = authz.Expires + + // After expiring, authorizations are inaccessible + if authz.Expires == nil || authz.Expires.Before(wfe.clk.Now()) { + msg := fmt.Sprintf("Authorization %v expired in the past (%v)", authz.ID, *authz.Expires) + logEvent.AddError(msg) + wfe.sendError(response, logEvent, probs.NotFound("Expired authorization"), nil) + return + } + + if wfe.AllowAuthzDeactivation && request.Method == "POST" { + // If the deactivation fails return early as errors and return codes + // have already been set. Otherwise continue so that the user gets + // sent the deactivated authorization. + if !wfe.deactivateAuthorization(ctx, &authz, logEvent, response, request) { + return + } + } + + wfe.prepAuthorizationForDisplay(request, &authz) + + response.Header().Add("Link", link(wfe.relativeEndpoint(request, newCertPath), "next")) + + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, authz) + if err != nil { + // InternalServerError because this is a failure to decode from our DB. + logEvent.AddError("Failed to JSON marshal authz: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to JSON marshal authz"), err) + return + } +} + +var allHex = regexp.MustCompile("^[0-9a-f]+$") + +// Certificate is used by clients to request a copy of their current certificate, or to +// request a reissuance of the certificate. +func (wfe *WebFrontEndImpl) Certificate(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + + serial := request.URL.Path + // Certificate paths consist of the CertBase path, plus exactly sixteen hex + // digits. + if !core.ValidSerial(serial) { + logEvent.AddError("certificate serial provided was not valid: %s", serial) + wfe.sendError(response, logEvent, probs.NotFound("Certificate not found"), nil) + return + } + logEvent.Extra["RequestedSerial"] = serial + + cert, err := wfe.SA.GetCertificate(ctx, serial) + // TODO(#991): handle db errors + if err != nil { + logEvent.AddError("unable to get certificate by serial id %#v: %s", serial, err) + if strings.HasPrefix(err.Error(), "gorp: multiple rows returned") { + wfe.sendError(response, logEvent, probs.Conflict("Multiple certificates with same short serial"), err) + } else { + wfe.sendError(response, logEvent, probs.NotFound("Certificate not found"), err) + } + return + } + + // TODO Content negotiation + response.Header().Set("Content-Type", "application/pkix-cert") + if features.Enabled(features.UseAIAIssuerURL) { + parsedCertificate, err := x509.ParseCertificate([]byte(cert.DER)) + if err != nil { + logEvent.AddError("unable to parse certificate: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Unable to parse certificate"), err) + return + } + if err = wfe.addIssuingCertificateURLs(response, parsedCertificate.IssuingCertificateURL); err != nil { + logEvent.AddError("unable to parse IssuingCertificateURL: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("unable to parse IssuingCertificateURL"), err) + return + } + } else { + relativeIssuerPath := wfe.relativeEndpoint(request, issuerPath) + response.Header().Add("Link", link(relativeIssuerPath, "up")) + } + response.WriteHeader(http.StatusOK) + if _, err = response.Write(cert.DER); err != nil { + logEvent.AddError(err.Error()) + wfe.log.Warning(fmt.Sprintf("Could not write response: %s", err)) + } + return +} + +// Terms is used by the client to obtain the current Terms of Service / +// Subscriber Agreement to which the subscriber must agree. +func (wfe *WebFrontEndImpl) Terms(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + http.Redirect(response, request, wfe.SubscriberAgreementURL, http.StatusFound) +} + +// Issuer obtains the issuer certificate used by this instance of Boulder. +func (wfe *WebFrontEndImpl) Issuer(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + // TODO Content negotiation + response.Header().Set("Content-Type", "application/pkix-cert") + response.WriteHeader(http.StatusOK) + if _, err := response.Write(wfe.IssuerCert); err != nil { + logEvent.AddError("unable to write issuer certificate response: %s", err) + wfe.log.Warning(fmt.Sprintf("Could not write response: %s", err)) + } +} + +// BuildID tells the requestor what build we're running. +func (wfe *WebFrontEndImpl) BuildID(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "text/plain") + response.WriteHeader(http.StatusOK) + detailsString := fmt.Sprintf("Boulder=(%s %s)", core.GetBuildID(), core.GetBuildTime()) + if _, err := fmt.Fprintln(response, detailsString); err != nil { + logEvent.AddError("unable to print build information: %s", err) + wfe.log.Warning(fmt.Sprintf("Could not write response: %s", err)) + } +} + +// Options responds to an HTTP OPTIONS request. +func (wfe *WebFrontEndImpl) Options(response http.ResponseWriter, request *http.Request, methodsStr string, methodsMap map[string]bool) { + // Every OPTIONS request gets an Allow header with a list of supported methods. + response.Header().Set("Allow", methodsStr) + + // CORS preflight requests get additional headers. See + // http://www.w3.org/TR/cors/#resource-preflight-requests + reqMethod := request.Header.Get("Access-Control-Request-Method") + if reqMethod == "" { + reqMethod = "GET" + } + if methodsMap[reqMethod] { + wfe.setCORSHeaders(response, request, methodsStr) + } +} + +// setCORSHeaders() tells the client that CORS is acceptable for this +// request. If allowMethods == "" the request is assumed to be a CORS +// actual request and no Access-Control-Allow-Methods header will be +// sent. +func (wfe *WebFrontEndImpl) setCORSHeaders(response http.ResponseWriter, request *http.Request, allowMethods string) { + reqOrigin := request.Header.Get("Origin") + if reqOrigin == "" { + // This is not a CORS request. + return + } + + // Allow CORS if the current origin (or "*") is listed as an + // allowed origin in config. Otherwise, disallow by returning + // without setting any CORS headers. + allow := false + for _, ao := range wfe.AllowOrigins { + if ao == "*" { + response.Header().Set("Access-Control-Allow-Origin", "*") + allow = true + break + } else if ao == reqOrigin { + response.Header().Set("Vary", "Origin") + response.Header().Set("Access-Control-Allow-Origin", ao) + allow = true + break + } + } + if !allow { + return + } + + if allowMethods != "" { + // For an OPTIONS request: allow all methods handled at this URL. + response.Header().Set("Access-Control-Allow-Methods", allowMethods) + } + response.Header().Set("Access-Control-Expose-Headers", "Link, Replay-Nonce") + response.Header().Set("Access-Control-Max-Age", "86400") +} + +// KeyRollover allows a user to change their signing key +func (wfe *WebFrontEndImpl) KeyRollover(ctx context.Context, logEvent *requestEvent, response http.ResponseWriter, request *http.Request) { + body, _, reg, prob := wfe.verifyPOST(ctx, logEvent, request, true, core.ResourceKeyChange) + addRequesterHeader(response, logEvent.Requester) + if prob != nil { + wfe.sendError(response, logEvent, prob, nil) + return + } + + // Parse as JWS + newKey, parsedJWS, err := wfe.extractJWSKey(string(body)) + if err != nil { + logEvent.AddError(err.Error()) + wfe.sendError(response, logEvent, probs.Malformed(err.Error()), err) + return + } + payload, err := parsedJWS.Verify(newKey) + if err != nil { + logEvent.AddError("verification of the inner JWS with the inner JWK failed: %v", err) + wfe.sendError(response, logEvent, probs.Malformed("JWS verification error"), err) + return + } + var rolloverRequest struct { + NewKey jose.JsonWebKey + Account string + } + err = json.Unmarshal(payload, &rolloverRequest) + if err != nil { + logEvent.AddError("unable to JSON parse resource from JWS payload: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Request payload did not parse as JSON"), nil) + return + } + + if wfe.relativeEndpoint(request, fmt.Sprintf("%s%d", regPath, reg.ID)) != rolloverRequest.Account { + logEvent.AddError("incorrect account URL provided") + wfe.sendError(response, logEvent, probs.Malformed("Incorrect account URL provided in payload"), nil) + return + } + + keysEqual, err := core.PublicKeysEqual(rolloverRequest.NewKey.Key, newKey.Key) + if err != nil { + logEvent.AddError("unable to marshal new key: %s", err) + wfe.sendError(response, logEvent, probs.Malformed("Unable to marshal new JWK"), nil) + return + } + if !keysEqual { + logEvent.AddError("new key in inner payload doesn't match key used to sign inner JWS") + wfe.sendError(response, logEvent, probs.Malformed("New JWK in inner payload doesn't match key used to sign inner JWS"), nil) + return + } + + // Update registration key + updatedReg, err := wfe.RA.UpdateRegistration(ctx, reg, core.Registration{Key: newKey}) + if err != nil { + logEvent.AddError("unable to update registration: %s", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Unable to update registration"), err) + return + } + + jsonReply, err := marshalIndent(updatedReg) + if err != nil { + logEvent.AddError("unable to marshal updated registration: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal registration"), err) + return + } + response.Header().Set("Content-Type", "application/json") + response.WriteHeader(http.StatusOK) + response.Write(jsonReply) +} + +func (wfe *WebFrontEndImpl) deactivateRegistration(ctx context.Context, reg core.Registration, response http.ResponseWriter, request *http.Request, logEvent *requestEvent) { + err := wfe.RA.DeactivateRegistration(ctx, reg) + if err != nil { + logEvent.AddError("unable to deactivate registration", err) + wfe.sendError(response, logEvent, problemDetailsForError(err, "Error deactivating registration"), err) + return + } + reg.Status = core.StatusDeactivated + + err = wfe.writeJsonResponse(response, logEvent, http.StatusOK, reg) + if err != nil { + // ServerInternal because registration is from DB and should be fine + logEvent.AddError("unable to marshal updated registration: %s", err) + wfe.sendError(response, logEvent, probs.ServerInternal("Failed to marshal registration"), err) + return + } +} + +// addIssuingCertificateURLs() adds Issuing Certificate URLs (AIA) from a +// X.509 certificate to the HTTP response. If the IssuingCertificateURL +// in a certificate is not https://, it will be upgraded to https:// +func (wfe *WebFrontEndImpl) addIssuingCertificateURLs(response http.ResponseWriter, issuingCertificateURL []string) error { + for _, rawURL := range issuingCertificateURL { + parsedURI, err := url.ParseRequestURI(rawURL) + if err != nil { + return err + } + parsedURI.Scheme = "https" + response.Header().Add("Link", link(parsedURI.String(), "up")) + } + return nil +} diff --git a/wfe2/wfe_test.go b/wfe2/wfe_test.go new file mode 100644 index 000000000..f39a91058 --- /dev/null +++ b/wfe2/wfe_test.go @@ -0,0 +1,2224 @@ +package wfe2 + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + "golang.org/x/net/context" + "gopkg.in/square/go-jose.v1" + + "github.com/letsencrypt/boulder/core" + berrors "github.com/letsencrypt/boulder/errors" + "github.com/letsencrypt/boulder/features" + "github.com/letsencrypt/boulder/goodkey" + blog "github.com/letsencrypt/boulder/log" + "github.com/letsencrypt/boulder/metrics" + "github.com/letsencrypt/boulder/mocks" + "github.com/letsencrypt/boulder/nonce" + "github.com/letsencrypt/boulder/probs" + "github.com/letsencrypt/boulder/ra" + "github.com/letsencrypt/boulder/revocation" + "github.com/letsencrypt/boulder/test" +) + +const ( + agreementURL = "http://example.invalid/terms" + + test1KeyPublicJSON = ` + { + "kty":"RSA", + "n":"yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e":"AQAB" + }` + + test1KeyPrivatePEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAyNWVhtYEKJR21y9xsHV+PD/bYwbXSeNuFal46xYxVfRL5mqh +a7vttvjB/vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K/klBYN8oYvTwwmeSkAz +6ut7ZxPv+nZaT5TJhGk0NT2kh/zSpdriEJ/3vW+mqxYbbBmpvHqsa1/zx9fSuHYc +tAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV+mzfMyboQjujPh7aNJxAWS +q4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF+w8hOTI3XXohUdu +29Se26k2B0PolDSuj0GIQU6+W9TdLXSjBb2SpQIDAQABAoIBAHw58SXYV/Yp72Cn +jjFSW+U0sqWMY7rmnP91NsBjl9zNIe3C41pagm39bTIjB2vkBNR8ZRG7pDEB/QAc +Cn9Keo094+lmTArjL407ien7Ld+koW7YS8TyKADYikZo0vAK3qOy14JfQNiFAF9r +Bw61hG5/E58cK5YwQZe+YcyBK6/erM8fLrJEyw4CV49wWdq/QqmNYU1dx4OExAkl +KMfvYXpjzpvyyTnZuS4RONfHsO8+JTyJVm+lUv2x+bTce6R4W++UhQY38HakJ0x3 +XRfXooRv1Bletu5OFlpXfTSGz/5gqsfemLSr5UHncsCcFMgoFBsk2t/5BVukBgC7 +PnHrAjkCgYEA887PRr7zu3OnaXKxylW5U5t4LzdMQLpslVW7cLPD4Y08Rye6fF5s +O/jK1DNFXIoUB7iS30qR7HtaOnveW6H8/kTmMv/YAhLO7PAbRPCKxxcKtniEmP1x +ADH0tF2g5uHB/zeZhCo9qJiF0QaJynvSyvSyJFmY6lLvYZsAW+C+PesCgYEA0uCi +Q8rXLzLpfH2NKlLwlJTi5JjE+xjbabgja0YySwsKzSlmvYJqdnE2Xk+FHj7TCnSK +KUzQKR7+rEk5flwEAf+aCCNh3W4+Hp9MmrdAcCn8ZsKmEW/o7oDzwiAkRCmLw/ck +RSFJZpvFoxEg15riT37EjOJ4LBZ6SwedsoGA/a8CgYEA2Ve4sdGSR73/NOKZGc23 +q4/B4R2DrYRDPhEySnMGoPCeFrSU6z/lbsUIU4jtQWSaHJPu4n2AfncsZUx9WeSb +OzTCnh4zOw33R4N4W8mvfXHODAJ9+kCc1tax1YRN5uTEYzb2dLqPQtfNGxygA1DF +BkaC9CKnTeTnH3TlKgK8tUcCgYB7J1lcgh+9ntwhKinBKAL8ox8HJfkUM+YgDbwR +sEM69E3wl1c7IekPFvsLhSFXEpWpq3nsuMFw4nsVHwaGtzJYAHByhEdpTDLXK21P +heoKF1sioFbgJB1C/Ohe3OqRLDpFzhXOkawOUrbPjvdBM2Erz/r11GUeSlpNazs7 +vsoYXQKBgFwFM1IHmqOf8a2wEFa/a++2y/WT7ZG9nNw1W36S3P04K4lGRNRS2Y/S +snYiqxD9nL7pVqQP2Qbqbn0yD6d3G5/7r86F7Wu2pihM8g6oyMZ3qZvvRIBvKfWo +eROL1ve1vmQF3kjrMPhhK2kr6qdWnTE5XlPllVSZFQenSTzj98AO +-----END RSA PRIVATE KEY----- +` + + test2KeyPublicJSON = `{ + "kty":"RSA", + "n":"qnARLrT7Xz4gRcKyLdydmCr-ey9OuPImX4X40thk3on26FkMznR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBrhR6uIoO4jAzJZR-ChzZuSDt7iHN-3xUVspu5XGwXU_MVJZshTwp4TaFx5elHIT_ObnTvTOU3Xhish07AbgZKmWsVbXh5s-CrIicU4OexJPgunWZ_YJJueOKmTvnLlTV4MzKR2oZlBKZ27S0-SfdV_QDx_ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY_2Uzi5eX0lTc7MPRwz6qR1kip-i59VcGcUQgqHV6Fyqw", + "e":"AQAB" + }` + + test2KeyPrivatePEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAqnARLrT7Xz4gRcKyLdydmCr+ey9OuPImX4X40thk3on26FkM +znR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBr +hR6uIoO4jAzJZR+ChzZuSDt7iHN+3xUVspu5XGwXU/MVJZshTwp4TaFx5elHIT/O +bnTvTOU3Xhish07AbgZKmWsVbXh5s+CrIicU4OexJPgunWZ/YJJueOKmTvnLlTV4 +MzKR2oZlBKZ27S0+SfdV/QDx/ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY/2Uzi5 +eX0lTc7MPRwz6qR1kip+i59VcGcUQgqHV6FyqwIDAQABAoIBAG5m8Xpj2YC0aYtG +tsxmX9812mpJFqFOmfS+f5N0gMJ2c+3F4TnKz6vE/ZMYkFnehAT0GErC4WrOiw68 +F/hLdtJM74gQ0LGh9dKeJmz67bKqngcAHWW5nerVkDGIBtzuMEsNwxofDcIxrjkr +G0b7AHMRwXqrt0MI3eapTYxby7+08Yxm40mxpSsW87FSaI61LDxUDpeVkn7kolSN +WifVat7CpZb/D2BfGAQDxiU79YzgztpKhbynPdGc/OyyU+CNgk9S5MgUX2m9Elh3 +aXrWh2bT2xzF+3KgZdNkJQcdIYVoGq/YRBxlGXPYcG4Do3xKhBmH79Io2BizevZv +nHkbUGECgYEAydjb4rl7wYrElDqAYpoVwKDCZAgC6o3AKSGXfPX1Jd2CXgGR5Hkl +ywP0jdSLbn2v/jgKQSAdRbYuEiP7VdroMb5M6BkBhSY619cH8etoRoLzFo1GxcE8 +Y7B598VXMq8TT+TQqw/XRvM18aL3YDZ3LSsR7Gl2jF/sl6VwQAaZToUCgYEA2Cn4 +fG58ME+M4IzlZLgAIJ83PlLb9ip6MeHEhUq2Dd0In89nss7Acu0IVg8ES88glJZy +4SjDLGSiuQuoQVo9UBq/E5YghdMJFp5ovwVfEaJ+ruWqOeujvWzzzPVyIWSLXRQa +N4kedtfrlqldMIXywxVru66Q1NOGvhDHm/Q8+28CgYEAkhLCbn3VNed7A9qidrkT +7OdqRoIVujEDU8DfpKtK0jBP3EA+mJ2j4Bvoq4uZrEiBSPS9VwwqovyIstAfX66g +Qv95IK6YDwfvpawUL9sxB3ZU/YkYIp0JWwun+Mtzo1ZYH4V0DZfVL59q9of9hj9k +V+fHfNOF22jAC67KYUtlPxECgYEAwF6hj4L3rDqvQYrB/p8tJdrrW+B7dhgZRNkJ +fiGd4LqLGUWHoH4UkHJXT9bvWNPMx88YDz6qapBoq8svAnHfTLFwyGp7KP1FAkcZ +Kp4KG/SDTvx+QCtvPX1/fjAUUJlc2QmxxyiU3uiK9Tpl/2/FOk2O4aiZpX1VVUIz +kZuKxasCgYBiVRkEBk2W4Ia0B7dDkr2VBrz4m23Y7B9cQLpNAapiijz/0uHrrCl8 +TkLlEeVOuQfxTadw05gzKX0jKkMC4igGxvEeilYc6NR6a4nvRulG84Q8VV9Sy9Ie +wk6Oiadty3eQqSBJv0HnpmiEdQVffIK5Pg4M8Dd+aOBnEkbopAJOuA== +-----END RSA PRIVATE KEY----- +` + test3KeyPrivatePEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAuTQER6vUA1RDixS8xsfCRiKUNGRzzyIK0MhbS2biClShbb0h +Sx2mPP7gBvis2lizZ9r+y9hL57kNQoYCKndOBg0FYsHzrQ3O9AcoV1z2Mq+XhHZb +FrVYaXI0M3oY9BJCWog0dyi3XC0x8AxC1npd1U61cToHx+3uSvgZOuQA5ffEn5L3 +8Dz1Ti7OV3E4XahnRJvejadUmTkki7phLBUXm5MnnyFm0CPpf6ApV7zhLjN5W+nV +0WL17o7v8aDgV/t9nIdi1Y26c3PlCEtiVHZcebDH5F1Deta3oLLg9+g6rWnTqPbY +3knffhp4m0scLD6e33k8MtzxDX/D7vHsg0/X1wIDAQABAoIBAQCnFJpX3lhiuH5G +1uqHmmdVxpRVv9oKn/eJ63cRSzvZfgg0bE/A6Hq0xGtvXqDySttvck4zsGqqHnQr +86G4lfE53D1jnv4qvS5bUKnARwmFKIxU4EHE9s1QM8uMNTaV2nMqIX7TkVP6QHuw +yB70R2inq15dS7EBWVGFKNX6HwAAdj8pFuF6o2vIwmAfee20aFzpWWf81jOH9Ai6 +hyJyV3NqrU1JzIwlXaeX67R1VroFdhN/lapp+2b0ZEcJJtFlcYFl99NjkQeVZyik +izNv0GZZNWizc57wU0/8cv+jQ2f26ltvyrPz3QNK61bFfzy+/tfMvLq7sdCmztKJ +tMxCBJOBAoGBAPKnIVQIS2nTvC/qZ8ajw1FP1rkvYblIiixegjgfFhM32HehQ+nu +3TELi3I3LngLYi9o6YSqtNBmdBJB+DUAzIXp0TdOihOweGiv5dAEWwY9rjCzMT5S +GP7dCWiJwoMUHrOs1Po3dwcjj/YsoAW+FC0jSvach2Ln2CvPgr5FP0ARAoGBAMNj +64qUCzgeXiSyPKK69bCCGtHlTYUndwHQAZmABjbmxAXZNYgp/kBezFpKOwmICE8R +kK8YALRrL0VWXl/yj85b0HAZGkquNFHPUDd1e6iiP5TrY+Hy4oqtlYApjH6f85CE +lWjQ1iyUL7aT6fcSgzq65ZWD2hUzvNtWbTt6zQFnAoGAWS/EuDY0QblpOdNWQVR/ +vasyqO4ZZRiccKJsCmSioH2uOoozhBAfjJ9JqblOgyDr/bD546E6xD5j+zH0IMci +ZTYDh+h+J659Ez1Topl3O1wAYjX6q4VRWpuzkZDQxYznm/KydSVdwmn3x+uvBW1P +zSdjrjDqMhg1BCVJUNXy4YECgYEAjX1z+dwO68qB3gz7/9NnSzRL+6cTJdNYSIW6 +QtAEsAkX9iw+qaXPKgn77X5HljVd3vQXU9QL3pqnloxetxhNrt+p5yMmeOIBnSSF +MEPxEkK7zDlRETPzfP0Kf86WoLNviz2XfFmOXqXIj2w5RuOvB/6DdmwOpr/aiPLj +EulwPw0CgYAMSzsWOt6vU+y/G5NyhUCHvY50TdnGOj2btBk9rYVwWGWxCpg2QF0R +pcKXgGzXEVZKFAqB8V1c/mmCo8ojPgmqGM+GzX2Bj4seVBW7PsTeZUjrHpADshjV +F7o5b7y92NlxO5kwQzRKEAhwS5PbKJdx90iCuG+JlI1YgWlA1VcJMw== +-----END RSA PRIVATE KEY----- +` + + test4KeyPrivatePEM = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAqih+cx32M0wq8MhhN+kBi2xPE+wnw4/iIg1hWO5wtBfpt2Pt +WikgPuBT6jvK9oyQwAWbSfwqlVZatMPY/+3IyytMNb9R9OatNr6o5HROBoyZnDVS +iC4iMRd7bRl/PWSIqj/MjhPNa9cYwBdW5iC3jM5TaOgmp0+YFm4tkLGirDcIBDkQ +Ylnv9NKILvuwqkapZ7XBixeqdCcikUcTRXW5unqygO6bnapzw+YtPsPPlj4Ih3Sv +K4doyziPV96U8u5lbNYYEzYiW1mbu9n0KLvmKDikGcdOpf6+yRa/10kMZyYQatY1 +eclIKI0xb54kbluEl0GQDaL5FxLmiKeVnsapzwIDAQABAoIBAQCYWNsmTHwjX53O +qUnJ2jfE0ftXNghAIvHvVRWEny+PPx3FUZWwNMQnJ4haXqCQ8DelhR+NNVYXERLz +Z6pBMm+l4CVCtgI2B9ar/jaPHMbDPF1IK8GyJcP9Oi4K91oh6IIoFCkcSASS+imx +yvPF5SMR0aWCduAsyqm743euZizkjIZ4ZzjJzhvtO17BLXpjD2Al8CBfeaaPFfPB +X86BRH5khuNaRbjG9MVg4h+D752/PuivE6+wBW+F2CYCbFMCYTFSFyHzrVdkw59C +RbHl6Pk7aTA9z0CR3zNI5k0bGd6z/o0rMei6tWO5OBTQRq5tpW9Gim0uVLH/XJlf +XmJoze+RAoGBAMNrcbPlWlSpd3C1fwYiztXwIe7TaaJIpQ+UhCZE2NuXmEZFGqD5 +5mrZYV3iIq1cDdeV/BkzkB8ggEuQusZ4d7JfEw/j6I8C3ZRmw4W/bb8LPJMX3Ea7 +SgzFv9e+PqqX/3oHZvUN+kH1FSI+UDpkIdegqUBUyWPvd98SDH0/HaY5AoGBAN7o +SfwWExIPEYQvpPjiSVxPuuv50z0BZB+vrQL6U2y4FIohuYSfBVvMiy/Q3Coo2yej +Js4M2bj79lGG86/E+ejdN/YExKWK7qiVnVkOjKnQeJ+bm0+aQWxgetN7RCosqu4T +Dp+Ih2fmhH9r5CInWjbY8js41c/KmYeMa9ZsehBHAoGAdNGg6eJ8KkoYDXdh1MAw +FvHyxvr4lbuJeJPWn63eWP75V2Bt97cLx+nk66OICUwTNkIBrusFB6Z9Ky78iDJx +k16EXaZnWj5jSRhZX3W83EySTHgiBOJm9NWtxgGDIqW0YjVUlb9iT9V7aboIaa98 +D5OKOdu1fBkl9mKqtqBpT/kCgYAugjT9nfV4rSAwfmhjbYN0+UW8+rEyZ1nmqpbk +qipB4t6WO5cjrrJFhxX7cg6d1Ux0prvv/gpnaFrqg8fQgr7J8W49rJ0DFUvabO0Z +qcl7nP2t/5+WKk9AN5kpCu0cB5nadqt0ad4mtZgrpe1BmwhdrUJNTPx/kHwcJhZR +9Ow6/QKBgGzypcqehhIKPjOR7PR8uf0Lb8j5hlLH5akfxVDlUozr5j68cZA3nPW9 +ikuuM4LqU1dlaAp+c51nye7t4hhIw+JtGSWI2fl5NXxB71LOTvN/sN6sGCbNG3pe +xxBoTncDuGtTpubGbzBrY5W1SlNm1gqu9oQa23WNViN2Rc4aIVm3 +-----END RSA PRIVATE KEY----- +` + + testE1KeyPublicJSON = `{ + "kty":"EC", + "crv":"P-256", + "x":"FwvSZpu06i3frSk_mz9HcD9nETn4wf3mQ-zDtG21Gao", + "y":"S8rR-0dWa8nAcw1fbunF_ajS3PQZ-QwLps-2adgLgPk" + }` + + testE1KeyPrivatePEM = ` +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIH+p32RUnqT/iICBEGKrLIWFcyButv0S0lU/BLPOyHn2oAoGCCqGSM49 +AwEHoUQDQgAEFwvSZpu06i3frSk/mz9HcD9nETn4wf3mQ+zDtG21GapLytH7R1Zr +ycBzDV9u6cX9qNLc9Bn5DAumz7Zp2AuA+Q== +-----END EC PRIVATE KEY----- +` + + testE2KeyPublicJSON = `{ + "kty":"EC", + "crv":"P-256", + "x":"S8FOmrZ3ywj4yyFqt0etAD90U-EnkNaOBSLfQmf7pNg", + "y":"vMvpDyqFDRHjGfZ1siDOm5LS6xNdR5xTpyoQGLDOX2Q" + }` + testE2KeyPrivatePEM = ` +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFRcPxQ989AY6se2RyIoF1ll9O6gHev4oY15SWJ+Jf5eoAoGCCqGSM49 +AwEHoUQDQgAES8FOmrZ3ywj4yyFqt0etAD90U+EnkNaOBSLfQmf7pNi8y+kPKoUN +EeMZ9nWyIM6bktLrE11HnFOnKhAYsM5fZA== +-----END EC PRIVATE KEY-----` +) + +type MockRegistrationAuthority struct { + lastRevocationReason revocation.Reason +} + +func (ra *MockRegistrationAuthority) NewRegistration(ctx context.Context, reg core.Registration) (core.Registration, error) { + return reg, nil +} + +func (ra *MockRegistrationAuthority) NewAuthorization(ctx context.Context, authz core.Authorization, regID int64) (core.Authorization, error) { + authz.RegistrationID = regID + authz.ID = "bkrPh2u0JUf18-rVBZtOOWWb3GuIiliypL-hBM9Ak1Q" + return authz, nil +} + +func (ra *MockRegistrationAuthority) NewCertificate(ctx context.Context, req core.CertificateRequest, regID int64) (core.Certificate, error) { + return core.Certificate{}, nil +} + +func (ra *MockRegistrationAuthority) UpdateRegistration(ctx context.Context, reg core.Registration, updated core.Registration) (core.Registration, error) { + keysMatch, _ := core.PublicKeysEqual(reg.Key.Key, updated.Key.Key) + if !keysMatch { + reg.Key = updated.Key + } + return reg, nil +} + +func (ra *MockRegistrationAuthority) UpdateAuthorization(ctx context.Context, authz core.Authorization, foo int, challenge core.Challenge) (core.Authorization, error) { + return authz, nil +} + +func (ra *MockRegistrationAuthority) RevokeCertificateWithReg(ctx context.Context, cert x509.Certificate, reason revocation.Reason, reg int64) error { + ra.lastRevocationReason = reason + return nil +} + +func (ra *MockRegistrationAuthority) AdministrativelyRevokeCertificate(ctx context.Context, cert x509.Certificate, reason revocation.Reason, user string) error { + return nil +} + +func (ra *MockRegistrationAuthority) OnValidationUpdate(ctx context.Context, authz core.Authorization) error { + return nil +} + +func (ra *MockRegistrationAuthority) DeactivateAuthorization(ctx context.Context, authz core.Authorization) error { + return nil +} + +func (ra *MockRegistrationAuthority) DeactivateRegistration(ctx context.Context, _ core.Registration) error { + return nil +} + +type mockPA struct{} + +func (pa *mockPA) ChallengesFor(identifier core.AcmeIdentifier) (challenges []core.Challenge, combinations [][]int) { + return +} + +func (pa *mockPA) WillingToIssue(id core.AcmeIdentifier) error { + return nil +} + +func makeBody(s string) io.ReadCloser { + return ioutil.NopCloser(strings.NewReader(s)) +} + +func signRequest(t *testing.T, req string, nonceService *nonce.NonceService) string { + accountKey, err := jose.LoadPrivateKey([]byte(test1KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + + signer, err := jose.NewSigner("RS256", accountKey) + test.AssertNotError(t, err, "Failed to make signer") + signer.SetNonceSource(nonceService) + result, err := signer.Sign([]byte(req)) + test.AssertNotError(t, err, "Failed to sign req") + ret := result.FullSerialize() + return ret +} + +var testKeyPolicy = goodkey.KeyPolicy{ + AllowRSA: true, + AllowECDSANISTP256: true, + AllowECDSANISTP384: true, +} + +var ctx = context.Background() + +func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock) { + fc := clock.NewFake() + stats := metrics.NewNoopScope() + + wfe, err := NewWebFrontEndImpl(stats, fc, testKeyPolicy, blog.NewMock()) + test.AssertNotError(t, err, "Unable to create WFE") + + wfe.SubscriberAgreementURL = agreementURL + + wfe.RA = &MockRegistrationAuthority{} + wfe.SA = mocks.NewStorageAuthority(fc) + + return wfe, fc +} + +// makePostRequest creates an http.Request with method POST, the provided body, +// and the correct Content-Length. +func makePostRequest(body string) *http.Request { + return &http.Request{ + Method: "POST", + RemoteAddr: "1.1.1.1:7882", + Header: map[string][]string{ + "Content-Length": {fmt.Sprintf("%d", len(body))}, + }, + Body: makeBody(body), + } +} + +func makePostRequestWithPath(path string, body string) *http.Request { + request := makePostRequest(body) + request.URL = mustParseURL(path) + return request +} + +func mustParseURL(s string) *url.URL { + if u, err := url.Parse(s); err != nil { + panic("Cannot parse URL " + s) + } else { + return u + } +} + +func sortHeader(s string) string { + a := strings.Split(s, ", ") + sort.Sort(sort.StringSlice(a)) + return strings.Join(a, ", ") +} + +func addHeadIfGet(s []string) []string { + for _, a := range s { + if a == "GET" { + return append(s, "HEAD") + } + } + return s +} + +func assertJSONEquals(t *testing.T, got, expected string) { + var gotMap, expectedMap map[string]interface{} + err := json.Unmarshal([]byte(got), &gotMap) + test.AssertNotError(t, err, "failed to parse received JSON") + err = json.Unmarshal([]byte(expected), &expectedMap) + test.AssertNotError(t, err, "failed to parse expected JSON") + if !reflect.DeepEqual(gotMap, expectedMap) { + t.Fatalf("JSON response differed from expected:\n Got: %s, Expected: %s", got, expected) + } +} + +func TestHandleFunc(t *testing.T) { + wfe, _ := setupWFE(t) + var mux *http.ServeMux + var rw *httptest.ResponseRecorder + var stubCalled bool + runWrappedHandler := func(req *http.Request, allowed ...string) { + mux = http.NewServeMux() + rw = httptest.NewRecorder() + stubCalled = false + wfe.HandleFunc(mux, "/test", func(context.Context, *requestEvent, http.ResponseWriter, *http.Request) { + stubCalled = true + }, allowed...) + req.URL = mustParseURL("/test") + mux.ServeHTTP(rw, req) + } + + // Plain requests (no CORS) + type testCase struct { + allowed []string + reqMethod string + shouldCallStub bool + shouldSucceed bool + } + var lastNonce string + for _, c := range []testCase{ + {[]string{"GET", "POST"}, "GET", true, true}, + {[]string{"GET", "POST"}, "POST", true, true}, + {[]string{"GET"}, "", false, false}, + {[]string{"GET"}, "POST", false, false}, + {[]string{"GET"}, "OPTIONS", false, true}, + {[]string{"GET"}, "MAKE-COFFEE", false, false}, // 405, or 418? + } { + runWrappedHandler(&http.Request{Method: c.reqMethod}, c.allowed...) + test.AssertEquals(t, stubCalled, c.shouldCallStub) + if c.shouldSucceed { + test.AssertEquals(t, rw.Code, http.StatusOK) + } else { + test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) + test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), sortHeader(strings.Join(addHeadIfGet(c.allowed), ", "))) + assertJSONEquals(t, + rw.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Method not allowed","status":405}`) + } + nonce := rw.Header().Get("Replay-Nonce") + test.AssertNotEquals(t, nonce, lastNonce) + test.AssertNotEquals(t, nonce, "") + lastNonce = nonce + } + + // Disallowed method returns error JSON in body + runWrappedHandler(&http.Request{Method: "PUT"}, "GET", "POST") + test.AssertEquals(t, rw.Header().Get("Content-Type"), "application/problem+json") + assertJSONEquals(t, rw.Body.String(), `{"type":"urn:acme:error:malformed","detail":"Method not allowed","status":405}`) + test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), "GET, HEAD, POST") + + // Disallowed method special case: response to HEAD has got no body + runWrappedHandler(&http.Request{Method: "HEAD"}, "GET", "POST") + test.AssertEquals(t, stubCalled, true) + test.AssertEquals(t, rw.Body.String(), "") + + // HEAD doesn't work with POST-only endpoints + runWrappedHandler(&http.Request{Method: "HEAD"}, "POST") + test.AssertEquals(t, stubCalled, false) + test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) + test.AssertEquals(t, rw.Header().Get("Content-Type"), "application/problem+json") + test.AssertEquals(t, rw.Header().Get("Allow"), "POST") + assertJSONEquals(t, rw.Body.String(), `{"type":"urn:acme:error:malformed","detail":"Method not allowed","status":405}`) + + wfe.AllowOrigins = []string{"*"} + testOrigin := "https://example.com" + + // CORS "actual" request for disallowed method + runWrappedHandler(&http.Request{ + Method: "POST", + Header: map[string][]string{ + "Origin": {testOrigin}, + }, + }, "GET") + test.AssertEquals(t, stubCalled, false) + test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) + + // CORS "actual" request for allowed method + runWrappedHandler(&http.Request{ + Method: "GET", + Header: map[string][]string{ + "Origin": {testOrigin}, + }, + }, "GET", "POST") + test.AssertEquals(t, stubCalled, true) + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Methods"), "") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") + test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Expose-Headers")), "Link, Replay-Nonce") + + // CORS preflight request for disallowed method + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + "Access-Control-Request-Method": {"POST"}, + }, + }, "GET") + test.AssertEquals(t, stubCalled, false) + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Allow"), "GET, HEAD") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") + + // CORS preflight request for allowed method + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + "Access-Control-Request-Method": {"POST"}, + "Access-Control-Request-Headers": {"X-Accept-Header1, X-Accept-Header2", "X-Accept-Header3"}, + }, + }, "GET", "POST") + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") + test.AssertEquals(t, rw.Header().Get("Access-Control-Max-Age"), "86400") + test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Allow-Methods")), "GET, HEAD, POST") + test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Expose-Headers")), "Link, Replay-Nonce") + + // OPTIONS request without an Origin header (i.e., not a CORS + // preflight request) + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Access-Control-Request-Method": {"POST"}, + }, + }, "GET", "POST") + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") + test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), "GET, HEAD, POST") + + // CORS preflight request missing optional Request-Method + // header. The "actual" request will be GET. + for _, allowedMethod := range []string{"GET", "POST"} { + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + }, + }, allowedMethod) + test.AssertEquals(t, rw.Code, http.StatusOK) + if allowedMethod == "GET" { + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Methods"), "GET, HEAD") + } else { + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") + } + } + + // No CORS headers are given when configuration does not list + // "*" or the client-provided origin. + for _, wfe.AllowOrigins = range [][]string{ + {}, + {"http://example.com", "https://other.example"}, + {""}, // Invalid origin is never matched + } { + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + "Access-Control-Request-Method": {"POST"}, + }, + }, "POST") + test.AssertEquals(t, rw.Code, http.StatusOK) + for _, h := range []string{ + "Access-Control-Allow-Methods", + "Access-Control-Allow-Origin", + "Access-Control-Expose-Headers", + "Access-Control-Request-Headers", + } { + test.AssertEquals(t, rw.Header().Get(h), "") + } + } + + // CORS headers are offered when configuration lists "*" or + // the client-provided origin. + for _, wfe.AllowOrigins = range [][]string{ + {testOrigin, "http://example.org", "*"}, + {"", "http://example.org", testOrigin}, // Invalid origin is harmless + } { + runWrappedHandler(&http.Request{ + Method: "OPTIONS", + Header: map[string][]string{ + "Origin": {testOrigin}, + "Access-Control-Request-Method": {"POST"}, + }, + }, "POST") + test.AssertEquals(t, rw.Code, http.StatusOK) + test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), testOrigin) + // http://www.w3.org/TR/cors/ section 6.4: + test.AssertEquals(t, rw.Header().Get("Vary"), "Origin") + } +} + +func TestIndexPOST(t *testing.T) { + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + url, _ := url.Parse("/") + wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "POST", + URL: url, + }) + test.AssertEquals(t, responseWriter.Code, http.StatusMethodNotAllowed) +} + +func TestPOST404(t *testing.T) { + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + url, _ := url.Parse("/foobar") + wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "POST", + URL: url, + }) + test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) +} + +func TestIndex(t *testing.T) { + wfe, _ := setupWFE(t) + wfe.IndexCacheDuration = time.Second * 10 + + responseWriter := httptest.NewRecorder() + + url, _ := url.Parse("/") + wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "GET", + URL: url, + }) + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + test.AssertNotEquals(t, responseWriter.Body.String(), "404 page not found\n") + test.Assert(t, strings.Contains(responseWriter.Body.String(), directoryPath), + "directory path not found") + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "public, max-age=0, no-cache") + + responseWriter.Body.Reset() + responseWriter.Header().Del("Cache-Control") + url, _ = url.Parse("/foo") + wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ + URL: url, + }) + //test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) + test.AssertEquals(t, responseWriter.Body.String(), "404 page not found\n") + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "") +} + +func TestDirectory(t *testing.T) { + // Note: using `wfe.BaseURL` to test the non-relative /directory behaviour + // This tests to ensure the `Host` in the following `http.Request` is not + // used.by setting `BaseURL` using `localhost`, sending `127.0.0.1` in the Host, + // and expecting `localhost` in the JSON result. + _ = features.Set(map[string]bool{"AllowKeyRollover": true}) + defer features.Reset() + wfe, _ := setupWFE(t) + wfe.BaseURL = "http://localhost:4300" + mux := wfe.Handler() + + responseWriter := httptest.NewRecorder() + + url, _ := url.Parse("/directory") + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + URL: url, + Host: "127.0.0.1:4300", + }) + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + assertJSONEquals(t, responseWriter.Body.String(), `{"key-change":"http://localhost:4300/acme/key-change","new-authz":"http://localhost:4300/acme/new-authz","new-cert":"http://localhost:4300/acme/new-cert","new-reg":"http://localhost:4300/acme/new-reg","revoke-cert":"http://localhost:4300/acme/revoke-cert"}`) + + // With the DirectoryMeta flag enabled we expect to see a "meta" entry with + // the correct terms-of-service URL. + _ = features.Set(map[string]bool{"DirectoryMeta": true}) + responseWriter.Body.Reset() + url, _ = url.Parse("/directory") + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + URL: url, + Host: "127.0.0.1:4300", + }) + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + assertJSONEquals(t, responseWriter.Body.String(), `{"key-change":"http://localhost:4300/acme/key-change","meta":{"terms-of-service":"http://example.invalid/terms"},"new-authz":"http://localhost:4300/acme/new-authz","new-cert":"http://localhost:4300/acme/new-cert","new-reg":"http://localhost:4300/acme/new-reg","revoke-cert":"http://localhost:4300/acme/revoke-cert"}`) + + // Even with the DirectoryMeta flag enabled, if the UA is + // LetsEncryptPythonClient we expect to *not* see the meta entry. + responseWriter.Body.Reset() + url, _ = url.Parse("/directory") + headers := map[string][]string{ + "User-Agent": {"LetsEncryptPythonClient"}, + } + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + URL: url, + Host: "127.0.0.1:4300", + Header: headers, + }) + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + assertJSONEquals(t, responseWriter.Body.String(), `{"new-authz":"http://localhost:4300/acme/new-authz","new-cert":"http://localhost:4300/acme/new-cert","new-reg":"http://localhost:4300/acme/new-reg","revoke-cert":"http://localhost:4300/acme/revoke-cert"}`) +} + +func TestRandomDirectoryKey(t *testing.T) { + _ = features.Set(map[string]bool{"RandomDirectoryEntry": true}) + defer features.Reset() + wfe, _ := setupWFE(t) + wfe.BaseURL = "http://localhost:4300" + + responseWriter := httptest.NewRecorder() + url, _ := url.Parse("/directory") + wfe.Directory(ctx, &requestEvent{}, responseWriter, &http.Request{ + Method: "GET", + URL: url, + Host: "127.0.0.1:4300", + }) + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + var dir map[string]interface{} + if err := json.Unmarshal(responseWriter.Body.Bytes(), &dir); err != nil { + t.Errorf("Failed to unmarshal directory: %s", err) + } + found := false + for _, v := range dir { + if v == randomDirKeyExplanationLink { + found = true + break + } + } + if !found { + t.Errorf("Failed to find random entry in directory: %s", responseWriter.Body.String()) + } + + responseWriter.Body.Reset() + headers := map[string][]string{ + "User-Agent": {"LetsEncryptPythonClient"}, + } + wfe.Directory(ctx, &requestEvent{}, responseWriter, &http.Request{ + Method: "GET", + URL: url, + Host: "127.0.0.1:4300", + Header: headers, + }) + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + dir = map[string]interface{}{} + if err := json.Unmarshal(responseWriter.Body.Bytes(), &dir); err != nil { + t.Errorf("Failed to unmarshal directory: %s", err) + } + found = false + for _, v := range dir { + if v == randomDirKeyExplanationLink { + found = true + break + } + } + if found { + t.Error("Found random entry in directory with 'LetsEncryptPythonClient' UA") + } +} + +func TestRelativeDirectory(t *testing.T) { + _ = features.Set(map[string]bool{"AllowKeyRollover": true}) + defer features.Reset() + wfe, _ := setupWFE(t) + mux := wfe.Handler() + + dirTests := []struct { + host string + protoHeader string + result string + }{ + // Test '' (No host header) with no proto header + {"", "", `{"key-change":"http://localhost/acme/key-change","new-authz":"http://localhost/acme/new-authz","new-cert":"http://localhost/acme/new-cert","new-reg":"http://localhost/acme/new-reg","revoke-cert":"http://localhost/acme/revoke-cert"}`}, + // Test localhost:4300 with no proto header + {"localhost:4300", "", `{"key-change":"http://localhost:4300/acme/key-change","new-authz":"http://localhost:4300/acme/new-authz","new-cert":"http://localhost:4300/acme/new-cert","new-reg":"http://localhost:4300/acme/new-reg","revoke-cert":"http://localhost:4300/acme/revoke-cert"}`}, + // Test 127.0.0.1:4300 with no proto header + {"127.0.0.1:4300", "", `{"key-change":"http://127.0.0.1:4300/acme/key-change","new-authz":"http://127.0.0.1:4300/acme/new-authz","new-cert":"http://127.0.0.1:4300/acme/new-cert","new-reg":"http://127.0.0.1:4300/acme/new-reg","revoke-cert":"http://127.0.0.1:4300/acme/revoke-cert"}`}, + // Test localhost:4300 with HTTP proto header + {"localhost:4300", "http", `{"key-change":"http://localhost:4300/acme/key-change","new-authz":"http://localhost:4300/acme/new-authz","new-cert":"http://localhost:4300/acme/new-cert","new-reg":"http://localhost:4300/acme/new-reg","revoke-cert":"http://localhost:4300/acme/revoke-cert"}`}, + // Test localhost:4300 with HTTPS proto header + {"localhost:4300", "https", `{"key-change":"https://localhost:4300/acme/key-change","new-authz":"https://localhost:4300/acme/new-authz","new-cert":"https://localhost:4300/acme/new-cert","new-reg":"https://localhost:4300/acme/new-reg","revoke-cert":"https://localhost:4300/acme/revoke-cert"}`}, + } + + for _, tt := range dirTests { + var headers map[string][]string + responseWriter := httptest.NewRecorder() + + if tt.protoHeader != "" { + headers = map[string][]string{ + "X-Forwarded-Proto": {tt.protoHeader}, + } + } + + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + Host: tt.host, + URL: mustParseURL(directoryPath), + Header: headers, + }) + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + assertJSONEquals(t, responseWriter.Body.String(), tt.result) + } +} + +// TODO: Write additional test cases for: +// - RA returns with a failure +func TestIssueCertificate(t *testing.T) { + _ = features.Set(map[string]bool{"UseAIAIssuerURL": false}) + defer features.Reset() + wfe, fc := setupWFE(t) + mux := wfe.Handler() + mockLog := wfe.log.(*blog.Mock) + + // The mock CA we use always returns the same test certificate, with a Not + // Before of 2015-09-22. Since we're currently using a real RA instead of a + // mock (see below), that date would trigger failures for excessive + // backdating. So we set the fake clock's time to a time that matches that + // test certificate. + testTime := time.Date(2015, 9, 9, 22, 56, 0, 0, time.UTC) + fc.Add(fc.Now().Sub(testTime)) + + mockCertPEM, err := ioutil.ReadFile("test/not-an-example.com.crt") + test.AssertNotError(t, err, "Could not load mock cert") + + // TODO: Use a mock RA so we can test various conditions of authorized, not + // authorized, etc. + stats := metrics.NewNoopScope() + ra := ra.NewRegistrationAuthorityImpl( + fc, + wfe.log, + stats, + 0, + testKeyPolicy, + 0, + true, + false, + 300*24*time.Hour, + 7*24*time.Hour, + nil, + ) + ra.SA = mocks.NewStorageAuthority(fc) + ra.CA = &mocks.MockCA{ + PEM: mockCertPEM, + } + ra.PA = &mockPA{} + wfe.RA = ra + responseWriter := httptest.NewRecorder() + + // GET instead of POST should be rejected + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL(newCertPath), + }) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Method not allowed","status":405}`) + + // POST, but no body. + responseWriter.Body.Reset() + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "POST", + Header: map[string][]string{ + "Content-Length": {"0"}, + }, + }) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"No body on POST","status":400}`) + + // POST, but body that isn't valid JWS + responseWriter.Body.Reset() + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, makePostRequest("hi")) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Parse error reading JWS","status":400}`) + + // POST, Properly JWS-signed, but payload is "foo", not base64-encoded JSON. + responseWriter.Body.Reset() + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, "foo", wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Request payload did not parse as JSON","status":400}`) + + // Valid, signed JWS body, payload is '{}' + responseWriter.Body.Reset() + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest( + signRequest(t, "{}", wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Request payload does not specify a resource","status":400}`) + + // Valid, signed JWS body, payload is '{"resource":"new-cert"}' + responseWriter.Body.Reset() + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, `{"resource":"new-cert"}`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Error parsing certificate request: asn1: syntax error: sequence truncated","status":400}`) + + // Valid, signed JWS body, payload has an invalid signature on CSR and no authorizations: + // alias b64url="base64 -w0 | sed -e 's,+,-,g' -e 's,/,_,g'" + // openssl req -outform der -new -nodes -key wfe/test/178.key -subj /CN=foo.com | \ + // sed 's/foo.com/fob.com/' | b64url + responseWriter.Body.Reset() + wfe.NewCertificate(ctx, newRequestEvent(), + responseWriter, + makePostRequest(signRequest(t, `{ + "resource":"new-cert", + "csr": "MIICVzCCAT8CAQAwEjEQMA4GA1UEAwwHZm9iLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKzHhqcMSTVjBu61vufGVmIYM4mMbWXgndHOUWnIqSKcNtFtPQ465tcZRT5ITIZWXGjsmgDrj31qvG3t5qLwyaF5hsTvFHK72nLMAQhdgM6481Qe9yaoaulWpkGr_9LVz4jQ9pGAaLVamXGpSxV-ipTOo79Sev4aZE8ksD9atEfWtcOD9w8_zj74vpWjTAHN49Q88chlChVqakn0zSfHPfS-jF8g0UTddBuF0Ti3sZChjxzbo6LwZ4182xX7XPnOLav3AGj0Su7j5XMl3OpenOrlWulWJeZIHq5itGW321j306XiGdbrdWH4K7JygICFds6oolwQRGBY6yinAtCgkTcCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQBxPiHOtKuBxtvecMNtLkTSuTyEkusQGnjoFDaKe5oqwGYQgy0YBii2-BbaPmqS4ZaDc-vDz_RLeKH5ZiH-NliYR1V_CRtpFLQi18g_2pLQnZLVO3ENs-SM37nU_nBGn9O93t2bkssoM3fZmtgp3R2W7I_wvx7Z8oWKa4boTeBAg_q9Gmi6QskZBddK7A4S_vOR0frU6QSPK_ksPhvovp9fwb6CVKrlJWf556UwRPWgbkW39hvTxK2KHhrUEg3oawNkWde2jZtnZ9e-9zpw8-_5O0X7-YN0ucbFTfQybce_ReuLlGepiHT5bvVavBZoIvqw1XOgSMvGgZFU8tAWMBlj" + }`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Error creating new cert :: invalid signature on CSR","status":400}`) + + // Valid, signed JWS body, payload has a valid CSR but no authorizations: + // openssl req -outform der -new -nodes -key wfe/test/178.key -subj /CN=meep.com | b64url + mockLog.Clear() + responseWriter.Body.Reset() + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, `{ + "resource":"new-cert", + "csr": "MIICWDCCAUACAQAwEzERMA8GA1UEAwwIbWVlcC5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCaqzue57mgXEoGTZZoVkkCZraebWgXI8irX2BgQB1A3iZa9onxGPMcWQMxhSuUisbEJi4UkMcVST12HX01rUwhj41UuBxJvI1w4wvdstssTAaa9c9tsQ5-UED2bFRL1MsyBdbmCF_-pu3i-ZIYqWgiKbjVBe3nlAVbo77zizwp3Y4Tp1_TBOwTAuFkHePmkNT63uPm9My_hNzsSm1o-Q519Cf7ry-JQmOVgz_jIgFVGFYJ17EV3KUIpUuDShuyCFATBQspgJSN2DoXRUlQjXXkNTj23OxxdT_cVLcLJjytyG6e5izME2R2aCkDBWIc1a4_sRJ0R396auPXG6KhJ7o_AgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEALu046p76aKgvoAEHFINkMTgKokPXf9mZ4IZx_BKz-qs1MPMxVtPIrQDVweBH6tYT7Hfj2naLry6SpZ3vUNP_FYeTFWgW1V03LiqacX-QQgbEYtn99Dt3ScGyzb7EH833ztb3vDJ_-ha_CJplIrg-kHBBrlLFWXhh-I9K1qLRTNpbhZ18ooFde4Sbhkw9o9fKivGhx9aYr7ZbjRsNtKit_DsG1nwEXz53TMJ2vB9IQY29coJv_n5NFLkvBfzbG5faRNiFcimPYBO2jFdaA2mWzfxltLtwMF_dBwzTXDpMo3TVT9zEdV8YpsWqr63igqGDZVpKenlkqvRTeGJVayVuMA" + }`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:unauthorized","detail":"Error creating new cert :: authorizations for these names not found or expired: meep.com","status":403}`) + assertCsrLogged(t, mockLog) + + mockLog.Clear() + responseWriter.Body.Reset() + // openssl req -outform der -new -nodes -key wfe/test/178.key -subj /CN=not-an-example.com | b64url + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, `{ + "resource":"new-cert", + "csr": "MIICYjCCAUoCAQAwHTEbMBkGA1UEAwwSbm90LWFuLWV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmqs7nue5oFxKBk2WaFZJAma2nm1oFyPIq19gYEAdQN4mWvaJ8RjzHFkDMYUrlIrGxCYuFJDHFUk9dh19Na1MIY-NVLgcSbyNcOML3bLbLEwGmvXPbbEOflBA9mxUS9TLMgXW5ghf_qbt4vmSGKloIim41QXt55QFW6O-84s8Kd2OE6df0wTsEwLhZB3j5pDU-t7j5vTMv4Tc7EptaPkOdfQn-68viUJjlYM_4yIBVRhWCdexFdylCKVLg0obsghQEwULKYCUjdg6F0VJUI115DU49tzscXU_3FS3CyY8rchunuYszBNkdmgpAwViHNWuP7ESdEd_emrj1xuioSe6PwIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAE_T1nWU38XVYL28hNVSXU0rW5IBUKtbvr0qAkD4kda4HmQRTYkt-LNSuvxoZCC9lxijjgtJi-OJe_DCTdZZpYzewlVvcKToWSYHYQ6Wm1-fxxD_XzphvZOujpmBySchdiz7QSVWJmVZu34XD5RJbIcrmj_cjRt42J1hiTFjNMzQu9U6_HwIMmliDL-soFY2RTvvZf-dAFvOUQ-Wbxt97eM1PbbmxJNWRhbAmgEpe9PWDPTpqV5AK56VAa991cQ1P8ZVmPss5hvwGWhOtpnpTZVHN3toGNYFKqxWPboirqushQlfKiFqT9rpRgM3-mFjOHidGqsKEkTdmfSVlVEk3oo=" + }`, wfe.nonceService))) + assertCsrLogged(t, mockLog) + cert, err := core.LoadCert("test/not-an-example.com.crt") + test.AssertNotError(t, err, "Could not load cert") + test.AssertEquals(t, + responseWriter.Body.String(), + string(cert.Raw)) + test.AssertEquals( + t, responseWriter.Header().Get("Location"), + "http://localhost/acme/cert/0000ff0000000000000e4b4f67d86e818c46") + test.AssertEquals( + t, responseWriter.Header().Get("Link"), + `;rel="up"`) + test.AssertEquals( + t, responseWriter.Header().Get("Content-Type"), + "application/pkix-cert") + reqlogs := mockLog.GetAllMatching(`Certificate request - successful`) + test.AssertEquals(t, len(reqlogs), 1) + test.AssertContains(t, reqlogs[0], `INFO: `) + test.AssertContains(t, reqlogs[0], `[AUDIT] `) + test.AssertContains(t, reqlogs[0], `"CommonName":"not-an-example.com",`) + + mockLog.Clear() + responseWriter.HeaderMap = http.Header{} + _ = features.Set(map[string]bool{"UseAIAIssuerURL": true}) + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, `{ + "resource":"new-cert", + "csr": "MIICYjCCAUoCAQAwHTEbMBkGA1UEAwwSbm90LWFuLWV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmqs7nue5oFxKBk2WaFZJAma2nm1oFyPIq19gYEAdQN4mWvaJ8RjzHFkDMYUrlIrGxCYuFJDHFUk9dh19Na1MIY-NVLgcSbyNcOML3bLbLEwGmvXPbbEOflBA9mxUS9TLMgXW5ghf_qbt4vmSGKloIim41QXt55QFW6O-84s8Kd2OE6df0wTsEwLhZB3j5pDU-t7j5vTMv4Tc7EptaPkOdfQn-68viUJjlYM_4yIBVRhWCdexFdylCKVLg0obsghQEwULKYCUjdg6F0VJUI115DU49tzscXU_3FS3CyY8rchunuYszBNkdmgpAwViHNWuP7ESdEd_emrj1xuioSe6PwIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAE_T1nWU38XVYL28hNVSXU0rW5IBUKtbvr0qAkD4kda4HmQRTYkt-LNSuvxoZCC9lxijjgtJi-OJe_DCTdZZpYzewlVvcKToWSYHYQ6Wm1-fxxD_XzphvZOujpmBySchdiz7QSVWJmVZu34XD5RJbIcrmj_cjRt42J1hiTFjNMzQu9U6_HwIMmliDL-soFY2RTvvZf-dAFvOUQ-Wbxt97eM1PbbmxJNWRhbAmgEpe9PWDPTpqV5AK56VAa991cQ1P8ZVmPss5hvwGWhOtpnpTZVHN3toGNYFKqxWPboirqushQlfKiFqT9rpRgM3-mFjOHidGqsKEkTdmfSVlVEk3oo=" + }`, wfe.nonceService))) + test.AssertEquals( + t, responseWriter.Header().Get("Link"), + `;rel="up"`) + + mockLog.Clear() + responseWriter.Body.Reset() + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, `{ + "resource": "new-cert", + "csr": "MIICWjCCAUICADAWMRQwEgYDVQQDEwtleGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMpwCSKfLhKC3SnvLNpVayAEyAHVixkusgProAPZRBH0VAog_r4JOfoJez7ABiZ2ZIXXA2gg65_05HkGNl9ww-sa0EY8eCty_8WcHxqzafUnyXOJZuLMPJjaJ2oiBv_3BM7PZgpFzyNZ0_0ZuRKdFGtEY-vX9GXZUV0A3sxZMOpce0lhHAiBk_vNARJyM2-O-cZ7WjzZ7R1T9myAyxtsFhWy3QYvIwiKVVF3lDp3KXlPZ_7wBhVIBcVSk0bzhseotyUnKg-aL5qZIeB1ci7IT5qA_6C1_bsCSJSbQ5gnQwIQ0iaUV_SgUBpKNqYbmnSdZmDxvvW8FzhuL6JSDLfBR2kCAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQBxxkchTXfjv07aSWU9brHnRziNYOLvsSNiOWmWLNlZg9LKdBy6j1xwM8IQRCfTOVSkbuxVV-kU5p-Cg9UF_UGoerl3j8SiupurTovK9-L_PdX0wTKbK9xkh7OUq88jp32Rw0eAT87gODJRD-M1NXlTvm-j896e60hUmL-DIe3iPbFl8auUS-KROAWjci-LJZYVdomm9Iw47E-zr4Hg27EdZhvCZvSyPMK8ioys9mNg5TthHB6ExepKP1YW3HpQa1EdUVYWGEvyVL4upQZOxuEA1WJqHv6iVDzsQqkl5kkahK87NKTPS59k1TFetjw2GLnQ09-g_L7kT8dpq3Bk5Wo=" + }`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"CSR generated using a pre-1.0.2 OpenSSL with a client that doesn't properly specify the CSR version. See https://community.letsencrypt.org/t/openssl-bug-information/19591","status":400}`) +} + +func TestGetChallenge(t *testing.T) { + wfe, _ := setupWFE(t) + + challengeURL := "http://localhost/acme/challenge/valid/23" + + for _, method := range []string{"GET", "HEAD"} { + resp := httptest.NewRecorder() + + req, err := http.NewRequest(method, challengeURL, nil) + req.URL.Path = "valid/23" + test.AssertNotError(t, err, "Could not make NewRequest") + + wfe.Challenge(ctx, newRequestEvent(), resp, req) + test.AssertEquals(t, + resp.Code, + http.StatusAccepted) + test.AssertEquals(t, + resp.Header().Get("Location"), + challengeURL) + test.AssertEquals(t, + resp.Header().Get("Content-Type"), + "application/json") + test.AssertEquals(t, + resp.Header().Get("Link"), + `;rel="up"`) + // Body is only relevant for GET. For HEAD, body will + // be discarded by HandleFunc() anyway, so it doesn't + // matter what Challenge() writes to it. + if method == "GET" { + assertJSONEquals( + t, resp.Body.String(), + `{"type":"dns","uri":"http://localhost/acme/challenge/valid/23"}`) + } + } +} + +func TestChallenge(t *testing.T) { + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + + var key jose.JsonWebKey + err := json.Unmarshal([]byte(` + { + "e": "AQAB", + "kty": "RSA", + "n": "tSwgy3ORGvc7YJI9B2qqkelZRUC6F1S5NwXFvM4w5-M0TsxbFsH5UH6adigV0jzsDJ5imAechcSoOhAh9POceCbPN1sTNwLpNbOLiQQ7RD5mY_pSUHWXNmS9R4NZ3t2fQAzPeW7jOfF0LKuJRGkekx6tXP1uSnNibgpJULNc4208dgBaCHo3mvaE2HV2GmVl1yxwWX5QZZkGQGjNDZYnjFfa2DKVvFs0QbAk21ROm594kAxlRlMMrvqlf24Eq4ERO0ptzpZgm_3j_e4hGRD39gJS7kAzK-j2cacFQ5Qi2Y6wZI2p-FCq_wiYsfEAIkATPBiLKl_6d_Jfcvs_impcXQ" + } + `), &key) + test.AssertNotError(t, err, "Could not unmarshal testing key") + + challengeURL := "http://localhost/acme/challenge/valid/23" + path := "valid/23" + wfe.Challenge(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath(path, + signRequest(t, `{"resource":"challenge"}`, wfe.nonceService))) + + test.AssertEquals(t, responseWriter.Code, 202) + test.AssertEquals( + t, responseWriter.Header().Get("Location"), + challengeURL) + test.AssertEquals( + t, responseWriter.Header().Get("Link"), + `;rel="up"`) + assertJSONEquals( + t, responseWriter.Body.String(), + `{"type":"dns","uri":"http://localhost/acme/challenge/valid/23"}`) + + // Expired challenges should be inaccessible + challengeURL = "expired/23" + responseWriter = httptest.NewRecorder() + wfe.Challenge(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath(challengeURL, + signRequest(t, `{"resource":"challenge"}`, wfe.nonceService))) + test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) + assertJSONEquals(t, responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Expired authorization","status":404}`) + + // Challenge Not found + challengeURL = "" + responseWriter = httptest.NewRecorder() + wfe.Challenge(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath(challengeURL, + signRequest(t, `{"resource":"challenge"}`, wfe.nonceService))) + test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) + assertJSONEquals(t, responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"No such challenge","status":404}`) + + // Unspecified database error + errorURL := "error_result/24" + responseWriter = httptest.NewRecorder() + wfe.Challenge(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath(errorURL, + signRequest(t, `{"resource":"challenge"}`, wfe.nonceService))) + test.AssertEquals(t, responseWriter.Code, http.StatusInternalServerError) + assertJSONEquals(t, responseWriter.Body.String(), + `{"type":"urn:acme:error:serverInternal","detail":"Problem getting authorization","status":500}`) + +} + +func TestBadNonce(t *testing.T) { + wfe, _ := setupWFE(t) + + key, err := jose.LoadPrivateKey([]byte(test2KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + + responseWriter := httptest.NewRecorder() + result, err := signer.Sign([]byte(`{"resource":"new-reg","contact":["mailto:person@mail.com"],"agreement":"` + agreementURL + `"}`)) + test.AssertNotError(t, err, "Failed to sign body") + wfe.NewRegistration(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:badNonce","detail":"JWS has no anti-replay nonce","status":400}`) +} + +func TestNewECDSARegistration(t *testing.T) { + wfe, _ := setupWFE(t) + + // E1 always exists; E2 never exists + key, err := jose.LoadPrivateKey([]byte(testE2KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + ecdsaKey, ok := key.(*ecdsa.PrivateKey) + test.Assert(t, ok, "Couldn't load ECDSA key") + signer, err := jose.NewSigner("ES256", ecdsaKey) + test.AssertNotError(t, err, "Failed to make signer") + signer.SetNonceSource(wfe.nonceService) + + responseWriter := httptest.NewRecorder() + result, err := signer.Sign([]byte(`{"resource":"new-reg","contact":["mailto:person@mail.com"],"agreement":"` + agreementURL + `"}`)) + test.AssertNotError(t, err, "Failed to sign") + wfe.NewRegistration(ctx, newRequestEvent(), responseWriter, makePostRequest(result.FullSerialize())) + + var reg core.Registration + err = json.Unmarshal([]byte(responseWriter.Body.String()), ®) + test.AssertNotError(t, err, "Couldn't unmarshal returned registration object") + test.Assert(t, len(*reg.Contact) >= 1, "No contact field in registration") + test.AssertEquals(t, (*reg.Contact)[0], "mailto:person@mail.com") + test.AssertEquals(t, reg.Agreement, "http://example.invalid/terms") + test.AssertEquals(t, reg.InitialIP.String(), "1.1.1.1") + + test.AssertEquals(t, responseWriter.Header().Get("Location"), "http://localhost/acme/reg/0") + + key, err = jose.LoadPrivateKey([]byte(testE1KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + ecdsaKey, ok = key.(*ecdsa.PrivateKey) + test.Assert(t, ok, "Couldn't load ECDSA key") + signer, err = jose.NewSigner("ES256", ecdsaKey) + test.AssertNotError(t, err, "Failed to make signer") + signer.SetNonceSource(wfe.nonceService) + + // Reset the body and status code + responseWriter = httptest.NewRecorder() + // POST, Valid JSON, Key already in use + result, err = signer.Sign([]byte(`{"resource":"new-reg","contact":["mailto:person@mail.com"],"agreement":"` + agreementURL + `"}`)) + test.AssertNotError(t, err, "Failed to signer.Sign") + + wfe.NewRegistration(ctx, newRequestEvent(), responseWriter, makePostRequest(result.FullSerialize())) + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"Registration key is already in use","status":409}`) + test.AssertEquals(t, responseWriter.Header().Get("Location"), "http://localhost/acme/reg/3") + test.AssertEquals(t, responseWriter.Code, 409) +} + +// Test that the WFE handling of the "empty update" POST is correct. The ACME +// spec describes how when clients wish to query the server for information +// about a registration an empty registration update should be sent, and +// a populated reg object will be returned. +func TestEmptyRegistration(t *testing.T) { + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + + // Test Key 1 is mocked in the mock StorageAuthority used in setupWFE to + // return a populated registration for GetRegistrationByKey when test key 1 is + // used. + key, err := jose.LoadPrivateKey([]byte(test1KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + signer.SetNonceSource(wfe.nonceService) + + emptyReg := `{"resource":"reg"}` + emptyBody, err := signer.Sign([]byte(emptyReg)) + test.AssertNotError(t, err, "Unable to sign emptyBody") + + // Send a registration update with the trivial body + wfe.Registration( + ctx, + newRequestEvent(), + responseWriter, + makePostRequestWithPath("1", emptyBody.FullSerialize())) + + // There should be no error + test.AssertNotContains(t, responseWriter.Body.String(), "urn:acme:error") + + // We should get back a populated Registration + var reg core.Registration + err = json.Unmarshal([]byte(responseWriter.Body.String()), ®) + test.AssertNotError(t, err, "Couldn't unmarshal returned registration object") + test.Assert(t, len(*reg.Contact) >= 1, "No contact field in registration") + test.AssertEquals(t, (*reg.Contact)[0], "mailto:person@mail.com") + test.AssertEquals(t, reg.Agreement, "http://example.invalid/terms") + responseWriter.Body.Reset() +} + +func TestNewRegistration(t *testing.T) { + wfe, _ := setupWFE(t) + mux := wfe.Handler() + key, err := jose.LoadPrivateKey([]byte(test2KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + signer.SetNonceSource(wfe.nonceService) + fooBody, err := signer.Sign([]byte("foo")) + test.AssertNotError(t, err, "Unable to sign") + + wrongAgreementBody, err := signer.Sign([]byte(`{"resource":"new-reg","contact":["mailto:person@mail.com"],"agreement":"https://letsencrypt.org/im-bad"}`)) + test.AssertNotError(t, err, "Unable to sign") + + type newRegErrorTest struct { + r *http.Request + respBody string + } + regErrTests := []newRegErrorTest{ + // GET instead of POST should be rejected + { + &http.Request{ + Method: "GET", + URL: mustParseURL(newRegPath), + }, + `{"type":"urn:acme:error:malformed","detail":"Method not allowed","status":405}`, + }, + + // POST, but no body. + { + &http.Request{ + Method: "POST", + URL: mustParseURL(newRegPath), + Header: map[string][]string{ + "Content-Length": {"0"}, + }, + }, + `{"type":"urn:acme:error:malformed","detail":"No body on POST","status":400}`, + }, + + // POST, but body that isn't valid JWS + { + makePostRequestWithPath(newRegPath, "hi"), + `{"type":"urn:acme:error:malformed","detail":"Parse error reading JWS","status":400}`, + }, + + // POST, Properly JWS-signed, but payload is "foo", not base64-encoded JSON. + { + makePostRequestWithPath(newRegPath, fooBody.FullSerialize()), + `{"type":"urn:acme:error:malformed","detail":"Request payload did not parse as JSON","status":400}`, + }, + + // Same signed body, but payload modified by one byte, breaking signature. + // should fail JWS verification. + { + makePostRequestWithPath(newRegPath, ` + { + "header": { + "alg": "RS256", + "jwk": { + "e": "AQAB", + "kty": "RSA", + "n": "vd7rZIoTLEe-z1_8G1FcXSw9CQFEJgV4g9V277sER7yx5Qjz_Pkf2YVth6wwwFJEmzc0hoKY-MMYFNwBE4hQHw" + } + }, + "payload": "xm9vCg", + "signature": "RjUQ679fxJgeAJlxqgvDP_sfGZnJ-1RgWF2qmcbnBWljs6h1qp63pLnJOl13u81bP_bCSjaWkelGG8Ymx_X-aQ" + } + `), + `{"type":"urn:acme:error:malformed","detail":"JWS verification error","status":400}`, + }, + { + makePostRequestWithPath(newRegPath, wrongAgreementBody.FullSerialize()), + `{"type":"urn:acme:error:malformed","detail":"Provided agreement URL [https://letsencrypt.org/im-bad] does not match current agreement URL [` + agreementURL + `]","status":400}`, + }, + } + for _, rt := range regErrTests { + responseWriter := httptest.NewRecorder() + mux.ServeHTTP(responseWriter, rt.r) + assertJSONEquals(t, responseWriter.Body.String(), rt.respBody) + } + + responseWriter := httptest.NewRecorder() + result, err := signer.Sign([]byte(`{"resource":"new-reg","contact":["mailto:person@mail.com"],"agreement":"` + agreementURL + `"}`)) + wfe.NewRegistration(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + + var reg core.Registration + err = json.Unmarshal([]byte(responseWriter.Body.String()), ®) + test.AssertNotError(t, err, "Couldn't unmarshal returned registration object") + test.Assert(t, len(*reg.Contact) >= 1, "No contact field in registration") + test.AssertEquals(t, (*reg.Contact)[0], "mailto:person@mail.com") + test.AssertEquals(t, reg.Agreement, "http://example.invalid/terms") + test.AssertEquals(t, reg.InitialIP.String(), "1.1.1.1") + + test.AssertEquals( + t, responseWriter.Header().Get("Location"), + "http://localhost/acme/reg/0") + links := responseWriter.Header()["Link"] + test.AssertEquals(t, contains(links, ";rel=\"next\""), true) + test.AssertEquals(t, contains(links, "<"+agreementURL+">;rel=\"terms-of-service\""), true) + + test.AssertEquals( + t, responseWriter.Header().Get("Link"), + `;rel="next"`) + + key, err = jose.LoadPrivateKey([]byte(test1KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok = key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err = jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + signer.SetNonceSource(wfe.nonceService) + + // Reset the body and status code + responseWriter = httptest.NewRecorder() + // POST, Valid JSON, Key already in use + result, err = signer.Sign([]byte(`{"resource":"new-reg","contact":["mailto:person@mail.com"],"agreement":"` + agreementURL + `"}`)) + test.AssertNotError(t, err, "Failed to signer.Sign") + + wfe.NewRegistration(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Registration key is already in use","status":409}`) + test.AssertEquals( + t, responseWriter.Header().Get("Location"), + "http://localhost/acme/reg/1") + test.AssertEquals(t, responseWriter.Code, 409) +} + +func makeRevokeRequestJSON(reason *revocation.Reason) ([]byte, error) { + certPemBytes, err := ioutil.ReadFile("test/238.crt") + if err != nil { + return nil, err + } + certBlock, _ := pem.Decode(certPemBytes) + if err != nil { + return nil, err + } + revokeRequest := struct { + Resource string `json:"resource"` + CertificateDER core.JSONBuffer `json:"certificate"` + Reason *revocation.Reason `json:"reason"` + }{ + Resource: "revoke-cert", + CertificateDER: certBlock.Bytes, + Reason: reason, + } + revokeRequestJSON, err := json.Marshal(revokeRequest) + if err != nil { + return nil, err + } + return revokeRequestJSON, nil +} + +// An SA mock that always returns a berrors.NotFound type error. This is necessary +// because the standard mock in our mocks package always returns a given test +// registration when GetRegistrationByKey is called, and we want to get a +// berrors.NotFound type error for tests that pass regCheck = false to verifyPOST. +type mockSANoSuchRegistration struct { + core.StorageGetter +} + +func (msa mockSANoSuchRegistration) GetRegistrationByKey(ctx context.Context, jwk *jose.JsonWebKey) (core.Registration, error) { + return core.Registration{}, berrors.NotFoundError("reg not found") +} + +// Valid revocation request for existing, non-revoked cert, signed with cert +// key. +func TestRevokeCertificateCertKey(t *testing.T) { + keyPemBytes, err := ioutil.ReadFile("test/238.key") + test.AssertNotError(t, err, "Failed to load key") + key, err := jose.LoadPrivateKey(keyPemBytes) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + + revokeRequestJSON, err := makeRevokeRequestJSON(nil) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + + wfe, fc := setupWFE(t) + wfe.AcceptRevocationReason = true + wfe.SA = &mockSANoSuchRegistration{mocks.NewStorageAuthority(fc)} + responseWriter := httptest.NewRecorder() + + signer.SetNonceSource(wfe.nonceService) + result, _ := signer.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertEquals(t, responseWriter.Body.String(), "") +} + +func TestRevokeCertificateReasons(t *testing.T) { + keyPemBytes, err := ioutil.ReadFile("test/238.key") + test.AssertNotError(t, err, "Failed to load key") + key, err := jose.LoadPrivateKey(keyPemBytes) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + + // Valid reason + keyComp := revocation.Reason(1) + revokeRequestJSON, err := makeRevokeRequestJSON(&keyComp) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + + wfe, fc := setupWFE(t) + wfe.AcceptRevocationReason = true + ra := wfe.RA.(*MockRegistrationAuthority) + wfe.SA = &mockSANoSuchRegistration{mocks.NewStorageAuthority(fc)} + responseWriter := httptest.NewRecorder() + + signer.SetNonceSource(wfe.nonceService) + result, _ := signer.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertEquals(t, responseWriter.Body.String(), "") + test.AssertEquals(t, ra.lastRevocationReason, revocation.Reason(1)) + + // No reason + responseWriter = httptest.NewRecorder() + revokeRequestJSON, err = makeRevokeRequestJSON(nil) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + + signer.SetNonceSource(wfe.nonceService) + result, _ = signer.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertEquals(t, responseWriter.Body.String(), "") + test.AssertEquals(t, ra.lastRevocationReason, revocation.Reason(0)) + + // Unsupported reason + responseWriter = httptest.NewRecorder() + unsupported := revocation.Reason(2) + revokeRequestJSON, err = makeRevokeRequestJSON(&unsupported) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + + signer.SetNonceSource(wfe.nonceService) + result, _ = signer.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 400) + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"unsupported revocation reason code provided","status":400}`) + + responseWriter = httptest.NewRecorder() + unsupported = revocation.Reason(100) + revokeRequestJSON, err = makeRevokeRequestJSON(&unsupported) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + + signer.SetNonceSource(wfe.nonceService) + result, _ = signer.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 400) + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"unsupported revocation reason code provided","status":400}`) +} + +// Valid revocation request for existing, non-revoked cert, signed with account +// key. +func TestRevokeCertificateAccountKey(t *testing.T) { + revokeRequestJSON, err := makeRevokeRequestJSON(nil) + test.AssertNotError(t, err, "Failed to make revokeRequestJSON") + + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + + test1JWK, err := jose.LoadPrivateKey([]byte(test1KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + test1Key, ok := test1JWK.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + accountKeySigner, err := jose.NewSigner("RS256", test1Key) + test.AssertNotError(t, err, "Failed to make signer") + accountKeySigner.SetNonceSource(wfe.nonceService) + result, _ := accountKeySigner.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertEquals(t, responseWriter.Body.String(), "") +} + +// A revocation request signed by an unauthorized key. +func TestRevokeCertificateWrongKey(t *testing.T) { + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + test2JWK, err := jose.LoadPrivateKey([]byte(test2KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + test2Key, ok := test2JWK.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + accountKeySigner2, err := jose.NewSigner("RS256", test2Key) + test.AssertNotError(t, err, "Failed to make signer") + accountKeySigner2.SetNonceSource(wfe.nonceService) + revokeRequestJSON, err := makeRevokeRequestJSON(nil) + test.AssertNotError(t, err, "Unable to create revoke request") + + result, _ := accountKeySigner2.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 403) + assertJSONEquals(t, responseWriter.Body.String(), + `{"type":"urn:acme:error:unauthorized","detail":"Revocation request must be signed by private key of cert to be revoked, by the account key of the account that issued it, or by the account key of an account that holds valid authorizations for all names in the certificate.","status":403}`) +} + +// Valid revocation request for already-revoked cert +func TestRevokeCertificateAlreadyRevoked(t *testing.T) { + keyPemBytes, err := ioutil.ReadFile("test/178.key") + test.AssertNotError(t, err, "Failed to load key") + key, err := jose.LoadPrivateKey(keyPemBytes) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + + certPemBytes, err := ioutil.ReadFile("test/178.crt") + test.AssertNotError(t, err, "Failed to load cert") + certBlock, _ := pem.Decode(certPemBytes) + test.Assert(t, certBlock != nil, "Failed to decode PEM") + revokeRequest := struct { + Resource string `json:"resource"` + CertificateDER core.JSONBuffer `json:"certificate"` + }{ + Resource: "revoke-cert", + CertificateDER: certBlock.Bytes, + } + revokeRequestJSON, err := json.Marshal(revokeRequest) + test.AssertNotError(t, err, "Failed to marshal request") + + // POST, Properly JWS-signed, but payload is "foo", not base64-encoded JSON. + wfe, fc := setupWFE(t) + + wfe.SA = &mockSANoSuchRegistration{mocks.NewStorageAuthority(fc)} + responseWriter := httptest.NewRecorder() + responseWriter.Body.Reset() + signer.SetNonceSource(wfe.nonceService) + result, _ := signer.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 409) + assertJSONEquals(t, responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Certificate already revoked","status":409}`) +} + +func TestRevokeCertificateWithAuthz(t *testing.T) { + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + test4JWK, err := jose.LoadPrivateKey([]byte(test4KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + test4Key, ok := test4JWK.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + accountKeySigner, err := jose.NewSigner("RS256", test4Key) + test.AssertNotError(t, err, "Failed to make signer") + accountKeySigner.SetNonceSource(wfe.nonceService) + revokeRequestJSON, err := makeRevokeRequestJSON(nil) + test.AssertNotError(t, err, "Unable to create revoke request") + + result, _ := accountKeySigner.Sign(revokeRequestJSON) + wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(result.FullSerialize())) + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertEquals(t, responseWriter.Body.String(), "") +} + +func TestAuthorization(t *testing.T) { + wfe, _ := setupWFE(t) + mux := wfe.Handler() + + responseWriter := httptest.NewRecorder() + + // GET instead of POST should be rejected + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL(newAuthzPath), + }) + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"Method not allowed","status":405}`) + + // POST, but no body. + responseWriter.Body.Reset() + wfe.NewAuthorization(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "POST", + Header: map[string][]string{ + "Content-Length": {"0"}, + }, + }) + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"No body on POST","status":400}`) + + // POST, but body that isn't valid JWS + responseWriter.Body.Reset() + wfe.NewAuthorization(ctx, newRequestEvent(), responseWriter, makePostRequest("hi")) + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"Parse error reading JWS","status":400}`) + + // POST, Properly JWS-signed, but payload is "foo", not base64-encoded JSON. + responseWriter.Body.Reset() + wfe.NewAuthorization(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, "foo", wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Request payload did not parse as JSON","status":400}`) + + // Same signed body, but payload modified by one byte, breaking signature. + // should fail JWS verification. + responseWriter.Body.Reset() + wfe.NewAuthorization(ctx, newRequestEvent(), responseWriter, makePostRequest(` + { + "header": { + "alg": "RS256", + "jwk": { + "e": "AQAB", + "kty": "RSA", + "n": "vd7rZIoTLEe-z1_8G1FcXSw9CQFEJgV4g9V277sER7yx5Qjz_Pkf2YVth6wwwFJEmzc0hoKY-MMYFNwBE4hQHw" + } + }, + "payload": "xm9vCg", + "signature": "RjUQ679fxJgeAJlxqgvDP_sfGZnJ-1RgWF2qmcbnBWljs6h1qp63pLnJOl13u81bP_bCSjaWkelGG8Ymx_X-aQ" + } + `)) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"JWS verification error","status":400}`) + + responseWriter.Body.Reset() + wfe.NewAuthorization(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, `{"resource":"new-authz","identifier":{"type":"dns","value":"test.com"}}`, wfe.nonceService))) + + test.AssertEquals( + t, responseWriter.Header().Get("Location"), + "http://localhost/acme/authz/bkrPh2u0JUf18-rVBZtOOWWb3GuIiliypL-hBM9Ak1Q") + test.AssertEquals( + t, responseWriter.Header().Get("Link"), + `;rel="next"`) + + assertJSONEquals(t, responseWriter.Body.String(), `{"identifier":{"type":"dns","value":"test.com"}}`) + + var authz core.Authorization + err := json.Unmarshal([]byte(responseWriter.Body.String()), &authz) + test.AssertNotError(t, err, "Couldn't unmarshal returned authorization object") + + // Expired authorizations should be inaccessible + authzURL := "expired" + responseWriter = httptest.NewRecorder() + wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL(authzURL), + }) + test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) + assertJSONEquals(t, responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Expired authorization","status":404}`) + responseWriter.Body.Reset() + + // Ensure that a valid authorization can't be reached with an invalid URL + wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ + URL: mustParseURL("/a/bunch/of/garbage/valid"), + Method: "GET", + }) + assertJSONEquals(t, responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Unable to find authorization","status":404}`) +} + +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func TestRegistration(t *testing.T) { + _ = features.Set(map[string]bool{"AllowKeyRollover": true}) + defer features.Reset() + wfe, _ := setupWFE(t) + mux := wfe.Handler() + responseWriter := httptest.NewRecorder() + + // Test invalid method + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "MAKE-COFFEE", + URL: mustParseURL(regPath), + Body: makeBody("invalid"), + }) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Method not allowed","status":405}`) + responseWriter.Body.Reset() + + // Test GET proper entry returns 405 + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL(regPath), + }) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Method not allowed","status":405}`) + responseWriter.Body.Reset() + + // Test POST invalid JSON + wfe.Registration(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("2", "invalid")) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Parse error reading JWS","status":400}`) + responseWriter.Body.Reset() + + key, err := jose.LoadPrivateKey([]byte(test2KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + + // Test POST valid JSON but key is not registered + signer.SetNonceSource(wfe.nonceService) + result, err := signer.Sign([]byte(`{"resource":"reg","agreement":"` + agreementURL + `"}`)) + test.AssertNotError(t, err, "Unable to sign") + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("2", result.FullSerialize())) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:unauthorized","detail":"No registration exists matching provided key","status":403}`) + responseWriter.Body.Reset() + + key, err = jose.LoadPrivateKey([]byte(test1KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok = key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err = jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + + // Test POST valid JSON with registration up in the mock (with incorrect agreement URL) + signer.SetNonceSource(wfe.nonceService) + result, err = signer.Sign([]byte(`{"resource":"reg","agreement":"https://letsencrypt.org/im-bad"}`)) + + // Test POST valid JSON with registration up in the mock + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("1", result.FullSerialize())) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Provided agreement URL [https://letsencrypt.org/im-bad] does not match current agreement URL [`+agreementURL+`]","status":400}`) + responseWriter.Body.Reset() + + // Test POST valid JSON with registration up in the mock (with correct agreement URL) + result, err = signer.Sign([]byte(`{"resource":"reg","agreement":"` + agreementURL + `"}`)) + test.AssertNotError(t, err, "Couldn't sign") + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("1", result.FullSerialize())) + test.AssertNotContains(t, responseWriter.Body.String(), "urn:acme:error") + links := responseWriter.Header()["Link"] + test.AssertEquals(t, contains(links, ";rel=\"next\""), true) + test.AssertEquals(t, contains(links, "<"+agreementURL+">;rel=\"terms-of-service\""), true) + responseWriter.Body.Reset() + + // Test POST valid JSON with garbage in URL but valid registration ID + result, err = signer.Sign([]byte(`{"resource":"reg","agreement":"` + agreementURL + `"}`)) + test.AssertNotError(t, err, "Couldn't sign") + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("/a/bunch/of/garbage/1", result.FullSerialize())) + test.AssertContains(t, responseWriter.Body.String(), "400") + test.AssertContains(t, responseWriter.Body.String(), "urn:acme:error:malformed") + responseWriter.Body.Reset() + + // Test POST valid JSON with registration up in the mock (with old agreement URL) + responseWriter.HeaderMap = http.Header{} + wfe.SubscriberAgreementURL = "http://example.invalid/new-terms" + result, err = signer.Sign([]byte(`{"resource":"reg","agreement":"` + agreementURL + `"}`)) + test.AssertNotError(t, err, "Couldn't sign") + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("1", result.FullSerialize())) + test.AssertNotContains(t, responseWriter.Body.String(), "urn:acme:error") + links = responseWriter.Header()["Link"] + test.AssertEquals(t, contains(links, ";rel=\"next\""), true) + test.AssertEquals(t, contains(links, ";rel=\"terms-of-service\""), true) + responseWriter.Body.Reset() +} + +func TestTermsRedirect(t *testing.T) { + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + + path, _ := url.Parse("/terms") + wfe.Terms(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "GET", + URL: path, + }) + test.AssertEquals( + t, responseWriter.Header().Get("Location"), + agreementURL) + test.AssertEquals(t, responseWriter.Code, 302) +} + +func TestIssuer(t *testing.T) { + wfe, _ := setupWFE(t) + wfe.IssuerCacheDuration = time.Second * 10 + wfe.IssuerCert = []byte{0, 0, 1} + + responseWriter := httptest.NewRecorder() + + wfe.Issuer(ctx, newRequestEvent(), responseWriter, &http.Request{ + Method: "GET", + }) + test.AssertEquals(t, responseWriter.Code, http.StatusOK) + test.Assert(t, bytes.Compare(responseWriter.Body.Bytes(), wfe.IssuerCert) == 0, "Incorrect bytes returned") +} + +func TestGetCertificate(t *testing.T) { + _ = features.Set(map[string]bool{"UseAIAIssuerURL": false}) + defer features.Reset() + wfe, _ := setupWFE(t) + mux := wfe.Handler() + + wfe.CertCacheDuration = time.Second * 10 + wfe.CertNoCacheExpirationWindow = time.Hour * 24 * 7 + + certPemBytes, _ := ioutil.ReadFile("test/178.crt") + certBlock, _ := pem.Decode(certPemBytes) + + responseWriter := httptest.NewRecorder() + + mockLog := wfe.log.(*blog.Mock) + mockLog.Clear() + + // Valid serial, cached + req, _ := http.NewRequest("GET", "/acme/cert/0000000000000000000000000000000000b2", nil) + req.RemoteAddr = "192.168.0.1" + mux.ServeHTTP(responseWriter, req) + test.AssertEquals(t, responseWriter.Code, 200) + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "public, max-age=0, no-cache") + test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/pkix-cert") + test.Assert(t, bytes.Compare(responseWriter.Body.Bytes(), certBlock.Bytes) == 0, "Certificates don't match") + test.AssertEquals( + t, responseWriter.Header().Get("Link"), + `;rel="up"`) + + // Valid serial, UseAIAIssuerURL: true + mockLog.Clear() + responseWriter = httptest.NewRecorder() + _ = features.Set(map[string]bool{"UseAIAIssuerURL": true}) + req, _ = http.NewRequest("GET", "/acme/cert/0000000000000000000000000000000000b2", nil) + req.RemoteAddr = "192.168.0.1" + mux.ServeHTTP(responseWriter, req) + test.AssertEquals( + t, responseWriter.Header().Get("Link"), + `;rel="up"`) + + reqlogs := mockLog.GetAllMatching(`Successful request`) + test.AssertEquals(t, len(reqlogs), 1) + test.AssertContains(t, reqlogs[0], `INFO: `) + + // Unused serial, no cache + mockLog.Clear() + responseWriter = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/acme/cert/0000000000000000000000000000000000ff", nil) + req.RemoteAddr = "192.168.0.1" + req.Header.Set("X-Forwarded-For", "192.168.99.99") + mux.ServeHTTP(responseWriter, req) + test.AssertEquals(t, responseWriter.Code, 404) + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "public, max-age=0, no-cache") + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"Certificate not found","status":404}`) + + reqlogs = mockLog.GetAllMatching(`Terminated request`) + test.AssertEquals(t, len(reqlogs), 1) + test.AssertContains(t, reqlogs[0], `INFO: `) + + // Invalid serial, no cache + responseWriter = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/acme/cert/nothex", nil) + mux.ServeHTTP(responseWriter, req) + test.AssertEquals(t, responseWriter.Code, 404) + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "public, max-age=0, no-cache") + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"Certificate not found","status":404}`) + + // Invalid serial, no cache + responseWriter = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/acme/cert/00000000000000", nil) + mux.ServeHTTP(responseWriter, req) + test.AssertEquals(t, responseWriter.Code, 404) + test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "public, max-age=0, no-cache") + assertJSONEquals(t, responseWriter.Body.String(), `{"type":"urn:acme:error:malformed","detail":"Certificate not found","status":404}`) +} + +func assertCsrLogged(t *testing.T, mockLog *blog.Mock) { + matches := mockLog.GetAllMatching("^INFO: \\[AUDIT\\] Certificate request JSON=") + test.Assert(t, len(matches) == 1, + fmt.Sprintf("Incorrect number of certificate request log entries: %d", + len(matches))) +} + +func TestLogCsrPem(t *testing.T) { + const certificateRequestJSON = `{ + "csr": "MIICWTCCAUECAQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAycX3ca-fViOuRWF38mssORISFxbJvspDfhPGRBZDxJ63NIqQzupB-6dp48xkcX7Z_KDaRJStcpJT2S0u33moNT4FHLklQBETLhExDk66cmlz6Xibp3LGZAwhWuec7wJoEwIgY8oq4rxihIyGq7HVIJoq9DqZGrUgfZMDeEJqbphukQOaXGEop7mD-eeu8-z5EVkB1LiJ6Yej6R8MAhVPHzG5fyOu6YVo6vY6QgwjRLfZHNj5XthxgPIEETZlUbiSoI6J19GYHvLURBTy5Ys54lYAPIGfNwcIBAH4gtH9FrYcDY68R22rp4iuxdvkf03ZWiT0F2W1y7_C9B2jayTzvQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAHd6Do9DIZ2hvdt1GwBXYjsqprZidT_DYOMfYcK17KlvdkFT58XrBH88ulLZ72NXEpiFMeTyzfs3XEyGq_Bbe7TBGVYZabUEh-LOskYwhgcOuThVN7tHnH5rhN-gb7cEdysjTb1QL-vOUwYgV75CB6PE5JVYK-cQsMIVvo0Kz4TpNgjJnWzbcH7h0mtvub-fCv92vBPjvYq8gUDLNrok6rbg05tdOJkXsF2G_W-Q6sf2Fvx0bK5JeH4an7P7cXF9VG9nd4sRt5zd-L3IcyvHVKxNhIJXZVH0AOqh_1YrKI9R0QKQiZCEy0xN1okPlcaIVaFhb7IKAHPxTI3r5f72LXY" + }` + wfe, fc := setupWFE(t) + var certificateRequest core.CertificateRequest + err := json.Unmarshal([]byte(certificateRequestJSON), &certificateRequest) + test.AssertNotError(t, err, "Unable to parse certificateRequest") + + mockSA := mocks.NewStorageAuthority(fc) + reg, err := mockSA.GetRegistration(ctx, 789) + test.AssertNotError(t, err, "Unable to get registration") + + req, err := http.NewRequest("GET", "http://[::1]/", nil) + test.AssertNotError(t, err, "NewRequest failed") + req.RemoteAddr = "12.34.98.76" + req.Header.Set("X-Forwarded-For", "10.0.0.1,172.16.0.1") + + mockLog := wfe.log.(*blog.Mock) + mockLog.Clear() + + wfe.logCsr(req, certificateRequest, reg) + + assertCsrLogged(t, mockLog) +} + +func TestLengthRequired(t *testing.T) { + wfe, _ := setupWFE(t) + _, _, _, prob := wfe.verifyPOST(ctx, newRequestEvent(), &http.Request{ + Method: "POST", + URL: mustParseURL("/"), + }, false, "resource") + test.Assert(t, prob != nil, "No error returned for request body missing Content-Length.") + test.AssertEquals(t, probs.MalformedProblem, prob.Type) + test.AssertEquals(t, http.StatusLengthRequired, prob.HTTPStatus) +} + +type mockSADifferentStoredKey struct { + core.StorageGetter +} + +// TestLogPayload ensures that verifyPOST sets the Payload field of the logEvent +// it is passed. +func TestLogPayload(t *testing.T) { + wfe, _ := setupWFE(t) + event := newRequestEvent() + payload := `{"resource":"ima-payload"}` + _, _, _, err := wfe.verifyPOST(ctx, event, makePostRequest(signRequest(t, + payload, wfe.nonceService)), false, "ima-payload") + if err != nil { + t.Fatal(err) + } + + test.AssertEquals(t, event.Payload, payload) +} + +func (sa mockSADifferentStoredKey) GetRegistrationByKey(ctx context.Context, jwk *jose.JsonWebKey) (core.Registration, error) { + keyJSON := []byte(test2KeyPublicJSON) + var parsedKey jose.JsonWebKey + err := parsedKey.UnmarshalJSON(keyJSON) + if err != nil { + panic(err) + } + + return core.Registration{ + Key: &parsedKey, + }, nil +} + +func TestVerifyPOSTUsesStoredKey(t *testing.T) { + wfe, fc := setupWFE(t) + wfe.SA = &mockSADifferentStoredKey{mocks.NewStorageAuthority(fc)} + // signRequest signs with test1Key, but our special mock returns a + // registration with test2Key + _, _, _, err := wfe.verifyPOST(ctx, newRequestEvent(), makePostRequest(signRequest(t, `{"resource":"foo"}`, wfe.nonceService)), true, "foo") + test.AssertError(t, err, "No error returned when provided key differed from stored key.") +} + +func TestBadKeyCSR(t *testing.T) { + wfe, _ := setupWFE(t) + responseWriter := httptest.NewRecorder() + + // CSR with a bad (512 bit RSA) key. + // openssl req -outform der -new -newkey rsa:512 -nodes -keyout foo.com.key + // -subj /CN=foo.com | base64 -w0 | sed -e 's,+,-,g' -e 's,/,_,g' + wfe.NewCertificate(ctx, newRequestEvent(), responseWriter, + makePostRequest(signRequest(t, `{ + "resource":"new-cert", + "csr": "MIHLMHcCAQAwEjEQMA4GA1UEAwwHZm9vLmNvbTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQDCZftp4x4owgjBnwOKfzihIPedT-BUmV2fuQPMqaUlc8yJUp13vcO5uxUlaBm8leM7Dj_sgTDP_JgykorlYo73AgMBAAGgADANBgkqhkiG9w0BAQsFAANBAEaQ2QBhweK-kp1ejQCedUhMit_wG-uTBtKnc3M82f6_fztLkhg1vWQ782nmhbEI5orXp6QtNHgJYnBpqA9Ut00" + }`, wfe.nonceService))) + + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type":"urn:acme:error:malformed","detail":"Invalid key in certificate request :: key too small: 512","status":400}`) +} + +// This uses httptest.NewServer because ServeMux.ServeHTTP won't prevent the +// body from being sent like the net/http Server's actually do. +func TestGetCertificateHEADHasCorrectBodyLength(t *testing.T) { + wfe, _ := setupWFE(t) + + certPemBytes, _ := ioutil.ReadFile("test/178.crt") + certBlock, _ := pem.Decode(certPemBytes) + + mockLog := wfe.log.(*blog.Mock) + mockLog.Clear() + + mux := wfe.Handler() + s := httptest.NewServer(mux) + defer s.Close() + req, _ := http.NewRequest("HEAD", s.URL+"/acme/cert/0000000000000000000000000000000000b2", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + test.AssertNotError(t, err, "do error") + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + test.AssertNotEquals(t, err, "readall error") + } + err = resp.Body.Close() + if err != nil { + test.AssertNotEquals(t, err, "readall error") + } + test.AssertEquals(t, resp.StatusCode, 200) + test.AssertEquals(t, strconv.Itoa(len(certBlock.Bytes)), resp.Header.Get("Content-Length")) + test.AssertEquals(t, 0, len(body)) +} + +func newRequestEvent() *requestEvent { + return &requestEvent{Extra: make(map[string]interface{})} +} + +func TestVerifyPOSTInvalidJWK(t *testing.T) { + badJWS := `{"signatures":[{"header":{"jwk":{"kty":"RSA","n":"","e":""}}}],"payload":""}` + wfe, _ := setupWFE(t) + _, _, _, prob := wfe.verifyPOST(ctx, newRequestEvent(), makePostRequest(badJWS), false, "resource") + test.Assert(t, prob != nil, "No error returned for request body with invalid JWS key.") + test.AssertEquals(t, probs.MalformedProblem, prob.Type) + test.AssertEquals(t, http.StatusBadRequest, prob.HTTPStatus) +} + +func TestHeaderBoulderRequestId(t *testing.T) { + wfe, _ := setupWFE(t) + mux := wfe.Handler() + responseWriter := httptest.NewRecorder() + + mux.ServeHTTP(responseWriter, &http.Request{ + Method: "GET", + URL: mustParseURL(directoryPath), + }) + + requestID := responseWriter.Header().Get("Boulder-Request-ID") + test.Assert(t, len(requestID) > 0, "Boulder-Request-ID header is empty") +} + +func TestHeaderBoulderRequester(t *testing.T) { + wfe, _ := setupWFE(t) + mux := wfe.Handler() + responseWriter := httptest.NewRecorder() + + // create a signed request + key, err := jose.LoadPrivateKey([]byte(test1KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + + // requests that do not call sendError() have the requester header + signer.SetNonceSource(wfe.nonceService) + result, err := signer.Sign([]byte(`{"resource":"reg","agreement":"` + agreementURL + `"}`)) + request := makePostRequestWithPath(regPath+"1", result.FullSerialize()) + mux.ServeHTTP(responseWriter, request) + test.AssertEquals(t, responseWriter.Header().Get("Boulder-Requester"), "1") + + // requests that do call sendError() also should have the requester header + signer.SetNonceSource(wfe.nonceService) + result, err = signer.Sign([]byte(`{"resource":"reg","agreement":"https://letsencrypt.org/im-bad"}`)) + test.AssertNotError(t, err, "Failed to signer.Sign") + request = makePostRequestWithPath(regPath+"1", result.FullSerialize()) + mux.ServeHTTP(responseWriter, request) + test.AssertEquals(t, responseWriter.Header().Get("Boulder-Requester"), "1") +} + +func TestDeactivateAuthorization(t *testing.T) { + wfe, _ := setupWFE(t) + wfe.AllowAuthzDeactivation = true + responseWriter := httptest.NewRecorder() + + responseWriter.Body.Reset() + wfe.Authorization(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("valid", signRequest(t, `{"resource":"authz","status":""}`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type": "urn:acme:error:malformed","detail": "Invalid status value","status": 400}`) + + responseWriter.Body.Reset() + wfe.Authorization(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("valid", signRequest(t, `{"resource":"authz","status":"deactivated"}`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{ + "identifier": { + "type": "dns", + "value": "not-an-example.com" + }, + "status": "deactivated", + "expires": "2070-01-01T00:00:00Z", + "challenges": [ + { + "type": "dns", + "uri": "http://localhost/acme/challenge/valid/23" + } + ] + }`) +} + +func TestDeactivateRegistration(t *testing.T) { + responseWriter := httptest.NewRecorder() + wfe, _ := setupWFE(t) + _ = features.Set(map[string]bool{"AllowAccountDeactivation": true}) + defer features.Reset() + + responseWriter.Body.Reset() + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("1", signRequest(t, `{"resource":"reg","status":"asd"}`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{"type": "urn:acme:error:malformed","detail": "Invalid value provided for status field","status": 400}`) + + responseWriter.Body.Reset() + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("1", signRequest(t, `{"resource":"reg","status":"deactivated"}`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{ + "id": 1, + "key": { + "kty": "RSA", + "n": "yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e": "AQAB" + }, + "contact": [ + "mailto:person@mail.com" + ], + "agreement": "http://example.invalid/terms", + "initialIp": "", + "createdAt": "0001-01-01T00:00:00Z", + "Status": "deactivated" + }`) + + responseWriter.Body.Reset() + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("1", signRequest(t, `{"resource":"reg","status":"deactivated","contact":[]}`, wfe.nonceService))) + assertJSONEquals(t, + responseWriter.Body.String(), + `{ + "id": 1, + "key": { + "kty": "RSA", + "n": "yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", + "e": "AQAB" + }, + "contact": [ + "mailto:person@mail.com" + ], + "agreement": "http://example.invalid/terms", + "initialIp": "", + "createdAt": "0001-01-01T00:00:00Z", + "Status": "deactivated" + }`) + + key, err := jose.LoadPrivateKey([]byte(test3KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + signer.SetNonceSource(wfe.nonceService) + + result, err := signer.Sign([]byte(`{"resource":"reg","status":"deactivated"}`)) + test.AssertNotError(t, err, "Unable to sign") + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("2", result.FullSerialize())) + + responseWriter.Body.Reset() + wfe.Registration(ctx, newRequestEvent(), responseWriter, + makePostRequestWithPath("2", result.FullSerialize())) + assertJSONEquals(t, + responseWriter.Body.String(), + `{ + "type": "urn:acme:error:unauthorized", + "detail": "Registration is not valid, has status 'deactivated'", + "status": 403 + }`) +} + +func TestKeyRollover(t *testing.T) { + responseWriter := httptest.NewRecorder() + wfe, _ := setupWFE(t) + _ = features.Set(map[string]bool{"AllowAccountDeactivation": true}) + defer features.Reset() + + key, err := jose.LoadPrivateKey([]byte(test3KeyPrivatePEM)) + test.AssertNotError(t, err, "Failed to load key") + rsaKey, ok := key.(*rsa.PrivateKey) + test.Assert(t, ok, "Couldn't load RSA key") + signer, err := jose.NewSigner("RS256", rsaKey) + test.AssertNotError(t, err, "Failed to make signer") + signer.SetNonceSource(wfe.nonceService) + + wfe.KeyRollover(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("", "{}")) + assertJSONEquals(t, + responseWriter.Body.String(), + `{ + "type": "urn:acme:error:malformed", + "detail": "Parse error reading JWS", + "status": 400 + }`) + + for _, testCase := range []struct { + payload string + expectedResponse string + }{ + { + // Missing account URL + "{}", + `{ + "type": "urn:acme:error:malformed", + "detail": "Incorrect account URL provided in payload", + "status": 400 + }`, + }, + // Missing new key + { + `{"account":"http://localhost/acme/reg/1"}`, + `{ + "type": "urn:acme:error:malformed", + "detail": "Unable to marshal new JWK", + "status": 400 + }`, + }, + // Different key used to sign inner JWS + { + `{"newKey":{"kty":"RSA","n":"yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ","e":"AQAB"},"account":"http://localhost/acme/reg/1"}`, + `{ + "type": "urn:acme:error:malformed", + "detail": "New JWK in inner payload doesn't match key used to sign inner JWS", + "status": 400 + }`, + }, + // Valid request + { + `{"newKey":{"kty":"RSA","n":"uTQER6vUA1RDixS8xsfCRiKUNGRzzyIK0MhbS2biClShbb0hSx2mPP7gBvis2lizZ9r-y9hL57kNQoYCKndOBg0FYsHzrQ3O9AcoV1z2Mq-XhHZbFrVYaXI0M3oY9BJCWog0dyi3XC0x8AxC1npd1U61cToHx-3uSvgZOuQA5ffEn5L38Dz1Ti7OV3E4XahnRJvejadUmTkki7phLBUXm5MnnyFm0CPpf6ApV7zhLjN5W-nV0WL17o7v8aDgV_t9nIdi1Y26c3PlCEtiVHZcebDH5F1Deta3oLLg9-g6rWnTqPbY3knffhp4m0scLD6e33k8MtzxDX_D7vHsg0_X1w","e":"AQAB"},"account":"http://localhost/acme/reg/1"}`, + `{ + "id": 1, + "key": { + "kty": "RSA", + "n": "uTQER6vUA1RDixS8xsfCRiKUNGRzzyIK0MhbS2biClShbb0hSx2mPP7gBvis2lizZ9r-y9hL57kNQoYCKndOBg0FYsHzrQ3O9AcoV1z2Mq-XhHZbFrVYaXI0M3oY9BJCWog0dyi3XC0x8AxC1npd1U61cToHx-3uSvgZOuQA5ffEn5L38Dz1Ti7OV3E4XahnRJvejadUmTkki7phLBUXm5MnnyFm0CPpf6ApV7zhLjN5W-nV0WL17o7v8aDgV_t9nIdi1Y26c3PlCEtiVHZcebDH5F1Deta3oLLg9-g6rWnTqPbY3knffhp4m0scLD6e33k8MtzxDX_D7vHsg0_X1w", + "e": "AQAB" + }, + "contact": [ + "mailto:person@mail.com" + ], + "agreement": "http://example.invalid/terms", + "initialIp": "", + "createdAt": "0001-01-01T00:00:00Z", + "Status": "valid" + }`, + }, + } { + inner, err := signer.Sign([]byte(testCase.payload)) + test.AssertNotError(t, err, "Unable to sign") + innerStr := inner.FullSerialize() + innerStr = innerStr[:len(innerStr)-1] + `,"resource":"key-change"}` // awful + outer := signRequest(t, innerStr, wfe.nonceService) + + responseWriter.Body.Reset() + wfe.KeyRollover(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("", outer)) + assertJSONEquals(t, responseWriter.Body.String(), testCase.expectedResponse) + } +}