mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
The 313be7c commit message claimed key→org binding but only added
crypto/subtle.ConstantTimeCompare. A valid X-API-Key combined with
an attacker-chosen X-Org-Id still passed through with the attacker's
org context — full cross-tenant access. Closed here.
pkg/auth/api_keys.go: APIKey, Validator interface, StaticValidator
(legacy single-key shape, no org binding) and Store (Postgres-backed,
bcrypt-hashed). Sentinel errors ErrKeyInvalid / ErrKeyRevoked /
ErrKeyExpired / ErrOrgMismatch. APIKey.CheckOrg compares org_id to
the request's X-Org-Id in constant time.
migrations/022_api_keys_table.sql: api_keys (id, prefix, hash,
org_id, user_id, scopes, created_at, expires_at, revoked_at).
Partial index on prefix WHERE revoked_at IS NULL for the live-key
fast path. CHECK (org_id <> '') so the schema rejects unscoped keys
even before the application layer reads them.
internal/server/middleware/auth.go: APIKeyAuth takes a Validator
(legacy AuthConfig.APIKey string still works — auto-wrapped as
StaticValidator). On Validator success with a bound OrgID, the
middleware enforces:
- request X-Org-Id mismatched → 403 Forbidden
- request X-Org-Id matched OR absent → 200, org pinned from key
The key wins on the org context: c.Set("iam_user_org", apikey.OrgID)
and auth.WithOrgContext(req.Context(), apikey.OrgID) override any
header value. Static (legacy) keys leave org untouched, deferring
to the gateway-trust identity middleware.
internal/server/middleware/grpc_auth.go: same shape via
WithValidator(...) functional option. PermissionDenied (gRPC code
for HTTP 403) on org mismatch, Unauthenticated on bad/revoked/
expired key.
Tests (+14):
- pkg/auth: TestStaticValidator_*, TestAPIKey_CheckOrg,
TestHashKey_RoundTrip, TestPrefixOf, TestSplitScopes,
TestStore_Validate (skipped without TEST_POSTGRES_DSN).
- middleware (HTTP): TestAPIKeyAuth_BoundKey_{MatchingOrg,
MismatchedOrg_403, NoOrgHeader_KeyWins, InvalidHash_401,
Revoked_401, Expired_401, OrgMismatchSentinel},
TestAPIKeyAuth_StaticKey_DefersOrgToGateway.
- middleware (gRPC): TestAPIKeyUnaryInterceptor_BoundKey_*
(parallel set), TestAPIKeyUnaryInterceptor_StaticKey_*.
go build ./... PASS. go test ./pkg/auth/... ./internal/server/middleware/...
PASS. Storage test failures (TestQueryRunSummariesParsesTextTimestamps
et al.) are pre-existing, unrelated to this change set.
Closes Red 2026-04-27 P0-2.
251 lines
8.0 KiB
Go
251 lines
8.0 KiB
Go
// Copyright © 2026 Hanzo AI. MIT License.
|
|
|
|
// Package auth — API key validation. The API key is the trust boundary
|
|
// for the key-auth path: each key carries its bound OrgID, and the
|
|
// middleware rejects any request whose X-Org-Id mismatches that bound
|
|
// org. Closes Red 2026-04-27 P0-2 (cross-tenant access via valid key
|
|
// + attacker-chosen org header).
|
|
//
|
|
// Two validator shapes:
|
|
//
|
|
// - StaticValidator: single shared key from env (HANZO_AGENTS_API_KEY).
|
|
// No org binding — falls back to the gateway-trust identity headers
|
|
// for org context. The legacy operator path.
|
|
//
|
|
// - Store: durable per-key records in Postgres (table: api_keys,
|
|
// migration 022). Each row binds org_id, user_id, scopes, and
|
|
// expiry. Bcrypt-hashed key value, never plaintext.
|
|
//
|
|
// Wire shape: every key is `hk-<32-hex>`. The first 11 bytes
|
|
// (`hk-<8-hex>`) form the lookup prefix — long enough to defeat
|
|
// enumeration, short enough for an indexed equality match.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// KeyPrefixLen is the number of leading bytes used as the indexed
|
|
// lookup column. "hk-" (3) + 8 hex chars = 11. Anything beyond the
|
|
// prefix lives only in the bcrypt hash.
|
|
const KeyPrefixLen = 11
|
|
|
|
// Sentinel errors. Callers must distinguish "wrong key" from "wrong
|
|
// org" because the HTTP/gRPC adapters map them to 401 vs 403.
|
|
var (
|
|
ErrKeyInvalid = errors.New("api key invalid")
|
|
ErrKeyRevoked = errors.New("api key revoked")
|
|
ErrKeyExpired = errors.New("api key expired")
|
|
ErrOrgMismatch = errors.New("api key org mismatch")
|
|
)
|
|
|
|
// APIKey is the durable record. ID is opaque (uuid). Hash is bcrypt
|
|
// over the raw key value. OrgID is the trust pivot — the SQL layer
|
|
// scopes every read/write to it.
|
|
type APIKey struct {
|
|
ID string
|
|
Prefix string
|
|
Hash string
|
|
OrgID string
|
|
UserID string
|
|
Scopes []string
|
|
CreatedAt time.Time
|
|
ExpiresAt *time.Time
|
|
RevokedAt *time.Time
|
|
}
|
|
|
|
// Validator is the smallest contract APIKeyAuth needs: take a raw
|
|
// key string, return the bound APIKey (with OrgID populated) or an
|
|
// error. Implementations MUST be constant-time on the hash compare —
|
|
// bcrypt.CompareHashAndPassword is constant-time at the crypto layer
|
|
// for matched-length inputs, length-blinded by bcrypt's own padding.
|
|
type Validator interface {
|
|
Validate(ctx context.Context, raw string) (*APIKey, error)
|
|
}
|
|
|
|
// CheckOrg returns ErrOrgMismatch when the request claims an org that
|
|
// differs from the key's bound org. Empty requestOrg is treated as
|
|
// "trust the key" — handlers always read the key's OrgID, the header
|
|
// is informational. Constant-time compare avoids leaking the bound
|
|
// org via timing.
|
|
func (k *APIKey) CheckOrg(requestOrg string) error {
|
|
if requestOrg == "" {
|
|
return nil
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(k.OrgID), []byte(requestOrg)) == 1 {
|
|
return nil
|
|
}
|
|
return ErrOrgMismatch
|
|
}
|
|
|
|
// StaticValidator wraps a single shared key (legacy
|
|
// HANZO_AGENTS_API_KEY env). It has no org binding — the returned
|
|
// APIKey carries an empty OrgID, which CheckOrg short-circuits to
|
|
// success. The gateway-trust identity middleware still pins the
|
|
// request context to the JWT-validated org. This is the operator
|
|
// path for solo deployments where there is no key→org table yet.
|
|
type StaticValidator struct {
|
|
key string
|
|
}
|
|
|
|
// NewStaticValidator returns a Validator that succeeds when the raw
|
|
// key matches the configured static key (constant-time). Empty
|
|
// configKey means "no auth" and is the caller's responsibility to
|
|
// short-circuit before calling Validate.
|
|
func NewStaticValidator(configKey string) *StaticValidator {
|
|
return &StaticValidator{key: configKey}
|
|
}
|
|
|
|
// Validate compares raw against the static key in constant time.
|
|
// Mismatch → ErrKeyInvalid. Match → an APIKey with empty OrgID so
|
|
// the caller defers to the gateway's identity headers for org.
|
|
func (s *StaticValidator) Validate(_ context.Context, raw string) (*APIKey, error) {
|
|
if s.key == "" {
|
|
return nil, ErrKeyInvalid
|
|
}
|
|
if !constantTimeStringEqual(raw, s.key) {
|
|
return nil, ErrKeyInvalid
|
|
}
|
|
return &APIKey{ID: "static", Prefix: prefixOf(raw), OrgID: ""}, nil
|
|
}
|
|
|
|
// Store is the durable Validator backed by Postgres (table api_keys,
|
|
// migration 022). All lookups go through Validate — there is no
|
|
// "lookup by id" or "lookup by prefix" surface exposed to the
|
|
// middleware, so a misuse path that compares plaintext anywhere
|
|
// outside this file does not exist.
|
|
type Store struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewStore wires a Postgres-backed Validator to db. db must be the
|
|
// canonical *sql.DB used by the rest of the binary so tx semantics
|
|
// stay consistent.
|
|
func NewStore(db *sql.DB) *Store {
|
|
return &Store{db: db}
|
|
}
|
|
|
|
// Validate performs an indexed prefix lookup, then bcrypt-compares
|
|
// the hash. Two queries → one for the live (non-revoked) candidate,
|
|
// one for the hash compare. Returns ErrKeyInvalid for any of:
|
|
// short key, no row, hash mismatch. Returns ErrKeyRevoked /
|
|
// ErrKeyExpired when the candidate row exists but is no longer
|
|
// usable. The middleware maps both to 401.
|
|
//
|
|
// Constant-time properties:
|
|
// - prefix lookup is by indexed equality (PostgreSQL btree),
|
|
// not user-supplied substring, so DB-side timing is bounded
|
|
// by index height, not key length.
|
|
// - bcrypt.CompareHashAndPassword is constant-time at the crypto
|
|
// layer for matched bcrypt-encoded inputs.
|
|
// - We never short-circuit on partial bcrypt match.
|
|
func (s *Store) Validate(ctx context.Context, raw string) (*APIKey, error) {
|
|
if len(raw) < KeyPrefixLen {
|
|
return nil, ErrKeyInvalid
|
|
}
|
|
prefix := prefixOf(raw)
|
|
|
|
row := s.db.QueryRowContext(ctx, `
|
|
SELECT id, prefix, hash, org_id, user_id, scopes,
|
|
created_at, expires_at, revoked_at
|
|
FROM api_keys
|
|
WHERE prefix = $1
|
|
LIMIT 1
|
|
`, prefix)
|
|
|
|
var (
|
|
k APIKey
|
|
scopes string
|
|
expiresAt sql.NullTime
|
|
revokedAt sql.NullTime
|
|
)
|
|
err := row.Scan(
|
|
&k.ID, &k.Prefix, &k.Hash, &k.OrgID, &k.UserID, &scopes,
|
|
&k.CreatedAt, &expiresAt, &revokedAt,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrKeyInvalid
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("api_keys lookup: %w", err)
|
|
}
|
|
if revokedAt.Valid {
|
|
t := revokedAt.Time
|
|
k.RevokedAt = &t
|
|
return nil, ErrKeyRevoked
|
|
}
|
|
if expiresAt.Valid {
|
|
t := expiresAt.Time
|
|
k.ExpiresAt = &t
|
|
if !t.IsZero() && time.Now().After(t) {
|
|
return nil, ErrKeyExpired
|
|
}
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(k.Hash), []byte(raw)); err != nil {
|
|
return nil, ErrKeyInvalid
|
|
}
|
|
if scopes != "" {
|
|
k.Scopes = splitScopes(scopes)
|
|
}
|
|
return &k, nil
|
|
}
|
|
|
|
// HashKey returns the bcrypt hash of raw. Used by the admin path
|
|
// when issuing new keys; the middleware never calls this.
|
|
func HashKey(raw string) (string, error) {
|
|
h, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return "", fmt.Errorf("hash api key: %w", err)
|
|
}
|
|
return string(h), nil
|
|
}
|
|
|
|
// prefixOf returns the canonical lookup prefix. Short keys yield the
|
|
// whole string — Validate rejects sub-prefix-length keys before this
|
|
// point, so the only reachable path returns exactly KeyPrefixLen
|
|
// bytes.
|
|
func prefixOf(raw string) string {
|
|
if len(raw) <= KeyPrefixLen {
|
|
return raw
|
|
}
|
|
return raw[:KeyPrefixLen]
|
|
}
|
|
|
|
// constantTimeStringEqual mirrors the helper in the gin and grpc
|
|
// adapters — kept here so the static path can compare without
|
|
// importing internal/server/middleware. Length mismatch short-circuits
|
|
// because bytes-on-the-wire already leak the length.
|
|
func constantTimeStringEqual(a, b string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
|
}
|
|
|
|
// splitScopes turns a comma-separated DB column into a slice. Stored
|
|
// flat to avoid an N+1 join for what is effectively a small set of
|
|
// strings.
|
|
func splitScopes(s string) []string {
|
|
out := make([]string, 0, 4)
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == ',' {
|
|
if i > start {
|
|
out = append(out, s[start:i])
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
out = append(out, s[start:])
|
|
}
|
|
return out
|
|
}
|