feat(auth/jwt): add HMACSigner/HMACVerifier

This commit is contained in:
AJ ONeal 2026-08-13 17:25:50 -06:00
parent ab3013609a
commit 392b8bc0f9
No known key found for this signature in database
4 changed files with 1391 additions and 7 deletions

View File

@ -133,8 +133,6 @@
//
// 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
// downsides, and it's supported ubiquitously - Go, JavaScript, Web Browsers, Node, Rust,
// etc.
@ -143,16 +141,25 @@
// ECDSA is provided for backwards compatibility with existing systems.
// 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-384 => ES384 (ECDSA + SHA-384)
// - EC P-521 => ES512 (ECDSA + SHA-512)
// - RSA => RS256 (PKCS#1 v1.5 + SHA-256, RFC 7518 §3.3)
// - Ed25519 => EdDSA (RFC 8037)
//
// Supported algorithms are derived automatically from the key type - you never
// configure alg directly.
// HMAC is also provided for backwards compatibility - it's the least secure,
// 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
// and the key and then checking "alg" before any cryptographic operation is attempted.
// An alg/key-type mismatch is a hard error.
// - HS256 => HMAC + SHA-256, RFC 2104
// - HS384 => HMAC + SHA-384
// - HS512 => HMAC + SHA-512
package jwt

509
auth/jwt/hmac.go Normal file
View 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
View 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
View 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)
}