1
0
mirror of https://github.com/smallstep/cli.git synced 2025-08-09 03:22:43 +03:00
Files
step-ca-cli/command/crypto/jwt/verify.go
2024-09-30 13:25:46 +02:00

298 lines
9.2 KiB
Go

package jwt
import (
"fmt"
"os"
"strings"
"time"
"github.com/pkg/errors"
"github.com/urfave/cli"
"github.com/smallstep/cli-utils/errs"
"go.step.sm/crypto/jose"
"github.com/smallstep/cli/flags"
"github.com/smallstep/cli/utils"
)
func verifyCommand() cli.Command {
return cli.Command{
Name: "verify",
Action: cli.ActionFunc(verifyAction),
Usage: "verify a signed JWT data structure and return the payload",
UsageText: `**step crypto jwt verify**
[**--aud**=<audience>] [**--iss**=<issuer>] [**--alg**=<algorithm>]
[**--key**=<file>] [**--jwks**=<jwks>] [**--kid**=<kid>]`,
Description: `**step crypto jwt verify** reads a JWT data structure from STDIN; checks that
the audience, issuer, and algorithm are in agreement with expectations;
verifies the digital signature or message authentication code as appropriate;
and outputs the decoded payload of the JWT on STDOUT. If verification fails a
non-zero failure code is returned. If verification succeeds the command
returns 0.
For a JWT to be verified successfully:
* The JWT must be well formed (no errors during deserialization)
* The <algorithm> must match the **"alg"** member in the JWT header
* The <issuer> and <audience> must match the **"iss"** and **"aud"** claims in the JWT,
respectively
* The <kid> must match the **"kid"** member in the JWT header (if both are
present) and must match the **"kid"** in the JWK or the **"kid"** of one of the
JWKs in JWKS
* The JWT signature must be successfully verified
* The JWT must not be expired
For examples, see **step help crypto jwt**.`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "iss, issuer",
Usage: `The issuer of this JWT. The <issuer> must match the value of the **"iss"** claim in
the JWT. <issuer> is a case-sensitive string. Required unless disabled with the **--subtle** flag.`,
},
cli.StringFlag{
Name: "aud, audience",
Usage: `The identity of the principal running this command. The <audience> specified
must match one of the values in the **"aud"** claim, indicating the intended
recipient(s) of the JWT. <audience> is a case-sensitive string. Required unless disabled with the
**--subtle** flag.`,
},
cli.StringFlag{
Name: "alg, algorithm",
Usage: `The signature or MAC <algorithm> to use. Algorithms are case-sensitive strings
defined in RFC7518. If the key used do verify the JWT is not a JWK, or if it
is a JWK but does not have an **"alg"** member indicating its the intended
algorithm for use with the key, then the **--alg** flag is required to prevent
algorithm downgrade attacks. To disable this protection you can pass the
**--insecure** flag and omit the **--alg** flag.`,
},
cli.StringFlag{
Name: "key",
Usage: `The <file> containing the key to use to verify the JWT.
The contents of the file can be a public or private JWK (or a JWK
encrypted as a JWE payload) or a public or private PEM (or a private key
encrypted using the modes described on RFC 1423 or with PBES2+PBKDF2 described
in RFC 2898).`,
},
cli.StringFlag{
Name: "jwks",
Usage: `The JWK Set containing the key to use to verify the JWS. The <jwks> argument
should be the name of a file. The file contents should be a JWK Set or a JWE
with a JWK Set payload. The JWS being verified should have a "kid" member that
matches the "kid" of one of the JWKs in the JWK Set. If the JWS does not have
a "kid" member the '--kid' flag can be used.`,
},
cli.StringFlag{
Name: "kid",
Usage: `The ID of the key used to sign the JWK, used to select a JWK from a JWK Set.
The KID argument is a case-sensitive string. If the input JWS has a "kid"
member its value must match <kid> or verification will fail.`,
},
cli.StringFlag{
Name: "password-file",
Usage: `The path to the <file> containing the password to decrypt the key.`,
},
cli.BoolFlag{
Name: "no-exp-check",
Hidden: true,
},
flags.SubtleHidden,
flags.InsecureHidden,
},
}
}
type timeClaims struct {
Expiry *int64 `json:"exp,omitempty"`
NotBefore *int64 `json:"nbf,omitempty"`
}
// Get the public key for a JWK.
func publicKey(jwk *jose.JSONWebKey) interface{} {
if jose.IsSymmetric(jwk) {
return jwk.Key
}
return jwk.Public().Key
}
func verifyAction(ctx *cli.Context) error {
token, err := utils.ReadString(os.Stdin)
if err != nil {
return errors.Wrap(err, "error reading token")
}
tok, err := jose.ParseSigned(token)
if err != nil {
return errors.Errorf("error parsing token: %s", jose.TrimPrefix(err))
}
// Validate key, jwks and kid
key := ctx.String("key")
jwks := ctx.String("jwks")
kid := ctx.String("kid")
alg := ctx.String("alg")
switch {
case key == "" && jwks == "":
return errs.RequiredOrFlag(ctx, "key", "jwks")
case key != "" && jwks != "":
return errs.MutuallyExclusiveFlags(ctx, "key", "jwks")
case jwks != "" && kid == "":
if tok.Headers[0].KeyID == "" {
return errs.RequiredWithFlag(ctx, "kid", "jwks")
}
kid = tok.Headers[0].KeyID
}
// Validate subtle
isSubtle := ctx.Bool("subtle")
iss := ctx.String("iss")
aud := ctx.String("aud")
if !isSubtle {
switch {
case iss == "":
return errs.RequiredUnlessSubtleFlag(ctx, "iss")
case aud == "":
return errs.RequiredUnlessSubtleFlag(ctx, "aud")
}
}
// Validate no-exp-check with insecure
if ctx.Bool("no-exp-check") && !ctx.Bool("insecure") {
return errs.RequiredInsecureFlag(ctx, "no-exp-check")
}
// Add parse options
var options []jose.Option
options = append(options, jose.WithUse("sig"))
if alg != "" {
options = append(options, jose.WithAlg(alg))
}
if kid != "" {
options = append(options, jose.WithKid(kid))
}
if isSubtle {
options = append(options, jose.WithSubtle(true))
}
if !ctx.Bool("insecure") {
options = append(options, jose.WithNoDefaults(true))
}
if passwordFile := ctx.String("password-file"); passwordFile != "" {
options = append(options, jose.WithPasswordFile(passwordFile))
}
// Read key from --key or --jwks
var jwk *jose.JSONWebKey
switch {
case key != "":
jwk, err = jose.ReadKey(key, options...)
case jwks != "":
jwk, err = jose.ReadKeySet(jwks, options...)
default:
return errs.RequiredOrFlag(ctx, "key", "jwks")
}
if err != nil {
return err
}
// At this moment jwk.Algorithm should have an alg from:
// * alg parameter
// * jwk or jwkset
// * guessed for ecdsa and ed25519 keys
if jwk.Algorithm == "" {
return errors.New("flag '--alg' is required with the given key")
}
if err := jose.ValidateJWK(jwk); err != nil {
return err
}
// We don't support multiple signatures or any critical headers
if len(tok.Headers) > 1 {
return errors.New("validation failed: multiple signatures are not supported")
}
if _, ok := tok.Headers[0].ExtraHeaders["crit"]; ok {
return errors.New("validation failed: unrecognized critical headers (crit)")
}
if !isSubtle && alg != "" && tok.Headers[0].Algorithm != "" && alg != tok.Headers[0].Algorithm {
return errors.Errorf("alg %s does not match the alg on JWT (%s)", alg, tok.Headers[0].Algorithm)
}
claims := jose.Claims{}
if err := tok.Claims(publicKey(jwk), &claims); err != nil {
if errors.Is(err, jose.ErrCryptoFailure) {
return errors.New("validation failed: invalid signature")
}
return errors.Wrap(err, "claim verify failed")
}
// Check exp and nbf presence
// There's no need to do the verification again.
var tClaims timeClaims
if err := tok.UnsafeClaimsWithoutVerification(&tClaims); err != nil {
if errors.Is(err, jose.ErrCryptoFailure) {
return errors.New("validation failed: invalid signature")
}
return errors.Wrap(err, "claim verify failed")
}
expected := jose.Expected{Issuer: iss}
if aud != "" {
expected.Audience = jose.Audience{aud}
}
if tClaims.Expiry != nil || tClaims.NotBefore != nil {
expected.Time = time.Now()
}
if err := validateClaimsWithLeeway(ctx, claims, expected, tClaims, 0); err != nil {
return err
}
return printToken(token)
}
// validateClaimsWithLeeway is a custom implementation of go-jose
// jwt.Claims.ValidateWithLeeway that returns all the errors found.
func validateClaimsWithLeeway(ctx *cli.Context, c jose.Claims, e jose.Expected, t timeClaims, leeway time.Duration) error {
var ers []string
if e.Issuer != "" && e.Issuer != c.Issuer {
ers = append(ers, "invalid issuer claim (iss)")
}
// we're not currently checking the subject
if e.Subject != "" && e.Subject != c.Subject {
ers = append(ers, "invalid subject (sub)")
}
// we're not currently checking the id
if e.ID != "" && e.ID != c.ID {
ers = append(ers, "invalid ID claim (jti)")
}
for _, v := range e.Audience {
if !c.Audience.Contains(v) {
ers = append(ers, "invalid audience claim (aud)")
break
}
}
// Only if nbf is defined, just in case is tested in time <0 :)
if t.NotBefore != nil {
if !e.Time.IsZero() && e.Time.Add(leeway).Before(c.NotBefore.Time()) {
ers = append(ers, "token not valid yet (nbf)")
}
}
// Only if exp is defined and no-exp-check is not used
if t.Expiry != nil && !ctx.Bool("no-exp-check") {
if !e.Time.IsZero() && e.Time.Add(-leeway).After(c.Expiry.Time()) {
ers = append(ers, fmt.Sprintf("token is expired by %s (exp)", e.Time.Sub(c.Expiry.Time()).Round(time.Millisecond)))
}
}
if len(ers) > 0 {
return errors.Errorf("validation failed: %s", strings.Join(ers, ", "))
}
return nil
}