From 552ef7f845002d5f7a383cc351b0f2c1d42219f9 Mon Sep 17 00:00:00 2001 From: harystyleseze Date: Tue, 28 Jul 2026 12:52:56 -0700 Subject: [PATCH] feat(custody): add threshold signing and safe key rotation control plane Adds an off-chain custody control plane for issuer/distribution/treasury Stellar accounts: signer roles and weighted thresholds per operation category (issuance, payout, emergency, recovery, rotation), a SignerAdapter interface so the app process never receives raw signing secrets (testnet-only LocalDevSignerAdapter for dev/CI, a documented KMS/HSM adapter contract for production), network/source/sequence/time/memo/intent-bound envelopes with cross-network, stale-sequence, and cross-intent replay protection, overlap-safe signer-set rotation with a recovery quorum path and rollback, and tamper-evident audit logging of every approval request, approval, and ledger result via the existing audit-log infrastructure. --- __tests__/lib/custody/envelope.test.ts | 124 +++++ __tests__/lib/custody/policy.test.ts | 151 ++++++ __tests__/lib/custody/rotation.test.ts | 332 +++++++++++++ __tests__/lib/custody/service.test.ts | 461 ++++++++++++++++++ __tests__/lib/custody/signer-adapter.test.ts | 120 +++++ docs/custody-signer-rotation.md | 227 +++++++++ lib/custody/canonical.ts | 20 + lib/custody/envelope.ts | 111 +++++ lib/custody/operations.ts | 87 ++++ lib/custody/policy.ts | 192 ++++++++ lib/custody/rotation.ts | 365 ++++++++++++++ lib/custody/service.ts | 484 +++++++++++++++++++ lib/custody/signer-adapter.ts | 81 ++++ lib/custody/types.ts | 58 +++ models/CustodyApprovalRequest.ts | 102 ++++ models/CustodySequenceWatermark.ts | 32 ++ models/CustodySignerSet.ts | 154 ++++++ 17 files changed, 3101 insertions(+) create mode 100644 __tests__/lib/custody/envelope.test.ts create mode 100644 __tests__/lib/custody/policy.test.ts create mode 100644 __tests__/lib/custody/rotation.test.ts create mode 100644 __tests__/lib/custody/service.test.ts create mode 100644 __tests__/lib/custody/signer-adapter.test.ts create mode 100644 docs/custody-signer-rotation.md create mode 100644 lib/custody/canonical.ts create mode 100644 lib/custody/envelope.ts create mode 100644 lib/custody/operations.ts create mode 100644 lib/custody/policy.ts create mode 100644 lib/custody/rotation.ts create mode 100644 lib/custody/service.ts create mode 100644 lib/custody/signer-adapter.ts create mode 100644 lib/custody/types.ts create mode 100644 models/CustodyApprovalRequest.ts create mode 100644 models/CustodySequenceWatermark.ts create mode 100644 models/CustodySignerSet.ts diff --git a/__tests__/lib/custody/envelope.test.ts b/__tests__/lib/custody/envelope.test.ts new file mode 100644 index 00000000..985fbcbf --- /dev/null +++ b/__tests__/lib/custody/envelope.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as stellarConfig from "@/lib/stellar/config" +import { buildEnvelope, computeEnvelopeHash, assertEnvelopeFresh, EnvelopeValidationError, getNetworkPassphrase } from "@/lib/custody/envelope" + +vi.mock("@/lib/stellar/config") + +const TESTNET_CONFIG = { + network: "testnet" as const, + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + assetCode: "CMOVE", + issuerPublicKey: "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H", + distributionPublicKey: "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA", + contractId: "", + mock: true, +} + +const SOURCE_ACCOUNT = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" +const DESTINATION = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" + +function baseInput(overrides: Partial[0]> = {}) { + return { + sourceAccount: SOURCE_ACCOUNT, + sequence: "100", + minTime: new Date("2026-01-01T00:00:00.000Z"), + maxTime: new Date("2026-01-01T00:15:00.000Z"), + intent: { + category: "payout" as const, + operation: "distribution.payment", + params: { destination: DESTINATION, assetCode: "native", amount: "10.0000000" }, + }, + ...overrides, + } +} + +describe("buildEnvelope", () => { + beforeEach(() => { + vi.mocked(stellarConfig.getStellarConfig).mockReturnValue(TESTNET_CONFIG) + }) + + it("binds network, source, sequence, time bounds, memo, and intent", () => { + const envelope = buildEnvelope(baseInput()) + expect(envelope.network).toBe("testnet") + expect(envelope.networkPassphrase).toBe(getNetworkPassphrase("testnet")) + expect(envelope.sourceAccount).toBe(SOURCE_ACCOUNT) + expect(envelope.sequence).toBe("100") + expect(envelope.memo).toEqual({ type: "none" }) + expect(envelope.intent.operation).toBe("distribution.payment") + }) + + it("rejects an invalid source account", () => { + expect(() => buildEnvelope(baseInput({ sourceAccount: "not-a-key" }))).toThrow(EnvelopeValidationError) + }) + + it("rejects a non-numeric sequence", () => { + expect(() => buildEnvelope(baseInput({ sequence: "abc" }))).toThrow(EnvelopeValidationError) + }) + + it("rejects maxTime at or before minTime", () => { + expect(() => + buildEnvelope(baseInput({ minTime: new Date("2026-01-01T00:15:00.000Z"), maxTime: new Date("2026-01-01T00:15:00.000Z") })), + ).toThrow(EnvelopeValidationError) + }) +}) + +describe("computeEnvelopeHash", () => { + beforeEach(() => { + vi.mocked(stellarConfig.getStellarConfig).mockReturnValue(TESTNET_CONFIG) + }) + + it("is deterministic for identical envelopes", () => { + const envelope = buildEnvelope(baseInput()) + expect(computeEnvelopeHash(envelope)).toBe(computeEnvelopeHash(buildEnvelope(baseInput()))) + }) + + it("changes when the intent (cross-intent) changes", () => { + const envelope = buildEnvelope(baseInput()) + const differentIntent = buildEnvelope( + baseInput({ intent: { category: "payout", operation: "distribution.payment", params: { destination: DESTINATION, assetCode: "native", amount: "20.0000000" } } }), + ) + expect(computeEnvelopeHash(envelope)).not.toBe(computeEnvelopeHash(differentIntent)) + }) + + it("changes when the sequence changes", () => { + const envelope = buildEnvelope(baseInput()) + const differentSequence = buildEnvelope(baseInput({ sequence: "101" })) + expect(computeEnvelopeHash(envelope)).not.toBe(computeEnvelopeHash(differentSequence)) + }) +}) + +describe("assertEnvelopeFresh", () => { + beforeEach(() => { + vi.mocked(stellarConfig.getStellarConfig).mockReturnValue(TESTNET_CONFIG) + }) + + const now = new Date("2026-01-01T00:05:00.000Z") + + it("accepts a fresh envelope within its time bounds and above the watermark", () => { + const envelope = buildEnvelope(baseInput()) + expect(() => assertEnvelopeFresh({ envelope, now, lastConsumedSequence: "99" })).not.toThrow() + }) + + it("rejects cross-network replay when the configured network differs", () => { + const envelope = buildEnvelope(baseInput()) + vi.mocked(stellarConfig.getStellarConfig).mockReturnValue({ ...TESTNET_CONFIG, network: "mainnet" }) + expect(() => assertEnvelopeFresh({ envelope, now })).toThrow(/Cross-network replay/) + }) + + it("rejects a stale/replayed sequence", () => { + const envelope = buildEnvelope(baseInput({ sequence: "100" })) + expect(() => assertEnvelopeFresh({ envelope, now, lastConsumedSequence: "100" })).toThrow(/Stale sequence rejected/) + expect(() => assertEnvelopeFresh({ envelope, now, lastConsumedSequence: "150" })).toThrow(/Stale sequence rejected/) + }) + + it("rejects an expired envelope (past maxTime) - replay-by-delay fails", () => { + const envelope = buildEnvelope(baseInput()) + expect(() => assertEnvelopeFresh({ envelope, now: new Date("2026-01-01T01:00:00.000Z") })).toThrow(/expired/) + }) + + it("rejects an envelope not yet valid (before minTime)", () => { + const envelope = buildEnvelope(baseInput()) + expect(() => assertEnvelopeFresh({ envelope, now: new Date("2025-12-31T23:00:00.000Z") })).toThrow(/not yet valid/) + }) +}) diff --git a/__tests__/lib/custody/policy.test.ts b/__tests__/lib/custody/policy.test.ts new file mode 100644 index 00000000..e7106da4 --- /dev/null +++ b/__tests__/lib/custody/policy.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from "vitest" +import { + DEFAULT_THRESHOLD_POLICIES, + getThresholdPolicy, + validateSignerSetInvariants, + assertPayoutWithinPolicy, + SignerSetInvariantError, + PolicyViolationError, +} from "@/lib/custody/policy" +import type { SignerDescriptor } from "@/lib/custody/types" + +const KEY_A = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" +const KEY_B = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" +const KEY_C = "GBRLHRADGGA2RPHJ3AVHOTIT3GENGHQETXTHOHY4IUJJLI26KHWQBD6U" + +describe("threshold matrices", () => { + it("defines a policy for every operation category with a sane threshold/signer floor", () => { + for (const category of Object.keys(DEFAULT_THRESHOLD_POLICIES) as Array) { + const policy = getThresholdPolicy(category) + expect(policy.minThreshold).toBeGreaterThan(0) + expect(policy.minSigners).toBeGreaterThanOrEqual(policy.minThreshold) + expect(policy.eligibleRoles.length).toBeGreaterThan(0) + } + }) + + it("requires issuance to be at least 2-of-N (mirrors issuer cold multisig)", () => { + expect(getThresholdPolicy("issuance").minThreshold).toBeGreaterThanOrEqual(2) + }) + + it("requires payout to enforce a destination allowlist", () => { + expect(getThresholdPolicy("payout").requireDestinationAllowlist).toBe(true) + }) + + it("requires rotation to span at least 2 distinct signer roles", () => { + expect(getThresholdPolicy("rotation").minDistinctRoles).toBeGreaterThanOrEqual(2) + }) +}) + +function signers(overrides: Partial[] = []): SignerDescriptor[] { + const base: SignerDescriptor[] = [ + { signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 }, + { signerId: "issuer-2", role: "issuer", publicKey: KEY_B, weight: 1 }, + { signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 }, + ] + return overrides.length ? (overrides as SignerDescriptor[]) : base +} + +describe("validateSignerSetInvariants", () => { + it("accepts a valid 2-of-3 issuance signer set", () => { + expect(() => validateSignerSetInvariants({ category: "issuance", signers: signers(), threshold: 2 })).not.toThrow() + }) + + it("rejects a threshold that exceeds total signer weight (permanent lockout)", () => { + expect(() => validateSignerSetInvariants({ category: "issuance", signers: signers(), threshold: 10 })).toThrow( + SignerSetInvariantError, + ) + }) + + it("rejects duplicate signerId", () => { + const dup = [ + { signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 }, + { signerId: "issuer-1", role: "issuer", publicKey: KEY_B, weight: 1 }, + { signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 }, + ] as SignerDescriptor[] + expect(() => validateSignerSetInvariants({ category: "issuance", signers: dup, threshold: 2 })).toThrow(/Duplicate signerId/) + }) + + it("rejects duplicate signer public keys", () => { + const dup = [ + { signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 }, + { signerId: "issuer-2", role: "issuer", publicKey: KEY_A, weight: 1 }, + { signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 }, + ] as SignerDescriptor[] + expect(() => validateSignerSetInvariants({ category: "issuance", signers: dup, threshold: 2 })).toThrow( + /Duplicate signer public key/, + ) + }) + + it("rejects too few signers for the category", () => { + const tooFew = [{ signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 }] as SignerDescriptor[] + expect(() => validateSignerSetInvariants({ category: "issuance", signers: tooFew, threshold: 1 })).toThrow( + /requires at least 3 signers/, + ) + }) + + it("rejects a threshold below the category minimum", () => { + expect(() => validateSignerSetInvariants({ category: "issuance", signers: signers(), threshold: 1 })).toThrow( + /requires a threshold of at least/, + ) + }) + + it("rejects insufficient distinct roles for rotation", () => { + const sameRole = [ + { signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 1 }, + { signerId: "issuer-2", role: "issuer", publicKey: KEY_B, weight: 1 }, + { signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 }, + ] as SignerDescriptor[] + expect(() => validateSignerSetInvariants({ category: "rotation", signers: sameRole, threshold: 2 })).toThrow( + /distinct role/, + ) + }) + + it("rejects a non-positive-integer weight", () => { + const badWeight = [ + { signerId: "issuer-1", role: "issuer", publicKey: KEY_A, weight: 0 }, + { signerId: "issuer-2", role: "issuer", publicKey: KEY_B, weight: 1 }, + { signerId: "issuer-3", role: "issuer", publicKey: KEY_C, weight: 1 }, + ] as SignerDescriptor[] + expect(() => validateSignerSetInvariants({ category: "issuance", signers: badWeight, threshold: 1 })).toThrow( + /positive integer/, + ) + }) +}) + +describe("assertPayoutWithinPolicy", () => { + const allowedDestinations = [KEY_B] + + it("accepts a payout within limits to an allowlisted destination", () => { + expect(() => + assertPayoutWithinPolicy({ amount: "1000", destination: KEY_B, allowedDestinations, maxAmount: "5000", dailyLimit: "10000" }), + ).not.toThrow() + }) + + it("rejects a destination not on the allowlist", () => { + expect(() => + assertPayoutWithinPolicy({ amount: "1000", destination: KEY_C, allowedDestinations, maxAmount: "5000" }), + ).toThrow(PolicyViolationError) + }) + + it("rejects an amount over the per-operation limit", () => { + expect(() => + assertPayoutWithinPolicy({ amount: "6000", destination: KEY_B, allowedDestinations, maxAmount: "5000" }), + ).toThrow(/exceeds per-operation limit/) + }) + + it("rejects an amount that would exceed the daily limit", () => { + expect(() => + assertPayoutWithinPolicy({ + amount: "3000", + destination: KEY_B, + allowedDestinations, + dailyTotalSoFar: "8000", + dailyLimit: "10000", + }), + ).toThrow(/exceed daily limit/) + }) + + it("rejects a non-positive amount", () => { + expect(() => assertPayoutWithinPolicy({ amount: "0", destination: KEY_B, allowedDestinations })).toThrow(/must be positive/) + }) +}) diff --git a/__tests__/lib/custody/rotation.test.ts b/__tests__/lib/custody/rotation.test.ts new file mode 100644 index 00000000..4bd3d78f --- /dev/null +++ b/__tests__/lib/custody/rotation.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { + proposeRotation, + approveRotation, + activateRotation, + retireIfSafe, + rollbackRotation, + seedGenesisSignerSet, + RotationError, +} from "@/lib/custody/rotation" +import CustodySignerSet from "@/models/CustodySignerSet" +import CustodyApprovalRequest from "@/models/CustodyApprovalRequest" +import { logAuditEvent } from "@/lib/security/audit-log" + +vi.mock("@/lib/dbConnect", () => ({ default: vi.fn() })) +vi.mock("@/models/CustodySignerSet") +vi.mock("@/models/CustodyApprovalRequest") +vi.mock("@/lib/security/audit-log", () => ({ logAuditEvent: vi.fn() })) + +const KEY_A = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" +const KEY_B = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" +const KEY_C = "GBRLHRADGGA2RPHJ3AVHOTIT3GENGHQETXTHOHY4IUJJLI26KHWQBD6U" + +const ROTATION_SIGNERS = [ + { signerId: "issuer-1", role: "issuer" as const, publicKey: KEY_A, weight: 1 }, + { signerId: "distribution-1", role: "distribution" as const, publicKey: KEY_B, weight: 1 }, + { signerId: "security-1", role: "security" as const, publicKey: KEY_C, weight: 1 }, +] + +function lean(value: unknown) { + return { lean: vi.fn().mockResolvedValue(value) } as any +} + +const GOVERNING_ROTATION_SET = { + category: "rotation", + threshold: 2, + signers: [ + { signerId: "admin-1", role: "issuer", publicKey: KEY_A, weight: 1 }, + { signerId: "admin-2", role: "distribution", publicKey: KEY_B, weight: 1 }, + ], +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe("proposeRotation", () => { + it("creates a pending signer set versioned after the current active one", async () => { + vi.mocked(CustodySignerSet.findOne).mockImplementation((query: any) => { + if (query.status === "active") return { sort: vi.fn().mockReturnValue(lean({ version: 3 })) } as any + return lean(null) + }) + vi.mocked(CustodySignerSet.create).mockResolvedValue({ + _id: "set-1", + toObject: () => ({ _id: "set-1", version: 4, status: "pending" }), + } as any) + + const result = await proposeRotation({ + category: "rotation", + network: "testnet", + signers: ROTATION_SIGNERS, + threshold: 2, + createdBy: "admin-1", + }) + + expect(result.version).toBe(4) + expect(CustodySignerSet.create).toHaveBeenCalledWith(expect.objectContaining({ version: 4, previousVersion: 3, status: "pending" })) + expect(logAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "custody.rotation.proposed", criticalAction: true })) + }) + + it("rejects a second pending rotation for the same category/network", async () => { + vi.mocked(CustodySignerSet.findOne).mockImplementation((query: any) => { + if (query.status === "active") return { sort: vi.fn().mockReturnValue(lean(null)) } as any + return lean({ _id: "already-pending" }) + }) + + await expect( + proposeRotation({ category: "rotation", network: "testnet", signers: ROTATION_SIGNERS, threshold: 2, createdBy: "admin-1" }), + ).rejects.toThrow(/already pending/) + }) + + it("rejects an invalid signer set before ever touching the database", async () => { + await expect( + proposeRotation({ category: "rotation", network: "testnet", signers: ROTATION_SIGNERS, threshold: 99, createdBy: "admin-1" }), + ).rejects.toThrow() + expect(CustodySignerSet.findOne).not.toHaveBeenCalled() + }) +}) + +describe("approveRotation", () => { + it("accumulates distinct approvers and reports when quorum is met", async () => { + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(GOVERNING_ROTATION_SET)) + vi.mocked(CustodySignerSet.findById).mockReturnValue(lean({ status: "pending", network: "testnet", rotationApprovals: [] })) + vi.mocked(CustodySignerSet.findOneAndUpdate).mockReturnValue( + lean({ rotationApprovals: [{ approvedBy: "admin-1", quorumType: "standard" }] }), + ) + + const firstResult = await approveRotation({ signerSetId: "set-1", approvedBy: "admin-1", role: "issuer", quorumType: "standard" }) + expect(firstResult.quorumMet).toBe(false) + + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ status: "pending", network: "testnet", rotationApprovals: [{ approvedBy: "admin-1", quorumType: "standard" }] }), + ) + vi.mocked(CustodySignerSet.findOneAndUpdate).mockReturnValue( + lean({ + rotationApprovals: [ + { approvedBy: "admin-1", quorumType: "standard" }, + { approvedBy: "admin-2", quorumType: "standard" }, + ], + }), + ) + const secondResult = await approveRotation({ signerSetId: "set-1", approvedBy: "admin-2", role: "distribution", quorumType: "standard" }) + expect(secondResult.quorumMet).toBe(true) + }) + + it("rejects an approver who is not a real signer in the governing signer set (prevents fabricated-identity quorum)", async () => { + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(GOVERNING_ROTATION_SET)) + vi.mocked(CustodySignerSet.findById).mockReturnValue(lean({ status: "pending", network: "testnet", rotationApprovals: [] })) + + await expect( + approveRotation({ signerSetId: "set-1", approvedBy: "attacker-1", role: "issuer", quorumType: "standard" }), + ).rejects.toThrow(/not an eligible/) + expect(CustodySignerSet.findOneAndUpdate).not.toHaveBeenCalled() + }) + + it("rejects a real signerId approving under the wrong role", async () => { + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(GOVERNING_ROTATION_SET)) + vi.mocked(CustodySignerSet.findById).mockReturnValue(lean({ status: "pending", network: "testnet", rotationApprovals: [] })) + + await expect( + approveRotation({ signerSetId: "set-1", approvedBy: "admin-1", role: "security", quorumType: "standard" }), + ).rejects.toThrow(/not an eligible/) + }) + + it("rejects when no active governing signer set is configured for the network", async () => { + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(null)) + vi.mocked(CustodySignerSet.findById).mockReturnValue(lean({ status: "pending", network: "testnet", rotationApprovals: [] })) + + await expect( + approveRotation({ signerSetId: "set-1", approvedBy: "admin-1", role: "issuer", quorumType: "standard" }), + ).rejects.toThrow(/No active rotation signer set/) + }) + + it("rejects the same approver approving twice (separation of duties)", async () => { + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(GOVERNING_ROTATION_SET)) + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ status: "pending", network: "testnet", rotationApprovals: [{ approvedBy: "admin-1", quorumType: "standard" }] }), + ) + + await expect( + approveRotation({ signerSetId: "set-1", approvedBy: "admin-1", role: "issuer", quorumType: "standard" }), + ).rejects.toThrow(/already approved/) + }) + + it("rejects approving a signer set that is not pending", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue(lean({ status: "active", network: "testnet", rotationApprovals: [] })) + + await expect( + approveRotation({ signerSetId: "set-1", approvedBy: "admin-1", role: "issuer", quorumType: "standard" }), + ).rejects.toThrow(RotationError) + }) +}) + +describe("activateRotation", () => { + it("activates once quorum is met and puts the previous active set into an overlap-safe retiring window", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ + status: "pending", + category: "rotation", + network: "testnet", + version: 4, + previousVersion: 3, + overlapWindowMs: 1000, + rotationApprovals: [ + { approvedBy: "admin-1", quorumType: "standard" }, + { approvedBy: "admin-2", quorumType: "standard" }, + ], + }), + ) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(GOVERNING_ROTATION_SET)) + vi.mocked(CustodySignerSet.findOneAndUpdate).mockImplementation((query: any) => { + if (query.status === "pending") return lean({ status: "active", version: 4 }) + return lean({ status: "retiring", version: 3, effectiveTo: new Date(Date.now() + 1000) }) + }) + + const result = await activateRotation("set-1") + expect(result.active.status).toBe("active") + expect(result.retiring.status).toBe("retiring") + expect(logAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "custody.rotation.activated" })) + }) + + it("refuses to activate when quorum has not been met", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ status: "pending", network: "testnet", version: 4, rotationApprovals: [{ approvedBy: "admin-1", quorumType: "standard" }] }), + ) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(GOVERNING_ROTATION_SET)) + + await expect(activateRotation("set-1")).rejects.toThrow(/quorum not met/i) + }) +}) + +describe("retireIfSafe - rotation with pending approvals", () => { + it("refuses to retire while approval requests still reference the retiring signer set version", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ status: "retiring", version: 3, category: "rotation", network: "testnet", effectiveTo: new Date(Date.now() - 1000) }), + ) + vi.mocked(CustodyApprovalRequest.countDocuments).mockResolvedValue(2 as any) + + const result = await retireIfSafe("set-1") + expect(result.retired).toBe(false) + expect(result.reason).toMatch(/pending/) + expect(CustodySignerSet.findOneAndUpdate).not.toHaveBeenCalled() + }) + + it("refuses to retire before the overlap window has elapsed", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ status: "retiring", version: 3, category: "rotation", network: "testnet", effectiveTo: new Date(Date.now() + 60_000) }), + ) + + const result = await retireIfSafe("set-1") + expect(result.retired).toBe(false) + expect(result.reason).toMatch(/overlap/i) + }) + + it("retires once the overlap window has elapsed and no requests remain pending - this is the only path to retired", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ status: "retiring", version: 3, category: "rotation", network: "testnet", effectiveTo: new Date(Date.now() - 1000) }), + ) + vi.mocked(CustodyApprovalRequest.countDocuments).mockResolvedValue(0 as any) + vi.mocked(CustodySignerSet.findOneAndUpdate).mockReturnValue(lean({ status: "retired", version: 3 })) + + const result = await retireIfSafe("set-1") + expect(result.retired).toBe(true) + expect(logAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "custody.rotation.retired" })) + }) +}) + +describe("rollbackRotation", () => { + it("reactivates the previous signer set when rolling back an active rotation", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ status: "active", version: 4, category: "rotation", network: "testnet", previousVersion: 3 }), + ) + vi.mocked(CustodySignerSet.findOneAndUpdate).mockImplementation((query: any) => { + if (query.status === "active") return lean({ status: "rolled_back", version: 4 }) + return lean({ status: "active", version: 3 }) + }) + + const result = await rollbackRotation("set-4", { reason: "compromised signer detected" }) + expect(result.rolledBack.status).toBe("rolled_back") + expect(result.reactivated.status).toBe("active") + }) + + it("rolls back a still-pending rotation without touching any previous set", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue(lean({ status: "pending", version: 4, category: "rotation", network: "testnet" })) + vi.mocked(CustodySignerSet.findOneAndUpdate).mockReturnValue(lean({ status: "rolled_back", version: 4 })) + + const result = await rollbackRotation("set-4", { reason: "duplicate proposal" }) + expect(result.rolledBack.status).toBe("rolled_back") + expect(result.reactivated).toBeNull() + }) + + it("aborts (does not flip the current set to rolled_back) when the previous set is already fully retired - never leaves zero active sets", async () => { + vi.mocked(CustodySignerSet.findById).mockReturnValue( + lean({ status: "active", version: 4, category: "rotation", network: "testnet", previousVersion: 3 }), + ) + // Reactivation query finds nothing because the previous set is already "retired" (terminal), not "retiring". + vi.mocked(CustodySignerSet.findOneAndUpdate).mockReturnValue(lean(null)) + + await expect(rollbackRotation("set-4", { reason: "too late" })).rejects.toThrow(/already retired/) + + // The rolled_back flip must never have been attempted once reactivation failed - + // findOneAndUpdate should only have been called once, for the reactivation attempt. + expect(CustodySignerSet.findOneAndUpdate).toHaveBeenCalledTimes(1) + expect(CustodySignerSet.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ status: "retiring" }), + expect.anything(), + expect.anything(), + ) + }) +}) + +describe("seedGenesisSignerSet", () => { + it("requires the exact confirmation token", async () => { + await expect( + seedGenesisSignerSet({ + category: "rotation", + network: "testnet", + signers: ROTATION_SIGNERS, + threshold: 2, + createdBy: "admin-1", + confirmationToken: "wrong-token", + }), + ).rejects.toThrow(/confirmationToken/) + expect(CustodySignerSet.findOne).not.toHaveBeenCalled() + }) + + it("refuses to seed if a signer set already exists for the category/network", async () => { + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean({ _id: "existing-set" })) + + await expect( + seedGenesisSignerSet({ + category: "rotation", + network: "testnet", + signers: ROTATION_SIGNERS, + threshold: 2, + createdBy: "admin-1", + confirmationToken: "CONFIRM_GENESIS_SIGNER_SET", + }), + ).rejects.toThrow(/already exists/) + }) + + it("creates the first signer set directly as active for a brand new category/network", async () => { + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(null)) + vi.mocked(CustodySignerSet.create).mockResolvedValue({ + _id: "genesis-set", + toObject: () => ({ _id: "genesis-set", version: 1, status: "active" }), + } as any) + + const result = await seedGenesisSignerSet({ + category: "rotation", + network: "testnet", + signers: ROTATION_SIGNERS, + threshold: 2, + createdBy: "admin-1", + confirmationToken: "CONFIRM_GENESIS_SIGNER_SET", + }) + + expect(result.status).toBe("active") + expect(CustodySignerSet.create).toHaveBeenCalledWith(expect.objectContaining({ version: 1, status: "active" })) + expect(logAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "custody.rotation.genesis_seeded" })) + }) +}) diff --git a/__tests__/lib/custody/service.test.ts b/__tests__/lib/custody/service.test.ts new file mode 100644 index 00000000..7c5990d6 --- /dev/null +++ b/__tests__/lib/custody/service.test.ts @@ -0,0 +1,461 @@ +// @vitest-environment node +// +// Real ed25519 keypair generation (via @noble/curves) needs a working +// crypto.getRandomValues, which jsdom's default test environment does not +// provide reliably. This module has no DOM dependency, so it runs under the +// plain Node environment instead of the project-wide jsdom default. +import { describe, it, expect, vi, beforeEach } from "vitest" +import { Keypair, TransactionBuilder } from "@stellar/stellar-sdk" +import * as stellarConfig from "@/lib/stellar/config" + +vi.mock("server-only", () => ({})) +vi.mock("@/lib/dbConnect", () => ({ default: vi.fn() })) +vi.mock("@/models/CustodySignerSet") +vi.mock("@/models/CustodyApprovalRequest") +vi.mock("@/models/CustodySequenceWatermark") +vi.mock("@/lib/security/audit-log", () => ({ logAuditEvent: vi.fn() })) +vi.mock("@/lib/stellar/config") + +import { + requestApproval, + approve, + finalizeAndSubmit, + reconcileSubmission, + expireStaleRequests, + CustodyServiceError, + AmbiguousSubmissionError, +} from "@/lib/custody/service" +import CustodySignerSet from "@/models/CustodySignerSet" +import CustodyApprovalRequest from "@/models/CustodyApprovalRequest" +import CustodySequenceWatermark from "@/models/CustodySequenceWatermark" +import { logAuditEvent } from "@/lib/security/audit-log" +import { buildEnvelope, computeEnvelopeHash } from "@/lib/custody/envelope" +import { computeOperationsHash } from "@/lib/custody/operations" +import type { SignerAdapter } from "@/lib/custody/types" + +const TESTNET_CONFIG = { + network: "testnet" as const, + horizonUrl: "https://horizon-testnet.stellar.org", + rpcUrl: "https://soroban-testnet.stellar.org", + assetCode: "CMOVE", + issuerPublicKey: "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H", + distributionPublicKey: "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA", + contractId: "", + mock: true, +} + +const SOURCE_ACCOUNT = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H" +const DESTINATION = "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA" + +function lean(value: unknown) { + return { lean: vi.fn().mockResolvedValue(value) } as any +} + +function payoutIntent(amount = "10.0000000") { + return { + category: "payout" as const, + operation: "distribution.payment", + params: { destination: DESTINATION, assetCode: "native", amount }, + } +} + +const PAYOUT_SIGNER_SET = { + version: 2, + payoutPolicy: { allowedDestinations: [DESTINATION], maxAmount: "1000000000", dailyLimit: "5000000000" }, +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(stellarConfig.getStellarConfig).mockReturnValue(TESTNET_CONFIG) +}) + +describe("requestApproval", () => { + it("creates a pending request against the active signer set and records an audit event", async () => { + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(PAYOUT_SIGNER_SET)) + vi.mocked(CustodyApprovalRequest.find).mockReturnValue(lean([])) + vi.mocked(CustodyApprovalRequest.create).mockResolvedValue({ + _id: "req-1", + toObject: () => ({ _id: "req-1", status: "pending" }), + } as any) + + const result = await requestApproval({ + sourceAccount: SOURCE_ACCOUNT, + sequence: "100", + minTime: new Date(Date.now() - 1000), + maxTime: new Date(Date.now() + 60_000), + intent: payoutIntent(), + requestedBy: "ops-1", + }) + + expect(result.status).toBe("pending") + expect(logAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "custody.approval.requested", criticalAction: true })) + }) + + it("rejects when there is no active signer set for the category", async () => { + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(null)) + + await expect( + requestApproval({ + sourceAccount: SOURCE_ACCOUNT, + sequence: "100", + minTime: new Date(Date.now() - 1000), + maxTime: new Date(Date.now() + 60_000), + intent: payoutIntent(), + requestedBy: "ops-1", + }), + ).rejects.toThrow(/No active signer set/) + }) + + it("rejects a stale/replayed sequence before ever creating a request", async () => { + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean({ lastConsumedSequence: { toString: () => "100" } })) + + await expect( + requestApproval({ + sourceAccount: SOURCE_ACCOUNT, + sequence: "100", + minTime: new Date(Date.now() - 1000), + maxTime: new Date(Date.now() + 60_000), + intent: payoutIntent(), + requestedBy: "ops-1", + }), + ).rejects.toThrow(/Stale sequence rejected/) + expect(CustodyApprovalRequest.create).not.toHaveBeenCalled() + }) + + it("rejects a duplicate/replayed envelope surfaced as a DB unique-index conflict", async () => { + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(PAYOUT_SIGNER_SET)) + vi.mocked(CustodyApprovalRequest.find).mockReturnValue(lean([])) + const duplicateKeyError: any = new Error("duplicate key") + duplicateKeyError.code = 11000 + vi.mocked(CustodyApprovalRequest.create).mockRejectedValue(duplicateKeyError) + + await expect( + requestApproval({ + sourceAccount: SOURCE_ACCOUNT, + sequence: "100", + minTime: new Date(Date.now() - 1000), + maxTime: new Date(Date.now() + 60_000), + intent: payoutIntent(), + requestedBy: "ops-1", + }), + ).rejects.toThrow(/Replay rejected/) + }) + + it("rejects a payout to a destination that is not on the signer set's allowlist", async () => { + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + vi.mocked(CustodySignerSet.findOne).mockReturnValue( + lean({ version: 2, payoutPolicy: { allowedDestinations: [SOURCE_ACCOUNT] } }), + ) + vi.mocked(CustodyApprovalRequest.find).mockReturnValue(lean([])) + + await expect( + requestApproval({ + sourceAccount: SOURCE_ACCOUNT, + sequence: "100", + minTime: new Date(Date.now() - 1000), + maxTime: new Date(Date.now() + 60_000), + intent: payoutIntent(), + requestedBy: "ops-1", + }), + ).rejects.toThrow(/not on the payout allowlist/) + expect(CustodyApprovalRequest.create).not.toHaveBeenCalled() + }) + + it("rejects a payout over the signer set's per-operation limit", async () => { + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + vi.mocked(CustodySignerSet.findOne).mockReturnValue( + lean({ version: 2, payoutPolicy: { allowedDestinations: [DESTINATION], maxAmount: "1000000" } }), + ) + vi.mocked(CustodyApprovalRequest.find).mockReturnValue(lean([])) + + await expect( + requestApproval({ + sourceAccount: SOURCE_ACCOUNT, + sequence: "100", + minTime: new Date(Date.now() - 1000), + maxTime: new Date(Date.now() + 60_000), + intent: payoutIntent("10.0000000"), + requestedBy: "ops-1", + }), + ).rejects.toThrow(/exceeds per-operation limit/) + }) + + it("rejects a payout category request when the active signer set has no payoutPolicy configured", async () => { + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean({ version: 2 })) + + await expect( + requestApproval({ + sourceAccount: SOURCE_ACCOUNT, + sequence: "100", + minTime: new Date(Date.now() - 1000), + maxTime: new Date(Date.now() + 60_000), + intent: payoutIntent(), + requestedBy: "ops-1", + }), + ).rejects.toThrow(/no payoutPolicy/) + }) +}) + +describe("approve - threshold matrix and signer outage", () => { + const signerSet = { + threshold: 2, + signers: [ + { signerId: "dist-1", role: "distribution", publicKey: SOURCE_ACCOUNT, weight: 1 }, + { signerId: "dist-2", role: "distribution", publicKey: DESTINATION, weight: 1 }, + { signerId: "dist-3", role: "distribution", publicKey: SOURCE_ACCOUNT, weight: 1 }, + ], + } + + it("reaches quorum with only 2 of 3 eligible signers responding (tolerates one signer outage)", async () => { + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(signerSet)) + + vi.mocked(CustodyApprovalRequest.findById).mockReturnValueOnce( + lean({ status: "pending", maxTime: new Date(Date.now() + 60_000), approvals: [] }), + ) + vi.mocked(CustodyApprovalRequest.findOneAndUpdate).mockReturnValueOnce( + lean({ status: "pending", approvals: [{ signerId: "dist-1" }] }), + ) + const first = await approve({ requestId: "req-1", signerId: "dist-1", role: "distribution" as any }) + expect(first.status).toBe("pending") + + vi.mocked(CustodyApprovalRequest.findById).mockReturnValueOnce( + lean({ status: "pending", maxTime: new Date(Date.now() + 60_000), approvals: [{ signerId: "dist-1" }] }), + ) + vi.mocked(CustodyApprovalRequest.findOneAndUpdate) + .mockReturnValueOnce(lean({ status: "pending", approvals: [{ signerId: "dist-1" }, { signerId: "dist-2" }] })) + .mockReturnValueOnce(lean({ status: "quorum_reached", approvals: [{ signerId: "dist-1" }, { signerId: "dist-2" }] })) + + const second = await approve({ requestId: "req-1", signerId: "dist-2", role: "distribution" as any }) + expect(second.status).toBe("quorum_reached") + }) + + it("weights approvals rather than counting heads: a single higher-weight signer can satisfy the threshold alone", async () => { + const weightedSignerSet = { + threshold: 2, + signers: [ + { signerId: "senior-1", role: "distribution", publicKey: SOURCE_ACCOUNT, weight: 2 }, + { signerId: "junior-1", role: "distribution", publicKey: DESTINATION, weight: 1 }, + ], + } + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(weightedSignerSet)) + vi.mocked(CustodyApprovalRequest.findById).mockReturnValue( + lean({ status: "pending", maxTime: new Date(Date.now() + 60_000), approvals: [] }), + ) + vi.mocked(CustodyApprovalRequest.findOneAndUpdate) + .mockReturnValueOnce(lean({ status: "pending", approvals: [{ signerId: "senior-1" }] })) + .mockReturnValueOnce(lean({ status: "quorum_reached", approvals: [{ signerId: "senior-1" }] })) + + const result = await approve({ requestId: "req-1", signerId: "senior-1", role: "distribution" as any }) + expect(result.status).toBe("quorum_reached") + }) + + it("does not reach quorum from two low-weight signers whose combined weight is still below threshold", async () => { + const weightedSignerSet = { + threshold: 3, + signers: [ + { signerId: "junior-1", role: "distribution", publicKey: SOURCE_ACCOUNT, weight: 1 }, + { signerId: "junior-2", role: "distribution", publicKey: DESTINATION, weight: 1 }, + ], + } + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(weightedSignerSet)) + vi.mocked(CustodyApprovalRequest.findById).mockReturnValue( + lean({ status: "pending", maxTime: new Date(Date.now() + 60_000), approvals: [{ signerId: "junior-1" }] }), + ) + vi.mocked(CustodyApprovalRequest.findOneAndUpdate).mockReturnValue( + lean({ status: "pending", approvals: [{ signerId: "junior-1" }, { signerId: "junior-2" }] }), + ) + + const result = await approve({ requestId: "req-1", signerId: "junior-2", role: "distribution" as any }) + expect(result.status).toBe("pending") + }) + + it("rejects an ineligible signer/role", async () => { + vi.mocked(CustodyApprovalRequest.findById).mockReturnValue( + lean({ status: "pending", maxTime: new Date(Date.now() + 60_000), approvals: [] }), + ) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(signerSet)) + + await expect(approve({ requestId: "req-1", signerId: "unknown-signer", role: "distribution" as any })).rejects.toThrow( + /not an eligible/, + ) + }) + + it("rejects the same signer approving twice (separation of duties)", async () => { + vi.mocked(CustodyApprovalRequest.findById).mockReturnValue( + lean({ status: "pending", maxTime: new Date(Date.now() + 60_000), approvals: [{ signerId: "dist-1" }] }), + ) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean(signerSet)) + + await expect(approve({ requestId: "req-1", signerId: "dist-1", role: "distribution" as any })).rejects.toThrow( + /already approved/, + ) + }) + + it("rejects approving an expired request", async () => { + vi.mocked(CustodyApprovalRequest.findById).mockReturnValue( + lean({ status: "pending", maxTime: new Date(Date.now() - 1000), approvals: [] }), + ) + + await expect(approve({ requestId: "req-1", signerId: "dist-1", role: "distribution" as any })).rejects.toThrow( + /expired/, + ) + }) +}) + +describe("finalizeAndSubmit", () => { + function buildSigners() { + const keypairA = Keypair.random() + const keypairB = Keypair.random() + return { + keypairs: { "dist-1": keypairA, "dist-2": keypairB }, + descriptors: [ + { signerId: "dist-1", role: "distribution", publicKey: keypairA.publicKey(), weight: 1 }, + { signerId: "dist-2", role: "distribution", publicKey: keypairB.publicKey(), weight: 1 }, + ], + } + } + + function testAdapter(keypairs: Record>): SignerAdapter { + return { + adapterId: "test", + async getPublicKey(signerId: string) { + return keypairs[signerId].publicKey() + }, + async sign(signerId: string, payloadHash: string) { + return keypairs[signerId].sign(Buffer.from(payloadHash, "hex")).toString("base64") + }, + } + } + + function claimedRequest(overrides: Partial> = {}) { + const envelope = buildEnvelope({ + sourceAccount: SOURCE_ACCOUNT, + sequence: "101", + minTime: new Date(Date.now() - 1000), + maxTime: new Date(Date.now() + 60_000), + intent: payoutIntent(), + }) + return { + _id: "req-1", + network: "testnet", + category: "payout", + operation: "distribution.payment", + sourceAccount: SOURCE_ACCOUNT, + sequence: "101", + envelope, + envelopeHash: computeEnvelopeHash(envelope), + operationsHash: computeOperationsHash(envelope.intent), + maxTime: envelope.maxTime, + signerSetVersion: 1, + approvals: [{ signerId: "dist-1" }, { signerId: "dist-2" }], + ...overrides, + } + } + + it("builds, co-signs, and submits the transaction; advances the watermark; records the ledger result", async () => { + const { keypairs, descriptors } = buildSigners() + const request = claimedRequest() + + vi.mocked(CustodyApprovalRequest.findOneAndUpdate).mockImplementation((query: any) => { + if (query.status === "quorum_reached") return lean(request) + return lean({ ...request, status: "submitted" }) + }) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean({ threshold: 2, signers: descriptors })) + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + + const submit = vi.fn().mockResolvedValue({ hash: "ledgerhash123", ledger: 42, resultXdr: "AAAA" }) + + const result = await finalizeAndSubmit("req-1", { adapter: testAdapter(keypairs), submit }) + + expect(submit).toHaveBeenCalledTimes(1) + const submittedXdr = submit.mock.calls[0][0] as string + const parsedTx: any = TransactionBuilder.fromXDR(submittedXdr, request.envelope.networkPassphrase) + expect(parsedTx.signatures).toHaveLength(2) + + expect(CustodySequenceWatermark.findOneAndUpdate).toHaveBeenCalled() + expect(result?.status).toBe("submitted") + expect(logAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "custody.envelope.submitted" })) + }) + + it("refuses to finalize a request that is not quorum_reached (prevents double-submission)", async () => { + vi.mocked(CustodyApprovalRequest.findOneAndUpdate).mockReturnValue(lean(null)) + + await expect(finalizeAndSubmit("req-1", { submit: vi.fn() })).rejects.toThrow(CustodyServiceError) + }) + + it("rejects cross-intent tamper: recomputed operations hash no longer matches the approved hash", async () => { + const { keypairs, descriptors } = buildSigners() + const request = claimedRequest({ operationsHash: "0".repeat(64) }) + + vi.mocked(CustodyApprovalRequest.findOneAndUpdate).mockImplementation((query: any) => { + if (query.status === "quorum_reached") return lean(request) + return lean({ ...request, status: "failed" }) + }) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean({ threshold: 2, signers: descriptors })) + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + + const submit = vi.fn() + await expect(finalizeAndSubmit("req-1", { adapter: testAdapter(keypairs), submit })).rejects.toThrow( + /Cross-intent replay rejected/, + ) + expect(submit).not.toHaveBeenCalled() + expect(logAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "custody.envelope.submission_failed" })) + }) + + it("leaves the request in submitting (not failed, not retried) when submission is ambiguous", async () => { + const { keypairs, descriptors } = buildSigners() + const request = claimedRequest() + + vi.mocked(CustodyApprovalRequest.findOneAndUpdate).mockImplementation((query: any) => { + if (query.status === "quorum_reached") return lean(request) + throw new Error("must not attempt any further status transition for an ambiguous submission") + }) + vi.mocked(CustodySignerSet.findOne).mockReturnValue(lean({ threshold: 2, signers: descriptors })) + vi.mocked(CustodySequenceWatermark.findOne).mockReturnValue(lean(null)) + + const submit = vi.fn().mockRejectedValue(new AmbiguousSubmissionError("Horizon timeout, outcome unknown")) + + await expect(finalizeAndSubmit("req-1", { adapter: testAdapter(keypairs), submit })).rejects.toThrow(AmbiguousSubmissionError) + expect(logAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "custody.envelope.submission_ambiguous" })) + }) +}) + +describe("reconcileSubmission", () => { + it("only reconciles a request that is currently submitting", async () => { + vi.mocked(CustodyApprovalRequest.findById).mockReturnValue(lean({ status: "submitted" })) + + await expect(reconcileSubmission("req-1", { status: "submitted" })).rejects.toThrow(/Cannot reconcile/) + }) + + it("confirms a submission that actually landed and advances the watermark", async () => { + vi.mocked(CustodyApprovalRequest.findById).mockReturnValue( + lean({ status: "submitting", sourceAccount: SOURCE_ACCOUNT, network: "testnet", sequence: "101" }), + ) + vi.mocked(CustodyApprovalRequest.findOneAndUpdate).mockReturnValue(lean({ status: "submitted" })) + + const result = await reconcileSubmission("req-1", { + status: "submitted", + ledgerResult: { hash: "abc", ledger: 1, resultXdr: "AAAA" }, + }) + + expect(result.status).toBe("submitted") + expect(CustodySequenceWatermark.findOneAndUpdate).toHaveBeenCalled() + }) +}) + +describe("expireStaleRequests", () => { + it("only expires pending/quorum_reached requests past maxTime, never submitting ones", async () => { + vi.mocked(CustodyApprovalRequest.updateMany).mockResolvedValue({ modifiedCount: 3 } as any) + + const result = await expireStaleRequests(new Date()) + expect(result.expiredCount).toBe(3) + expect(CustodyApprovalRequest.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ status: { $in: ["pending", "quorum_reached"] } }), + expect.objectContaining({ $set: { status: "expired" } }), + ) + }) +}) diff --git a/__tests__/lib/custody/signer-adapter.test.ts b/__tests__/lib/custody/signer-adapter.test.ts new file mode 100644 index 00000000..f7af8884 --- /dev/null +++ b/__tests__/lib/custody/signer-adapter.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment node +// +// Real ed25519 keypair generation (via @noble/curves) needs a working +// crypto.getRandomValues, which jsdom's default test environment does not +// provide reliably. This module has no DOM dependency, so it runs under the +// plain Node environment instead of the project-wide jsdom default. +import { describe, it, expect, vi } from "vitest" +import crypto from "crypto" +import { Keypair } from "@stellar/stellar-sdk" +import { LocalDevSignerAdapter, createExternalSignerAdapter, createSignerAdapter, CustodyAdapterError } from "@/lib/custody/signer-adapter" + +function envWith(overrides: Partial): NodeJS.ProcessEnv { + return { + STELLAR_NETWORK: "testnet", + ENABLE_MOCK_STELLAR: "true", + ...overrides, + } as NodeJS.ProcessEnv +} + +describe("LocalDevSignerAdapter", () => { + it("is structurally testnet-only: throws when network is mainnet even if mock is claimed true", () => { + expect(() => new LocalDevSignerAdapter(envWith({ STELLAR_NETWORK: "mainnet" }))).toThrow(CustodyAdapterError) + }) + + it("throws when mock mode is not enabled, even on testnet", () => { + expect(() => + new LocalDevSignerAdapter( + envWith({ + ENABLE_MOCK_STELLAR: "false", + STELLAR_ISSUER_PUBLIC_KEY: "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H", + STELLAR_DISTRIBUTION_PUBLIC_KEY: "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA", + STELLAR_CONTRACT_ID: "C" + "A".repeat(55), + }), + ), + ).toThrow(CustodyAdapterError) + }) + + it("reads the network from shared config, not a caller-supplied value - there is no network parameter to spoof", () => { + const adapter = new LocalDevSignerAdapter(envWith({})) + expect(adapter).toBeInstanceOf(LocalDevSignerAdapter) + }) + + it("produces a verifiable ed25519 signature over the payload hash without ever exposing key material", async () => { + const adapter = new LocalDevSignerAdapter(envWith({})) + const publicKey = await adapter.getPublicKey("issuer-1") + const payloadHash = crypto.createHash("sha256").update("some transaction bytes").digest("hex") + const signature = await adapter.sign("issuer-1", payloadHash) + + // The adapter's public surface never returns anything but a public key + // (non-secret) and a base64 signature - assert both by type, and that + // the signature actually verifies against the returned public key. + expect(typeof publicKey).toBe("string") + expect(publicKey.startsWith("G")).toBe(true) + const verified = Keypair.fromPublicKey(publicKey).verify(Buffer.from(payloadHash, "hex"), Buffer.from(signature, "base64")) + expect(verified).toBe(true) + }) + + it("returns a stable public key for the same signerId within one adapter instance", async () => { + const adapter = new LocalDevSignerAdapter(envWith({})) + const first = await adapter.getPublicKey("issuer-1") + const second = await adapter.getPublicKey("issuer-1") + expect(first).toBe(second) + }) + + it("gives distinct signers distinct keys", async () => { + const adapter = new LocalDevSignerAdapter(envWith({})) + const a = await adapter.getPublicKey("issuer-1") + const b = await adapter.getPublicKey("issuer-2") + expect(a).not.toBe(b) + }) + + it("rejects a malformed payload hash", async () => { + const adapter = new LocalDevSignerAdapter(envWith({})) + await expect(adapter.sign("issuer-1", "not-a-hex-hash")).rejects.toThrow(CustodyAdapterError) + }) +}) + +describe("secret scanning", () => { + it("never puts raw key material in an audit-loggable form: getPublicKey/sign only return public data", async () => { + const adapter = new LocalDevSignerAdapter(envWith({})) + const publicKey = await adapter.getPublicKey("issuer-1") + const signature = await adapter.sign("issuer-1", crypto.createHash("sha256").update("x").digest("hex")) + + // Neither return value can be a raw ed25519 seed: a seed StrKey starts + // with "S" and is 56 chars; public keys start with "G" and signatures + // are base64, never StrKey-encoded at all. + expect(publicKey.startsWith("S")).toBe(false) + expect(signature.startsWith("S")).toBe(false) + expect(/^[A-Za-z0-9+/]+=*$/.test(signature)).toBe(true) + }) + + it("LocalDevSignerAdapter has no method or property that returns a private key", () => { + const adapter = new LocalDevSignerAdapter(envWith({})) + const members = Object.getOwnPropertyNames(Object.getPrototypeOf(adapter)) + for (const member of members) { + expect(member.toLowerCase()).not.toContain("secret") + expect(member.toLowerCase()).not.toContain("seed") + expect(member.toLowerCase()).not.toContain("privatekey") + } + }) +}) + +describe("createExternalSignerAdapter", () => { + it("throws until a production adapter is injected, never silently falling back to a local secret", () => { + expect(() => createExternalSignerAdapter()).toThrow(/CUSTODY_ADAPTER_NOT_CONFIGURED/) + }) +}) + +describe("createSignerAdapter", () => { + it("resolves to LocalDevSignerAdapter only for testnet+mock", () => { + const adapter = createSignerAdapter(envWith({})) + expect(adapter).toBeInstanceOf(LocalDevSignerAdapter) + }) + + it("resolves to the external adapter contract (which throws unconfigured) for mainnet", () => { + expect(() => createSignerAdapter(envWith({ STELLAR_NETWORK: "mainnet", ENABLE_MOCK_STELLAR: "false", STELLAR_ISSUER_PUBLIC_KEY: "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H", STELLAR_DISTRIBUTION_PUBLIC_KEY: "GABAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEJXA", STELLAR_CONTRACT_ID: "C" + "A".repeat(55) }))).toThrow( + /CUSTODY_ADAPTER_NOT_CONFIGURED/, + ) + }) +}) diff --git a/docs/custody-signer-rotation.md b/docs/custody-signer-rotation.md new file mode 100644 index 00000000..a5f308d8 --- /dev/null +++ b/docs/custody-signer-rotation.md @@ -0,0 +1,227 @@ +# Custody: Threshold Signing And Signer Rotation + +This document covers the issuer/distribution/treasury custody control plane +implemented in `lib/custody/`. It follows the same conventions as +[configuration-and-key-rotation.md](./configuration-and-key-rotation.md) and +[tamper-evident-audit.md](./tamper-evident-audit.md): a local reference +implementation for contributor/testnet use, a documented adapter contract for +production, and every state change recorded in the tamper-evident audit log. + +## Two layers + +1. **Control plane** (`lib/custody/rotation.ts`, `models/CustodySignerSet.ts`): + the platform's internal record of which signers, roles, and thresholds are + authorized to approve each operation category, per network. Proposing, + approving, activating, retiring, and rolling back a signer set never + touches the Stellar ledger by itself. +2. **Envelopes** (`lib/custody/envelope.ts`, `lib/custody/service.ts`, + `models/CustodyApprovalRequest.ts`): individual custody operations + (issuance, payout, emergency, recovery, or executing a rotation on-chain) + go through `requestApproval` → `approve` (repeated until quorum) → + `finalizeAndSubmit`, which builds the real Stellar transaction, collects + signatures through a `SignerAdapter`, and submits it. + +Rotating the control-plane signer set for the `rotation` category and +actually changing a Stellar account's on-chain signers/thresholds (via +`Operation.setOptions`) are two different, sequenced actions: the +control-plane rotation authorizes who can approve the on-chain change; the +on-chain change itself is a normal envelope with +`intent.operation: "rotation.setSignerOptions"`. + +## Roles and default thresholds + +| Category | Eligible roles | Min signers | Min threshold | Notes | +|-----------|----------------------------------|-------------|----------------|-------| +| issuance | issuer | 3 | 2 | Mirrors issuer 2-of-3/3-of-5 cold multisig | +| payout | distribution | 2 | 2 | Destination allowlist + amount/daily limits enforced | +| emergency | security, issuer | 2 | 1 | Fast freeze/revoke path | +| recovery | recovery | 5 | 3 | Used when the standard quorum is unavailable | +| rotation | issuer, distribution, security | 3 | 2 (2 distinct roles) | Authorizes control-plane and on-chain rotation | + +Defaults live in `lib/custody/policy.ts` as code, not secrets. The signers, +public keys, weights, and threshold actually in force for a deployment are +stored only in the DB-persisted `CustodySignerSet` document - never in env +vars or `lib/stellar/config.ts` (which is restricted to public identifiers). +`validateSignerSetInvariants()` rejects any proposed set where the threshold +exceeds total signer weight (a permanent lockout) or the category's minimum +signer/threshold/role-diversity requirements aren't met. + +Quorum everywhere (envelope approvals, rotation approvals) is **weighted**, +not headcount-based: `sumApprovedWeight()` sums the `weight` of each +distinct approving signer and compares against `threshold`, matching +Stellar's own weighted-multisig semantics rather than a plain "N people" +count. + +`payout` additionally requires a `payoutPolicy` on the active signer set +(`allowedDestinations`, optional `maxAmount`/`dailyLimit`, all in stroops). +`requestApproval` refuses any payout category request if the signer set has +no `payoutPolicy` configured, and enforces the allowlist/limits via +`assertPayoutWithinPolicy` before a request is ever created - a compromised +caller cannot supply its own allowlist, since it is read from the persisted +signer set, never from the request input. + +## Signer adapter contract + +No app process ever receives a raw signing secret. `SignerAdapter` +(`lib/custody/types.ts`) exposes only `getPublicKey(signerId)` and +`sign(signerId, payloadHash)`; both return public data (a public key or a +detached signature), never key material. + +- `LocalDevSignerAdapter` (`lib/custody/signer-adapter.ts`) is a + testnet-only reference implementation for contributor use and CI. It + never reads or persists any secret - each `signerId` gets an ephemeral, + process-local ed25519 keypair generated in memory. Its constructor reads + the network from `getStellarConfig()` (not a caller-supplied value) and + throws unless `STELLAR_NETWORK=testnet` and `ENABLE_MOCK_STELLAR=true`, + so it is structurally incapable of running against mainnet. +- `createExternalSignerAdapter()` is a contract stub: it throws + `CUSTODY_ADAPTER_NOT_CONFIGURED` until a deployment injects a real + KMS/HSM/external-signer implementation. This repo does not mandate or + implement a specific vendor. + +## Rotation runbook + +A rotation for *any* category (issuance, payout, emergency, recovery, or +rotation itself) is only ever approved by the **currently active `rotation` +(or, for the recovery path, `recovery`) category signer set** - +`approveRotation` looks up that governing set and rejects any `approvedBy`/ +`role` that isn't a real, currently-authorized signer within it. This is +deliberate: it prevents a caller from fabricating approver identities to +satisfy quorum, and it means a category cannot approve its own replacement. + +1. `proposeRotation({ category, network, signers, threshold, createdBy })` - + validates invariants and creates a `pending` `CustodySignerSet` version. +2. `approveRotation({ signerSetId, approvedBy, role, quorumType: "standard" })` + repeated by distinct, eligible governing-set signers until the governing + set's threshold weight is met. +3. `activateRotation(signerSetId)` - the new set becomes `active`; the + previous active set (if any) becomes `retiring` with an overlap window + (`overlapWindowMs`, default 24h) during which **both** sets remain valid + for collecting approvals on requests already pinned to the old version. +4. `retireIfSafe(signerSetId)` - the only path to `retired`. Refuses while + the overlap window hasn't elapsed, or while any `CustodyApprovalRequest` + still references the retiring version with a non-terminal status. Call + this periodically (e.g. from a scheduled job) rather than on a timer that + force-retires. +5. If a rotation must be undone, `rollbackRotation(signerSetId, { reason })` + is reachable while the new set is `pending`, or after activation but + before `retireIfSafe` has retired the previous set - it reactivates the + previous set *before* marking the current one `rolled_back`, so a + rollback can never leave the category with zero active signer sets; if + the previous set has already fully retired, the rollback aborts instead + and the active set is left unchanged. + +### Bootstrap (genesis) + +A brand new category/network has no governing signer set yet, so +`approveRotation` has nothing to check approvals against. ` +seedGenesisSignerSet({ category, network, signers, threshold, createdBy, +confirmationToken: "CONFIRM_GENESIS_SIGNER_SET" })` creates the first +signer set directly as `active`, bypassing the approval flow - this is an +explicit, audited, out-of-band trust-anchor decision (same confirmation-token +idiom as `scripts/audit-migrate.ts`'s `cleanupOldAuditLogs`), meant to run +once per category/network from an operational script, never from an +automated or HTTP-reachable path. + +### Recovery quorum (lost signer / compromise) + +When a normal-quorum signer is lost or suspected compromised, +`approveRotation({ ..., quorumType: "recovery" })` routes approval through +the `recovery` category's signer pool and threshold instead of the +(possibly unavailable) standard rotation quorum. `activateRotation` detects +which quorum type was used from the first recorded approval and checks +against the matching threshold. + +## Incident scenarios + +**Signer compromise.** Immediately propose a rotation excluding the +compromised signer (standard quorum if enough uncompromised signers remain, +otherwise recovery quorum). Do not wait for the overlap window on the +compromised set - `retireIfSafe` will hold it in `retiring` until pending +requests clear, but a compromised signer can no longer collect new +approvals once the new set is `active` and is the one `requestApproval` +resolves for new requests. + +**Lost signer.** If the lost signer is not suspected compromised, a standard +rotation is enough once the remaining signers still meet the category +threshold. If they don't, use the recovery quorum path. + +**Stuck sequence.** If Horizon accepts a transaction but the response is +lost (timeout, crash between submit and write), `finalizeAndSubmit`'s +`submit` callback should throw `AmbiguousSubmissionError` rather than being +retried. The request is left in `submitting`. An operator must query +Horizon/an explorer for the transaction hash and call +`reconcileSubmission(requestId, { status: "submitted", ledgerResult })` or +`{ status: "failed", reason }` once the true outcome is known. Never build +and submit a second transaction for the same request while it is in +`submitting` - the atomic `quorum_reached -> submitting` claim in +`finalizeAndSubmit` prevents a second `finalizeAndSubmit` call from doing +so, but a manual resubmission outside this module could still double-spend +the sequence number. The default `submit` (when no `options.submit` is +injected) classifies any Horizon rejection that carries an HTTP response +(bad sequence, malformed transaction, insufficient signature weight, etc.) +as a definite failure, and anything without one (timeout, connection reset, +DNS failure) as `AmbiguousSubmissionError`, since Horizon may have applied +the transaction anyway. + +**Partial signature / signer outage.** `approve()` accepts approvals one at +a time and only promotes a request to `quorum_reached` once enough distinct +eligible signers have responded; a request with fewer than the threshold +stays `pending` indefinitely (until `maxTime`, when `expireStaleRequests` +marks it `expired`) without blocking other requests or other signers. There +is no partial-signature submission: `finalizeAndSubmit` refuses to build a +transaction unless quorum is met. + +**Outage.** If Horizon or the network is unavailable for longer than a +request's approval window, the request expires (`expireStaleRequests`) and +must be re-requested with a fresh envelope (new sequence, new time bounds) +once the network recovers - an expired envelope can never be resubmitted. + +## Replay protection + +- **Cross-network:** `assertEnvelopeFresh` rejects an envelope whose network + or network passphrase doesn't match the currently configured network. +- **Stale/replayed sequence:** rejected against the per-source-account + `CustodySequenceWatermark`, and the DB unique index on + `(sourceAccount, network, sequence)` prevents two in-flight requests from + ever targeting the same sequence. +- **Cross-intent:** `computeEnvelopeHash` includes the intent, so an + envelope can never back two different requests. At `finalizeAndSubmit`, + the operations are rebuilt from the stored intent and their hash is + compared against the hash captured when the request was created + (`operationsHash`); a mismatch aborts before any signature is collected. +- **Terminal states are immutable:** `submitted`/`failed`/`expired` + approval requests and `retired`/`rolled_back` signer sets are enforced + immutable at the Mongoose hook level, so a completed or abandoned + envelope can never be edited back into a resubmittable state. + +## Known limitations + +- `rollbackRotation`: if reactivating the previous set succeeds but the + subsequent flip of the current set to `rolled_back` then conflicts (a + concurrent caller already changed it), both sets can briefly end up + `active` for the same category/network. This never leaves zero active + sets (the original bug), but resolving the duplicate today requires + manual intervention rather than a safe retry. +- The payout daily-limit check in `requestApproval` (`sumPendingOrSubmittedPayoutStroopsToday`) + is read-then-act, not atomic: two concurrent payout requests could each + read a total under the limit and jointly exceed it. Multisig approval + latency makes this a low-probability window; a hard guarantee would need + an atomic counter. +- The default `submit` in `finalizeAndSubmit` can classify a timed-out + pre-submission `checkMemoRequired` lookup (which runs before the + transaction is ever posted) as `AmbiguousSubmissionError`, even though no + submission was attempted. This only routes an occasional definite + non-submission to manual reconciliation instead of auto-`failed`; it + fails safe and cannot cause a double-submission. + +## Audit trail + +Every control-plane and envelope state change calls `logAuditEvent(..., +criticalAction: true)`, which writes to the append-only, hash-chained +tamper-evident audit log described in +[tamper-evident-audit.md](./tamper-evident-audit.md). This is what makes +every envelope traceable end to end: `custody.approval.requested` → +`custody.approval.granted` (one per signer) → `custody.envelope.submitted` +(or `.submission_failed` / `.submission_ambiguous`), plus +`custody.rotation.*` for control-plane changes. diff --git a/lib/custody/canonical.ts b/lib/custody/canonical.ts new file mode 100644 index 00000000..3f05bf72 --- /dev/null +++ b/lib/custody/canonical.ts @@ -0,0 +1,20 @@ +import crypto from "crypto" + +// Deterministic key-sorted JSON, same idiom as lib/stellar/pool-assets.ts and +// lib/security/audit-hash.ts, kept local so this module has no dependency on +// unrelated call sites. +export function canonicalize(value: unknown): string { + if (value === null || value === undefined) return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]` + if (typeof value === "object") { + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${canonicalize(child)}`) + return `{${entries.join(",")}}` + } + return JSON.stringify(value) +} + +export function canonicalHash(value: unknown): string { + return crypto.createHash("sha256").update(canonicalize(value)).digest("hex") +} diff --git a/lib/custody/envelope.ts b/lib/custody/envelope.ts new file mode 100644 index 00000000..ba85a35b --- /dev/null +++ b/lib/custody/envelope.ts @@ -0,0 +1,111 @@ +import { Networks } from "@stellar/stellar-sdk" +import { getStellarConfig } from "@/lib/stellar/config" +import { isValidStellarPublicKey, normalizeStellarPublicKey } from "@/lib/validation/stellar" +import { canonicalHash } from "./canonical" +import type { CustodyEnvelope, EnvelopeIntent, EnvelopeMemo } from "./types" + +export class EnvelopeValidationError extends Error {} + +const NETWORK_PASSPHRASES: Record = { + testnet: Networks.TESTNET, + mainnet: Networks.PUBLIC, +} + +export function getNetworkPassphrase(network: string): string { + const passphrase = NETWORK_PASSPHRASES[network] + if (!passphrase) { + throw new EnvelopeValidationError(`Unsupported network: ${network}`) + } + return passphrase +} + +export interface BuildEnvelopeInput { + sourceAccount: string + sequence: string + minTime: Date + maxTime: Date + memo?: EnvelopeMemo + intent: EnvelopeIntent + env?: NodeJS.ProcessEnv +} + +// Binds network, source account, sequence, time bounds, memo, and intent +// into one canonical structure. The resulting hash (computeEnvelopeHash) is +// the unit of replay protection: it can never be reused across networks or +// intents because both are inputs to the hash. +export function buildEnvelope(input: BuildEnvelopeInput): CustodyEnvelope { + const config = getStellarConfig(input.env) + const sourceAccount = normalizeStellarPublicKey(input.sourceAccount) + if (!isValidStellarPublicKey(sourceAccount)) { + throw new EnvelopeValidationError("Invalid envelope source account") + } + if (!/^\d+$/.test(input.sequence)) { + throw new EnvelopeValidationError("Envelope sequence must be a non-negative integer string") + } + if (input.maxTime.getTime() <= input.minTime.getTime()) { + throw new EnvelopeValidationError("Envelope maxTime must be after minTime") + } + if (!input.intent.operation || !input.intent.category) { + throw new EnvelopeValidationError("Envelope intent requires a category and operation") + } + + return { + network: config.network, + networkPassphrase: getNetworkPassphrase(config.network), + sourceAccount, + sequence: input.sequence, + minTime: input.minTime.toISOString(), + maxTime: input.maxTime.toISOString(), + memo: input.memo ?? { type: "none" }, + intent: input.intent, + } +} + +export function computeEnvelopeHash(envelope: CustodyEnvelope): string { + return canonicalHash(envelope) +} + +export interface EnvelopeFreshnessInput { + envelope: CustodyEnvelope + now?: Date + lastConsumedSequence?: string + env?: NodeJS.ProcessEnv +} + +// Rejects cross-network replay (network/passphrase must match the currently +// configured network), expired or not-yet-valid approval windows, and +// stale/replayed sequence numbers. Cross-intent replay is rejected +// separately by recomputing the operations hash immediately before signing +// (see lib/custody/operations.ts computeOperationsHash). +export function assertEnvelopeFresh(input: EnvelopeFreshnessInput): void { + const config = getStellarConfig(input.env) + const now = input.now ?? new Date() + + if (input.envelope.network !== config.network) { + throw new EnvelopeValidationError( + `Cross-network replay rejected: envelope network "${input.envelope.network}" does not match configured network "${config.network}"`, + ) + } + if (input.envelope.networkPassphrase !== getNetworkPassphrase(config.network)) { + throw new EnvelopeValidationError("Cross-network replay rejected: network passphrase mismatch") + } + + const minTime = new Date(input.envelope.minTime) + const maxTime = new Date(input.envelope.maxTime) + if (now.getTime() < minTime.getTime()) { + throw new EnvelopeValidationError("Envelope is not yet valid (before minTime)") + } + if (now.getTime() > maxTime.getTime()) { + throw new EnvelopeValidationError("Envelope has expired (past maxTime); replay rejected") + } + + if (input.lastConsumedSequence !== undefined) { + const sequence = BigInt(input.envelope.sequence) + const watermark = BigInt(input.lastConsumedSequence) + if (sequence <= watermark) { + throw new EnvelopeValidationError( + `Stale sequence rejected: envelope sequence ${sequence} is not greater than last consumed sequence ${watermark}`, + ) + } + } +} diff --git a/lib/custody/operations.ts b/lib/custody/operations.ts new file mode 100644 index 00000000..05f39025 --- /dev/null +++ b/lib/custody/operations.ts @@ -0,0 +1,87 @@ +import { Asset, Operation } from "@stellar/stellar-sdk" +import { isValidStellarPublicKey, normalizeStellarPublicKey } from "@/lib/validation/stellar" +import { canonicalHash } from "./canonical" +import type { EnvelopeIntent } from "./types" + +export class OperationBuildError extends Error {} + +export interface PaymentParams { + destination: string + assetCode: string + assetIssuer?: string + amount: string +} + +export interface SetSignerOptionsParams { + signer?: { publicKey: string; weight: number } + masterWeight?: number + lowThreshold?: number + medThreshold?: number + highThreshold?: number +} + +function buildPayment(params: PaymentParams) { + if (!params || typeof params.destination !== "string" || typeof params.amount !== "string") { + throw new OperationBuildError("Payment requires destination and amount") + } + const destination = normalizeStellarPublicKey(params.destination) + if (!isValidStellarPublicKey(destination)) { + throw new OperationBuildError("Invalid payment destination") + } + + const asset = + params.assetCode === "native" || params.assetCode === "XLM" + ? Asset.native() + : new Asset(params.assetCode, normalizeStellarPublicKey(params.assetIssuer || "")) + + return [Operation.payment({ destination, asset, amount: params.amount })] +} + +function buildSetSignerOptions(params: SetSignerOptionsParams) { + if (!params || typeof params !== "object") { + throw new OperationBuildError("setSignerOptions requires parameters") + } + const options: Record = {} + if (params.signer) { + const publicKey = normalizeStellarPublicKey(params.signer.publicKey) + if (!isValidStellarPublicKey(publicKey)) { + throw new OperationBuildError("Invalid signer public key in setSignerOptions") + } + options.signer = { ed25519PublicKey: publicKey, weight: params.signer.weight } + } + if (params.masterWeight !== undefined) options.masterWeight = params.masterWeight + if (params.lowThreshold !== undefined) options.lowThreshold = params.lowThreshold + if (params.medThreshold !== undefined) options.medThreshold = params.medThreshold + if (params.highThreshold !== undefined) options.highThreshold = params.highThreshold + + if (Object.keys(options).length === 0) { + throw new OperationBuildError("setSignerOptions requires at least one field to change") + } + + return [Operation.setOptions(options)] +} + +// Registry of intent.operation -> Stellar operation builder. Extend this +// when a new on-chain custody action is needed; every entry must be a pure +// function of its params so computeOperationsHash stays deterministic. +export function buildOperations(intent: EnvelopeIntent) { + switch (intent.operation) { + case "distribution.payment": + return buildPayment(intent.params as unknown as PaymentParams) + case "rotation.setSignerOptions": + case "emergency.setSignerOptions": + return buildSetSignerOptions(intent.params as unknown as SetSignerOptionsParams) + default: + throw new OperationBuildError(`No operation builder registered for "${intent.operation}"`) + } +} + +// Recomputed immediately before signing/submission and compared against the +// hash captured when the approval request was created. A mismatch means the +// operation-builder code path produced different on-chain operations than +// what signers actually approved, and finalize must refuse to sign. +export function computeOperationsHash(intent: EnvelopeIntent): string { + const operations = buildOperations(intent) + const encoded = operations.map((operation) => operation.toXDR("base64")) + return canonicalHash(encoded) +} diff --git a/lib/custody/policy.ts b/lib/custody/policy.ts new file mode 100644 index 00000000..85f4ebad --- /dev/null +++ b/lib/custody/policy.ts @@ -0,0 +1,192 @@ +import { isValidStellarPublicKey, normalizeStellarPublicKey } from "@/lib/validation/stellar" +import type { OperationCategory, SignerDescriptor, SignerRole } from "./types" + +export interface ThresholdPolicy { + category: OperationCategory + minSigners: number + minThreshold: number + eligibleRoles: SignerRole[] + minDistinctRoles: number + maxApprovalWindowMs: number + requireDestinationAllowlist?: boolean +} + +// Defaults mirror docs/stellar-asset-and-soroban-design.md (issuer multisig +// 2-of-3 or 3-of-5, rotated hot distribution signer, governance/multisig +// treasury actions). These are policy defaults, not secrets - the effective +// signers/threshold for a deployment live in the DB-persisted +// CustodySignerSet document, never in env vars or lib/stellar/config.ts. +export const DEFAULT_THRESHOLD_POLICIES: Record = { + issuance: { + category: "issuance", + minSigners: 3, + minThreshold: 2, + eligibleRoles: ["issuer"], + minDistinctRoles: 1, + maxApprovalWindowMs: 30 * 60 * 1000, + }, + payout: { + category: "payout", + minSigners: 2, + minThreshold: 2, + eligibleRoles: ["distribution"], + minDistinctRoles: 1, + maxApprovalWindowMs: 15 * 60 * 1000, + requireDestinationAllowlist: true, + }, + emergency: { + category: "emergency", + minSigners: 2, + minThreshold: 1, + eligibleRoles: ["security", "issuer"], + minDistinctRoles: 1, + maxApprovalWindowMs: 10 * 60 * 1000, + }, + recovery: { + category: "recovery", + minSigners: 5, + minThreshold: 3, + eligibleRoles: ["recovery"], + minDistinctRoles: 1, + maxApprovalWindowMs: 60 * 60 * 1000, + }, + rotation: { + category: "rotation", + minSigners: 3, + minThreshold: 2, + eligibleRoles: ["issuer", "distribution", "security"], + minDistinctRoles: 2, + maxApprovalWindowMs: 60 * 60 * 1000, + }, +} + +export function getThresholdPolicy(category: OperationCategory): ThresholdPolicy { + return DEFAULT_THRESHOLD_POLICIES[category] +} + +export class SignerSetInvariantError extends Error {} + +// Guards against the two ways a Stellar-style weighted threshold can be +// misconfigured into a permanent lockout: a threshold higher than the +// signer weights can ever reach, or too few eligible/distinct-role signers +// to ever satisfy separation of duties for this category. +export function validateSignerSetInvariants(input: { + category: OperationCategory + signers: SignerDescriptor[] + threshold: number +}): void { + const policy = getThresholdPolicy(input.category) + const { signers, threshold } = input + + if (signers.length === 0) { + throw new SignerSetInvariantError("Signer set must include at least one signer") + } + + const signerIds = new Set() + const publicKeys = new Set() + let totalWeight = 0 + + for (const signer of signers) { + if (signerIds.has(signer.signerId)) { + throw new SignerSetInvariantError(`Duplicate signerId: ${signer.signerId}`) + } + signerIds.add(signer.signerId) + + const normalizedKey = normalizeStellarPublicKey(signer.publicKey) + if (!isValidStellarPublicKey(normalizedKey)) { + throw new SignerSetInvariantError(`Invalid signer public key for ${signer.signerId}`) + } + if (publicKeys.has(normalizedKey)) { + throw new SignerSetInvariantError(`Duplicate signer public key: ${normalizedKey}`) + } + publicKeys.add(normalizedKey) + + if (!Number.isInteger(signer.weight) || signer.weight <= 0) { + throw new SignerSetInvariantError(`Signer weight must be a positive integer for ${signer.signerId}`) + } + totalWeight += signer.weight + } + + if (!Number.isInteger(threshold) || threshold <= 0) { + throw new SignerSetInvariantError("Threshold must be a positive integer") + } + if (threshold > totalWeight) { + throw new SignerSetInvariantError( + `Threshold (${threshold}) exceeds total signer weight (${totalWeight}); this would permanently lock the account`, + ) + } + if (signers.length < policy.minSigners) { + throw new SignerSetInvariantError(`${input.category} requires at least ${policy.minSigners} signers`) + } + if (threshold < policy.minThreshold) { + throw new SignerSetInvariantError(`${input.category} requires a threshold of at least ${policy.minThreshold}`) + } + + const eligibleCount = signers.filter((signer) => policy.eligibleRoles.includes(signer.role)).length + if (eligibleCount < policy.minThreshold) { + throw new SignerSetInvariantError( + `${input.category} requires at least ${policy.minThreshold} signers with an eligible role (${policy.eligibleRoles.join(", ")})`, + ) + } + + const distinctRoles = new Set(signers.map((signer) => signer.role)) + if (distinctRoles.size < policy.minDistinctRoles) { + throw new SignerSetInvariantError( + `${input.category} requires signers spanning at least ${policy.minDistinctRoles} distinct role(s)`, + ) + } +} + +// Quorum is weighted, not headcount-based, so it matches the same +// signer-weight model validateSignerSetInvariants enforces (and Stellar's +// own on-chain weighted-threshold semantics). A distinct approver who is no +// longer part of the signer set contributes no weight. +export function sumApprovedWeight( + approvals: Array<{ signerId: string }>, + signers: Array<{ signerId: string; weight: number }>, +): number { + const distinctApprovers = new Set(approvals.map((approval) => approval.signerId)) + let total = 0 + for (const signerId of distinctApprovers) { + const signer = signers.find((candidate) => candidate.signerId === signerId) + if (signer) total += signer.weight + } + return total +} + +export class PolicyViolationError extends Error {} + +export interface PayoutLimitCheck { + amount: string + destination: string + allowedDestinations: string[] + maxAmount?: string + dailyTotalSoFar?: string + dailyLimit?: string +} + +// Amounts are integer stroop strings (1 XLM/unit = 10,000,000 stroops) so +// limits can be compared exactly with BigInt instead of floating point. +export function assertPayoutWithinPolicy(input: PayoutLimitCheck): void { + const destination = normalizeStellarPublicKey(input.destination) + if (!isValidStellarPublicKey(destination)) { + throw new PolicyViolationError("Invalid payout destination public key") + } + if (!input.allowedDestinations.map(normalizeStellarPublicKey).includes(destination)) { + throw new PolicyViolationError(`Destination ${destination} is not on the payout allowlist`) + } + + const amount = BigInt(input.amount) + if (amount <= BigInt(0)) { + throw new PolicyViolationError("Payout amount must be positive") + } + if (input.maxAmount !== undefined && amount > BigInt(input.maxAmount)) { + throw new PolicyViolationError(`Payout amount exceeds per-operation limit of ${input.maxAmount}`) + } + if (input.dailyLimit !== undefined) { + const dailyTotal = BigInt(input.dailyTotalSoFar ?? "0") + amount + if (dailyTotal > BigInt(input.dailyLimit)) { + throw new PolicyViolationError(`Payout would exceed daily limit of ${input.dailyLimit}`) + } + } +} diff --git a/lib/custody/rotation.ts b/lib/custody/rotation.ts new file mode 100644 index 00000000..00627109 --- /dev/null +++ b/lib/custody/rotation.ts @@ -0,0 +1,365 @@ +import dbConnect from "@/lib/dbConnect" +import CustodySignerSet, { type CustodyQuorumType } from "@/models/CustodySignerSet" +import CustodyApprovalRequest from "@/models/CustodyApprovalRequest" +import { logAuditEvent } from "@/lib/security/audit-log" +import { validateSignerSetInvariants, sumApprovedWeight } from "./policy" +import type { OperationCategory, SignerDescriptor, SignerRole } from "./types" + +export class RotationError extends Error {} + +// Rotations (and, via the recovery quorum type, lost-signer/compromise +// recovery) are always approved by the "rotation" or "recovery" category's +// own currently active signer set - never by the category being rotated, +// and never by an unvetted caller-supplied identity. This is what makes +// approveRotation safe: an attacker cannot fabricate approvedBy identities +// to satisfy quorum, because every approval is checked against real, +// currently-authorized signers. +async function resolveGoverningSignerSet(network: string, quorumType: CustodyQuorumType) { + const governingCategory: OperationCategory = quorumType === "recovery" ? "recovery" : "rotation" + const governingSet = (await CustodySignerSet.findOne({ + category: governingCategory, + network, + status: "active", + }).lean()) as any + if (!governingSet) { + throw new RotationError( + `No active ${governingCategory} signer set is configured for ${network}; seed one with seedGenesisSignerSet before any rotation can be approved`, + ) + } + return governingSet +} + +export interface ProposeRotationInput { + category: OperationCategory + network: string + signers: SignerDescriptor[] + threshold: number + overlapWindowMs?: number + createdBy: string + requestId?: string +} + +// Off-chain control-plane rotation: proposes a new authorized signer set for +// a category. This does not itself change any on-chain Stellar account - +// executing the equivalent on-chain setOptions change (if required) is a +// normal custody operation submitted through lib/custody/service.ts using +// the "rotation" category's currently active signer set for authorization. +export async function proposeRotation(input: ProposeRotationInput) { + await dbConnect() + validateSignerSetInvariants({ category: input.category, signers: input.signers, threshold: input.threshold }) + + const network = input.network.toLowerCase() + const current = (await CustodySignerSet.findOne({ category: input.category, network, status: "active" }) + .sort({ version: -1 }) + .lean()) as any + const pendingExisting = await CustodySignerSet.findOne({ category: input.category, network, status: "pending" }).lean() + if (pendingExisting) { + throw new RotationError("A rotation is already pending for this category/network") + } + + const version = (current?.version ?? 0) + 1 + const signerSet = await CustodySignerSet.create({ + category: input.category, + network, + version, + previousVersion: current?.version, + status: "pending", + signers: input.signers, + threshold: input.threshold, + overlapWindowMs: input.overlapWindowMs ?? 24 * 60 * 60 * 1000, + rotationApprovals: [], + createdBy: input.createdBy, + requestId: input.requestId, + }) + + await logAuditEvent({ + action: "custody.rotation.proposed", + targetType: "custody_signer_set", + targetId: signerSet._id.toString(), + requestId: input.requestId, + metadata: { category: input.category, network, version, threshold: input.threshold, signerCount: input.signers.length }, + criticalAction: true, + }) + + return signerSet.toObject() +} + +export interface ApproveRotationInput { + signerSetId: string + approvedBy: string + role: SignerRole + quorumType: CustodyQuorumType + requestId?: string +} + +// quorumType "recovery" is the lost-signer/compromise path: it is approved +// by the recovery-eligible signer pool using the recovery category's +// (larger) threshold instead of the standard rotation threshold, so a +// rotation can still proceed when the normal quorum is unavailable. +export async function approveRotation(input: ApproveRotationInput) { + await dbConnect() + const signerSet = (await CustodySignerSet.findById(input.signerSetId).lean()) as any + if (!signerSet) throw new RotationError("Signer set not found") + if (signerSet.status !== "pending") throw new RotationError(`Cannot approve rotation in status ${signerSet.status}`) + + const governingSet = await resolveGoverningSignerSet(signerSet.network, input.quorumType) + const eligible = governingSet.signers.find((signer: any) => signer.signerId === input.approvedBy && signer.role === input.role) + if (!eligible) { + throw new RotationError( + `"${input.approvedBy}" is not an eligible ${input.role} signer in the active ${governingSet.category} signer set`, + ) + } + + const alreadyApproved = (signerSet.rotationApprovals || []).some((approval: any) => approval.approvedBy === input.approvedBy) + if (alreadyApproved) throw new RotationError(`${input.approvedBy} has already approved this rotation`) + + const updated = (await CustodySignerSet.findOneAndUpdate( + { _id: input.signerSetId, status: "pending" }, + { + $push: { + rotationApprovals: { + approvedBy: input.approvedBy, + role: input.role, + quorumType: input.quorumType, + approvedAt: new Date(), + }, + }, + }, + { new: true, runValidators: true }, + ).lean()) as any + if (!updated) throw new RotationError("Rotation approval conflict; reload and retry") + + await logAuditEvent({ + action: "custody.rotation.approved", + targetType: "custody_signer_set", + targetId: input.signerSetId, + requestId: input.requestId, + metadata: { approvedBy: input.approvedBy, role: input.role, quorumType: input.quorumType }, + criticalAction: true, + }) + + const approvalsOfType = (updated.rotationApprovals || []) + .filter((approval: any) => approval.quorumType === input.quorumType) + .map((approval: any) => ({ signerId: approval.approvedBy })) + const approvedWeight = sumApprovedWeight(approvalsOfType, governingSet.signers) + + return { signerSet: updated, quorumMet: approvedWeight >= governingSet.threshold } +} + +// Activates a pending signer set once its quorum is met. The previous +// active set (if any) moves to "retiring" with an overlap window during +// which BOTH sets remain valid for collecting approvals on in-flight +// requests - this is the overlap-safe part of rotation. +export async function activateRotation(signerSetId: string, options: { requestId?: string } = {}) { + await dbConnect() + const pending = (await CustodySignerSet.findById(signerSetId).lean()) as any + if (!pending) throw new RotationError("Signer set not found") + if (pending.status !== "pending") throw new RotationError(`Cannot activate rotation in status ${pending.status}`) + + const approvals = pending.rotationApprovals || [] + const usedQuorumType: CustodyQuorumType = approvals[0]?.quorumType === "recovery" ? "recovery" : "standard" + const governingSet = await resolveGoverningSignerSet(pending.network, usedQuorumType) + const approvalsOfType = approvals + .filter((approval: any) => approval.quorumType === usedQuorumType) + .map((approval: any) => ({ signerId: approval.approvedBy })) + const approvedWeight = sumApprovedWeight(approvalsOfType, governingSet.signers) + if (approvedWeight < governingSet.threshold) { + throw new RotationError(`Rotation quorum not met: weight ${approvedWeight}/${governingSet.threshold}`) + } + + const now = new Date() + const activated = (await CustodySignerSet.findOneAndUpdate( + { _id: signerSetId, status: "pending" }, + { $set: { status: "active", effectiveFrom: now } }, + { new: true, runValidators: true }, + ).lean()) as any + if (!activated) throw new RotationError("Activation conflict; reload and retry") + + let retiring: any = null + if (pending.previousVersion !== undefined) { + retiring = await CustodySignerSet.findOneAndUpdate( + { category: pending.category, network: pending.network, version: pending.previousVersion, status: "active" }, + { $set: { status: "retiring", effectiveTo: new Date(now.getTime() + pending.overlapWindowMs) } }, + { new: true, runValidators: true }, + ).lean() + } + + await logAuditEvent({ + action: "custody.rotation.activated", + targetType: "custody_signer_set", + targetId: signerSetId, + requestId: options.requestId, + metadata: { + category: pending.category, + network: pending.network, + version: pending.version, + previousVersion: pending.previousVersion, + overlapUntil: retiring?.effectiveTo, + quorumType: usedQuorumType, + }, + criticalAction: true, + }) + + return { active: activated, retiring } +} + +// The sole path to a "retired" signer set. Refuses while any non-terminal +// approval request still references this set version, so continuity is +// preserved for approvals collected before rotation began - this is what +// "rotation with pending approvals" protects against. +export async function retireIfSafe(signerSetId: string, options: { requestId?: string; now?: Date } = {}) { + await dbConnect() + const signerSet = (await CustodySignerSet.findById(signerSetId).lean()) as any + if (!signerSet) throw new RotationError("Signer set not found") + if (signerSet.status !== "retiring") { + return { retired: false, reason: `Signer set status is ${signerSet.status}, not retiring` } + } + + const now = options.now ?? new Date() + if (signerSet.effectiveTo && now.getTime() < new Date(signerSet.effectiveTo).getTime()) { + return { retired: false, reason: "Overlap window has not elapsed" } + } + + const pendingRequests = await CustodyApprovalRequest.countDocuments({ + signerSetVersion: signerSet.version, + category: signerSet.category, + network: signerSet.network, + status: { $in: ["pending", "quorum_reached", "submitting"] }, + }) + if (pendingRequests > 0) { + return { retired: false, reason: `${pendingRequests} approval request(s) still pending against this signer set` } + } + + const retired = await CustodySignerSet.findOneAndUpdate( + { _id: signerSetId, status: "retiring" }, + { $set: { status: "retired" } }, + { new: true, runValidators: true }, + ).lean() + if (!retired) return { retired: false, reason: "Retirement conflict; reload and retry" } + + await logAuditEvent({ + action: "custody.rotation.retired", + targetType: "custody_signer_set", + targetId: signerSetId, + requestId: options.requestId, + metadata: { category: signerSet.category, network: signerSet.network, version: signerSet.version }, + criticalAction: true, + }) + + return { retired: true } +} + +// Reachable while a rotation is still pending, or after activation but +// before retireIfSafe has fully retired the previous set (the overlap +// window). Reactivates the previous signer set so custody never loses a +// valid quorum mid-rotation. +export async function rollbackRotation(signerSetId: string, options: { reason: string; requestId?: string }) { + await dbConnect() + const signerSet = (await CustodySignerSet.findById(signerSetId).lean()) as any + if (!signerSet) throw new RotationError("Signer set not found") + if (signerSet.status !== "pending" && signerSet.status !== "active") { + throw new RotationError(`Cannot roll back a signer set in status ${signerSet.status}`) + } + + // Reactivate the previous set FIRST, before touching this one's status. + // Reversing this order would risk flipping the current set to + // "rolled_back" (terminal/immutable) and then discovering the previous + // set already fully retired (also terminal) - leaving the category with + // zero active signer sets and no way back through this module. + let reactivated: any = null + if (signerSet.status === "active" && signerSet.previousVersion !== undefined) { + reactivated = await CustodySignerSet.findOneAndUpdate( + { category: signerSet.category, network: signerSet.network, version: signerSet.previousVersion, status: "retiring" }, + { $set: { status: "active" }, $unset: { effectiveTo: "" } }, + { new: true, runValidators: true }, + ).lean() + if (!reactivated) { + throw new RotationError( + "Cannot roll back: previous signer set is no longer retiring (already retired); rollback aborted, active set unchanged", + ) + } + } + + const rolledBack = (await CustodySignerSet.findOneAndUpdate( + { _id: signerSetId, status: signerSet.status }, + { $set: { status: "rolled_back" } }, + { new: true, runValidators: true }, + ).lean()) as any + if (!rolledBack) { + throw new RotationError("Rollback conflict after reactivating the previous signer set; reload and retry") + } + + await logAuditEvent({ + action: "custody.rotation.rolled_back", + targetType: "custody_signer_set", + targetId: signerSetId, + requestId: options.requestId, + metadata: { category: signerSet.category, network: signerSet.network, version: signerSet.version, reason: options.reason }, + criticalAction: true, + }) + + return { rolledBack, reactivated } +} + +export interface SeedGenesisSignerSetInput { + category: OperationCategory + network: string + signers: SignerDescriptor[] + threshold: number + overlapWindowMs?: number + createdBy: string + requestId?: string + confirmationToken: string +} + +const GENESIS_CONFIRMATION_TOKEN = "CONFIRM_GENESIS_SIGNER_SET" + +// One-time bootstrap for a category/network that has no signer set at all +// yet (a brand new deployment). Creates the first signer set directly as +// "active", bypassing the approval quorum that proposeRotation/ +// approveRotation would otherwise require - by definition there is no +// governing signer set yet to grant that approval, so this is an +// out-of-band trust-anchor decision, not a normal control-plane action. +// Requires the literal confirmationToken (same idiom as +// scripts/audit-migrate.ts's cleanupOldAuditLogs) so it cannot be invoked +// by accident, and must only be run once per category/network from an +// audited operational script - never from an automated or HTTP-reachable +// path. +export async function seedGenesisSignerSet(input: SeedGenesisSignerSetInput) { + if (input.confirmationToken !== GENESIS_CONFIRMATION_TOKEN) { + throw new RotationError(`Genesis seeding requires confirmationToken: "${GENESIS_CONFIRMATION_TOKEN}"`) + } + await dbConnect() + validateSignerSetInvariants({ category: input.category, signers: input.signers, threshold: input.threshold }) + + const network = input.network.toLowerCase() + const existing = await CustodySignerSet.findOne({ category: input.category, network }).lean() + if (existing) { + throw new RotationError(`A signer set already exists for ${input.category}/${network}; use proposeRotation instead`) + } + + const signerSet = await CustodySignerSet.create({ + category: input.category, + network, + version: 1, + status: "active", + signers: input.signers, + threshold: input.threshold, + overlapWindowMs: input.overlapWindowMs ?? 24 * 60 * 60 * 1000, + rotationApprovals: [], + effectiveFrom: new Date(), + createdBy: input.createdBy, + requestId: input.requestId, + }) + + await logAuditEvent({ + action: "custody.rotation.genesis_seeded", + targetType: "custody_signer_set", + targetId: signerSet._id.toString(), + requestId: input.requestId, + metadata: { category: input.category, network, threshold: input.threshold, signerCount: input.signers.length }, + criticalAction: true, + }) + + return signerSet.toObject() +} diff --git a/lib/custody/service.ts b/lib/custody/service.ts new file mode 100644 index 00000000..b3e5d6a0 --- /dev/null +++ b/lib/custody/service.ts @@ -0,0 +1,484 @@ +import { Account, BASE_FEE, Keypair, Memo, TransactionBuilder, xdr } from "@stellar/stellar-sdk" +import mongoose from "mongoose" +import dbConnect from "@/lib/dbConnect" +import CustodySignerSet from "@/models/CustodySignerSet" +import CustodyApprovalRequest from "@/models/CustodyApprovalRequest" +import CustodySequenceWatermark from "@/models/CustodySequenceWatermark" +import { logAuditEvent } from "@/lib/security/audit-log" +import { getHorizonServer } from "@/lib/stellar/client" +import { buildEnvelope, computeEnvelopeHash, assertEnvelopeFresh } from "./envelope" +import { buildOperations, computeOperationsHash } from "./operations" +import { createSignerAdapter } from "./signer-adapter" +import { assertPayoutWithinPolicy, sumApprovedWeight } from "./policy" +import type { CustodyEnvelope, EnvelopeIntent, EnvelopeMemo, SignerAdapter, SignerRole } from "./types" +import type { PaymentParams } from "./operations" + +export class CustodyServiceError extends Error {} +export class AmbiguousSubmissionError extends CustodyServiceError {} + +async function getWatermark(sourceAccount: string, network: string): Promise { + const doc = (await CustodySequenceWatermark.findOne({ sourceAccount, network }).lean()) as any + return doc ? doc.lastConsumedSequence.toString() : undefined +} + +async function advanceWatermark(sourceAccount: string, network: string, sequence: string): Promise { + await CustodySequenceWatermark.findOneAndUpdate( + { sourceAccount, network }, + { $max: { lastConsumedSequence: mongoose.Types.Decimal128.fromString(sequence) } }, + { upsert: true }, + ) +} + +const STROOPS_PER_UNIT = BigInt(10_000_000) + +// Converts a decimal Stellar amount string (7 fractional digits, e.g. +// "10.5000000") to an integer stroop string, so payout limits can be +// compared exactly with BigInt instead of floating point. +function toStroops(decimalAmount: string): string { + const [whole, fraction = ""] = decimalAmount.split(".") + const paddedFraction = (fraction + "0".repeat(7)).slice(0, 7) + const stroops = BigInt(whole || "0") * STROOPS_PER_UNIT + BigInt(paddedFraction || "0") + return stroops.toString() +} + +// Sums today's (UTC) not-yet-failed/expired payout requests for a source +// account, in stroops, so assertPayoutWithinPolicy can enforce a daily +// limit. Only pending/quorum_reached/submitting/submitted requests count - +// failed and expired requests never moved funds. +async function sumPendingOrSubmittedPayoutStroopsToday(sourceAccount: string, network: string): Promise { + const startOfDayUtc = new Date() + startOfDayUtc.setUTCHours(0, 0, 0, 0) + + const requests = (await CustodyApprovalRequest.find({ + sourceAccount, + network, + category: "payout", + status: { $in: ["pending", "quorum_reached", "submitting", "submitted"] }, + createdAt: { $gte: startOfDayUtc }, + }).lean()) as any[] + + let total = BigInt(0) + for (const request of requests) { + const params = request.envelope?.intent?.params as PaymentParams | undefined + if (params?.amount) { + total += BigInt(toStroops(params.amount)) + } + } + return total.toString() +} + +export interface RequestApprovalInput { + sourceAccount: string + sequence: string + minTime: Date + maxTime: Date + memo?: EnvelopeMemo + intent: EnvelopeIntent + requestedBy: string + requestId?: string + env?: NodeJS.ProcessEnv +} + +// Creates a pending approval request for one custody operation. Validates +// the envelope (network, time bounds, stale-sequence) before persisting so +// a doomed request can never collect approvals. The unique DB indexes on +// (network, envelopeHash) and (sourceAccount, network, sequence) are the +// authoritative replay/duplicate guard under concurrent callers. +export async function requestApproval(input: RequestApprovalInput) { + await dbConnect() + + const envelope = buildEnvelope({ + sourceAccount: input.sourceAccount, + sequence: input.sequence, + minTime: input.minTime, + maxTime: input.maxTime, + memo: input.memo, + intent: input.intent, + env: input.env, + }) + + const lastConsumedSequence = await getWatermark(envelope.sourceAccount, envelope.network) + assertEnvelopeFresh({ envelope, lastConsumedSequence, env: input.env }) + + const signerSet = (await CustodySignerSet.findOne({ + category: input.intent.category, + network: envelope.network, + status: "active", + }).lean()) as any + if (!signerSet) { + throw new CustodyServiceError(`No active signer set for category "${input.intent.category}" on ${envelope.network}`) + } + + if (input.intent.category === "payout") { + if (!signerSet.payoutPolicy) { + throw new CustodyServiceError("Payout signer set has no payoutPolicy (allowlist/limits) configured") + } + const paymentParams = input.intent.params as unknown as PaymentParams + const dailyTotalSoFar = await sumPendingOrSubmittedPayoutStroopsToday(envelope.sourceAccount, envelope.network) + assertPayoutWithinPolicy({ + amount: toStroops(paymentParams.amount), + destination: paymentParams.destination, + allowedDestinations: signerSet.payoutPolicy.allowedDestinations, + maxAmount: signerSet.payoutPolicy.maxAmount, + dailyLimit: signerSet.payoutPolicy.dailyLimit, + dailyTotalSoFar, + }) + } + + const envelopeHash = computeEnvelopeHash(envelope) + const operationsHash = computeOperationsHash(input.intent) + + try { + const request = await CustodyApprovalRequest.create({ + network: envelope.network, + category: input.intent.category, + operation: input.intent.operation, + sourceAccount: envelope.sourceAccount, + sequence: envelope.sequence, + envelope, + envelopeHash, + operationsHash, + minTime: envelope.minTime, + maxTime: envelope.maxTime, + signerSetVersion: signerSet.version, + status: "pending", + approvals: [], + requestedBy: input.requestedBy, + requestId: input.requestId, + }) + + await logAuditEvent({ + action: "custody.approval.requested", + targetType: "custody_approval_request", + targetId: request._id.toString(), + requestId: input.requestId, + metadata: { + category: input.intent.category, + operation: input.intent.operation, + envelopeHash, + sourceAccount: envelope.sourceAccount, + sequence: envelope.sequence, + }, + criticalAction: true, + }) + + return request.toObject() + } catch (error: any) { + if (error?.code === 11000) { + throw new CustodyServiceError("Replay rejected: an approval request for this envelope or sequence already exists") + } + throw error + } +} + +export interface ApproveInput { + requestId: string + signerId: string + role: SignerRole +} + +// Records one signer's approval. Enforces signer eligibility (role must +// match the pinned signer-set version), separation of duties (a signer can +// never approve the same request twice), and the approval-window bound. +// Quorum is evaluated against the signer set pinned to this request at +// creation time - old and new signer sets are never combined for one +// request, even during a rotation overlap window. +export async function approve(input: ApproveInput) { + await dbConnect() + const request = (await CustodyApprovalRequest.findById(input.requestId).lean()) as any + if (!request) throw new CustodyServiceError("Approval request not found") + if (request.status !== "pending" && request.status !== "quorum_reached") { + throw new CustodyServiceError(`Cannot approve a request in status "${request.status}"`) + } + if (new Date() > new Date(request.maxTime)) { + throw new CustodyServiceError("Approval window has expired; replay rejected") + } + + const signerSet = (await CustodySignerSet.findOne({ + category: request.category, + network: request.network, + version: request.signerSetVersion, + status: { $in: ["active", "retiring"] }, + }).lean()) as any + if (!signerSet) { + throw new CustodyServiceError("No active or retiring signer set backs this approval request") + } + + const eligible = signerSet.signers.find((signer: any) => signer.signerId === input.signerId && signer.role === input.role) + if (!eligible) { + throw new CustodyServiceError(`"${input.signerId}" is not an eligible ${input.role} signer for this request`) + } + if ((request.approvals || []).some((approval: any) => approval.signerId === input.signerId)) { + throw new CustodyServiceError(`"${input.signerId}" has already approved this request`) + } + + const updated = (await CustodyApprovalRequest.findOneAndUpdate( + { _id: input.requestId, status: request.status }, + { $push: { approvals: { signerId: input.signerId, role: input.role, approvedAt: new Date() } } }, + { new: true, runValidators: true }, + ).lean()) as any + if (!updated) throw new CustodyServiceError("Approval conflict; reload and retry") + + await logAuditEvent({ + action: "custody.approval.granted", + targetType: "custody_approval_request", + targetId: input.requestId, + metadata: { signerId: input.signerId, role: input.role }, + criticalAction: true, + }) + + const approvedWeight = sumApprovedWeight(updated.approvals, signerSet.signers) + if (approvedWeight >= signerSet.threshold && updated.status === "pending") { + const promoted = (await CustodyApprovalRequest.findOneAndUpdate( + { _id: input.requestId, status: "pending" }, + { $set: { status: "quorum_reached" } }, + { new: true, runValidators: true }, + ).lean()) as any + return promoted ?? updated + } + + return updated +} + +function attachSignature(tx: { addDecoratedSignature(signature: InstanceType): void }, publicKey: string, signatureBase64: string) { + const keypair = Keypair.fromPublicKey(publicKey) + const hint = keypair.signatureHint() + const signature = Buffer.from(signatureBase64, "base64") + tx.addDecoratedSignature(new xdr.DecoratedSignature({ hint, signature })) +} + +export interface FinalizeOptions { + env?: NodeJS.ProcessEnv + adapter?: SignerAdapter + submit?: (envelopeXdr: string) => Promise<{ hash: string; ledger: number; resultXdr: string }> + now?: Date +} + +// Finalizes a quorum-reached request: recomputes envelope and operations +// hashes against live inputs, builds and co-signs the real transaction, and +// submits it. Claims the request via an atomic (quorum_reached -> +// submitting) status transition first, so two concurrent finalize calls +// (e.g. a caller retrying after a timeout) can never both submit. If +// submit() throws AmbiguousSubmissionError, the request is left in +// "submitting" for reconcileSubmission rather than retried automatically. +export async function finalizeAndSubmit(requestId: string, options: FinalizeOptions = {}) { + await dbConnect() + + const claimed = (await CustodyApprovalRequest.findOneAndUpdate( + { _id: requestId, status: "quorum_reached" }, + { $set: { status: "submitting" } }, + { new: true, runValidators: true }, + ).lean()) as any + if (!claimed) { + throw new CustodyServiceError( + "Cannot finalize: request is not in quorum_reached status (already submitting/submitted/failed/expired, or quorum not met)", + ) + } + + const now = options.now ?? new Date() + + try { + if (now.getTime() > new Date(claimed.maxTime).getTime()) { + throw new CustodyServiceError("Approval window expired before submission; replay rejected") + } + + const envelope = claimed.envelope as CustodyEnvelope + if (computeEnvelopeHash(envelope) !== claimed.envelopeHash) { + throw new CustodyServiceError("Envelope integrity check failed: stored envelope does not match its recorded hash") + } + + const lastConsumedSequence = await getWatermark(claimed.sourceAccount, claimed.network) + assertEnvelopeFresh({ envelope, now, lastConsumedSequence, env: options.env }) + + const operationsHash = computeOperationsHash(envelope.intent) + if (operationsHash !== claimed.operationsHash) { + throw new CustodyServiceError("Cross-intent replay rejected: recomputed operations do not match what signers approved") + } + + const signerSet = (await CustodySignerSet.findOne({ + category: claimed.category, + network: claimed.network, + version: claimed.signerSetVersion, + }).lean()) as any + if (!signerSet) throw new CustodyServiceError("Signer set no longer exists") + + const distinctApprovers = [...new Set((claimed.approvals || []).map((approval: any) => approval.signerId))] as string[] + const approvedWeight = sumApprovedWeight(claimed.approvals || [], signerSet.signers) + if (approvedWeight < signerSet.threshold) { + throw new CustodyServiceError(`Quorum not met at finalize time: weight ${approvedWeight}/${signerSet.threshold}`) + } + + const operations = buildOperations(envelope.intent) + const account = new Account(envelope.sourceAccount, (BigInt(envelope.sequence) - BigInt(1)).toString()) + const builder = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: envelope.networkPassphrase, + timebounds: { + minTime: Math.floor(new Date(envelope.minTime).getTime() / 1000), + maxTime: Math.floor(new Date(envelope.maxTime).getTime() / 1000), + }, + }) + for (const operation of operations) builder.addOperation(operation) + if (envelope.memo.type !== "none" && envelope.memo.value) { + builder.addMemo( + envelope.memo.type === "text" + ? Memo.text(envelope.memo.value) + : envelope.memo.type === "id" + ? Memo.id(envelope.memo.value) + : Memo.hash(envelope.memo.value), + ) + } + const tx = builder.build() + const payloadHash = tx.hash().toString("hex") + + const adapter = options.adapter ?? createSignerAdapter(options.env) + for (const signerId of distinctApprovers) { + const signer = signerSet.signers.find((candidate: any) => candidate.signerId === signerId) + if (!signer) continue + const signature = await adapter.sign(signerId, payloadHash) + attachSignature(tx, signer.publicKey, signature) + } + + const submit = + options.submit ?? + (async (xdrString: string) => { + const horizon = getHorizonServer() + const parsed = TransactionBuilder.fromXDR(xdrString, envelope.networkPassphrase) + try { + const result: any = await horizon.submitTransaction(parsed as any) + return { + hash: result.hash, + ledger: result.ledger, + resultXdr: result.result_xdr ?? result.resultXdr, + } + } catch (submitError: any) { + // Horizon returned a well-formed rejection (bad sequence, malformed + // transaction, insufficient signature weight, etc.) - the + // submission definitely never landed, so it is safe to fail. + // Anything without an HTTP response (timeout, connection reset, + // DNS failure) means Horizon may or may not have applied the + // transaction, and must not be treated as a definite failure. + const hasDefiniteHorizonResponse = submitError?.response?.status !== undefined + if (!hasDefiniteHorizonResponse) { + throw new AmbiguousSubmissionError(submitError?.message || "Horizon submission outcome unknown") + } + throw submitError + } + }) + + const ledgerResult = await submit(tx.toXDR()) + + await advanceWatermark(claimed.sourceAccount, claimed.network, envelope.sequence) + + const submitted = (await CustodyApprovalRequest.findOneAndUpdate( + { _id: requestId, status: "submitting" }, + { $set: { status: "submitted", ledgerResult: { ...ledgerResult, submittedAt: new Date() } } }, + { new: true, runValidators: true }, + ).lean()) as any + + await logAuditEvent({ + action: "custody.envelope.submitted", + targetType: "custody_approval_request", + targetId: requestId, + metadata: { + category: claimed.category, + operation: claimed.operation, + envelopeHash: claimed.envelopeHash, + ledgerHash: ledgerResult.hash, + ledger: ledgerResult.ledger, + }, + criticalAction: true, + }) + + return submitted + } catch (error: any) { + if (error instanceof AmbiguousSubmissionError) { + await logAuditEvent({ + action: "custody.envelope.submission_ambiguous", + targetType: "custody_approval_request", + targetId: requestId, + status: "failure", + metadata: { category: claimed.category, envelopeHash: claimed.envelopeHash, reason: error.message }, + criticalAction: true, + }) + throw error + } + + await CustodyApprovalRequest.findOneAndUpdate( + { _id: requestId, status: "submitting" }, + { $set: { status: "failed", failureReason: error?.message || "Unknown submission failure" } }, + { new: true, runValidators: true }, + ).lean() + + await logAuditEvent({ + action: "custody.envelope.submission_failed", + targetType: "custody_approval_request", + targetId: requestId, + status: "failure", + metadata: { category: claimed.category, envelopeHash: claimed.envelopeHash, reason: error?.message }, + criticalAction: true, + }) + + throw error + } +} + +export interface ReconcileOutcome { + status: "submitted" | "failed" + ledgerResult?: { hash: string; ledger: number; resultXdr: string } + reason?: string +} + +// Manual operator path for requests stuck in "submitting" after an +// ambiguous or crashed submission (see docs/custody-signer-rotation.md, +// "Stuck sequence" and "Outage" runbooks). Takes an authoritative Horizon +// lookup result rather than retrying blindly, so a submission that already +// landed on the ledger can never be double-submitted. +export async function reconcileSubmission(requestId: string, outcome: ReconcileOutcome) { + await dbConnect() + const request = (await CustodyApprovalRequest.findById(requestId).lean()) as any + if (!request) throw new CustodyServiceError("Approval request not found") + if (request.status !== "submitting") { + throw new CustodyServiceError(`Cannot reconcile a request in status "${request.status}"`) + } + + const update = + outcome.status === "submitted" + ? { status: "submitted", ledgerResult: { ...outcome.ledgerResult, submittedAt: new Date() } } + : { status: "failed", failureReason: outcome.reason || "Reconciled as failed" } + + const reconciled = (await CustodyApprovalRequest.findOneAndUpdate( + { _id: requestId, status: "submitting" }, + { $set: update }, + { new: true, runValidators: true }, + ).lean()) as any + if (!reconciled) throw new CustodyServiceError("Reconciliation conflict; reload and retry") + + if (outcome.status === "submitted") { + await advanceWatermark(request.sourceAccount, request.network, request.sequence) + } + + await logAuditEvent({ + action: "custody.envelope.reconciled", + targetType: "custody_approval_request", + targetId: requestId, + metadata: { outcomeStatus: outcome.status, reason: outcome.reason }, + criticalAction: true, + }) + + return reconciled +} + +// Expires pending/quorum-reached requests once their approval window has +// elapsed, guaranteeing an expired envelope can never later be approved or +// submitted (replay-by-delay fails). "submitting" requests are deliberately +// excluded - they require reconcileSubmission, not blind expiry, since a +// submission may already have landed on the ledger. +export async function expireStaleRequests(now: Date = new Date()) { + await dbConnect() + const result = await CustodyApprovalRequest.updateMany( + { status: { $in: ["pending", "quorum_reached"] }, maxTime: { $lt: now } }, + { $set: { status: "expired" } }, + ) + return { expiredCount: result.modifiedCount } +} diff --git a/lib/custody/signer-adapter.ts b/lib/custody/signer-adapter.ts new file mode 100644 index 00000000..5bd3d48a --- /dev/null +++ b/lib/custody/signer-adapter.ts @@ -0,0 +1,81 @@ +import { Keypair } from "@stellar/stellar-sdk" +import { getStellarConfig } from "@/lib/stellar/config" +import type { SignerAdapter } from "./types" + +export class CustodyAdapterError extends Error {} + +/** + * Testnet-only reference adapter for local development, CI, and dry-runs of + * the approval/rotation orchestration. It never reads or persists any + * secret: each signerId gets an ephemeral, process-local ed25519 keypair + * generated in memory (`Keypair.random()`), so no key material can ever + * reach an env var, log line, database document, or the repository. The + * network is read from the shared Stellar config, not a caller-supplied + * value, and the constructor throws unless mock testnet mode is active - + * this adapter is structurally incapable of being wired to a mainnet + * signer set. + * + * Because keys are ephemeral, this adapter cannot control a specific + * pre-funded testnet account across process restarts. It exists to exercise + * the custody control plane (thresholds, envelopes, rotation, audit trail) + * end-to-end, not to move real testnet funds. Production deployments must + * inject a real KMS/HSM/external-signer adapter (see createExternalSignerAdapter + * below and docs/custody-signer-rotation.md). + */ +export class LocalDevSignerAdapter implements SignerAdapter { + readonly adapterId = "local-dev" + private readonly keypairs = new Map() + + constructor(env: NodeJS.ProcessEnv = process.env) { + const config = getStellarConfig(env) + if (config.network !== "testnet" || !config.mock) { + throw new CustodyAdapterError( + "LocalDevSignerAdapter requires STELLAR_NETWORK=testnet and ENABLE_MOCK_STELLAR=true", + ) + } + } + + private keypairFor(signerId: string): Keypair { + let keypair = this.keypairs.get(signerId) + if (!keypair) { + keypair = Keypair.random() + this.keypairs.set(signerId, keypair) + } + return keypair + } + + async getPublicKey(signerId: string): Promise { + return this.keypairFor(signerId).publicKey() + } + + async sign(signerId: string, payloadHash: string): Promise { + if (!/^[a-f0-9]{64}$/i.test(payloadHash)) { + throw new CustodyAdapterError("sign() requires a 32-byte hex payload hash") + } + const keypair = this.keypairFor(signerId) + return keypair.sign(Buffer.from(payloadHash, "hex")).toString("base64") + } +} + +/** + * Production signer contract: a KMS, HSM, or external signer service that + * holds key material outside the app process. The app only ever calls + * sign()/getPublicKey() over this interface and never receives raw secrets. + * This repo intentionally does not mandate or implement a specific vendor - + * deployments must inject a concrete SignerAdapter implementation before + * any mainnet custody operation can run. + */ +export function createExternalSignerAdapter(): SignerAdapter { + throw new CustodyAdapterError( + "CUSTODY_ADAPTER_NOT_CONFIGURED: inject a production SignerAdapter (KMS/HSM/external signer) " + + "before performing mainnet custody operations. See docs/custody-signer-rotation.md.", + ) +} + +export function createSignerAdapter(env: NodeJS.ProcessEnv = process.env): SignerAdapter { + const config = getStellarConfig(env) + if (config.network === "testnet" && config.mock) { + return new LocalDevSignerAdapter(env) + } + return createExternalSignerAdapter() +} diff --git a/lib/custody/types.ts b/lib/custody/types.ts new file mode 100644 index 00000000..0744b301 --- /dev/null +++ b/lib/custody/types.ts @@ -0,0 +1,58 @@ +// Shared types for the issuer/distribution custody control plane (threshold +// signing + safe key rotation). See docs/custody-signer-rotation.md. + +export type OperationCategory = "issuance" | "payout" | "emergency" | "recovery" | "rotation" + +export type SignerRole = "issuer" | "distribution" | "security" | "recovery" + +export type QuorumType = "standard" | "recovery" + +export type SignerSetStatus = "pending" | "active" | "retiring" | "retired" | "rolled_back" + +export type ApprovalRequestStatus = + | "pending" + | "quorum_reached" + | "submitting" + | "submitted" + | "failed" + | "expired" + +export interface EnvelopeMemo { + type: "none" | "text" | "id" | "hash" + value?: string +} + +// The business intent an envelope carries. `params` is operation-specific and +// is hashed as part of the envelope; it is never a substitute for the actual +// operations built by `lib/custody/operations.ts`. +export interface EnvelopeIntent { + category: OperationCategory + operation: string + params: Record +} + +export interface CustodyEnvelope { + network: string + networkPassphrase: string + sourceAccount: string + sequence: string + minTime: string + maxTime: string + memo: EnvelopeMemo + intent: EnvelopeIntent +} + +export interface SignerDescriptor { + signerId: string + role: SignerRole + publicKey: string + weight: number +} + +// A signing backend never returns raw key material to the caller. Only a +// public key and a detached signature ever cross this boundary. +export interface SignerAdapter { + readonly adapterId: string + getPublicKey(signerId: string): Promise + sign(signerId: string, payloadHash: string): Promise +} diff --git a/models/CustodyApprovalRequest.ts b/models/CustodyApprovalRequest.ts new file mode 100644 index 00000000..a456d21b --- /dev/null +++ b/models/CustodyApprovalRequest.ts @@ -0,0 +1,102 @@ +import mongoose, { Document, Schema } from "mongoose" + +export type CustodyApprovalStatus = "pending" | "quorum_reached" | "submitting" | "submitted" | "failed" | "expired" + +export interface ICustodyApprovalRequest extends Document { + network: string + category: string + operation: string + sourceAccount: string + sequence: string + envelope: Record + envelopeHash: string + operationsHash: string + minTime: Date + maxTime: Date + signerSetVersion: number + status: CustodyApprovalStatus + approvals: Array<{ signerId: string; role: string; approvedAt: Date }> + requestedBy?: string + requestId?: string + ledgerResult?: { hash: string; ledger: number; resultXdr: string; submittedAt: Date } + failureReason?: string + createdAt: Date + updatedAt: Date +} + +// Terminal: no further approvals, submission, or reconciliation is possible. +// "submitting" is intentionally excluded - it is a transient reconciliation +// state, not terminal (see lib/custody/service.ts reconcileSubmission). +const TERMINAL_STATUSES: CustodyApprovalStatus[] = ["submitted", "failed", "expired"] + +const CustodyApprovalRequestSchema: Schema = new Schema( + { + network: { type: String, required: true, trim: true, lowercase: true }, + category: { + type: String, + enum: ["issuance", "payout", "emergency", "recovery", "rotation"], + required: true, + }, + operation: { type: String, required: true, trim: true }, + sourceAccount: { type: String, required: true, trim: true }, + sequence: { type: String, required: true }, + envelope: { type: Schema.Types.Mixed, required: true }, + envelopeHash: { type: String, required: true }, + operationsHash: { type: String, required: true }, + minTime: { type: Date, required: true }, + maxTime: { type: Date, required: true }, + signerSetVersion: { type: Number, required: true }, + status: { + type: String, + enum: ["pending", "quorum_reached", "submitting", "submitted", "failed", "expired"], + default: "pending", + }, + approvals: { + type: [ + { + signerId: { type: String, required: true }, + role: { type: String, required: true }, + approvedAt: { type: Date, required: true }, + }, + ], + default: [], + }, + requestedBy: { type: String }, + requestId: { type: String }, + ledgerResult: { + type: { + hash: { type: String, required: true }, + ledger: { type: Number, required: true }, + resultXdr: { type: String, required: true }, + submittedAt: { type: Date, required: true }, + }, + default: undefined, + }, + failureReason: { type: String }, + }, + { timestamps: true }, +) + +// Cross-network/cross-intent replay guard: the same envelope can never back +// more than one approval request on a given network. +CustodyApprovalRequestSchema.index({ network: 1, envelopeHash: 1 }, { unique: true }) +// Stale/replayed-sequence guard: only one in-flight request may target a +// given source-account sequence at a time, so concurrent proposals for the +// same sequence fail fast instead of racing. +CustodyApprovalRequestSchema.index({ sourceAccount: 1, network: 1, sequence: 1 }, { unique: true }) +CustodyApprovalRequestSchema.index({ status: 1, category: 1 }) +CustodyApprovalRequestSchema.index({ signerSetVersion: 1, status: 1 }) + +for (const hook of ["findOneAndUpdate", "updateOne", "updateMany"] as const) { + CustodyApprovalRequestSchema.pre(hook, async function (this: any, next) { + const existing = (await this.model.findOne(this.getQuery()).select("status").lean()) as any + if (existing && TERMINAL_STATUSES.includes(existing.status)) { + next(new Error("CUSTODY_APPROVAL_REQUEST_TERMINAL: submitted/failed/expired requests are immutable")) + return + } + next() + }) +} + +export default mongoose.models.CustodyApprovalRequest || + mongoose.model("CustodyApprovalRequest", CustodyApprovalRequestSchema) diff --git a/models/CustodySequenceWatermark.ts b/models/CustodySequenceWatermark.ts new file mode 100644 index 00000000..efe4ca9e --- /dev/null +++ b/models/CustodySequenceWatermark.ts @@ -0,0 +1,32 @@ +import mongoose, { Document, Schema } from "mongoose" + +// Tracks the highest source-account sequence number consumed by a +// successfully submitted custody envelope. This is a pre-submission +// liveness/staleness guard, not the authoritative replay defense - Horizon's +// own sequence-number check on the ledger is authoritative. See +// docs/custody-signer-rotation.md for the reconciliation procedure used +// when this watermark and the live account sequence disagree (stuck +// sequence / ambiguous submission). +export interface ICustodySequenceWatermark extends Document { + sourceAccount: string + network: string + lastConsumedSequence: mongoose.Types.Decimal128 + createdAt: Date + updatedAt: Date +} + +const CustodySequenceWatermarkSchema: Schema = new Schema( + { + sourceAccount: { type: String, required: true, trim: true }, + network: { type: String, required: true, trim: true, lowercase: true }, + // Decimal128 so MongoDB's $max operator compares Stellar's int64 + // sequence numbers numerically instead of lexicographically as strings. + lastConsumedSequence: { type: Schema.Types.Decimal128, required: true }, + }, + { timestamps: true }, +) + +CustodySequenceWatermarkSchema.index({ sourceAccount: 1, network: 1 }, { unique: true }) + +export default mongoose.models.CustodySequenceWatermark || + mongoose.model("CustodySequenceWatermark", CustodySequenceWatermarkSchema) diff --git a/models/CustodySignerSet.ts b/models/CustodySignerSet.ts new file mode 100644 index 00000000..1bc3f4b1 --- /dev/null +++ b/models/CustodySignerSet.ts @@ -0,0 +1,154 @@ +import mongoose, { Document, Schema } from "mongoose" +import { isValidStellarPublicKey, normalizeStellarPublicKey } from "@/lib/validation/stellar" + +export type CustodyOperationCategory = "issuance" | "payout" | "emergency" | "recovery" | "rotation" +export type CustodySignerRole = "issuer" | "distribution" | "security" | "recovery" +export type CustodySignerSetStatus = "pending" | "active" | "retiring" | "retired" | "rolled_back" +export type CustodyQuorumType = "standard" | "recovery" + +export interface ICustodySignerSet extends Document { + category: CustodyOperationCategory + network: string + version: number + previousVersion?: number + status: CustodySignerSetStatus + signers: Array<{ + signerId: string + role: CustodySignerRole + publicKey: string + weight: number + }> + threshold: number + overlapWindowMs: number + effectiveFrom?: Date + effectiveTo?: Date + payoutPolicy?: { + allowedDestinations: string[] + maxAmount?: string + dailyLimit?: string + } + rotationApprovals: Array<{ + approvedBy: string + role: CustodySignerRole + quorumType: CustodyQuorumType + approvedAt: Date + }> + createdBy?: string + requestId?: string + createdAt: Date + updatedAt: Date +} + +const TERMINAL_STATUSES: CustodySignerSetStatus[] = ["retired", "rolled_back"] + +const CustodySignerSetSchema: Schema = new Schema( + { + category: { + type: String, + enum: ["issuance", "payout", "emergency", "recovery", "rotation"], + required: true, + }, + network: { type: String, required: true, trim: true, lowercase: true }, + version: { type: Number, required: true, min: 1 }, + previousVersion: { type: Number, min: 1 }, + status: { + type: String, + enum: ["pending", "active", "retiring", "retired", "rolled_back"], + default: "pending", + }, + signers: { + type: [ + { + signerId: { type: String, required: true, trim: true }, + role: { type: String, enum: ["issuer", "distribution", "security", "recovery"], required: true }, + publicKey: { + type: String, + required: true, + trim: true, + validate: { + validator: (value: string) => isValidStellarPublicKey(normalizeStellarPublicKey(value)), + message: "Invalid signer public key", + }, + }, + weight: { type: Number, required: true, min: 1 }, + }, + ], + required: true, + validate: { + validator: (value: unknown[]) => Array.isArray(value) && value.length > 0, + message: "Signer set must include at least one signer", + }, + }, + threshold: { type: Number, required: true, min: 1 }, + overlapWindowMs: { type: Number, required: true, default: 24 * 60 * 60 * 1000 }, + effectiveFrom: { type: Date }, + effectiveTo: { type: Date }, + payoutPolicy: { + type: { + allowedDestinations: { + type: [ + { + type: String, + validate: { + validator: (value: string) => isValidStellarPublicKey(normalizeStellarPublicKey(value)), + message: "Invalid payout allowlist destination", + }, + }, + ], + required: true, + }, + maxAmount: { type: String }, + dailyLimit: { type: String }, + }, + default: undefined, + }, + rotationApprovals: { + type: [ + { + approvedBy: { type: String, required: true }, + role: { type: String, enum: ["issuer", "distribution", "security", "recovery"], required: true }, + quorumType: { type: String, enum: ["standard", "recovery"], required: true }, + approvedAt: { type: Date, required: true }, + }, + ], + default: [], + }, + createdBy: { type: String }, + requestId: { type: String }, + }, + { timestamps: true }, +) + +CustodySignerSetSchema.index({ category: 1, network: 1, version: 1 }, { unique: true }) +CustodySignerSetSchema.index({ category: 1, network: 1, status: 1 }) + +// Retired and rolled-back signer sets are terminal and immutable, mirroring +// the append-only conventions used for TamperEvidentAuditLog and the +// active-identity immutability rule on StellarPoolAsset. Rotation must +// always create a new version rather than edit a terminal document. +CustodySignerSetSchema.pre("save", async function (next) { + if (this.isNew) { + next() + return + } + const existing = (await mongoose.models.CustodySignerSet.findById(this._id).select("status").lean()) as any + if (existing && TERMINAL_STATUSES.includes(existing.status)) { + next(new Error("CUSTODY_SIGNER_SET_TERMINAL: retired/rolled_back signer sets are immutable")) + return + } + next() +}) + +for (const hook of ["findOneAndUpdate", "updateOne", "updateMany"] as const) { + CustodySignerSetSchema.pre(hook, async function (this: any, next) { + const existing = (await this.model.findOne(this.getQuery()).select("status").lean()) as any + if (existing && TERMINAL_STATUSES.includes(existing.status)) { + next(new Error("CUSTODY_SIGNER_SET_TERMINAL: retired/rolled_back signer sets are immutable")) + return + } + next() + }) +} + +export default mongoose.models.CustodySignerSet || + mongoose.model("CustodySignerSet", CustodySignerSetSchema)