mirror of
https://github.com/luxfi/wallet.git
synced 2026-10-30 15:13:19 +00:00
Add apps/backend — the wallet's custody server. The load-bearing Front-E gap
(repo was FE-only) is now closed: a wallet account model backed by MPC custody
where the private key is split t-of-n across the MPC nodes (lux →
mpc.lux.network) and NEVER assembled — this service stores only public keys +
addresses.
- internal/iam: lux.id OIDC bearer verification via JWKS (discovery + RS256/
ES256, alg-confusion-safe — HMAC/none refused). Org from the 'owner' claim.
golang-jwt/v5 (matches the lux ecosystem), stdlib JWKS fetcher (no extra dep).
- internal/custody: the Custodian port (a value the wallet owns) + HTTP adapter
to lux/mpc's verified /keygen + /sign wire. Mirrors mpc client.Threshold's
security contract (idempotency required, org-scoped). PQ-ready: mldsa65 is a
first-class scheme (the PQ primitives stay in pkgs/wallet/.../pq + consensus —
not duplicated). No in-process key fallback — unconfigured MPC is an error.
- internal/store: wallet↔org↔addresses (no key field). Org-scoped reads — a
cross-org get is ErrNotFound (no existence disclosure).
- internal/api: /v1/* (no /api/ prefix). IAM-gated, org-scoped. POST/GET
/v1/wallets, GET /v1/wallets/{id}, POST /v1/wallets/{id}/sign, GET /v1/health.
- cmd/wallet-backend: env config (white-label per tenant: IAM_ISSUER/AUDIENCE,
MPC_ENDPOINT — shared hanzo-mpc or BYOMPC), JSON logs, graceful shutdown.
Deploy (native CI + operator, NO GHA): build-only .platform.yml → ghcr.io/
luxfi/wallet-backend on lux-build pools; lux operator rolls
apps/backend/k8s/wallet-backend.yaml (lux.cloud/v1 Service + KMSSecret for
MPC_SERVICE_TOKEN), ingress wallet-api.lux.network.
17 tests green (go test ./...): IAM (valid/no-org/wrong-aud/expired/alg-
confusion), custody (no-key-leak, idempotency-required, PQ-accepted, no-
fallback), API (auth-gate, org-isolation cross-org→404, create→sign), + a full
E2E through the real adapter→mock-MPC→real-server. Binary boots, /v1/health ok,
/v1/wallets 401 unauth. LLM.md captures the App(Wallet) spec for the unified
operator's App Kind.
194 lines
4.5 KiB
Go
194 lines
4.5 KiB
Go
// JWKS discovery + key cache. Stdlib only (no jose/keyfunc dep): fetch the
|
|
// issuer's JWKS, decode RSA/EC public keys, and resolve a token's `kid` to its
|
|
// key. Keys are cached; a cache miss (rotated kid) triggers one refresh.
|
|
package iam
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
type discoveryDoc struct {
|
|
JWKSURI string `json:"jwks_uri"`
|
|
}
|
|
|
|
func discoverJWKS(ctx context.Context, issuer string, hc *http.Client) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer+"/.well-known/openid-configuration", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
res, err := hc.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("discovery %s: status %d", issuer, res.StatusCode)
|
|
}
|
|
var doc discoveryDoc
|
|
if err := json.NewDecoder(res.Body).Decode(&doc); err != nil {
|
|
return "", err
|
|
}
|
|
if doc.JWKSURI == "" {
|
|
return "", fmt.Errorf("discovery %s: no jwks_uri", issuer)
|
|
}
|
|
return doc.JWKSURI, nil
|
|
}
|
|
|
|
type jwk struct {
|
|
Kty string `json:"kty"`
|
|
Kid string `json:"kid"`
|
|
N string `json:"n"` // RSA modulus
|
|
E string `json:"e"` // RSA exponent
|
|
Crv string `json:"crv"` // EC curve
|
|
X string `json:"x"` // EC x
|
|
Y string `json:"y"` // EC y
|
|
}
|
|
|
|
type jwks struct {
|
|
uri string
|
|
hc *http.Client
|
|
|
|
mu sync.RWMutex
|
|
keys map[string]interface{} // kid → *rsa.PublicKey | *ecdsa.PublicKey
|
|
fetchedAt time.Time
|
|
}
|
|
|
|
func newJWKS(uri string, hc *http.Client) *jwks {
|
|
return &jwks{uri: uri, hc: hc, keys: map[string]interface{}{}}
|
|
}
|
|
|
|
// key resolves the token's `kid` to its public key, refreshing once on a miss
|
|
// (handles key rotation) but no more than every 60s (avoids fetch storms).
|
|
func (j *jwks) key(ctx context.Context, t *jwt.Token) (interface{}, error) {
|
|
kid, _ := t.Header["kid"].(string)
|
|
if kid == "" {
|
|
return nil, fmt.Errorf("token has no kid")
|
|
}
|
|
if k := j.lookup(kid); k != nil {
|
|
return k, nil
|
|
}
|
|
j.mu.RLock()
|
|
stale := time.Since(j.fetchedAt) > time.Minute
|
|
j.mu.RUnlock()
|
|
if stale || j.empty() {
|
|
if err := j.refresh(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if k := j.lookup(kid); k != nil {
|
|
return k, nil
|
|
}
|
|
return nil, fmt.Errorf("no key for kid %q", kid)
|
|
}
|
|
|
|
func (j *jwks) lookup(kid string) interface{} {
|
|
j.mu.RLock()
|
|
defer j.mu.RUnlock()
|
|
return j.keys[kid]
|
|
}
|
|
|
|
func (j *jwks) empty() bool {
|
|
j.mu.RLock()
|
|
defer j.mu.RUnlock()
|
|
return len(j.keys) == 0
|
|
}
|
|
|
|
func (j *jwks) refresh(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, j.uri, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := j.hc.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("jwks %s: status %d", j.uri, res.StatusCode)
|
|
}
|
|
var doc struct {
|
|
Keys []jwk `json:"keys"`
|
|
}
|
|
if err := json.NewDecoder(res.Body).Decode(&doc); err != nil {
|
|
return err
|
|
}
|
|
parsed := make(map[string]interface{}, len(doc.Keys))
|
|
for _, k := range doc.Keys {
|
|
pub, err := k.publicKey()
|
|
if err != nil || pub == nil {
|
|
continue // skip keys we can't parse rather than failing the whole set
|
|
}
|
|
parsed[k.Kid] = pub
|
|
}
|
|
if len(parsed) == 0 {
|
|
return fmt.Errorf("jwks %s: no usable keys", j.uri)
|
|
}
|
|
j.mu.Lock()
|
|
j.keys = parsed
|
|
j.fetchedAt = time.Now()
|
|
j.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (k jwk) publicKey() (interface{}, error) {
|
|
switch k.Kty {
|
|
case "RSA":
|
|
nb, err := b64uint(k.N)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
eb, err := base64.RawURLEncoding.DecodeString(k.E)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Right-align the exponent into 8 bytes for a uint64.
|
|
var ebuf [8]byte
|
|
copy(ebuf[8-len(eb):], eb)
|
|
return &rsa.PublicKey{N: nb, E: int(binary.BigEndian.Uint64(ebuf[:]))}, nil
|
|
case "EC":
|
|
var curve elliptic.Curve
|
|
switch k.Crv {
|
|
case "P-256":
|
|
curve = elliptic.P256()
|
|
case "P-384":
|
|
curve = elliptic.P384()
|
|
case "P-521":
|
|
curve = elliptic.P521()
|
|
default:
|
|
return nil, fmt.Errorf("unsupported EC curve %q", k.Crv)
|
|
}
|
|
xb, err := b64uint(k.X)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
yb, err := b64uint(k.Y)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ecdsa.PublicKey{Curve: curve, X: xb, Y: yb}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported kty %q", k.Kty)
|
|
}
|
|
}
|
|
|
|
func b64uint(s string) (*big.Int, error) {
|
|
b, err := base64.RawURLEncoding.DecodeString(s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return new(big.Int).SetBytes(b), nil
|
|
}
|