mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Why: consolidate the Python bootnode backend (bootnode/api/, ~100 .py files)
onto Hanzo Base, leveraging Base's IAM client and per-org/per-user tenant
infrastructure instead of reimplementing auth, sessions, and multi-tenancy.
This is the structural foundation + the 5 most-important modules, not the whole
port.
What landed
- plugins/bootnode/: blockchain developer platform mounted under /v1.
Five modules ported end-to-end:
1. auth — multi-network OAuth2 callback (lux/pars/zoo/hanzo share the
lux-web3 IAM app; client id derived from redirect_uri) + bn_
project API keys (salted SHA-256, raw key shown once, verified
in constant time). Accepts IAM JWTs and pk-/sk-/hk- keys by
reusing github.com/hanzoai/base/iam — no IAM logic duplicated.
2. team — org/member CRUD scoped to the caller's project; invited emails
resolved against IAM (active) or held pending with an invite
token.
3. networks — applies bootno.de/v1 Network CRs (white-label brand, tier,
region, validator fleet). Replaces the Python's kubectl + raw
nginx-Ingress templating with a declarative CR for the
bootno.de operator to reconcile.
4. nodes — applies bootno.de/v1 NodeFleet CRs (CRD-driven cloud path;
the Python docker provider was a local-dev concern).
5. keys — applies bootno.de/v1 KMSSecret CRs by KMS path. NO plaintext
key material ever touches this service; the request and
response carry none, and a guard rejects any private-key field.
- plugins/bootnode/kube/: dependency-free Kubernetes REST client (net/http
server-side apply). No client-go, no CGO. In-cluster SA or KUBE_APISERVER.
- plugins/commerce/: typed Hanzo Commerce (Square billing) client behind a
Client interface. bootnode depends on the interface; commerce never depends
on bootnode/iam.
- 11 SQLAlchemy models -> Base collections (models/collections.go). No `users`
collection: IAM owns identity; bootnode references IAM user ids as text.
OrgCluster is the canonical org->k8s-cluster mapping.
- examples/base/main.go: platform plugin now runs PrincipalIsolation="sqlite"
(per-org + per-user encrypted SQLite); bootnode registered (BOOTNODE_ENABLED).
Modules pending (15, tracked in PR body): chat, zap, billing-http, bundler,
fleets, gas, infra, launch, lux, mpc, nfts, observability, rpc, tokens,
transfers, wallets, webhooks-http. (chains is also ported as a bonus 6th.)
Tests (20 functions, all green incl -race)
- auth: token classification, key gen/hash/verify (tamper + wrong-salt),
redirect->clientId derivation.
- kube: server-side-apply shape (PATCH + apply-patch+yaml + fieldManager),
error propagation, 404 get/delete idempotency.
- commerce: disabled no-op, get-or-create (create + existing), usage error
propagation, immediate cancel.
- workers: HMAC-signed delivery, non-2xx-is-failure, unreachable-is-failure.
- bootnode: full end-to-end against a fake IAM + fake apiserver — /me 401 then
200, project + bn_ key, team invite/list, Network + NodeFleet CR apply/get,
KMSSecret plaintext-rejection + apply + status, public chains. Plus
fail-fast on the insecure default salt against production IAM.
Verified: `go build ./...` exits 0; `go test -race ./plugins/bootnode/...
./plugins/commerce/...` passes; live binary serves /v1/chains (200) and gates
/v1/auth/me, /v1/networks (401); per-org SQLite isolation logged active; all
10 _bootnode_ collections created on boot.
Co-authored-by: zeekay <[email protected]>
99 lines
3.3 KiB
Go
99 lines
3.3 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// KeyType classifies a presented credential. bootnode accepts three credential
|
|
// shapes: its own project keys (bn_), IAM bearer JWTs, and IAM-managed API
|
|
// keys (pk-/sk-/hk-).
|
|
type KeyType int
|
|
|
|
const (
|
|
// KeyUnknown is an unrecognized credential shape.
|
|
KeyUnknown KeyType = iota
|
|
// KeyBootnode is a bootnode-issued project key (bn_…), hashed in
|
|
// _bootnode_api_keys.
|
|
KeyBootnode
|
|
// KeyIAMPublishable is an IAM publishable key (pk-…), read-only.
|
|
KeyIAMPublishable
|
|
// KeyIAMSecret is an IAM secret key (sk-…), full access.
|
|
KeyIAMSecret
|
|
// KeyIAMService is an IAM service key (hk-…), full access.
|
|
KeyIAMService
|
|
// KeyJWT is an IAM bearer JWT (three dot-separated segments).
|
|
KeyJWT
|
|
)
|
|
|
|
// BootnodeKeyPrefix is the prefix for bootnode-issued project keys.
|
|
const BootnodeKeyPrefix = "bn_"
|
|
|
|
// keyPrefixLen is the number of leading characters stored for display.
|
|
const keyPrefixLen = 12
|
|
|
|
// ClassifyCredential determines the [KeyType] of a presented credential
|
|
// without any network call. isBearer indicates the credential arrived via an
|
|
// Authorization: Bearer header (vs an X-API-Key header).
|
|
func ClassifyCredential(cred string, isBearer bool) KeyType {
|
|
switch {
|
|
case strings.HasPrefix(cred, BootnodeKeyPrefix):
|
|
return KeyBootnode
|
|
case strings.HasPrefix(cred, "pk-"):
|
|
return KeyIAMPublishable
|
|
case strings.HasPrefix(cred, "sk-"):
|
|
return KeyIAMSecret
|
|
case strings.HasPrefix(cred, "hk-"):
|
|
return KeyIAMService
|
|
case isBearer || strings.Count(cred, ".") == 2:
|
|
return KeyJWT
|
|
default:
|
|
return KeyUnknown
|
|
}
|
|
}
|
|
|
|
// GenerateKey mints a new bootnode project API key. It returns the raw key
|
|
// (shown to the user exactly once), the salted hash to persist, and the prefix
|
|
// to persist for display.
|
|
//
|
|
// The raw key is 32 bytes of crypto/rand entropy, URL-safe base64 encoded,
|
|
// prefixed with bn_. The stored hash is SHA-256(rawKey + salt) — the same
|
|
// construction as the Python bootnode so previously-issued keys verify
|
|
// unchanged. SHA-256+salt (not bcrypt) is correct here: the key is a
|
|
// high-entropy random token, not a low-entropy human password, so the
|
|
// brute-force resistance bcrypt buys is irrelevant and its cost would only
|
|
// slow every authenticated request.
|
|
func GenerateKey(salt string) (rawKey, hash, prefix string, err error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", "", "", fmt.Errorf("auth: generate key entropy: %w", err)
|
|
}
|
|
rawKey = BootnodeKeyPrefix + base64.RawURLEncoding.EncodeToString(buf)
|
|
hash = HashKey(rawKey, salt)
|
|
prefix = rawKey
|
|
if len(prefix) > keyPrefixLen {
|
|
prefix = prefix[:keyPrefixLen]
|
|
}
|
|
return rawKey, hash, prefix, nil
|
|
}
|
|
|
|
// HashKey computes the salted SHA-256 hash of a raw key for storage and
|
|
// lookup. Deterministic: the same (rawKey, salt) always yields the same hash,
|
|
// which is required because the hash is the lookup index.
|
|
func HashKey(rawKey, salt string) string {
|
|
sum := sha256.Sum256([]byte(rawKey + salt))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// VerifyKey reports whether rawKey hashes (with salt) to storedHash, using a
|
|
// constant-time comparison to avoid leaking the hash via timing.
|
|
func VerifyKey(rawKey, salt, storedHash string) bool {
|
|
got := HashKey(rawKey, salt)
|
|
return subtle.ConstantTimeCompare([]byte(got), []byte(storedHash)) == 1
|
|
}
|