From 986c78a2b48a4f354214e45ec5ccae87afb2a414 Mon Sep 17 00:00:00 2001 From: Samantha Frank Date: Thu, 25 Jul 2024 13:46:40 -0400 Subject: [PATCH] WFE: Reject new orders containing paused identifiers (#7599) Part of #7406 Fixes #7475 --- cmd/boulder-wfe2/main.go | 29 ++++ cmd/config.go | 28 +++- cmd/sfe/main.go | 17 +-- core/util.go | 17 +++ core/util_test.go | 21 +++ docker-compose.yml | 2 +- features/features.go | 6 + probs/probs.go | 9 ++ sfe/pages/index.html | 2 +- sfe/pages/unpause-form.html | 10 +- sfe/sfe.go | 114 ++++------------ sfe/sfe_test.go | 219 ++----------------------------- test/config-next/sfe.json | 6 +- test/config-next/wfe2.json | 10 +- test/config/sfe.json | 6 +- test/integration/pausing_test.go | 75 +++++++++++ unpause/unpause.go | 141 ++++++++++++++++++++ unpause/unpause_test.go | 155 ++++++++++++++++++++++ wfe2/wfe.go | 63 +++++++++ wfe2/wfe_test.go | 15 +++ 20 files changed, 620 insertions(+), 325 deletions(-) create mode 100644 test/integration/pausing_test.go create mode 100644 unpause/unpause.go create mode 100644 unpause/unpause_test.go diff --git a/cmd/boulder-wfe2/main.go b/cmd/boulder-wfe2/main.go index 1fd10769019..d269f4cb5f7 100644 --- a/cmd/boulder-wfe2/main.go +++ b/cmd/boulder-wfe2/main.go @@ -23,6 +23,7 @@ import ( "github.com/letsencrypt/boulder/ratelimits" bredis "github.com/letsencrypt/boulder/redis" sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/unpause" "github.com/letsencrypt/boulder/web" "github.com/letsencrypt/boulder/wfe2" ) @@ -160,6 +161,25 @@ type Config struct { // Requests with a profile name not present in this map will be rejected. // This field is optional; if unset, no profile names are accepted. CertProfiles map[string]string `validate:"omitempty,dive,keys,alphanum,min=1,max=32,endkeys"` + + Unpause struct { + // HMACKey signs outgoing JWTs for redemption at the unpause + // endpoint. This key must match the one configured for all SFEs. + // This field is required to enable the pausing feature. + HMACKey cmd.HMACKeyConfig `validate:"required_with=JWTLifetime URL,structonly"` + + // JWTLifetime is the lifetime of the unpause JWTs generated by the + // WFE for redemption at the SFE. The minimum value for this field + // is 336h (14 days). This field is required to enable the pausing + // feature. + JWTLifetime config.Duration `validate:"omitempty,required_with=HMACKey URL,min=336h"` + + // URL is the URL of the Self-Service Frontend (SFE). This is used + // to build URLs sent to end-users in error messages. This field + // must be a URL with a scheme of 'https://' This field is required + // to enable the pausing feature. + URL string `validate:"omitempty,required_with=HMACKey JWTLifetime,url,startswith=https://,endsnotwith=/"` + } } Syslog cmd.SyslogConfig @@ -248,6 +268,12 @@ func main() { clk := cmd.Clock() + var unpauseSigner unpause.JWTSigner + if features.Get().CheckIdentifiersPaused { + unpauseSigner, err = unpause.NewJWTSigner(c.WFE.Unpause.HMACKey) + cmd.FailOnError(err, "Failed to create unpause signer from HMACKey") + } + tlsConfig, err := c.WFE.TLS.Load(stats) cmd.FailOnError(err, "TLS config") @@ -356,6 +382,9 @@ func main() { txnBuilder, maxNames, c.WFE.CertProfiles, + unpauseSigner, + c.WFE.Unpause.JWTLifetime.Duration, + c.WFE.Unpause.URL, ) cmd.FailOnError(err, "Unable to create WFE") diff --git a/cmd/config.go b/cmd/config.go index d7408936e6f..08fa36b69df 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -554,11 +554,25 @@ type DNSProvider struct { SRVLookup ServiceDomain `validate:"required"` } -type UnpauseConfig struct { - // HMACKey is a shared symmetric secret used to sign/validate unpause JWTs. - // It should be 32 alphanumeric characters, e.g. the output of `openssl rand - // -hex 16` to satisfy the go-jose HS256 algorithm implementation. In a - // multi-DC deployment this value should be the same across all boulder-wfe - // and sfe instances. - HMACKey PasswordConfig `validate:"-"` +// HMACKeyConfig contains a path to a file containing an HMAC key. +type HMACKeyConfig struct { + KeyFile string `validate:"required"` +} + +// Load loads the HMAC key from the file, ensures it is exactly 32 characters +// in length, and returns it as a byte slice. +func (hc *HMACKeyConfig) Load() ([]byte, error) { + contents, err := os.ReadFile(hc.KeyFile) + if err != nil { + return nil, err + } + trimmed := strings.TrimRight(string(contents), "\n") + + if len(trimmed) != 32 { + return nil, fmt.Errorf( + "validating unpauseHMACKey, length must be 32 alphanumeric characters, got %d", + len(trimmed), + ) + } + return []byte(trimmed), nil } diff --git a/cmd/sfe/main.go b/cmd/sfe/main.go index 8b8d57ac5d2..ebd06cac061 100644 --- a/cmd/sfe/main.go +++ b/cmd/sfe/main.go @@ -37,7 +37,10 @@ type Config struct { RAService *cmd.GRPCClientConfig SAService *cmd.GRPCClientConfig - Unpause cmd.UnpauseConfig + // UnpauseHMACKey validates incoming JWT signatures at the unpause + // endpoint. This key must be the same as the one configured for all + // WFEs. This field is required to enable the pausing feature. + UnpauseHMACKey cmd.HMACKeyConfig Features features.Config } @@ -80,17 +83,9 @@ func main() { clk := cmd.Clock() - unpauseHMACKey, err := c.SFE.Unpause.HMACKey.Pass() + unpauseHMACKey, err := c.SFE.UnpauseHMACKey.Load() cmd.FailOnError(err, "Failed to load unpauseHMACKey") - if len(unpauseHMACKey) != 32 { - cmd.Fail("Invalid unpauseHMACKey length, should be 32 alphanumeric characters") - } - - // The jose.SigningKey key interface where this is used can be satisfied by - // a byte slice, not a string. - unpauseHMACKeyBytes := []byte(unpauseHMACKey) - tlsConfig, err := c.SFE.TLS.Load(stats) cmd.FailOnError(err, "TLS config") @@ -109,7 +104,7 @@ func main() { c.SFE.Timeout.Duration, rac, sac, - unpauseHMACKeyBytes, + unpauseHMACKey, ) cmd.FailOnError(err, "Unable to create SFE") diff --git a/core/util.go b/core/util.go index 641521f1699..36a32353084 100644 --- a/core/util.go +++ b/core/util.go @@ -20,12 +20,14 @@ import ( "path" "reflect" "regexp" + "slices" "sort" "strings" "time" "unicode" "github.com/go-jose/go-jose/v4" + "github.com/letsencrypt/boulder/identifier" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -316,6 +318,21 @@ func UniqueLowerNames(names []string) (unique []string) { return } +// NormalizeIdentifiers returns the set of all unique ACME identifiers in the +// input after all of them are lowercased. The returned identifier values will +// be in their lowercased form and sorted alphabetically by value. +func NormalizeIdentifiers(identifiers []identifier.ACMEIdentifier) []identifier.ACMEIdentifier { + for i := range identifiers { + identifiers[i].Value = strings.ToLower(identifiers[i].Value) + } + + sort.Slice(identifiers, func(i, j int) bool { + return fmt.Sprintf("%s:%s", identifiers[i].Type, identifiers[i].Value) < fmt.Sprintf("%s:%s", identifiers[j].Type, identifiers[j].Value) + }) + + return slices.Compact(identifiers) +} + // HashNames returns a hash of the names requested. This is intended for use // when interacting with the orderFqdnSets table and rate limiting. func HashNames(names []string) []byte { diff --git a/core/util_test.go b/core/util_test.go index 294f555a379..cebbb324557 100644 --- a/core/util_test.go +++ b/core/util_test.go @@ -16,6 +16,7 @@ import ( "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/letsencrypt/boulder/identifier" "github.com/letsencrypt/boulder/test" ) @@ -250,6 +251,26 @@ func TestUniqueLowerNames(t *testing.T) { test.AssertDeepEquals(t, []string{"a.com", "bar.com", "baz.com", "foobar.com"}, u) } +func TestNormalizeIdentifiers(t *testing.T) { + identifiers := []identifier.ACMEIdentifier{ + {Type: "DNS", Value: "foobar.com"}, + {Type: "DNS", Value: "fooBAR.com"}, + {Type: "DNS", Value: "baz.com"}, + {Type: "DNS", Value: "foobar.com"}, + {Type: "DNS", Value: "bar.com"}, + {Type: "DNS", Value: "bar.com"}, + {Type: "DNS", Value: "a.com"}, + } + expected := []identifier.ACMEIdentifier{ + {Type: "DNS", Value: "a.com"}, + {Type: "DNS", Value: "bar.com"}, + {Type: "DNS", Value: "baz.com"}, + {Type: "DNS", Value: "foobar.com"}, + } + u := NormalizeIdentifiers(identifiers) + test.AssertDeepEquals(t, expected, u) +} + func TestValidSerial(t *testing.T) { notLength32Or36 := "A" length32 := strings.Repeat("A", 32) diff --git a/docker-compose.yml b/docker-compose.yml index 3e857fde816..5f2aa45232f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,7 +47,7 @@ services: ports: - 4001:4001 # ACMEv2 - 4002:4002 # OCSP - - 4003:4003 # OCSP + - 4003:4003 # SFE depends_on: - bmysql - bproxysql diff --git a/features/features.go b/features/features.go index a303b7b47ac..892d29f9931 100644 --- a/features/features.go +++ b/features/features.go @@ -98,6 +98,12 @@ type Config struct { // // TODO(#7511): Remove this feature flag. CheckRenewalExemptionAtWFE bool + + // CheckIdentifiersPaused checks if any of the identifiers in the order are + // currently paused at NewOrder time. If any are paused, an error is + // returned to the Subscriber indicating that the order cannot be processed + // until the paused identifiers are unpaused and the order is resubmitted. + CheckIdentifiersPaused bool } var fMu = new(sync.RWMutex) diff --git a/probs/probs.go b/probs/probs.go index ec6c272ae52..90ba0b78505 100644 --- a/probs/probs.go +++ b/probs/probs.go @@ -217,6 +217,15 @@ func RateLimited(detail string) *ProblemDetails { } } +// Paused returns a ProblemDetails representing a RateLimitedProblem error +func Paused(detail string) *ProblemDetails { + return &ProblemDetails{ + Type: RateLimitedProblem, + Detail: detail, + HTTPStatus: http.StatusTooManyRequests, + } +} + // RejectedIdentifier returns a ProblemDetails with a RejectedIdentifierProblem and a 400 Bad // Request status code. func RejectedIdentifier(detail string) *ProblemDetails { diff --git a/sfe/pages/index.html b/sfe/pages/index.html index 8dc4ef2576d..e01c4114fb9 100644 --- a/sfe/pages/index.html +++ b/sfe/pages/index.html @@ -10,7 +10,7 @@

No Action Required

There is no action for you to take. This page is intended for Subscribers whose accounts have been temporarily restricted from - requesting new certificates for certain hostnames, following a + requesting new certificates for certain identifiers, following a significant number of failed validation attempts without any recent successes. If your account was paused, your ACME client diff --git a/sfe/pages/unpause-form.html b/sfe/pages/unpause-form.html index 2d6c2dfab14..c08ed101dd9 100644 --- a/sfe/pages/unpause-form.html +++ b/sfe/pages/unpause-form.html @@ -11,9 +11,9 @@

Action Required to Unpause Your ACME Account

You have been directed to this page because your Account ID {{ .AccountID }} is temporarily restricted from requesting new certificates for certain - hostnames including, but potentially not limited to, the following: + identifiers including, but potentially not limited to, the following:

@@ -29,7 +29,7 @@

Why Did This Happen?

What Can You Do?

Please check the DNS configuration and web server settings for the - affected hostnames. Ensure they are properly set up to respond to ACME + affected identifiers. Ensure they are properly set up to respond to ACME challenges. This might involve updating DNS records, renewing domain registrations, or adjusting web server configurations. If you use a hosting provider or third-party service for domain management, you may @@ -43,8 +43,8 @@

Ready to Unpause?

Once you have addressed these issues, click the button below to remove the pause on your account. This action will allow you to resume - requesting certificates for all affected hostnames associated with your - account. + requesting certificates for all affected identifiers associated with + your account.

Note: If you face difficulties unpausing your account or diff --git a/sfe/sfe.go b/sfe/sfe.go index 2e1d6cff92c..0938fbf548b 100644 --- a/sfe/sfe.go +++ b/sfe/sfe.go @@ -11,8 +11,6 @@ import ( "text/template" "time" - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" "github.com/jmhodges/clock" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -22,16 +20,12 @@ import ( "github.com/letsencrypt/boulder/metrics/measured_http" rapb "github.com/letsencrypt/boulder/ra/proto" sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/unpause" ) const ( - // The API version should be checked when parsing parameters to quickly deny - // a client request. Can be used to mass-invalidate URLs. Must be - // concatenated with other path slugs. - unpauseAPIPrefix = "/sfe/v1" - unpauseGetForm = unpauseAPIPrefix + "/unpause" - unpausePostForm = unpauseAPIPrefix + "/do-unpause" - unpauseStatus = unpauseAPIPrefix + "/unpause-status" + unpausePostForm = unpause.APIPrefix + "/do-unpause" + unpauseStatus = unpause.APIPrefix + "/unpause-status" ) var ( @@ -56,12 +50,8 @@ type SelfServiceFrontEndImpl struct { // requestTimeout is the per-request overall timeout. requestTimeout time.Duration - // unpauseHMACKey is used to validate incoming JWT signatures on the unpause - // endpoint and should be shared by the SFE and WFE. unpauseHMACKey []byte - - // HTML pages served by the SFE - templatePages *template.Template + templatePages *template.Template } // NewSelfServiceFrontEndImpl constructs a web service for Boulder @@ -105,7 +95,7 @@ func (sfe *SelfServiceFrontEndImpl) Handler(stats prometheus.Registerer, oTelHTT m.Handle("GET /static/", staticAssetsHandler) m.HandleFunc("/", sfe.Index) m.HandleFunc("GET /build", sfe.BuildID) - m.HandleFunc("GET "+unpauseGetForm, sfe.UnpauseForm) + m.HandleFunc("GET "+unpause.GetForm, sfe.UnpauseForm) m.HandleFunc("POST "+unpausePostForm, sfe.UnpauseSubmit) m.HandleFunc("GET "+unpauseStatus, sfe.UnpauseStatus) @@ -141,10 +131,6 @@ func (sfe *SelfServiceFrontEndImpl) BuildID(response http.ResponseWriter, reques } } -// unpauseJWT is generated by a WFE and is used to round-trip back through the -// SFE to unpause the requester's account. -type unpauseJWT string - // UnpauseForm allows a requester to unpause their account via a form present on // the page. The Subscriber's client will receive a log line emitted by the WFE // which contains a URL pre-filled with a JWT that will populate a hidden field @@ -156,7 +142,7 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, re return } - regID, domains, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT)) + regID, identifiers, err := sfe.parseUnpauseJWT(incomingJWT) if err != nil { sfe.unpauseStatusHelper(response, false) return @@ -165,13 +151,13 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, re type tmplData struct { UnpauseFormRedirectionPath string JWT string - AccountID string - PausedDomains []string + AccountID int64 + Identifiers []string } // Serve the actual unpause page given to a Subscriber. Populates the // unpause form with the JWT from the URL. - sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, regID, domains}) + sfe.renderTemplate(response, "unpause-form.html", tmplData{unpausePostForm, incomingJWT, regID, identifiers}) } // UnpauseSubmit serves a page indicating if the unpause form submission @@ -185,24 +171,16 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, return } - regID, _, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT)) + _, _, err := sfe.parseUnpauseJWT(incomingJWT) if err != nil { sfe.unpauseStatusHelper(response, false) return } - // TODO(#7356) Declare a registration ID variable to populate an - // rapb unpause account request message. - _, innerErr := strconv.ParseInt(regID, 10, 64) - if innerErr != nil { - sfe.unpauseStatusHelper(response, false) - return - } - - // TODO(#7536) Send a gRPC request to the RA informing it to unpause the - // account specified in the claim. At this point we should wait for the RA - // to process the request before returning to the client, just in case the - // request fails. + // TODO(#7536) Send gRPC request to the RA informing it to unpause + // the account specified in the claim. At this point we should wait + // for the RA to process the request before returning to the client, + // just in case the request fails. // Success, the account has been unpaused. http.Redirect(response, request, unpauseStatus, http.StatusFound) @@ -240,65 +218,29 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseStatus(response http.ResponseWriter, // TODO(#7580) This should only be reachable after a client has clicked the // "Please unblock my account" button and that request succeeding. No one // should be able to access this page otherwise. - sfe.unpauseStatusHelper(response, true) } -// validateUnpauseJWTforAccount validates the signature and contents of an -// unpauseJWT and verify that the its claims match a set of expected claims. -// After JWT validation, return the registration ID from claim's subject and -// paused domains if the validation was successful or an error. -func (sfe *SelfServiceFrontEndImpl) validateUnpauseJWTforAccount(incomingJWT unpauseJWT) (string, []string, error) { - slug := strings.Split(unpauseAPIPrefix, "/") +// parseUnpauseJWT extracts and returns the subscriber's registration ID and a +// slice of paused identifiers from the claims. If the JWT cannot be parsed or +// is otherwise invalid, an error is returned. +func (sfe *SelfServiceFrontEndImpl) parseUnpauseJWT(incomingJWT string) (int64, []string, error) { + slug := strings.Split(unpause.APIPrefix, "/") if len(slug) != 3 { - return "", nil, errors.New("Could not parse API version") + return 0, nil, errors.New("failed to parse API version") } - token, err := jwt.ParseSigned(string(incomingJWT), []jose.SignatureAlgorithm{jose.HS256}) + claims, err := unpause.RedeemJWT(incomingJWT, sfe.unpauseHMACKey, slug[2], sfe.clk) if err != nil { - return "", nil, fmt.Errorf("parsing JWT: %s", err) - } - - type sfeJWTClaims struct { - jwt.Claims - - // Version is a custom claim used to mass invalidate existing JWTs by - // changing the API version via unpausePath. - Version string `json:"apiVersion,omitempty"` - - // Domains is set of comma separated paused domains. - Domains string `json:"pausedDomains,omitempty"` - } - - incomingClaims := sfeJWTClaims{} - err = token.Claims(sfe.unpauseHMACKey[:], &incomingClaims) - if err != nil { - return "", nil, err - } - - expectedClaims := jwt.Expected{ - Issuer: "WFE", - AnyAudience: jwt.Audience{"SFE Unpause"}, - // Time is passed into the jwt package for tests to manipulate time. - Time: sfe.clk.Now(), - } - - err = incomingClaims.Validate(expectedClaims) - if err != nil { - return "", nil, err - } - - if len(incomingClaims.Subject) == 0 { - return "", nil, errors.New("Account ID required for account unpausing") - } - - if incomingClaims.Version == "" { - return "", nil, errors.New("Incoming JWT was created with no API version") + return 0, nil, err } - if incomingClaims.Version != slug[2] { - return "", nil, fmt.Errorf("JWT created for unpause API version %s was provided to the incompatible API version %s", incomingClaims.Version, slug[2]) + account, convErr := strconv.ParseInt(claims.Subject, 10, 64) + if convErr != nil { + // This should never happen as this was just validated by the call to + // unpause.RedeemJWT(). + return 0, nil, errors.New("failed to parse account ID from JWT") } - return incomingClaims.Subject, strings.Split(incomingClaims.Domains, ","), nil + return account, strings.Split(claims.I, ","), nil } diff --git a/sfe/sfe_test.go b/sfe/sfe_test.go index c6137f699ce..33c0f816331 100644 --- a/sfe/sfe_test.go +++ b/sfe/sfe_test.go @@ -7,18 +7,16 @@ import ( "net/http" "net/http/httptest" "net/url" - "strings" "testing" "time" - "github.com/go-jose/go-jose/v4" - "github.com/go-jose/go-jose/v4/jwt" "github.com/jmhodges/clock" "golang.org/x/crypto/ocsp" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/letsencrypt/boulder/cmd" "github.com/letsencrypt/boulder/core" "github.com/letsencrypt/boulder/features" blog "github.com/letsencrypt/boulder/log" @@ -27,6 +25,7 @@ import ( "github.com/letsencrypt/boulder/must" "github.com/letsencrypt/boulder/revocation" "github.com/letsencrypt/boulder/test" + "github.com/letsencrypt/boulder/unpause" capb "github.com/letsencrypt/boulder/ca/proto" corepb "github.com/letsencrypt/boulder/core/proto" @@ -113,8 +112,6 @@ func mustParseURL(s string) *url.URL { return must.Do(url.Parse(s)) } -const hmacKey = "pcl04dl3tt3rb1gb4dd4db0d34ts000p" - func setupSFE(t *testing.T) (SelfServiceFrontEndImpl, clock.FakeClock) { features.Reset() @@ -126,6 +123,10 @@ func setupSFE(t *testing.T) (SelfServiceFrontEndImpl, clock.FakeClock) { mockSA := mocks.NewStorageAuthorityReadOnly(fc) + hmacKey := cmd.HMACKeyConfig{KeyFile: "../test/secrets/sfe_unpause_key"} + key, err := hmacKey.Load() + test.AssertNotError(t, err, "Unable to load HMAC key") + sfe, err := NewSelfServiceFrontEndImpl( stats, fc, @@ -133,7 +134,7 @@ func setupSFE(t *testing.T) (SelfServiceFrontEndImpl, clock.FakeClock) { 10*time.Second, &MockRegistrationAuthority{}, mockSA, - []byte(hmacKey), + key, ) test.AssertNotError(t, err, "Unable to create SFE") @@ -169,13 +170,12 @@ func TestBuildIDPath(t *testing.T) { func TestUnpausePaths(t *testing.T) { t.Parallel() sfe, fc := setupSFE(t) - now := fc.Now() // GET with no JWT responseWriter := httptest.NewRecorder() sfe.UnpauseForm(responseWriter, &http.Request{ Method: "GET", - URL: mustParseURL(unpauseGetForm), + URL: mustParseURL(unpause.GetForm), }) test.AssertEquals(t, responseWriter.Code, http.StatusOK) test.AssertContains(t, responseWriter.Body.String(), "request was invalid meaning that we could not") @@ -184,18 +184,20 @@ func TestUnpausePaths(t *testing.T) { responseWriter = httptest.NewRecorder() sfe.UnpauseForm(responseWriter, &http.Request{ Method: "GET", - URL: mustParseURL(fmt.Sprintf(unpauseGetForm + "?jwt=x")), + URL: mustParseURL(fmt.Sprintf(unpause.GetForm + "?jwt=x")), }) test.AssertEquals(t, responseWriter.Code, http.StatusOK) test.AssertContains(t, responseWriter.Body.String(), "error was encountered when attempting to unpause your account") // GET with a valid JWT - validJWT, err := makeJWTForAccount(now, now, now.Add(24*time.Hour), []byte(hmacKey), 1, "v1", "example.com") + unpauseSigner, err := unpause.NewJWTSigner(cmd.HMACKeyConfig{KeyFile: "../test/secrets/sfe_unpause_key"}) + test.AssertNotError(t, err, "Should have been able to create JWT signer, but could not") + validJWT, err := unpause.GenerateJWT(unpauseSigner, 1234567890, []string{"example.com"}, time.Hour, fc) test.AssertNotError(t, err, "Should have been able to create JWT, but could not") responseWriter = httptest.NewRecorder() sfe.UnpauseForm(responseWriter, &http.Request{ Method: "GET", - URL: mustParseURL(fmt.Sprintf(unpauseGetForm + "?jwt=" + string(validJWT))), + URL: mustParseURL(fmt.Sprintf(unpause.GetForm + "?jwt=" + validJWT)), }) test.AssertEquals(t, responseWriter.Code, http.StatusOK) test.AssertContains(t, responseWriter.Body.String(), "This action will allow you to resume") @@ -222,7 +224,7 @@ func TestUnpausePaths(t *testing.T) { responseWriter = httptest.NewRecorder() sfe.UnpauseSubmit(responseWriter, &http.Request{ Method: "POST", - URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=" + string(validJWT))), + URL: mustParseURL(fmt.Sprintf(unpausePostForm + "?jwt=" + validJWT)), }) test.AssertEquals(t, responseWriter.Code, http.StatusFound) test.AssertEquals(t, unpauseStatus, responseWriter.Result().Header.Get("Location")) @@ -236,196 +238,3 @@ func TestUnpausePaths(t *testing.T) { test.AssertEquals(t, responseWriter.Code, http.StatusOK) test.AssertContains(t, responseWriter.Body.String(), "Your ACME account has been unpaused.") } - -// makeJWTForAccount is a standin for a WFE method that returns an unpauseJWT or -// an error. The JWT contains a set of claims which should be validated by the -// caller. -func makeJWTForAccount(notBefore time.Time, issuedAt time.Time, expiresAt time.Time, hmacKey []byte, regID int64, apiVersion string, pausedDomains string) (unpauseJWT, error) { - if len(hmacKey) != 32 { - return "", fmt.Errorf("invalid seed length") - } - - signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: hmacKey}, (&jose.SignerOptions{}).WithType("JWT")) - if err != nil { - return "", fmt.Errorf("making signer: %s", err) - } - - // Ensure that we test an empty subject - var subject string - if regID == 0 { - subject = "" - } else { - subject = fmt.Sprint(regID) - } - - // Ensure that we test receiving an empty API version string while - // defaulting the rest to match SFE unpausePath. - if apiVersion == "magicEmptyString" { - apiVersion = "" - } else if apiVersion == "" { - apiVersion = "v1" - } - - // Ensure that we always send at least one domain in the JWT. - if pausedDomains == "" { - pausedDomains = "example.com" - } - - // The SA returns a maximum of 15 domains and the SFE displays some text - // about "potentially more domains" being paused. - domains := strings.Split(pausedDomains, ",") - if len(domains) > 15 { - domains = domains[:15] - } - - // Join slice back into a comma separated string with the maximum of 15 - // domains. - pausedDomains = strings.Join(domains, ",") - - customClaims := struct { - Version string `json:"apiVersion,omitempty"` - Domains string `json:"pausedDomains,omitempty"` - }{ - apiVersion, - pausedDomains, - } - - wfeClaims := jwt.Claims{ - Issuer: "WFE", - Subject: subject, - Audience: jwt.Audience{"SFE Unpause"}, - NotBefore: jwt.NewNumericDate(notBefore), - IssuedAt: jwt.NewNumericDate(issuedAt), - Expiry: jwt.NewNumericDate(expiresAt), - } - - signedJWT, err := jwt.Signed(signer).Claims(&wfeClaims).Claims(&customClaims).Serialize() - if err != nil { - return "", fmt.Errorf("signing JWT: %s", err) - } - - return unpauseJWT(signedJWT), nil -} - -func TestValidateJWT(t *testing.T) { - t.Parallel() - sfe, fc := setupSFE(t) - - now := fc.Now() - originalClock := fc - testCases := []struct { - Name string - IssuedAt time.Time - NotBefore time.Time - ExpiresAt time.Time - HMACKey string - RegID int64 // Default value set in makeJWTForAccount - Version string // Default value set in makeJWTForAccount - PausedDomains string // Default value set in makeJWTForAccount - ExpectedPausedDomains []string - ExpectedMakeJWTSubstr string - ExpectedValidationErrSubstr string - }{ - { - Name: "valid", - IssuedAt: now, - NotBefore: now, - ExpiresAt: now.Add(1 * time.Hour), - HMACKey: hmacKey, - RegID: 1, - ExpectedPausedDomains: []string{"example.com"}, - }, - { - Name: "valid, but more than 15 domains sent", - IssuedAt: now, - NotBefore: now, - ExpiresAt: now.Add(1 * time.Hour), - HMACKey: hmacKey, - RegID: 1, - PausedDomains: "1.example.com,2.example.com,3.example.com,4.example.com,5.example.com,6.example.com,7.example.com,8.example.com,9.example.com,10.example.com,11.example.com,12.example.com,13.example.com,14.example.com,15.example.com,16.example.com", - ExpectedPausedDomains: []string{"1.example.com", "2.example.com", "3.example.com", "4.example.com", "5.example.com", "6.example.com", "7.example.com", "8.example.com", "9.example.com", "10.example.com", "11.example.com", "12.example.com", "13.example.com", "14.example.com", "15.example.com"}, - }, - { - Name: "apiVersion mismatch", - IssuedAt: now, - NotBefore: now.Add(5 * time.Minute), - ExpiresAt: now.Add(1 * time.Hour), - HMACKey: hmacKey, - RegID: 1, - Version: "v2", - ExpectedValidationErrSubstr: "incompatible API version", - }, - { - Name: "no API specified in claim", - IssuedAt: now, - NotBefore: now.Add(5 * time.Minute), - ExpiresAt: now.Add(1 * time.Hour), - HMACKey: hmacKey, - RegID: 1, - Version: "magicEmptyString", - ExpectedValidationErrSubstr: "no API version", - }, - { - Name: "creating JWT with empty seed fails", - IssuedAt: now, - NotBefore: now.Add(5 * time.Minute), - ExpiresAt: now.Add(1 * time.Hour), - HMACKey: "", - RegID: 1, - ExpectedMakeJWTSubstr: "invalid seed length", - ExpectedValidationErrSubstr: "JWS format must have", - }, - { - Name: "registration ID is required to pass validation", - IssuedAt: now, - NotBefore: now.Add(5 * time.Minute), - ExpiresAt: now.Add(24 * time.Hour), - HMACKey: hmacKey, - RegID: 0, // This is a magic case where 0 is turned into an empty string in the Subject field of a jwt.Claims - ExpectedValidationErrSubstr: "required for account unpausing", - }, - { - Name: "validating expired JWT fails", - IssuedAt: now, - NotBefore: now.Add(5 * time.Minute), - ExpiresAt: now.Add(-24 * time.Hour), - HMACKey: hmacKey, - RegID: 1, - ExpectedValidationErrSubstr: "token is expired (exp)", - }, - { - Name: "validating JWT with hash derived from different seed fails", - IssuedAt: now, - NotBefore: now.Add(5 * time.Minute), - ExpiresAt: now.Add(1 * time.Hour), - HMACKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - RegID: 1, - ExpectedValidationErrSubstr: "cryptographic primitive", - }, - } - for _, tc := range testCases { - t.Run(tc.Name, func(t *testing.T) { - fc = originalClock - newJWT, err := makeJWTForAccount(tc.NotBefore, tc.IssuedAt, tc.ExpiresAt, []byte(tc.HMACKey), tc.RegID, tc.Version, tc.PausedDomains) - if tc.ExpectedMakeJWTSubstr != "" || string(newJWT) == "" { - test.AssertError(t, err, "JWT was created but should not have been") - test.AssertContains(t, err.Error(), tc.ExpectedMakeJWTSubstr) - } else { - test.AssertNotError(t, err, "Should have been able to create a JWT") - } - - // Advance the clock an arbitrary amount. The WFE sets a notBefore - // claim in the JWT as a first pass annoyance for clients attempting - // to automate unpausing. - fc.Add(10 * time.Minute) - _, domains, err := sfe.validateUnpauseJWTforAccount(newJWT) - if tc.ExpectedValidationErrSubstr != "" || err != nil { - test.AssertError(t, err, "Error expected, but received none") - test.AssertContains(t, err.Error(), tc.ExpectedValidationErrSubstr) - } else { - test.AssertNotError(t, err, "Unable to validate JWT") - test.AssertDeepEquals(t, domains, tc.ExpectedPausedDomains) - } - }) - } -} diff --git a/test/config-next/sfe.json b/test/config-next/sfe.json index 7501494c366..58ec7ad0fab 100644 --- a/test/config-next/sfe.json +++ b/test/config-next/sfe.json @@ -29,10 +29,8 @@ "noWaitForReady": true, "hostOverride": "sa.boulder" }, - "unpause": { - "hmacKey": { - "passwordFile": "test/secrets/sfe_unpause_key" - } + "unpauseHMACKey": { + "keyFile": "test/secrets/sfe_unpause_key" }, "features": {} }, diff --git a/test/config-next/wfe2.json b/test/config-next/wfe2.json index 21b3f2d23b4..55b28980ae2 100644 --- a/test/config-next/wfe2.json +++ b/test/config-next/wfe2.json @@ -129,11 +129,19 @@ "features": { "ServeRenewalInfo": true, "TrackReplacementCertificatesARI": true, - "CheckRenewalExemptionAtWFE": true + "CheckRenewalExemptionAtWFE": true, + "CheckIdentifiersPaused": true }, "certProfiles": { "legacy": "The normal profile you know and love", "modern": "Profile 2: Electric Boogaloo" + }, + "unpause": { + "hmacKey": { + "keyFile": "test/secrets/sfe_unpause_key" + }, + "jwtLifetime": "336h", + "url": "https://boulder.service.consul:4003" } }, "syslog": { diff --git a/test/config/sfe.json b/test/config/sfe.json index 4fb9626adb8..73aa1f58efc 100644 --- a/test/config/sfe.json +++ b/test/config/sfe.json @@ -29,10 +29,8 @@ "noWaitForReady": true, "hostOverride": "sa.boulder" }, - "unpause": { - "hmacKey": { - "passwordFile": "test/secrets/sfe_unpause_key" - } + "unpauseHMACKey": { + "keyFile": "test/secrets/sfe_unpause_key" }, "features": {} }, diff --git a/test/integration/pausing_test.go b/test/integration/pausing_test.go new file mode 100644 index 00000000000..8eb194ae09f --- /dev/null +++ b/test/integration/pausing_test.go @@ -0,0 +1,75 @@ +//go:build integration + +package integration + +import ( + "context" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/config" + bgrpc "github.com/letsencrypt/boulder/grpc" + "github.com/letsencrypt/boulder/identifier" + "github.com/letsencrypt/boulder/metrics" + sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/test" +) + +func TestPausedOrderFails(t *testing.T) { + t.Parallel() + + if !strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") { + t.Skip("Skipping test as it requires the next configuration") + } + + tlsCerts := &cmd.TLSConfig{ + CACertFile: "test/certs/ipki/minica.pem", + CertFile: "test/certs/ipki/ra.boulder/cert.pem", + KeyFile: "test/certs/ipki/ra.boulder/key.pem", + } + tlsConf, err := tlsCerts.Load(metrics.NoopRegisterer) + test.AssertNotError(t, err, "Failed to load TLS config") + saConn, err := bgrpc.ClientSetup( + &cmd.GRPCClientConfig{ + DNSAuthority: "consul.service.consul", + SRVLookup: &cmd.ServiceDomain{ + Service: "sa", + Domain: "service.consul", + }, + + Timeout: config.Duration{Duration: 5 * time.Second}, + NoWaitForReady: true, + HostOverride: "sa.boulder", + }, + tlsConf, + metrics.NoopRegisterer, + clock.NewFake(), + ) + cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA") + saClient := sapb.NewStorageAuthorityClient(saConn) + + c, err := makeClient() + parts := strings.SplitAfter(c.URL, "/") + regID, err := strconv.ParseInt(parts[len(parts)-1], 10, 64) + domain := random_domain() + + _, err = saClient.PauseIdentifiers(context.Background(), &sapb.PauseRequest{ + RegistrationID: regID, + Identifiers: []*sapb.Identifier{ + { + Type: string(identifier.DNS), + Value: domain}, + }, + }) + test.AssertNotError(t, err, "Failed to pause domain") + + _, err = authAndIssue(c, nil, []string{domain}, true) + test.AssertError(t, err, "Should not be able to issue a certificate for a paused domain") + test.AssertContains(t, err.Error(), "Your account is temporarily prevented from requesting certificates for") + test.AssertContains(t, err.Error(), "https://boulder.service.consul:4003/sfe/v1/unpause?jwt=") +} diff --git a/unpause/unpause.go b/unpause/unpause.go new file mode 100644 index 00000000000..6b0a7179a91 --- /dev/null +++ b/unpause/unpause.go @@ -0,0 +1,141 @@ +package unpause + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/cmd" +) + +const ( + // API + + // Changing this value will invalidate all existing JWTs. + apiVersion = "v1" + APIPrefix = "/sfe/" + apiVersion + GetForm = APIPrefix + "/unpause" + + // JWT + defaultIssuer = "WFE" + defaultAudience = "SFE Unpause" +) + +// JWTSigner is a type alias for jose.Signer. To create a JWTSigner instance, +// use the NewJWTSigner function provided in this package. +type JWTSigner = jose.Signer + +// NewJWTSigner loads the HMAC key from the provided configuration and returns a +// new JWT signer. +func NewJWTSigner(hmacKey cmd.HMACKeyConfig) (JWTSigner, error) { + key, err := hmacKey.Load() + if err != nil { + return nil, err + } + return jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, nil) +} + +// JWTClaims represents the claims of a JWT token issued by the WFE for +// redemption by the SFE. The following claims required for unpausing: +// - Subject: the account ID of the Subscriber +// - V: the API version this JWT was created for +// - I: a set of ACME identifier values. Identifier types are omitted +// since DNS and IP string representations do not overlap. +type JWTClaims struct { + jwt.Claims + + // V is the API version this JWT was created for. + V string `json:"version"` + + // I is set of comma separated ACME identifiers. + I string `json:"identifiers"` +} + +// GenerateJWT generates a serialized unpause JWT with the provided claims. +func GenerateJWT(signer JWTSigner, regID int64, identifiers []string, lifetime time.Duration, clk clock.Clock) (string, error) { + claims := JWTClaims{ + Claims: jwt.Claims{ + Issuer: defaultIssuer, + Subject: fmt.Sprintf("%d", regID), + Audience: jwt.Audience{defaultAudience}, + // IssuedAt is necessary for metrics. + IssuedAt: jwt.NewNumericDate(clk.Now()), + Expiry: jwt.NewNumericDate(clk.Now().Add(lifetime)), + }, + V: apiVersion, + I: strings.Join(identifiers, ","), + } + + serialized, err := jwt.Signed(signer).Claims(&claims).Serialize() + if err != nil { + return "", fmt.Errorf("serializing JWT: %s", err) + } + + return serialized, nil +} + +// RedeemJWT deserializes an unpause JWT and returns the validated claims. The +// key is used to validate the signature of the JWT. The version is the expected +// API version of the JWT. This function validates that the JWT is: +// - well-formed, +// - valid for the current time (+/- 1 minute leeway), +// - issued by the WFE, +// - intended for the SFE, +// - contains an Account ID as the 'Subject', +// - subject can be parsed as a 64-bit integer, +// - contains a set of paused identifiers as 'Identifiers', and +// - contains the API the expected version as 'Version'. +func RedeemJWT(token string, key []byte, version string, clk clock.Clock) (JWTClaims, error) { + parsedToken, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) + if err != nil { + return JWTClaims{}, fmt.Errorf("parsing JWT: %s", err) + } + + claims := JWTClaims{} + err = parsedToken.Claims(key, &claims) + if err != nil { + return JWTClaims{}, fmt.Errorf("verifying JWT: %s", err) + } + + err = claims.Validate(jwt.Expected{ + Issuer: defaultIssuer, + AnyAudience: jwt.Audience{defaultAudience}, + + // By default, the go-jose library validates the NotBefore and Expiry + // fields with a default leeway of 1 minute. + Time: clk.Now(), + }) + if err != nil { + return JWTClaims{}, fmt.Errorf("validating JWT: %w", err) + } + + if len(claims.Subject) == 0 { + return JWTClaims{}, errors.New("no account ID specified in the JWT") + } + account, err := strconv.ParseInt(claims.Subject, 10, 64) + if err != nil { + return JWTClaims{}, errors.New("invalid account ID specified in the JWT") + } + if account == 0 { + return JWTClaims{}, errors.New("no account ID specified in the JWT") + } + + if claims.V == "" { + return JWTClaims{}, errors.New("no API version specified in the JWT") + } + + if claims.V != version { + return JWTClaims{}, fmt.Errorf("unexpected API version in the JWT: %s", claims.V) + } + + if claims.I == "" { + return JWTClaims{}, errors.New("no identifiers specified in the JWT") + } + + return claims, nil +} diff --git a/unpause/unpause_test.go b/unpause/unpause_test.go new file mode 100644 index 00000000000..1346375e8b0 --- /dev/null +++ b/unpause/unpause_test.go @@ -0,0 +1,155 @@ +package unpause + +import ( + "testing" + "time" + + "github.com/go-jose/go-jose/v4/jwt" + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/cmd" + "github.com/letsencrypt/boulder/test" +) + +func TestUnpauseJWT(t *testing.T) { + fc := clock.NewFake() + + signer, err := NewJWTSigner(cmd.HMACKeyConfig{KeyFile: "../test/secrets/sfe_unpause_key"}) + test.AssertNotError(t, err, "unexpected error from NewJWTSigner()") + + config := cmd.HMACKeyConfig{KeyFile: "../test/secrets/sfe_unpause_key"} + hmacKey, err := config.Load() + test.AssertNotError(t, err, "unexpected error from Load()") + + type args struct { + key []byte + version string + account int64 + identifiers []string + lifetime time.Duration + clk clock.Clock + } + + tests := []struct { + name string + args args + want JWTClaims + wantGenerateJWTErr bool + wantRedeemJWTErr bool + }{ + { + name: "valid one identifier", + args: args{ + key: hmacKey, + version: apiVersion, + account: 1234567890, + identifiers: []string{"example.com"}, + lifetime: time.Hour, + clk: fc, + }, + want: JWTClaims{ + Claims: jwt.Claims{ + Issuer: defaultIssuer, + Subject: "1234567890", + Audience: jwt.Audience{defaultAudience}, + Expiry: jwt.NewNumericDate(fc.Now().Add(time.Hour)), + }, + V: apiVersion, + I: "example.com", + }, + wantGenerateJWTErr: false, + wantRedeemJWTErr: false, + }, + { + name: "valid multiple identifiers", + args: args{ + key: hmacKey, + version: apiVersion, + account: 1234567890, + identifiers: []string{"example.com", "example.org", "example.net"}, + lifetime: time.Hour, + clk: fc, + }, + want: JWTClaims{ + Claims: jwt.Claims{ + Issuer: defaultIssuer, + Subject: "1234567890", + Audience: jwt.Audience{defaultAudience}, + Expiry: jwt.NewNumericDate(fc.Now().Add(time.Hour)), + }, + V: apiVersion, + I: "example.com,example.org,example.net", + }, + wantGenerateJWTErr: false, + wantRedeemJWTErr: false, + }, + { + name: "invalid no account", + args: args{ + key: hmacKey, + version: apiVersion, + account: 0, + identifiers: []string{"example.com"}, + lifetime: time.Hour, + clk: fc, + }, + want: JWTClaims{}, + wantGenerateJWTErr: false, + wantRedeemJWTErr: true, + }, + { + // This test is only testing the "key too small" case for RedeemJWT + // because the "key too small" case for GenerateJWT is handled when + // the key is loaded to initialize a signer. + name: "invalid key too small", + args: args{ + key: []byte("key"), + version: apiVersion, + account: 1234567890, + identifiers: []string{"example.com"}, + lifetime: time.Hour, + clk: fc, + }, + want: JWTClaims{}, + wantGenerateJWTErr: false, + wantRedeemJWTErr: true, + }, + { + name: "invalid no identifiers", + args: args{ + key: hmacKey, + version: apiVersion, + account: 1234567890, + identifiers: nil, + lifetime: time.Hour, + clk: fc, + }, + want: JWTClaims{}, + wantGenerateJWTErr: false, + wantRedeemJWTErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + token, err := GenerateJWT(signer, tt.args.account, tt.args.identifiers, tt.args.lifetime, tt.args.clk) + if tt.wantGenerateJWTErr { + test.AssertError(t, err, "expected error from GenerateJWT()") + return + } + test.AssertNotError(t, err, "unexpected error from GenerateJWT()") + + got, err := RedeemJWT(token, tt.args.key, tt.args.version, tt.args.clk) + if tt.wantRedeemJWTErr { + test.AssertError(t, err, "expected error from RedeemJWT()") + return + } + test.AssertNotError(t, err, "unexpected error from RedeemJWT()") + test.AssertEquals(t, got.Issuer, tt.want.Issuer) + test.AssertEquals(t, got.Subject, tt.want.Subject) + test.AssertDeepEquals(t, got.Audience, tt.want.Audience) + test.Assert(t, got.Expiry.Time().Equal(tt.want.Expiry.Time()), "expected Expiry time to be equal") + test.AssertEquals(t, got.V, tt.want.V) + test.AssertEquals(t, got.I, tt.want.I) + }) + } +} diff --git a/wfe2/wfe.go b/wfe2/wfe.go index af60a9240fa..59b33dd320d 100644 --- a/wfe2/wfe.go +++ b/wfe2/wfe.go @@ -42,6 +42,7 @@ import ( "github.com/letsencrypt/boulder/ratelimits" "github.com/letsencrypt/boulder/revocation" sapb "github.com/letsencrypt/boulder/sa/proto" + "github.com/letsencrypt/boulder/unpause" "github.com/letsencrypt/boulder/web" ) @@ -164,6 +165,10 @@ type WebFrontEndImpl struct { txnBuilder *ratelimits.TransactionBuilder maxNames int + unpauseSigner unpause.JWTSigner + unpauseJWTLifetime time.Duration + unpauseURL string + // certProfiles is a map of acceptable certificate profile names to // descriptions (perhaps including URLs) of those profiles. NewOrder // Requests with a profile name not present in this map will be rejected. @@ -192,6 +197,9 @@ func NewWebFrontEndImpl( txnBuilder *ratelimits.TransactionBuilder, maxNames int, certProfiles map[string]string, + unpauseSigner unpause.JWTSigner, + unpauseJWTLifetime time.Duration, + unpauseURL string, ) (WebFrontEndImpl, error) { if len(issuerCertificates) == 0 { return WebFrontEndImpl{}, errors.New("must provide at least one issuer certificate") @@ -230,6 +238,9 @@ func NewWebFrontEndImpl( txnBuilder: txnBuilder, maxNames: maxNames, certProfiles: certProfiles, + unpauseSigner: unpauseSigner, + unpauseJWTLifetime: unpauseJWTLifetime, + unpauseURL: unpauseURL, } return wfe, nil @@ -2201,6 +2212,37 @@ func (wfe *WebFrontEndImpl) validateCertificateProfileName(profile string) error return nil } +func (wfe *WebFrontEndImpl) checkIdentifiersPaused(ctx context.Context, orderIdentifiers []identifier.ACMEIdentifier, regID int64) ([]string, error) { + uniqueOrderIdentifiers := core.NormalizeIdentifiers(orderIdentifiers) + var identifiers []*sapb.Identifier + for _, ident := range uniqueOrderIdentifiers { + identifiers = append(identifiers, &sapb.Identifier{ + Type: string(ident.Type), + Value: ident.Value, + }) + } + + paused, err := wfe.sa.CheckIdentifiersPaused(ctx, &sapb.PauseRequest{ + RegistrationID: regID, + Identifiers: identifiers, + }) + if err != nil { + return nil, err + } + if len(paused.Identifiers) <= 0 { + // No identifiers are paused. + return nil, nil + } + + // At least one of the requested identifiers is paused. + pausedValues := make([]string, 0, len(paused.Identifiers)) + for _, ident := range paused.Identifiers { + pausedValues = append(pausedValues, ident.Value) + } + + return pausedValues, nil +} + // NewOrder is used by clients to create a new order object and a set of // authorizations to fulfill for issuance. func (wfe *WebFrontEndImpl) NewOrder( @@ -2276,6 +2318,27 @@ func (wfe *WebFrontEndImpl) NewOrder( logEvent.DNSNames = names + if features.Get().CheckIdentifiersPaused { + pausedValues, err := wfe.checkIdentifiersPaused(ctx, newOrderRequest.Identifiers, acct.ID) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Failure while checking pause status of identifiers"), err) + return + } + if len(pausedValues) > 0 { + jwt, err := unpause.GenerateJWT(wfe.unpauseSigner, acct.ID, pausedValues, wfe.unpauseJWTLifetime, wfe.clk) + if err != nil { + wfe.sendError(response, logEvent, probs.ServerInternal("Error generating JWT for self-service unpause"), err) + } + msg := fmt.Sprintf( + "Your account is temporarily prevented from requesting certificates for %s and possibly others. Please visit: %s", + strings.Join(pausedValues, ", "), + fmt.Sprintf("%s%s?jwt=%s", wfe.unpauseURL, unpause.GetForm, jwt), + ) + wfe.sendError(response, logEvent, probs.Paused(msg), nil) + return + } + } + var replaces string var isARIRenewal bool if features.Get().TrackReplacementCertificatesARI { diff --git a/wfe2/wfe_test.go b/wfe2/wfe_test.go index a6ca935d0f5..91590e9afdd 100644 --- a/wfe2/wfe_test.go +++ b/wfe2/wfe_test.go @@ -59,6 +59,7 @@ import ( sapb "github.com/letsencrypt/boulder/sa/proto" "github.com/letsencrypt/boulder/test" inmemnonce "github.com/letsencrypt/boulder/test/inmem/nonce" + "github.com/letsencrypt/boulder/unpause" "github.com/letsencrypt/boulder/web" ) @@ -387,6 +388,17 @@ func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock, requestSigner) { txnBuilder, err := ratelimits.NewTransactionBuilder("../test/config-next/wfe2-ratelimit-defaults.yml", "") test.AssertNotError(t, err, "making transaction composer") + var unpauseSigner unpause.JWTSigner + var unpauseLifetime time.Duration + var unpauseURL string + if os.Getenv("BOULDER_CONFIG_DIR") == "test/config-next" { + features.Set(features.Config{CheckRenewalExemptionAtWFE: true}) + unpauseSigner, err = unpause.NewJWTSigner(cmd.HMACKeyConfig{KeyFile: "../test/secrets/sfe_unpause_key"}) + test.AssertNotError(t, err, "making unpause signer") + unpauseLifetime = time.Hour * 24 * 14 + unpauseURL = "https://boulder.service.consul:4003" + } + wfe, err := NewWebFrontEndImpl( stats, fc, @@ -408,6 +420,9 @@ func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock, requestSigner) { txnBuilder, 100, nil, + unpauseSigner, + unpauseLifetime, + unpauseURL, ) test.AssertNotError(t, err, "Unable to create WFE")