jwt

package
v1.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package jwt is a stdlib-only JWT implementation for Vault42: RS256 + ES256 sign/verify, claim parsing, algorithm whitelisting, and canonical segment decoding.

The rest of the defenses spelled out in docs/spec.md belong to the callers and are not enforced here, because this package never sees the policy they depend on. The jku/x5u/x5c/jwk and crit rejections and the kid format check live in the Keyfunc, which is the only place that knows which keys are trusted; the size cap lives at each entry point, 8 KB in crypto.ParseAndValidate and 4 KB in crypto.ValidateDPoPProof. A caller that reaches ParseWithClaims directly gets none of them.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrTokenMalformed is returned when the token cannot be decomposed into a
	// JWT at all: the wrong number of dot-separated segments, base64url that
	// will not decode, or a header or claims segment that is not JSON. Nothing
	// cryptographic has been attempted when this is returned.
	ErrTokenMalformed = errors.New("token is malformed")

	// ErrTokenUnverifiable is returned when the token is well-formed but could
	// not be checked: the alg header is missing, no Keyfunc was supplied, the
	// Keyfunc failed to produce a key (an unknown kid, typically), or the alg
	// is not one of the implemented signature algorithms. This is the
	// fail-closed branch that rejects "none" and every symmetric algorithm.
	ErrTokenUnverifiable = errors.New("token is unverifiable")

	// ErrTokenSignatureInvalid is returned when verification ran against a real
	// key and the signature did not match, and when the alg is outside a
	// caller-supplied allowlist. Callers that want to conceal key-management
	// state from an attacker collapse ErrTokenUnverifiable into this one before
	// answering the request.
	ErrTokenSignatureInvalid = errors.New("token signature is invalid")

	// ErrTokenExpired is returned when the exp claim is at or before the
	// current time. The comparison has no leeway, so a token whose exp equals
	// now is already expired.
	ErrTokenExpired = errors.New("token is expired")

	// ErrTokenNotValidYet is returned when the nbf claim is in the future. As
	// with exp, no clock skew is tolerated.
	ErrTokenNotValidYet = errors.New("token is not valid yet")

	// ErrTokenUsedBeforeIssued is returned when the iat claim is in the future.
	// It is only ever produced when the caller opts in with WithIssuedAt.
	ErrTokenUsedBeforeIssued = errors.New("token used before issued")

	// ErrTokenInvalidAudience is returned when the aud claim does not contain
	// the audience the caller required with WithAudience. A token minted for a
	// different service is rejected here, not silently accepted.
	ErrTokenInvalidAudience = errors.New("token has invalid audience")

	// ErrTokenInvalidIssuer is returned when the iss claim does not equal the
	// issuer the caller required with WithIssuer.
	ErrTokenInvalidIssuer = errors.New("token has invalid issuer")

	// ErrTokenRequiredClaimMissing is returned when a claim the caller demanded
	// is absent. Today only WithExpirationRequired demands one, which turns a
	// token with no exp from "never expires" into a rejection.
	ErrTokenRequiredClaimMissing = errors.New("token is missing required claim")

	// ErrInvalidKeyType is returned when the key the Keyfunc produced does not
	// match the token's algorithm, for instance an ECDSA key for an RS256
	// token, or when a nil key reaches a sign or verify call. It signals a
	// caller or configuration bug rather than a hostile token.
	ErrInvalidKeyType = errors.New("key is of invalid type")
)

The sentinel errors every parse and verification path wraps. Callers branch on them with errors.Is, so each one names a distinct condition and the boundaries below are part of the package's contract rather than an accident of where a return statement sits.

The distinction that matters most is unverifiable versus signature-invalid. Unverifiable means the token could not be checked at all: no key was available, or the algorithm is one this package does not implement. Invalid means the check ran and the signature did not match the key. Both deny the request, but only the first indicates a key-management problem on this side, which is why internal/middleware/auth.go maps an unknown kid onto ErrTokenSignatureInvalid: an attacker must not be able to tell a misconfigured verifier apart from a forged token by the response it gets.

Functions

func EncodeSegment

func EncodeSegment(data []byte) string

EncodeSegment base64url-encodes a byte slice (exported for test helpers).

func SignRS256

func SignRS256(claims Claims, key *rsa.PrivateKey, kid string) (string, error)

SignRS256 creates a signed RS256 JWT string. Header: {"alg":"RS256","typ":"JWT","kid":kid}

