WFE: Reject new orders containing paused identifiers (#7599)

Part of #7406
Fixes #7475
This commit is contained in:
Samantha Frank 2024-07-25 13:46:40 -04:00 committed by GitHub
parent a6e0fdc80e
commit 986c78a2b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 620 additions and 325 deletions

View File

@ -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")

View File

@ -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
}

View File

@ -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")

View File

@ -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 {

View File

@ -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)

View File

@ -47,7 +47,7 @@ services:
ports:
- 4001:4001 # ACMEv2
- 4002:4002 # OCSP
- 4003:4003 # OCSP
- 4003:4003 # SFE
depends_on:
- bmysql
- bproxysql

View File

@ -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)

View File

@ -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 {

View File

@ -10,7 +10,7 @@
<p>
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 <a
href="https://letsencrypt.org/docs/client-options/">ACME client</a>

View File

@ -11,9 +11,9 @@
<p>
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:
<ul>
{{ range $domain := .PausedDomains }}<li>{{ $domain }}</li>{{ end }}
{{ range $identifier := .Identifiers }}<li>{{ $identifier }}</li>{{ end }}
</ul>
</p>
@ -29,7 +29,7 @@
<h2>What Can You Do?</h2>
<p>
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 @@
<p>
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.
</p>
<p>
<strong>Note:</strong> If you face difficulties unpausing your account or

View File

@ -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)
return 0, nil, 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"`
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")
}
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")
}
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])
}
return incomingClaims.Subject, strings.Split(incomingClaims.Domains, ","), nil
return account, strings.Split(claims.I, ","), nil
}

View File

@ -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)
}
})
}
}

View File

@ -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": {}
},

View File

@ -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": {

View File

@ -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": {}
},

View File

@ -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=")
}

141
unpause/unpause.go Normal file
View File

@ -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
}

155
unpause/unpause_test.go Normal file
View File

@ -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)
})
}
}

View File

@ -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 {

View File

@ -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")