Skip to content

Commit

Permalink
Merge branch main into ra-unpause-handler
Browse files Browse the repository at this point in the history
  • Loading branch information
aarongable committed Jul 25, 2024
2 parents 7b94226 + 98a4bc0 commit e20a6f1
Show file tree
Hide file tree
Showing 29 changed files with 1,433 additions and 1,079 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
8 changes: 4 additions & 4 deletions mocks/sa.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ func (sa *StorageAuthorityReadOnly) GetOrder(_ context.Context, req *sapb.OrderR
V2Authorizations: []int64{1},
CertificateSerial: "serial",
Error: nil,
CertificateProfileName: "defaultBoulderCertificateProfile",
CertificateProfileName: "default",
}

// Order ID doesn't have a certificate serial yet
Expand Down Expand Up @@ -468,10 +468,10 @@ func (sa *StorageAuthorityReadOnly) GetValidAuthorizations2(ctx context.Context,
if req.RegistrationID != 1 && req.RegistrationID != 5 && req.RegistrationID != 4 {
return &sapb.Authorizations{}, nil
}
now := req.Now.AsTime()
expiryCutoff := req.ValidUntil.AsTime()
auths := &sapb.Authorizations{}
for _, name := range req.Domains {
exp := now.AddDate(100, 0, 0)
exp := expiryCutoff.AddDate(100, 0, 0)
authzPB, err := bgrpc.AuthzToPB(core.Authorization{
Status: core.StatusValid,
RegistrationID: req.RegistrationID,
Expand All @@ -485,7 +485,7 @@ func (sa *StorageAuthorityReadOnly) GetValidAuthorizations2(ctx context.Context,
Status: core.StatusValid,
Type: core.ChallengeTypeDNS01,
Token: "exampleToken",
Validated: &now,
Validated: &expiryCutoff,
},
},
})
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
4 changes: 2 additions & 2 deletions ra/ra.go
Original file line number Diff line number Diff line change
Expand Up @@ -2098,7 +2098,7 @@ func (ra *RegistrationAuthorityImpl) RevokeCertByApplicant(ctx context.Context,
authzMapPB, err = ra.SA.GetValidAuthorizations2(ctx, &sapb.GetValidAuthorizationsRequest{
RegistrationID: req.RegID,
Domains: cert.DNSNames,
Now: timestamppb.New(ra.clk.Now()),
ValidUntil: timestamppb.New(ra.clk.Now()),
})
if err != nil {
return nil, err
Expand Down Expand Up @@ -2526,7 +2526,7 @@ func (ra *RegistrationAuthorityImpl) NewOrder(ctx context.Context, req *rapb.New

getAuthReq := &sapb.GetAuthorizationsRequest{
RegistrationID: newOrder.RegistrationID,
Now: timestamppb.New(authzExpiryCutoff),
ValidUntil: timestamppb.New(authzExpiryCutoff),
Domains: newOrder.Names,
}
existingAuthz, err := ra.SA.GetAuthorizations2(ctx, getAuthReq)
Expand Down
39 changes: 27 additions & 12 deletions ratelimits/limiter.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,26 @@ type Limiter struct {
// NewLimiter returns a new *Limiter. The provided source must be safe for
// concurrent use.
func NewLimiter(clk clock.Clock, source source, stats prometheus.Registerer) (*Limiter, error) {
limiter := &Limiter{source: source, clk: clk}
limiter.spendLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{
spendLatency := prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "ratelimits_spend_latency",
Help: fmt.Sprintf("Latency of ratelimit checks labeled by limit=[name] and decision=[%s|%s], in seconds", Allowed, Denied),
// Exponential buckets ranging from 0.0005s to 3s.
Buckets: prometheus.ExponentialBuckets(0.0005, 3, 8),
}, []string{"limit", "decision"})
stats.MustRegister(limiter.spendLatency)
stats.MustRegister(spendLatency)

limiter.overrideUsageGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{
overrideUsageGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "ratelimits_override_usage",
Help: "Proportion of override limit used, by limit name and bucket key.",
}, []string{"limit", "bucket_key"})
stats.MustRegister(limiter.overrideUsageGauge)

return limiter, nil
stats.MustRegister(overrideUsageGauge)

return &Limiter{
source: source,
clk: clk,
spendLatency: spendLatency,
overrideUsageGauge: overrideUsageGauge,
}, nil
}

type Decision struct {
Expand Down Expand Up @@ -166,6 +170,8 @@ func (d *batchDecision) merge(in *Decision) {
// - Remaining is the smallest value of each across all Decisions, and
// - Decisions resulting from spend-only Transactions are never merged.
func (l *Limiter) BatchSpend(ctx context.Context, txns []Transaction) (*Decision, error) {
start := l.clk.Now()

batch, bucketKeys, err := prepareBatch(txns)
if err != nil {
return nil, err
Expand All @@ -183,9 +189,9 @@ func (l *Limiter) BatchSpend(ctx context.Context, txns []Transaction) (*Decision
return nil, err
}

start := l.clk.Now()
batchDecision := newBatchDecision()
newTATs := make(map[string]time.Time)
txnOutcomes := make(map[Transaction]string)

for _, txn := range batch {
tat, exists := tats[txn.bucketKey]
Expand All @@ -209,16 +215,25 @@ func (l *Limiter) BatchSpend(ctx context.Context, txns []Transaction) (*Decision
if !txn.spendOnly() {
batchDecision.merge(d)
}

txnOutcomes[txn] = Denied
if d.Allowed {
txnOutcomes[txn] = Allowed
}
}

if batchDecision.Allowed {
if batchDecision.Allowed && len(newTATs) > 0 {
err = l.source.BatchSet(ctx, newTATs)
if err != nil {
return nil, err
}
l.spendLatency.WithLabelValues("batch", Allowed).Observe(l.clk.Since(start).Seconds())
} else {
l.spendLatency.WithLabelValues("batch", Denied).Observe(l.clk.Since(start).Seconds())
}

// Observe latency equally across all transactions in the batch.
totalLatency := l.clk.Since(start)
perTxnLatency := totalLatency / time.Duration(len(txnOutcomes))
for txn, outcome := range txnOutcomes {
l.spendLatency.WithLabelValues(txn.limit.name.String(), outcome).Observe(perTxnLatency.Seconds())
}
return batchDecision.Decision, nil
}
Expand Down
Loading

0 comments on commit e20a6f1

Please sign in to comment.