func SignRS256Bytes

func SignRS256Bytes(signingString string, key *rsa.PrivateKey) ([]byte, error)

SignRS256Bytes signs a signing string with RS256 (PKCS1v15 + SHA-256). Returns the raw signature bytes.

PKCS#1 v1.5 is not a fallback for RSASSA-PSS here: RFC 7518 §3.3 defines the RS256 JWS algorithm as RSASSA-PKCS1-v1_5 with SHA-256, so a token labeled RS256 must use it or no standard verifier will accept it. PSS has its own JWS identifiers (PS256 and up), which this package does not implement.

func SignRS256WithHeader

func SignRS256WithHeader(header map[string]any, claims any, key *rsa.PrivateKey) (string, error)

SignRS256WithHeader creates a signed RS256 JWT with a caller-provided header map. Use for testing with custom/malicious headers (kid overrides, jku, x5u, x5c, jwk).

func SignTokenCustom

func SignTokenCustom(header map[string]any, claims any, signFunc func(signingString string) ([]byte, error)) (string, error)

SignTokenCustom creates a token with arbitrary header and signs it with the provided function. Use for attack tests that need non-RS256 tokens (ES256, HS256, PS256, none).

func UnsignedToken

func UnsignedToken(header map[string]any, claims any) (string, error)

UnsignedToken creates a token with no signature (for alg:none attack tests).

func VerifyES256

func VerifyES256(signingString string, sig []byte, key *ecdsa.PublicKey) error

VerifyES256 verifies an ES256 signature. Returns nil on success.

RFC 7515 §3.4 mandates the raw R‖S form for JWS, and that is what vault42 emits. This function additionally accepts ASN.1 DER because the ES256 tokens it must verify include DPoP proofs and third-party OIDC ID tokens produced by libraries and HSMs that hand back the DER form their signing API returns. Rejecting those would fail interoperability, not close an attack.

The discriminator is length alone: [isRawRS] treats a signature of exactly twice the curve's coordinate size as raw. A DER signature that happens to be 64 bytes is therefore misclassified, reinterpreted as R‖S, and fails verification. That direction is safe, because the only path out of this function is ecdsa.VerifyASN1 over a signature the caller must have produced with the private key: a misclassified signature is rejected, never accepted.

The curve is pinned to P-256 because RFC 7518 §3.4 assigns exactly that curve to the ES256 identifier. Without the pin the expected raw signature length is derived from whatever curve the caller's key sits on, so a P-384 key verifies a 96-byte signature under a header that still says ES256. The ES256 path is reached from DPoP, where the proof carries its own key in the jwk header and the binding to the access token is the RFC 7638 thumbprint of that key; the thumbprint covers the curve name, so accepting a curve the algorithm did not name puts vault42 and every conforming relying party into disagreement about which proofs are valid.

func VerifyRS256

func VerifyRS256(signingString string, sig []byte, key *rsa.PublicKey) error

VerifyRS256 verifies an RS256 signature. Returns nil on success.

Types

type ClaimStrings

type ClaimStrings []string

ClaimStrings is []string that unmarshals from either "string" or ["array"].

func (ClaimStrings) MarshalJSON

func (s ClaimStrings) MarshalJSON() ([]byte, error)

MarshalJSON always marshals as a JSON array.

func (*ClaimStrings) UnmarshalJSON

func (s *ClaimStrings) UnmarshalJSON(data []byte) error

UnmarshalJSON handles both a single string and an array of strings.

type Claims

type Claims interface {
	// GetExpirationTime returns the exp claim, or nil when the token carries
	// none. Returning nil is not the same as returning a zero time: nil means
	// "no expiry claimed", which validateClaims rejects only when the caller
	// required exp, whereas a zero time would read as long expired.
	GetExpirationTime() *NumericDate
	// GetIssuedAt returns the iat claim, or nil when it is absent.
	GetIssuedAt() *NumericDate
	// GetNotBefore returns the nbf claim, or nil when the token has no
	// not-before bound.
	GetNotBefore() *NumericDate
	// GetIssuer returns the iss claim, or "" when it is absent. An
	// implementation must return "" rather than a placeholder, since "" can
	// never equal a configured issuer and therefore fails the check closed.
	GetIssuer() string
	// GetSubject returns the sub claim, or "" when it is absent. This package
	// never validates it.
	GetSubject() string
	// GetAudience returns the aud claim as a list, or nil when it is absent.
	// Audience checks test membership of this list, so an implementation must
	// not collapse a multi-valued aud to its first element.
	GetAudience() ClaimStrings
}

