From eb5726a26870f9bf0283588e09983a0050d42bbc Mon Sep 17 00:00:00 2001 From: sirpy Date: Thu, 8 Oct 2026 10:21:44 +0300 Subject: [PATCH 1/2] fix: lint --- biome.json | 12 + src/app/api/chains/OWNERSHIP.md | 24 + src/app/api/chains/auth/address/route.ts | 117 ++++ src/app/api/chains/chainsProxy.test.ts | 351 +++++++++++ .../api/chains/clientAddressSession.test.ts | 384 ++++++++++++ src/app/api/chains/clientAddressSession.ts | 247 ++++++++ .../api/chains/clientOwnershipSignature.ts | 71 +++ src/app/api/chains/ownershipProtocol.ts | 19 + src/app/api/chains/ownershipRoutes.test.ts | 266 +++++++++ src/app/api/chains/ownershipSession.test.ts | 546 ++++++++++++++++++ src/app/api/chains/ownershipSignature.test.ts | 365 ++++++++++++ src/app/api/chains/ownershipSignature.ts | 114 ++++ .../ownershipSignature.vitest.config.ts | 31 + src/app/api/chains/powSession.ts | 310 ++++++++++ src/chain/provider/Bitcoin/bitcoinProvider.ts | 11 +- src/chain/provider/EVM/evmProvider.ts | 4 +- src/chain/provider/Solana/solanaProvider.ts | 4 +- src/chain/provider/XRP/xrpProvider.ts | 4 +- src/hooks/useLedger/tatumBitcoinHandler.ts | 33 +- src/login/adapters/privatekey.ts | 25 + src/login/types.ts | 4 + src/proxy.ts | 192 +++++- vitest.config.ts | 45 +- 23 files changed, 3145 insertions(+), 34 deletions(-) create mode 100644 src/app/api/chains/OWNERSHIP.md create mode 100644 src/app/api/chains/auth/address/route.ts create mode 100644 src/app/api/chains/chainsProxy.test.ts create mode 100644 src/app/api/chains/clientAddressSession.test.ts create mode 100644 src/app/api/chains/clientAddressSession.ts create mode 100644 src/app/api/chains/clientOwnershipSignature.ts create mode 100644 src/app/api/chains/ownershipProtocol.ts create mode 100644 src/app/api/chains/ownershipRoutes.test.ts create mode 100644 src/app/api/chains/ownershipSession.test.ts create mode 100644 src/app/api/chains/ownershipSignature.test.ts create mode 100644 src/app/api/chains/ownershipSignature.ts create mode 100644 src/app/api/chains/ownershipSignature.vitest.config.ts create mode 100644 src/app/api/chains/powSession.ts diff --git a/biome.json b/biome.json index 24e0220..94baf03 100644 --- a/biome.json +++ b/biome.json @@ -75,6 +75,18 @@ "semicolons": "asNeeded" } }, + "overrides": [ + { + "includes": ["**/*.test.{ts,tsx}"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ], "assist": { "actions": { "source": { diff --git a/src/app/api/chains/OWNERSHIP.md b/src/app/api/chains/OWNERSHIP.md new file mode 100644 index 0000000..2797288 --- /dev/null +++ b/src/app/api/chains/OWNERSHIP.md @@ -0,0 +1,24 @@ +# Blockchain API ownership authentication + +## Deployment + +- Set `API_POW_SESSION_SECRET` to an independently generated random server-only secret of at least 32 characters. Use the same value on all instances. Rotating it invalidates all challenges and sessions. +- Set `API_OWNERSHIP_ORIGIN` to the exact canonical app origin, without a trailing slash (HTTPS in production). Each preview deployment needs its own configured origin. Development defaults to `http://localhost:3000`. +- No shared nonce storage is required. Existing per-instance IP rate limits are **not distributed protection**; use a shared limiter or WAF for production quota protection. + +## Protocol v2 + +The server HMAC-signs a five-minute challenge containing family, address, audience, timestamp and PoW difficulty. The client validates these fields, signs a domain-separated authentication message containing the challenge with that address's signer, and solves the SHA-256 PoW. The POST endpoint verifies all three proofs before issuing an HttpOnly cookie. + +Cookies are scoped to family/address and expire 30 minutes after the original challenge timestamp. A replay within the five-minute challenge window produces the same session token and expiration, never a fresh lifetime. This is time-bounded authentication, not a single-use protocol; there is no individual revocation without state. Old PoW-only cookies are rejected by the v2 token types and cookie names. + +EVM uses personal-message signing, Solana uses Ed25519 message signing, and XRP uses ripple-keypairs with public-key/address verification. BTC/DOGE use a GoodWallet-specific, domain-separated SHA-256/ECDSA authentication protocol with network-specific public-key/address derivation. This is not BIP-322 and does not sign transactions. Imported external BTC/DOGE/XRP signers must implement `signAuthMessage`; unsupported signers fail closed. External EVM wallets may prompt for authentication and contract-wallet signatures (EIP-1271) are not supported by this verifier. + +## Scope and remaining limitations + +- Balance/history/UTXO requests require an ownership cookie for the exact chain family and address. +- Non-verbose raw transaction requests require the address cookie, but the existing transaction handler does **not** verify that the hash belongs to the supplied address. This change authenticates the requesting address; it does not establish transaction association. +- Fee queries and verbose transaction confirmation queries retain their existing rate-limited, unauthenticated behavior. Counterparty name lookups and direct client RPC calls are unchanged. +- Authentication proves ownership, not use of the official UI. Maintain upstream budgets, rate limits and caching independently. + +Run the isolated crypto/protocol tests with `yarn vitest run --config src/app/api/chains/ownershipSignature.vitest.config.ts`. \ No newline at end of file diff --git a/src/app/api/chains/auth/address/route.ts b/src/app/api/chains/auth/address/route.ts new file mode 100644 index 0000000..73d6e28 --- /dev/null +++ b/src/app/api/chains/auth/address/route.ts @@ -0,0 +1,117 @@ +import { type NextRequest, NextResponse } from "next/server" +import z from "zod" + +import { OwnershipScope, ownershipMessage } from "../../ownershipProtocol" +import { verifyOwnershipSignature } from "../../ownershipSignature" +import { + getAddressSessionCookieName, + getOwnershipAudience, + issuePowChallenge, + issuePowSession, + verifyPowSolution, +} from "../../powSession" + +const VerifySchema = OwnershipScope.extend({ + challenge: z.string().min(1).max(2048), + nonce: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + signature: z.string().min(1).max(300), + publicKey: z.string().max(130).optional(), +}) + +const noStore = { "Cache-Control": "private, no-store" } + +function checkOrigin(request: NextRequest) { + return request.headers.get("origin") === getOwnershipAudience() +} + +export async function GET(request: NextRequest) { + try { + const { family, address } = OwnershipScope.parse( + Object.fromEntries(request.nextUrl.searchParams), + ) + const challenge = await issuePowChallenge(family, address) + + return NextResponse.json(challenge, { status: 200, headers: noStore }) + } catch (error) { + return NextResponse.json( + { + message: "Unable to issue ownership challenge", + }, + { status: error instanceof z.ZodError ? 400 : 503, headers: noStore }, + ) + } +} + +export async function POST(request: NextRequest) { + try { + if (!checkOrigin(request)) + return NextResponse.json( + { message: "Forbidden" }, + { status: 403, headers: noStore }, + ) + const text = await request.text() + if (text.length > 4096) + return NextResponse.json( + { message: "Payload too large" }, + { status: 413, headers: noStore }, + ) + const parsedBody = VerifySchema.parse(JSON.parse(text)) + const isValid = await verifyPowSolution( + parsedBody.family, + parsedBody.address, + parsedBody.challenge, + parsedBody.nonce, + ) + + if ( + !isValid || + !(await verifyOwnershipSignature( + parsedBody.family, + parsedBody.address, + ownershipMessage(parsedBody.challenge), + parsedBody.signature, + parsedBody.publicKey, + )) + ) { + return NextResponse.json( + { message: "Invalid ownership proof" }, + { status: 401, headers: noStore }, + ) + } + + const session = await issuePowSession(parsedBody.challenge) + const cookieName = await getAddressSessionCookieName( + parsedBody.family, + parsedBody.address, + ) + const response = NextResponse.json( + { ok: true, expiresAt: session.expiresAt }, + { status: 200, headers: noStore }, + ) + + response.cookies.set({ + name: cookieName, + value: session.token, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/api/chains", + expires: new Date(session.expiresAt), + }) + + return response + } catch (error) { + return NextResponse.json( + { + message: "Failed to create address session", + }, + { + status: + error instanceof z.ZodError || error instanceof SyntaxError + ? 400 + : 503, + headers: noStore, + }, + ) + } +} diff --git a/src/app/api/chains/chainsProxy.test.ts b/src/app/api/chains/chainsProxy.test.ts new file mode 100644 index 0000000..ac8663d --- /dev/null +++ b/src/app/api/chains/chainsProxy.test.ts @@ -0,0 +1,351 @@ +import { NextRequest } from "next/server" +import { randomBytes } from "node:crypto" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { proxy } from "@/proxy" + +import { + getAddressSessionCookieName, + issuePowChallenge, + issuePowSession, +} from "./powSession" + +const ORIGIN = "http://localhost:3000" +const NOW = Date.UTC(2026, 9, 7, 12) +const WINDOW = 60_000 +const SESSION_TTL = 30 * 60_000 +// Ephemeral test-only secret: never persisted or sent to a provider. +const secret = randomBytes(32).toString("hex") + +const BTC_A = "bc1qtestaddressone" +const BTC_B = "bc1qtestaddresstwo" + +// The limiter store is module-global, so every test uses its own client IP. +let ipCounter = 0 +const freshIp = () => `chains-proxy-test-${++ipCounter}` + +type CallInit = { + method?: string + // null omits every client-IP header. + ip?: string | null + headers?: Record +} + +const call = (path: string, { method = "GET", ip, headers }: CallInit = {}) => + proxy( + new NextRequest(`${ORIGIN}${path}`, { + method, + headers: { + Origin: ORIGIN, + Host: "localhost:3000", + ...(ip === null ? {} : { "x-real-ip": ip ?? freshIp() }), + ...headers, + }, + }), + ) + +const passesThrough = (response: Response) => + response.headers.get("x-middleware-next") === "1" + +const mintSession = async (family: string, address: string) => { + const { challenge } = await issuePowChallenge(family as "BTC", address) + const { token } = await issuePowSession(challenge) + return { name: await getAddressSessionCookieName(family, address), token } +} + +const cookieFor = async (family: string, address: string) => { + const { name, token } = await mintSession(family, address) + return `${name}=${token}` +} + +// Call `limit` times and assert none were limited, then return the next response. +const exhaust = async (path: string, limit: number, ip = freshIp()) => { + for (let i = 0; i < limit; i += 1) { + expect((await call(path, { ip })).status).not.toBe(429) + } + return call(path, { ip }) +} + +beforeEach(() => { + vi.stubEnv("API_OWNERSHIP_ORIGIN", ORIGIN) + vi.stubEnv("API_POW_SESSION_SECRET", secret) + vi.spyOn(Date, "now").mockReturnValue(NOW) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +describe("cross-origin blocking", () => { + it.each([ + ["Sec-Fetch-Site cross-site", { "sec-fetch-site": "cross-site" }], + [ + "mismatched Origin without Sec-Fetch-Site", + { Origin: "https://evil.example" }, + ], + [ + "cross-site even with a matching Origin", + { "sec-fetch-site": "cross-site", Origin: ORIGIN }, + ], + ])("rejects %s", async (_label, headers) => { + const response = await call("/api/tokens", { headers }) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ message: "Forbidden" }) + expect(passesThrough(response)).toBe(false) + }) + + it.each([ + ["same-origin", { "sec-fetch-site": "same-origin" }], + ["same-site", { "sec-fetch-site": "same-site" }], + ["direct navigation", { "sec-fetch-site": "none" }], + ["no Origin header", { Origin: "" }], + [ + "Sec-Fetch-Site taking precedence over Origin", + { "sec-fetch-site": "same-origin", Origin: "https://evil.example" }, + ], + ])("allows %s", async (_label, headers) => { + expect(passesThrough(await call("/api/tokens", { headers }))).toBe(true) + }) + + it("does not spend rate-limit budget on blocked cross-site requests", async () => { + const ip = freshIp() + for (let i = 0; i < 20; i += 1) { + const blocked = await call("/api/chains/EVM/addresses/0xabc/history", { + ip, + headers: { "sec-fetch-site": "cross-site" }, + }) + expect(blocked.status).toBe(403) + } + const response = await call("/api/chains/EVM/addresses/0xabc/history", { + ip, + }) + expect(response.status).toBe(401) + }) +}) + +describe("rate limiting", () => { + it.each([ + ["balance", 30, "/api/chains/EVM/addresses/0xabc/balance"], + ["history", 12, "/api/chains/EVM/addresses/0xabc/history"], + ["utxos", 18, "/api/chains/BTC/addresses/bc1qtestaddressone/utxos"], + ["fee", 60, "/api/chains/BTC/fee"], + ["transaction lookup", 30, "/api/chains/BTC/transactions/abc?verbose=true"], + ])("allows %s up to %i requests per window, then returns 429", async (_label, limit, path) => { + const blocked = await exhaust(path, limit) + expect(blocked.status).toBe(429) + expect(await blocked.json()).toEqual({ message: "Too many requests" }) + expect(blocked.headers.get("retry-after")).toBe("60") + expect(passesThrough(blocked)).toBe(false) + }) + + it("counts requests that fail the session guard", async () => { + const blocked = await exhaust("/api/chains/EVM/addresses/0xabc/history", 12) + expect(blocked.status).toBe(429) + }) + + it("rate limits clients independently", async () => { + const path = "/api/chains/EVM/addresses/0xabc/history" + expect((await exhaust(path, 12)).status).toBe(429) + expect((await call(path)).status).toBe(401) + }) + + it("rate limits endpoints independently for the same client", async () => { + const ip = freshIp() + expect( + (await exhaust("/api/chains/EVM/addresses/0xabc/history", 12, ip)).status, + ).toBe(429) + expect( + (await call("/api/chains/EVM/addresses/0xabc/balance", { ip })).status, + ).toBe(401) + expect((await call("/api/chains/BTC/fee", { ip })).status).toBe(200) + }) + + it("reports the remaining window in Retry-After and resets after it", async () => { + const ip = freshIp() + const path = "/api/chains/EVM/addresses/0xabc/history" + expect((await exhaust(path, 12, ip)).status).toBe(429) + + vi.mocked(Date.now).mockReturnValue(NOW + 20_000) + const stillBlocked = await call(path, { ip }) + expect(stillBlocked.status).toBe(429) + expect(stillBlocked.headers.get("retry-after")).toBe("40") + + vi.mocked(Date.now).mockReturnValue(NOW + WINDOW - 1) + expect((await call(path, { ip })).status).toBe(429) + + vi.mocked(Date.now).mockReturnValue(NOW + WINDOW) + expect((await call(path, { ip })).status).toBe(401) + }) + + it("rounds Retry-After up to at least one second", async () => { + const ip = freshIp() + const path = "/api/chains/EVM/addresses/0xabc/history" + await exhaust(path, 12, ip) + vi.mocked(Date.now).mockReturnValue(NOW + WINDOW - 1) + expect((await call(path, { ip })).headers.get("retry-after")).toBe("1") + }) + + it("keys on the first X-Forwarded-For hop, ahead of other IP headers", async () => { + const path = "/api/chains/EVM/addresses/0xabc/history" + const hop = `203.0.113.${ipCounter + 1}` + for (let i = 0; i < 12; i += 1) { + await call(path, { + ip: `ignored-${i}`, + headers: { "x-forwarded-for": `${hop}, 10.0.0.${i}` }, + }) + } + const response = await call(path, { + ip: "another-real-ip", + headers: { "x-forwarded-for": `${hop}, 10.9.9.9` }, + }) + expect(response.status).toBe(429) + }) + + it("falls back to CF-Connecting-IP, then X-Real-IP", async () => { + const path = "/api/chains/EVM/addresses/0xabc/history" + const cf = `cf-${freshIp()}` + for (let i = 0; i < 12; i += 1) { + await call(path, { ip: `real-${i}`, headers: { "cf-connecting-ip": cf } }) + } + expect( + (await call(path, { ip: "real-x", headers: { "cf-connecting-ip": cf } })) + .status, + ).toBe(429) + // Same X-Real-IP is a different client once CF-Connecting-IP differs. + expect( + ( + await call(path, { + ip: "real-0", + headers: { "cf-connecting-ip": `${cf}-other` }, + }) + ).status, + ).toBe(401) + }) + + it("buckets requests without any client IP header together", async () => { + const path = "/api/chains/BTC/addresses/bc1qtestaddressone/utxos" + for (let i = 0; i < 18; i += 1) { + expect((await call(path, { ip: null })).status).not.toBe(429) + } + expect((await call(path, { ip: null })).status).toBe(429) + }) + + it("never limits or guards the broadcast endpoint", async () => { + const ip = freshIp() + for (let i = 0; i < 100; i += 1) { + const response = await call("/api/chains/BTC/transactions/broadcast", { + method: "POST", + ip, + }) + expect(passesThrough(response)).toBe(true) + } + }) + + it("never limits unrelated API routes", async () => { + const ip = freshIp() + for (let i = 0; i < 100; i += 1) { + expect(passesThrough(await call("/api/tokens", { ip }))).toBe(true) + } + }) + + it("limits challenge and verification requests in separate buckets", async () => { + const ip = freshIp() + const path = "/api/chains/auth/address" + for (let i = 0; i < 12; i += 1) { + expect(passesThrough(await call(path, { ip }))).toBe(true) + } + expect((await call(path, { ip })).status).toBe(429) + + for (let i = 0; i < 24; i += 1) { + expect(passesThrough(await call(path, { method: "POST", ip }))).toBe(true) + } + const blocked = await call(path, { method: "POST", ip }) + expect(blocked.status).toBe(429) + expect(blocked.headers.get("retry-after")).toBe("60") + }) + + it("does not require a session cookie for the auth endpoint", async () => { + expect(passesThrough(await call("/api/chains/auth/address"))).toBe(true) + expect( + passesThrough(await call("/api/chains/auth/address", { method: "POST" })), + ).toBe(true) + }) +}) + +describe("raw transaction lookup session guard", () => { + const txPath = (address?: string, query = "") => + `/api/chains/BTC/transactions/abc123?${address ? `address=${address}&` : ""}${query}` + + it("requires the owner address for non-verbose lookups", async () => { + const response = await call("/api/chains/BTC/transactions/abc123") + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + message: "Wallet address is required for raw transaction reads", + }) + expect(passesThrough(response)).toBe(false) + }) + + it("requires the address even when verbose is false", async () => { + const response = await call(txPath(undefined, "verbose=false")) + expect(response.status).toBe(400) + }) + + it("rejects a lookup with an address but no cookie", async () => { + const response = await call(txPath(BTC_A)) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + message: "Address session required", + }) + }) + + it("allows a lookup with the matching address cookie", async () => { + const cookie = await cookieFor("BTC", BTC_A) + const response = await call(txPath(BTC_A), { headers: { Cookie: cookie } }) + expect(passesThrough(response)).toBe(true) + }) + + it("rejects another address's session presented under the requested cookie name", async () => { + const other = await mintSession("BTC", BTC_B) + const requestedName = await getAddressSessionCookieName("BTC", BTC_A) + const response = await call(txPath(BTC_A), { + headers: { Cookie: `${requestedName}=${other.token}` }, + }) + expect(response.status).toBe(401) + }) + + it("does not accept a session minted for a different family", async () => { + const dogeSession = await mintSession("DOGE", BTC_A) + const requestedName = await getAddressSessionCookieName("BTC", BTC_A) + const response = await call(txPath(BTC_A), { + headers: { Cookie: `${requestedName}=${dogeSession.token}` }, + }) + expect(response.status).toBe(401) + }) + + it("uses only the cookie named for the requested address", async () => { + const cookieForB = await cookieFor("BTC", BTC_B) + const response = await call(txPath(BTC_A), { + headers: { Cookie: cookieForB }, + }) + expect(response.status).toBe(401) + }) + + it("rejects an expired session", async () => { + const cookie = await cookieFor("BTC", BTC_A) + vi.mocked(Date.now).mockReturnValue(NOW + SESSION_TTL) + const response = await call(txPath(BTC_A), { headers: { Cookie: cookie } }) + expect(response.status).toBe(401) + }) + + it("leaves verbose confirmation lookups unauthenticated", async () => { + const response = await call( + "/api/chains/BTC/transactions/abc123?verbose=true", + ) + expect(passesThrough(response)).toBe(true) + }) + + it("does not guard the fee endpoint", async () => { + expect(passesThrough(await call("/api/chains/BTC/fee"))).toBe(true) + }) +}) diff --git a/src/app/api/chains/clientAddressSession.test.ts b/src/app/api/chains/clientAddressSession.test.ts new file mode 100644 index 0000000..37477d1 --- /dev/null +++ b/src/app/api/chains/clientAddressSession.test.ts @@ -0,0 +1,384 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { ChainFamily } from "@/chain/types" + +import { signOwnershipMessage } from "./clientOwnershipSignature" + +vi.mock("./clientOwnershipSignature", () => ({ + signOwnershipMessage: vi.fn(), +})) + +const ORIGIN = "https://wallet.example" +const NOW = Date.UTC(2026, 9, 5, 12) +const ADDRESS = "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01" +const NORMALIZED_ADDRESS = ADDRESS.toLowerCase() +const SESSION_EXPIRY = NOW + 30 * 60_000 +const AUTH_URL = "/api/chains/auth/address" +const signature = "test-ownership-signature" +const sign = vi.mocked(signOwnershipMessage) +const fetchMock = vi.fn() +const digest = vi.fn() + +let client: typeof import("./clientAddressSession") +let storage: Storage +let challengeId: number + +const readUrl = (family: ChainFamily = "EVM", address = ADDRESS) => + `/api/chains/${family}/addresses/${address}/balance` +const storageKey = (family: ChainFamily, address: string) => + `gw-ownership-v2:${family}:${address}` + +// Node has no browser sessionStorage. Keep this stub and all globals local to +// each test, without modifying native Web Crypto or any production code. +const createStorage = (): Storage => { + const values = new Map() + return { + get length() { + return values.size + }, + clear: vi.fn(() => values.clear()), + getItem: vi.fn((key: string) => values.get(key) ?? null), + key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null), + removeItem: vi.fn((key: string) => { + values.delete(key) + }), + setItem: vi.fn((key: string, value: string) => { + values.set(key, value) + }), + } +} + +const makeChallenge = ( + family: ChainFamily = "EVM", + address = NORMALIZED_ADDRESS, + audience = ORIGIN, +) => { + const expiresAt = NOW + 5 * 60_000 + const token = { + type: "ownership-challenge-v2", + payload: { + family, + address, + audience, + issuedAt: NOW, + difficultyPrefix: "0000", + }, + expiresAt, + } + // Real base64url JSON and a syntactically valid MAC segment. The client + // validates scope/timestamps; MAC verification belongs to the server tests. + const encoded = Buffer.from(JSON.stringify(token)).toString("base64url") + const mac = Buffer.alloc(32, ++challengeId).toString("base64url") + return { challenge: `${encoded}.${mac}`, difficultyPrefix: "0000", expiresAt } +} + +const json = (body: unknown, status = 200) => Response.json(body, { status }) + +const mockSuccessfulFlow = () => { + fetchMock.mockImplementation(async (input, init) => { + const url = new URL(String(input), ORIGIN) + if (url.pathname === AUTH_URL) { + if (init?.method === "POST") { + return json({ ok: true, expiresAt: SESSION_EXPIRY }) + } + return json( + makeChallenge( + url.searchParams.get("family") as ChainFamily, + url.searchParams.get("address")!, + ), + ) + } + return json({ balance: "123" }) + }) +} + +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +beforeEach(async () => { + vi.resetModules() + sign.mockReset().mockResolvedValue({ signature }) + fetchMock.mockReset() + digest.mockReset().mockImplementation(async () => new Uint8Array(32).buffer) + vi.spyOn(Date, "now").mockReturnValue(NOW) + storage = createStorage() + challengeId = 0 + vi.stubGlobal("window", { + location: { origin: ORIGIN }, + sessionStorage: storage, + }) + vi.stubGlobal("sessionStorage", storage) + vi.stubGlobal("fetch", fetchMock) + vi.stubGlobal("crypto", { subtle: { digest } }) + client = await import("./clientAddressSession") +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe("client ownership session integration", () => { + it.each( + ChainFamily, + )("signs and POSTs the scoped %s proof before reading", async (family) => { + const address = family === "EVM" ? ADDRESS : "CaseSensitiveAddress123" + const normalized = family === "EVM" ? NORMALIZED_ADDRESS : address + const challenge = makeChallenge(family, normalized) + const proof = { signature, publicKey: "test-public-key" } + sign.mockResolvedValueOnce(proof) + const readResponse = json({ balance: "123" }) + fetchMock + .mockResolvedValueOnce(json(challenge)) + .mockResolvedValueOnce(json({ ok: true, expiresAt: SESSION_EXPIRY })) + .mockResolvedValueOnce(readResponse) + + const url = readUrl(family, address) + const response = await client.fetchWithAddressSession(address, url, { + headers: { Accept: "application/json" }, + }) + + expect(response).toBe(readResponse) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + `${AUTH_URL}?family=${family}&address=${encodeURIComponent(normalized)}`, + { method: "GET", credentials: "same-origin", cache: "no-store" }, + ) + expect(sign).toHaveBeenCalledExactlyOnceWith( + family, + normalized, + `GoodWallet API ownership v2\nAuthorize wallet-scoped blockchain reads only.\nChallenge: ${challenge.challenge}`, + ) + expect(digest).toHaveBeenCalledExactlyOnceWith( + "SHA-256", + new TextEncoder().encode(`${normalized}:${challenge.challenge}:0`), + ) + expect(fetchMock).toHaveBeenNthCalledWith(2, AUTH_URL, { + method: "POST", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + family, + address: normalized, + challenge: challenge.challenge, + nonce: 0, + ...proof, + }), + }) + expect(fetchMock).toHaveBeenNthCalledWith(3, url, { + headers: { Accept: "application/json" }, + credentials: "same-origin", + }) + expect(storage.getItem(storageKey(family, normalized))).toBe( + String(SESSION_EXPIRY), + ) + }) + + it.each([ + ["address", "EVM", "0x0000000000000000000000000000000000000000", ORIGIN], + ["family", "BTC", NORMALIZED_ADDRESS, ORIGIN], + ["audience", "EVM", NORMALIZED_ADDRESS, "https://other.example"], + ] as const)("rejects a challenge with the wrong %s before signing", async (_, family, address, audience) => { + fetchMock.mockResolvedValueOnce( + json(makeChallenge(family, address, audience)), + ) + + await expect( + client.fetchWithAddressSession(ADDRESS, readUrl()), + ).rejects.toThrow("Invalid ownership challenge scope") + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(sign).not.toHaveBeenCalled() + expect(digest).not.toHaveBeenCalled() + expect(storage.setItem).not.toHaveBeenCalled() + }) + + it.each([ + [ + "address", + readUrl("EVM", "0x0000000000000000000000000000000000000000"), + "Ownership API address mismatch", + ], + [ + "family", + `/api/chains/UNKNOWN/addresses/${ADDRESS}/balance`, + "Invalid ownership API URL", + ], + [ + "audience", + `https://other.example${readUrl()}`, + "Invalid ownership API URL", + ], + ])("rejects a read URL with the wrong %s without requesting a challenge", async (_, url, error) => { + await expect(client.fetchWithAddressSession(ADDRESS, url)).rejects.toThrow( + error, + ) + expect(fetchMock).not.toHaveBeenCalled() + expect(sign).not.toHaveBeenCalled() + }) + + it("keeps challenges and cached sessions separate per family for the same address", async () => { + mockSuccessfulFlow() + const address = "SharedCaseSensitiveAddress123" + for (const family of ["SOLANA", "SOLANA_DEVNET"] as const) { + await client.fetchWithAddressSession(address, readUrl(family, address)) + } + expect(sign).toHaveBeenCalledTimes(2) + expect(sign.mock.calls.map(([family]) => family)).toEqual([ + "SOLANA", + "SOLANA_DEVNET", + ]) + expect(sign.mock.calls[0][2]).not.toBe(sign.mock.calls[1][2]) + expect(storage.getItem(storageKey("SOLANA", address))).toBe( + String(SESSION_EXPIRY), + ) + expect(storage.getItem(storageKey("SOLANA_DEVNET", address))).toBe( + String(SESSION_EXPIRY), + ) + expect(fetchMock).toHaveBeenCalledTimes(6) + + for (const family of ["SOLANA", "SOLANA_DEVNET"] as const) { + await client.fetchWithAddressSession(address, readUrl(family, address)) + } + expect(fetchMock).toHaveBeenCalledTimes(8) + expect(sign).toHaveBeenCalledTimes(2) + + storage.setItem(storageKey("SOLANA", address), String(NOW - 1)) + await client.ensureAddressSession("SOLANA_DEVNET", address) + expect(fetchMock).toHaveBeenCalledTimes(8) + await client.ensureAddressSession("SOLANA", address) + expect(fetchMock).toHaveBeenCalledTimes(10) + expect(sign).toHaveBeenLastCalledWith("SOLANA", address, expect.any(String)) + }) + + it("deduplicates concurrent normalized EVM sessions but still performs each read", async () => { + mockSuccessfulFlow() + const gate = deferred<{ signature: string }>() + sign.mockReturnValueOnce(gate.promise) + const first = client.fetchWithAddressSession(ADDRESS, readUrl()) + const second = client.fetchWithAddressSession( + NORMALIZED_ADDRESS, + readUrl("EVM", NORMALIZED_ADDRESS), + ) + await vi.waitFor(() => expect(sign).toHaveBeenCalledTimes(1)) + expect(fetchMock).toHaveBeenCalledTimes(1) + gate.resolve({ signature }) + const responses = await Promise.all([first, second]) + expect(responses.every((response) => response.ok)).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect( + fetchMock.mock.calls.filter(([, init]) => init?.method === "POST"), + ).toHaveLength(1) + await client.ensureAddressSession("EVM", ADDRESS) + expect(fetchMock).toHaveBeenCalledTimes(4) + }) + + it("does not deduplicate in-flight sessions across families", async () => { + mockSuccessfulFlow() + const address = "SharedCaseSensitiveAddress123" + const gate = deferred<{ signature: string }>() + sign.mockReturnValue(gate.promise) + const first = client.ensureAddressSession("SOLANA", address) + const second = client.ensureAddressSession("SOLANA_DEVNET", address) + await vi.waitFor(() => expect(sign).toHaveBeenCalledTimes(2)) + expect(fetchMock).toHaveBeenCalledTimes(2) + gate.resolve({ signature }) + await Promise.all([first, second]) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(storage.length).toBe(2) + }) + + it.each([ + 200, 401, + ])("refreshes and retries exactly once after 401, even when the retry returns %s", async (status) => { + const key = storageKey("EVM", NORMALIZED_ADDRESS) + storage.setItem(key, String(SESSION_EXPIRY)) + const retried = json({ status }, status) + fetchMock + .mockResolvedValueOnce(json({}, 401)) + .mockResolvedValueOnce(json(makeChallenge())) + .mockResolvedValueOnce(json({ ok: true, expiresAt: SESSION_EXPIRY + 1 })) + .mockResolvedValueOnce(retried) + + const response = await client.fetchWithAddressSession(ADDRESS, readUrl()) + expect(response).toBe(retried) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + readUrl(), + `${AUTH_URL}?family=EVM&address=${NORMALIZED_ADDRESS}`, + AUTH_URL, + readUrl(), + ]) + expect(storage.removeItem).toHaveBeenCalledExactlyOnceWith(key) + expect(sign).toHaveBeenCalledTimes(1) + expect(storage.getItem(key)).toBe(String(SESSION_EXPIRY + 1)) + }) + + it("returns 429 without retrying, clearing the session, or fetching a fresh challenge", async () => { + mockSuccessfulFlow() + await client.ensureAddressSession("EVM", ADDRESS) + const rateLimited = json({ error: "rate limited" }, 429) + fetchMock.mockResolvedValueOnce(rateLimited) + + expect(await client.fetchWithAddressSession(ADDRESS, readUrl())).toBe( + rateLimited, + ) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(sign).toHaveBeenCalledTimes(1) + expect(storage.removeItem).not.toHaveBeenCalled() + expect(storage.getItem(storageKey("EVM", NORMALIZED_ADDRESS))).toBe( + String(SESSION_EXPIRY), + ) + }) + + it.each([ + "challenge", + "session", + ] as const)("does not retry a 429 from the %s endpoint", async (stage) => { + if (stage === "session") + fetchMock.mockResolvedValueOnce(json(makeChallenge())) + fetchMock.mockResolvedValueOnce(json({}, 429)) + + await expect( + client.fetchWithAddressSession(ADDRESS, readUrl()), + ).rejects.toThrow( + stage === "challenge" + ? "Failed to request address challenge: 429" + : "Failed to verify address challenge: 429", + ) + expect(fetchMock).toHaveBeenCalledTimes(stage === "challenge" ? 1 : 2) + expect(sign).toHaveBeenCalledTimes(stage === "challenge" ? 0 : 1) + expect(storage.setItem).not.toHaveBeenCalled() + }) + + it("propagates signer rejection without a session POST or read and clears in-flight state", async () => { + mockSuccessfulFlow() + const rejection = new Error("User rejected ownership signing") + sign.mockRejectedValueOnce(rejection) + + await expect( + client.fetchWithAddressSession(ADDRESS, readUrl()), + ).rejects.toBe(rejection) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(sign).toHaveBeenCalledTimes(1) + expect(digest).not.toHaveBeenCalled() + expect(storage.setItem).not.toHaveBeenCalled() + + // A later user action must be able to try again, rather than reuse a + // permanently rejected in-flight promise. + expect((await client.fetchWithAddressSession(ADDRESS, readUrl())).ok).toBe( + true, + ) + expect(sign).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/app/api/chains/clientAddressSession.ts b/src/app/api/chains/clientAddressSession.ts new file mode 100644 index 0000000..da94b52 --- /dev/null +++ b/src/app/api/chains/clientAddressSession.ts @@ -0,0 +1,247 @@ +import { z } from "zod" + +import { type ChainFamily, isSupportedFamily } from "@/chain/types" + +import { signOwnershipMessage } from "./clientOwnershipSignature" +import { + normalizeOwnershipAddress, + ownershipMessage, +} from "./ownershipProtocol" + +const SESSION_STORAGE_PREFIX = "gw-ownership-v2:" + +type ChallengeResponse = { + challenge: string + difficultyPrefix: string + expiresAt: number +} + +type SessionResponse = { + ok: true + expiresAt: number +} + +const inFlightSessions = new Map>() + +const normalizeAddress = (address: string) => { + return /^0x/i.test(address) ? address.toLowerCase() : address +} + +const getSessionStorageKey = (address: string) => { + return `${SESSION_STORAGE_PREFIX}${normalizeAddress(address)}` +} + +const getStoredExpiry = (address: string) => { + if (typeof window === "undefined") { + return null + } + + const rawValue = window.sessionStorage.getItem(getSessionStorageKey(address)) + if (!rawValue) { + return null + } + + const parsedExpiry = Number(rawValue) + return Number.isFinite(parsedExpiry) ? parsedExpiry : null +} + +const setStoredExpiry = (address: string, expiresAt: number) => { + if (typeof window === "undefined") { + return + } + + window.sessionStorage.setItem( + getSessionStorageKey(address), + String(expiresAt), + ) +} + +const clearStoredExpiry = (address: string) => { + if (typeof window === "undefined") { + return + } + + window.sessionStorage.removeItem(getSessionStorageKey(address)) +} + +const sha256Hex = async (value: string) => { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(value), + ) + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("") +} + +const solveProofOfWork = async ( + address: string, + challenge: ChallengeResponse, +) => { + let nonce = 0 + const normalizedAddress = normalizeAddress(address) + + while (Date.now() < challenge.expiresAt) { + const hash = await sha256Hex( + `${normalizedAddress}:${challenge.challenge}:${nonce}`, + ) + if (hash.startsWith(challenge.difficultyPrefix)) { + return nonce + } + nonce += 1 + } + + throw new Error("Proof-of-work challenge expired before completion") +} + +const createAddressSession = async (family: ChainFamily, address: string) => { + const normalizedAddress = normalizeOwnershipAddress(family, address) + const challengeResponse = await fetch( + `/api/chains/auth/address?family=${family}&address=${encodeURIComponent(normalizedAddress)}`, + { + method: "GET", + credentials: "same-origin", + cache: "no-store", + }, + ) + + if (!challengeResponse.ok) { + throw new Error( + `Failed to request address challenge: ${challengeResponse.status}`, + ) + } + + const challenge = z + .object({ + challenge: z.string().max(2048), + difficultyPrefix: z.literal("0000"), + expiresAt: z.number().int(), + }) + .parse(await challengeResponse.json()) + const encoded = challenge.challenge + .split(".")[0] + .replace(/-/g, "+") + .replace(/_/g, "/") + const payload = JSON.parse(atob(encoded)) + if ( + payload.type !== "ownership-challenge-v2" || + payload.payload?.address !== normalizedAddress || + payload.payload?.family !== family || + payload.payload?.audience !== window.location.origin || + !Number.isSafeInteger(payload.payload?.issuedAt) || + payload.expiresAt !== payload.payload.issuedAt + 5 * 60_000 || + payload.expiresAt !== challenge.expiresAt || + challenge.expiresAt <= Date.now() || + payload.payload.issuedAt > Date.now() + 30_000 + ) { + throw new Error("Invalid ownership challenge scope") + } + const proof = await signOwnershipMessage( + family, + normalizedAddress, + ownershipMessage(challenge.challenge), + ) + const nonce = await solveProofOfWork(normalizedAddress, challenge) + + const sessionResponse = await fetch(`/api/chains/auth/address`, { + method: "POST", + credentials: "same-origin", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + family, + address: normalizedAddress, + challenge: challenge.challenge, + nonce, + ...proof, + }), + }) + + if (!sessionResponse.ok) { + throw new Error( + `Failed to verify address challenge: ${sessionResponse.status}`, + ) + } + + const session = (await sessionResponse.json()) as SessionResponse + setStoredExpiry(`${family}:${normalizedAddress}`, session.expiresAt) +} + +export const ensureAddressSession = async ( + family: ChainFamily, + address: string, + forceRefresh = false, +) => { + const normalizedAddress = normalizeOwnershipAddress(family, address) + const scope = `${family}:${normalizedAddress}` + const storedExpiry = getStoredExpiry(scope) + + if (!forceRefresh && storedExpiry && storedExpiry > Date.now()) { + return + } + + const existing = inFlightSessions.get(scope) + if (existing) { + return existing + } + + const promise = createAddressSession(family, normalizedAddress).finally( + () => { + inFlightSessions.delete(scope) + }, + ) + + inFlightSessions.set(scope, promise) + return promise +} + +export const fetchWithAddressSession = async ( + address: string, + input: RequestInfo | URL, + init?: RequestInit, +) => { + const url = new URL( + input instanceof Request ? input.url : String(input), + window.location.origin, + ) + const match = url.pathname.match( + /^\/api\/chains\/([^/]+)\/(?:addresses\/([^/]+)\/(?:history|balance|utxos)|transactions\/[^/]+)$/, + ) + const family = match?.[1] + if ( + url.origin !== window.location.origin || + !family || + !isSupportedFamily(family) + ) { + throw new Error("Invalid ownership API URL") + } + const normalizedAddress = normalizeOwnershipAddress(family, address) + const requestedAddress = match?.[2] + ? decodeURIComponent(match[2]) + : url.searchParams.get("address") + if ( + !requestedAddress || + normalizeOwnershipAddress(family, requestedAddress) !== normalizedAddress + ) { + throw new Error("Ownership API address mismatch") + } + await ensureAddressSession(family, normalizedAddress) + + let response = await fetch(input, { + ...init, + credentials: init?.credentials ?? "same-origin", + }) + + if (response.status === 401) { + clearStoredExpiry(`${family}:${normalizedAddress}`) + await ensureAddressSession(family, normalizedAddress, true) + response = await fetch(input, { + ...init, + credentials: init?.credentials ?? "same-origin", + }) + } + + return response +} diff --git a/src/app/api/chains/clientOwnershipSignature.ts b/src/app/api/chains/clientOwnershipSignature.ts new file mode 100644 index 0000000..fc1656d --- /dev/null +++ b/src/app/api/chains/clientOwnershipSignature.ts @@ -0,0 +1,71 @@ +import type { ChainFamily } from "@/chain/types" + +/** Sign a server-issued ownership challenge with the exact family/address signer. */ +export async function signOwnershipMessage( + family: ChainFamily, + address: string, + message: string, +): Promise<{ signature: string; publicKey?: string }> { + if (!message.startsWith("GoodWallet API ownership v2\n")) { + throw new Error("Invalid ownership message domain") + } + + // Storage reconstructs private-key signers on initialization. Import only when + // needed to avoid a storage -> provider -> auth -> storage initialization cycle. + const { sessionState } = await import( + "@/login/context/SessionContext/storage" + ) + const signers = sessionState.session?.signer + const signer = signers?.[family] + if (!signers || !signer) { + throw new Error(`No ownership signer for ${family}`) + } + + const { getAddress } = await import("ethers/address") + const matches = + family === "EVM" + ? getAddress(signer.address) === getAddress(address) + : signer.address === address + if (!matches) throw new Error("Ownership signer address mismatch") + + switch (family) { + case "EVM": + return { signature: await signers.EVM.signMessage(message) } + case "SOLANA": + case "SOLANA_DEVNET": { + const { + address: solanaAddress, + createSignableMessage, + getBase58Decoder, + } = await import("gill") + const signingAddress = solanaAddress(address) + const solanaSigner = signers[family] + if (!solanaSigner) throw new Error(`No ownership signer for ${family}`) + const results = await solanaSigner.signMessages([ + createSignableMessage(message), + ]) + const signature = results[0]?.[signingAddress] + if (!signature || signature.length !== 64) { + throw new Error("Missing ownership signature") + } + return { signature: getBase58Decoder().decode(signature) } + } + case "BTC": + case "BTC_TESTNET": + case "DOGE": + case "DOGE_TESTNET": + case "XRP": + case "XRP_TESTNET": { + const authSigner = signers[family] + if (!authSigner?.signAuthMessage) { + throw new Error(`Ownership message signing unsupported for ${family}`) + } + return { + signature: await authSigner.signAuthMessage(message), + publicKey: authSigner.publicKey, + } + } + default: + throw new Error("Unsupported ownership family") + } +} diff --git a/src/app/api/chains/ownershipProtocol.ts b/src/app/api/chains/ownershipProtocol.ts new file mode 100644 index 0000000..2a91a6b --- /dev/null +++ b/src/app/api/chains/ownershipProtocol.ts @@ -0,0 +1,19 @@ +import { z } from "zod" + +import { ChainFamily } from "@/chain/types" + +export const OwnershipScope = z.object({ + family: z.enum(ChainFamily), + address: z + .string() + .min(1) + .max(128) + .regex(/^[a-zA-Z0-9]+$/), +}) + +export const normalizeOwnershipAddress = (family: string, address: string) => + family === "EVM" ? address.toLowerCase() : address + +// Both sides construct exactly this message; never sign arbitrary server text. +export const ownershipMessage = (challenge: string) => + `GoodWallet API ownership v2\nAuthorize wallet-scoped blockchain reads only.\nChallenge: ${challenge}` diff --git a/src/app/api/chains/ownershipRoutes.test.ts b/src/app/api/chains/ownershipRoutes.test.ts new file mode 100644 index 0000000..d4753fc --- /dev/null +++ b/src/app/api/chains/ownershipRoutes.test.ts @@ -0,0 +1,266 @@ +import { NextRequest } from "next/server" +import { randomBytes } from "node:crypto" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { ChainFamily } from "@/chain/types" + +import { GET, POST } from "./auth/address/route" +import { + normalizeOwnershipAddress, + OwnershipScope, + ownershipMessage, +} from "./ownershipProtocol" +import { + getOwnershipAudience, + isProtectedAddressReadPath, + isTxLookupPath, +} from "./powSession" + +const ORIGIN = "http://localhost:3000" +const NOW = Date.UTC(2026, 9, 7, 12) +// Ephemeral test-only secret: never persisted or sent to a provider. +const secret = randomBytes(32).toString("hex") + +const decodeToken = (token: string) => + JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString("utf8")) + +const getChallenge = (query: Record) => + GET( + new NextRequest( + `${ORIGIN}/api/chains/auth/address?${new URLSearchParams(query)}`, + ), + ) + +const postRaw = (body: string, origin: string | null = ORIGIN) => + POST( + new NextRequest(`${ORIGIN}/api/chains/auth/address`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(origin === null ? {} : { Origin: origin }), + }, + body, + }), + ) + +const validBody = { + family: "EVM", + address: "0xabc", + challenge: "payload.mac", + nonce: 0, + signature: "0xsignature", +} + +beforeEach(() => { + vi.stubEnv("API_OWNERSHIP_ORIGIN", ORIGIN) + vi.stubEnv("API_POW_SESSION_SECRET", secret) + vi.spyOn(Date, "now").mockReturnValue(NOW) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +describe("ownership protocol helpers", () => { + it("lowercases only EVM addresses", () => { + expect(normalizeOwnershipAddress("EVM", "0xAbCd")).toBe("0xabcd") + for (const family of ["BTC", "DOGE", "SOLANA", "XRP"]) { + expect(normalizeOwnershipAddress(family, "AbCd")).toBe("AbCd") + } + }) + + it("builds a domain-separated message ending in the exact challenge", () => { + const message = ownershipMessage("payload.mac") + expect(message.startsWith("GoodWallet API ownership v2\n")).toBe(true) + expect(message.endsWith("Challenge: payload.mac")).toBe(true) + }) + + it.each(ChainFamily)("accepts a %s scope", (family) => { + expect( + OwnershipScope.safeParse({ family, address: "Abc123" }).success, + ).toBe(true) + }) + + it.each([ + ["unknown family", { family: "ETH", address: "abc" }], + ["missing family", { address: "abc" }], + ["empty address", { family: "EVM", address: "" }], + ["missing address", { family: "EVM" }], + ["slash", { family: "EVM", address: "a/b" }], + ["dot segments", { family: "EVM", address: ".." }], + ["space", { family: "EVM", address: "a b" }], + ["query characters", { family: "EVM", address: "a?b=c" }], + ["oversized address", { family: "EVM", address: "a".repeat(129) }], + ])("rejects a scope with %s", (_label, scope) => { + expect(OwnershipScope.safeParse(scope).success).toBe(false) + }) +}) + +describe("path matchers", () => { + it.each([ + "balance", + "history", + "utxos", + ])("protects the %s read", (endpoint) => { + expect( + isProtectedAddressReadPath(`/api/chains/EVM/addresses/0xabc/${endpoint}`), + ).toBe(true) + }) + + it.each([ + "/api/chains/BTC/fee", + "/api/chains/BTC/transactions/abc", + "/api/chains/EVM/addresses/0xabc/other", + "/api/chains/EVM/addresses/0xabc/balance/extra", + "/api/chains/EVM/addresses/balance", + "/api/chains/auth/address", + "/api/tokens", + ])("does not treat %s as an address read", (path) => { + expect(isProtectedAddressReadPath(path)).toBe(false) + }) + + it("matches transaction lookups but never the broadcast endpoint", () => { + expect(isTxLookupPath("/api/chains/BTC/transactions/abc123")).toBe(true) + expect(isTxLookupPath("/api/chains/BTC/transactions/broadcast")).toBe(false) + expect(isTxLookupPath("/api/chains/BTC/transactions")).toBe(false) + expect(isTxLookupPath("/api/chains/BTC/transactions/a/b")).toBe(false) + expect(isTxLookupPath("/api/chains/BTC/fee")).toBe(false) + }) +}) + +describe("getOwnershipAudience", () => { + it("returns the configured canonical origin", () => { + vi.stubEnv("API_OWNERSHIP_ORIGIN", "https://wallet.example") + expect(getOwnershipAudience()).toBe("https://wallet.example") + }) + + it("defaults to localhost outside production", () => { + vi.stubEnv("API_OWNERSHIP_ORIGIN", undefined) + expect(getOwnershipAudience()).toBe("http://localhost:3000") + }) + + it.each([ + "http://localhost:3000/", + "http://localhost:3000/path", + "https://wallet.example?x=1", + "not a url", + "", + ])("rejects non-canonical origin %j", (value) => { + vi.stubEnv("API_OWNERSHIP_ORIGIN", value) + expect(() => getOwnershipAudience()).toThrow() + }) + + it("requires an explicit HTTPS origin in production", () => { + vi.stubEnv("NODE_ENV", "production") + vi.stubEnv("API_OWNERSHIP_ORIGIN", undefined) + expect(() => getOwnershipAudience()).toThrow() + vi.stubEnv("API_OWNERSHIP_ORIGIN", "http://wallet.example") + expect(() => getOwnershipAudience()).toThrow() + vi.stubEnv("API_OWNERSHIP_ORIGIN", "https://wallet.example") + expect(getOwnershipAudience()).toBe("https://wallet.example") + }) +}) + +describe("GET challenge endpoint", () => { + it("issues a no-store challenge bound to the normalized EVM scope", async () => { + const response = await getChallenge({ family: "EVM", address: "0xAbCdEf" }) + expect(response.status).toBe(200) + expect(response.headers.get("cache-control")).toBe("private, no-store") + const body = await response.json() + expect(body.difficultyPrefix).toBe("0000") + expect(body.expiresAt).toBe(NOW + 5 * 60_000) + expect(decodeToken(body.challenge)).toMatchObject({ + type: "ownership-challenge-v2", + payload: { + family: "EVM", + address: "0xabcdef", + audience: ORIGIN, + issuedAt: NOW, + }, + }) + }) + + it("preserves the case of non-EVM addresses", async () => { + const response = await getChallenge({ family: "SOLANA", address: "AbCdEf" }) + expect( + decodeToken((await response.json()).challenge).payload, + ).toMatchObject({ family: "SOLANA", address: "AbCdEf" }) + }) + + it.each([ + ["missing family", { address: "abc" }], + ["unknown family", { family: "ETH", address: "abc" }], + ["missing address", { family: "EVM" }], + ["address with symbols", { family: "EVM", address: "a/../b" }], + ["oversized address", { family: "EVM", address: "a".repeat(129) }], + ])("returns 400 without leaking details for %s", async (_label, query) => { + const response = await getChallenge(query) + expect(response.status).toBe(400) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(await response.json()).toEqual({ + message: "Unable to issue ownership challenge", + }) + }) + + it.each([ + ["missing secret", "API_POW_SESSION_SECRET", ""], + ["short secret", "API_POW_SESSION_SECRET", "too-short"], + ["non-canonical origin", "API_OWNERSHIP_ORIGIN", "http://localhost:3000/"], + ])("returns 503 for %s", async (_label, name, value) => { + vi.stubEnv(name, value) + const response = await getChallenge({ family: "EVM", address: "0xabc" }) + expect(response.status).toBe(503) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.headers.get("set-cookie")).toBeNull() + }) +}) + +describe("POST verification input handling", () => { + const expectNoSession = (response: Response, status: number) => { + expect(response.status).toBe(status) + expect(response.headers.get("set-cookie")).toBeNull() + expect(response.headers.get("cache-control")).toBe("private, no-store") + } + + it("returns 400 for malformed JSON", async () => { + expectNoSession(await postRaw("{not json"), 400) + }) + + it.each([ + ["missing family", { ...validBody, family: undefined }], + ["unknown family", { ...validBody, family: "ETH" }], + ["missing signature", { ...validBody, signature: undefined }], + ["empty signature", { ...validBody, signature: "" }], + ["oversized signature", { ...validBody, signature: "s".repeat(301) }], + ["oversized public key", { ...validBody, publicKey: "k".repeat(131) }], + ["oversized challenge", { ...validBody, challenge: "c".repeat(2049) }], + ["string nonce", { ...validBody, nonce: "0" }], + ["array body", [validBody]], + ])("returns 400 for %s", async (_label, body) => { + expectNoSession(await postRaw(JSON.stringify(body)), 400) + }) + + it("returns 413 for payloads over 4096 characters", async () => { + expectNoSession( + await postRaw( + JSON.stringify({ ...validBody, padding: "x".repeat(5000) }), + ), + 413, + ) + }) + + it("returns 401 and no cookie for a well-formed but unverifiable proof", async () => { + expectNoSession(await postRaw(JSON.stringify(validBody)), 401) + }) + + it("fails closed without a session secret", async () => { + vi.stubEnv("API_POW_SESSION_SECRET", "") + expectNoSession(await postRaw(JSON.stringify(validBody)), 401) + }) + + it("returns 503 when the canonical origin is misconfigured", async () => { + vi.stubEnv("API_OWNERSHIP_ORIGIN", "http://localhost:3000/") + expectNoSession(await postRaw(JSON.stringify(validBody)), 503) + }) +}) diff --git a/src/app/api/chains/ownershipSession.test.ts b/src/app/api/chains/ownershipSession.test.ts new file mode 100644 index 0000000..9d5e209 --- /dev/null +++ b/src/app/api/chains/ownershipSession.test.ts @@ -0,0 +1,546 @@ +import { NextRequest, type NextResponse } from "next/server" +import { createHash, createHmac, randomBytes } from "node:crypto" +import { Wallet } from "ethers/wallet" +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest" + +import { proxy } from "@/proxy" + +import { GET, POST } from "./auth/address/route" +import { + normalizeOwnershipAddress, + ownershipMessage, +} from "./ownershipProtocol" +import { + getAddressSessionCookieName, + issuePowChallenge, + verifyPowSessionCookie, + verifyPowSolution, +} from "./powSession" + +const ORIGIN = "http://localhost:3000" +const ISSUED_AT = Date.UTC(2026, 9, 5, 12) +const CHALLENGE_TTL = 5 * 60_000 +const SESSION_TTL = 30 * 60_000 +// Ephemeral test-only credentials: never persisted or sent to a provider. +const secret = randomBytes(32).toString("hex") +const wallet = Wallet.createRandom() +const otherWallet = Wallet.createRandom() + +type Token = { + type: string + payload: { + family: string + address: string + audience: string + issuedAt: number + difficultyPrefix: string + } + expiresAt: number +} + +type Proof = { + family: string + address: string + challenge: string + nonce: number + signature: string +} + +const decodeToken = (token: string): Token => + JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString("utf8")) + +// Valid HMACs let negative tests reach schema/expiry/audience checks rather +// than being rejected only for a broken MAC. Production signing is not mocked. +const authenticatePayload = (encoded: string) => + `${encoded}.${createHmac("sha256", secret).update(encoded).digest("base64url")}` + +const signToken = (token: unknown) => + authenticatePayload(Buffer.from(JSON.stringify(token)).toString("base64url")) + +const solvePow = (address: string, challenge: string, family = "EVM") => { + const prefix = `${normalizeOwnershipAddress(family, address)}:${challenge}:` + for (let nonce = 0; ; nonce += 1) { + if ( + createHash("sha256") + .update(`${prefix}${nonce}`) + .digest("hex") + .startsWith("0000") + ) { + return nonce + } + } +} + +const makeProof = async (challenge: string): Promise => ({ + family: "EVM", + address: wallet.address, + challenge, + nonce: solvePow(wallet.address, challenge), + signature: await wallet.signMessage(ownershipMessage(challenge)), +}) + +const post = (body: unknown, origin: string | null = ORIGIN) => + POST( + new NextRequest(`${ORIGIN}/api/chains/auth/address`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(origin === null ? {} : { Origin: origin }), + }, + body: JSON.stringify(body), + }), + ) + +const expectRejected = async (response: NextResponse, status: number) => { + expect(response.status).toBe(status) + expect(response.headers.get("set-cookie")).toBeNull() + expect(response.headers.get("cache-control")).toBe("private, no-store") +} + +let proof: Proof +let sessionToken: string +let cookieName: string +let requestId = 0 + +beforeAll(async () => { + vi.stubEnv("API_OWNERSHIP_ORIGIN", ORIGIN) + vi.stubEnv("API_POW_SESSION_SECRET", secret) + const clock = vi.spyOn(Date, "now").mockReturnValue(ISSUED_AT) + try { + const response = await GET( + new NextRequest( + `${ORIGIN}/api/chains/auth/address?family=EVM&address=${wallet.address}`, + { headers: { Origin: ORIGIN } }, + ), + ) + expect(response.status).toBe(200) + expect(response.headers.get("cache-control")).toBe("private, no-store") + const challenge = await response.json() + expect(challenge.difficultyPrefix).toBe("0000") + expect(challenge.expiresAt).toBe(ISSUED_AT + CHALLENGE_TTL) + proof = await makeProof(challenge.challenge) + cookieName = await getAddressSessionCookieName("EVM", wallet.address) + const verified = await post(proof) + expect(verified.status).toBe(200) + sessionToken = verified.cookies.get(cookieName)!.value + } finally { + clock.mockRestore() + vi.unstubAllEnvs() + } +}) + +beforeEach(() => { + vi.stubEnv("API_OWNERSHIP_ORIGIN", ORIGIN) + vi.stubEnv("API_POW_SESSION_SECRET", secret) + vi.spyOn(Date, "now").mockReturnValue(ISSUED_AT) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +describe("ownership challenge and POST session flow", () => { + it("accepts real EVM signatures and SHA-256 work and sets a scoped HttpOnly cookie", async () => { + expect(decodeToken(proof.challenge)).toEqual({ + type: "ownership-challenge-v2", + payload: { + family: "EVM", + address: wallet.address.toLowerCase(), + audience: ORIGIN, + issuedAt: ISSUED_AT, + difficultyPrefix: "0000", + }, + expiresAt: ISSUED_AT + CHALLENGE_TTL, + }) + expect( + await verifyPowSolution( + "EVM", + wallet.address, + proof.challenge, + proof.nonce, + ), + ).toBe(true) + const response = await post({ + ...proof, + address: wallet.address.toLowerCase(), + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + expiresAt: ISSUED_AT + SESSION_TTL, + }) + expect(response.headers.get("cache-control")).toBe("private, no-store") + expect(response.cookies.get(cookieName)).toMatchObject({ + value: sessionToken, + httpOnly: true, + sameSite: "lax", + path: "/api/chains", + expires: new Date(ISSUED_AT + SESSION_TTL), + }) + expect(cookieName).toMatch(/^gw_owner_v2_[a-f0-9]{24}$/) + expect( + await getAddressSessionCookieName("EVM", wallet.address.toLowerCase()), + ).toBe(cookieName) + expect( + await verifyPowSessionCookie(sessionToken, "EVM", wallet.address), + ).toBe(true) + expect( + await verifyPowSessionCookie( + sessionToken, + "EVM", + wallet.address.toLowerCase(), + ), + ).toBe(true) + }) + + it("rejects the former PoW-only POST with no ownership signature", async () => { + const { signature: _signature, ...powOnly } = proof + await expectRejected(await post(powOnly), 400) + }) + + it.each([ + "wrong signer", + "wrong message", + "malformed", + ])("rejects a %s signature despite valid work", async (kind) => { + const signature = + kind === "wrong signer" + ? await otherWallet.signMessage(ownershipMessage(proof.challenge)) + : kind === "wrong message" + ? await wallet.signMessage( + `${ownershipMessage(proof.challenge)}tampered`, + ) + : "not-a-signature" + await expectRejected(await post({ ...proof, signature }), 401) + }) + + it.each([ + "address", + "family", + ])("rejects proof submitted for the wrong %s even with recomputed work", async (field) => { + const address = field === "address" ? otherWallet.address : wallet.address + const family = field === "family" ? "BTC" : "EVM" + const nonce = solvePow(address, proof.challenge, family) + expect( + await verifyPowSolution(family, address, proof.challenge, nonce), + ).toBe(false) + await expectRejected(await post({ ...proof, address, family, nonce }), 401) + }) + + it("rejects incorrect work even with a genuine signature", async () => { + let nonce = proof.nonce + 1 + while ( + await verifyPowSolution("EVM", wallet.address, proof.challenge, nonce) + ) + nonce += 1 + await expectRejected(await post({ ...proof, nonce }), 401) + }) + + it.each([ + -1, + 1.5, + Number.MAX_SAFE_INTEGER + 1, + ])("rejects invalid nonce %s", async (nonce) => { + expect( + await verifyPowSolution("EVM", wallet.address, proof.challenge, nonce), + ).toBe(false) + await expectRejected(await post({ ...proof, nonce }), 400) + }) + + it.each([ + null, + "https://other.example", + ])("rejects missing or wrong Origin: %s", async (origin) => { + await expectRejected(await post(proof, origin), 403) + }) + + it("rejects a challenge exactly at expiry", async () => { + vi.mocked(Date.now).mockReturnValue(ISSUED_AT + CHALLENGE_TTL) + await expectRejected(await post(proof), 401) + }) + + it("rejects a genuinely issued challenge from the future", async () => { + vi.mocked(Date.now).mockReturnValue(ISSUED_AT + 1) + const future = await issuePowChallenge("EVM", wallet.address) + const futureProof = await makeProof(future.challenge) + vi.mocked(Date.now).mockReturnValue(ISSUED_AT) + await expectRejected(await post(futureProof), 401) + }) + + it("rejects payload tampering even with fresh PoW and the owner's signature", async () => { + const payload = decodeToken(proof.challenge) + payload.payload.address = otherWallet.address.toLowerCase() + const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url") + const tampered = `${encoded}.${proof.challenge.split(".")[1]}` + await expectRejected(await post(await makeProof(tampered)), 401) + }) + + it("rejects a correctly authenticated challenge for the wrong audience", async () => { + const payload = decodeToken(proof.challenge) + payload.payload.audience = "https://other.example" + await expectRejected(await post(await makeProof(signToken(payload))), 401) + }) + + it("replay never extends expiry beyond challenge issuedAt + 30 minutes", async () => { + vi.mocked(Date.now).mockReturnValue(ISSUED_AT + 60_000) + const first = await post(proof) + vi.mocked(Date.now).mockReturnValue(ISSUED_AT + CHALLENGE_TTL - 1) + const replay = await post(proof) + for (const response of [first, replay]) { + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + ok: true, + expiresAt: ISSUED_AT + SESSION_TTL, + }) + expect(response.cookies.get(cookieName)?.value).toBe(sessionToken) + expect( + new Date(response.cookies.get(cookieName)!.expires!).getTime(), + ).toBe(ISSUED_AT + SESSION_TTL) + } + expect(decodeToken(sessionToken).payload.issuedAt).toBe(ISSUED_AT) + vi.mocked(Date.now).mockReturnValue(ISSUED_AT + CHALLENGE_TTL) + await expectRejected(await post(proof), 401) + vi.mocked(Date.now).mockReturnValue(ISSUED_AT + SESSION_TTL - 1) + expect( + await verifyPowSessionCookie(sessionToken, "EVM", wallet.address), + ).toBe(true) + vi.mocked(Date.now).mockReturnValue(ISSUED_AT + SESSION_TTL) + expect( + await verifyPowSessionCookie(sessionToken, "EVM", wallet.address), + ).toBe(false) + }) +}) + +describe("session token validation", () => { + it.each([ + "address", + "family", + ])("rejects the wrong session %s", async (field) => { + expect( + await verifyPowSessionCookie( + sessionToken, + field === "family" ? "BTC" : "EVM", + field === "address" ? otherWallet.address : wallet.address, + ), + ).toBe(false) + }) + + it.each([ + "pow-session", + "pow-challenge", + "ownership-challenge-v2", + ])("rejects authenticated %s tokens as ownership cookies", async (type) => { + // Even with a current payload and valid MAC, a legacy type cannot upgrade. + const legacy = signToken({ ...decodeToken(sessionToken), type }) + expect(await verifyPowSessionCookie(legacy, "EVM", wallet.address)).toBe( + false, + ) + }) + + it("rejects an old PoW-only cookie lacking ownership audience and issuedAt", async () => { + const legacy = signToken({ + type: "pow-session", + payload: { family: "EVM", address: wallet.address.toLowerCase() }, + expiresAt: ISSUED_AT + SESSION_TTL, + }) + expect(await verifyPowSessionCookie(legacy, "EVM", wallet.address)).toBe( + false, + ) + }) + + it.each([ + [ + "wrong audience", + (token: Token) => { + token.payload.audience = "https://other.example" + }, + ], + [ + "future issuedAt", + (token: Token) => { + token.payload.issuedAt += 1 + token.expiresAt += 1 + }, + ], + [ + "extended expiry", + (token: Token) => { + token.expiresAt += 1 + }, + ], + [ + "fractional expiry", + (token: Token) => { + token.expiresAt += 0.5 + }, + ], + [ + "negative issuedAt", + (token: Token) => { + token.payload.issuedAt = -1 + }, + ], + [ + "wrong difficulty", + (token: Token) => { + token.payload.difficultyPrefix = "0" + }, + ], + [ + "unknown family", + (token: Token) => { + token.payload.family = "UNKNOWN" + }, + ], + [ + "invalid address", + (token: Token) => { + token.payload.address = "invalid/address" + }, + ], + ] as const)("rejects a correctly authenticated token with %s", async (_label, mutate) => { + for (const original of [proof.challenge, sessionToken]) { + const token = decodeToken(original) + mutate(token) + const invalid = signToken(token) + if (original === proof.challenge) { + await expectRejected(await post(await makeProof(invalid)), 401) + } else { + expect( + await verifyPowSessionCookie(invalid, "EVM", wallet.address), + ).toBe(false) + } + } + }) + + it.each([ + ["empty", (): string => ""], + ["missing separator", (): string => "invalid"], + ["missing payload", (): string => ".signature"], + ["missing MAC", (): string => "payload."], + ["extra segment", () => `${sessionToken}.extra`], + ["oversized", () => "x".repeat(2049)], + ["invalid MAC", () => `${sessionToken.split(".")[0]}.invalid`], + ["authenticated invalid base64", () => authenticatePayload("%%%")], + [ + "authenticated invalid JSON", + () => authenticatePayload(Buffer.from("{").toString("base64url")), + ], + ["authenticated null", () => signToken(null)], + ["authenticated empty object", () => signToken({})], + ["authenticated array", () => signToken([])], + ] as const)("rejects malformed tokens: %s", async (_label, tokenFor) => { + const token = tokenFor() + expect(await verifyPowSessionCookie(token, "EVM", wallet.address)).toBe( + false, + ) + expect(await verifyPowSolution("EVM", wallet.address, token, 0)).toBe(false) + await expectRejected( + await post({ ...proof, challenge: token }), + token.length === 0 || token.length > 2048 ? 400 : 401, + ) + }) + + it("rejects absent cookies", async () => { + expect(await verifyPowSessionCookie(undefined, "EVM", wallet.address)).toBe( + false, + ) + }) +}) + +// Origin + Host exercise the actual same-origin check. Give each request its +// own test IP so rate limiting cannot mask the ownership guard's response. +const guardedRead = async ( + endpoint: string, + family = "EVM", + address = wallet.address, + token?: string, + suppliedCookieName?: string, +) => { + const name = + suppliedCookieName ?? (await getAddressSessionCookieName(family, address)) + return proxy( + new NextRequest( + `${ORIGIN}/api/chains/${family}/addresses/${address}/${endpoint}`, + { + headers: { + Origin: ORIGIN, + Host: "localhost:3000", + "x-real-ip": `ownership-test-${++requestId}`, + ...(token === undefined ? {} : { Cookie: `${name}=${token}` }), + }, + }, + ), + ) +} + +describe("proxy address ownership guards", () => { + it.each([ + "balance", + "history", + "utxos", + ])("guards %s with the exact family/address cookie", async (endpoint) => { + const allowed = await guardedRead( + endpoint, + "EVM", + wallet.address.toLowerCase(), + sessionToken, + ) + expect(allowed.status).toBe(200) + expect(allowed.headers.get("x-middleware-next")).toBe("1") + + for (const response of [ + await guardedRead(endpoint), + await guardedRead(endpoint, "EVM", wallet.address, "malformed"), + // Put the real token under the target cookie name: prove scope checking, + // not merely absence of the cookie belonging to the other scope. + await guardedRead(endpoint, "EVM", otherWallet.address, sessionToken), + await guardedRead(endpoint, "BTC", wallet.address, sessionToken), + ]) { + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + message: "Address session required", + }) + expect(response.headers.get("x-middleware-next")).toBeNull() + } + }) + + it("does not accept old cookie names or authenticated PoW-only tokens", async () => { + const legacy = signToken({ + ...decodeToken(sessionToken), + type: "pow-session", + }) + const legacyName = cookieName.replace("gw_owner_v2_", "gw_pow_") + expect( + ( + await guardedRead( + "balance", + "EVM", + wallet.address, + sessionToken, + legacyName, + ) + ).status, + ).toBe(401) + expect( + (await guardedRead("balance", "EVM", wallet.address, legacy)).status, + ).toBe(401) + }) + + it("rejects expired sessions at the proxy", async () => { + vi.mocked(Date.now).mockReturnValue(ISSUED_AT + SESSION_TTL) + expect( + (await guardedRead("balance", "EVM", wallet.address, sessionToken)) + .status, + ).toBe(401) + }) +}) diff --git a/src/app/api/chains/ownershipSignature.test.ts b/src/app/api/chains/ownershipSignature.test.ts new file mode 100644 index 0000000..5dd3cba --- /dev/null +++ b/src/app/api/chains/ownershipSignature.test.ts @@ -0,0 +1,365 @@ +import { createHash, randomBytes } from "node:crypto" +import * as ecc from "@bitcoinerlab/secp256k1" +import { networks, payments } from "bitcoinjs-lib" +import { Wallet } from "ethers/wallet" +import { + deriveAddress, + deriveKeypair, + generateSeed, + sign, +} from "ripple-keypairs" +import { beforeAll, describe, expect, it, vi } from "vitest" + +import { ChainFamily } from "@/chain/types" +import { dogeNetwork, dogeTestNetwork } from "@/login/adapters/bitcoinNetworks" +import { getPrivateKeySession } from "@/login/adapters/privatekey" +import type { ISignerSession } from "@/login/types" + +import { signOwnershipMessage } from "./clientOwnershipSignature" +import { verifyOwnershipSignature } from "./ownershipSignature" + +vi.mock("@/config", () => ({ config: { includeTestnetTokens: true } })) +vi.mock("@/login/context/SessionContext/storage", () => ({ + sessionState: { session: null }, +})) + +const messageFor = (family: ChainFamily, address: string) => + `GoodWallet API ownership v2\n${JSON.stringify({ + family, + address, + nonce: randomBytes(32).toString("hex"), + expiresAt: Date.now() + 60_000, + })}` + +describe("ownership signatures", () => { + let session: ISignerSession + let otherSession: ISignerSession + + beforeAll(async () => { + // Ephemeral test-only seeds; never persisted or logged. + session = await getPrivateKeySession( + randomBytes(32).toString("hex"), + "test", + "test", + ) + otherSession = await getPrivateKeySession( + randomBytes(32).toString("hex"), + "test", + "test", + ) + const { sessionState } = await import( + "@/login/context/SessionContext/storage" + ) + sessionState.session = session + }) + + it.each( + ChainFamily, + )("signs and verifies exact %s address/message", async (family) => { + const signer = session.signer[family]! + const message = messageFor(family, signer.address) + const proof = await signOwnershipMessage(family, signer.address, message) + expect( + await verifyOwnershipSignature( + family, + signer.address, + message, + proof.signature, + proof.publicKey, + ), + ).toBe(true) + expect( + await verifyOwnershipSignature( + family, + signer.address, + `${message}tampered`, + proof.signature, + proof.publicKey, + ), + ).toBe(false) + expect( + await verifyOwnershipSignature( + family, + otherSession.signer[family]!.address, + message, + proof.signature, + proof.publicKey, + ), + ).toBe(false) + expect( + await verifyOwnershipSignature( + family, + signer.address, + message, + "invalid", + proof.publicKey, + ), + ).toBe(false) + expect( + await verifyOwnershipSignature( + family, + signer.address, + "not an auth message", + proof.signature, + proof.publicKey, + ), + ).toBe(false) + await expect( + signOwnershipMessage( + family, + otherSession.signer[family]!.address, + message, + ), + ).rejects.toThrow("address mismatch") + await expect( + signOwnershipMessage(family, signer.address, "raw transaction digest"), + ).rejects.toThrow("domain") + }) + + it.each([ + "BTC", + "BTC_TESTNET", + "DOGE", + "DOGE_TESTNET", + "XRP", + "XRP_TESTNET", + ] as const)("%s auth-only signer rejects non-domain text and binds the public key", async (family) => { + const signer = session.signer[family]! + await expect( + signer.signAuthMessage!(randomBytes(32).toString("hex")), + ).rejects.toThrow("domain") + const message = messageFor(family, signer.address) + const signature = await signer.signAuthMessage!(message) + expect( + await verifyOwnershipSignature( + family, + signer.address, + message, + signature, + ), + ).toBe(false) + expect( + await verifyOwnershipSignature( + family, + signer.address, + message, + signature, + otherSession.signer[family]!.publicKey, + ), + ).toBe(false) + expect( + await verifyOwnershipSignature( + family, + signer.address, + message, + `${signature}00`, + signer.publicKey, + ), + ).toBe(false) + expect( + await verifyOwnershipSignature( + family, + signer.address, + message, + signature, + `${signer.publicKey}zz`, + ), + ).toBe(false) + }) + + it.each([ + "BTC", + "BTC_TESTNET", + "DOGE", + "DOGE_TESTNET", + ] as const)("%s uses compact SHA256 ECDSA and the correct address network", async (family) => { + const signer = session.signer[family]! + const message = messageFor(family, signer.address) + const signature = await signer.signAuthMessage!(message) + expect(signature).toMatch(/^[0-9a-f]{128}$/i) + expect( + ecc.verify( + createHash("sha256").update(message).digest(), + Buffer.from(signer.publicKey, "hex"), + Buffer.from(signature, "hex"), + ), + ).toBe(true) + for (const otherFamily of [ + "BTC", + "BTC_TESTNET", + "DOGE", + "DOGE_TESTNET", + ] as const) { + if (otherFamily !== family) { + expect( + await verifyOwnershipSignature( + otherFamily, + signer.address, + message, + signature, + signer.publicKey, + ), + ).toBe(false) + } + } + const network = { + BTC: networks.bitcoin, + BTC_TESTNET: networks.testnet, + DOGE: dogeNetwork, + DOGE_TESTNET: dogeTestNetwork, + }[family] + const payment = family.startsWith("BTC") ? payments.p2wpkh : payments.p2pkh + expect( + payment({ pubkey: Buffer.from(signer.publicKey, "hex"), network }) + .address, + ).toBe(signer.address) + }) + + it("accepts EVM case normalization but rejects invalid addresses", async () => { + const { address } = session.signer.EVM + const message = messageFor("EVM", address) + const { signature } = await signOwnershipMessage( + "EVM", + address.toLowerCase(), + message, + ) + expect( + await verifyOwnershipSignature( + "EVM", + address.toLowerCase(), + message, + signature, + ), + ).toBe(true) + expect( + await verifyOwnershipSignature( + "EVM", + "not-an-address", + message, + signature, + ), + ).toBe(false) + const wallet = Wallet.createRandom() + expect( + await verifyOwnershipSignature( + "EVM", + address, + message, + await wallet.signMessage(message), + ), + ).toBe(false) + }) + + it.each([ + "ed25519", + "ecdsa-secp256k1", + ] as const)("verifies XRP %s public keys", async (algorithm) => { + const { publicKey, privateKey } = deriveKeypair(generateSeed({ algorithm })) + const address = deriveAddress(publicKey) + const message = messageFor("XRP", address) + const signature = sign(Buffer.from(message).toString("hex"), privateKey) + expect( + await verifyOwnershipSignature( + "XRP", + address, + message, + signature, + publicKey, + ), + ).toBe(true) + }) + + it("rejects malformed Solana lengths and ignores a supplied substitute public key", async () => { + const address = session.signer.SOLANA!.address + const message = messageFor("SOLANA", address) + const { signature } = await signOwnershipMessage("SOLANA", address, message) + expect( + await verifyOwnershipSignature( + "SOLANA", + "1".repeat(33), + message, + signature, + ), + ).toBe(false) + expect( + await verifyOwnershipSignature( + "SOLANA", + address, + message, + "1".repeat(63), + ), + ).toBe(false) + expect( + await verifyOwnershipSignature( + "SOLANA", + otherSession.signer.SOLANA!.address, + message, + signature, + address, + ), + ).toBe(false) + }) + + it("fails closed for unknown families", async () => { + const family = "UNKNOWN" as ChainFamily + expect( + await verifyOwnershipSignature( + family, + "address", + messageFor("EVM", "address"), + "signature", + ), + ).toBe(false) + await expect( + signOwnershipMessage(family, "address", messageFor("EVM", "address")), + ).rejects.toThrow() + }) + + it("rejects unavailable signers and never falls back to transaction signing", async () => { + const { sessionState } = await import( + "@/login/context/SessionContext/storage" + ) + const signPsbt = vi.fn() + const signTransaction = vi.fn() + try { + sessionState.session = null + await expect( + signOwnershipMessage( + "BTC", + session.signer.BTC.address, + messageFor("BTC", session.signer.BTC.address), + ), + ).rejects.toThrow("No ownership signer") + sessionState.session = { + ...session, + signer: { + ...session.signer, + BTC: { ...session.signer.BTC, signAuthMessage: undefined, signPsbt }, + XRP: { + ...session.signer.XRP!, + signAuthMessage: undefined, + sign: signTransaction, + }, + SOLANA_DEVNET: undefined, + }, + } + for (const family of ["BTC", "XRP"] as const) { + const address = session.signer[family]!.address + await expect( + signOwnershipMessage(family, address, messageFor(family, address)), + ).rejects.toThrow("unsupported") + } + await expect( + signOwnershipMessage( + "SOLANA_DEVNET", + session.signer.SOLANA!.address, + messageFor("SOLANA_DEVNET", session.signer.SOLANA!.address), + ), + ).rejects.toThrow("No ownership signer") + expect(signPsbt).not.toHaveBeenCalled() + expect(signTransaction).not.toHaveBeenCalled() + } finally { + sessionState.session = session + } + }) +}) diff --git a/src/app/api/chains/ownershipSignature.ts b/src/app/api/chains/ownershipSignature.ts new file mode 100644 index 0000000..156481b --- /dev/null +++ b/src/app/api/chains/ownershipSignature.ts @@ -0,0 +1,114 @@ +import { createHash, createPublicKey, verify } from "node:crypto" + +import type { ChainFamily } from "@/chain/types" + +/** + * Verify ownership of the requested address, not merely validity under a supplied key. + * The caller must reconstruct a trusted challenge message containing family, address, + * timestamp and expiry, and enforce freshness. This function checks cryptography + * only; SOLANA/XRP network variants share address formats and signing algorithms. + * Wire signatures: EVM ethers hex, Solana base58, BTC/DOGE compact hex, XRP hex. + */ +export async function verifyOwnershipSignature( + family: ChainFamily, + address: string, + message: string, + signature: string, + publicKey?: string, +): Promise { + try { + if (!message.startsWith("GoodWallet API ownership v2\n")) return false + + switch (family) { + case "EVM": { + const { getAddress, verifyMessage } = await import("ethers") + return getAddress(address) === verifyMessage(message, signature) + } + case "SOLANA": + case "SOLANA_DEVNET": { + const { getBase58Encoder } = await import("gill") + // Bound decoding before handling untrusted base58 strings. + if (address.length > 44 || signature.length > 88) return false + const encoder = getBase58Encoder() + const key = encoder.encode(address) + const sig = encoder.encode(signature) + if (key.length !== 32 || sig.length !== 64) return false + const publicKeyObject = createPublicKey({ + key: Buffer.concat([ + // SubjectPublicKeyInfo header for a raw 32-byte Ed25519 public key. + Buffer.from("302a300506032b6570032100", "hex"), + new Uint8Array(key), + ]), + format: "der", + type: "spki", + }) + return verify( + null, + Buffer.from(message, "utf8"), + publicKeyObject, + new Uint8Array(sig), + ) + } + case "BTC": + case "BTC_TESTNET": + case "DOGE": + case "DOGE_TESTNET": { + if ( + !publicKey || + !/^(02|03)[0-9a-f]{64}$/i.test(publicKey) || + !/^[0-9a-f]{128}$/i.test(signature) + ) { + return false + } + + const { networks, payments } = await import("bitcoinjs-lib") + const { verify: verifyEcdsa } = await import("@bitcoinerlab/secp256k1") + const { dogeNetwork, dogeTestNetwork } = await import( + "@/login/adapters/bitcoinNetworks" + ) + const pubkey = Buffer.from(publicKey, "hex") + const network = { + BTC: networks.bitcoin, + BTC_TESTNET: networks.testnet, + DOGE: dogeNetwork, + DOGE_TESTNET: dogeTestNetwork, + }[family] + const payment = + family === "BTC" || family === "BTC_TESTNET" + ? payments.p2wpkh({ pubkey, network }) + : payments.p2pkh({ pubkey, network }) + if (payment.address !== address) return false + + // Internal authentication protocol, explicitly NOT BIP322 or tx signing. + const digest = createHash("sha256").update(message, "utf8").digest() + return verifyEcdsa(digest, pubkey, Buffer.from(signature, "hex")) + } + case "XRP": + case "XRP_TESTNET": { + if ( + !publicKey || + !/^(02|03|ed)[0-9a-f]{64}$/i.test(publicKey) || + !/^(?:[0-9a-f]{2}){64,72}$/i.test(signature) + ) { + return false + } + const { deriveAddress, verify: verifyXrp } = await import( + "ripple-keypairs" + ) + return ( + deriveAddress(publicKey) === address && + verifyXrp( + Buffer.from(message, "utf8").toString("hex"), + signature, + publicKey, + ) + ) + } + default: + return false + } + } catch { + // Untrusted malformed keys, addresses and signatures fail closed. + return false + } +} diff --git a/src/app/api/chains/ownershipSignature.vitest.config.ts b/src/app/api/chains/ownershipSignature.vitest.config.ts new file mode 100644 index 0000000..9db99e9 --- /dev/null +++ b/src/app/api/chains/ownershipSignature.vitest.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from "node:url" +import { defineConfig } from "vitest/config" + +// Isolated crypto tests: the shared config's broad @ alias shadows @/config. +// Keep this override local rather than changing unrelated test infrastructure. +export default defineConfig({ + test: { + environment: "node", + include: [ + "src/app/api/chains/ownershipSignature.test.ts", + "src/app/api/chains/ownershipSession.test.ts", + "src/app/api/chains/clientAddressSession.test.ts", + "src/app/api/chains/ownershipRoutes.test.ts", + "src/app/api/chains/chainsProxy.test.ts", + ], + }, + resolve: { + alias: [ + { + find: "@/config", + replacement: fileURLToPath( + new URL("../../../../config.ts", import.meta.url), + ), + }, + { + find: "@", + replacement: fileURLToPath(new URL("../../../", import.meta.url)), + }, + ], + }, +}) diff --git a/src/app/api/chains/powSession.ts b/src/app/api/chains/powSession.ts new file mode 100644 index 0000000..64ae221 --- /dev/null +++ b/src/app/api/chains/powSession.ts @@ -0,0 +1,310 @@ +import { z } from "zod" + +import type { ChainFamily } from "@/chain/types" + +import { normalizeOwnershipAddress, OwnershipScope } from "./ownershipProtocol" + +const POW_CHALLENGE_TTL_MS = 5 * 60 * 1000 +const POW_SESSION_TTL_MS = 30 * 60 * 1000 +const POW_DIFFICULTY_PREFIX = "0000" +const POW_COOKIE_PREFIX = "gw_owner_v2_" + +type SignedPayload = { + type: T + payload: P + expiresAt: number +} + +type PowChallengePayload = { + address: string + difficultyPrefix: string + family: ChainFamily + audience: string + issuedAt: number +} + +type PowSessionPayload = PowChallengePayload + +export const getOwnershipAudience = () => { + const value = + process.env.API_OWNERSHIP_ORIGIN ?? + (process.env.NODE_ENV !== "production" ? "http://localhost:3000" : "") + const url = new URL(value) + if ( + url.origin !== value || + (process.env.NODE_ENV === "production" && url.protocol !== "https:") + ) { + throw new Error( + "Configure API_OWNERSHIP_ORIGIN as the canonical app origin", + ) + } + return value +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +const encodeBase64Url = (bytes: Uint8Array) => { + let binary = "" + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} + +const decodeBase64Url = (value: string) => { + const padded = value + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(value.length / 4) * 4, "=") + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + + return bytes +} + +const getPowSecret = () => { + const secret = process.env.API_POW_SESSION_SECRET + if (!secret || secret.length < 32) { + throw new Error("Missing API_POW_SESSION_SECRET env variable") + } + + return secret +} + +const constantTimeEqual = (left: string, right: string) => { + if (left.length !== right.length) { + return false + } + + let diff = 0 + for (let index = 0; index < left.length; index += 1) { + diff |= left.charCodeAt(index) ^ right.charCodeAt(index) + } + + return diff === 0 +} + +const importHmacKey = async () => { + return crypto.subtle.importKey( + "raw", + encoder.encode(getPowSecret()), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ) +} + +const sign = async (value: string) => { + const key = await importHmacKey() + const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value)) + return encodeBase64Url(new Uint8Array(signature)) +} + +const sha256Hex = async (value: string) => { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)) + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("") +} + +const createSignedToken = async ( + type: T, + payload: P, + ttlMs: number, + issuedAt = Date.now(), +) => { + const expiresAt = issuedAt + ttlMs + const tokenPayload: SignedPayload = { + type, + payload, + expiresAt, + } + + const encodedPayload = encodeBase64Url( + encoder.encode(JSON.stringify(tokenPayload)), + ) + const signature = await sign(encodedPayload) + + return { + token: `${encodedPayload}.${signature}`, + expiresAt, + } +} + +const parseSignedToken = async ( + token: string, + expectedType: T, +) => { + try { + if (token.length > 2048 || token.split(".").length !== 2) return null + const [encodedPayload, signature] = token.split(".") + if (!encodedPayload || !signature) { + return null + } + + const expectedSignature = await sign(encodedPayload) + if (!constantTimeEqual(signature, expectedSignature)) { + return null + } + + const payloadJson = decoder.decode(decodeBase64Url(encodedPayload)) + const parsed = JSON.parse(payloadJson) as SignedPayload + + const scope = OwnershipScope.extend({ + audience: z.string(), + issuedAt: z.number().int().nonnegative(), + difficultyPrefix: z.literal(POW_DIFFICULTY_PREFIX), + }).safeParse(parsed.payload) + const ttl = + expectedType === "ownership-challenge-v2" + ? POW_CHALLENGE_TTL_MS + : POW_SESSION_TTL_MS + if ( + !scope.success || + parsed.type !== expectedType || + !Number.isSafeInteger(parsed.expiresAt) || + parsed.expiresAt !== scope.data.issuedAt + ttl || + scope.data.issuedAt > Date.now() || + parsed.expiresAt <= Date.now() || + scope.data.audience !== getOwnershipAudience() + ) { + return null + } + + return parsed + } catch { + return null + } +} + +export const issuePowChallenge = async ( + family: ChainFamily, + address: string, +) => { + OwnershipScope.parse({ family, address }) + const normalizedAddress = normalizeOwnershipAddress(family, address) + const issuedAt = Date.now() + const challenge = await createSignedToken( + "ownership-challenge-v2", + { + address: normalizedAddress, + difficultyPrefix: POW_DIFFICULTY_PREFIX, + family, + audience: getOwnershipAudience(), + issuedAt, + } satisfies PowChallengePayload, + POW_CHALLENGE_TTL_MS, + issuedAt, + ) + + return { + challenge: challenge.token, + difficultyPrefix: POW_DIFFICULTY_PREFIX, + expiresAt: challenge.expiresAt, + } +} + +export const verifyPowSolution = async ( + family: ChainFamily, + address: string, + challengeToken: string, + nonce: number, +) => { + const challenge = await parseSignedToken< + "ownership-challenge-v2", + PowChallengePayload + >(challengeToken, "ownership-challenge-v2") + + if (!challenge || !Number.isSafeInteger(nonce) || nonce < 0) { + return false + } + + const normalizedAddress = normalizeOwnershipAddress(family, address) + if ( + challenge.payload.address !== normalizedAddress || + challenge.payload.family !== family + ) { + return false + } + + const powHash = await sha256Hex( + `${normalizedAddress}:${challengeToken}:${nonce}`, + ) + return powHash.startsWith(challenge.payload.difficultyPrefix) +} + +// Called only after signature + PoW verification. Replay cannot extend expiry. +export const issuePowSession = async (challengeToken: string) => { + const challenge = await parseSignedToken< + "ownership-challenge-v2", + PowChallengePayload + >(challengeToken, "ownership-challenge-v2") + if (!challenge) throw new Error("Expired ownership challenge") + const session = await createSignedToken( + "ownership-session-v2", + challenge.payload, + POW_SESSION_TTL_MS, + challenge.payload.issuedAt, + ) + + return { + token: session.token, + expiresAt: session.expiresAt, + } +} + +export const getAddressSessionCookieName = async ( + family: string, + address: string, +) => { + const digest = await sha256Hex( + `${family}:${normalizeOwnershipAddress(family, address)}`, + ) + return `${POW_COOKIE_PREFIX}${digest.slice(0, 24)}` +} + +export const verifyPowSessionCookie = async ( + token: string | undefined, + family: string, + address: string, +) => { + if (!token) { + return false + } + + const session = await parseSignedToken< + "ownership-session-v2", + PowSessionPayload + >(token, "ownership-session-v2") + + if (!session) { + return false + } + + return ( + session.payload.family === family && + session.payload.address === normalizeOwnershipAddress(family, address) + ) +} + +export const isProtectedAddressReadPath = (pathname: string) => { + return /\/api\/chains\/[^/]+\/addresses\/[^/]+\/(balance|history|utxos)$/.test( + pathname, + ) +} + +export const isTxLookupPath = (pathname: string) => { + return ( + /\/api\/chains\/[^/]+\/transactions\/[^/]+$/.test(pathname) && + !pathname.endsWith("/broadcast") + ) +} diff --git a/src/chain/provider/Bitcoin/bitcoinProvider.ts b/src/chain/provider/Bitcoin/bitcoinProvider.ts index d550d2f..d65454d 100644 --- a/src/chain/provider/Bitcoin/bitcoinProvider.ts +++ b/src/chain/provider/Bitcoin/bitcoinProvider.ts @@ -2,6 +2,7 @@ import { address, networks, Psbt } from "bitcoinjs-lib" import { toOutputScript } from "bitcoinjs-lib/src/address" import { type FixedNumber, formatUnits, parseUnits } from "ethers/utils" +import { fetchWithAddressSession } from "@/app/api/chains/clientAddressSession" import { BITCOIN_FAMILY, BITCOIN_TESTNET_FAMILY, @@ -98,7 +99,8 @@ export const getBitcoinProvider = ( return amountsForChain } - const res = await fetch( + const res = await fetchWithAddressSession( + address, `/api/chains/${family}/addresses/${address}/balance`, { method: "GET", @@ -168,7 +170,7 @@ export const getBitcoinProvider = ( }) const fullUrl = `/api/chains/${family}/addresses/${fromAddress}/utxos?${params}` - const res = await fetch(fullUrl, { + const res = await fetchWithAddressSession(fromAddress, fullUrl, { method: "GET", headers: { Accept: "application/json", @@ -188,8 +190,9 @@ export const getBitcoinProvider = ( switch (txType) { case "legacy": { - const res = await fetch( - `/api/chains/${family}/transactions/${utxo.txHash}`, + const res = await fetchWithAddressSession( + fromAddress, + `/api/chains/${family}/transactions/${utxo.txHash}?address=${encodeURIComponent(fromAddress)}`, { method: "GET", headers: { diff --git a/src/chain/provider/EVM/evmProvider.ts b/src/chain/provider/EVM/evmProvider.ts index 5a6cace..6dac704 100644 --- a/src/chain/provider/EVM/evmProvider.ts +++ b/src/chain/provider/EVM/evmProvider.ts @@ -3,6 +3,7 @@ import { isAddress } from "viem" import { AnalyticsEventTypes } from "@/analytics/types" import { captureEvent } from "@/analytics/useAnalytics" +import { fetchWithAddressSession } from "@/app/api/chains/clientAddressSession" import { ETHEREUM_CHAIN_ID } from "@/chain/chain-ids" import { EVM_FAMILY } from "@/chain/types" import { config } from "@/config" @@ -89,7 +90,8 @@ export const getEVMProvider = (): EVMProvider => { // call this from the client, move the switch server side getTransactions: async (chainId, address) => { try { - const res = await fetch( + const res = await fetchWithAddressSession( + address, `/api/chains/EVM/addresses/${address}/history?chainId=${chainId}`, ) if (res.status === 200) { diff --git a/src/chain/provider/Solana/solanaProvider.ts b/src/chain/provider/Solana/solanaProvider.ts index fb0b2e0..ae4a012 100644 --- a/src/chain/provider/Solana/solanaProvider.ts +++ b/src/chain/provider/Solana/solanaProvider.ts @@ -27,6 +27,7 @@ import { TOKEN_PROGRAM_ADDRESS, } from "gill/programs/token" +import { fetchWithAddressSession } from "@/app/api/chains/clientAddressSession" import { SOLANA_CHAIN_ID, SOLANA_DEVNET_CHAIN_ID } from "@/chain/chain-ids" import { SOLANA_DEVNET_FAMILY, SOLANA_FAMILY } from "@/chain/types" import { config } from "@/config" @@ -131,7 +132,8 @@ export const getSolanaProvider = ( }, getTransactions: async (_, address) => { try { - const res = await fetch( + const res = await fetchWithAddressSession( + address, `/api/chains/${family}/addresses/${address}/history`, ) if (res.status === 200) return await res.json() diff --git a/src/chain/provider/XRP/xrpProvider.ts b/src/chain/provider/XRP/xrpProvider.ts index c41e812..baa4a67 100644 --- a/src/chain/provider/XRP/xrpProvider.ts +++ b/src/chain/provider/XRP/xrpProvider.ts @@ -8,6 +8,7 @@ import { xrpToDrops, } from "xrpl" +import { fetchWithAddressSession } from "@/app/api/chains/clientAddressSession" import { XRP_CHAIN_ID, XRP_TESTNET_CHAIN_ID } from "@/chain/chain-ids" import { XRP_FAMILY, XRP_TESTNET_FAMILY } from "@/chain/types" import { config } from "@/config" @@ -82,7 +83,8 @@ export const getXrpProvider = (family: SUPPORTED_XRP_FAMILIES): XRPProvider => { try { const family = chainId === XRP_CHAIN_ID ? XRP_FAMILY : XRP_TESTNET_FAMILY - const res = await fetch( + const res = await fetchWithAddressSession( + address, `/api/chains/${family}/addresses/${address}/history`, ) if (res.status === 200) { diff --git a/src/hooks/useLedger/tatumBitcoinHandler.ts b/src/hooks/useLedger/tatumBitcoinHandler.ts index a2ffe12..a51e14f 100644 --- a/src/hooks/useLedger/tatumBitcoinHandler.ts +++ b/src/hooks/useLedger/tatumBitcoinHandler.ts @@ -1,5 +1,6 @@ import { formatUnits } from "ethers" +import { fetchWithAddressSession } from "@/app/api/chains/clientAddressSession" import { BITCOIN_CHAIN_ID, BITCOIN_TESTNET_CHAIN_ID, @@ -38,13 +39,17 @@ export const getAllBitcoinTransactions = async ( address: string, ) => { //preburn the balance (1 credits) - let res = await fetch(`/api/chains/${family}/addresses/${address}/balance`, { - method: "GET", - headers: { - Accept: "application/json", - "Content-Type": "application/json", + let res = await fetchWithAddressSession( + address, + `/api/chains/${family}/addresses/${address}/balance`, + { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, }, - }) + ) const resJson = (await res.json()) as ParsedBalanceSchema if (resJson.incoming <= 0) { @@ -54,13 +59,17 @@ export const getAllBitcoinTransactions = async ( //Fetch history (20 credits) const txs: Tx[] = [] - res = await fetch(`/api/chains/${family}/addresses/${address}/history`, { - method: "GET", - headers: { - Accept: "application/json", - "Content-Type": "application/json", + res = await fetchWithAddressSession( + address, + `/api/chains/${family}/addresses/${address}/history`, + { + method: "GET", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, }, - }) + ) const tatumTxs = await res.json() for (const tatumTx of tatumTxs) { diff --git a/src/login/adapters/privatekey.ts b/src/login/adapters/privatekey.ts index 65531ee..0e2f000 100644 --- a/src/login/adapters/privatekey.ts +++ b/src/login/adapters/privatekey.ts @@ -2,6 +2,8 @@ import * as ecc from "@bitcoinerlab/secp256k1" import { networks, type Psbt, payments } from "bitcoinjs-lib" import ECPairFactory, { type ECPairInterface } from "ecpair" import { derivePath, getPublicKey } from "ed25519-hd-key" +import { sha256 } from "ethers/crypto" +import { getBytes, toUtf8Bytes } from "ethers/utils" import { Wallet } from "ethers/wallet" import { createKeyPairSignerFromBytes } from "gill" import HDKey from "hdkey" @@ -86,6 +88,20 @@ const bitcoinSigner = async (keyPair: ECPairInterface, psbt: Psbt) => { return psbt.extractTransaction(true).toHex() } +const authMessageBytes = (message: string) => { + if (!message.startsWith("GoodWallet API ownership v2\n")) { + throw new Error("Invalid ownership message domain") + } + return toUtf8Bytes(message) +} + +// Auth-only protocol, not Bitcoin Signed Message or BIP322. Hash the text here; +// never accept a caller-provided transaction digest for raw signing. +const signBitcoinAuthMessage = async ( + keyPair: ECPairInterface, + message: string, +) => toHex(keyPair.sign(getBytes(sha256(authMessageBytes(message))))) + const hasEd25519Support = (async () => { try { await crypto.subtle.importKey( @@ -148,11 +164,14 @@ export const getPrivateKeySession = async ( BTC: { address: await getBitcoinAddress(btcKeyPair, networks.bitcoin, "p2wpkh"), signPsbt: async (psbt: Psbt) => bitcoinSigner(btcKeyPair, psbt), + signAuthMessage: (message) => signBitcoinAuthMessage(btcKeyPair, message), publicKey: toHex(btcKeyPair.publicKey), }, DOGE: { address: await getBitcoinAddress(dogeKeyPair, dogeNetwork, "p2pkh"), signPsbt: async (psbt: Psbt) => bitcoinSigner(dogeKeyPair, psbt), + signAuthMessage: (message) => + signBitcoinAuthMessage(dogeKeyPair, message), publicKey: toHex(dogeKeyPair.publicKey), }, SOLANA: solanaKeyPairSigner, @@ -173,6 +192,8 @@ export const getPrivateKeySession = async ( "p2wpkh", ), signPsbt: async (psbt: Psbt) => bitcoinSigner(btcTestnetKeyPair, psbt), + signAuthMessage: (message) => + signBitcoinAuthMessage(btcTestnetKeyPair, message), publicKey: toHex(btcTestnetKeyPair.publicKey), } @@ -190,6 +211,8 @@ export const getPrivateKeySession = async ( "p2pkh", ), signPsbt: async (psbt: Psbt) => bitcoinSigner(dogeTestKeyPair, psbt), + signAuthMessage: (message) => + signBitcoinAuthMessage(dogeTestKeyPair, message), } // SOL @@ -236,6 +259,8 @@ const getXrpPair = async ( return { address: deriveAddress(publicKey), publicKey, + signAuthMessage: async (message: string) => + sign(toHex(authMessageBytes(message)), privateKey), sign: async (tx: Transaction) => { const txToSignAndEncode = { ...tx } txToSignAndEncode.SigningPubKey = publicKey diff --git a/src/login/types.ts b/src/login/types.ts index aec5ea2..edd202b 100644 --- a/src/login/types.ts +++ b/src/login/types.ts @@ -17,6 +17,8 @@ export type EVMSigner = Pick< export type BTCSigner = { signPsbt: (psbt: bitcoin.Psbt) => Promise + /** Internal ownership auth only: compact ECDSA over SHA256(UTF8(message)), not BIP322. */ + signAuthMessage?: (message: string) => Promise publicKey: string } & Address @@ -34,6 +36,8 @@ export type SVMSigner = Pick & { export type XRPSigner = { sign: (tx: Transaction) => Promise<{ tx_blob: string; hash: string }> + /** Internal ownership auth only; accepts domain-prefixed text, never a transaction. */ + signAuthMessage?: (message: string) => Promise publicKey: string } & Address diff --git a/src/proxy.ts b/src/proxy.ts index efb2b93..4f11717 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,12 @@ import { type NextRequest, NextResponse } from "next/server" +import { + getAddressSessionCookieName, + isProtectedAddressReadPath, + isTxLookupPath, + verifyPowSessionCookie, +} from "@/app/api/chains/powSession" + /** * Proxy (Next.js middleware) for /api/*. * @@ -18,6 +25,28 @@ export const config = { matcher: "/api/:path*", } +type RateLimitPolicy = { + limit: number + windowMs: number +} + +type RateLimitEntry = { + resetAt: number + hits: number +} + +const rateLimitStore = new Map() + +const RATE_LIMITS = { + addressChallenge: { limit: 12, windowMs: 60 * 1000 }, + addressChallengeVerify: { limit: 24, windowMs: 60 * 1000 }, + balance: { limit: 30, windowMs: 60 * 1000 }, + history: { limit: 12, windowMs: 60 * 1000 }, + utxos: { limit: 18, windowMs: 60 * 1000 }, + txLookup: { limit: 30, windowMs: 60 * 1000 }, + fee: { limit: 60, windowMs: 60 * 1000 }, +} satisfies Record + function isCrossOrigin(request: NextRequest): boolean { const site = request.headers.get("sec-fetch-site") if (site) return site === "cross-site" @@ -27,9 +56,170 @@ function isCrossOrigin(request: NextRequest): boolean { return new URL(origin).host !== request.headers.get("host") } -export function proxy(request: NextRequest) { +function getClientIp(request: NextRequest) { + const forwardedFor = request.headers.get("x-forwarded-for") + if (forwardedFor) { + return forwardedFor.split(",")[0]?.trim() ?? "unknown" + } + + return ( + request.headers.get("cf-connecting-ip") ?? + request.headers.get("x-real-ip") ?? + "unknown" + ) +} + +function applyRateLimit( + request: NextRequest, + key: string, + policy: RateLimitPolicy, +) { + const now = Date.now() + const clientIp = getClientIp(request) + const compositeKey = `${clientIp}:${key}` + const current = rateLimitStore.get(compositeKey) + + if (!current || current.resetAt <= now) { + rateLimitStore.set(compositeKey, { + hits: 1, + resetAt: now + policy.windowMs, + }) + return null + } + + if (current.hits >= policy.limit) { + const retryAfter = Math.max(1, Math.ceil((current.resetAt - now) / 1000)) + return NextResponse.json( + { message: "Too many requests" }, + { + status: 429, + headers: { + "Retry-After": String(retryAfter), + }, + }, + ) + } + + current.hits += 1 + rateLimitStore.set(compositeKey, current) + return null +} + +function getAddressFromPath(pathname: string) { + const match = pathname.match( + /^\/api\/chains\/[^/]+\/addresses\/([^/]+)\/(balance|history|utxos)$/, + ) + return match?.[1] ?? null +} + +function getPolicyForPath(request: NextRequest) { + const pathname = request.nextUrl.pathname + + if (pathname.endsWith("/balance")) { + return { key: "balance", policy: RATE_LIMITS.balance } + } + + if (pathname.endsWith("/history")) { + return { key: "history", policy: RATE_LIMITS.history } + } + + if (pathname.endsWith("/utxos")) { + return { key: "utxos", policy: RATE_LIMITS.utxos } + } + + if (pathname.endsWith("/fee")) { + return { key: "fee", policy: RATE_LIMITS.fee } + } + + if (request.method === "GET" && isTxLookupPath(pathname)) { + return { key: "txLookup", policy: RATE_LIMITS.txLookup } + } + + return null +} + +export async function proxy(request: NextRequest) { if (isCrossOrigin(request)) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }) } + + const pathname = request.nextUrl.pathname + + const family = pathname.split("/")[3] ?? "" + + if (pathname === "/api/chains/auth/address") { + const rateLimitResponse = applyRateLimit( + request, + request.method === "POST" ? "addressChallengeVerify" : "addressChallenge", + request.method === "POST" + ? RATE_LIMITS.addressChallengeVerify + : RATE_LIMITS.addressChallenge, + ) + + return rateLimitResponse ?? NextResponse.next() + } + + const routePolicy = getPolicyForPath(request) + if (routePolicy) { + const rateLimitResponse = applyRateLimit( + request, + routePolicy.key, + routePolicy.policy, + ) + if (rateLimitResponse) { + return rateLimitResponse + } + } + + if (isProtectedAddressReadPath(pathname)) { + const address = getAddressFromPath(pathname) + if (!address) { + return NextResponse.json( + { message: "Missing wallet address" }, + { status: 400 }, + ) + } + + const cookieName = await getAddressSessionCookieName(family, address) + const isValid = await verifyPowSessionCookie( + request.cookies.get(cookieName)?.value, + family, + address, + ) + if (!isValid) { + return NextResponse.json( + { message: "Address session required" }, + { status: 401 }, + ) + } + } + + if ( + request.method === "GET" && + isTxLookupPath(pathname) && + request.nextUrl.searchParams.get("verbose") !== "true" + ) { + const address = request.nextUrl.searchParams.get("address") + if (!address) { + return NextResponse.json( + { message: "Wallet address is required for raw transaction reads" }, + { status: 400 }, + ) + } + + const cookieName = await getAddressSessionCookieName(family, address) + const isValid = await verifyPowSessionCookie( + request.cookies.get(cookieName)?.value, + family, + address, + ) + if (!isValid) { + return NextResponse.json( + { message: "Address session required" }, + { status: 401 }, + ) + } + } + return NextResponse.next() } diff --git a/vitest.config.ts b/vitest.config.ts index c43d5fd..2761886 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,22 +13,39 @@ export default defineConfig({ }, }, resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - "@/config": path.resolve(__dirname, "./config"), - "@/configServerless": path.resolve(__dirname, "./configServerless"), - translations: path.resolve(__dirname, "./src/translations"), - "ethers-utils": path.resolve(__dirname, "./src/ethers-utils"), - "ethers-utils/*": path.resolve(__dirname, "./src/ethers-utils"), - gooddollar: path.resolve(__dirname, "./src/gooddollar"), - "gooddollar/*": path.resolve(__dirname, "./src/gooddollar"), + // Array order matters: specific "@/config*" entries must precede the "@" catch-all. + alias: [ + { + find: "@/config", + replacement: path.resolve(__dirname, "./config"), + }, + { + find: "@/configServerless", + replacement: path.resolve(__dirname, "./configServerless"), + }, + { find: "@", replacement: path.resolve(__dirname, "./src") }, + { + find: "translations", + replacement: path.resolve(__dirname, "./src/translations"), + }, + { + find: "ethers-utils", + replacement: path.resolve(__dirname, "./src/ethers-utils"), + }, + { + find: "gooddollar", + replacement: path.resolve(__dirname, "./src/gooddollar"), + }, // Mock the AI Credits widget register module in tests since the package // is installed via local tarball and its side-effect registration is not // needed during unit tests. - "@goodwidget/ai-credits-widget/register": path.resolve( - __dirname, - "./src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts", - ), - }, + { + find: "@goodwidget/ai-credits-widget/register", + replacement: path.resolve( + __dirname, + "./src/widgets/fixtures/aiCreditsWidgetRegisterMock.ts", + ), + }, + ], }, }) From 300e162e39d55826d9d6b92634f7b54136bbda89 Mon Sep 17 00:00:00 2001 From: sirpy Date: Thu, 8 Oct 2026 11:00:36 +0300 Subject: [PATCH 2/2] feat: support multi-origin ownership login sessions --- src/app/api/chains/auth/address/route.ts | 26 ++++-- src/app/api/chains/chainsProxy.test.ts | 2 +- .../api/chains/clientAddressSession.test.ts | 8 +- src/app/api/chains/clientAddressSession.ts | 2 +- .../api/chains/clientOwnershipSignature.ts | 2 +- src/app/api/chains/ownershipProtocol.ts | 2 +- src/app/api/chains/ownershipRoutes.test.ts | 82 ++++++++++++++++--- src/app/api/chains/ownershipSession.test.ts | 2 +- src/app/api/chains/ownershipSignature.test.ts | 2 +- src/app/api/chains/ownershipSignature.ts | 2 +- src/app/api/chains/powSession.ts | 66 +++++++++++---- src/login/adapters/privatekey.ts | 2 +- 12 files changed, 154 insertions(+), 44 deletions(-) diff --git a/src/app/api/chains/auth/address/route.ts b/src/app/api/chains/auth/address/route.ts index 73d6e28..6aa8a91 100644 --- a/src/app/api/chains/auth/address/route.ts +++ b/src/app/api/chains/auth/address/route.ts @@ -5,9 +5,10 @@ import { OwnershipScope, ownershipMessage } from "../../ownershipProtocol" import { verifyOwnershipSignature } from "../../ownershipSignature" import { getAddressSessionCookieName, - getOwnershipAudience, + isOwnershipOrigin, issuePowChallenge, issuePowSession, + OwnershipOriginError, verifyPowSolution, } from "../../powSession" @@ -20,8 +21,9 @@ const VerifySchema = OwnershipScope.extend({ const noStore = { "Cache-Control": "private, no-store" } -function checkOrigin(request: NextRequest) { - return request.headers.get("origin") === getOwnershipAudience() +function getAllowedOrigin(request: NextRequest) { + const origin = request.headers.get("origin") + return origin && isOwnershipOrigin(origin) ? origin : null } export async function GET(request: NextRequest) { @@ -29,7 +31,11 @@ export async function GET(request: NextRequest) { const { family, address } = OwnershipScope.parse( Object.fromEntries(request.nextUrl.searchParams), ) - const challenge = await issuePowChallenge(family, address) + const challenge = await issuePowChallenge( + family, + address, + request.nextUrl.searchParams.get("origin"), + ) return NextResponse.json(challenge, { status: 200, headers: noStore }) } catch (error) { @@ -37,14 +43,21 @@ export async function GET(request: NextRequest) { { message: "Unable to issue ownership challenge", }, - { status: error instanceof z.ZodError ? 400 : 503, headers: noStore }, + { + status: + error instanceof z.ZodError || error instanceof OwnershipOriginError + ? 400 + : 503, + headers: noStore, + }, ) } } export async function POST(request: NextRequest) { try { - if (!checkOrigin(request)) + const origin = getAllowedOrigin(request) + if (!origin) return NextResponse.json( { message: "Forbidden" }, { status: 403, headers: noStore }, @@ -61,6 +74,7 @@ export async function POST(request: NextRequest) { parsedBody.address, parsedBody.challenge, parsedBody.nonce, + origin, ) if ( diff --git a/src/app/api/chains/chainsProxy.test.ts b/src/app/api/chains/chainsProxy.test.ts index ac8663d..6f4c9e2 100644 --- a/src/app/api/chains/chainsProxy.test.ts +++ b/src/app/api/chains/chainsProxy.test.ts @@ -13,7 +13,7 @@ import { const ORIGIN = "http://localhost:3000" const NOW = Date.UTC(2026, 9, 7, 12) const WINDOW = 60_000 -const SESSION_TTL = 30 * 60_000 +const SESSION_TTL = 7 * 24 * 60 * 60_000 // Ephemeral test-only secret: never persisted or sent to a provider. const secret = randomBytes(32).toString("hex") diff --git a/src/app/api/chains/clientAddressSession.test.ts b/src/app/api/chains/clientAddressSession.test.ts index 37477d1..bbbd0d4 100644 --- a/src/app/api/chains/clientAddressSession.test.ts +++ b/src/app/api/chains/clientAddressSession.test.ts @@ -12,7 +12,7 @@ const ORIGIN = "https://wallet.example" const NOW = Date.UTC(2026, 9, 5, 12) const ADDRESS = "0xAbCdEf0123456789AbCdEf0123456789AbCdEf01" const NORMALIZED_ADDRESS = ADDRESS.toLowerCase() -const SESSION_EXPIRY = NOW + 30 * 60_000 +const SESSION_EXPIRY = NOW + 7 * 24 * 60 * 60_000 const AUTH_URL = "/api/chains/auth/address" const signature = "test-ownership-signature" const sign = vi.mocked(signOwnershipMessage) @@ -147,13 +147,13 @@ describe("client ownership session integration", () => { expect(fetchMock).toHaveBeenCalledTimes(3) expect(fetchMock).toHaveBeenNthCalledWith( 1, - `${AUTH_URL}?family=${family}&address=${encodeURIComponent(normalized)}`, + `${AUTH_URL}?family=${family}&address=${encodeURIComponent(normalized)}&origin=${encodeURIComponent(ORIGIN)}`, { method: "GET", credentials: "same-origin", cache: "no-store" }, ) expect(sign).toHaveBeenCalledExactlyOnceWith( family, normalized, - `GoodWallet API ownership v2\nAuthorize wallet-scoped blockchain reads only.\nChallenge: ${challenge.challenge}`, + `GoodWallet wallet login v2\nUse this signature to log in and authorize wallet-scoped blockchain reads.\nChallenge: ${challenge.challenge}`, ) expect(digest).toHaveBeenCalledExactlyOnceWith( "SHA-256", @@ -314,7 +314,7 @@ describe("client ownership session integration", () => { expect(fetchMock).toHaveBeenCalledTimes(4) expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ readUrl(), - `${AUTH_URL}?family=EVM&address=${NORMALIZED_ADDRESS}`, + `${AUTH_URL}?family=EVM&address=${NORMALIZED_ADDRESS}&origin=${encodeURIComponent(ORIGIN)}`, AUTH_URL, readUrl(), ]) diff --git a/src/app/api/chains/clientAddressSession.ts b/src/app/api/chains/clientAddressSession.ts index da94b52..14982e2 100644 --- a/src/app/api/chains/clientAddressSession.ts +++ b/src/app/api/chains/clientAddressSession.ts @@ -97,7 +97,7 @@ const solveProofOfWork = async ( const createAddressSession = async (family: ChainFamily, address: string) => { const normalizedAddress = normalizeOwnershipAddress(family, address) const challengeResponse = await fetch( - `/api/chains/auth/address?family=${family}&address=${encodeURIComponent(normalizedAddress)}`, + `/api/chains/auth/address?family=${family}&address=${encodeURIComponent(normalizedAddress)}&origin=${encodeURIComponent(window.location.origin)}`, { method: "GET", credentials: "same-origin", diff --git a/src/app/api/chains/clientOwnershipSignature.ts b/src/app/api/chains/clientOwnershipSignature.ts index fc1656d..01e3236 100644 --- a/src/app/api/chains/clientOwnershipSignature.ts +++ b/src/app/api/chains/clientOwnershipSignature.ts @@ -6,7 +6,7 @@ export async function signOwnershipMessage( address: string, message: string, ): Promise<{ signature: string; publicKey?: string }> { - if (!message.startsWith("GoodWallet API ownership v2\n")) { + if (!message.startsWith("GoodWallet wallet login v2\n")) { throw new Error("Invalid ownership message domain") } diff --git a/src/app/api/chains/ownershipProtocol.ts b/src/app/api/chains/ownershipProtocol.ts index 2a91a6b..9d3929b 100644 --- a/src/app/api/chains/ownershipProtocol.ts +++ b/src/app/api/chains/ownershipProtocol.ts @@ -16,4 +16,4 @@ export const normalizeOwnershipAddress = (family: string, address: string) => // Both sides construct exactly this message; never sign arbitrary server text. export const ownershipMessage = (challenge: string) => - `GoodWallet API ownership v2\nAuthorize wallet-scoped blockchain reads only.\nChallenge: ${challenge}` + `GoodWallet wallet login v2\nUse this signature to log in and authorize wallet-scoped blockchain reads.\nChallenge: ${challenge}` diff --git a/src/app/api/chains/ownershipRoutes.test.ts b/src/app/api/chains/ownershipRoutes.test.ts index d4753fc..d84f066 100644 --- a/src/app/api/chains/ownershipRoutes.test.ts +++ b/src/app/api/chains/ownershipRoutes.test.ts @@ -11,9 +11,11 @@ import { ownershipMessage, } from "./ownershipProtocol" import { - getOwnershipAudience, + getOwnershipOrigins, isProtectedAddressReadPath, isTxLookupPath, + OwnershipOriginError, + resolveOwnershipAudience, } from "./powSession" const ORIGIN = "http://localhost:3000" @@ -72,7 +74,8 @@ describe("ownership protocol helpers", () => { it("builds a domain-separated message ending in the exact challenge", () => { const message = ownershipMessage("payload.mac") - expect(message.startsWith("GoodWallet API ownership v2\n")).toBe(true) + expect(message.startsWith("GoodWallet wallet login v2\n")).toBe(true) + expect(message).toContain("Use this signature to log in") expect(message.endsWith("Challenge: payload.mac")).toBe(true) }) @@ -129,15 +132,26 @@ describe("path matchers", () => { }) }) -describe("getOwnershipAudience", () => { +describe("ownership origins", () => { it("returns the configured canonical origin", () => { vi.stubEnv("API_OWNERSHIP_ORIGIN", "https://wallet.example") - expect(getOwnershipAudience()).toBe("https://wallet.example") + expect(getOwnershipOrigins()).toEqual(["https://wallet.example"]) }) it("defaults to localhost outside production", () => { vi.stubEnv("API_OWNERSHIP_ORIGIN", undefined) - expect(getOwnershipAudience()).toBe("http://localhost:3000") + expect(getOwnershipOrigins()).toEqual(["http://localhost:3000"]) + }) + + it("supports comma-separated canonical origins", () => { + vi.stubEnv( + "API_OWNERSHIP_ORIGIN", + " https://wallet.example,https://preview.example ,, https://wallet.example ", + ) + expect(getOwnershipOrigins()).toEqual([ + "https://wallet.example", + "https://preview.example", + ]) }) it.each([ @@ -146,25 +160,44 @@ describe("getOwnershipAudience", () => { "https://wallet.example?x=1", "not a url", "", + "https://wallet.example,http://preview.example/", ])("rejects non-canonical origin %j", (value) => { vi.stubEnv("API_OWNERSHIP_ORIGIN", value) - expect(() => getOwnershipAudience()).toThrow() + expect(() => getOwnershipOrigins()).toThrow() }) it("requires an explicit HTTPS origin in production", () => { vi.stubEnv("NODE_ENV", "production") vi.stubEnv("API_OWNERSHIP_ORIGIN", undefined) - expect(() => getOwnershipAudience()).toThrow() + expect(() => getOwnershipOrigins()).toThrow() vi.stubEnv("API_OWNERSHIP_ORIGIN", "http://wallet.example") - expect(() => getOwnershipAudience()).toThrow() + expect(() => getOwnershipOrigins()).toThrow() vi.stubEnv("API_OWNERSHIP_ORIGIN", "https://wallet.example") - expect(getOwnershipAudience()).toBe("https://wallet.example") + expect(getOwnershipOrigins()).toEqual(["https://wallet.example"]) + }) + + it("requires explicit origin selection when multiple origins are configured", () => { + vi.stubEnv( + "API_OWNERSHIP_ORIGIN", + "https://wallet.example,https://preview.example", + ) + expect(() => resolveOwnershipAudience()).toThrow(OwnershipOriginError) + expect(resolveOwnershipAudience("https://preview.example")).toBe( + "https://preview.example", + ) + expect(() => resolveOwnershipAudience("https://other.example")).toThrow( + OwnershipOriginError, + ) }) }) describe("GET challenge endpoint", () => { it("issues a no-store challenge bound to the normalized EVM scope", async () => { - const response = await getChallenge({ family: "EVM", address: "0xAbCdEf" }) + const response = await getChallenge({ + family: "EVM", + address: "0xAbCdEf", + origin: ORIGIN, + }) expect(response.status).toBe(200) expect(response.headers.get("cache-control")).toBe("private, no-store") const body = await response.json() @@ -182,7 +215,11 @@ describe("GET challenge endpoint", () => { }) it("preserves the case of non-EVM addresses", async () => { - const response = await getChallenge({ family: "SOLANA", address: "AbCdEf" }) + const response = await getChallenge({ + family: "SOLANA", + address: "AbCdEf", + origin: ORIGIN, + }) expect( decodeToken((await response.json()).challenge).payload, ).toMatchObject({ family: "SOLANA", address: "AbCdEf" }) @@ -209,11 +246,32 @@ describe("GET challenge endpoint", () => { ["non-canonical origin", "API_OWNERSHIP_ORIGIN", "http://localhost:3000/"], ])("returns 503 for %s", async (_label, name, value) => { vi.stubEnv(name, value) - const response = await getChallenge({ family: "EVM", address: "0xabc" }) + const response = await getChallenge({ + family: "EVM", + address: "0xabc", + origin: ORIGIN, + }) expect(response.status).toBe(503) expect(response.headers.get("cache-control")).toBe("private, no-store") expect(response.headers.get("set-cookie")).toBeNull() }) + + it("supports multiple configured origins when origin is explicitly requested", async () => { + vi.stubEnv( + "API_OWNERSHIP_ORIGIN", + "https://wallet.example,https://preview.example", + ) + const response = await getChallenge({ + family: "EVM", + address: "0xabc", + origin: "https://preview.example", + }) + expect(response.status).toBe(200) + const body = await response.json() + expect(decodeToken(body.challenge).payload.audience).toBe( + "https://preview.example", + ) + }) }) describe("POST verification input handling", () => { diff --git a/src/app/api/chains/ownershipSession.test.ts b/src/app/api/chains/ownershipSession.test.ts index 9d5e209..96c4cbb 100644 --- a/src/app/api/chains/ownershipSession.test.ts +++ b/src/app/api/chains/ownershipSession.test.ts @@ -28,7 +28,7 @@ import { const ORIGIN = "http://localhost:3000" const ISSUED_AT = Date.UTC(2026, 9, 5, 12) const CHALLENGE_TTL = 5 * 60_000 -const SESSION_TTL = 30 * 60_000 +const SESSION_TTL = 7 * 24 * 60 * 60_000 // Ephemeral test-only credentials: never persisted or sent to a provider. const secret = randomBytes(32).toString("hex") const wallet = Wallet.createRandom() diff --git a/src/app/api/chains/ownershipSignature.test.ts b/src/app/api/chains/ownershipSignature.test.ts index 5dd3cba..c5ab287 100644 --- a/src/app/api/chains/ownershipSignature.test.ts +++ b/src/app/api/chains/ownershipSignature.test.ts @@ -24,7 +24,7 @@ vi.mock("@/login/context/SessionContext/storage", () => ({ })) const messageFor = (family: ChainFamily, address: string) => - `GoodWallet API ownership v2\n${JSON.stringify({ + `GoodWallet wallet login v2\n${JSON.stringify({ family, address, nonce: randomBytes(32).toString("hex"), diff --git a/src/app/api/chains/ownershipSignature.ts b/src/app/api/chains/ownershipSignature.ts index 156481b..c9da5e5 100644 --- a/src/app/api/chains/ownershipSignature.ts +++ b/src/app/api/chains/ownershipSignature.ts @@ -17,7 +17,7 @@ export async function verifyOwnershipSignature( publicKey?: string, ): Promise { try { - if (!message.startsWith("GoodWallet API ownership v2\n")) return false + if (!message.startsWith("GoodWallet wallet login v2\n")) return false switch (family) { case "EVM": { diff --git a/src/app/api/chains/powSession.ts b/src/app/api/chains/powSession.ts index 64ae221..2ee440b 100644 --- a/src/app/api/chains/powSession.ts +++ b/src/app/api/chains/powSession.ts @@ -5,7 +5,7 @@ import type { ChainFamily } from "@/chain/types" import { normalizeOwnershipAddress, OwnershipScope } from "./ownershipProtocol" const POW_CHALLENGE_TTL_MS = 5 * 60 * 1000 -const POW_SESSION_TTL_MS = 30 * 60 * 1000 +const POW_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 const POW_DIFFICULTY_PREFIX = "0000" const POW_COOKIE_PREFIX = "gw_owner_v2_" @@ -25,20 +25,52 @@ type PowChallengePayload = { type PowSessionPayload = PowChallengePayload -export const getOwnershipAudience = () => { - const value = +export class OwnershipOriginError extends Error {} + +export const getOwnershipOrigins = () => { + const rawValue = process.env.API_OWNERSHIP_ORIGIN ?? (process.env.NODE_ENV !== "production" ? "http://localhost:3000" : "") - const url = new URL(value) - if ( - url.origin !== value || - (process.env.NODE_ENV === "production" && url.protocol !== "https:") - ) { - throw new Error( - "Configure API_OWNERSHIP_ORIGIN as the canonical app origin", - ) + const values = rawValue + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + if (values.length === 0) { + throw new Error("Configure API_OWNERSHIP_ORIGIN as canonical app origin(s)") } - return value + + for (const value of values) { + const url = new URL(value) + if ( + url.origin !== value || + (process.env.NODE_ENV === "production" && url.protocol !== "https:") + ) { + throw new Error( + "Configure API_OWNERSHIP_ORIGIN as canonical app origin(s)", + ) + } + } + + return [...new Set(values)] +} + +export const isOwnershipOrigin = (origin: string | null | undefined) => + !!origin && getOwnershipOrigins().includes(origin) + +export const resolveOwnershipAudience = (requestedOrigin?: string | null) => { + const origins = getOwnershipOrigins() + if (requestedOrigin) { + if (!origins.includes(requestedOrigin)) { + throw new OwnershipOriginError( + "Requested ownership origin is not allowed", + ) + } + return requestedOrigin + } + if (origins.length !== 1) { + throw new OwnershipOriginError("Requested ownership origin is required") + } + return origins[0] } const encoder = new TextEncoder() @@ -175,7 +207,7 @@ const parseSignedToken = async ( parsed.expiresAt !== scope.data.issuedAt + ttl || scope.data.issuedAt > Date.now() || parsed.expiresAt <= Date.now() || - scope.data.audience !== getOwnershipAudience() + !getOwnershipOrigins().includes(scope.data.audience) ) { return null } @@ -189,8 +221,10 @@ const parseSignedToken = async ( export const issuePowChallenge = async ( family: ChainFamily, address: string, + requestedOrigin?: string | null, ) => { OwnershipScope.parse({ family, address }) + const audience = resolveOwnershipAudience(requestedOrigin) const normalizedAddress = normalizeOwnershipAddress(family, address) const issuedAt = Date.now() const challenge = await createSignedToken( @@ -199,7 +233,7 @@ export const issuePowChallenge = async ( address: normalizedAddress, difficultyPrefix: POW_DIFFICULTY_PREFIX, family, - audience: getOwnershipAudience(), + audience, issuedAt, } satisfies PowChallengePayload, POW_CHALLENGE_TTL_MS, @@ -218,6 +252,7 @@ export const verifyPowSolution = async ( address: string, challengeToken: string, nonce: number, + audience?: string, ) => { const challenge = await parseSignedToken< "ownership-challenge-v2", @@ -227,6 +262,9 @@ export const verifyPowSolution = async ( if (!challenge || !Number.isSafeInteger(nonce) || nonce < 0) { return false } + if (audience !== undefined && challenge.payload.audience !== audience) { + return false + } const normalizedAddress = normalizeOwnershipAddress(family, address) if ( diff --git a/src/login/adapters/privatekey.ts b/src/login/adapters/privatekey.ts index 0e2f000..69222e4 100644 --- a/src/login/adapters/privatekey.ts +++ b/src/login/adapters/privatekey.ts @@ -89,7 +89,7 @@ const bitcoinSigner = async (keyPair: ECPairInterface, psbt: Psbt) => { } const authMessageBytes = (message: string) => { - if (!message.startsWith("GoodWallet API ownership v2\n")) { + if (!message.startsWith("GoodWallet wallet login v2\n")) { throw new Error("Invalid ownership message domain") } return toUtf8Bytes(message)