From 392b8bc0f9ae040db17f02090a165b276173f654 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 13 Aug 2026 17:25:50 -0600 Subject: [PATCH] feat(auth/jwt): add HMACSigner/HMACVerifier --- auth/jwt/doc.go | 21 +- auth/jwt/hmac.go | 509 +++++++++++++++++++++++++ auth/jwt/hmac_test.go | 794 +++++++++++++++++++++++++++++++++++++++ auth/jwt/keyfile/hmac.go | 74 ++++ 4 files changed, 1391 insertions(+), 7 deletions(-) create mode 100644 auth/jwt/hmac.go create mode 100644 auth/jwt/hmac_test.go create mode 100644 auth/jwt/keyfile/hmac.go diff --git a/auth/jwt/doc.go b/auth/jwt/doc.go index 0ecc328..67a9c23 100644 --- a/auth/jwt/doc.go +++ b/auth/jwt/doc.go @@ -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 diff --git a/auth/jwt/hmac.go b/auth/jwt/hmac.go new file mode 100644 index 0000000..e6d8fdd --- /dev/null +++ b/auth/jwt/hmac.go @@ -0,0 +1,509 @@ +// Copyright 2026 AJ ONeal (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) +} diff --git a/auth/jwt/hmac_test.go b/auth/jwt/hmac_test.go new file mode 100644 index 0000000..facc048 --- /dev/null +++ b/auth/jwt/hmac_test.go @@ -0,0 +1,794 @@ +// Copyright 2026 AJ ONeal (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) + } + } +} diff --git a/auth/jwt/keyfile/hmac.go b/auth/jwt/keyfile/hmac.go new file mode 100644 index 0000000..5cb52ec --- /dev/null +++ b/auth/jwt/keyfile/hmac.go @@ -0,0 +1,74 @@ +// Copyright 2026 AJ ONeal (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) +}