Claims is satisfied by any type that can return the registered JWT claims. Unlike golang-jwt, getters return values directly (no error return) since we only use typed struct claims where errors are impossible.

type Keyfunc

type Keyfunc func(token *Token) (any, error)

Keyfunc receives the unverified token and returns the key to verify it with.

It runs before any signature check, so token.Header is attacker-controlled at this point. A Keyfunc must select the key from data it trusts, typically by looking the kid up in a local key set, and must never fetch or construct a key from a URL or an embedded JWK found in the header. Returning an error makes the parse fail with ErrTokenUnverifiable.

type MapClaims

type MapClaims map[string]any

MapClaims is a generic claims type for test code that needs arbitrary claim maps. Production code should use typed claims structs instead.

func (MapClaims) GetAudience

func (m MapClaims) GetAudience() ClaimStrings

GetAudience returns the aud claim as a list, accepting the single-string and array forms RFC 7519 allows. Non-string members are dropped, and any other shape yields nil, which fails an audience check rather than passing it.

func (MapClaims) GetExpirationTime

func (m MapClaims) GetExpirationTime() *NumericDate

GetExpirationTime returns the exp claim. It returns nil when the claim is absent or is not a number, so a token whose exp arrived as a string is treated as carrying no expiry rather than as expired. A numeric zero is a real timestamp, the epoch, and reads as long expired.

func (MapClaims) GetIssuedAt

func (m MapClaims) GetIssuedAt() *NumericDate

GetIssuedAt returns the iat claim, with the same absent-or-untyped-is-nil behavior as GetExpirationTime.

func (MapClaims) GetIssuer

func (m MapClaims) GetIssuer() string

GetIssuer returns the iss claim, or "" when it is absent or not a string. An issuer check against "" never matches a configured issuer, so a wrong-typed claim is rejected rather than skipped.

func (MapClaims) GetNotBefore

func (m MapClaims) GetNotBefore() *NumericDate

GetNotBefore returns the nbf claim, with the same absent-or-untyped-is-nil behavior as GetExpirationTime.

func (MapClaims) GetSubject

func (m MapClaims) GetSubject() string

GetSubject returns the sub claim, or "" when it is absent or not a string.

type NumericDate

type NumericDate struct {
	time.Time
}

NumericDate wraps time.Time with UNIX-epoch JSON serialization.

func NewNumericDate

func NewNumericDate(t time.Time) *NumericDate

NewNumericDate creates a NumericDate from a time.Time, truncated to seconds.

func (NumericDate) MarshalJSON

func (d NumericDate) MarshalJSON() ([]byte, error)

MarshalJSON outputs the UNIX epoch as an integer (no fractional seconds).

func (*NumericDate) UnmarshalJSON

func (d *NumericDate) UnmarshalJSON(b []byte) error

UnmarshalJSON parses a UNIX epoch number (integer or float) back to time.Time.

type ParseOption

type ParseOption func(*validationConfig)

ParseOption configures parse-time behavior.

func WithAudience

func WithAudience(aud string) ParseOption

WithAudience requires aud to contain the expected audience.

func WithExpirationRequired

func WithExpirationRequired() ParseOption

WithExpirationRequired requires the exp claim to be present.

func WithIssuedAt

func WithIssuedAt() ParseOption

WithIssuedAt enables iat validation (iat <= now).

func WithIssuer

func WithIssuer(iss string) ParseOption

WithIssuer requires iss to match the expected issuer.

func WithValidMethods

func WithValidMethods(methods []string) ParseOption

WithValidMethods narrows the set of accepted "alg" header values to methods.

It only ever narrows. The hard allowlist is the signature switch in ParseWithClaims, which implements RS256 and ES256 and rejects everything else through its default branch; naming an algorithm here that the switch does not implement does not make it verifiable. Omitting the option leaves that switch as the sole gate rather than disabling algorithm checking.

func WithoutClaimsValidation

func WithoutClaimsValidation() ParseOption

WithoutClaimsValidation skips all claims validation (useful for DPoP).

type RegisteredClaims

