Files
Hanzo AI ca66adc00a purge: rip remaining Infisical residue — docs, env examples, repo boilerplate
Following the earlier AdminShell+CollectionCRUD rewrite (PR #14) which
already deleted backend/, backend-go/, cloudformation/, helm-charts/,
nginx/, sink/, wasm/, upgrade-impact/, e2e/, migration/, package.json
and switched the frontend to a 184K Hanzo-first build, complete the
cleanup by deleting the remaining Infisical-era residue:

  - docs/                                   (1.0G Infisical mintlify docs)
  - .env.example, .env.dev.example,
    .env.migration.example, .env.test.example
                                            (legacy Postgres/Redis envs;
                                             no Go code references them)
  - .eslintignore, .husky/                  (TS toolchain leftovers)
  - CODE_OF_CONDUCT.md, CONTRIBUTING.md,
    SECURITY.md                             (Infisical repo boilerplate)

Working tree drops from ~1.0G to <600K of content (1.5G total is .git/
which `git gc --aggressive --prune=now` shrinks locally; the remote
keeps its history shape — anyone pulling gets the lean checkout).

Build + test verification:
  go build ./...                            (4 cmd binaries: green)
  go test ./... -count=1 -short             (all packages: ok)

Kept: cmd/, pkg/, sdk/, frontend/ (AdminShell), schema/, examples/,
root Go (audit.go, auth.go, consensus.go, embed.go, jwks.go, mount.go,
versioning.go + tests), Dockerfile, Dockerfile.kms-fetch, Makefile,
LLM.md, CLAUDE.md, DEPRECATED.md, SOVEREIGN-KMS-ARCHITECTURE.md,
TFHE-KMS-ARCHITECTURE.md, LICENSE, README.md, VERSION, .github/
(8 Hanzo workflows: build, build-kms-fetch, check-fe-ts-and-lint,
ci, pr-preview, release, validate-pr-title, workflow-sanity).
2026-06-07 14:23:12 -07:00

165 lines
4.1 KiB
Go

// JWKS cache — per-env RSA public key resolver for JWT verification.
//
// One cache per process. TTL 15min, serialized refresh, fail-closed on
// empty/bad JWKS. Same primitives every Hanzo service uses to verify
// IAM-issued tokens — keep behaviour identical across the platform.
package kms
import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"sync"
"time"
)
// jwksCacheTTL is how long a fetched JWKS set is trusted. Hanzo IAM
// rotates signing keys on the order of days, so 15 minutes is well below
// the rotation window while cheap enough to not hammer IAM.
const jwksCacheTTL = 15 * time.Minute
type jwksCache struct {
mu sync.RWMutex
keys map[string]*rsa.PublicKey
expiry time.Time
refreshMu sync.Mutex // serializes refresh to prevent stampede
url string
client *http.Client
}
func newJWKSCache(url string) *jwksCache {
return &jwksCache{
url: url,
client: &http.Client{Timeout: 10 * time.Second},
}
}
// resolve returns the RSA public key for the given kid, refreshing the
// cache from the configured JWKS URL when the TTL has expired or the kid
// isn't in the current cache. Returns fail-closed: if JWKS is unreachable
// or empty, returns an error.
func (c *jwksCache) resolve(kid string) (*rsa.PublicKey, error) {
if kid == "" {
return nil, errors.New("jwks: JWT missing kid header")
}
// Fast path: cache hit and not expired.
c.mu.RLock()
if time.Now().Before(c.expiry) {
if k, ok := c.keys[kid]; ok {
c.mu.RUnlock()
return k, nil
}
}
c.mu.RUnlock()
// Serialize refresh. Only one goroutine fetches; the rest wait and
// then read the refreshed cache.
c.refreshMu.Lock()
defer c.refreshMu.Unlock()
// Double-check — another goroutine may have just refreshed.
c.mu.RLock()
if time.Now().Before(c.expiry) {
if k, ok := c.keys[kid]; ok {
c.mu.RUnlock()
return k, nil
}
}
c.mu.RUnlock()
if err := c.refresh(); err != nil {
return nil, err
}
c.mu.RLock()
defer c.mu.RUnlock()
k, ok := c.keys[kid]
if !ok {
return nil, fmt.Errorf("jwks: kid %q not found in keyset", kid)
}
return k, nil
}
func (c *jwksCache) refresh() error {
if c.url == "" {
return errors.New("jwks: IAM_KEYS_URL not configured")
}
resp, err := c.client.Get(c.url)
if err != nil {
return fmt.Errorf("jwks: GET %s: %w", c.url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("jwks: %s returned %d", c.url, resp.StatusCode)
}
var doc struct {
Keys []struct {
Kty string `json:"kty"`
Kid string `json:"kid"`
Use string `json:"use"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return fmt.Errorf("jwks: decode: %w", err)
}
out := make(map[string]*rsa.PublicKey, len(doc.Keys))
for _, k := range doc.Keys {
if k.Kty != "RSA" {
continue
}
// use=sig preferred but not mandatory — some IAM builds omit it.
if k.Use != "" && k.Use != "sig" {
continue
}
pub, err := parseJWKSRSAPublicKey(k.N, k.E)
if err != nil {
continue
}
out[k.Kid] = pub
}
if len(out) == 0 {
return errors.New("jwks: no usable RSA signing keys")
}
c.mu.Lock()
c.keys = out
c.expiry = time.Now().Add(jwksCacheTTL)
c.mu.Unlock()
return nil
}
func parseJWKSRSAPublicKey(nB64, eB64 string) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(nB64)
if err != nil {
return nil, fmt.Errorf("decode n: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(eB64)
if err != nil {
return nil, fmt.Errorf("decode e: %w", err)
}
n := new(big.Int).SetBytes(nBytes)
e := new(big.Int).SetBytes(eBytes)
if !e.IsInt64() {
return nil, errors.New("exponent too large")
}
return &rsa.PublicKey{N: n, E: int(e.Int64())}, nil
}
// resetJWKSCacheForTest is used by jwt_test.go to invalidate the cache
// between test environments so each newJWTTestEnv gets a fresh fetch
// against its own mock JWKS server.
func resetJWKSCacheForTest() {
if authConfig.jwks != nil {
authConfig.jwks.mu.Lock()
authConfig.jwks.keys = nil
authConfig.jwks.expiry = time.Time{}
authConfig.jwks.mu.Unlock()
}
}