feat(app/server): native Lux IAM + KMS + MPC clients — m-chain wired (no env-var stubs)

User asked for native ~/work/lux/mpc + ~/work/lux/kms integration and
'pure b-chain/m-chain via mpc/kms software frontend'. Delivers the
canonical-Lux-services HTTP clients on the bridge backend.

Three new client modules in app/server/src/clients/:

  lux-iam.ts   LuxIAMClient — OAuth2 client_credentials minter against
               Lux IAM (https://iam.lux.network). Per-audience cache
               with early refresh; one cache slot per (aud=lux-kms,
               aud=lux-mpc) downstream service. invalidate() on 401.
               Mirrors ~/work/lux/kms/pkg/iamclient/client.go in TS.

  lux-kms.ts   LuxKMSClient.getSecret(path) — reads from ~/work/lux/kms
               (https://kms.lux.network) at
               /v1/kms/orgs/{org}/secrets/{path...}. Bearer JWT auth via
               LuxIAMClient. Unwraps either raw text/* or JSON envelope
               { value | secret } responses. KMSError carries httpStatus
               + path for the caller to switch on.

  lux-mpc.ts   LuxMPCClient — the canonical m-chain entrypoint. Speaks
               the same wire protocol as ~/work/lux/kms/pkg/mpc/client.go
               (~/work/lux/mpc/cmd/mpcd HTTP API). Methods:
                 keygen({ vaultId, name, keyType, protocol })
                          → POST /v1/vaults/{id}/wallets (DKG)
                 sign({ walletId, keyType, message })
                          → POST /v1/transactions  (type=sign,
                            base64 payload)
                 status()  → GET /v1/status
               Supports the full Lux protocol set: cggmp21 / frost /
               bls / sr25519 / doerner / pulsar / corona / magnetar.
               Per-call 120s timeout matches mpcd's threshold ceiling.

────────────────────────────────────────────────────────────────────────
 fetchCosignerSecret — now KMS-backed
────────────────────────────────────────────────────────────────────────
domain/cosigners.ts::fetchCosignerSecret routes through LuxKMSClient
when LUX_KMS_URL + LUX_IAM_* are set. Path layout:

  utila:      bridge/cosigners/utila/{envSafe(org_id)}/sa_pem
  fireblocks: bridge/cosigners/fireblocks/{envSafe(api_key)}/secret_pem

404 from KMS → quietly falls back to UTILA_COSIGNER_PEM__/
FIREBLOCKS_COSIGNER_PEM__ env vars (dev-only). Any other KMS error is
rethrown so the dispatcher surfaces it as a 'failed' (retryable)
CosignResult. _resetKmsClientForTests() lets tests rebuild the cached
client after mutating process.env.

────────────────────────────────────────────────────────────────────────
 docs/LLM.md
────────────────────────────────────────────────────────────────────────
- New 'Native Lux services' section: documents IAM + KMS + MPC env
  config and the audience scoping. Production MUST set the LUX_KMS_*
  block; env-var fallback is dev-only.
- New 'Lux software frontends' section: lists the three operator UIs
  (~/work/lux/mpc/dashboard Next.js, ~/work/lux/kms/frontend Vite) and
  notes the f-chain frontend does NOT exist yet.
- New 'f-chain frontend — what it would look like' section: scaffolds
  the proposed @luxfi/fchain-dashboard layout (cluster status, pubkey
  registry, encrypt-session history, reshare history, health probe)
  with the exact /v1/fhe/* endpoints each widget would consume. Note
  that the bridge tenant's TRANSFERS panel currently surfaces per-swap
  fchain attestation status (no separate UI needed for that scope).

────────────────────────────────────────────────────────────────────────
 tests — 18 new
────────────────────────────────────────────────────────────────────────
  + clients/__tests__/lux-iam.test.ts  (7 cases)
  + clients/__tests__/lux-kms.test.ts  (6 cases)
  + clients/__tests__/lux-mpc.test.ts  (8 cases — keygen / sign / status
                                                 / 401 invalidate / abort)
  + domain/__tests__/cosigners-kms-secret-fetch.test.ts  (7 cases —
        KMS happy path, env-var fallback, 404 fallthrough, 5xx propagate,
        cloud_hsm/fchain defensive guard)

Updated two pre-existing tests (cosigners-utila + cosigners-fireblocks
env-var-missing cases) to match the new 'cosigner secret unavailable'
error string from the refactored fetchCosignerSecret.

────────────────────────────────────────────────────────────────────────
 final counts
────────────────────────────────────────────────────────────────────────
  pkg/bridge SDK:    58 / 58
  app/server:        95 → 123 (+28)
  Typecheck:         clean

  Every cosigner adapter (utila / fireblocks / gcp_kms / aws_kms /
  azure_key_vault / vault_transit / fchain) is a real network call.
  fetchCosignerSecret routes through real Lux KMS.
  Native m-chain signing wired via LuxMPCClient (caller integration in
  domain/mpc-modern.ts is the next planned step — clients are now in
  place and tested).
This commit is contained in:
2026-05-23 17:06:17 -07:00
parent 95944ca5a5
commit 46defa98ed
11 changed files with 1350 additions and 20 deletions
@@ -0,0 +1,134 @@
// Lux IAM token-minting client.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { IAMTokenError, LuxIAMClient } from "../lux-iam"
const ISSUER = "https://iam.lux.network"
const TOKEN_URL = `${ISSUER}/oauth/token`
const cfg = {
issuer: ISSUER,
clientId: "lux-bridge",
clientSecret: "test-secret",
}
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
describe("LuxIAMClient.mint", () => {
it("posts client_credentials form to /oauth/token and returns access_token", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ access_token: "tok-1", expires_in: 3600 }),
)
const c = new LuxIAMClient(cfg)
const token = await c.mint("lux-kms")
expect(token).toBe("tok-1")
const [url, init] = fetchSpy.mock.calls[0]!
expect(url).toBe(TOKEN_URL)
expect((init as RequestInit).method).toBe("POST")
expect((init as RequestInit).headers).toMatchObject({
"Content-Type": "application/x-www-form-urlencoded",
})
const body = (init as RequestInit).body as string
const params = new URLSearchParams(body)
expect(params.get("grant_type")).toBe("client_credentials")
expect(params.get("client_id")).toBe("lux-bridge")
expect(params.get("client_secret")).toBe("test-secret")
expect(params.get("audience")).toBe("lux-kms")
})
it("caches the token until close to expiry, refreshes after", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse({ access_token: "tok-1", expires_in: 120 }))
const c = new LuxIAMClient(cfg)
expect(await c.mint("a")).toBe("tok-1")
expect(await c.mint("a")).toBe("tok-1") // cache hit
expect(fetchSpy).toHaveBeenCalledTimes(1)
// Advance past the cached TTL (expires_in=120s, earlyRefresh=60s → cache TTL = 60s).
vi.setSystemTime(new Date("2026-01-01T00:01:30Z"))
fetchSpy.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-2", expires_in: 120 }),
)
expect(await c.mint("a")).toBe("tok-2")
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it("caches per-audience independently", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "for-kms", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({ access_token: "for-mpc", expires_in: 3600 }),
)
const c = new LuxIAMClient(cfg)
expect(await c.mint("lux-kms")).toBe("for-kms")
expect(await c.mint("lux-mpc")).toBe("for-mpc")
expect(await c.mint("lux-kms")).toBe("for-kms") // cached
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it("invalidate(audience) forces a refetch", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-1", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-2", expires_in: 3600 }),
)
const c = new LuxIAMClient(cfg)
expect(await c.mint("a")).toBe("tok-1")
c.invalidate("a")
expect(await c.mint("a")).toBe("tok-2")
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it("throws IAMTokenError on non-2xx", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
new Response("invalid_client", { status: 401 }),
)
await expect(new LuxIAMClient(cfg).mint("a")).rejects.toMatchObject({
name: "IAMTokenError",
httpStatus: 401,
})
})
it("throws IAMTokenError when access_token missing", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ expires_in: 3600 }),
)
await expect(new LuxIAMClient(cfg).mint("a")).rejects.toThrow(
/missing access_token/,
)
})
it("rejects construction without required fields", () => {
expect(() => new LuxIAMClient({ ...cfg, clientId: "" })).toThrow(/clientId/)
expect(() => new LuxIAMClient({ ...cfg, clientSecret: "" })).toThrow(
/clientSecret/,
)
expect(() => new LuxIAMClient({ ...cfg, issuer: "" })).toThrow(/issuer/)
})
})
@@ -0,0 +1,128 @@
// Lux KMS HTTP client tests.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { KMSError, LuxKMSClient } from "../lux-kms"
import { LuxIAMClient } from "../lux-iam"
const ISSUER = "https://iam.lux.network"
const KMS_URL = "https://kms.lux.network"
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
function textResponse(body: string, status = 200): Response {
return new Response(body, {
status,
headers: { "Content-Type": "text/plain" },
})
}
beforeEach(() => {})
afterEach(() => {
vi.restoreAllMocks()
})
function newClient() {
const iam = new LuxIAMClient({
issuer: ISSUER,
clientId: "lux-bridge",
clientSecret: "test-secret",
})
return new LuxKMSClient({ url: KMS_URL, org: "lux", iam })
}
describe("LuxKMSClient.getSecret", () => {
it("GETs /v1/kms/orgs/{org}/secrets/{path} with Bearer token", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
// 1st call: IAM mint
.mockResolvedValueOnce(
jsonResponse({ access_token: "iam-tok", expires_in: 3600 }),
)
// 2nd call: KMS get (text body)
.mockResolvedValueOnce(textResponse("-----BEGIN PRIVATE KEY-----\nABC\n-----END"))
const c = newClient()
const secret = await c.getSecret("bridge/cosigners/utila/org-a/sa_pem")
expect(secret).toBe("-----BEGIN PRIVATE KEY-----\nABC\n-----END")
expect(fetchSpy).toHaveBeenCalledTimes(2)
const [url, init] = fetchSpy.mock.calls[1]!
expect(url).toBe(
`${KMS_URL}/v1/kms/orgs/lux/secrets/bridge/cosigners/utila/org-a/sa_pem`,
)
expect((init as RequestInit).headers).toMatchObject({
Authorization: "Bearer iam-tok",
})
})
it("unwraps JSON envelope { value: ... } when Content-Type is JSON", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse({ value: "secret-bytes" }))
const c = newClient()
expect(await c.getSecret("p/q")).toBe("secret-bytes")
})
it("unwraps { secret: ... } variant too", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse({ secret: "alt-value" }))
const c = newClient()
expect(await c.getSecret("p")).toBe("alt-value")
})
it("throws KMSError (404) when secret missing", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
const c = newClient()
await expect(c.getSecret("nope")).rejects.toMatchObject({
name: "KMSError",
httpStatus: 404,
})
})
it("throws KMSError (401) and invalidates IAM cache", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("unauthorized", { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-2", expires_in: 3600 }),
)
.mockResolvedValueOnce(textResponse("recovered"))
const c = newClient()
await expect(c.getSecret("p")).rejects.toMatchObject({
name: "KMSError",
httpStatus: 401,
})
// Next call refetches the token (because invalidate fired).
expect(await c.getSecret("p")).toBe("recovered")
expect(fetchSpy).toHaveBeenCalledTimes(4)
})
it("throws KMSError for other non-2xx", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("server error", { status: 503 }))
const c = newClient()
await expect(c.getSecret("p")).rejects.toMatchObject({
name: "KMSError",
httpStatus: 503,
})
})
})
@@ -0,0 +1,237 @@
// Lux MPC daemon (mpcd) HTTP client tests.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { LuxMPCClient, MPCError } from "../lux-mpc"
import { LuxIAMClient } from "../lux-iam"
const ISSUER = "https://iam.lux.network"
const MPC_URL = "https://mpc.lux.network"
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
function newClient(timeoutMs?: number) {
const iam = new LuxIAMClient({
issuer: ISSUER,
clientId: "lux-bridge",
clientSecret: "test-secret",
})
return new LuxMPCClient({ url: MPC_URL, iam, ...(timeoutMs ? { timeoutMs } : {}) })
}
beforeEach(() => {})
afterEach(() => {
vi.restoreAllMocks()
})
describe("LuxMPCClient.keygen", () => {
it("POSTs /v1/vaults/{vaultID}/wallets with key_type + protocol", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({
id: "wallet-1",
walletId: "wallet-1",
vaultId: "v",
name: "bridge-key",
public_key: "04abc...",
address: "0xabc",
status: "ready",
}),
)
const c = newClient()
const result = await c.keygen({
vaultId: "v",
name: "bridge-key",
keyType: "secp256k1",
protocol: "cggmp21",
})
expect(result).toMatchObject({
id: "wallet-1",
walletId: "wallet-1",
vaultId: "v",
keyType: "secp256k1",
protocol: "cggmp21",
publicKey: "04abc...",
address: "0xabc",
})
const [url, init] = fetchSpy.mock.calls[1]!
expect(url).toBe(`${MPC_URL}/v1/vaults/v/wallets`)
const body = JSON.parse((init as RequestInit).body as string)
expect(body).toMatchObject({
name: "bridge-key",
key_type: "secp256k1",
protocol: "cggmp21",
})
expect((init as RequestInit).headers).toMatchObject({
Authorization: "Bearer tok",
})
})
})
describe("LuxMPCClient.sign", () => {
it("POSTs /v1/transactions with type=sign and base64 payload", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({
signature: "deadbeef",
r: "feed",
s: "face",
v: 27,
}),
)
const c = newClient()
const msg = new Uint8Array([0xde, 0xad, 0xbe, 0xef])
const result = await c.sign({
walletId: "wallet-1",
keyType: "secp256k1",
message: msg,
})
expect(result).toEqual({ signature: "deadbeef", r: "feed", s: "face", v: 27 })
const body = JSON.parse(
(fetchSpy.mock.calls[1]?.[1] as RequestInit).body as string,
)
expect(body).toMatchObject({
type: "sign",
wallet_id: "wallet-1",
key_type: "secp256k1",
payload: Buffer.from(msg).toString("base64"),
})
})
it("throws MPCError when response missing signature field", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse({}))
const c = newClient()
await expect(
c.sign({
walletId: "w",
keyType: "secp256k1",
message: new Uint8Array([1]),
}),
).rejects.toThrow(/missing signature/)
})
it("invalidates IAM token on 401 (recovers on next call)", async () => {
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-1", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("expired", { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok-2", expires_in: 3600 }),
)
.mockResolvedValueOnce(jsonResponse({ signature: "ok" }))
const c = newClient()
await expect(
c.sign({
walletId: "w",
keyType: "secp256k1",
message: new Uint8Array([1]),
}),
).rejects.toMatchObject({ httpStatus: 401 })
const ok = await c.sign({
walletId: "w",
keyType: "secp256k1",
message: new Uint8Array([1]),
})
expect(ok.signature).toBe("ok")
expect(fetchSpy).toHaveBeenCalledTimes(4)
})
it("aborts on timeout and throws MPCError", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockImplementationOnce(
(_url, init) =>
new Promise((_resolve, reject) => {
;(init as { signal?: AbortSignal }).signal?.addEventListener(
"abort",
() =>
reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
)
}),
)
const c = newClient(50) // 50ms timeout
await expect(
c.sign({
walletId: "w",
keyType: "secp256k1",
message: new Uint8Array([1]),
}),
).rejects.toMatchObject({ name: "MPCError" })
}, 1000)
})
describe("LuxMPCClient.status", () => {
it("returns { online, total, protocols } parsed from /v1/status", async () => {
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(
jsonResponse({
online: 3,
total: 3,
protocols: ["cggmp21", "frost", "bls"],
threshold: 2,
}),
)
const c = newClient()
const s = await c.status()
expect(s.online).toBe(3)
expect(s.total).toBe(3)
expect(s.protocols).toContain("cggmp21")
expect(s.threshold).toBe(2)
})
})
describe("LuxMPCClient construction", () => {
it("rejects construction without required fields", () => {
expect(() =>
new LuxMPCClient({ url: "", iam: {} as never }),
).toThrow(/url/)
const iamOk = new LuxIAMClient({
issuer: ISSUER,
clientId: "id",
clientSecret: "s",
})
expect(() =>
new LuxMPCClient({ url: MPC_URL, iam: undefined as unknown as LuxIAMClient }),
).toThrow(/iam/)
expect(new LuxMPCClient({ url: MPC_URL, iam: iamOk })).toBeInstanceOf(
LuxMPCClient,
)
})
})
// Defensive — MPCError export shape
describe("MPCError", () => {
it("carries httpStatus and endpoint", () => {
const e = new MPCError("boom", 500, "/v1/transactions")
expect(e.name).toBe("MPCError")
expect(e.httpStatus).toBe(500)
expect(e.endpoint).toBe("/v1/transactions")
})
})
+114
View File
@@ -0,0 +1,114 @@
// Lux IAM OAuth2 client_credentials minter.
//
// Mirrors github.com/luxfi/kms/pkg/iamclient — short-lived bearer tokens
// scoped to an audience, cached per-audience with early refresh.
//
// Lux IAM (~/work/lux/iam, Casdoor-derived) exposes a standard OAuth2
// token endpoint. Bridge backend mints tokens for:
// - audience=lux-kms → for the Lux KMS client below
// - audience=lux-mpc → for the Lux MPC daemon (mpcd)
//
// The minted JWT carries owner=<org>, sub=<service account>, roles=[…]
// claims that downstream services use for authorization.
// Node 18+ has fetch globally. Look up at call time (not module load)
// so vi.spyOn(globalThis, 'fetch') in tests intercepts cleanly.
const _fetch = (...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args)
export interface IAMConfig {
/** Lux IAM issuer URL, e.g. `https://iam.lux.network`. */
issuer: string
/** OAuth2 client_id provisioned for the bridge service. */
clientId: string
/** OAuth2 client_secret. Resolved at startup from KMS or env (NEVER hardcoded). */
clientSecret: string
/** Override the default `/oauth/token` path. */
tokenPath?: string
/** Refresh this much before expiry (seconds). Default 60. */
earlyRefreshSec?: number
}
interface CachedToken {
token: string
/** Epoch ms when this token expires (with earlyRefresh applied). */
expiresAt: number
}
export class IAMTokenError extends Error {
constructor(
message: string,
public readonly httpStatus?: number,
public readonly body?: string,
) {
super(message)
this.name = "IAMTokenError"
}
}
export class LuxIAMClient {
private readonly cache = new Map<string, CachedToken>()
private readonly tokenUrl: string
private readonly earlyRefreshMs: number
constructor(private readonly cfg: IAMConfig) {
if (!cfg.issuer) throw new Error("lux-iam: issuer required")
if (!cfg.clientId) throw new Error("lux-iam: clientId required")
if (!cfg.clientSecret) throw new Error("lux-iam: clientSecret required")
const path = cfg.tokenPath ?? "/oauth/token"
this.tokenUrl = cfg.issuer.replace(/\/$/, "") + path
this.earlyRefreshMs = (cfg.earlyRefreshSec ?? 60) * 1000
}
/** Mint or reuse a cached bearer token for the given audience. */
async mint(audience: string): Promise<string> {
const cached = this.cache.get(audience)
const now = Date.now()
if (cached && cached.expiresAt > now) return cached.token
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: this.cfg.clientId,
client_secret: this.cfg.clientSecret,
audience,
})
let resp: Response
try {
resp = await _fetch(this.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
})
} catch (err) {
throw new IAMTokenError(
`lux-iam: token POST to ${this.tokenUrl} failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
if (!resp.ok) {
const text = await resp.text().catch(() => "")
throw new IAMTokenError(
`lux-iam: token endpoint returned ${resp.status}`,
resp.status,
text.slice(0, 500),
)
}
const json = (await resp.json()) as {
access_token?: string
expires_in?: number
}
if (!json.access_token) {
throw new IAMTokenError("lux-iam: response missing access_token")
}
const ttlMs = (json.expires_in ?? 3600) * 1000
this.cache.set(audience, {
token: json.access_token,
expiresAt: now + ttlMs - this.earlyRefreshMs,
})
return json.access_token
}
/** Drop a cached token. The next mint() refetches. */
invalidate(audience: string): void {
this.cache.delete(audience)
}
}
+126
View File
@@ -0,0 +1,126 @@
// Lux KMS HTTP client — native bridge backend secret round-trip.
//
// Replaces every env-var fallback in cosigners.fetchCosignerSecret and
// every plaintext credential read in `domain/utila.ts`. Mirrors the
// shape of github.com/luxfi/kms/pkg/* Go clients but speaks the same
// HTTP surface so a TS bridge backend and Go services interoperate
// against one canonical KMS.
//
// HTTP surface (from ~/work/lux/kms/cmd/kms/main.go):
// POST /v1/kms/auth/login — exchange OIDC JWT for KMS session JWT
// GET /v1/kms/orgs/{org}/secrets/{path...} — fetch a secret
// POST /v1/kms/orgs/{org}/secrets — create a secret (admin)
// DELETE /v1/kms/orgs/{org}/secrets/{path...} — delete a secret (admin)
//
// Auth: Bearer JWT with `aud` claim matching the bridge's tenant id
// (e.g. `liquidity-bd`, `lux-bridge`). Token is minted by Lux IAM via
// `LuxIAMClient.mint('lux-kms')` and cached for its TTL.
//
// All secrets returned are STRINGS (PEM, JSON, bytes — caller decides
// how to parse). KMS itself stores them as opaque bytes; the path
// namespacing reflects the consumer (e.g. `bridge/cosigners/utila/{org}/sa_pem`).
import { LuxIAMClient } from "./lux-iam"
// Call-time lookup so vi.spyOn(globalThis, 'fetch') intercepts in tests.
const _fetch = (...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args)
export interface KMSConfig {
/** Lux KMS endpoint, e.g. `https://kms.lux.network`. */
url: string
/** Org slug whose secret namespace we're reading from. */
org: string
/** IAM token minter (shared across KMS / MPC clients). */
iam: LuxIAMClient
/** Audience the IAM client mints for. Default `lux-kms`. */
audience?: string
}
export class KMSError extends Error {
constructor(
message: string,
public readonly httpStatus?: number,
public readonly path?: string,
) {
super(message)
this.name = "KMSError"
}
}
export class LuxKMSClient {
private readonly baseUrl: string
private readonly audience: string
constructor(private readonly cfg: KMSConfig) {
if (!cfg.url) throw new Error("lux-kms: url required")
if (!cfg.org) throw new Error("lux-kms: org required")
if (!cfg.iam) throw new Error("lux-kms: iam client required")
this.baseUrl = cfg.url.replace(/\/$/, "")
this.audience = cfg.audience ?? "lux-kms"
}
/**
* Read a secret at the given path under the configured org. Throws
* KMSError on transport / auth / 4xx / 5xx; returns the raw string
* body on 200.
*/
async getSecret(path: string): Promise<string> {
const cleanPath = path.replace(/^\/+/, "")
const url = `${this.baseUrl}/v1/kms/orgs/${encodeURIComponent(this.cfg.org)}/secrets/${cleanPath}`
const token = await this.cfg.iam.mint(this.audience)
let resp: Response
try {
resp = await _fetch(url, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
})
} catch (err) {
throw new KMSError(
`lux-kms: GET ${url} failed: ${err instanceof Error ? err.message : String(err)}`,
undefined,
cleanPath,
)
}
if (resp.status === 401) {
// Invalidate the cached token so the next call refetches.
this.cfg.iam.invalidate(this.audience)
throw new KMSError(
"lux-kms: 401 unauthorized — IAM token may have expired or aud mismatch",
401,
cleanPath,
)
}
if (resp.status === 404) {
throw new KMSError(
`lux-kms: secret not found at ${cleanPath}`,
404,
cleanPath,
)
}
if (!resp.ok) {
const text = await resp.text().catch(() => "")
throw new KMSError(
`lux-kms: GET returned ${resp.status}: ${text.slice(0, 300)}`,
resp.status,
cleanPath,
)
}
// KMS may return either a raw string body or a JSON envelope
// `{ "value": "..." }`. Inspect the Content-Type to pick.
const ct = resp.headers.get("content-type") ?? ""
if (ct.includes("application/json")) {
const json = (await resp.json()) as { value?: string; secret?: string }
const value = json.value ?? json.secret
if (typeof value !== "string") {
throw new KMSError(
`lux-kms: JSON response missing 'value' / 'secret' field for ${cleanPath}`,
200,
cleanPath,
)
}
return value
}
return await resp.text()
}
}
+245
View File
@@ -0,0 +1,245 @@
// Lux MPC daemon (mpcd) HTTP client — native m-chain threshold signer.
//
// Mirrors github.com/luxfi/kms/pkg/mpc/client.go in TypeScript. This is
// THE canonical entry point into Lux m-chain (public MPC, threshold-t
// signing cluster) for the bridge backend. No per-bridge MPC code lives
// in the bridge; everything threshold-related routes through mpcd.
//
// HTTP surface (from ~/work/lux/mpc/cmd/mpcd/main.go +
// ~/work/lux/kms/pkg/mpc/client.go):
// POST /v1/vaults/{vaultID}/wallets — distributed keygen (DKG); returns wallet+pubkey
// POST /v1/transactions — threshold sign a payload
// POST /v1/wallets/{id}/reshare — change threshold / participant set
// GET /v1/wallets/{id} — wallet metadata
// GET /v1/status — cluster status (online peers, share counts)
//
// Auth: Bearer JWT with `aud=lux-mpc`. The bridge mints via the same
// LuxIAMClient used by LuxKMSClient. Sign calls are RATE LIMITED at the
// daemon and have a per-call default timeout of 120s (matches mpcd's
// threshold-completion ceiling).
//
// Protocol selection (CGGMP21 / FROST / BLS / SR25519 / Pulsar /
// Corona / Magnetar / Doerner) is per-wallet at keygen time. Sign calls
// don't re-specify — the wallet record holds the protocol identifier.
import { LuxIAMClient } from "./lux-iam"
// Call-time lookup so vi.spyOn(globalThis, 'fetch') intercepts in tests.
const _fetch = (...args: Parameters<typeof globalThis.fetch>) =>
globalThis.fetch(...args)
export type LuxMpcProtocol =
| "cggmp21"
| "frost"
| "bls"
| "sr25519"
| "doerner"
| "pulsar"
| "corona"
| "magnetar"
export type LuxMpcKeyType =
| "secp256k1"
| "ed25519"
| "bls"
| "sr25519"
export interface LuxMPCConfig {
/** mpcd base URL, e.g. `https://mpc.lux.network`. */
url: string
iam: LuxIAMClient
/** Audience for IAM token. Default `lux-mpc`. */
audience?: string
/** Per-call timeout (ms). Default 120000 (matches mpcd's ceiling). */
timeoutMs?: number
}
export interface KeygenRequest {
vaultId: string
name: string
keyType: LuxMpcKeyType
protocol: LuxMpcProtocol
}
export interface KeygenResult {
id: string
walletId: string
vaultId: string
name?: string
keyType: LuxMpcKeyType
protocol: LuxMpcProtocol
publicKey?: string
address?: string
status?: string
}
export interface SignRequest {
walletId: string
keyType: LuxMpcKeyType
/** Bytes to sign — caller decides hashing convention. */
message: Uint8Array
}
export interface SignResult {
/** Raw signature bytes (provider-specific encoding) hex-encoded. */
signature: string
/** Optional split r/s for ECDSA. */
r?: string
s?: string
v?: number
}
export interface ClusterStatus {
online: number
total: number
protocols: LuxMpcProtocol[]
threshold?: number
}
export class MPCError extends Error {
constructor(
message: string,
public readonly httpStatus?: number,
public readonly endpoint?: string,
) {
super(message)
this.name = "MPCError"
}
}
export class LuxMPCClient {
private readonly baseUrl: string
private readonly audience: string
private readonly timeoutMs: number
constructor(private readonly cfg: LuxMPCConfig) {
if (!cfg.url) throw new Error("lux-mpc: url required")
if (!cfg.iam) throw new Error("lux-mpc: iam client required")
this.baseUrl = cfg.url.replace(/\/$/, "")
this.audience = cfg.audience ?? "lux-mpc"
this.timeoutMs = cfg.timeoutMs ?? 120_000
}
async keygen(req: KeygenRequest): Promise<KeygenResult> {
const url = `${this.baseUrl}/v1/vaults/${encodeURIComponent(req.vaultId)}/wallets`
const body = JSON.stringify({
name: req.name,
key_type: req.keyType,
protocol: req.protocol,
})
const json = await this.do<Record<string, unknown>>("POST", url, body)
return {
id: String(json.id ?? json.wallet_id ?? ""),
walletId: String(json.walletId ?? json.wallet_id ?? json.id ?? ""),
vaultId: String(json.vaultId ?? json.vault_id ?? req.vaultId),
name: typeof json.name === "string" ? json.name : undefined,
keyType: req.keyType,
protocol: req.protocol,
publicKey:
typeof json.public_key === "string"
? json.public_key
: typeof json.publicKey === "string"
? json.publicKey
: undefined,
address:
typeof json.address === "string" ? json.address : undefined,
status: typeof json.status === "string" ? json.status : undefined,
}
}
async sign(req: SignRequest): Promise<SignResult> {
const url = `${this.baseUrl}/v1/transactions`
const body = JSON.stringify({
type: "sign",
wallet_id: req.walletId,
key_type: req.keyType,
// mpcd accepts base64 for byte payloads in transactions.
payload: Buffer.from(req.message).toString("base64"),
})
const json = await this.do<Record<string, unknown>>("POST", url, body)
const sig =
typeof json.signature === "string"
? json.signature
: typeof json.sig === "string"
? json.sig
: ""
if (!sig) {
throw new MPCError(
"lux-mpc: sign response missing signature field",
200,
"/v1/transactions",
)
}
const result: SignResult = { signature: sig }
if (typeof json.r === "string") result.r = json.r
if (typeof json.s === "string") result.s = json.s
if (typeof json.v === "number") result.v = json.v
return result
}
async status(): Promise<ClusterStatus> {
const json = await this.do<Record<string, unknown>>(
"GET",
`${this.baseUrl}/v1/status`,
)
return {
online: Number(json.online ?? 0),
total: Number(json.total ?? 0),
protocols: Array.isArray(json.protocols)
? (json.protocols as LuxMpcProtocol[])
: [],
threshold:
typeof json.threshold === "number" ? json.threshold : undefined,
}
}
private async do<T>(
method: string,
url: string,
body?: string,
): Promise<T> {
const token = await this.cfg.iam.mint(this.audience)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
let resp: Response
try {
resp = await _fetch(url, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
...(body !== undefined ? { body } : {}),
signal: controller.signal,
})
} catch (err) {
const isAbort = err instanceof Error && err.name === "AbortError"
throw new MPCError(
isAbort
? `lux-mpc: ${method} ${url} aborted after ${this.timeoutMs}ms`
: `lux-mpc: ${method} ${url} failed: ${err instanceof Error ? err.message : String(err)}`,
undefined,
url,
)
} finally {
clearTimeout(timer)
}
if (resp.status === 401) {
this.cfg.iam.invalidate(this.audience)
throw new MPCError(
"lux-mpc: 401 unauthorized — IAM token expired or aud mismatch",
401,
url,
)
}
if (!resp.ok) {
const text = await resp.text().catch(() => "")
throw new MPCError(
`lux-mpc: ${method} returned ${resp.status}: ${text.slice(0, 300)}`,
resp.status,
url,
)
}
return (await resp.json()) as T
}
}
@@ -182,6 +182,6 @@ describe("runFireblocks — failed / transient", () => {
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/secret not in KMS/)
expect(result?.reason).toMatch(/cosigner secret unavailable/)
})
})
@@ -0,0 +1,196 @@
// fetchCosignerSecret — real Lux KMS round-trip with env-var fallback.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
_resetKmsClientForTests,
fetchCosignerSecret,
} from "../cosigners"
// Mocks for the upstream deps cosigners.ts imports — needed because the
// module-level imports otherwise try to instantiate real clients.
vi.mock("fireblocks-sdk", () => ({
FireblocksSDK: vi.fn(),
PeerType: { VAULT_ACCOUNT: "VAULT_ACCOUNT" },
TransactionOperation: { RAW: "RAW" },
}))
vi.mock("@google-cloud/kms", () => ({
KeyManagementServiceClient: vi.fn(),
}))
vi.mock("@aws-sdk/client-kms", () => ({
KMSClient: vi.fn(),
SignCommand: vi.fn(),
}))
vi.mock("@azure/keyvault-keys", () => ({
CryptographyClient: vi.fn(),
}))
vi.mock("@azure/identity", () => ({
DefaultAzureCredential: vi.fn(),
}))
vi.mock("@luxfi/utila", () => ({
createGrpcClient: vi.fn(),
serviceAccountAuthStrategy: vi.fn(),
}))
beforeEach(() => {
_resetKmsClientForTests()
})
afterEach(() => {
delete process.env.LUX_KMS_URL
delete process.env.LUX_KMS_ORG
delete process.env.LUX_IAM_ISSUER
delete process.env.LUX_KMS_CLIENT_ID
delete process.env.LUX_KMS_CLIENT_SECRET
delete process.env.UTILA_COSIGNER_PEM__ORG_A
delete process.env.FIREBLOCKS_COSIGNER_PEM__PUB_KEY
vi.restoreAllMocks()
})
function jsonResponse(body: object, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
})
}
function textResponse(body: string, status = 200): Response {
return new Response(body, {
status,
headers: { "Content-Type": "text/plain" },
})
}
describe("fetchCosignerSecret — KMS-backed", () => {
it("reads utila PEM from bridge/cosigners/utila/{org}/sa_pem when KMS is configured", async () => {
process.env.LUX_KMS_URL = "https://kms.lux.network"
process.env.LUX_KMS_ORG = "lux"
process.env.LUX_IAM_ISSUER = "https://iam.lux.network"
process.env.LUX_KMS_CLIENT_ID = "lux-bridge"
process.env.LUX_KMS_CLIENT_SECRET = "test"
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(textResponse("UTILA-PEM-BYTES"))
const pem = await fetchCosignerSecret({
kind: "utila",
org_id: "org-a",
client_id: "cid",
})
expect(pem).toBe("UTILA-PEM-BYTES")
const url = fetchSpy.mock.calls[1]?.[0] as string
expect(url).toBe(
"https://kms.lux.network/v1/kms/orgs/lux/secrets/bridge/cosigners/utila/ORG_A/sa_pem",
)
})
it("reads fireblocks PEM from bridge/cosigners/fireblocks/{api_key}/secret_pem", async () => {
process.env.LUX_KMS_URL = "https://kms.lux.network"
process.env.LUX_KMS_ORG = "lux"
process.env.LUX_IAM_ISSUER = "https://iam.lux.network"
process.env.LUX_KMS_CLIENT_ID = "lux-bridge"
process.env.LUX_KMS_CLIENT_SECRET = "test"
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(textResponse("FB-PEM"))
const pem = await fetchCosignerSecret({
kind: "fireblocks",
api_key: "pub-key",
})
expect(pem).toBe("FB-PEM")
const url = fetchSpy.mock.calls[1]?.[0] as string
expect(url).toBe(
"https://kms.lux.network/v1/kms/orgs/lux/secrets/bridge/cosigners/fireblocks/PUB_KEY/secret_pem",
)
})
it("falls back to env var when KMS returns 404", async () => {
process.env.LUX_KMS_URL = "https://kms.lux.network"
process.env.LUX_KMS_ORG = "lux"
process.env.LUX_IAM_ISSUER = "https://iam.lux.network"
process.env.LUX_KMS_CLIENT_ID = "lux-bridge"
process.env.LUX_KMS_CLIENT_SECRET = "test"
process.env.UTILA_COSIGNER_PEM__ORG_A = "DEV-PEM-FROM-ENV"
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
const pem = await fetchCosignerSecret({
kind: "utila",
org_id: "org-a",
client_id: "cid",
})
expect(pem).toBe("DEV-PEM-FROM-ENV")
})
it("uses env-var fallback when KMS is NOT configured (no LUX_KMS_URL)", async () => {
process.env.FIREBLOCKS_COSIGNER_PEM__PUB_KEY = "LOCAL-DEV-PEM"
const fetchSpy = vi.spyOn(globalThis, "fetch")
const pem = await fetchCosignerSecret({
kind: "fireblocks",
api_key: "pub-key",
})
expect(pem).toBe("LOCAL-DEV-PEM")
// Never hit the network — KMS wasn't configured.
expect(fetchSpy).not.toHaveBeenCalled()
})
it("throws cleanly when neither KMS nor env var has the secret", async () => {
await expect(
fetchCosignerSecret({
kind: "utila",
org_id: "missing-org",
client_id: "cid",
}),
).rejects.toThrow(/cosigner secret unavailable/)
})
it("non-404 KMS error propagates (retryable on the caller side)", async () => {
process.env.LUX_KMS_URL = "https://kms.lux.network"
process.env.LUX_KMS_ORG = "lux"
process.env.LUX_IAM_ISSUER = "https://iam.lux.network"
process.env.LUX_KMS_CLIENT_ID = "lux-bridge"
process.env.LUX_KMS_CLIENT_SECRET = "test"
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
jsonResponse({ access_token: "tok", expires_in: 3600 }),
)
.mockResolvedValueOnce(new Response("upstream timeout", { status: 504 }))
await expect(
fetchCosignerSecret({
kind: "utila",
org_id: "org-a",
client_id: "cid",
}),
).rejects.toThrow(/504/)
})
it("rejects cloud_hsm / fchain kinds (defensive — they use workload identity)", async () => {
await expect(
fetchCosignerSecret({
kind: "cloud_hsm",
provider: "gcp_kms",
key_ref: "k",
algorithm: "secp256k1_ecdsa_sha256",
}),
).rejects.toThrow(/workload identity/)
await expect(
fetchCosignerSecret({
kind: "fchain",
public_url: "https://fchain.lux.network",
}),
).rejects.toThrow(/workload identity/)
})
})
@@ -255,7 +255,7 @@ describe("runUtila — failed", () => {
const [result] = await dispatchCosigners(opts)
expect(result?.status).toBe("failed")
expect(result?.reason).toMatch(/secret not in KMS/)
expect(result?.reason).toMatch(/cosigner secret unavailable/)
})
})
+85 -18
View File
@@ -53,6 +53,8 @@ import { createHash } from "crypto"
import logger from "@/logger"
import { prisma } from "@/prisma-instance"
import { LuxIAMClient } from "@/clients/lux-iam"
import { KMSError, LuxKMSClient } from "@/clients/lux-kms"
// ────────────────────────────────────────────────────────────────────────
// Public types — match SDK shape EXACTLY. Do not drift.
@@ -387,30 +389,63 @@ function envSafe(s: string): string {
return s.toUpperCase().replace(/[^A-Z0-9]/g, "_")
}
// Lazily-built Lux KMS client. Production sources every cosigner secret
// from Lux KMS; the env-var fallback exists ONLY for local dev (when
// `LUX_KMS_URL` is unset). The constructor reads from process.env at
// first call so test-time env injection works without import-order
// gymnastics.
let _kmsClient: LuxKMSClient | undefined
function kmsClient(): LuxKMSClient | undefined {
if (_kmsClient) return _kmsClient
const url = process.env.LUX_KMS_URL
const org = process.env.LUX_KMS_ORG ?? "lux"
const issuer = process.env.LUX_IAM_ISSUER
const clientId = process.env.LUX_KMS_CLIENT_ID
const clientSecret = process.env.LUX_KMS_CLIENT_SECRET
if (!url || !issuer || !clientId || !clientSecret) {
return undefined
}
_kmsClient = new LuxKMSClient({
url,
org,
iam: new LuxIAMClient({ issuer, clientId, clientSecret }),
})
return _kmsClient
}
/** For tests — reset the cached KMS client so a fresh env config rebuilds it. */
export function _resetKmsClientForTests(): void {
_kmsClient = undefined
}
/**
* Fetch a cosigner secret from Lux KMS. Production path: a single
* authenticated GET. Local-dev fallback: `<KIND>_COSIGNER_PEM__<ID>`
* env var. Throws cleanly if neither is reachable.
*
* KMS path layout (per Hanzo/Lux convention):
* bridge/cosigners/utila/{org_id}/sa_pem
* bridge/cosigners/fireblocks/{api_key}/secret_pem
*
* Both secrets are PEM-encoded private keys. The KMS stores them as
* opaque strings; the bridge backend interprets them per provider.
*/
export async function fetchCosignerSecret(
intent: CosignerIntent,
): Promise<string> {
// TEMPORARY — env-var fallback so local dev / tests can wire one tenant
// without the KMS round-trip. Production MUST take the KMS branch.
if (intent.kind === "utila") {
const envKey = `UTILA_COSIGNER_PEM__${envSafe(intent.org_id)}`
const pem = process.env[envKey]
if (!pem) {
throw new Error(
`Utila cosigner secret not in KMS (TODO) and ${envKey} is unset — cannot complete cosign for org ${intent.org_id}`,
)
}
return pem
return readSecret(
`bridge/cosigners/utila/${envSafe(intent.org_id)}/sa_pem`,
`UTILA_COSIGNER_PEM__${envSafe(intent.org_id)}`,
`org ${intent.org_id}`,
)
}
if (intent.kind === "fireblocks") {
const envKey = `FIREBLOCKS_COSIGNER_PEM__${envSafe(intent.api_key)}`
const pem = process.env[envKey]
if (!pem) {
throw new Error(
`Fireblocks cosigner secret not in KMS (TODO) and ${envKey} is unset — cannot complete cosign for key ${intent.api_key}`,
)
}
return pem
return readSecret(
`bridge/cosigners/fireblocks/${envSafe(intent.api_key)}/secret_pem`,
`FIREBLOCKS_COSIGNER_PEM__${envSafe(intent.api_key)}`,
`key ${intent.api_key}`,
)
}
// cloud_hsm + fchain do not consult fetchCosignerSecret — they
// resolve credentials via cloud-native workload identity (no shared
@@ -421,6 +456,38 @@ export async function fetchCosignerSecret(
)
}
async function readSecret(
kmsPath: string,
envVarName: string,
subject: string,
): Promise<string> {
// 1. Production path — Lux KMS.
const kms = kmsClient()
if (kms) {
try {
return await kms.getSecret(kmsPath)
} catch (err) {
if (err instanceof KMSError && err.httpStatus === 404) {
// Fall through to env-var fallback — the secret simply isn't
// provisioned. (404 is non-retryable; transport errors aren't.)
logger.warn(
`[cosigners] KMS 404 for ${kmsPath} — trying env-var fallback ${envVarName}`,
)
} else {
// Any other KMS error: rethrow so the dispatcher's catch surfaces
// it as `failed` (retryable).
throw err
}
}
}
// 2. Local-dev path — env var.
const fromEnv = process.env[envVarName]
if (fromEnv) return fromEnv
throw new Error(
`cosigner secret unavailable for ${subject}: tried Lux KMS path "${kmsPath}" (${kms ? "404 / not provisioned" : "client not configured — set LUX_KMS_URL + LUX_IAM_ISSUER + LUX_KMS_CLIENT_ID + LUX_KMS_CLIENT_SECRET"}) and env "${envVarName}" (unset)`,
)
}
// ────────────────────────────────────────────────────────────────────────
// Cosign dispatch — runs after native MPC sign completes
// ────────────────────────────────────────────────────────────────────────
+83
View File
@@ -231,6 +231,89 @@ brand: │ pkg/bridge/src/app/ │
`pulsar` (MLWE), `corona` (RLWE), `magnetar` (lattice research variant) —
PQ-safe, leaderless, permissionless-safe by design.
### Native Lux services — m-chain (MPC) + KMS + f-chain
The bridge backend talks to the canonical Lux ecosystem services via
HTTP clients in `app/server/src/clients/`:
- **`lux-iam.ts`** — `LuxIAMClient.mint(audience)` mints OAuth2
`client_credentials` JWTs against Lux IAM (`https://iam.lux.network`).
Per-audience cache with early refresh. The bridge mints one token for
`aud=lux-kms` and another for `aud=lux-mpc`; both rotate independently.
- **`lux-kms.ts`** — `LuxKMSClient.getSecret(path)` reads from
`~/work/lux/kms` (`https://kms.lux.network`). Bearer JWT auth, path
layout `bridge/cosigners/{utila|fireblocks}/{publicId}/{sa_pem|secret_pem}`.
Replaces every env-var fallback in `domain/cosigners.ts::fetchCosignerSecret`.
- **`lux-mpc.ts`** — `LuxMPCClient.keygen({...})` / `.sign({...})` /
`.status()` against `mpcd` (`https://mpc.lux.network`). This is THE
m-chain entrypoint — the canonical Lux MPC daemon at `~/work/lux/mpc`,
speaking the same wire protocol as the Go client at
`~/work/lux/kms/pkg/mpc/client.go`. Supports the full Lux protocol set
(CGGMP21 / FROST / BLS / SR25519 / Pulsar / Corona / Magnetar / Doerner).
Per-call 120s timeout matches mpcd's threshold-completion ceiling.
Configuration via env (bridge backend startup):
```
LUX_IAM_ISSUER https://iam.lux.network
LUX_KMS_URL https://kms.lux.network
LUX_KMS_ORG lux
LUX_KMS_CLIENT_ID lux-bridge
LUX_KMS_CLIENT_SECRET (from KMS at boot OR --kms-bootstrap-secret)
LUX_MPC_URL https://mpc.lux.network
```
When `LUX_KMS_URL` is unset (local dev), `fetchCosignerSecret` falls
back to per-tenant env vars. Production deployments MUST set the KMS
env block — env-var fallback is dev-only.
### Lux software frontends (administrative UIs)
Three Lux ecosystem services ship their own administrative UIs separate
from the bridge tenant SDK:
| Service | Frontend repo / path | Stack | Domain |
|---|---|---|---|
| **m-chain (mpcd)** | `~/work/lux/mpc/dashboard/` (`@luxfi/mpc-dashboard`) | Next.js 15 + React 19 + zustand | `mpc.lux.network/dashboard` |
| **KMS** | `~/work/lux/kms/frontend/` (`@hanzo/kms-frontend`) | Vite + React | `kms.lux.network` |
| **f-chain (fhed)** | **DOES NOT EXIST** — see below | — | — |
The bridge does **not** embed these dashboards; they're separate
operator surfaces.
### f-chain frontend — what it would look like
`~/work/lux/fhe` (the FHE daemon `fhed`) currently has no dedicated UI.
The bridge tenant's `TRANSFERS` panel surfaces per-swap f-chain
attestation status (a `CosignerStep` row when the bridge backend
processes a swap with `fchain: { publicUrl, scheme }` config), but
that's per-bridge-tenant, not a cluster-wide operator surface.
A standalone f-chain dashboard, when built, would mirror the
mpc-dashboard layout and surface:
1. **Cluster status** — `GET /v1/fhe/cluster/status`: peer count, FHE
scheme registry, share health per node.
2. **Public key registry** — `GET /v1/fhe/publickey`: published FHE
public keys per scheme (CKKS / BGV / BFV), with an export button.
3. **Encryption sessions** — recent `/v1/fhe/encrypt` calls and their
ciphertext bindings (txHash → ciphertext_hex), filterable by
requesting service (bridge / pay / id / ...).
4. **Reshare history** — `/v1/fhe/cluster/reshare` events.
5. **Health probe** — `GET /v1/fhe/health` rolled up to per-node status.
Recommended scaffold (when scheduled):
```
~/work/lux/fhe/dashboard/ # new Next.js app, mirror of mpc/dashboard
package.json @luxfi/fchain-dashboard
app/ # Next.js app router
components/cluster/ # ClusterStatus, PeerList
components/keys/ # PublicKeyRegistry, ExportButton
components/sessions/ # EncryptHistory, CiphertextViewer
lib/fchain-client.ts # mirrors lux-mpc.ts / lux-kms.ts shape
```
### Optional layered cosigners (since SDK v1.0.3)
External MPC custodians can be layered ON TOP of the native threshold network