mirror of
https://github.com/hanzoai/kms.git
synced 2026-10-17 11:18:16 +00:00
Both modules declare go 1.27.1 and the build images follow. go fix applied the modernizers: promoted-field keys in struct literals, SplitSeq, strings.Cut, new(expr), slices.Contains and any. The ptr helper had no callers left once new(expr) could say it. omitzero is not applied; it changes what the JSON says, so it is a per-field decision. Co-authored-by: Hanzo Dev <[email protected]>
388 lines
12 KiB
Go
388 lines
12 KiB
Go
// JWT verification — full RFC 7519 enforcement: signature, alg allowlist,
|
|
// iss, aud, exp. Replaces the unverified-payload parsing that Red
|
|
// demonstrated fully bypassed with a 3-line curl + alg=none token on
|
|
// 2026-04-21.
|
|
//
|
|
// Contract (by design, no hidden branches):
|
|
//
|
|
// 1. Authorization: Bearer <token> — else 401
|
|
// 2. JWT header.alg ∈ {RS256, ES256, EdDSA} — else 401 (no HS*, no none)
|
|
// 3. Signature verifies against JWKS by kid — else 401
|
|
// 4. iss == $IAM_ISSUER — else 401
|
|
// 5. aud ∋ $IAM_AUDIENCE — else 401
|
|
// 6. exp > now (no leeway) — else 401
|
|
// 7. nbf ≤ now when present — else 401
|
|
// 8. sub present — else 401
|
|
//
|
|
// Any failure logs a structured audit line with the failure class
|
|
// (alg_none, expired, wrong_iss, wrong_aud, jwks_miss, sig, missing_sub).
|
|
// No JWT payload claim is echoed back to the caller or into logs.
|
|
package kms
|
|
|
|
import (
|
|
"crypto/rsa"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/luxfi/log"
|
|
)
|
|
|
|
// authConfig is the per-process JWT verification contract. It is
|
|
// populated by loadAuthConfig() at boot and exposed to tests via
|
|
// reloadAuthConfigForTest so tests can rewire the issuer/audience/jwks
|
|
// without restarting the server.
|
|
var authConfig = struct {
|
|
issuer atomic.Pointer[string]
|
|
audience atomic.Pointer[string]
|
|
jwks *jwksCache
|
|
}{}
|
|
|
|
type authCfgValues struct {
|
|
issuer string
|
|
audience string
|
|
jwksURL string
|
|
}
|
|
|
|
// loadAuthConfig reads IAM_ISSUER, IAM_AUDIENCE, and IAM_KEYS_URL from
|
|
// the environment. Called once at boot from main().
|
|
func loadAuthConfig() authCfgValues {
|
|
return authCfgValues{
|
|
issuer: strings.TrimSpace(os.Getenv("IAM_ISSUER")),
|
|
audience: strings.TrimSpace(os.Getenv("IAM_AUDIENCE")),
|
|
jwksURL: strings.TrimSpace(os.Getenv("IAM_KEYS_URL")),
|
|
}
|
|
}
|
|
|
|
// applyAuthConfig swaps the live authConfig atomically. Called from main()
|
|
// at boot and from tests via reloadAuthConfigForTest.
|
|
func applyAuthConfig(v authCfgValues) {
|
|
iss := v.issuer
|
|
authConfig.issuer.Store(&iss)
|
|
aud := v.audience
|
|
authConfig.audience.Store(&aud)
|
|
if v.jwksURL == "" {
|
|
authConfig.jwks = nil
|
|
} else {
|
|
authConfig.jwks = newJWKSCache(v.jwksURL)
|
|
}
|
|
}
|
|
|
|
// validateAuthConfigAtBoot enforces that the three auth env vars are
|
|
// populated in every env. No dev escape hatch — a missing config must
|
|
// fail loud at boot, the same way it would at staging.
|
|
func validateAuthConfigAtBoot(env, issuer, audience, jwksURL string) error {
|
|
if issuer == "" {
|
|
return fmt.Errorf("IAM_ISSUER is required (env=%q)", env)
|
|
}
|
|
if audience == "" {
|
|
return fmt.Errorf("IAM_AUDIENCE is required (env=%q)", env)
|
|
}
|
|
if jwksURL == "" {
|
|
return fmt.Errorf("IAM_KEYS_URL is required (env=%q)", env)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reloadAuthConfigForTest is called by jwt_test.go after t.Setenv() to
|
|
// re-read the three IAM_* vars and rebuild the JWKS cache against the
|
|
// current test server.
|
|
func reloadAuthConfigForTest() {
|
|
applyAuthConfig(loadAuthConfig())
|
|
}
|
|
|
|
var (
|
|
errAuthNoHeader = errors.New("authorization header required")
|
|
errAuthNotBearer = errors.New("bearer token required")
|
|
errAuthNoConfig = errors.New("KMS auth misconfigured: JWKS URL or issuer missing")
|
|
errAuthBadAlg = errors.New("jwt: unsupported signing algorithm")
|
|
errAuthBadSig = errors.New("jwt: signature verification failed")
|
|
errAuthWrongIss = errors.New("jwt: issuer mismatch")
|
|
errAuthWrongAud = errors.New("jwt: audience mismatch")
|
|
errAuthExpired = errors.New("jwt: token expired")
|
|
errAuthMissingSub = errors.New("jwt: missing subject claim")
|
|
errAuthMissingAud = errors.New("jwt: missing audience claim")
|
|
errAuthMissingExp = errors.New("jwt: missing expiry claim")
|
|
errAuthMissingIss = errors.New("jwt: missing issuer claim")
|
|
)
|
|
|
|
// verifyJWT is the single verified-parse entry point. Called by
|
|
// authorize() for every handler that requires a JWT. Returns the
|
|
// authorized claims subset on success, or an error whose message is
|
|
// suitable for the audit log (but NOT for the client body — the client
|
|
// always gets "unauthorized" to avoid oracle attacks).
|
|
func verifyJWT(authHeader string) (jwtClaims, error) {
|
|
if authHeader == "" {
|
|
return jwtClaims{}, errAuthNoHeader
|
|
}
|
|
const prefix = "Bearer "
|
|
if !strings.HasPrefix(authHeader, prefix) && !strings.HasPrefix(authHeader, "bearer ") {
|
|
return jwtClaims{}, errAuthNotBearer
|
|
}
|
|
tokenStr := strings.TrimSpace(authHeader[len(prefix):])
|
|
if tokenStr == "" {
|
|
return jwtClaims{}, errAuthNotBearer
|
|
}
|
|
|
|
issPtr := authConfig.issuer.Load()
|
|
if issPtr == nil || *issPtr == "" || authConfig.jwks == nil {
|
|
return jwtClaims{}, errAuthNoConfig
|
|
}
|
|
|
|
// Asymmetric alg allowlist — HS* and none are NEVER accepted.
|
|
allowed := []string{
|
|
jwt.SigningMethodRS256.Name,
|
|
jwt.SigningMethodRS384.Name,
|
|
jwt.SigningMethodRS512.Name,
|
|
jwt.SigningMethodES256.Name,
|
|
jwt.SigningMethodES384.Name,
|
|
jwt.SigningMethodES512.Name,
|
|
jwt.SigningMethodPS256.Name,
|
|
jwt.SigningMethodPS384.Name,
|
|
jwt.SigningMethodPS512.Name,
|
|
"EdDSA",
|
|
}
|
|
|
|
parser := jwt.NewParser(
|
|
jwt.WithValidMethods(allowed),
|
|
// Issuer is enforced post-parse by checkIssuer, which accepts a
|
|
// comma-separated allowlist. jwt.WithIssuer only matches a single
|
|
// value and would reject every token when IAM_ISSUER is a list.
|
|
jwt.WithExpirationRequired(),
|
|
// Leeway 0 — Red's prod attack used exp=Sep 2001; we honour real time.
|
|
jwt.WithLeeway(0),
|
|
)
|
|
|
|
token, err := parser.Parse(tokenStr, func(tok *jwt.Token) (any, error) {
|
|
alg, _ := tok.Method.(jwt.SigningMethod)
|
|
if alg == nil {
|
|
return nil, errAuthBadAlg
|
|
}
|
|
// Additional defence-in-depth: reject "none" even though
|
|
// WithValidMethods already blocks it. jwt/v5 does not register a
|
|
// "none" method by default, but belt + suspenders.
|
|
if strings.EqualFold(alg.Alg(), "none") {
|
|
return nil, errAuthBadAlg
|
|
}
|
|
kid, _ := tok.Header["kid"].(string)
|
|
key, err := authConfig.jwks.resolve(kid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Return typed key so jwt/v5 picks the right verifier. Only RSA
|
|
// keys from JWKS for now — Hanzo IAM emits RS256.
|
|
return key, nil
|
|
})
|
|
if err != nil {
|
|
return jwtClaims{}, wrapAuthErr(err)
|
|
}
|
|
if !token.Valid {
|
|
return jwtClaims{}, errAuthBadSig
|
|
}
|
|
|
|
mc, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return jwtClaims{}, errAuthBadSig
|
|
}
|
|
|
|
// Issuer — accept a comma-separated allowlist (mirrors checkAudience)
|
|
// so one KMS can trust multiple IAM issuers (hanzo.id, iam.hanzo.ai,
|
|
// hanzo.ai) without re-patching the pod. Trailing slashes normalized.
|
|
gotIss, _ := mc["iss"].(string)
|
|
if gotIss == "" {
|
|
return jwtClaims{}, errAuthMissingIss
|
|
}
|
|
if err := checkIssuer(gotIss, *issPtr); err != nil {
|
|
return jwtClaims{}, err
|
|
}
|
|
|
|
// Audience — jwt/v5 doesn't enforce unless we check explicitly.
|
|
audPtr := authConfig.audience.Load()
|
|
expectedAud := "kms"
|
|
if audPtr != nil && *audPtr != "" {
|
|
expectedAud = *audPtr
|
|
}
|
|
if err := checkAudience(mc, expectedAud); err != nil {
|
|
return jwtClaims{}, err
|
|
}
|
|
|
|
// Expiry — WithExpirationRequired + leeway 0 enforces this, but we
|
|
// re-check to emit a clean error class for the audit log.
|
|
expFloat, hasExp := numericClaim(mc, "exp")
|
|
if !hasExp {
|
|
return jwtClaims{}, errAuthMissingExp
|
|
}
|
|
_ = expFloat // jwt/v5 already rejected expired; variable kept for audit
|
|
|
|
c := jwtClaims{Iss: gotIss}
|
|
if v, ok := mc["sub"].(string); ok {
|
|
c.Sub = v
|
|
}
|
|
if c.Sub == "" {
|
|
if v, ok := mc["id"].(string); ok {
|
|
c.Sub = v
|
|
}
|
|
}
|
|
if c.Sub == "" {
|
|
return jwtClaims{}, errAuthMissingSub
|
|
}
|
|
if v, ok := mc["owner"].(string); ok {
|
|
c.Owner = v
|
|
}
|
|
// A `roles` claim is deliberately NOT read. IAM mints no such claim,
|
|
// and a permission decision does not belong to a secret store — see
|
|
// jwtClaims (embed.go).
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// checkAudience enforces that at least one audience in the JWT `aud` claim
|
|
// matches one of the expected audiences configured for this KMS instance.
|
|
//
|
|
// `expected` may be a single audience ("hanzo-app") or a comma-separated
|
|
// list ("hanzo-app,console,paas,kms") so that one KMS can serve multiple
|
|
// client services without re-patching the pod on every new consumer.
|
|
func checkAudience(mc jwt.MapClaims, expected string) error {
|
|
raw, ok := mc["aud"]
|
|
if !ok {
|
|
return errAuthMissingAud
|
|
}
|
|
// Expand comma-separated expected list into a set. Empty entries and
|
|
// surrounding whitespace are ignored.
|
|
want := make(map[string]struct{})
|
|
for e := range strings.SplitSeq(expected, ",") {
|
|
if s := strings.TrimSpace(e); s != "" {
|
|
want[s] = struct{}{}
|
|
}
|
|
}
|
|
if len(want) == 0 {
|
|
return errAuthWrongAud
|
|
}
|
|
switch aud := raw.(type) {
|
|
case string:
|
|
if _, ok := want[aud]; ok {
|
|
return nil
|
|
}
|
|
case []any:
|
|
for _, v := range aud {
|
|
if s, ok := v.(string); ok {
|
|
if _, ok := want[s]; ok {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return errAuthWrongAud
|
|
}
|
|
|
|
// checkIssuer enforces that the JWT `iss` matches one of the expected
|
|
// issuers. `expected` may be a single issuer ("https://hanzo.id") or a
|
|
// comma-separated list ("https://hanzo.id,https://iam.hanzo.ai,https://hanzo.ai")
|
|
// so one KMS can trust multiple IAM issuers without re-patching the pod.
|
|
// Trailing slashes are normalized on both sides. Mirrors checkAudience.
|
|
func checkIssuer(got, expected string) error {
|
|
got = strings.TrimRight(got, "/")
|
|
for e := range strings.SplitSeq(expected, ",") {
|
|
if s := strings.TrimRight(strings.TrimSpace(e), "/"); s != "" && s == got {
|
|
return nil
|
|
}
|
|
}
|
|
return errAuthWrongIss
|
|
}
|
|
|
|
func numericClaim(mc jwt.MapClaims, k string) (float64, bool) {
|
|
switch v := mc[k].(type) {
|
|
case float64:
|
|
return v, true
|
|
case int64:
|
|
return float64(v), true
|
|
case int:
|
|
return float64(v), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// wrapAuthErr maps jwt/v5 parse errors to our audit-friendly error set.
|
|
func wrapAuthErr(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
switch {
|
|
case errors.Is(err, jwt.ErrTokenExpired):
|
|
return errAuthExpired
|
|
case errors.Is(err, jwt.ErrTokenNotValidYet):
|
|
return errAuthExpired
|
|
case errors.Is(err, jwt.ErrTokenInvalidIssuer):
|
|
return errAuthWrongIss
|
|
case errors.Is(err, jwt.ErrTokenSignatureInvalid):
|
|
return errAuthBadSig
|
|
case errors.Is(err, jwt.ErrSignatureInvalid):
|
|
return errAuthBadSig
|
|
case errors.Is(err, jwt.ErrTokenUnverifiable):
|
|
return errAuthBadSig
|
|
case errors.Is(err, jwt.ErrTokenMalformed):
|
|
return errAuthBadSig
|
|
}
|
|
// jwt/v5 sometimes wraps alg-not-allowed / method-not-registered:
|
|
if strings.Contains(err.Error(), "unexpected signing method") ||
|
|
strings.Contains(err.Error(), "signing method") ||
|
|
strings.Contains(err.Error(), "alg") {
|
|
return errAuthBadAlg
|
|
}
|
|
// jwks resolve failure is fail-closed.
|
|
if strings.Contains(err.Error(), "jwks:") || strings.Contains(err.Error(), "kid") {
|
|
return errAuthBadSig // don't leak cache/net details via 401
|
|
}
|
|
return errAuthBadSig
|
|
}
|
|
|
|
// authz logger — scoped child of the package root so audit entries
|
|
// get their own component tag. One logger, no parallel slog tree.
|
|
var authLog = log.Root().New("component", "kms-auth")
|
|
|
|
// authFailReason converts an auth error into a short tag suitable for
|
|
// inclusion in the audit log.
|
|
func authFailReason(err error) string {
|
|
switch {
|
|
case errors.Is(err, errAuthNoHeader), errors.Is(err, errAuthNotBearer):
|
|
return "no_bearer"
|
|
case errors.Is(err, errAuthNoConfig):
|
|
return "misconfigured"
|
|
case errors.Is(err, errAuthBadAlg):
|
|
return "alg_not_allowed"
|
|
case errors.Is(err, errAuthBadSig):
|
|
return "sig_invalid"
|
|
case errors.Is(err, errAuthExpired):
|
|
return "expired"
|
|
case errors.Is(err, errAuthWrongIss), errors.Is(err, errAuthMissingIss):
|
|
return "wrong_iss"
|
|
case errors.Is(err, errAuthWrongAud), errors.Is(err, errAuthMissingAud):
|
|
return "wrong_aud"
|
|
case errors.Is(err, errAuthMissingExp):
|
|
return "missing_exp"
|
|
case errors.Is(err, errAuthMissingSub):
|
|
return "missing_sub"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// peerIP extracts the client IP from the request for audit logging.
|
|
// Honours X-Forwarded-For when present (gateway is trusted upstream).
|
|
func peerIP(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if before, _, ok := strings.Cut(xff, ","); ok {
|
|
return strings.TrimSpace(before)
|
|
}
|
|
return strings.TrimSpace(xff)
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
// Compile-time guard that rsa.PublicKey implements crypto.PublicKey the
|
|
// way jwt/v5 expects.
|
|
var _ = (*rsa.PublicKey)(nil)
|