Skip to content

Commit

Permalink
WFE: Reject new orders containing paused identifiers (#7599)
Browse files Browse the repository at this point in the history
Part of #7406
Fixes #7475
  • Loading branch information
beautifulentropy authored Jul 25, 2024
1 parent a6e0fdc commit 986c78a
Show file tree
Hide file tree
Showing 20 changed files with 620 additions and 325 deletions.
29 changes: 29 additions & 0 deletions cmd/boulder-wfe2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
28 changes: 21 additions & 7 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
17 changes: 6 additions & 11 deletions cmd/sfe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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")

Expand All @@ -109,7 +104,7 @@ func main() {
c.SFE.Timeout.Duration,
rac,
sac,
unpauseHMACKeyBytes,
unpauseHMACKey,
)
cmd.FailOnError(err, "Unable to create SFE")

Expand Down
17 changes: 17 additions & 0 deletions core/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions core/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ services:
ports:
- 4001:4001 # ACMEv2
- 4002:4002 # OCSP
- 4003:4003 # OCSP
- 4003:4003 # SFE
depends_on:
- bmysql
- bproxysql
Expand Down
6 changes: 6 additions & 0 deletions features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions probs/probs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion sfe/pages/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ <h1>No Action Required</h1>
<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>
Expand Down
10 changes: 5 additions & 5 deletions sfe/pages/unpause-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ <h1>Action Required to Unpause Your ACME Account</h1>
<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>

Expand All @@ -29,7 +29,7 @@ <h2>Why Did This Happen?</h2>
<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
Expand All @@ -43,8 +43,8 @@ <h2>Ready to Unpause?</h2>
<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
Expand Down
Loading

0 comments on commit 986c78a

Please sign in to comment.