Files
zeekay 414daf6d87 feat(backend): App(Wallet) MPC custody server (Go, no plaintext keys)
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.
2026-06-23 15:07:40 -07:00

72 lines
2.1 KiB
Go

// Package store persists the wallet↔org mapping and the wallet's public
// addresses. It NEVER stores a private key (custody is MPC). Org scoping is
// enforced here: a wallet read/list is always filtered by org, so one tenant
// can never see another's wallets.
package store
import (
"context"
"errors"
"sync"
"github.com/luxfi/wallet/backend/internal/custody"
)
var ErrNotFound = errors.New("store: wallet not found")
// Store is the wallet record store. All methods are org-scoped.
type Store interface {
// Put records a wallet under an org. Idempotent on WalletID within the org.
Put(ctx context.Context, org string, w *custody.Wallet) error
// Get returns the org's wallet by id, or ErrNotFound (also for a wallet
// owned by a different org — no cross-org existence disclosure).
Get(ctx context.Context, org, walletID string) (*custody.Wallet, error)
// List returns all wallets for an org.
List(ctx context.Context, org string) ([]*custody.Wallet, error)
}
// Memory is an in-process Store. Production swaps in a Postgres-backed Store
// behind the same interface; the API is indifferent. Records hold public data
// only — there is no key field to leak.
type Memory struct {
mu sync.RWMutex
// org → walletID → wallet
byOrg map[string]map[string]*custody.Wallet
}
func NewMemory() *Memory {
return &Memory{byOrg: map[string]map[string]*custody.Wallet{}}
}
func (m *Memory) Put(_ context.Context, org string, w *custody.Wallet) error {
m.mu.Lock()
defer m.mu.Unlock()
wallets := m.byOrg[org]
if wallets == nil {
wallets = map[string]*custody.Wallet{}
m.byOrg[org] = wallets
}
wallets[w.WalletID] = w
return nil
}
func (m *Memory) Get(_ context.Context, org, walletID string) (*custody.Wallet, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if w, ok := m.byOrg[org][walletID]; ok {
return w, nil
}
return nil, ErrNotFound
}
func (m *Memory) List(_ context.Context, org string) ([]*custody.Wallet, error) {
m.mu.RLock()
defer m.mu.RUnlock()
wallets := m.byOrg[org]
out := make([]*custody.Wallet, 0, len(wallets))
for _, w := range wallets {
out = append(out, w)
}
return out, nil
}