This repository was archived by the owner on Aug 26, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.go
More file actions
440 lines (411 loc) · 14.1 KB
/
Copy pathauth.go
File metadata and controls
440 lines (411 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
// 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
}
// Roles may be []any or string — normalize to []string.
switch v := mc["roles"].(type) {
case string:
for _, r := range strings.Split(v, ",") {
if rr := strings.TrimSpace(r); rr != "" {
c.Roles = append(c.Roles, rr)
}
}
case []any:
for _, item := range v {
if s, ok := item.(string); ok {
if rr := strings.TrimSpace(s); rr != "" {
c.Roles = append(c.Roles, rr)
}
}
}
}
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
}
// registerKeyRouteAuthGatesForTest is test-only wiring so jwt_test.go
// can exercise F5 (key-route gating) without spinning up an MPC cluster.
// It mounts the same authorize() gate that registerKeyRoutes uses but
// with stub handlers that return 200. Any production caller must use
// registerKeyRoutes (which composes authorize() + mgr handlers).
func registerKeyRouteAuthGatesForTest(mux *http.ServeMux) {
adminOnly := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := authorize(w, r)
if !ok {
return
}
if !claims.isAdmin() {
writeJSON(w, http.StatusForbidden, map[string]any{"message": "admin role required"})
return
}
next(w, r)
}
}
mux.HandleFunc("GET /v1/kms/keys", adminOnly(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, []string{})
}))
mux.HandleFunc("GET /v1/kms/keys/{id}", adminOnly(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"id": r.PathValue("id")})
}))
mux.HandleFunc("POST /v1/kms/keys/generate", adminOnly(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"ok": true})
}))
mux.HandleFunc("POST /v1/kms/keys/{id}/sign", adminOnly(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"sig": "test"})
}))
mux.HandleFunc("POST /v1/kms/keys/{id}/rotate", adminOnly(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}))
mux.HandleFunc("GET /v1/kms/status", adminOnly(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"kms": "ok"})
}))
}
// Compile-time guard that rsa.PublicKey implements crypto.PublicKey the
// way jwt/v5 expects.
var _ = (*rsa.PublicKey)(nil)