From 0440b1b2011e40861a6ea23aaa9f4fae4519bd2e Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Mon, 17 Aug 2026 13:29:50 -0700 Subject: [PATCH] Keep transfer write capabilities durable --- README.md | 7 +- api/routes/__tests__/transfer-patch.test.ts | 33 +++++++- api/services/__tests__/transfer.test.ts | 19 ++++- api/services/transfer.ts | 37 ++++++--- components/Bridge/context/TransferContext.tsx | 31 ++++---- components/Bridge/hooks/sponsored-utxo.ts | 5 +- pages/api/transfer/[id].ts | 16 ++-- pages/api/transfer/[id]/sponsored-utxo.ts | 9 +-- utils/api/transfer-write-capability.test.ts | 53 +++++++++++++ utils/api/transfer-write-capability.ts | 76 +++++++++++++++++++ utils/transfer-write-token.test.ts | 76 +++++++++++++++++++ utils/transfer-write-token.ts | 63 +++++++++++++++ 12 files changed, 379 insertions(+), 46 deletions(-) create mode 100644 utils/api/transfer-write-capability.test.ts create mode 100644 utils/api/transfer-write-capability.ts create mode 100644 utils/transfer-write-token.test.ts create mode 100644 utils/transfer-write-token.ts diff --git a/README.md b/README.md index 455ab5c..bac8929 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,11 @@ Treat enabling `FOUNDATION_FUNDED=true` as an atomic V2 backend cutover: writes. 5. Deploy the V2 backend and frontend together to every instance and allow its MongoDB indexes to be created. New transfers receive a per-transfer write - capability; pre-cutover rows without one are intentionally read-only through - the public API. + capability. The browser retains the active capability in memory and local + storage, while accepted writes refresh an HttpOnly backup cookie scoped to + that transfer's API path. The backend stores only the capability hash. + Pre-cutover rows without one are intentionally read-only through the public + API. 6. Enable foundation funding only after all instances run the same V2 sponsor protocol. diff --git a/api/routes/__tests__/transfer-patch.test.ts b/api/routes/__tests__/transfer-patch.test.ts index 2803635..3d7bd0b 100644 --- a/api/routes/__tests__/transfer-patch.test.ts +++ b/api/routes/__tests__/transfer-patch.test.ts @@ -14,6 +14,7 @@ jest.mock("api/services/transfer", () => { return { TransferNotFoundError, + TransferWriteUnauthorizedError: class TransferWriteUnauthorizedError extends Error {}, TransferService: jest.fn().mockImplementation(() => ({ getTransfer: mockGetTransfer, upsertTransfer: mockUpsertTransfer, @@ -33,6 +34,7 @@ const createResponse = () => { const response = { status: jest.fn(), json: jest.fn(), + setHeader: jest.fn(), }; response.status.mockReturnValue(response); return response as unknown as NextApiResponse & typeof response; @@ -41,7 +43,10 @@ const createResponse = () => { describe("transfer PATCH binding", () => { beforeEach(() => { jest.clearAllMocks(); - mockUpsertTransfer.mockResolvedValue({}); + mockUpsertTransfer.mockResolvedValue({ + transfer: { id: "transfer-id" }, + writeToken: "accepted-token", + }); }); it("rejects a body that targets a different transfer than the URL", async () => { @@ -57,6 +62,32 @@ describe("transfer PATCH binding", () => { expect(response.status).toHaveBeenCalledWith(400); expect(mockUpsertTransfer).not.toHaveBeenCalled(); }); + + it("offers both bearer and backup-cookie capabilities and refreshes the accepted cookie", async () => { + const request = { + query: { id: "transfer-id" }, + body: { id: "transfer-id" }, + headers: { + authorization: "Bearer replacement-token", + cookie: "transfer-write-token=original-token", + "x-forwarded-proto": "https", + }, + socket: {}, + } as unknown as NextApiRequest; + const response = createResponse(); + + await patchRequest(request, response); + + expect(mockUpsertTransfer).toHaveBeenCalledWith(request.body, [ + "replacement-token", + "original-token", + ]); + expect(response.setHeader).toHaveBeenCalledWith( + "Set-Cookie", + expect.stringContaining("transfer-write-token=accepted-token") + ); + expect(response.status).toHaveBeenCalledWith(200); + }); }); describe("transfer GET errors", () => { diff --git a/api/services/__tests__/transfer.test.ts b/api/services/__tests__/transfer.test.ts index 77775e3..956a65d 100644 --- a/api/services/__tests__/transfer.test.ts +++ b/api/services/__tests__/transfer.test.ts @@ -79,7 +79,7 @@ describe("TransferService write capabilities", () => { await expect( new TransferService().upsertTransfer(transfer, writeToken) - ).resolves.toEqual({ transfer }); + ).resolves.toEqual({ transfer, writeToken }); expect(TransferModelMock.findOneAndUpdate).toHaveBeenCalledWith( { id: transfer.id, writeTokenHash }, expect.objectContaining({ @@ -108,6 +108,22 @@ describe("TransferService write capabilities", () => { }); }); + it("accepts the original backup capability when a replacement bearer token is wrong", async () => { + const writeToken = "original-capability"; + const writeTokenHash = createHash("sha256") + .update(writeToken) + .digest("hex"); + findExisting({ ...transfer, writeTokenHash }); + TransferModelMock.findOneAndUpdate.mockResolvedValue(transfer); + + await expect( + new TransferService().upsertTransfer(transfer, [ + "replacement-capability", + writeToken, + ]) + ).resolves.toEqual({ transfer, writeToken }); + }); + it("rejects sponsored actions without the transfer capability", async () => { findExisting({ ...transfer, writeTokenHash: "00".repeat(32) }); @@ -126,6 +142,7 @@ describe("TransferService write capabilities", () => { }, "new-transfer-capability"); expect(result.transfer.version).toBe("v2"); + expect(result.writeToken).toBe("new-transfer-capability"); expect(result.transfer).not.toHaveProperty("writeTokenHash"); expect(TransferModelMock.create).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/api/services/transfer.ts b/api/services/transfer.ts index 3163538..38bfc63 100644 --- a/api/services/transfer.ts +++ b/api/services/transfer.ts @@ -25,8 +25,11 @@ export class TransferNotFoundError extends Error { type TransferWriteResult = { transfer: ITransfer; + writeToken: string; }; +type WriteTokenCandidates = string | readonly string[] | undefined; + const hashWriteToken = (writeToken: string) => createHash("sha256").update(writeToken, "utf8").digest("hex"); @@ -38,6 +41,19 @@ const writeTokenMatches = (writeToken: string, expectedHash: string) => { return actual.length === expected.length && timingSafeEqual(actual, expected); }; +const normalizeWriteTokens = (writeTokens: WriteTokenCandidates): string[] => + typeof writeTokens === "string" + ? [writeTokens] + : Array.from(new Set(writeTokens ?? [])).filter(Boolean); + +const findMatchingWriteToken = ( + writeTokens: WriteTokenCandidates, + expectedHash: string +) => + normalizeWriteTokens(writeTokens).find((writeToken) => + writeTokenMatches(writeToken, expectedHash) + ); + const toTransferUpdate = (transfer: ITransfer) => ({ type: transfer.type, status: transfer.status, @@ -98,7 +114,7 @@ export class TransferService { async getAuthorizedTransfer( id: string, - writeToken?: string + writeTokens?: WriteTokenCandidates ): Promise { const transfer = await TransferModel.findOne({ id: { $eq: id } }).select( "+writeTokenHash" @@ -108,9 +124,8 @@ export class TransferService { throw new Error("Transfer not found"); } if ( - !writeToken || !transfer.writeTokenHash || - !writeTokenMatches(writeToken, transfer.writeTokenHash) + !findMatchingWriteToken(writeTokens, transfer.writeTokenHash) ) { throw new TransferWriteUnauthorizedError(); } @@ -148,13 +163,14 @@ export class TransferService { async upsertTransfer( transfer: ITransfer, - writeToken?: string + writeTokens?: WriteTokenCandidates ): Promise { const existing = await TransferModel.findOne({ id: transfer.id }).select( "+writeTokenHash" ); if (!existing) { + const [writeToken] = normalizeWriteTokens(writeTokens); if (!writeToken) { throw new TransferWriteUnauthorizedError(); } @@ -166,14 +182,13 @@ export class TransferService { writeTokenHash: hashWriteToken(writeToken), }); - return { transfer: toPublicTransfer(created) }; + return { transfer: toPublicTransfer(created), writeToken }; } - if ( - !writeToken || - !existing.writeTokenHash || - !writeTokenMatches(writeToken, existing.writeTokenHash) - ) { + const writeToken = existing.writeTokenHash + ? findMatchingWriteToken(writeTokens, existing.writeTokenHash) + : undefined; + if (!writeToken || !existing.writeTokenHash) { throw new TransferWriteUnauthorizedError(); } @@ -190,6 +205,6 @@ export class TransferService { throw new TransferWriteUnauthorizedError(); } - return { transfer: toPublicTransfer(updatedTransfer) }; + return { transfer: toPublicTransfer(updatedTransfer), writeToken }; } } diff --git a/components/Bridge/context/TransferContext.tsx b/components/Bridge/context/TransferContext.tsx index d787c4f..5b3433e 100644 --- a/components/Bridge/context/TransferContext.tsx +++ b/components/Bridge/context/TransferContext.tsx @@ -2,6 +2,10 @@ import { ITransfer } from "@contexts/Transfer/types"; import { createContext, useContext } from "react"; import { UseMutateFunction, useMutation, useQuery } from "react-query"; import isTransfer from "utils/isTransfer"; +import { + getOrCreateTransferWriteToken, + getTransferWriteToken, +} from "utils/transfer-write-token"; export interface ITransferContext { transfer: ITransfer; @@ -46,17 +50,6 @@ const buildTransferPath = (id: string) => { return `/api/transfer/${encodeURIComponent(id)}`; }; -const getOrCreateTransferWriteToken = (id: string) => { - const storageKey = `transfer-write-token-${id}`; - const existing = localStorage.getItem(storageKey); - if (existing) { - return existing; - } - const writeToken = crypto.randomUUID(); - localStorage.setItem(storageKey, writeToken); - return writeToken; -}; - export const TransferContextProvider: React.FC< TransferContextProviderProps > = ({ children, transfer: initialData }) => { @@ -82,20 +75,30 @@ export const TransferContextProvider: React.FC< ["transfer", initialData.id], async (updatedTransfer: ITransfer) => { const url = buildTransferPath(initialData.id); - const writeToken = getOrCreateTransferWriteToken(initialData.id); + const writeToken = + getTransferWriteToken(initialData.id) ?? + (initialData.status === "initialize" + ? getOrCreateTransferWriteToken(initialData.id) + : undefined); const res = await fetch(url, { method: "PATCH", body: JSON.stringify(updatedTransfer), headers: { "Content-Type": "application/json", - Authorization: `Bearer ${writeToken}`, + ...(writeToken + ? { Authorization: `Bearer ${writeToken}` } + : {}), }, }); const jsonData = await res.json(); if (isTransfer(jsonData)) { return jsonData; } - throw new Error("Invalid transfer"); + throw new Error( + typeof jsonData?.message === "string" + ? jsonData.message + : "Invalid transfer" + ); }, { onSuccess: () => refetchTransfer(), diff --git a/components/Bridge/hooks/sponsored-utxo.ts b/components/Bridge/hooks/sponsored-utxo.ts index b8defd4..6be5704 100644 --- a/components/Bridge/hooks/sponsored-utxo.ts +++ b/components/Bridge/hooks/sponsored-utxo.ts @@ -1,5 +1,6 @@ import { UTXOTransaction } from "syscoinjs-lib"; import { buildApiUrl } from "utils/api-base-url"; +import { getTransferWriteToken } from "utils/transfer-write-token"; export type SponsoredUtxoResponse = | { @@ -15,9 +16,7 @@ export const requestSponsoredUtxo = async ( action: "mint" | "prepare-burn" | "submit-burn", transaction?: UTXOTransaction ): Promise => { - const writeToken = localStorage.getItem( - `transfer-write-token-${transferId}` - ); + const writeToken = getTransferWriteToken(transferId); const response = await fetch( buildApiUrl( `/api/transfer/${encodeURIComponent(transferId)}/sponsored-utxo` diff --git a/pages/api/transfer/[id].ts b/pages/api/transfer/[id].ts index 027cccb..73a59a8 100644 --- a/pages/api/transfer/[id].ts +++ b/pages/api/transfer/[id].ts @@ -6,6 +6,10 @@ import { } from "api/services/transfer"; import dbConnect from "lib/mongodb"; import { applyApiCors } from "utils/api/cors"; +import { + getTransferWriteTokens, + setTransferWriteTokenCookie, +} from "utils/api/transfer-write-capability"; const transferService = new TransferService(); @@ -44,13 +48,11 @@ export const patchRequest = async ( } try { - const authorization = req.headers.authorization; - const writeToken = - typeof authorization === "string" && - authorization.startsWith("Bearer ") - ? authorization.slice("Bearer ".length) - : undefined; - const updated = await transferService.upsertTransfer(req.body, writeToken); + const updated = await transferService.upsertTransfer( + req.body, + getTransferWriteTokens(req) + ); + setTransferWriteTokenCookie(req, res, id, updated.writeToken); res.status(200).json(updated.transfer); } catch (e) { if (e instanceof TransferWriteUnauthorizedError) { diff --git a/pages/api/transfer/[id]/sponsored-utxo.ts b/pages/api/transfer/[id]/sponsored-utxo.ts index d99246c..effc88f 100644 --- a/pages/api/transfer/[id]/sponsored-utxo.ts +++ b/pages/api/transfer/[id]/sponsored-utxo.ts @@ -16,6 +16,7 @@ import dbConnect from "lib/mongodb"; import { NextApiHandler } from "next"; import { UTXOTransaction } from "syscoinjs-lib"; import { applyApiCors } from "utils/api/cors"; +import { getTransferWriteTokens } from "utils/api/transfer-write-capability"; type SponsoredUtxoRequest = { action?: "mint" | "prepare-burn" | "submit-burn"; @@ -46,15 +47,9 @@ const handler: NextApiHandler = async (req, res) => { try { await dbConnect(); - const authorization = req.headers.authorization; - const writeToken = - typeof authorization === "string" && - authorization.startsWith("Bearer ") - ? authorization.slice("Bearer ".length) - : undefined; const transfer = await transferService.getAuthorizedTransfer( id, - writeToken + getTransferWriteTokens(req) ); const { action, transaction } = req.body as SponsoredUtxoRequest; diff --git a/utils/api/transfer-write-capability.test.ts b/utils/api/transfer-write-capability.test.ts new file mode 100644 index 0000000..6fc2e0c --- /dev/null +++ b/utils/api/transfer-write-capability.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { + getTransferWriteTokens, + setTransferWriteTokenCookie, +} from "./transfer-write-capability"; + +const request = (headers: NextApiRequest["headers"] = {}) => + ({ headers, socket: {} }) as NextApiRequest; + +describe("transfer write capability transport", () => { + it("returns distinct bearer and backup-cookie candidates", () => { + expect( + getTransferWriteTokens( + request({ + authorization: "Bearer replacement-token", + cookie: + "unrelated=value; transfer-write-token=original-token; transfer-write-token=original-token", + }) + ) + ).toEqual(["replacement-token", "original-token"]); + }); + + it("ignores malformed capability cookies", () => { + expect( + getTransferWriteTokens( + request({ cookie: "transfer-write-token=%E0%A4%A" }) + ) + ).toEqual([]); + }); + + it("sets an HttpOnly, transfer-scoped secure backup cookie", () => { + const response = { setHeader: jest.fn() } as unknown as NextApiResponse; + + setTransferWriteTokenCookie( + request({ "x-forwarded-proto": "https" }), + response, + "transfer/id", + "write token" + ); + + expect(response.setHeader).toHaveBeenCalledWith( + "Set-Cookie", + expect.stringContaining( + "transfer-write-token=write%20token; Path=/api/transfer/transfer%2Fid;" + ) + ); + const cookie = (response.setHeader as jest.Mock).mock.calls[0][1] as string; + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Strict"); + expect(cookie).toContain("Secure"); + }); +}); diff --git a/utils/api/transfer-write-capability.ts b/utils/api/transfer-write-capability.ts new file mode 100644 index 0000000..7c2b4f8 --- /dev/null +++ b/utils/api/transfer-write-capability.ts @@ -0,0 +1,76 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +const WRITE_TOKEN_COOKIE = "transfer-write-token"; +const WRITE_TOKEN_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; +const MAX_WRITE_TOKEN_CANDIDATES = 4; + +const firstHeaderValue = (value?: string | string[]) => + Array.isArray(value) ? value[0] : value; + +const getBearerToken = (req: NextApiRequest): string | undefined => { + const authorization = firstHeaderValue(req.headers.authorization); + if (!authorization?.startsWith("Bearer ")) { + return undefined; + } + + const token = authorization.slice("Bearer ".length).trim(); + return token || undefined; +}; + +const getCookieTokens = (req: NextApiRequest): string[] => { + const cookieHeader = firstHeaderValue(req.headers.cookie); + if (!cookieHeader) { + return []; + } + + return cookieHeader.split(";").flatMap((part) => { + const separator = part.indexOf("="); + if ( + separator === -1 || + part.slice(0, separator).trim() !== WRITE_TOKEN_COOKIE + ) { + return []; + } + + try { + const token = decodeURIComponent(part.slice(separator + 1).trim()); + return token ? [token] : []; + } catch { + return []; + } + }); +}; + +export const getTransferWriteTokens = (req: NextApiRequest): string[] => + Array.from( + new Set( + [getBearerToken(req), ...getCookieTokens(req)].filter(Boolean) as string[] + ) + ).slice(0, MAX_WRITE_TOKEN_CANDIDATES); + +const isSecureRequest = (req: NextApiRequest) => { + const forwardedProto = firstHeaderValue(req.headers["x-forwarded-proto"]) + ?.split(",")[0] + ?.trim(); + const socket = req.socket as typeof req.socket & { encrypted?: boolean }; + return forwardedProto === "https" || socket.encrypted === true; +}; + +export const setTransferWriteTokenCookie = ( + req: NextApiRequest, + res: NextApiResponse, + transferId: string, + writeToken: string +) => { + const attributes = [ + `${WRITE_TOKEN_COOKIE}=${encodeURIComponent(writeToken)}`, + `Path=/api/transfer/${encodeURIComponent(transferId)}`, + `Max-Age=${WRITE_TOKEN_COOKIE_MAX_AGE_SECONDS}`, + "HttpOnly", + "SameSite=Strict", + ]; + if (isSecureRequest(req)) { + attributes.push("Secure"); + } + res.setHeader("Set-Cookie", attributes.join("; ")); +}; diff --git a/utils/transfer-write-token.test.ts b/utils/transfer-write-token.test.ts new file mode 100644 index 0000000..7af7032 --- /dev/null +++ b/utils/transfer-write-token.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { + clearActiveTransferWriteTokensForTests, + getOrCreateTransferWriteToken, + getTransferWriteToken, +} from "./transfer-write-token"; + +const values = new Map(); +const storage = { + getItem: jest.fn((key: string) => values.get(key) ?? null), + setItem: jest.fn((key: string, value: string) => values.set(key, value)), +}; + +describe("transfer write token storage", () => { + beforeEach(() => { + values.clear(); + clearActiveTransferWriteTokensForTests(); + storage.getItem.mockClear(); + storage.setItem.mockClear(); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { localStorage: storage }, + }); + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: jest.fn(() => "generated-token") }, + }); + }); + + afterEach(() => { + Reflect.deleteProperty(globalThis, "window"); + Reflect.deleteProperty(globalThis, "crypto"); + }); + + it("keeps the active capability when persistent storage disappears", () => { + expect(getOrCreateTransferWriteToken("transfer-a")).toBe( + "generated-token" + ); + + values.clear(); + + expect(getTransferWriteToken("transfer-a")).toBe("generated-token"); + expect(getOrCreateTransferWriteToken("transfer-a")).toBe( + "generated-token" + ); + expect(crypto.randomUUID).toHaveBeenCalledTimes(1); + }); + + it("keeps simultaneous transfer capabilities isolated by transfer ID", () => { + const randomUUID = crypto.randomUUID as jest.MockedFunction< + typeof crypto.randomUUID + >; + randomUUID + .mockReturnValueOnce("token-a") + .mockReturnValueOnce("token-b") + .mockReturnValueOnce("token-c"); + + expect(getOrCreateTransferWriteToken("transfer-a")).toBe("token-a"); + expect(getOrCreateTransferWriteToken("transfer-b")).toBe("token-b"); + expect(getOrCreateTransferWriteToken("transfer-c")).toBe("token-c"); + expect(getTransferWriteToken("transfer-a")).toBe("token-a"); + expect(getTransferWriteToken("transfer-b")).toBe("token-b"); + expect(getTransferWriteToken("transfer-c")).toBe("token-c"); + }); + + it("continues with the active copy when persistent storage rejects writes", () => { + storage.setItem.mockImplementationOnce(() => { + throw new Error("Storage quota exceeded"); + }); + + expect(getOrCreateTransferWriteToken("transfer-a")).toBe( + "generated-token" + ); + expect(getTransferWriteToken("transfer-a")).toBe("generated-token"); + }); +}); diff --git a/utils/transfer-write-token.ts b/utils/transfer-write-token.ts new file mode 100644 index 0000000..de4a6cc --- /dev/null +++ b/utils/transfer-write-token.ts @@ -0,0 +1,63 @@ +const TRANSFER_WRITE_TOKEN_PREFIX = "transfer-write-token-"; + +const activeWriteTokens = new Map(); + +const getStorage = (): Storage | undefined => { + try { + return window.localStorage; + } catch { + return undefined; + } +}; + +const getStorageKey = (transferId: string) => + `${TRANSFER_WRITE_TOKEN_PREFIX}${transferId}`; + +const readStoredToken = (transferId: string): string | undefined => { + try { + return getStorage()?.getItem(getStorageKey(transferId)) ?? undefined; + } catch { + return undefined; + } +}; + +const storeToken = (transferId: string, writeToken: string) => { + try { + getStorage()?.setItem(getStorageKey(transferId), writeToken); + } catch { + // The active-page copy still keeps an in-progress transfer writable. + } +}; + +export const getTransferWriteToken = ( + transferId: string +): string | undefined => { + const activeToken = activeWriteTokens.get(transferId); + if (activeToken) { + return activeToken; + } + + const storedToken = readStoredToken(transferId); + if (!storedToken) { + return undefined; + } + + activeWriteTokens.set(transferId, storedToken); + return storedToken; +}; + +export const getOrCreateTransferWriteToken = (transferId: string): string => { + const existingToken = getTransferWriteToken(transferId); + if (existingToken) { + return existingToken; + } + + const writeToken = crypto.randomUUID(); + activeWriteTokens.set(transferId, writeToken); + storeToken(transferId, writeToken); + return writeToken; +}; + +export const clearActiveTransferWriteTokensForTests = () => { + activeWriteTokens.clear(); +};