From bd97d81fec947acb55bb7b0060322230504c918f Mon Sep 17 00:00:00 2001 From: Samantha Date: Thu, 11 Jul 2024 15:01:55 -0400 Subject: [PATCH] WIP --- cmd/boulder-wfe2/main.go | 22 ++++ cmd/config.go | 28 +++-- cmd/sfe/main.go | 17 ++- features/features.go | 6 ++ probs/probs.go | 9 ++ sfe/pages/unpause-form.html | 2 +- sfe/sfe.go | 102 ++++-------------- sfe/sfe_test.go | 208 ++---------------------------------- test/config-next/sfe.json | 6 +- test/config-next/wfe2.json | 10 +- unpause/unpause.go | 129 ++++++++++++++++++++++ unpause/unpause_test.go | 151 ++++++++++++++++++++++++++ wfe2/wfe.go | 61 +++++++++++ wfe2/wfe_test.go | 3 + 14 files changed, 448 insertions(+), 306 deletions(-) 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 90ad22417493..858eb75ca5b6 100644 --- a/cmd/boulder-wfe2/main.go +++ b/cmd/boulder-wfe2/main.go @@ -160,6 +160,22 @@ type Config struct { // list will be rejected. This field is optional; if unset, no profile // names are accepted. CertificateProfileNames []string `validate:"omitempty,dive,alphanum,min=1,max=32"` + + // UnpauseHMACKey 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. + UnpauseHMACKey cmd.HMACKeyConfig `validate:"required_with=UnpauseLifetime SFEUrl"` + + // UnpauseLifetime 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. + UnpauseLifetime config.Duration `validate:"required_with=UnpauseHMACKey SFEUrl,min=336h"` + + // SFEUrl 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. + SFEUrl string `validate:"required_with=UnpauseHMACKey UnpauseLifetime,url,startswith=https://,endsnotwith=/"` } Syslog cmd.SyslogConfig @@ -248,6 +264,9 @@ func main() { clk := cmd.Clock() + unpauseHMACKey, err := c.WFE.UnpauseHMACKey.Load() + cmd.FailOnError(err, "Failed to load unpauseHMACKey") + tlsConfig, err := c.WFE.TLS.Load(stats) cmd.FailOnError(err, "TLS config") @@ -356,6 +375,9 @@ func main() { txnBuilder, maxNames, c.WFE.CertificateProfileNames, + unpauseHMACKey, + c.WFE.UnpauseLifetime.Duration, + c.WFE.SFEUrl, ) cmd.FailOnError(err, "Unable to create WFE") diff --git a/cmd/config.go b/cmd/config.go index d7408936e6f0..08fa36b69dff 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 8b8d57ac5d29..ebd06cac061c 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/features/features.go b/features/features.go index a303b7b47ac3..892d29f9931a 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 ec6c272ae52b..90ba0b785054 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/unpause-form.html b/sfe/pages/unpause-form.html index 2d6c2dfab143..2c8d576c7d72 100644 --- a/sfe/pages/unpause-form.html +++ b/sfe/pages/unpause-form.html @@ -13,7 +13,7 @@

Action Required to Unpause Your ACME Account

is temporarily restricted from requesting new certificates for certain hostnames including, but potentially not limited to, the following:

diff --git a/sfe/sfe.go b/sfe/sfe.go index f347a00f705c..fbb1e7f8f535 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,8 +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 is used to validate incoming JWT signatures at the unpause + // endpoint. This key must be the same as the one configured for the WFE. unpauseHMACKey []byte // HTML pages served by the SFE @@ -105,7 +99,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(unpauseGetForm, sfe.UnpauseForm) + m.HandleFunc(unpause.GetForm, sfe.UnpauseForm) m.HandleFunc(unpausePostForm, sfe.UnpauseSubmit) m.HandleFunc(unpauseStatus, sfe.UnpauseStatus) @@ -145,10 +139,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 @@ -166,7 +156,7 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, re return } - regID, domains, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT)) + regID, identifiers, err := sfe.validateUnpauseJWTforAccount(incomingJWT) if err != nil { sfe.unpauseStatusHelper(response, false) return @@ -175,13 +165,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 @@ -201,20 +191,12 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter, return } - regID, _, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT)) + _, _, err := sfe.validateUnpauseJWTforAccount(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 gRPC nrequest 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, @@ -256,65 +238,27 @@ 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, "/") +// validateUnpauseJWTforAccount validates the incoming JWT and returns the +// subscriber's registration ID and a slice of paused identifiers unpacked from +// the claims. If the JWT is invalid, an error is returned. +func (sfe *SelfServiceFrontEndImpl) validateUnpauseJWTforAccount(incomingJWT string) (int64, []string, error) { + slug := strings.Split(unpause.APIPrefix, "/") if len(slug) != 3 { - return "", nil, errors.New("Could not parse API version") - } - - token, err := jwt.ParseSigned(string(incomingJWT), []jose.SignatureAlgorithm{jose.HS256}) - 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"` + return 0, nil, errors.New("failed to parse API version") } - incomingClaims := sfeJWTClaims{} - err = token.Claims(sfe.unpauseHMACKey[:], &incomingClaims) + claims, err := unpause.RedeemJWT(incomingJWT, sfe.unpauseHMACKey, slug[2], sfe.clk) 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]) + acccount, convErr := strconv.ParseInt(claims.Subject, 10, 64) + if convErr != nil { + return 0, nil, errors.New("failed to parse account ID") } - return incomingClaims.Subject, strings.Split(incomingClaims.Domains, ","), nil + return acccount, strings.Split(claims.Identifiers, ","), nil } diff --git a/sfe/sfe_test.go b/sfe/sfe_test.go index c6137f699ce7..3b51110aa0e9 100644 --- a/sfe/sfe_test.go +++ b/sfe/sfe_test.go @@ -7,12 +7,9 @@ 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" @@ -27,6 +24,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" @@ -169,13 +167,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 +181,18 @@ 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") + validJWT, err := unpause.GenerateJWT([]byte(hmacKey), 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 +219,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 +233,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 7501494c3662..58ec7ad0fab9 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 a5a297dd852e..69799c2f7cf8 100644 --- a/test/config-next/wfe2.json +++ b/test/config-next/wfe2.json @@ -129,11 +129,17 @@ "features": { "ServeRenewalInfo": true, "TrackReplacementCertificatesARI": true, - "CheckRenewalExemptionAtWFE": true + "CheckRenewalExemptionAtWFE": true, + "CheckIdentifiersPaused": true }, "certificateProfileNames": [ "defaultBoulderCertificateProfile" - ] + ], + "unpauseHMACKey": { + "keyFile": "test/secrets/sfe_unpause_key" + }, + "unpauseLifetime": "336h", + "sfeURL": "https://boulder.service.consul:4003" }, "syslog": { "stdoutlevel": 4, diff --git a/unpause/unpause.go b/unpause/unpause.go new file mode 100644 index 000000000000..90f00debdb5d --- /dev/null +++ b/unpause/unpause.go @@ -0,0 +1,129 @@ +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" +) + +const ( + // API + APIVersion = "v1" + APIPrefix = "/sfe/" + APIVersion + GetForm = APIPrefix + "/unpause" + + // JWT + defaultIssuer = "WFE" + defaultAudience = "SFE Unpause" +) + +// JWTClaims represents the claims of a JWT token issued by the WFE for +// redemption by the SFE. The JWT contains the following claims required for +// unpausing: +// - Subject: the account ID of the Subscriber, +// - Version: the API version this JWT was created for, +// - Identifiers: a set of paused identifiers. +type JWTClaims struct { + jwt.Claims + + // Version is the API version this JWT was created for. + Version string `json:"version"` + + // Identifiers is set of comma separated ACME identifiers. + Identifiers string `json:"identifiers"` +} + +// GenerateJWT generates a serialized unpause JWT with the provided claims. +func GenerateJWT(key []byte, regID int64, identifiers []string, lifetime time.Duration, clk clock.Clock) (string, error) { + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, nil) + if err != nil { + return "", fmt.Errorf("creating signer: %s", err) + } + + claims := JWTClaims{ + Claims: jwt.Claims{ + Issuer: defaultIssuer, + Subject: fmt.Sprintf("%d", regID), + Audience: jwt.Audience{defaultAudience}, + Expiry: jwt.NewNumericDate(clk.Now().Add(lifetime)), + NotBefore: jwt.NewNumericDate(clk.Now()), + IssuedAt: jwt.NewNumericDate(clk.Now()), + }, + Version: APIVersion, + Identifiers: 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: %s", 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.Version == "" { + return JWTClaims{}, errors.New("no API version specified in the JWT") + } + + if claims.Version != version { + return JWTClaims{}, fmt.Errorf("unexpected API version in the JWT: %s", claims.Version) + } + + if claims.Identifiers == "" { + 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 000000000000..f01bc2fe45e7 --- /dev/null +++ b/unpause/unpause_test.go @@ -0,0 +1,151 @@ +package unpause + +import ( + "testing" + "time" + + "github.com/go-jose/go-jose/v4/jwt" + "github.com/jmhodges/clock" + "github.com/letsencrypt/boulder/test" +) + +func TestUnpauseJWT(t *testing.T) { + fc := clock.NewFake() + hmacKey := []byte("pcl04dl3tt3rb1gb4dd4db0d34ts000p") + + 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 identfier", + 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}, + NotBefore: jwt.NewNumericDate(fc.Now()), + Expiry: jwt.NewNumericDate(fc.Now().Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(fc.Now()), + }, + Version: APIVersion, + Identifiers: "example.com", + }, + wantGenerateJWTErr: false, + wantRedeemJWTErr: false, + }, + { + name: "valid multiple identfiers", + 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}, + NotBefore: jwt.NewNumericDate(fc.Now()), + Expiry: jwt.NewNumericDate(fc.Now().Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(fc.Now()), + }, + Version: APIVersion, + Identifiers: "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, + }, + { + 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: true, + wantRedeemJWTErr: false, + }, + { + 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(tt.args.key, 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.Assert(t, got.NotBefore.Time().Equal(tt.want.NotBefore.Time()), "expected NotBefore time to be equal") + test.Assert(t, got.IssuedAt.Time().Equal(tt.want.IssuedAt.Time()), "expected IssuedAt time to be equal") + test.AssertEquals(t, got.Version, tt.want.Version) + test.AssertEquals(t, got.Identifiers, tt.want.Identifiers) + }) + } +} diff --git a/wfe2/wfe.go b/wfe2/wfe.go index 708fbad94cb2..271a9eb410a4 100644 --- a/wfe2/wfe.go +++ b/wfe2/wfe.go @@ -31,6 +31,7 @@ import ( bgrpc "github.com/letsencrypt/boulder/grpc" "github.com/letsencrypt/boulder/policy" "github.com/letsencrypt/boulder/ratelimits" + "github.com/letsencrypt/boulder/unpause" // 'grpc/noncebalancer' is imported for its init function. _ "github.com/letsencrypt/boulder/grpc/noncebalancer" @@ -165,6 +166,10 @@ type WebFrontEndImpl struct { txnBuilder *ratelimits.TransactionBuilder maxNames int + unpauseHMACKey []byte + unpauseLifetime time.Duration + sfeURL string + // certificateProfileNames is a list of profile names that are allowed to be // passed to the newOrder endpoint. If a profile name is not in this list, // the request will be rejected as malformed. @@ -193,6 +198,9 @@ func NewWebFrontEndImpl( txnBuilder *ratelimits.TransactionBuilder, maxNames int, certificateProfileNames []string, + unpauseHMACKey []byte, + unpauseLifetime time.Duration, + sfeURL string, ) (WebFrontEndImpl, error) { if len(issuerCertificates) == 0 { return WebFrontEndImpl{}, errors.New("must provide at least one issuer certificate") @@ -231,6 +239,9 @@ func NewWebFrontEndImpl( txnBuilder: txnBuilder, maxNames: maxNames, certificateProfileNames: certificateProfileNames, + unpauseHMACKey: unpauseHMACKey, + unpauseLifetime: unpauseLifetime, + sfeURL: sfeURL, } return wfe, nil @@ -2245,6 +2256,7 @@ func (wfe *WebFrontEndImpl) NewOrder( // type identifier here. Check to make sure one of the strings is // short enough to meet the max CN bytes requirement. names := make([]string, len(newOrderRequest.Identifiers)) + identMap := make(map[string]*sapb.Identifier, len(newOrderRequest.Identifiers)) for i, ident := range newOrderRequest.Identifiers { if ident.Type != identifier.DNS { wfe.sendError(response, logEvent, @@ -2258,6 +2270,13 @@ func (wfe *WebFrontEndImpl) NewOrder( return } names[i] = ident.Value + lowerValue := strings.ToLower(ident.Value) + if slices.Contains(names, lowerValue) { + identMap[lowerValue] = &sapb.Identifier{ + Type: string(ident.Type), + Value: ident.Value, + } + } } names = core.UniqueLowerNames(names) @@ -2273,6 +2292,48 @@ func (wfe *WebFrontEndImpl) NewOrder( logEvent.DNSNames = names + if features.Get().CheckIdentifiersPaused { + // Ensure issuance is not paused for any of the requested identifiers. + orderIdentifiers := make([]*sapb.Identifier, 0, len(names)) + for _, name := range names { + ident, exists := identMap[name] + if exists { + orderIdentifiers = append(orderIdentifiers, ident) + } + } + paused, err := wfe.sa.CheckIdentifiersPaused(ctx, &sapb.PauseRequest{ + RegistrationID: acct.ID, + Identifiers: orderIdentifiers, + }) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error checking if identifiers are paused"), err) + return + } + + if len(paused.Identifiers) > 0 { + // Some or all of the identifiers are paused. + pausedValues := make([]string, len(paused.Identifiers)) + for i, ident := range paused.Identifiers { + pausedValues[i] = ident.Value + } + unpauseJWT, err := unpause.GenerateJWT(wfe.unpauseHMACKey, acct.ID, pausedValues, wfe.unpauseLifetime, wfe.clk) + if err != nil { + wfe.sendError(response, logEvent, web.ProblemDetailsForError(err, "Error generating unpause JWT"), err) + return + } + pausedMsg := fmt.Sprintf( + "Your account is temporarily prevented from requesting certificates "+ + "for %s (and possibly others), because it had a large number of failed "+ + "validation requests with no recent successes. To fix this and be able "+ + "to attempt certificate issuance again, please visit: %s", + strings.Join(pausedValues, ", "), + fmt.Sprintf("%s/%s%s?jwt=%s", wfe.sfeURL, unpause.APIPrefix, unpause.GetForm, unpauseJWT), + ) + wfe.sendError(response, logEvent, probs.Paused(pausedMsg), 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 754c7562d95c..499d7347720f 100644 --- a/wfe2/wfe_test.go +++ b/wfe2/wfe_test.go @@ -408,6 +408,9 @@ func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock, requestSigner) { txnBuilder, 100, []string{""}, + []byte("pcl04dl3tt3rb1gb4dd4db0d34ts000p"), + time.Hour*24*14, + "https://localhost", ) test.AssertNotError(t, err, "Unable to create WFE")