WFE: Reject new orders containing paused identifiers (#7599)
Part of #7406 Fixes #7475
This commit is contained in:
parent
a6e0fdc80e
commit
986c78a2b4
|
@ -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")
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
17
core/util.go
17
core/util.go
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -47,7 +47,7 @@ services:
|
|||
ports:
|
||||
- 4001:4001 # ACMEv2
|
||||
- 4002:4002 # OCSP
|
||||
- 4003:4003 # OCSP
|
||||
- 4003:4003 # SFE
|
||||
depends_on:
|
||||
- bmysql
|
||||
- bproxysql
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
112
sfe/sfe.go
112
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,11 +50,7 @@ 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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
|
219
sfe/sfe_test.go
219
sfe/sfe_test.go
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": {}
|
||||
},
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {}
|
||||
},
|
||||
|
|
|
@ -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=")
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
63
wfe2/wfe.go
63
wfe2/wfe.go
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
Loading…
Reference in New Issue