boulder/unpause/unpause.go

161 lines
4.8 KiB
Go

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"
// BatchSize is the maximum number of identifiers that the SA will unpause
// in a single batch.
BatchSize = 10000
// MaxBatches is the maximum number of batches that the SA will unpause in a
// single request.
MaxBatches = 5
// RequestLimit is the maximum number of identifiers that the SA will
// unpause in a single request. This is used by the SFE to infer whether
// there are more identifiers to unpause.
RequestLimit = BatchSize * MaxBatches
// 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, idents []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(idents, ","),
}
serialized, err := jwt.Signed(signer).Claims(&claims).Serialize()
if err != nil {
return "", fmt.Errorf("serializing JWT: %s", err)
}
return serialized, nil
}
// ErrMalformedJWT is returned when the JWT is malformed.
var ErrMalformedJWT = errors.New("malformed JWT")
// 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'.
//
// If the JWT is malformed or invalid in any way, ErrMalformedJWT is returned.
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{}, errors.Join(ErrMalformedJWT, err)
}
claims := JWTClaims{}
err = parsedToken.Claims(key, &claims)
if err != nil {
return JWTClaims{}, errors.Join(ErrMalformedJWT, 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
}