mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user