diff --git a/README.md b/README.md index d9a6f062..53844b42 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Simulation is strictly read-only: it never saves the policy and never consumes u 3. `Submit Signed XDR` orchestrates signing/submission path: - if signed input is already present → submit directly - if unsigned input is present → wallet signing is triggered first, then submit -4. Submit signed tx as a signer: `POST /api/stellar/submit-signed` with the signed XDR and the same `auditEntryId`. The server checks that the XDR contains one native payment matching the unexpired `APPROVE`/`WARN` quote before broadcasting. +4. Submit signed tx: `POST /api/stellar/submit-signed` with the build response's `decisionId`, `quoteExpiresAt`, and `buildAuthorization` alongside `signedXdr`. Submit reloads the decision and rejects an expired quote or a build token that does not match the signed transaction. 5. Explorer URL is returned and shown as clickable link. #### Quote-to-XDR trust boundary diff --git a/src/app/api/decision/route.ts b/src/app/api/decision/route.ts index bbdbba75..a126bfaa 100644 --- a/src/app/api/decision/route.ts +++ b/src/app/api/decision/route.ts @@ -165,9 +165,10 @@ export async function POST(request: NextRequest) { await consumeUsage(userId, action.amountXLM); } + const decisionNowMs = Date.now(); const auditEntry: AuditEntry = { id: randomUUID(), - timestamp: new Date().toISOString(), + timestamp: new Date(decisionNowMs).toISOString(), action, decision: finalDecision, explanation, @@ -177,18 +178,18 @@ export async function POST(request: NextRequest) { riskFindings: decision.riskFindings.map( (finding) => `${finding.code}: ${finding.detail}`, ), - ...(finalDecision === "APPROVE" || finalDecision === "WARN" - ? body.paymentQuoteInput - ? { - paymentQuote: buildPaymentQuoteFromDecision({ - destination: body.paymentQuoteInput.destination, - amountXLM: action.amountXLM, - memo: body.paymentQuoteInput.memo, - actionId: action.id, - network: body.paymentQuoteInput.network, - }), - } - : {} + ...((finalDecision === "APPROVE" || finalDecision === "WARN") && + (body.paymentQuote || body.paymentQuoteInput) + ? { + paymentQuote: buildPaymentQuoteFromDecision({ + destination: (body.paymentQuote || body.paymentQuoteInput)!.destination, + amountXLM: action.amountXLM, + memo: (body.paymentQuote || body.paymentQuoteInput)!.memo, + actionId: action.id, + network: (body.paymentQuote || body.paymentQuoteInput)!.network, + nowMs: decisionNowMs, + }), + } : {}), }; diff --git a/src/app/api/stellar/build-payment/route.test.ts b/src/app/api/stellar/build-payment/route.test.ts index 3d41729c..9bb9368a 100644 --- a/src/app/api/stellar/build-payment/route.test.ts +++ b/src/app/api/stellar/build-payment/route.test.ts @@ -38,6 +38,18 @@ vi.mock("@stellar/stellar-sdk", async () => { }; }); +vi.mock("@/lib/decision/engine", () => ({ + evaluateDecision: vi.fn(async () => ({ + decision: "APPROVE", + explanation: "Fixture decision", + triggeredPolicies: [], + riskScore: 0, + riskFindings: [], + requiresManualApproval: false, + analyzerStatus: { isDegraded: false }, + })), +})); + import { Account } from "@stellar/stellar-sdk"; import { NextRequest } from "next/server"; diff --git a/src/app/api/stellar/build-payment/route.ts b/src/app/api/stellar/build-payment/route.ts index e140f6c2..fb1321e3 100644 --- a/src/app/api/stellar/build-payment/route.ts +++ b/src/app/api/stellar/build-payment/route.ts @@ -6,7 +6,8 @@ import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/producti import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; import { securityHeadersForRequest } from "@/lib/security/headers"; import { buildUnsignedPaymentTransaction } from "@/lib/stellar/client"; -import { verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote"; +import { createBuildAuthorization, getTransactionHash } from "@/lib/stellar/payment-build-authorization"; +import { getQuoteExpiresAt, verifyPaymentAgainstQuote } from "@/lib/stellar/verify-payment-quote"; import { getAuditEntryById } from "@/lib/storage/audit-store"; import { getUserWallet } from "@/lib/storage/user-wallet-store"; import { stellarBuildPaymentRequestSchema } from "@/lib/validation/schemas"; @@ -129,6 +130,14 @@ export async function POST(request: NextRequest) { sourcePublicKey, ); + const quoteExpiresAt = getQuoteExpiresAt(auditEntry!); + const buildAuthorization = createBuildAuthorization({ + userId, + decisionId: auditEntry!.id, + quoteExpiresAt, + transactionHash: getTransactionHash(unsigned.xdr, unsigned.networkPassphrase), + }); + return NextResponse.json( { ok: true, @@ -138,6 +147,9 @@ export async function POST(request: NextRequest) { sourcePublicKey, xdr: unsigned.xdr, networkPassphrase: unsigned.networkPassphrase, + decisionId: auditEntry!.id, + quoteExpiresAt, + buildAuthorization, }, { headers: { ...rateLimitHeaders(rate), ...securityHeadersForRequest(request) } }, ); diff --git a/src/app/api/stellar/e2e-decision-build-submit.test.ts b/src/app/api/stellar/e2e-decision-build-submit.test.ts index 52d6be6f..c749a322 100644 --- a/src/app/api/stellar/e2e-decision-build-submit.test.ts +++ b/src/app/api/stellar/e2e-decision-build-submit.test.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; vi.hoisted(() => { const tmpDir = `/tmp/fortexa-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`; @@ -41,6 +41,19 @@ vi.mock("@stellar/stellar-sdk", async () => { }; }); +// Keep this fixture focused on the API handoff; the analyzer has separate tests. +vi.mock("@/lib/decision/engine", () => ({ + evaluateDecision: vi.fn(async (action: { amountXLM: number }) => ({ + decision: action.amountXLM === 95 ? "WARN" : "APPROVE", + explanation: "Fixture decision", + triggeredPolicies: [], + riskScore: 0, + riskFindings: [], + requiresManualApproval: false, + analyzerStatus: { isDegraded: false }, + })), +})); + import { Account, Keypair, Networks, TransactionBuilder } from "@stellar/stellar-sdk"; import { NextRequest } from "next/server"; @@ -126,7 +139,16 @@ beforeEach(async () => { await resetAuditState(OPERATOR_USER_ID); }); -async function runOperatorFlow(decisionBody: Record, paymentAmount: string) { +afterEach(() => { + vi.useRealTimers(); + delete process.env.FORTEXA_PAYMENT_QUOTE_TTL_SECONDS; +}); + +async function runOperatorFlow( + decisionBody: Record, + paymentAmount: string, + options: { beforeSubmit?: () => void | string | Promise; decisionId?: string; expectedStatus?: number } = {}, +) { const decisionRes = await decisionPost( jsonRequest("http://localhost/api/decision", { ...decisionBody, @@ -141,7 +163,7 @@ async function runOperatorFlow(decisionBody: Record, paymentAmo const decisionPayload = (await decisionRes.json()) as { result: { decision: string; riskScore: number }; - auditEntry: { id: string; decision: string }; + auditEntry: { id: string; decision: string; paymentQuote: { expiresAt: string } }; userId: string; }; @@ -162,22 +184,29 @@ async function runOperatorFlow(decisionBody: Record, paymentAmo xdr: string; networkPassphrase: string; sourcePublicKey: string; + decisionId: string; + quoteExpiresAt: string; + buildAuthorization: string; }; const unsignedTx = TransactionBuilder.fromXDR(buildPayload.xdr, buildPayload.networkPassphrase); unsignedTx.sign(sourceKeypair); const signedXdr = unsignedTx.toXDR(); + const replacementDecisionId = await options.beforeSubmit?.(); const submitRes = await submitSignedPost( jsonRequest("http://localhost/api/stellar/submit-signed", { signedXdr, - auditEntryId: decisionPayload.auditEntry.id, + decisionId: options.decisionId ?? replacementDecisionId ?? buildPayload.decisionId, + quoteExpiresAt: buildPayload.quoteExpiresAt, + buildAuthorization: buildPayload.buildAuthorization, }) ); - expect(submitRes.status).toBe(200); + expect(submitRes.status).toBe(options.expectedStatus ?? 200); const submitPayload = (await submitRes.json()) as { ok: boolean; + error?: string; explorerUrl: string; payment: { hash: string; ledger: number; status: string }; }; @@ -201,6 +230,8 @@ describe("E2E: decision → build XDR → submit (Horizon mocked)", () => { expect(auditEntries[0].id).toBe(decisionPayload.auditEntry.id); expect(buildPayload.ok).toBe(true); + expect(buildPayload.decisionId).toBe(decisionPayload.auditEntry.id); + expect(buildPayload.quoteExpiresAt).toBe(decisionPayload.auditEntry.paymentQuote.expiresAt); expect(buildPayload.networkPassphrase).toBe(Networks.TESTNET); expect(buildPayload.sourcePublicKey).toBe(sourceKeypair.publicKey()); expect(typeof buildPayload.xdr).toBe("string"); @@ -250,4 +281,44 @@ describe("E2E: decision → build XDR → submit (Horizon mocked)", () => { expect(horizonMocks.loadAccount).toHaveBeenCalled(); expect(horizonMocks.submitTransaction).toHaveBeenCalled(); }); + + it("rejects a quote that expires while the wallet is signing", async () => { + process.env.FORTEXA_PAYMENT_QUOTE_TTL_SECONDS = "1"; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-30T12:00:00.000Z")); + const { submitPayload } = await runOperatorFlow( + { scenarioId: "safe-research-payment" }, + "18.0000000", + { beforeSubmit: () => { vi.setSystemTime(new Date("2026-09-30T12:00:01.000Z")); }, expectedStatus: 403 }, + ); + expect(submitPayload.error).toMatch(/quote|decision/i); + expect(horizonMocks.submitTransaction).not.toHaveBeenCalled(); + }); + + it("rejects a swapped decision id for the signed build", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-09-30T12:00:00.000Z")); + const { submitPayload } = await runOperatorFlow( + { scenarioId: "safe-research-payment" }, + "18.0000000", + { + beforeSubmit: async () => { + const secondDecision = await decisionPost(jsonRequest("http://localhost/api/decision", { + scenarioId: "safe-research-payment", + paymentQuoteInput: { + destination: destinationKeypair.publicKey(), + memo: "e2e-test", + network: "testnet", + }, + })); + expect(secondDecision.status).toBe(200); + const payload = (await secondDecision.json()) as { auditEntry: { id: string } }; + return payload.auditEntry.id; + }, + expectedStatus: 403, + }, + ); + expect(submitPayload.error).toMatch(/authorized payment build/i); + expect(horizonMocks.submitTransaction).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/stellar/submit-signed-idempotency.test.ts b/src/app/api/stellar/submit-signed-idempotency.test.ts index 30610a6d..164dbc63 100644 --- a/src/app/api/stellar/submit-signed-idempotency.test.ts +++ b/src/app/api/stellar/submit-signed-idempotency.test.ts @@ -57,19 +57,15 @@ vi.mock("@stellar/stellar-sdk", async () => { }; }); -vi.mock("@/lib/storage/audit-store", () => ({ - getAuditEntryById: horizonMocks.getAuditEntry, -})); - -vi.mock("@/lib/stellar/verify-payment-quote", async (importOriginal) => ({ - ...await importOriginal(), - verifyPaymentAgainstQuote: horizonMocks.verifyQuote, -})); +vi.mock("@/lib/storage/audit-store", () => ({ getAuditEntryById: vi.fn() })); -import { Account, Asset, Keypair, Memo, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; +import { Account, Asset, Keypair, Networks, Operation, TransactionBuilder } from "@stellar/stellar-sdk"; import { NextRequest } from "next/server"; import { POST as submitSignedPost } from "@/app/api/stellar/submit-signed/route"; +import { createBuildAuthorization, getTransactionHash } from "@/lib/stellar/payment-build-authorization"; +import { getAuditEntryById } from "@/lib/storage/audit-store"; +import { IDEMPOTENCY_KEY_ERROR } from "@/lib/validation/schemas"; import { AUTH_COOKIE_KEY, createSessionToken } from "@/lib/auth/session"; import { resetRateLimitStore } from "@/lib/security/rate-limit"; import { @@ -79,7 +75,8 @@ import { const OPERATOR_USER_ID = "idem-operator-id"; const mockTxHash = "b".repeat(64); -const OTHER_TX_HASH = "d".repeat(64); +const decisionId = "00000000-0000-4000-8000-000000000001"; +const quoteExpiresAt = new Date(Date.now() + 300_000).toISOString(); function horizonAccepted(hash = mockTxHash) { return { @@ -102,6 +99,18 @@ function operatorCookie() { } function submitRequest(body: unknown, extraHeaders: Record = {}) { + const payload = body as { signedXdr?: string }; + const authorizedBody = payload.signedXdr ? { + ...payload, + decisionId, + quoteExpiresAt, + buildAuthorization: createBuildAuthorization({ + userId: OPERATOR_USER_ID, + decisionId, + quoteExpiresAt, + transactionHash: getTransactionHash(payload.signedXdr, Networks.TESTNET), + }), + } : body; return new NextRequest("http://localhost/api/stellar/submit-signed", { method: "POST", headers: { @@ -109,7 +118,7 @@ function submitRequest(body: unknown, extraHeaders: Record = {}) cookie: operatorCookie(), ...extraHeaders, }, - body: JSON.stringify({ auditEntryId: "00000000-0000-4000-8000-000000000000", ...(body as Record) }), + body: JSON.stringify(authorizedBody), }); } @@ -135,9 +144,19 @@ function buildSignedXdr(amount: string, destination?: string) { return tx.toXDR(); } -function slowSuccess(delayMs: number, hash = mockTxHash) { - return new Promise((resolve) => { - setTimeout(() => resolve(horizonAccepted(hash)), delayMs); +beforeEach(async () => { + vi.mocked(getAuditEntryById).mockResolvedValue({ + id: decisionId, + timestamp: new Date().toISOString(), + decision: "APPROVE", + paymentQuote: { expiresAt: quoteExpiresAt }, + } as Awaited>); + horizonMocks.submitTransaction.mockReset(); + horizonMocks.submitTransaction.mockResolvedValue({ + hash: mockTxHash, + ledger: 42, + successful: true, + result_xdr: "AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAA=", }); horizonMocks.getAuditEntry.mockResolvedValue({ id: "00000000-0000-4000-8000-000000000000", diff --git a/src/app/api/stellar/submit-signed/route.test.ts b/src/app/api/stellar/submit-signed/route.test.ts index 5733d39e..4c5bcb14 100644 --- a/src/app/api/stellar/submit-signed/route.test.ts +++ b/src/app/api/stellar/submit-signed/route.test.ts @@ -60,12 +60,14 @@ vi.mock("@/lib/storage/user-wallet-store", () => ({ getUserWallet: vi.fn(), })); -vi.mock("@/lib/storage/audit-store", () => ({ - getAuditEntryById: getAuditEntryMock, +vi.mock("@/lib/storage/audit-store", () => ({ getAuditEntryById: vi.fn(async () => ({ id: "decision-1" })) })); +vi.mock("@/lib/stellar/verify-payment-quote", async (importOriginal) => ({ + ...await importOriginal(), + isPaymentDecisionCurrent: vi.fn(() => true), })); - -vi.mock("@/lib/stellar/verify-payment-quote", () => ({ - verifyPaymentAgainstQuote: verifyQuoteMock, +vi.mock("@/lib/stellar/payment-build-authorization", async (importOriginal) => ({ + ...await importOriginal(), + verifyBuildAuthorization: vi.fn(() => true), })); vi.mock("@/lib/validation/schemas", () => ({ diff --git a/src/app/api/stellar/submit-signed/route.ts b/src/app/api/stellar/submit-signed/route.ts index 481fac00..581f82cb 100644 --- a/src/app/api/stellar/submit-signed/route.ts +++ b/src/app/api/stellar/submit-signed/route.ts @@ -8,15 +8,13 @@ import { getRequestLogContext, logError, logInfo, logWarn } from "@/lib/observab import { recordStellarSubmitResult } from "@/lib/observability/metrics"; import { redactSensitiveFields } from "@/lib/observability/redact"; import { getProtectedPaymentFlowReadinessReport } from "@/lib/readiness/production"; -import { enforceRequestGate, gateErrorHeaders } from "@/lib/security/enforcement-gate"; -import { rateLimitHeaders } from "@/lib/security/rate-limit"; -import { - decodeSignedXdrDestination, - decodeSignedXdrSourceAccount, - submitSignedTransactionXdr, - type SignedXdrDestinationResult, -} from "@/lib/stellar/client"; +import { consumeRateLimit, rateLimitHeaders } from "@/lib/security/rate-limit"; +import { decodeSignedXdrSourceAccount, submitSignedTransactionXdr } from "@/lib/stellar/client"; +import { assertStellarNetworkConfig } from "@/lib/stellar/network-config"; import { getStellarExplorerTransactionUrl } from "@/lib/stellar/network"; +import { getTransactionHash, verifyBuildAuthorization } from "@/lib/stellar/payment-build-authorization"; +import { isPaymentDecisionCurrent } from "@/lib/stellar/verify-payment-quote"; +import { getAuditEntryById } from "@/lib/storage/audit-store"; import { abortIdempotentSubmit, beginIdempotentSubmit, @@ -315,6 +313,36 @@ export async function POST(request: NextRequest) { }); } + const auditEntry = await getAuditEntryById(userId, payload.decisionId); + if (!isPaymentDecisionCurrent(auditEntry, payload.decisionId, payload.quoteExpiresAt)) { + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 403, + body: { error: "Payment decision or quote is no longer valid. Please re-evaluate the action." }, + headers: rateLimitHeaders(rate), + }); + } + + const transactionHash = getTransactionHash( + payload.signedXdr, + assertStellarNetworkConfig().networkPassphrase, + ); + if (!verifyBuildAuthorization(payload.buildAuthorization, { + userId, + decisionId: payload.decisionId, + quoteExpiresAt: payload.quoteExpiresAt, + transactionHash, + })) { + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 403, + body: { error: "Signed transaction does not match the authorized payment build." }, + headers: rateLimitHeaders(rate), + }); + } + const headerKey = request.headers.get("idempotency-key")?.trim(); const bodyKey = payload.idempotencyKey?.trim(); const idempotencyKey = headerKey && headerKey.length > 0 ? headerKey : bodyKey; @@ -399,6 +427,19 @@ export async function POST(request: NextRequest) { claimedIdempotency = { userId, key: idempotencyKey, requestHash }; } + // The idempotency lookup can take time; check the persisted authorization + // again at the last point before the external submission. + const currentDecision = await getAuditEntryById(userId, payload.decisionId); + if (!isPaymentDecisionCurrent(currentDecision, payload.decisionId, payload.quoteExpiresAt)) { + return jsonWithRequestContext(request, { + route: "/api/stellar/submit-signed", + startedAtMs, + status: 403, + body: { error: "Payment decision or quote is no longer valid. Please re-evaluate the action." }, + headers: rateLimitHeaders(rate), + }); + } + const submitted = await submitSignedTransactionXdr(payload.signedXdr); logInfo("Signed transaction submitted", { diff --git a/src/components/decision-console.tsx b/src/components/decision-console.tsx index 37aafa0a..52c573b0 100644 --- a/src/components/decision-console.tsx +++ b/src/components/decision-console.tsx @@ -78,6 +78,9 @@ type BuildPaymentResponse = { xdr?: string; sourcePublicKey?: string; networkPassphrase?: string; + decisionId?: string; + quoteExpiresAt?: string; + buildAuthorization?: string; }; type AgentPlanResponse = { ok?: boolean; error?: string; action?: AgentAction }; @@ -100,6 +103,8 @@ export function DecisionConsole() { const [executeAmount, setExecuteAmount] = useState(""); const [decisionData, setDecisionData] = useState(null); const [authorizedAuditEntryId, setAuthorizedAuditEntryId] = useState(null); + const [paymentBuildAuthorization, setPaymentBuildAuthorization] = useState(null); + const [paymentQuoteExpiresAt, setPaymentQuoteExpiresAt] = useState(null); const [lastTxExplorerUrl, setLastTxExplorerUrl] = useState(null); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); @@ -146,6 +151,8 @@ export function DecisionConsole() { }, [step, evaluatedAmount, executeAmount]); function resetPreparedXdr() { + setPaymentBuildAuthorization(null); + setPaymentQuoteExpiresAt(null); setUnsignedXdr(""); setSignedXdrInput(""); setSourcePublicKey(""); @@ -240,6 +247,7 @@ export function DecisionConsole() { return; } + resetPreparedXdr(); setDecisionData(payload); setAuthorizedAuditEntryId(payload.auditEntry.id); setMessage("Decision recorded in audit trail."); @@ -343,12 +351,16 @@ export function DecisionConsole() { }); const buildPayload = (await buildResponse.json()) as BuildPaymentResponse; - if (!buildResponse.ok || buildPayload.error || !buildPayload.xdr) { + if (!buildResponse.ok || buildPayload.error || !buildPayload.xdr || + buildPayload.decisionId !== authorizedAuditEntryId || + !buildPayload.quoteExpiresAt || !buildPayload.buildAuthorization) { setMessage(buildPayload.error ?? "Failed to build XDR."); return; } setUnsignedXdr(buildPayload.xdr); + setPaymentBuildAuthorization(buildPayload.buildAuthorization); + setPaymentQuoteExpiresAt(buildPayload.quoteExpiresAt); setSignedXdrInput(buildPayload.xdr); setSourcePublicKey(buildPayload.sourcePublicKey ?? balancePayload.publicKey ?? ""); setNetworkPassphrase(buildPayload.networkPassphrase ?? "TESTNET"); @@ -391,6 +403,10 @@ export function DecisionConsole() { async function submitSignedXdr(signedXdrArg?: string) { if (!ensureOperator()) return; + if (!authorizedAuditEntryId || !paymentQuoteExpiresAt || !paymentBuildAuthorization) { + setMessage("Build a payment from a current decision before submitting."); + return; + } const signedXdr = (signedXdrArg ?? signedXdrInput).trim(); if (!signedXdr) return; @@ -405,7 +421,12 @@ export function DecisionConsole() { const submitResponse = await fetch("/api/stellar/submit-signed", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ signedXdr }), + body: JSON.stringify({ + signedXdr, + decisionId: authorizedAuditEntryId, + quoteExpiresAt: paymentQuoteExpiresAt, + buildAuthorization: paymentBuildAuthorization, + }), }); const submitPayload = (await submitResponse.json()) as { diff --git a/src/lib/stellar/payment-build-authorization.ts b/src/lib/stellar/payment-build-authorization.ts new file mode 100644 index 00000000..09d95f66 --- /dev/null +++ b/src/lib/stellar/payment-build-authorization.ts @@ -0,0 +1,43 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +import { TransactionBuilder } from "@stellar/stellar-sdk"; + +type BuildAuthorization = { + userId: string; + decisionId: string; + quoteExpiresAt: string; + transactionHash: string; +}; + +function sign(payload: string): string { + const secret = process.env.FORTEXA_AUTH_SECRET?.trim(); + if (!secret) throw new Error("FORTEXA_AUTH_SECRET is required for payment authorization."); + return createHmac("sha256", secret).update(`payment-build:${payload}`).digest("base64url"); +} + +export function getTransactionHash(xdr: string, networkPassphrase: string): string { + return TransactionBuilder.fromXDR(xdr, networkPassphrase).hash().toString("hex"); +} + +export function createBuildAuthorization(input: BuildAuthorization): string { + const payload = Buffer.from(JSON.stringify(input)).toString("base64url"); + return `${payload}.${sign(payload)}`; +} + +export function verifyBuildAuthorization(token: string, expected: BuildAuthorization): boolean { + const parts = token.split("."); + if (parts.length !== 2 || token.length > 2048) return false; + const [payload, signature] = parts; + const actual = Buffer.from(signature); + const wanted = Buffer.from(sign(payload)); + if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return false; + try { + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as BuildAuthorization; + return decoded.userId === expected.userId && + decoded.decisionId === expected.decisionId && + decoded.quoteExpiresAt === expected.quoteExpiresAt && + decoded.transactionHash === expected.transactionHash; + } catch { + return false; + } +} diff --git a/src/lib/stellar/verify-payment-quote.test.ts b/src/lib/stellar/verify-payment-quote.test.ts index 93423e24..4dfe7ef9 100644 --- a/src/lib/stellar/verify-payment-quote.test.ts +++ b/src/lib/stellar/verify-payment-quote.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { buildPaymentQuoteFromDecision, + isPaymentDecisionCurrent, normalizeAmountXLM, verifyPaymentAgainstQuote, verifySignedPaymentAgainstQuote, @@ -18,6 +19,13 @@ import { import type { AuditEntry } from "@/lib/types/domain"; function mockAuditEntry(overrides: Partial = {}): AuditEntry { + const quote = buildPaymentQuoteFromDecision({ + destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + amountXLM: 10, + actionId: "act-1", + }); + // Older entries have no fixed expiry and use their audit timestamp. + if (overrides.timestamp && !overrides.paymentQuote) delete quote.expiresAt; return { id: "audit-1", timestamp: new Date().toISOString(), @@ -33,11 +41,7 @@ function mockAuditEntry(overrides: Partial = {}): AuditEntry { explanation: "Approved", triggeredPolicies: [], riskFindings: [], - paymentQuote: buildPaymentQuoteFromDecision({ - destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - amountXLM: 10, - actionId: "act-1", - }), + paymentQuote: quote, ...overrides, }; } @@ -56,6 +60,22 @@ describe("verifyPaymentAgainstQuote", () => { expect(result.ok).toBe(true); }); + it("uses the injected clock and fixed expiry at submit", () => { + const nowMs = Date.parse("2026-09-30T12:00:00.000Z"); + const entry = mockAuditEntry({ + paymentQuote: buildPaymentQuoteFromDecision({ + destination: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + amountXLM: 10, + actionId: "act-1", + nowMs, + }), + }); + const expiresAt = entry.paymentQuote!.expiresAt!; + expect(isPaymentDecisionCurrent(entry, entry.id, expiresAt, Date.parse(expiresAt) - 1)).toBe(true); + expect(isPaymentDecisionCurrent(entry, entry.id, expiresAt, Date.parse(expiresAt))).toBe(false); + expect(isPaymentDecisionCurrent(entry, "different-decision", expiresAt, nowMs)).toBe(false); + }); + it("rejects blocked decisions", () => { const entry = mockAuditEntry({ decision: "BLOCK", paymentQuote: undefined }); const result = verifyPaymentAgainstQuote(entry, { diff --git a/src/lib/stellar/verify-payment-quote.ts b/src/lib/stellar/verify-payment-quote.ts index d39d5681..e05eb383 100644 --- a/src/lib/stellar/verify-payment-quote.ts +++ b/src/lib/stellar/verify-payment-quote.ts @@ -102,7 +102,7 @@ const DEFAULT_QUOTE_TTL_SECONDS = 300; * Reads FORTEXA_PAYMENT_QUOTE_TTL_SECONDS; falls back to 300 s when the * value is absent, non-numeric, or less than 1. */ -function getQuoteTtlSeconds(): number { +export function getQuoteTtlMs(): number { const parsed = Number( process.env.FORTEXA_PAYMENT_QUOTE_TTL_SECONDS ?? DEFAULT_QUOTE_TTL_SECONDS, ); @@ -167,6 +167,7 @@ export function buildPaymentQuoteFromDecision(input: { memo?: string; actionId: string; network?: StellarNetworkId; + nowMs?: number; }): PaymentQuote { return { destination: input.destination.trim().toUpperCase(), @@ -174,12 +175,34 @@ export function buildPaymentQuoteFromDecision(input: { asset: "native", memo: (input.memo ?? `fortexa:${input.actionId}`).slice(0, 28), network: input.network ?? "testnet", + expiresAt: new Date((input.nowMs ?? Date.now()) + getQuoteTtlMs()).toISOString(), }; } +export function getQuoteExpiresAt(auditEntry: AuditEntry): string { + if (auditEntry.paymentQuote?.expiresAt) return auditEntry.paymentQuote.expiresAt; + const legacyExpiryMs = Date.parse(auditEntry.timestamp) + getQuoteTtlMs(); + return Number.isFinite(legacyExpiryMs) ? new Date(legacyExpiryMs).toISOString() : ""; +} + +/** Rechecks the persisted decision and its original expiry at submission time. */ +export function isPaymentDecisionCurrent( + auditEntry: AuditEntry | undefined, + decisionId: string, + quoteExpiresAt: string, + nowMs = Date.now(), +): boolean { + if (!auditEntry?.paymentQuote || auditEntry.id !== decisionId || + !EXECUTABLE_DECISIONS.has(auditEntry.decision)) return false; + const storedExpiry = getQuoteExpiresAt(auditEntry); + const expiryMs = Date.parse(storedExpiry); + return storedExpiry === quoteExpiresAt && Number.isFinite(expiryMs) && nowMs < expiryMs; +} + export function verifyPaymentAgainstQuote( auditEntry: AuditEntry | undefined, request: PaymentBuildParams, + nowMs = Date.now(), ): VerifyPaymentQuoteResult { if (!auditEntry) { return { @@ -198,7 +221,7 @@ export function verifyPaymentAgainstQuote( } if (request.requestTimestampMs !== undefined) { - const skewResult = validateRequestTimestamp(request.requestTimestampMs); + const skewResult = validateRequestTimestamp(request.requestTimestampMs, { nowMs }); if (!skewResult.ok) { const reason = skewResult.code === "stale" @@ -215,9 +238,13 @@ export function verifyPaymentAgainstQuote( } } - const gate = assertQuoteFreshAndPresent(auditEntry); - if (!gate.ok) { - return gate; + const expiresAtMs = Date.parse(getQuoteExpiresAt(auditEntry)); + if (!Number.isFinite(expiresAtMs) || nowMs >= expiresAtMs) { + return { + ok: false, + status: 403, + error: "Payment quote has expired. Please re-evaluate the action.", + }; } const { quote } = gate; diff --git a/src/lib/types/domain.ts b/src/lib/types/domain.ts index 2d04aad2..b00e4cd2 100644 --- a/src/lib/types/domain.ts +++ b/src/lib/types/domain.ts @@ -120,13 +120,15 @@ export type StellarNetworkId = "testnet"; export type StellarAssetId = "native"; /** Immutable payment parameters authorized by an APPROVE/WARN decision. */ -export interface PaymentQuote { +export interface PaymentQuote { destination: string; amountXLM: string; asset: StellarAssetId; memo: string; - network: StellarNetworkId; -} + network: StellarNetworkId; + /** Fixed at the time of the decision, rather than extended by later steps. */ + expiresAt?: string; +} export interface AuditEntry { id: string; diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index d377969e..380d05db 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -74,7 +74,9 @@ export const stellarBuildPaymentRequestSchema = z.object({ export const stellarSubmitSignedRequestSchema = z.object({ signedXdr: z.string().min(20).max(120000), - auditEntryId: z.string().uuid(), + decisionId: z.string().uuid(), + quoteExpiresAt: z.string().datetime(), + buildAuthorization: z.string().min(20).max(2048), idempotencyKey: idempotencyKeySchema.optional(), });