161 lines
4.8 KiB
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
|
|
}
|