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.
174 lines
4.6 KiB
Go
174 lines
4.6 KiB
Go
package iam
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// testIDP stands up a fake lux.id: a discovery doc + JWKS for one RSA key, and
|
|
// mints tokens with that key.
|
|
type testIDP struct {
|
|
srv *httptest.Server
|
|
key *rsa.PrivateKey
|
|
kid string
|
|
issuer string
|
|
}
|
|
|
|
func newTestIDP(t *testing.T) *testIDP {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
idp := &testIDP{key: key, kid: "test-kid-1"}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
|
|
json.NewEncoder(w).Encode(map[string]string{"jwks_uri": idp.issuer + "/jwks"})
|
|
})
|
|
mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
|
|
pub := key.PublicKey
|
|
eb := big.NewInt(int64(pub.E)).Bytes()
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"keys": []map[string]string{{
|
|
"kty": "RSA",
|
|
"kid": idp.kid,
|
|
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
|
"e": base64.RawURLEncoding.EncodeToString(eb),
|
|
}},
|
|
})
|
|
})
|
|
idp.srv = httptest.NewServer(mux)
|
|
idp.issuer = idp.srv.URL
|
|
return idp
|
|
}
|
|
|
|
func (idp *testIDP) token(t *testing.T, claims Claims) string {
|
|
t.Helper()
|
|
if claims.Issuer == "" {
|
|
claims.Issuer = idp.issuer
|
|
}
|
|
if len(claims.Audience) == 0 {
|
|
claims.Audience = jwt.ClaimStrings{"lux-wallet"}
|
|
}
|
|
if claims.ExpiresAt == nil {
|
|
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour))
|
|
}
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
tok.Header["kid"] = idp.kid
|
|
s, err := tok.SignedString(idp.key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func newVerifier(t *testing.T, idp *testIDP) *Verifier {
|
|
t.Helper()
|
|
v, err := New(context.Background(), idp.issuer, "lux-wallet", http.DefaultClient)
|
|
if err != nil {
|
|
t.Fatalf("New verifier: %v", err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestVerify_ValidToken_ResolvesOrgFromOwner(t *testing.T) {
|
|
idp := newTestIDP(t)
|
|
defer idp.srv.Close()
|
|
v := newVerifier(t, idp)
|
|
|
|
raw := idp.token(t, Claims{
|
|
Owner: "acme",
|
|
Email: "[email protected]",
|
|
RegisteredClaims: jwt.RegisteredClaims{Subject: "user-1"},
|
|
})
|
|
id, err := v.Verify(context.Background(), raw)
|
|
if err != nil {
|
|
t.Fatalf("Verify: %v", err)
|
|
}
|
|
if id.Org != "acme" {
|
|
t.Errorf("org = %q, want acme", id.Org)
|
|
}
|
|
if id.Subject != "user-1" || id.Email != "[email protected]" {
|
|
t.Errorf("identity = %+v", id)
|
|
}
|
|
}
|
|
|
|
func TestVerify_NoOrgClaim_Rejected(t *testing.T) {
|
|
idp := newTestIDP(t)
|
|
defer idp.srv.Close()
|
|
v := newVerifier(t, idp)
|
|
|
|
raw := idp.token(t, Claims{RegisteredClaims: jwt.RegisteredClaims{Subject: "user-1"}})
|
|
if _, err := v.Verify(context.Background(), raw); err != ErrNoOrg {
|
|
t.Errorf("err = %v, want ErrNoOrg", err)
|
|
}
|
|
}
|
|
|
|
func TestVerify_WrongAudience_Rejected(t *testing.T) {
|
|
idp := newTestIDP(t)
|
|
defer idp.srv.Close()
|
|
v := newVerifier(t, idp)
|
|
|
|
raw := idp.token(t, Claims{
|
|
Owner: "acme",
|
|
RegisteredClaims: jwt.RegisteredClaims{Subject: "u", Audience: jwt.ClaimStrings{"some-other-app"}},
|
|
})
|
|
if _, err := v.Verify(context.Background(), raw); err == nil {
|
|
t.Error("expected rejection for wrong audience")
|
|
}
|
|
}
|
|
|
|
func TestVerify_Expired_Rejected(t *testing.T) {
|
|
idp := newTestIDP(t)
|
|
defer idp.srv.Close()
|
|
v := newVerifier(t, idp)
|
|
|
|
raw := idp.token(t, Claims{
|
|
Owner: "acme",
|
|
RegisteredClaims: jwt.RegisteredClaims{Subject: "u", ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour))},
|
|
})
|
|
if _, err := v.Verify(context.Background(), raw); err == nil {
|
|
t.Error("expected rejection for expired token")
|
|
}
|
|
}
|
|
|
|
func TestVerify_HMACToken_Rejected(t *testing.T) {
|
|
// An attacker presenting an HS256 token (alg confusion) must be refused —
|
|
// we only accept asymmetric methods.
|
|
idp := newTestIDP(t)
|
|
defer idp.srv.Close()
|
|
v := newVerifier(t, idp)
|
|
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
|
Owner: "acme",
|
|
RegisteredClaims: jwt.RegisteredClaims{Issuer: idp.issuer, Audience: jwt.ClaimStrings{"lux-wallet"}, Subject: "u", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
|
|
})
|
|
tok.Header["kid"] = idp.kid
|
|
raw, _ := tok.SignedString([]byte("guessed-secret"))
|
|
if _, err := v.Verify(context.Background(), raw); err == nil {
|
|
t.Error("expected rejection for HMAC-signed token (alg confusion)")
|
|
}
|
|
}
|
|
|
|
func TestBearerFromHeader(t *testing.T) {
|
|
h := http.Header{}
|
|
if _, err := BearerFromHeader(h); err != ErrNoToken {
|
|
t.Errorf("empty: err = %v", err)
|
|
}
|
|
h.Set("Authorization", "Bearer abc.def.ghi")
|
|
got, err := BearerFromHeader(h)
|
|
if err != nil || got != "abc.def.ghi" {
|
|
t.Errorf("got %q, %v", got, err)
|
|
}
|
|
}
|