type RegisteredClaims struct {
	// Issuer is the iss claim. Checked only when the caller passes WithIssuer.
	Issuer string `json:"iss,omitempty"`
	// Subject is the sub claim, the identity the token speaks for. This package
	// never validates it; that is the consuming service's decision.
	Subject string `json:"sub,omitempty"`
	// Audience is the aud claim. Serialized as an array, accepted as either a
	// string or an array. Checked only when the caller passes WithAudience, and
	// then by membership, not equality.
	Audience ClaimStrings `json:"aud,omitempty"`
	// ExpiresAt is the exp claim. Nil means the token carries no expiry, which
	// is rejected only when the caller passes WithExpirationRequired.
	ExpiresAt *NumericDate `json:"exp,omitempty"`
	// NotBefore is the nbf claim. Nil means the token is valid immediately.
	NotBefore *NumericDate `json:"nbf,omitempty"`
	// IssuedAt is the iat claim. Checked only when the caller passes
	// WithIssuedAt.
	IssuedAt *NumericDate `json:"iat,omitempty"`
	// ID is the jti claim, the unique token identifier used for replay
	// tracking. This package carries it; revocation lookups happen elsewhere.
	ID string `json:"jti,omitempty"`
}

RegisteredClaims implements Claims with standard RFC 7519 fields. Every field is omitempty, so an absent claim and a zero value are indistinguishable on the wire; validation therefore treats a nil timestamp as "claim absent" rather than as the epoch.

func (RegisteredClaims) GetAudience

func (c RegisteredClaims) GetAudience() ClaimStrings

GetAudience returns the aud claim as a list, or nil when it is absent.

func (RegisteredClaims) GetExpirationTime

func (c RegisteredClaims) GetExpirationTime() *NumericDate

GetExpirationTime returns the exp claim, or nil when the token carries no expiry.

func (RegisteredClaims) GetIssuedAt

func (c RegisteredClaims) GetIssuedAt() *NumericDate

GetIssuedAt returns the iat claim, or nil when it is absent.

func (RegisteredClaims) GetIssuer

func (c RegisteredClaims) GetIssuer() string

GetIssuer returns the iss claim, or "" when it is absent.

func (RegisteredClaims) GetNotBefore

func (c RegisteredClaims) GetNotBefore() *NumericDate

GetNotBefore returns the nbf claim, or nil when the token has no not-before bound.

func (RegisteredClaims) GetSubject

func (c RegisteredClaims) GetSubject() string

GetSubject returns the sub claim, or "" when it is absent.

type Token

type Token struct {
	// Header is the decoded JOSE header. It is attacker-controlled until the
	// signature verifies, so a Keyfunc reading it must treat every value as
	// untrusted input; that is where the jku/x5u/x5c/jwk rejections belong.
	Header map[string]any
	// Claims is the decoded payload, unmarshaled into the caller's type. It is
	// populated before verification, so it is attacker-controlled whenever
	// Valid is false.
	Claims Claims
	// Signature is the raw decoded signature bytes.
	Signature []byte
	// Raw is the original compact serialization exactly as received.
	Raw string
	// Valid reports that the signature verified against the key the Keyfunc
	// returned and that claims validation passed. It is set at the very end of
	// [ParseWithClaims] and is false on every error path, including the ones
	// that still return a non-nil Token.
	Valid bool
}

Token represents a parsed or constructed JWT.

A Token obtained from parsing is only trustworthy when the parse returned a nil error. ParseWithClaims returns a populated Token alongside most of its errors so callers can log the offending kid or alg, and ParseUnverified returns one that was never checked at all. Read Valid, or better, read the error.

func ParseUnverified

func ParseUnverified(tokenString string, claims Claims) (*Token, error)

ParseUnverified parses a JWT without signature verification or claims validation. Only use for header inspection (e.g., DPoP jwk extraction).

func ParseWithClaims

func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc, opts ...ParseOption) (*Token, error)

ParseWithClaims parses and fully validates a JWT string, in this order: segment/header decoding, the caller's optional algorithm allowlist, the signature switch that is the real algorithm gate, and finally claims validation.

The fail-closed guarantee lives in the signature switch below, not in the WithValidMethods allowlist: the switch implements RS256 and ES256 and its default branch rejects every other alg as unverifiable, so "none" and the symmetric algorithms behind CVE-2015-9235 are refused even when no allowlist is configured. WithValidMethods narrows that set earlier and with a clearer error; it can never widen it.

On any error the returned *Token may be non-nil but its Valid field is false and its claims are unverified, attacker-controlled data. Callers must branch on err, never on the token being non-nil.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL