mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
feat(auth/jwt): add HMACSigner/HMACVerifier
This commit is contained in:
parent
ab3013609a
commit
392b8bc0f9
@ -133,8 +133,6 @@
|
|||||||
//
|
//
|
||||||
// 3. Algorithms: The fewer the merrier.
|
// 3. Algorithms: The fewer the merrier.
|
||||||
//
|
//
|
||||||
// Only asymmetric (public-key) algorithms are implemented.
|
|
||||||
//
|
|
||||||
// You should use Ed25519. It's the end-game algorithm - all upside, no known
|
// You should use Ed25519. It's the end-game algorithm - all upside, no known
|
||||||
// downsides, and it's supported ubiquitously - Go, JavaScript, Web Browsers, Node, Rust,
|
// downsides, and it's supported ubiquitously - Go, JavaScript, Web Browsers, Node, Rust,
|
||||||
// etc.
|
// etc.
|
||||||
@ -143,16 +141,25 @@
|
|||||||
// ECDSA is provided for backwards compatibility with existing systems.
|
// ECDSA is provided for backwards compatibility with existing systems.
|
||||||
// RSA is provided only for backwards compatibility - it's larger, slower, with no real benefit.
|
// RSA is provided only for backwards compatibility - it's larger, slower, with no real benefit.
|
||||||
//
|
//
|
||||||
|
// Supported algorithms are derived automatically from the key type - you never configure alg
|
||||||
|
// directly.
|
||||||
|
//
|
||||||
|
// The verification process selects a key by matching the "kid" (KeyID) of token
|
||||||
|
// and the key and then checking "alg" before any cryptographic operation is attempted.
|
||||||
|
// An alg/key-type mismatch is a hard error.
|
||||||
|
//
|
||||||
// - EC P-256 => ES256 (ECDSA + SHA-256, RFC 7518 §3.4)
|
// - EC P-256 => ES256 (ECDSA + SHA-256, RFC 7518 §3.4)
|
||||||
// - EC P-384 => ES384 (ECDSA + SHA-384)
|
// - EC P-384 => ES384 (ECDSA + SHA-384)
|
||||||
// - EC P-521 => ES512 (ECDSA + SHA-512)
|
// - EC P-521 => ES512 (ECDSA + SHA-512)
|
||||||
// - RSA => RS256 (PKCS#1 v1.5 + SHA-256, RFC 7518 §3.3)
|
// - RSA => RS256 (PKCS#1 v1.5 + SHA-256, RFC 7518 §3.3)
|
||||||
// - Ed25519 => EdDSA (RFC 8037)
|
// - Ed25519 => EdDSA (RFC 8037)
|
||||||
//
|
//
|
||||||
// Supported algorithms are derived automatically from the key type - you never
|
// HMAC is also provided for backwards compatibility - it's the least secure,
|
||||||
// configure alg directly.
|
// most difficult to manage, and should not be used in new software. This implementation is not
|
||||||
|
// vulnerable to common misuse as it must be used explicitly and does not allow extremely short
|
||||||
|
// secrets which are typical of example code.
|
||||||
//
|
//
|
||||||
// The verification process selects a key by matching the "kid" (KeyID) of token
|
// - HS256 => HMAC + SHA-256, RFC 2104
|
||||||
// and the key and then checking "alg" before any cryptographic operation is attempted.
|
// - HS384 => HMAC + SHA-384
|
||||||
// An alg/key-type mismatch is a hard error.
|
// - HS512 => HMAC + SHA-512
|
||||||
package jwt
|
package jwt
|
||||||
|
|||||||
509
auth/jwt/hmac.go
Normal file
509
auth/jwt/hmac.go
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SharedSecret is the symmetric-key counterpart to [PrivateKey].
|
||||||
|
//
|
||||||
|
// It holds a shared secret byte slice with JWKS metadata (KID, Use).
|
||||||
|
// [SharedSecret.Thumbprint] computes the RFC 7638 JWK Thumbprint
|
||||||
|
// (SHA-256 of the canonical key JSON). For HMAC keys the canonical form
|
||||||
|
// is {"k":"...","kty":"oct"} — note that KID is NOT included in the
|
||||||
|
// thumbprint per RFC 7638 §3.2.
|
||||||
|
//
|
||||||
|
// [SharedSecret.MarshalJSON] / [UnmarshalJSON] handle JWK wire format:
|
||||||
|
// {"k":"...","kty":"oct","kid":"...","use":"sig"}.
|
||||||
|
//
|
||||||
|
// Use [NewHMACSecret] to construct with auto-computed KID, [NewSharedSecret]
|
||||||
|
// for manual KID, [ParseSharedSecretJWK] to parse from JWK JSON, and
|
||||||
|
// [keyfile.LoadSharedSecretJWK] to load from file.
|
||||||
|
type SharedSecret struct {
|
||||||
|
KID string
|
||||||
|
Use string
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHMACSecret generates a secure SharedSecret with sane defaults.
|
||||||
|
//
|
||||||
|
// The key is cryptographically random, 32 bytes (256-bit), hex-encoded,
|
||||||
|
// with JWK Thumbprint to optionally use as a Key ID.
|
||||||
|
func NewHMACSecret() (*SharedSecret, string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("NewHMACSecret: read random: %w", err)
|
||||||
|
}
|
||||||
|
secret := []byte(hex.EncodeToString(b))
|
||||||
|
ss := &SharedSecret{secret: secret}
|
||||||
|
thumb, err := ss.Thumbprint()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("NewHMACSecret: thumbprint: %w", err)
|
||||||
|
}
|
||||||
|
return ss, thumb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSharedSecret creates a SharedSecret from raw bytes with optional
|
||||||
|
// public-facing Key ID.
|
||||||
|
//
|
||||||
|
// Requires at least 16 bytes. For randomly generated secrets, use
|
||||||
|
// [NewHMACSecret].
|
||||||
|
func NewSharedSecret(secret []byte, kid string) (*SharedSecret, error) {
|
||||||
|
if len(secret) == 0 {
|
||||||
|
return nil, fmt.Errorf("NewSharedSecret: %w", ErrNoSigningKey)
|
||||||
|
}
|
||||||
|
if len(secret) < 16 {
|
||||||
|
return nil, fmt.Errorf("NewSharedSecret: %w", ErrKeyTooSmall)
|
||||||
|
}
|
||||||
|
return &SharedSecret{secret: secret, KID: kid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyType returns the JWK "kty" string: "oct".
|
||||||
|
func (s SharedSecret) KeyType() string { return "oct" }
|
||||||
|
|
||||||
|
// Secret returns the raw secret bytes.
|
||||||
|
func (s SharedSecret) Secret() []byte { return s.secret }
|
||||||
|
|
||||||
|
// Thumbprint computes the RFC 7638 JWK Thumbprint for this key.
|
||||||
|
//
|
||||||
|
// The canonical JSON for "oct" keys is {"k":"...","kty":"oct"} —
|
||||||
|
// KID is excluded from the thumbprint per RFC 7638 §3.2.
|
||||||
|
// The result is base64url-encoded SHA-256.
|
||||||
|
//
|
||||||
|
// https://www.rfc-editor.org/rfc/rfc7638.html
|
||||||
|
func (s SharedSecret) Thumbprint() (string, error) {
|
||||||
|
k := base64.RawURLEncoding.EncodeToString(s.secret)
|
||||||
|
canonical, err := json.Marshal(struct {
|
||||||
|
K string `json:"k"`
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
}{K: k, Kty: "oct"})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("thumbprint: marshal canonical JSON: %w", err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(canonical)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(sum[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements [json.Marshaler], encoding the secret as a JWK
|
||||||
|
// JSON object with the "k" field and "kty":"oct".
|
||||||
|
func (s SharedSecret) MarshalJSON() ([]byte, error) {
|
||||||
|
k := base64.RawURLEncoding.EncodeToString(s.secret)
|
||||||
|
return json.Marshal(sharedSecretJSON{
|
||||||
|
K: k,
|
||||||
|
KID: s.KID,
|
||||||
|
Kty: "oct",
|
||||||
|
Use: s.Use,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements [json.Unmarshaler], parsing a JWK JSON object
|
||||||
|
// with "kty":"oct". The "k" field (base64url-encoded secret) is required
|
||||||
|
// and must be non-empty. If KID is empty, it is left unset.
|
||||||
|
func (s *SharedSecret) UnmarshalJSON(data []byte) error {
|
||||||
|
var raw sharedSecretJSON
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return fmt.Errorf("parse JWK: %w", err)
|
||||||
|
}
|
||||||
|
if raw.Kty != "oct" {
|
||||||
|
return fmt.Errorf("SharedSecret: kty %q: %w", raw.Kty, ErrUnsupportedKeyType)
|
||||||
|
}
|
||||||
|
if raw.K == "" {
|
||||||
|
return fmt.Errorf("parse JWK: missing k field: %w", ErrMissingKeyData)
|
||||||
|
}
|
||||||
|
secret, err := base64.RawURLEncoding.DecodeString(raw.K)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse JWK: decode k: %w", err)
|
||||||
|
}
|
||||||
|
s.secret = secret
|
||||||
|
s.KID = raw.KID
|
||||||
|
s.Use = raw.Use
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSharedSecretJWK parses a single JWK JSON object with "kty":"oct"
|
||||||
|
// into a [SharedSecret]. If the JWK has no "kid" field, the KID is
|
||||||
|
// left unset.
|
||||||
|
func ParseSharedSecretJWK(data []byte) (*SharedSecret, error) {
|
||||||
|
var ss SharedSecret
|
||||||
|
if err := ss.UnmarshalJSON(data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type sharedSecretJSON struct {
|
||||||
|
K string `json:"k,omitempty"`
|
||||||
|
KID string `json:"kid,omitempty"`
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
Use string `json:"use,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMACSigner signs JWTs using HMAC with a shared secret.
|
||||||
|
//
|
||||||
|
// HMACSigner implements the symmetric-key signing counterpart to [Signer].
|
||||||
|
// It creates tokens with alg=HS256/HS384/HS512 and a shared secret key.
|
||||||
|
//
|
||||||
|
// Use [HMACSigner.Sign] or [HMACSigner.SignToString] to create tokens, then
|
||||||
|
// use [HMACSigner.Verifier] to verify them.
|
||||||
|
type HMACSigner struct {
|
||||||
|
alg crypto.Hash
|
||||||
|
secret *SharedSecret
|
||||||
|
retired []*SharedSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ jwtsigner[*HMACVerifier] = (*HMACSigner)(nil)
|
||||||
|
|
||||||
|
// NewHMACSigner creates a HMACSigner that signs JWTs with the given
|
||||||
|
// algorithm and shared secret.
|
||||||
|
//
|
||||||
|
// The recommended minimum secret length is 32 bytes for HS256, 48 bytes
|
||||||
|
// for HS384, and 64 bytes for HS512. Shorter secrets (minimum 16 bytes)
|
||||||
|
// are accepted but weaken security.
|
||||||
|
//
|
||||||
|
// Retired secrets are accepted for verifying legacy tokens signed with
|
||||||
|
// older keys. They are passed through to the verifier.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// secret := []byte(os.Getenv("JWT_SECRET"))
|
||||||
|
// key := jwt.NewSharedSecret([]byte(secret), "my-key")
|
||||||
|
// signer, _ := jwt.NewHMACSigner(crypto.SHA256, key)
|
||||||
|
func NewHMACSigner(alg crypto.Hash, secret *SharedSecret, retired ...*SharedSecret) (*HMACSigner, error) {
|
||||||
|
if secret == nil {
|
||||||
|
return nil, fmt.Errorf("NewHMACSigner: %w", ErrNoSigningKey)
|
||||||
|
}
|
||||||
|
// Validate algorithm by attempting to create an HMAC hasher.
|
||||||
|
if _, err := hmacSign(alg, secret.secret, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("NewHMACSigner: key %q: %w", secret.KID, err)
|
||||||
|
}
|
||||||
|
// Validate retired secrets (length/nil checks are in NewSharedSecret).
|
||||||
|
for i, rs := range retired {
|
||||||
|
if rs == nil {
|
||||||
|
return nil, fmt.Errorf("NewHMACSigner: retired[%d]: %w", i, ErrNoSigningKey)
|
||||||
|
}
|
||||||
|
if _, err := hmacSign(alg, rs.secret, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("NewHMACSigner: retired[%d] key %q: %w", i, rs.KID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &HMACSigner{
|
||||||
|
alg: alg,
|
||||||
|
secret: secret,
|
||||||
|
retired: retired,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign creates and signs a JWT from claims, returning the signed [*JWT].
|
||||||
|
//
|
||||||
|
// Use this when you need access to the signed JWT object (e.g., to inspect
|
||||||
|
// headers or read the raw signature). For the common case of producing a
|
||||||
|
// compact token string, use [HMACSigner.SignToString].
|
||||||
|
func (s *HMACSigner) Sign(claims Claims) (*JWT, error) {
|
||||||
|
jws, err := New(claims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.SignJWT(jws); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return jws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignToString creates and signs a JWT from claims and returns the compact
|
||||||
|
// token string (header.payload.signature).
|
||||||
|
//
|
||||||
|
// This is the most convenient form for the common case of signing and
|
||||||
|
// immediately transmitting a token.
|
||||||
|
func (s *HMACSigner) SignToString(claims Claims) (string, error) {
|
||||||
|
jws, err := s.Sign(claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return Encode(jws)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignJWT signs a JWT in-place.
|
||||||
|
//
|
||||||
|
// Sets the alg header to the signer's algorithm. If the signing key has a
|
||||||
|
// KID set, it is written into the JWT header.
|
||||||
|
//
|
||||||
|
// Computes the HMAC signature over the protected.payload input.
|
||||||
|
func (s *HMACSigner) SignJWT(jws SignableJWT) error {
|
||||||
|
if len(s.secret.secret) == 0 {
|
||||||
|
return ErrNoSigningKey
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr := jws.GetHeader()
|
||||||
|
|
||||||
|
// Validate and set header algorithm.
|
||||||
|
if hdr.Alg != "" && hdr.Alg != s.algorithm() {
|
||||||
|
return ErrAlgConflict
|
||||||
|
}
|
||||||
|
hdr.Alg = s.algorithm()
|
||||||
|
|
||||||
|
// Set KID from the signing key if present.
|
||||||
|
if s.secret.KID != "" {
|
||||||
|
hdr.KID = s.secret.KID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := jws.SetHeader(&hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
input := signingInputBytes(jws.GetProtected(), jws.GetPayload())
|
||||||
|
|
||||||
|
sig, err := hmacSign(s.alg, s.secret.secret, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
jws.SetSignature(sig)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignRaw signs an arbitrary protected header and payload, returning
|
||||||
|
// the result as a [*RawJWT] suitable for json.Marshal (flattened JWS)
|
||||||
|
// or Encode (compact serialization).
|
||||||
|
//
|
||||||
|
// Unlike [SignJWT], SignRaw does not set or validate the KID field —
|
||||||
|
// the caller controls it entirely. This supports protocols like ACME
|
||||||
|
// (RFC 8555) where kid is an account URL, or where kid must be absent
|
||||||
|
// (newAccount uses jwk instead).
|
||||||
|
//
|
||||||
|
// The alg field is always set from the signer's algorithm. If hdr
|
||||||
|
// already has a non-empty Alg that conflicts with the signer's
|
||||||
|
// algorithm, SignRaw returns an error.
|
||||||
|
//
|
||||||
|
// payload is the raw bytes to encode as the JWS payload. A nil payload
|
||||||
|
// produces an empty payload segment (used by ACME POST-as-GET).
|
||||||
|
func (s *HMACSigner) SignRaw(hdr Header, payload []byte) (*RawJWT, error) {
|
||||||
|
if len(s.secret.secret) == 0 {
|
||||||
|
return nil, ErrNoSigningKey
|
||||||
|
}
|
||||||
|
|
||||||
|
rfc := hdr.GetRFCHeader()
|
||||||
|
|
||||||
|
// Validate and set header algorithm.
|
||||||
|
if rfc.Alg != "" && rfc.Alg != s.algorithm() {
|
||||||
|
return nil, ErrAlgConflict
|
||||||
|
}
|
||||||
|
rfc.Alg = s.algorithm()
|
||||||
|
|
||||||
|
headerJSON, err := json.Marshal(hdr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
protectedB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||||
|
var payloadB64 string
|
||||||
|
if payload != nil {
|
||||||
|
payloadB64 = base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
input := signingInputBytes([]byte(protectedB64), []byte(payloadB64))
|
||||||
|
|
||||||
|
sig, err := hmacSign(s.alg, s.secret.secret, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RawJWT{
|
||||||
|
Protected: []byte(protectedB64),
|
||||||
|
Payload: []byte(payloadB64),
|
||||||
|
Signature: sig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// algorithm returns the JWS alg string for this algorithm (e.g. "HS256").
|
||||||
|
func (s *HMACSigner) algorithm() string {
|
||||||
|
return hashToHMACAlg(s.alg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hmacSign computes HMAC of data with the given key and algorithm.
|
||||||
|
func hmacSign(alg crypto.Hash, key, data []byte) ([]byte, error) {
|
||||||
|
h := crypto.Hash(alg)
|
||||||
|
if !h.Available() {
|
||||||
|
return nil, fmt.Errorf("algorithm %s: %w", hashToHMACAlg(h), ErrUnsupportedAlg)
|
||||||
|
}
|
||||||
|
mac := hmac.New(h.New, key)
|
||||||
|
mac.Write(data)
|
||||||
|
return mac.Sum(nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashToHMACAlg maps a crypto.Hash to its JWS HMAC algorithm string.
|
||||||
|
func hashToHMACAlg(h crypto.Hash) string {
|
||||||
|
switch h {
|
||||||
|
case crypto.SHA256:
|
||||||
|
return "HS256"
|
||||||
|
case crypto.SHA384:
|
||||||
|
return "HS384"
|
||||||
|
case crypto.SHA512:
|
||||||
|
return "HS512"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("unknown(%d)", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier returns a new [*HMACVerifier] for verifying tokens signed by this signer.
|
||||||
|
//
|
||||||
|
// The verifier uses the same algorithm and secret as the signer.
|
||||||
|
func (s *HMACSigner) Verifier() *HMACVerifier {
|
||||||
|
return &HMACVerifier{
|
||||||
|
alg: s.alg,
|
||||||
|
secret: s.secret,
|
||||||
|
retired: s.retired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ jwtverifier = (*HMACVerifier)(nil)
|
||||||
|
|
||||||
|
// HMACVerifier verifies JWT signatures using HMAC with a shared secret.
|
||||||
|
//
|
||||||
|
// HMACVerifier is the symmetric-key counterpart to [Signer]. It holds
|
||||||
|
// a secret and an algorithm, matching what [HMACSigner] uses. Retired
|
||||||
|
// secrets are also included for verifying legacy tokens.
|
||||||
|
//
|
||||||
|
// HMACVerifier is immutable after construction - safe for concurrent use.
|
||||||
|
type HMACVerifier struct {
|
||||||
|
alg crypto.Hash
|
||||||
|
secret *SharedSecret
|
||||||
|
retired []*SharedSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHMACVerifier creates a HMACVerifier that verifies JWTs signed with
|
||||||
|
// the given algorithm and shared secret.
|
||||||
|
//
|
||||||
|
// The secret must match what was used to sign the tokens. A wrong secret
|
||||||
|
// will cause [HMACVerifier.Verify] to return [ErrSignatureInvalid].
|
||||||
|
//
|
||||||
|
// Retired secrets are accepted for verifying legacy tokens signed with
|
||||||
|
// older keys. They are tried after the active secret.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// secret := []byte(os.Getenv("JWT_SECRET"))
|
||||||
|
// key := jwt.NewSharedSecret([]byte(secret), "my-key")
|
||||||
|
// verifier, _ := jwt.NewHMACVerifier(crypto.SHA256, key)
|
||||||
|
func NewHMACVerifier(alg crypto.Hash, secret *SharedSecret, retired ...*SharedSecret) (*HMACVerifier, error) {
|
||||||
|
if secret == nil {
|
||||||
|
return nil, fmt.Errorf("NewHMACVerifier: %w", ErrNoVerificationKey)
|
||||||
|
}
|
||||||
|
// Validate algorithm by attempting to create an HMAC hasher.
|
||||||
|
if _, err := hmacSign(alg, secret.secret, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("NewHMACVerifier: key %q: %w", secret.KID, err)
|
||||||
|
}
|
||||||
|
// Validate retired secrets (length/nil checks are in NewSharedSecret).
|
||||||
|
for i, rs := range retired {
|
||||||
|
if rs == nil {
|
||||||
|
return nil, fmt.Errorf("NewHMACVerifier: retired[%d]: %w", i, ErrNoVerificationKey)
|
||||||
|
}
|
||||||
|
if _, err := hmacSign(alg, rs.secret, nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("NewHMACVerifier: retired[%d] key %q: %w", i, rs.KID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &HMACVerifier{
|
||||||
|
alg: alg,
|
||||||
|
secret: secret,
|
||||||
|
retired: retired,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the signature of an already-decoded [*JWT].
|
||||||
|
//
|
||||||
|
// Key selection by KID:
|
||||||
|
// - Token has a KID: only secrets matching that KID are tried.
|
||||||
|
// - Token has no KID, or a secret has no KID: considered a match.
|
||||||
|
// - Returns [ErrUnknownKID] if no secret matches the token's KID.
|
||||||
|
//
|
||||||
|
// The algorithm is fixed at construction time. Within matching secrets,
|
||||||
|
// the active secret is tried first, then retired secrets. The first
|
||||||
|
// successful verification wins.
|
||||||
|
//
|
||||||
|
// Returns nil on success, a descriptive error on failure. Claim values
|
||||||
|
// (iss, aud, exp, etc.) are NOT checked - call [Validator.Validate]
|
||||||
|
// on the unmarshalled claims after verifying.
|
||||||
|
//
|
||||||
|
// Use [VerifyJWT] to decode and verify in one step.
|
||||||
|
func (v *HMACVerifier) Verify(jws VerifiableJWT) error {
|
||||||
|
h := jws.GetHeader()
|
||||||
|
|
||||||
|
// Validate algorithm matches.
|
||||||
|
if h.Alg != v.algorithm() {
|
||||||
|
return fmt.Errorf("key %s vs header %q: %w", v.algorithm(), h.Alg, ErrAlgConflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
signingInput := signingInputBytes(jws.GetProtected(), jws.GetPayload())
|
||||||
|
sig := jws.GetSignature()
|
||||||
|
|
||||||
|
// Try each secret, matching by KID (empty KID on either side is a match).
|
||||||
|
var tried bool
|
||||||
|
for _, ss := range v.allSecrets() {
|
||||||
|
if h.KID != "" {
|
||||||
|
if ss.KID != "" && h.KID != ss.KID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tried = true
|
||||||
|
expectedSig, err := hmacSign(v.alg, ss.secret, signingInput)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hmac.Equal(expectedSig, sig) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tried {
|
||||||
|
return fmt.Errorf("kid %q: %w", h.KID, ErrUnknownKID)
|
||||||
|
}
|
||||||
|
return ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// allSecrets returns the active secret followed by retired secrets.
|
||||||
|
func (v *HMACVerifier) allSecrets() []*SharedSecret {
|
||||||
|
result := make([]*SharedSecret, 0, 1+len(v.retired))
|
||||||
|
result = append(result, v.secret)
|
||||||
|
result = append(result, v.retired...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyJWT decodes tokenStr and verifies its signature, returning the parsed
|
||||||
|
// [*JWT] on success.
|
||||||
|
//
|
||||||
|
// Returns (nil, err) on any failure - the caller never receives an
|
||||||
|
// unauthenticated JWT. Claim values (iss, aud, exp, etc.) are NOT checked;
|
||||||
|
// call [Validator.Validate] on the unmarshalled claims after VerifyJWT:
|
||||||
|
//
|
||||||
|
// jws, err := v.VerifyJWT(tokenStr)
|
||||||
|
// if err != nil { /* bad sig, malformed token */ }
|
||||||
|
// var claims jwt.TokenClaims
|
||||||
|
// if err := jws.UnmarshalClaims(&claims); err != nil { /* ... */ }
|
||||||
|
// if err := v.Validate(nil, &claims, time.Now()); err != nil { /* ... */ }
|
||||||
|
func (v *HMACVerifier) VerifyJWT(tokenStr string) (*JWT, error) {
|
||||||
|
jws, err := Decode(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := v.Verify(jws); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return jws, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// algorithm returns the JWS alg string for this verifier (e.g. "HS256").
|
||||||
|
func (v *HMACVerifier) algorithm() string {
|
||||||
|
return hashToHMACAlg(v.alg)
|
||||||
|
}
|
||||||
794
auth/jwt/hmac_test.go
Normal file
794
auth/jwt/hmac_test.go
Normal file
@ -0,0 +1,794 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeHMACSigner(tb testing.TB, hash crypto.Hash, secret []byte) *HMACSigner {
|
||||||
|
tb.Helper()
|
||||||
|
key, err := NewSharedSecret(secret, "")
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
signer, err := NewHMACSigner(hash, key)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("NewHMACSigner: %v", err)
|
||||||
|
}
|
||||||
|
return signer
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeHMACVerifier(tb testing.TB, hash crypto.Hash, secret []byte) *HMACVerifier {
|
||||||
|
tb.Helper()
|
||||||
|
key, err := NewSharedSecret(secret, "")
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
verifier, err := NewHMACVerifier(hash, key)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("NewHMACVerifier: %v", err)
|
||||||
|
}
|
||||||
|
return verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_SignToString(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-that-is-long-enough-for-hs256")
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test-issuer",
|
||||||
|
Sub: "user-42",
|
||||||
|
Aud: Listish{"api"},
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
IAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify structure: three base64url segments
|
||||||
|
parts := 0
|
||||||
|
for _, c := range token {
|
||||||
|
if c == '.' {
|
||||||
|
parts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts != 2 {
|
||||||
|
t.Errorf("expected 2 dots in token, got %d", parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we can decode and verify
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
jws, err := verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded TokenClaims
|
||||||
|
if err := jws.UnmarshalClaims(&decoded); err != nil {
|
||||||
|
t.Fatalf("UnmarshalClaims: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Iss != "test-issuer" {
|
||||||
|
t.Errorf("iss = %q, want %q", decoded.Iss, "test-issuer")
|
||||||
|
}
|
||||||
|
if decoded.Sub != "user-42" {
|
||||||
|
t.Errorf("sub = %q, want %q", decoded.Sub, "user-42")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_Sign(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
jws, err := signer.Sign(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jws.GetHeader().Alg != "HS256" {
|
||||||
|
t.Errorf("alg = %q, want %q", jws.GetHeader().Alg, "HS256")
|
||||||
|
}
|
||||||
|
|
||||||
|
// KID is empty because the key has no KID set
|
||||||
|
if jws.GetHeader().KID != "" {
|
||||||
|
t.Errorf("KID = %q, want empty", jws.GetHeader().KID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(jws.GetSignature()) == 0 {
|
||||||
|
t.Error("signature is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jws.Encode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
_, err = verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_SignJWT(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
jws, err := New(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header should be empty initially
|
||||||
|
if jws.GetHeader().Alg != "" {
|
||||||
|
t.Errorf("initial alg = %q, want empty", jws.GetHeader().Alg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := signer.SignJWT(jws); err != nil {
|
||||||
|
t.Fatalf("SignJWT: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jws.GetHeader().Alg != "HS256" {
|
||||||
|
t.Errorf("alg = %q, want %q", jws.GetHeader().Alg, "HS256")
|
||||||
|
}
|
||||||
|
|
||||||
|
// KID is empty because the key has no KID set
|
||||||
|
if jws.GetHeader().KID != "" {
|
||||||
|
t.Errorf("KID = %q, want empty", jws.GetHeader().KID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(jws.GetSignature()) == 0 {
|
||||||
|
t.Error("signature is empty after signing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_KID(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
key := newSharedSecret(t, secret, "my-hmac-key")
|
||||||
|
signer, err := NewHMACSigner(crypto.SHA256, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHMACSigner: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
jws, err := signer.Sign(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if jws.GetHeader().KID != "my-hmac-key" {
|
||||||
|
t.Errorf("KID = %q, want %q", jws.GetHeader().KID, "my-hmac-key")
|
||||||
|
}
|
||||||
|
if jws.GetHeader().Alg != "HS256" {
|
||||||
|
t.Errorf("alg = %q, want %q", jws.GetHeader().Alg, "HS256")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify round-trip works
|
||||||
|
token, encErr := jws.Encode()
|
||||||
|
if encErr != nil {
|
||||||
|
t.Fatalf("Encode: %v", encErr)
|
||||||
|
}
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
_, err = verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_AlgConflict(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
jws, err := New(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a conflicting algorithm
|
||||||
|
hdr := jws.GetHeader()
|
||||||
|
hdr.Alg = "HS384"
|
||||||
|
if err := jws.SetHeader(&hdr); err != nil {
|
||||||
|
t.Fatalf("SetHeader: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = signer.SignJWT(jws)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for alg conflict, got nil")
|
||||||
|
}
|
||||||
|
if err != ErrAlgConflict {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrAlgConflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHMACSigner_NilSecret(t *testing.T) {
|
||||||
|
_, err := NewHMACSigner(crypto.SHA256, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil secret, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNoSigningKey) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrNoSigningKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHMACSigner_InvalidAlgorithm(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
key, err := NewSharedSecret(secret, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
_, err = NewHMACSigner(crypto.Hash(99), key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid algorithm, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_Verifier(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
|
||||||
|
if verifier.alg != crypto.SHA256 {
|
||||||
|
t.Errorf("verifier alg = %v, want %v", verifier.alg, crypto.SHA256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACSigner_SignRaw(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
|
||||||
|
// Test with a simple header
|
||||||
|
hdr := &RFCHeader{Alg: "", KID: "my-key", Typ: "JWT"}
|
||||||
|
payload := []byte(`{"sub":"user"}`)
|
||||||
|
|
||||||
|
raw, err := signer.SignRaw(hdr, payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignRaw: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(raw.Protected) == 0 {
|
||||||
|
t.Error("protected is empty")
|
||||||
|
}
|
||||||
|
if len(raw.Payload) == 0 {
|
||||||
|
t.Error("payload is empty")
|
||||||
|
}
|
||||||
|
if len(raw.Signature) == 0 {
|
||||||
|
t.Error("signature is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature by round-tripping through Encode/VerifyJWT
|
||||||
|
jws, err := New(&TokenClaims{Sub: "test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if err := signer.SignJWT(jws); err != nil {
|
||||||
|
t.Fatalf("SignJWT: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jws.Encode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encode: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
_, err = verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllAlgorithms(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-all-algorithms")
|
||||||
|
|
||||||
|
for _, hash := range []crypto.Hash{crypto.SHA256, crypto.SHA384, crypto.SHA512} {
|
||||||
|
t.Run(hash.String(), func(t *testing.T) {
|
||||||
|
signer := makeHMACSigner(t, hash, secret)
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jws, err := verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedAlg := signer.algorithm()
|
||||||
|
if jws.GetHeader().Alg != expectedAlg {
|
||||||
|
t.Errorf("alg = %q, want %q", jws.GetHeader().Alg, expectedAlg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACVerifier_AlgorithmMismatch(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
verifier := makeHMACVerifier(t, crypto.SHA384, secret)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = verifier.VerifyJWT(token)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for algorithm mismatch, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACVerifier_WrongSecret(t *testing.T) {
|
||||||
|
secret := []byte("correct-secret-long-enough-for-hs256")
|
||||||
|
wrongSecret := []byte("wrong-secret-long-enough-for-hs256")
|
||||||
|
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
verifier := makeHMACVerifier(t, crypto.SHA256, wrongSecret)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = verifier.VerifyJWT(token)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for wrong secret, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrSignatureInvalid) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrSignatureInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHMACVerifier_NilSecret(t *testing.T) {
|
||||||
|
_, err := NewHMACVerifier(crypto.SHA256, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil secret, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNoVerificationKey) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrNoVerificationKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACVerifier_RetiredKeys(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
retiredSecret := []byte("retired-secret-long-enough-for-hs256")
|
||||||
|
|
||||||
|
active, err := NewSharedSecret(secret, "active")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret active: %v", err)
|
||||||
|
}
|
||||||
|
retired, err := NewSharedSecret(retiredSecret, "retired")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret retired: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier, err := NewHMACVerifier(crypto.SHA256, active, retired)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHMACVerifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign with active key
|
||||||
|
signer := makeHMACSigner(t, crypto.SHA256, secret)
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with verifier that has retired key
|
||||||
|
_, err = verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT with retired: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign with retired key
|
||||||
|
retiredSigner := makeHMACSigner(t, crypto.SHA256, retiredSecret)
|
||||||
|
retiredToken, err := retiredSigner.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString retired: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify retired token with active+retired verifier
|
||||||
|
_, err = verifier.VerifyJWT(retiredToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT retired token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify retired token with active-only verifier should fail
|
||||||
|
activeOnly, err := NewHMACVerifier(crypto.SHA256, active)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHMACVerifier active-only: %v", err)
|
||||||
|
}
|
||||||
|
_, err = activeOnly.VerifyJWT(retiredToken)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for retired token with active-only verifier")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrSignatureInvalid) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrSignatureInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedSecret_Thumbprint(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough")
|
||||||
|
ss, err := NewSharedSecret(secret, "my-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb, err := ss.Thumbprint()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Thumbprint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if thumb == "" {
|
||||||
|
t.Error("thumbprint is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbprint should not include KID
|
||||||
|
ss2, err := NewSharedSecret([]byte("test-secret-long-enough"), "different-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
thumb2, err := ss2.Thumbprint()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Thumbprint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if thumb != thumb2 {
|
||||||
|
t.Error("thumbprints differ for same secret with different KID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedSecret_MarshalUnmarshal(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough")
|
||||||
|
ss, err := NewSharedSecret(secret, "my-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := ss.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ss2 SharedSecret
|
||||||
|
if err := ss2.UnmarshalJSON(data); err != nil {
|
||||||
|
t.Fatalf("UnmarshalJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(ss2.secret) != string(secret) {
|
||||||
|
t.Errorf("secret = %q, want %q", ss2.secret, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ss2.KID != "my-key" {
|
||||||
|
t.Errorf("KID = %q, want %q", ss2.KID, "my-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedSecret_MarshalUnmarshal_NoKID(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough")
|
||||||
|
ss := &SharedSecret{secret: secret} // no KID
|
||||||
|
|
||||||
|
data, err := ss.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ss2 SharedSecret
|
||||||
|
if err := ss2.UnmarshalJSON(data); err != nil {
|
||||||
|
t.Fatalf("UnmarshalJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ss2.KID != "" {
|
||||||
|
t.Errorf("KID = %q, want empty", ss2.KID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedSecret_UnmarshalJSON_EmptyK(t *testing.T) {
|
||||||
|
data := []byte(`{"kty":"oct","k":""}`)
|
||||||
|
var ss SharedSecret
|
||||||
|
err := ss.UnmarshalJSON(data)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty k, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrMissingKeyData) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrMissingKeyData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSharedSecretJWK(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough")
|
||||||
|
ss, err := NewSharedSecret(secret, "my-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := ss.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MarshalJSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ParseSharedSecretJWK(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseSharedSecretJWK: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(parsed.secret) != string(secret) {
|
||||||
|
t.Errorf("secret = %q, want %q", parsed.secret, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.KID != "my-key" {
|
||||||
|
t.Errorf("KID = %q, want %q", parsed.KID, "my-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedSecret_KeyType(t *testing.T) {
|
||||||
|
ss, err := NewSharedSecret([]byte("test-secret-long-enough"), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
if ss.KeyType() != "oct" {
|
||||||
|
t.Errorf("KeyType() = %q, want %q", ss.KeyType(), "oct")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSharedSecret is a test helper that panics on error.
|
||||||
|
func newSharedSecret(tb testing.TB, secret []byte, kid string) *SharedSecret {
|
||||||
|
tb.Helper()
|
||||||
|
ss, err := NewSharedSecret(secret, kid)
|
||||||
|
if err != nil {
|
||||||
|
tb.Fatalf("NewSharedSecret: %v", err)
|
||||||
|
}
|
||||||
|
return ss
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSharedSecret_ShortSecret(t *testing.T) {
|
||||||
|
_, err := NewSharedSecret([]byte("short"), "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for short secret, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrKeyTooSmall) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrKeyTooSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSharedSecret_EmptySecret(t *testing.T) {
|
||||||
|
_, err := NewSharedSecret([]byte{}, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty secret, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNoSigningKey) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrNoSigningKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHMACVerifier_NilRetired(t *testing.T) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
_, err := NewHMACVerifier(crypto.SHA256, newSharedSecret(t, secret, ""), nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nil retired secret, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrNoVerificationKey) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrNoVerificationKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACVerifier_KIDMatching(t *testing.T) {
|
||||||
|
secret1 := []byte("test-secret-long-enough-for-hs256-one")
|
||||||
|
secret2 := []byte("test-secret-long-enough-for-hs256-two")
|
||||||
|
|
||||||
|
key1, err := NewSharedSecret(secret1, "key-one")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret key1: %v", err)
|
||||||
|
}
|
||||||
|
key2, err := NewSharedSecret(secret2, "key-two")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret key2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier, err := NewHMACVerifier(crypto.SHA256, key2, key1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHMACVerifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign with key1
|
||||||
|
signer1 := makeHMACSigner(t, crypto.SHA256, secret1)
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer1.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with verifier that has key1 as retired — should succeed
|
||||||
|
_, err = verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT with matching retired key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign with key2
|
||||||
|
signer2 := makeHMACSigner(t, crypto.SHA256, secret2)
|
||||||
|
token2, err := signer2.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString key2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with verifier that has key2 as active — should succeed
|
||||||
|
_, err = verifier.VerifyJWT(token2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT with matching active key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify with wrong KID should fail with ErrUnknownKID
|
||||||
|
// Sign with a key that has KID set
|
||||||
|
signerWithKID, err := NewHMACSigner(crypto.SHA256, key1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHMACSigner with KID: %v", err)
|
||||||
|
}
|
||||||
|
tokenWithKID, err := signerWithKID.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString with KID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifierNoKey1, err := NewHMACVerifier(crypto.SHA256, key2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHMACVerifier no-key1: %v", err)
|
||||||
|
}
|
||||||
|
_, err = verifierNoKey1.VerifyJWT(tokenWithKID)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for KID not found, got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrUnknownKID) {
|
||||||
|
t.Errorf("error = %v, want %v", err, ErrUnknownKID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHMACVerifier_NoKID_TriesAll(t *testing.T) {
|
||||||
|
secret1 := []byte("test-secret-long-enough-for-hs256-one")
|
||||||
|
secret2 := []byte("test-secret-long-enough-for-hs256-two")
|
||||||
|
|
||||||
|
key1, err := NewSharedSecret(secret1, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret key1: %v", err)
|
||||||
|
}
|
||||||
|
key2, err := NewSharedSecret(secret2, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSharedSecret key2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier, err := NewHMACVerifier(crypto.SHA256, key2, key1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewHMACVerifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign with key1 (no KID)
|
||||||
|
signer1 := makeHMACSigner(t, crypto.SHA256, secret1)
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test",
|
||||||
|
Sub: "user",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer1.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToString: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token has no KID — verifier should try all secrets
|
||||||
|
_, err = verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyJWT no-KID token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkHMACSigner_SignToString(b *testing.B) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
signer := makeHMACSigner(b, crypto.SHA256, secret)
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test-issuer",
|
||||||
|
Sub: "user-42",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
b.ReportAllocs()
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkHMACVerifier_VerifyJWT(b *testing.B) {
|
||||||
|
secret := []byte("test-secret-long-enough-for-hs256")
|
||||||
|
signer := makeHMACSigner(b, crypto.SHA256, secret)
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
|
||||||
|
claims := &TokenClaims{
|
||||||
|
Iss: "test-issuer",
|
||||||
|
Sub: "user-42",
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
b.ReportAllocs()
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := verifier.VerifyJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
auth/jwt/keyfile/hmac.go
Normal file
74
auth/jwt/keyfile/hmac.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package keyfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadSharedSecretJWK loads a single HMAC shared secret JWK from a local file.
|
||||||
|
//
|
||||||
|
// The file must contain a JSON object with "kty":"oct" and "k" fields.
|
||||||
|
// If the JWK has no "kid" field, the KID is left unset.
|
||||||
|
func LoadSharedSecretJWK(path string) (*jwt.SharedSecret, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return jwt.ParseSharedSecretJWK(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSharedSecretString reads a file as a string, trims whitespace, and
|
||||||
|
// uses the result directly as the shared secret bytes. The KID is left unset.
|
||||||
|
//
|
||||||
|
// Use this when the secret is stored as raw bytes in a plain-text file
|
||||||
|
// (e.g. openssl rand output file with a trailing newline).
|
||||||
|
func LoadSharedSecretString(path string) (*jwt.SharedSecret, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(string(data))
|
||||||
|
return jwt.NewSharedSecret([]byte(raw), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSharedSecretBytes reads a file as raw bytes and uses them directly
|
||||||
|
// as the shared secret, without trimming. The KID is left unset.
|
||||||
|
//
|
||||||
|
// Use this when the file contents are binary or must be preserved exactly.
|
||||||
|
func LoadSharedSecretBytes(path string) (*jwt.SharedSecret, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return jwt.NewSharedSecret(data, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Save functions (key => file) ---
|
||||||
|
|
||||||
|
// SaveSharedSecretJWK writes a shared secret as a JWK JSON file.
|
||||||
|
// The file is created with mode 0600 (owner-only).
|
||||||
|
func SaveSharedSecretJWK(path string, ss *jwt.SharedSecret) error {
|
||||||
|
data, err := json.Marshal(ss)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
return os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSharedSecret writes the raw secret bytes to a file as a plain-text string.
|
||||||
|
// The file is created with mode 0600 (owner-only).
|
||||||
|
func SaveSharedSecret(path string, ss *jwt.SharedSecret) error {
|
||||||
|
return os.WriteFile(path, ss.Secret(), 0600)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user