Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 32 additions & 1 deletion api/routes/__tests__/transfer-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jest.mock("api/services/transfer", () => {

return {
TransferNotFoundError,
TransferWriteUnauthorizedError: class TransferWriteUnauthorizedError extends Error {},
TransferService: jest.fn().mockImplementation(() => ({
getTransfer: mockGetTransfer,
upsertTransfer: mockUpsertTransfer,
Expand All @@ -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;
Expand All @@ -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 () => {
Expand All @@ -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", () => {
Expand Down
19 changes: 18 additions & 1 deletion api/services/__tests__/transfer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) });

Expand All @@ -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({
Expand Down
37 changes: 26 additions & 11 deletions api/services/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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,
Expand Down Expand Up @@ -98,7 +114,7 @@ export class TransferService {

async getAuthorizedTransfer(
id: string,
writeToken?: string
writeTokens?: WriteTokenCandidates
): Promise<ITransfer> {
const transfer = await TransferModel.findOne({ id: { $eq: id } }).select(
"+writeTokenHash"
Expand All @@ -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();
}
Expand Down Expand Up @@ -148,13 +163,14 @@ export class TransferService {

async upsertTransfer(
transfer: ITransfer,
writeToken?: string
writeTokens?: WriteTokenCandidates
): Promise<TransferWriteResult> {
const existing = await TransferModel.findOne({ id: transfer.id }).select(
"+writeTokenHash"
);

if (!existing) {
const [writeToken] = normalizeWriteTokens(writeTokens);
if (!writeToken) {
throw new TransferWriteUnauthorizedError();
}
Expand All @@ -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();
}

Expand All @@ -190,6 +205,6 @@ export class TransferService {
throw new TransferWriteUnauthorizedError();
}

return { transfer: toPublicTransfer(updatedTransfer) };
return { transfer: toPublicTransfer(updatedTransfer), writeToken };
}
}
31 changes: 17 additions & 14 deletions components/Bridge/context/TransferContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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(),
Expand Down
5 changes: 2 additions & 3 deletions components/Bridge/hooks/sponsored-utxo.ts
Original file line number Diff line number Diff line change
@@ -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 =
| {
Expand All @@ -15,9 +16,7 @@ export const requestSponsoredUtxo = async (
action: "mint" | "prepare-burn" | "submit-burn",
transaction?: UTXOTransaction
): Promise<SponsoredUtxoResponse> => {
const writeToken = localStorage.getItem(
`transfer-write-token-${transferId}`
);
const writeToken = getTransferWriteToken(transferId);
const response = await fetch(
buildApiUrl(
`/api/transfer/${encodeURIComponent(transferId)}/sponsored-utxo`
Expand Down
16 changes: 9 additions & 7 deletions pages/api/transfer/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 2 additions & 7 deletions pages/api/transfer/[id]/sponsored-utxo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading