Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
beautifulentropy committed Jul 12, 2024
1 parent 74eba3b commit bd97d81
Show file tree
Hide file tree
Showing 14 changed files with 448 additions and 306 deletions.
22 changes: 22 additions & 0 deletions cmd/boulder-wfe2/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,22 @@ type Config struct {
// list will be rejected. This field is optional; if unset, no profile
// names are accepted.
CertificateProfileNames []string `validate:"omitempty,dive,alphanum,min=1,max=32"`

// UnpauseHMACKey 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.
UnpauseHMACKey cmd.HMACKeyConfig `validate:"required_with=UnpauseLifetime SFEUrl"`

// UnpauseLifetime 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.
UnpauseLifetime config.Duration `validate:"required_with=UnpauseHMACKey SFEUrl,min=336h"`

// SFEUrl 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.
SFEUrl string `validate:"required_with=UnpauseHMACKey UnpauseLifetime,url,startswith=https://,endsnotwith=/"`
}

Syslog cmd.SyslogConfig
Expand Down Expand Up @@ -248,6 +264,9 @@ func main() {

clk := cmd.Clock()

unpauseHMACKey, err := c.WFE.UnpauseHMACKey.Load()
cmd.FailOnError(err, "Failed to load unpauseHMACKey")

tlsConfig, err := c.WFE.TLS.Load(stats)
cmd.FailOnError(err, "TLS config")

Expand Down Expand Up @@ -356,6 +375,9 @@ func main() {
txnBuilder,
maxNames,
c.WFE.CertificateProfileNames,
unpauseHMACKey,
c.WFE.UnpauseLifetime.Duration,
c.WFE.SFEUrl,
)
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
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/unpause-form.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ <h1>Action Required to Unpause Your ACME Account</h1>
is temporarily restricted from requesting new certificates for certain
hostnames 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 Down
102 changes: 23 additions & 79 deletions sfe/sfe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 (
Expand All @@ -56,8 +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 is used to validate incoming JWT signatures at the unpause
// endpoint. This key must be the same as the one configured for the WFE.
unpauseHMACKey []byte

// HTML pages served by the SFE
Expand Down Expand Up @@ -105,7 +99,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(unpauseGetForm, sfe.UnpauseForm)
m.HandleFunc(unpause.GetForm, sfe.UnpauseForm)
m.HandleFunc(unpausePostForm, sfe.UnpauseSubmit)
m.HandleFunc(unpauseStatus, sfe.UnpauseStatus)

Expand Down Expand Up @@ -145,10 +139,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
Expand All @@ -166,7 +156,7 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseForm(response http.ResponseWriter, re
return
}

regID, domains, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT))
regID, identifiers, err := sfe.validateUnpauseJWTforAccount(incomingJWT)
if err != nil {
sfe.unpauseStatusHelper(response, false)
return
Expand All @@ -175,13 +165,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
Expand All @@ -201,20 +191,12 @@ func (sfe *SelfServiceFrontEndImpl) UnpauseSubmit(response http.ResponseWriter,
return
}

regID, _, err := sfe.validateUnpauseJWTforAccount(unpauseJWT(incomingJWT))
_, _, err := sfe.validateUnpauseJWTforAccount(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 gRPC nrequest 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,
Expand Down Expand Up @@ -256,65 +238,27 @@ 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, "/")
// validateUnpauseJWTforAccount validates the incoming JWT and returns the
// subscriber's registration ID and a slice of paused identifiers unpacked from
// the claims. If the JWT is invalid, an error is returned.
func (sfe *SelfServiceFrontEndImpl) validateUnpauseJWTforAccount(incomingJWT string) (int64, []string, error) {
slug := strings.Split(unpause.APIPrefix, "/")
if len(slug) != 3 {
return "", nil, errors.New("Could not parse API version")
}

token, err := jwt.ParseSigned(string(incomingJWT), []jose.SignatureAlgorithm{jose.HS256})
if err != nil {
return "", nil, fmt.Errorf("parsing JWT: %s", 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"`
return 0, nil, errors.New("failed to parse API version")
}

incomingClaims := sfeJWTClaims{}
err = token.Claims(sfe.unpauseHMACKey[:], &incomingClaims)
claims, err := unpause.RedeemJWT(incomingJWT, sfe.unpauseHMACKey, slug[2], sfe.clk)
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")
return 0, nil, err
}

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])
acccount, convErr := strconv.ParseInt(claims.Subject, 10, 64)
if convErr != nil {
return 0, nil, errors.New("failed to parse account ID")
}

return incomingClaims.Subject, strings.Split(incomingClaims.Domains, ","), nil
return acccount, strings.Split(claims.Identifiers, ","), nil
}
Loading

0 comments on commit bd97d81

Please sign in to comment.