Skip to content

Commit

Permalink
RA: Count failed authorizations using key-value ratelimits
Browse files Browse the repository at this point in the history
  • Loading branch information
beautifulentropy committed Feb 29, 2024
1 parent 9e78787 commit 5d9ac4c
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 4 deletions.
2 changes: 2 additions & 0 deletions cmd/admin-revoker/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,8 @@ func setup(t *testing.T) testCtx {
metrics.NoopRegisterer,
1,
goodkey.KeyPolicy{},
nil,
nil,
100,
300*24*time.Hour,
7*24*time.Hour,
Expand Down
46 changes: 46 additions & 0 deletions cmd/boulder-ra/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
pubpb "github.com/letsencrypt/boulder/publisher/proto"
"github.com/letsencrypt/boulder/ra"
rapb "github.com/letsencrypt/boulder/ra/proto"
"github.com/letsencrypt/boulder/ratelimits"
bredis "github.com/letsencrypt/boulder/redis"
sapb "github.com/letsencrypt/boulder/sa/proto"
vapb "github.com/letsencrypt/boulder/va/proto"
)
Expand All @@ -42,6 +44,33 @@ type Config struct {
PublisherService *cmd.GRPCClientConfig
AkamaiPurgerService *cmd.GRPCClientConfig

Limiter struct {
// Redis contains the configuration necessary to connect to Redis
// for rate limiting. This field is required to enable rate
// limiting.
Redis *bredis.Config `validate:"required_with=Defaults"`

// Defaults is a path to a YAML file containing default rate limits.
// See: ratelimits/README.md for details. This field is required to
// enable rate limiting. If any individual rate limit is not set,
// that limit will be disabled. Limits passed in this file must be
// identical to those in the WFE.
//
// Note: At this time, only the Failed Authorizations rate limit is
// necessary in the RA.
Defaults string `validate:"required_with=Redis"`

// Overrides is a path to a YAML file containing overrides for the
// default rate limits. See: ratelimits/README.md for details. If
// this field is not set, all requesters will be subject to the
// default rate limits. Overrides passed in this file must be
// identical to those in the WFE.
//
// Note: At this time, only the Failed Authorizations overrides are
// necessary in the RA.
Overrides string
}

// MaxNames is the maximum number of subjectAltNames in a single cert.
// The value supplied MUST be greater than 0 and no more than 100. These
// limits are per section 7.1 of our combined CP/CPS, under "DV-SSL
Expand Down Expand Up @@ -232,12 +261,29 @@ func main() {
cmd.Fail("Error in RA config: MaxNames must not be 0")
}

var limiter *ratelimits.Limiter
var txnBuilder *ratelimits.TransactionBuilder
var limiterRedis *bredis.Ring
if c.RA.Limiter.Defaults != "" {
// Setup rate limiting.
limiterRedis, err = bredis.NewRingFromConfig(*c.RA.Limiter.Redis, scope, logger)
cmd.FailOnError(err, "Failed to create Redis ring")

source := ratelimits.NewRedisSource(limiterRedis.Ring, clk, scope)
limiter, err = ratelimits.NewLimiter(clk, source, scope)
cmd.FailOnError(err, "Failed to create rate limiter")
txnBuilder, err = ratelimits.NewTransactionBuilder(c.RA.Limiter.Defaults, c.RA.Limiter.Overrides)
cmd.FailOnError(err, "Failed to create rate limits transaction builder")
}

rai := ra.NewRegistrationAuthorityImpl(
clk,
logger,
scope,
c.RA.MaxContactsPerRegistration,
kp,
limiter,
txnBuilder,
c.RA.MaxNames,
authorizationLifetime,
pendingAuthorizationLifetime,
Expand Down
7 changes: 5 additions & 2 deletions cmd/boulder-wfe2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,16 @@ type Config struct {
// Defaults is a path to a YAML file containing default rate limits.
// See: ratelimits/README.md for details. This field is required to
// enable rate limiting. If any individual rate limit is not set,
// that limit will be disabled.
// that limit will be disabled. Failed Authorizations limits passed
// in this file must be identical to those in the RA.
Defaults string `validate:"required_with=Redis"`

// Overrides is a path to a YAML file containing overrides for the
// default rate limits. See: ratelimits/README.md for details. If
// this field is not set, all requesters will be subject to the
// default rate limits.
// default rate limits. Overrides for the Failed Authorizations
// overrides passed in this file must be identical to those in the
// RA.
Overrides string
}

Expand Down
33 changes: 33 additions & 0 deletions ra/ra.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ type RegistrationAuthorityImpl struct {
pendingAuthorizationLifetime time.Duration
rlPolicies ratelimit.Limits
maxContactsPerReg int
limiter *ratelimits.Limiter
txnBuilder *ratelimits.TransactionBuilder
maxNames int
orderLifetime time.Duration
finalizeTimeout time.Duration
Expand Down Expand Up @@ -127,6 +129,8 @@ func NewRegistrationAuthorityImpl(
stats prometheus.Registerer,
maxContactsPerReg int,
keyPolicy goodkey.KeyPolicy,
limiter *ratelimits.Limiter,
txnBuilder *ratelimits.TransactionBuilder,
maxNames int,
authorizationLifetime time.Duration,
pendingAuthorizationLifetime time.Duration,
Expand Down Expand Up @@ -246,6 +250,8 @@ func NewRegistrationAuthorityImpl(
rlPolicies: ratelimit.New(),
maxContactsPerReg: maxContactsPerReg,
keyPolicy: keyPolicy,
limiter: limiter,
txnBuilder: txnBuilder,
maxNames: maxNames,
publisher: pubc,
caa: caaClient,
Expand Down Expand Up @@ -1756,6 +1762,26 @@ func (ra *RegistrationAuthorityImpl) recordValidation(ctx context.Context, authI
return err
}

func (ra *RegistrationAuthorityImpl) countFailedValidation(ctx context.Context, regId int64, name string) {
if ra.limiter == nil && ra.txnBuilder == nil {
// Limiter is disabled.
return
}

txns, err := ra.txnBuilder.FailedAuthorizationsPerDomainPerAccountSpendOnlyTransactions(regId, []string{name}, ra.maxNames)
if err != nil {
ra.log.Errf("constructing rate limit transaction for the %s rate limit: %s", ratelimits.FailedAuthorizationsPerDomainPerAccount, err)
}

_, err = ra.limiter.BatchSpend(ctx, txns)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
ra.log.Errf("checking the %s limit", ratelimits.FailedAuthorizationsPerDomainPerAccount, err)
}
}

// PerformValidation initiates validation for a specific challenge associated
// with the given base authorization. The authorization and challenge are
// updated based on the results.
Expand Down Expand Up @@ -1884,6 +1910,13 @@ func (ra *RegistrationAuthorityImpl) PerformValidation(
if prob != nil {
challenge.Status = core.StatusInvalid
challenge.Error = prob

// TODO(#5545): Spending can be async until key-value rate limits
// are authoritative. This saves us from adding latency to each
// request. Goroutines spun out below will respect a context
// deadline set by the ratelimits package and cannot be prematurely
// canceled by the requester.
go ra.countFailedValidation(vaCtx, authz.RegistrationID, authz.Identifier.Value)
} else {
challenge.Status = core.StatusValid
}
Expand Down
76 changes: 75 additions & 1 deletion ra/ra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"math/big"
mrand "math/rand"
"net"
"os"
"regexp"
"strconv"
"strings"
Expand All @@ -38,6 +39,7 @@ import (

akamaipb "github.com/letsencrypt/boulder/akamai/proto"
capb "github.com/letsencrypt/boulder/ca/proto"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/config"
"github.com/letsencrypt/boulder/core"
corepb "github.com/letsencrypt/boulder/core/proto"
Expand All @@ -56,6 +58,7 @@ import (
rapb "github.com/letsencrypt/boulder/ra/proto"
"github.com/letsencrypt/boulder/ratelimit"
"github.com/letsencrypt/boulder/ratelimits"
bredis "github.com/letsencrypt/boulder/redis"
"github.com/letsencrypt/boulder/sa"
sapb "github.com/letsencrypt/boulder/sa/proto"
"github.com/letsencrypt/boulder/test"
Expand Down Expand Up @@ -365,9 +368,40 @@ func initAuthorities(t *testing.T) (*DummyValidationAuthority, sapb.StorageAutho
},
}, nil, nil, 0, log, metrics.NoopRegisterer)

var limiter *ratelimits.Limiter
var txnBuilder *ratelimits.TransactionBuilder
if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") {
rc := bredis.Config{
Username: "unittest-rw",
TLS: cmd.TLSConfig{
CACertFile: "../test/redis-tls/minica.pem",
CertFile: "../test/redis-tls/boulder/cert.pem",
KeyFile: "../test/redis-tls/boulder/key.pem",
},
Lookups: []cmd.ServiceDomain{
{
Service: "redisratelimits",
Domain: "service.consul",
},
},
LookupDNSAuthority: "consul.service.consul",
}
rc.PasswordConfig = cmd.PasswordConfig{
PasswordFile: "../test/secrets/ratelimits_redis_password",
}
ring, err := bredis.NewRingFromConfig(rc, stats, log)
test.AssertNotError(t, err, "making redis ring client")
source := ratelimits.NewRedisSource(ring.Ring, fc, stats)
test.AssertNotNil(t, source, "source should not be nil")
limiter, err = ratelimits.NewLimiter(fc, source, stats)
test.AssertNotError(t, err, "making limiter")
txnBuilder, err = ratelimits.NewTransactionBuilder("../test/config-next/wfe2-ratelimit-defaults.yml", "")
test.AssertNotError(t, err, "making transaction composer")
}

ra := NewRegistrationAuthorityImpl(
fc, log, stats,
1, testKeyPolicy, 100,
1, testKeyPolicy, limiter, txnBuilder, 100,
300*24*time.Hour, 7*24*time.Hour,
nil, noopCAA{},
0, 5*time.Minute,
Expand Down Expand Up @@ -857,6 +891,19 @@ func TestPerformValidationSuccess(t *testing.T) {
Problems: nil,
}

var remainingFailedValidations int64
var rlTxns []ratelimits.Transaction
if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") {
// Gather a baseline for the rate limit.
var err error
rlTxns, err = ra.txnBuilder.FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions(authzPB.RegistrationID, []string{Identifier}, 100)
test.AssertNotError(t, err, "FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions failed")

d, err := ra.limiter.BatchSpend(ctx, rlTxns)
test.AssertNotError(t, err, "BatchSpend failed")
remainingFailedValidations = d.Remaining
}

now := fc.Now()
challIdx := dnsChallIdx(t, authzPB.Challenges)
authzPB, err := ra.PerformValidation(ctx, &rapb.PerformValidationRequest{
Expand Down Expand Up @@ -898,6 +945,13 @@ func TestPerformValidationSuccess(t *testing.T) {
// Check that validated timestamp was recorded, stored, and retrieved
expectedValidated := fc.Now()
test.Assert(t, *challenge.Validated == expectedValidated, "Validated timestamp incorrect or missing")

if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") {
// The failed validations bucket should be identical to the baseline.
d, err := ra.limiter.BatchSpend(ctx, rlTxns)
test.AssertNotError(t, err, "BatchSpend failed")
test.AssertEquals(t, d.Remaining, remainingFailedValidations)
}
}

func TestPerformValidationVAError(t *testing.T) {
Expand All @@ -906,6 +960,19 @@ func TestPerformValidationVAError(t *testing.T) {

authzPB := createPendingAuthorization(t, sa, Identifier, fc.Now().Add(12*time.Hour))

var remainingFailedValidations int64
var rlTxns []ratelimits.Transaction
if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") {
// Gather a baseline for the rate limit.
var err error
rlTxns, err = ra.txnBuilder.FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions(authzPB.RegistrationID, []string{Identifier}, 100)
test.AssertNotError(t, err, "FailedAuthorizationsPerDomainPerAccountCheckOnlyTransactions failed")

d, err := ra.limiter.BatchSpend(ctx, rlTxns)
test.AssertNotError(t, err, "BatchSpend failed")
remainingFailedValidations = d.Remaining
}

va.PerformValidationRequestResultError = fmt.Errorf("Something went wrong")

challIdx := dnsChallIdx(t, authzPB.Challenges)
Expand Down Expand Up @@ -945,6 +1012,13 @@ func TestPerformValidationVAError(t *testing.T) {
// Check that validated timestamp was recorded, stored, and retrieved
expectedValidated := fc.Now()
test.Assert(t, *challenge.Validated == expectedValidated, "Validated timestamp incorrect or missing")

if strings.Contains(os.Getenv("BOULDER_CONFIG_DIR"), "test/config-next") {
// The failed validations bucket should have been decremented by 1.
d, err := ra.limiter.BatchSpend(ctx, rlTxns)
test.AssertNotError(t, err, "BatchSpend failed")
test.AssertEquals(t, d.Remaining, remainingFailedValidations-1)
}
}

func TestCertificateKeyNotEqualAccountKey(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion ratelimits/bucket.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ func (builder *TransactionBuilder) FailedAuthorizationsPerDomainPerAccountCheckO
}

// Add a check-only transaction for each per domain per account bucket.
txn, err := newCheckOnlyTransaction(limit, perDomainPerAccountBucketKey, 1)
txn, err := newCheckOnlyTransaction(limit, perDomainPerAccountBucketKey, 0)
if err != nil {
return nil, err
}
Expand Down

0 comments on commit 5d9ac4c

Please sign in to comment.