Files
kms/auth.go
T
zeekayandHanzo Dev e6a50f427e Hold the secrets; let cloud decide who may read them
This package kept its own HTTP secret plane — routes, an audit log, version CAS
and a JWT gate — beside the one cloud already serves at
/v1/kms/orgs/{org}/secrets. Two doors onto the same store means two places
where a tenant boundary can be got wrong, and only one of them was reviewed as
the authorization surface.

What is left is storage and the ZAP transport, whose authorizer is a signed
consensus-authority snapshot and which stays disabled unless that snapshot is
present. This package now makes no authorization decision at all: IAM owns
identity, cloud folds the caller's validated org into the storage key so one
tenant's path cannot name another's record, and secrets are sealed at rest with
the replicator streaming encrypted backups to S3.

go.sum carried a stale pair for luxfi/vm v1.3.1 — the tag was republished, and
the entries here predated it, so nothing in the module could build. The values
now match the public checksum database, which six other repos in the estate
already agreed with.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-20 13:13:33 -07:00

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.Split(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.Split(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 i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
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)