diff --git a/.changeset/quiet-tigers-sign.md b/.changeset/quiet-tigers-sign.md new file mode 100644 index 0000000000..31842cc623 --- /dev/null +++ b/.changeset/quiet-tigers-sign.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add panda signature diff --git a/server/api/card.ts b/server/api/card.ts index b4ba346c65..4c94d51e25 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -5,6 +5,9 @@ import { Hono } from "hono"; import { describeRoute } from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; import { + any, + array, + check, integer, literal, maxValue, @@ -13,6 +16,7 @@ import { nullable, number, object, + optional, parse, picklist, pipe, @@ -21,20 +25,37 @@ import { transform, union, uuid, + variant, + type InferInput, type InferOutput, } from "valibot"; +import { createSiweMessage, parseSiweMessage, verifySiweMessage } from "viem/siwe"; +import domain from "@exactly/common/domain"; +import chain from "@exactly/common/generated/chain"; import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS"; import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; -import { Address } from "@exactly/common/validation"; +import { Address, Base64URL, Hex } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; import t from "../i18n"; import auth from "../middleware/auth"; import { sendPushNotification } from "../utils/onesignal"; -import { autoCredit, createCard, getCard, getPIN, getSecrets, getUser, setPIN, updateCard } from "../utils/panda"; +import { + autoCredit, + createCard, + getCard, + getNonce, + getPIN, + getSecrets, + getUser, + setPIN, + updateCard, + verify, +} from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { getAccount } from "../utils/persona"; +import publicClient from "../utils/publicClient"; import { customer } from "../utils/sardine"; import { track } from "../utils/segment"; import ServiceError from "../utils/ServiceError"; @@ -49,13 +70,13 @@ function createMutex(credentialId: string) { const CardResponse = object({ displayName: pipe(string(), metadata({ examples: ["John Doe"] })), - encryptedPan: object({ data: string(), iv: string() }), - encryptedCvc: object({ data: string(), iv: string() }), + encryptedPan: optional(object({ data: string(), iv: string() })), + encryptedCvc: optional(object({ data: string(), iv: string() })), expirationMonth: pipe(string(), metadata({ examples: ["12"] })), expirationYear: pipe(string(), metadata({ examples: ["2025"] })), lastFour: pipe(string(), metadata({ examples: ["1234"] })), mode: pipe(number(), metadata({ examples: [0] })), - pin: nullable(object({ data: string(), iv: string() })), + pin: optional(nullable(object({ data: string(), iv: string() }))), provider: pipe(literal("panda"), metadata({ examples: ["panda"] })), status: pipe(picklist(["ACTIVE", "FROZEN"]), metadata({ examples: ["ACTIVE", "FROZEN"] })), limit: object({ @@ -70,6 +91,7 @@ const CardResponse = object({ ]), }), productId: pipe(string(), metadata({ examples: ["402"] })), + challenge: optional(pipe(string(), metadata({ examples: ["1a2b3c"] }))), }); const CreatedCardResponse = object({ @@ -91,6 +113,27 @@ const UpdateCard = union([ strictObject({ data: string(), iv: string(), sessionId: string() }), transform((patch) => ({ ...patch, type: "pin" as const })), ), + pipe( + variant("method", [ + object({ method: literal("siwe"), message: string(), signature: Hex }), + object({ + method: literal("webauthn"), + assertion: object({ + id: Base64URL, + rawId: Base64URL, + response: object({ + clientDataJSON: Base64URL, + authenticatorData: Base64URL, + signature: Base64URL, + userHandle: optional(Base64URL), + }), + clientExtensionResults: any(), + type: literal("public-key"), + }), + }), + ]), + transform((signature) => ({ ...signature, type: "signature" as const })), + ), ]); const UpdatedCardResponse = union([ @@ -99,23 +142,56 @@ const UpdatedCardResponse = union([ object({ status: pipe(picklist(["ACTIVE", "DELETED", "FROZEN"]), metadata({ examples: ["ACTIVE", "DELETED", "FROZEN"] })), }), + object({ verification: literal("OK") }), ]); +const Scopes = picklist(["siwe", "webauthn"]); + export default new Hono() .get( "/", - vValidator("header", object({ sessionid: string() }), validatorHook({ code: "bad session id", status: 400 })), + vValidator( + "header", + object({ sessionid: optional(string()) }), + validatorHook({ code: "bad session id", status: 400 }), + ), + vValidator( + "query", + optional( + object({ + scope: optional( + union([ + Scopes, + pipe( + array(Scopes), + check((scopes) => !(scopes.includes("siwe") && scopes.includes("webauthn")), "bad scope"), + ), + ]), + ), + }), + {}, + ), + validatorHook(), + ), auth(), describeRoute({ summary: "Get card information", description: ` -Retrieve the card profile and encrypted card data for an authenticated user. +Retrieve the card profile, encrypted card data, and (optionally) a signature challenge for an authenticated user. + +The \`sessionid\` header and the \`scope\` query parameter are independent and may be used together or separately: +- Provide \`sessionid\` to receive \`encryptedPan\`, \`encryptedCvc\`, and \`pin\`. Without it, only the card profile is returned. +- Provide \`scope=siwe\` or \`scope=webauthn\` to receive a \`challenge\` to be signed and submitted via \`PATCH /\`. \`siwe\` and \`webauthn\` are mutually exclusive within a single request. **Retrieving encrypted card details** 1. **Generate a session ID**: Encrypt a 32‑character hexadecimal secret (no spaces/dashes) with the provided public RSA key using RSA‑OAEP. 2. **Send the request**: Include the encrypted secret in the header \`sessionid\` when calling this endpoint. 3. **Decrypt the response**: Use the original secret to decrypt \`encryptedPan\`, \`encryptedCvc\`, and \`pin\` (each returned as \`{ data, iv }\`). +**Requesting a signature challenge** + +Pass \`scope=siwe\` to receive a fully formed Sign-In with Ethereum message in \`challenge\`, or \`scope=webauthn\` to receive the plain authorization statement to be signed by a passkey. The signed result is submitted to \`PATCH /\` to bind the card to the user. + **Step 1: Generate a sessionid and secret** \`\`\`typescript @@ -197,6 +273,10 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str }, }), async (c) => { + const { scope } = c.req.valid("query"); + function include(type: InferInput) { + return Array.isArray(scope) ? scope.includes(type) : scope === type; + } const { credentialId } = c.req.valid("cookie"); const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, credentialId), @@ -212,19 +292,20 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str const account = parse(Address, credential.account); setUser({ id: account }); if (!credential.pandaId) return c.json({ code: "no panda" }, 403); + const sessionid = c.req.valid("header").sessionid; if (credential.cards.length > 0 && credential.cards[0]) { const { id, lastFour, status, mode, productId } = credential.cards[0]; if (status === "DELETED") throw new Error("card deleted"); - const [{ expirationMonth, expirationYear, limit }, pan, user, pin] = await Promise.all([ + const [{ expirationMonth, expirationYear, limit }, pan, user, pin, challenge] = await Promise.all([ getCard(id), - getSecrets(id, c.req.valid("header").sessionid), + sessionid && getSecrets(id, sessionid), getUser(credential.pandaId).catch((error: unknown) => { const issue = noUser(error); if (!issue) throw error; const shouldCapture = issue.error.status === 404 || status === "ACTIVE"; if (shouldCapture) { - withScope((scope) => { - scope.addEventProcessor((event) => { + withScope((s) => { + s.addEventProcessor((event) => { if (event.exception?.values?.[0]) event.exception.values[0].type = issue.type; return event; }); @@ -244,13 +325,32 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str } return null; }), - getPIN(id, c.req.valid("header").sessionid), + sessionid && getPIN(id, sessionid), + (async () => { + if (include("siwe")) { + if (!credential.pandaId) return; + return getNonce(credential.pandaId).then(({ nonce }) => + createSiweMessage({ + domain, + address: parse(Address, credentialId), + statement: `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce, + }), + ); + } else if (include("webauthn")) { + return `I authorize the account ${account} to be linked with the card ending in ${lastFour} for my user (${credential.pandaId})`; + } + })(), ]); if (!user) return c.json({ code: "no panda" }, 403); + return c.json( { - ...pan, - ...pin, + ...(pan && { ...pan }), + ...(pin && { ...pin }), displayName: `${user.firstName} ${user.lastName}`, expirationMonth, expirationYear, @@ -260,6 +360,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str status, limit, productId, + ...(challenge && { challenge }), } satisfies InferOutput, 200, ); @@ -434,7 +535,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str validateResponse: true, security: [{ credentialAuth: [] }], description: ` -Update the card status, PIN, or installments mode. +Update the card status, installments mode, or PIN, or submit a signed challenge to bind the card to the authenticated user. **Updating the card status** @@ -447,6 +548,13 @@ Update the card status, PIN, or installments mode. 1. **Encrypt the PIN**: Format and encrypt the PIN using the session secret. 2. **Submit the update**: Send the encrypted PIN with the \`sessionId\` to update the card. +**Submitting a signature** + +Use \`method: "siwe"\` or \`method: "webauthn"\` to verify the \`challenge\` previously obtained from \`GET /?scope=...\`. On success the response is \`{ "verification": "OK" }\`; an invalid or unverifiable signature returns \`{ "code": "bad signature" }\` with HTTP 400. + +- **siwe**: Sign the SIWE message with the account's wallet and submit \`{ method: "siwe", message, signature }\`. The server checks the message's \`statement\`, \`domain\`, and \`chainId\` against the expected values, validates the signature on-chain, and forwards it to the provider. +- **webauthn**: Sign the statement with a passkey and submit \`{ method: "webauthn", assertion }\`, where \`assertion\` is the WebAuthn assertion (\`id\`, \`rawId\`, \`response\`, \`clientExtensionResults\`, \`type\`). + **PIN Requirements** - Length must be between 4–12 digits. - No simple sequences (e.g., 1234, 0000) @@ -494,6 +602,7 @@ async function encryptPIN(pin: string) { schema: resolver( union([ object({ code: literal("bad request") }), + object({ code: literal("bad signature") }), object({ code: literal("already set"), mode: number() }), object({ code: literal("already set"), status: picklist(["ACTIVE", "DELETED", "FROZEN"]) }), ]), @@ -502,6 +611,12 @@ async function encryptPIN(pin: string) { }, }, }, + 403: { + description: "Forbidden", + content: { + "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, + }, + }, 404: { description: "Not found", content: { @@ -518,10 +633,21 @@ async function encryptPIN(pin: string) { return mutex .runExclusive(async () => { const credential = await database.query.credentials.findFirst({ - columns: { account: true, source: true }, + columns: { + account: true, + counter: true, + factory: true, + pandaId: true, + publicKey: true, + source: true, + transports: true, + }, where: eq(credentials.id, credentialId), with: { - cards: { columns: { id: true, mode: true, status: true }, where: ne(cards.status, "DELETED") }, + cards: { + columns: { id: true, mode: true, status: true, lastFour: true }, + where: ne(cards.status, "DELETED"), + }, }, }); if (!credential) return c.json({ code: "no credential" }, 500); @@ -561,6 +687,66 @@ async function encryptPIN(pin: string) { await setPIN(card.id, sessionId, { data, iv }); return c.json({ data, iv } satisfies InferOutput, 200); } + case "signature": { + if (!credential.pandaId) return c.json({ code: "no panda" }, 403); + const statement = `I authorize the account ${account} to be linked with the card ending in ${card.lastFour} for my user (${credential.pandaId})`; + switch (patch.method) { + case "siwe": { + const verified = await Promise.resolve() + .then(() => parseSiweMessage(patch.message)) + .then((m) => { + if (m.statement !== statement || m.chainId !== chain.id || m.domain !== domain) { + return false; + } + return verifySiweMessage(publicClient, { + address: parse(Address, credentialId), + domain, + message: patch.message, + signature: patch.signature, + }); + }) + .catch((error: unknown) => { + captureException(error, { level: "error" }); + return false; + }); + if (!verified) return c.json({ code: "bad signature" }, 400); + try { + await verify(credential.pandaId, { + message: patch.message, + signature: patch.signature, + authType: "siwe", + }); + } catch (error) { + if (error instanceof ServiceError && error.status === 401) { + return c.json({ code: "bad signature" }, 400); + } + throw error; + } + return c.json({ verification: "OK" } satisfies InferOutput, 200); + } + + case "webauthn": + try { + await verify(credential.pandaId, { + authType: "webauthn", + credential: { + publicKey: { type: "Buffer", data: [...credential.publicKey] }, + transports: credential.transports, + counter: credential.counter, + }, + assertion: patch.assertion, + factory: credential.factory, + statement, + }); + } catch (error) { + if (error instanceof ServiceError && error.status === 401) { + return c.json({ code: "bad signature" }, 400); + } + throw error; + } + return c.json({ verification: "OK" } satisfies InferOutput, 200); + } + } } }) .finally(() => { diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 6c79d185da..c28bec8239 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -47,14 +47,23 @@ import { } from "@exactly/common/generated/chain"; import MIN_BORROW_INTERVAL from "@exactly/common/MIN_BORROW_INTERVAL"; import revertReason from "@exactly/common/revertReason"; -import { Address, type Hash, type Hex } from "@exactly/common/validation"; +import { Address, Hex, type Hash } from "@exactly/common/validation"; import { MATURITY_INTERVAL, splitInstallments } from "@exactly/lib"; import database, { cards, credentials, transactions } from "../database/index"; import t, { f } from "../i18n"; import keeper from "../utils/keeper"; import { sendPushNotification } from "../utils/onesignal"; -import { collectors, createMutex, getMutex, getUser, headerValidator, signIssuerOp, updateUser } from "../utils/panda"; +import { + collectors, + createMutex, + getMutex, + getUser, + headerValidator, + signIssuerOp, + updateUser, + verifyPandaSignature, +} from "../utils/panda"; import publicClient from "../utils/publicClient"; import revertFingerprint from "../utils/revertFingerprint"; import risk, { feedback } from "../utils/sardine"; @@ -87,6 +96,8 @@ const BaseTransaction = v.object({ authorizedAmount: v.nullish(v.number()), authorizationMethod: v.optional(v.string()), userId: v.string(), + signature: v.optional(Hex), + timestamp: v.optional(v.number()), }), }); @@ -510,6 +521,32 @@ export default new Hono().post( Math.floor(new Date(payload.body.spend.authorizedAt).getTime() / 1000) - Number(BigInt(`0x${payload.id.replaceAll(/[^0-9a-f]/g, "")}`) % 3600n); const signature = await signIssuerOp({ account, amount: -refundAmount, timestamp }); // TODO replace with payload signature + if (payload.body.spend.signature) { + await startSpan( + { + name: "panda.signature", + op: "panda.signature", + attributes: { + "signature.account": account, + "signature.amount": String(-refundAmount), + "signature.timestamp": String(payload.body.spend.timestamp ?? 0), + }, + }, + (span) => { + if (!payload.body.spend.signature) throw new Error("signature not found"); + if (!payload.body.spend.timestamp) throw new Error("timestamp not found"); + return verifyPandaSignature({ + account, + amount: -refundAmount, + timestamp: payload.body.spend.timestamp, + signature: payload.body.spend.signature, + }).then((valid) => { + span.setAttribute("signature.valid", valid); + if (!valid) captureException(new Error("invalid panda signature"), { level: "error" }); + }); + }, + ).catch((error: unknown) => captureException(error, { level: "error" })); + } try { await keeper.exaSend( { name: "exa.refund", op: "exa.refund", attributes: { account } }, @@ -1097,6 +1134,33 @@ async function prepareCollection( (payload.body.spend.authorizedAt ? new Date(payload.body.spend.authorizedAt) : new Date()).getTime() / 1000, // TODO remove fallback ); const signature = await signIssuerOp({ account, amount, timestamp }); // TODO replace with payload signature + if (payload.body.spend.signature) { + await startSpan( + { + name: "panda.signature", + op: "panda.signature", + attributes: { + "signature.account": account, + "signature.amount": String(amount), + "signature.timestamp": String(payload.body.spend.timestamp ?? 0), + }, + }, + (span) => { + if (!payload.body.spend.signature) throw new Error("signature not found"); + if (!payload.body.spend.timestamp) throw new Error("timestamp not found"); + return verifyPandaSignature({ + account, + amount, + timestamp: payload.body.spend.timestamp, + signature: payload.body.spend.signature, + }).then((valid) => { + span.setAttribute("signature.valid", valid); + if (!valid) captureException(new Error("invalid panda signature"), { level: "error" }); + }); + }, + ).catch((error: unknown) => captureException(error, { level: "error" })); + } + if (card.mode === 0) { return { functionName: "collectDebit", args: [amount, BigInt(timestamp), signature] } as const; } diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 7f5c244905..75455aa045 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -9,12 +9,14 @@ import { eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { testClient } from "hono/testing"; import { parse } from "valibot"; -import { hexToBigInt, padHex, parseEther, zeroHash } from "viem"; -import { privateKeyToAddress } from "viem/accounts"; +import { checksumAddress, hexToBigInt, padHex, parseEther, zeroHash } from "viem"; +import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts"; +import { createSiweMessage, parseSiweMessage } from "viem/siwe"; import { afterEach, beforeAll, describe, expect, inject, it, vi } from "vitest"; import deriveAddress from "@exactly/common/deriveAddress"; -import { exaAccountFactoryAbi, exaPluginAbi } from "@exactly/common/generated/chain"; +import domain from "@exactly/common/domain"; +import chain, { exaAccountFactoryAbi, exaPluginAbi } from "@exactly/common/generated/chain"; import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda"; import { Address } from "@exactly/common/validation"; @@ -797,6 +799,639 @@ describe("authenticated", () => { expect(card?.status).toBe("DELETED"); }); + describe("signature", () => { + describe("siwe", () => { + it("returns a siwe message", async () => { + const credentialId = privateKeyToAddress(padHex("0xcafe")); + const account = padHex("0xbbb1", { size: 20 }); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId: "siwe-ok-panda", + }); + await database.insert(cards).values({ id: "siwe-ok-card", credentialId, lastFour: "7777" }); + const nonceSpy = vi.spyOn(panda, "getNonce").mockResolvedValueOnce({ nonce: "Db2ItfTPLuZ2dV0ZQ" }); + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ ...cardTemplate, last4: "7777" }); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + + const response = await appClient.index.$get( + { header: {}, query: { scope: "siwe" } }, + { headers: { "test-credential-id": credentialId } }, + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { challenge: string }; + expect(body).toStrictEqual({ + displayName: `${userTemplate.firstName} ${userTemplate.lastName}`, + expirationMonth: cardTemplate.expirationMonth, + expirationYear: cardTemplate.expirationYear, + lastFour: "7777", + mode: 0, + provider: "panda", + status: "ACTIVE", + limit: cardTemplate.limit, + productId: PLATINUM_PRODUCT_ID, + challenge: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + expect(parseSiweMessage(body.challenge)).toStrictEqual({ + domain, + address: credentialId, + statement: `I authorize the account ${checksumAddress(account)} to be linked with the card ending in 7777 for my user (siwe-ok-panda)`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce: "Db2ItfTPLuZ2dV0ZQ", + issuedAt: expect.any(Date), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + expect(nonceSpy).toHaveBeenCalledWith("siwe-ok-panda"); + }); + + it("returns 403 on message when credential has no panda id", async () => { + const credentialId = privateKeyToAddress(padHex("0xdeadbeef")); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xbbb2", { size: 20 }), + factory: inject("ExaAccountFactory"), + }); + await database.insert(cards).values({ id: "siwe-no-panda-card", credentialId, lastFour: "8888" }); + const nonceSpy = vi.spyOn(panda, "getNonce").mockResolvedValue({ nonce: "unreachable" }); + + const response = await appClient.index.$get( + { header: {}, query: { scope: "siwe" } }, + { headers: { "test-credential-id": credentialId } }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + expect(nonceSpy).not.toHaveBeenCalled(); + }); + + it("propagates getNonce failure", async () => { + const credentialId = privateKeyToAddress(padHex("0xfeed")); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xbbb3", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "siwe-nonce-fail-panda", + }); + await database.insert(cards).values({ id: "siwe-nonce-fail-card", credentialId, lastFour: "6666" }); + const nonceSpy = vi.spyOn(panda, "getNonce").mockRejectedValueOnce(new Error("nonce unreachable")); + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ ...cardTemplate, last4: "6666" }); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + + const response = await appClient.index.$get( + { header: {}, query: { scope: "siwe" } }, + { headers: { "test-credential-id": credentialId } }, + ); + + expect(response.status).toBe(500); + expect(nonceSpy).toHaveBeenCalledWith("siwe-nonce-fail-panda"); + expect(captureException).not.toHaveBeenCalled(); + }); + + it("verifies the signed message", async () => { + const owner = privateKeyToAccount(padHex("0xc0d1")); + const credentialId = owner.address; + const account = checksumAddress(padHex("0xbbc1", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId: "siwe-verify-panda", + }); + await database.insert(cards).values({ id: "siwe-verify-card", credentialId, lastFour: "9999" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValueOnce({}); + const message = createSiweMessage({ + domain, + address: credentialId, + statement: `I authorize the account ${account} to be linked with the card ending in 9999 for my user (siwe-verify-panda)`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + const signature = await owner.signMessage({ message }); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "siwe", message, signature }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ verification: "OK" }); + expect(verifySpy).toHaveBeenCalledWith("siwe-verify-panda", { message, signature, authType: "siwe" }); + }); + + it("rejects siwe message with non-canonical statement", async () => { + const owner = privateKeyToAccount(padHex("0xc0d3")); + const credentialId = owner.address; + const account = checksumAddress(padHex("0xbbc4", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId: "siwe-any-statement-panda", + }); + await database.insert(cards).values({ id: "siwe-any-statement-card", credentialId, lastFour: "2020" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValueOnce({}); + const message = createSiweMessage({ + domain, + address: credentialId, + statement: "arbitrary statement that does not match the canonical authorization phrase", + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + const signature = await owner.signMessage({ message }); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "siwe", message, signature }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad signature" }); + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it("rejects siwe message with mismatched chain id", async () => { + const owner = privateKeyToAccount(padHex("0xc0d8")); + const credentialId = owner.address; + const account = checksumAddress(padHex("0xbbc8", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId: "siwe-bad-chain-panda", + }); + await database.insert(cards).values({ id: "siwe-bad-chain-card", credentialId, lastFour: "5050" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValueOnce({}); + const message = createSiweMessage({ + domain, + address: credentialId, + statement: `I authorize the account ${account} to be linked with the card ending in 5050 for my user (siwe-bad-chain-panda)`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id + 1, + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + const signature = await owner.signMessage({ message }); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "siwe", message, signature }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad signature" }); + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it("rejects siwe message with mismatched domain", async () => { + const owner = privateKeyToAccount(padHex("0xc0d9")); + const credentialId = owner.address; + const account = checksumAddress(padHex("0xbbc9", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId: "siwe-bad-domain-panda", + }); + await database.insert(cards).values({ id: "siwe-bad-domain-card", credentialId, lastFour: "6060" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValueOnce({}); + const message = createSiweMessage({ + domain: "evil.example", + address: credentialId, + statement: `I authorize the account ${account} to be linked with the card ending in 6060 for my user (siwe-bad-domain-panda)`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + const signature = await owner.signMessage({ message }); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "siwe", message, signature }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad signature" }); + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it("rejects siwe signature from a different signer", async () => { + const owner = privateKeyToAccount(padHex("0xc0d4")); + const attacker = privateKeyToAccount(padHex("0xbad1")); + const credentialId = owner.address; + const account = checksumAddress(padHex("0xbbc5", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId: "siwe-bad-signer-panda", + }); + await database.insert(cards).values({ id: "siwe-bad-signer-card", credentialId, lastFour: "3030" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValue({}); + const message = createSiweMessage({ + domain, + address: credentialId, + statement: `I authorize the account ${account} to be linked with the card ending in 3030 for my user (siwe-bad-signer-panda)`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + const signature = await attacker.signMessage({ message }); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "siwe", message, signature }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad signature" }); + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it("returns 403 on verify when credential has no panda id", async () => { + const owner = privateKeyToAccount(padHex("0xc0d5")); + const credentialId = owner.address; + const account = checksumAddress(padHex("0xbbc2", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + }); + await database.insert(cards).values({ id: "siwe-verify-no-panda-card", credentialId, lastFour: "1010" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValue({}); + const message = createSiweMessage({ + domain, + address: credentialId, + statement: `I authorize the account ${account} to be linked with the card ending in 1010 for my user (none)`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + const signature = await owner.signMessage({ message }); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "siwe", message, signature }, + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it("returns 400 bad signature when panda rejects siwe verify with 401", async () => { + const owner = privateKeyToAccount(padHex("0xc0d6")); + const credentialId = owner.address; + const account = checksumAddress(padHex("0xbbc6", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId: "siwe-panda-401-panda", + }); + await database.insert(cards).values({ id: "siwe-panda-401-card", credentialId, lastFour: "4040" }); + const verifySpy = vi + .spyOn(panda, "verify") + .mockRejectedValueOnce(new ServiceError("Panda", 401, "invalid signature")); + const message = createSiweMessage({ + domain, + address: credentialId, + statement: `I authorize the account ${account} to be linked with the card ending in 4040 for my user (siwe-panda-401-panda)`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + const signature = await owner.signMessage({ message }); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "siwe", message, signature }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad signature" }); + expect(verifySpy).toHaveBeenCalledWith("siwe-panda-401-panda", { message, signature, authType: "siwe" }); + }); + + it("propagates non-401 panda errors from siwe verify", async () => { + const owner = privateKeyToAccount(padHex("0xc0d7")); + const credentialId = owner.address; + const account = checksumAddress(padHex("0xbbc7", { size: 20 })); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account, + factory: inject("ExaAccountFactory"), + pandaId: "siwe-panda-503-panda", + }); + await database.insert(cards).values({ id: "siwe-panda-503-card", credentialId, lastFour: "5050" }); + const verifySpy = vi + .spyOn(panda, "verify") + .mockRejectedValueOnce(new ServiceError("Panda", 503, "service unavailable")); + const message = createSiweMessage({ + domain, + address: credentialId, + statement: `I authorize the account ${account} to be linked with the card ending in 5050 for my user (siwe-panda-503-panda)`, + uri: `https://${domain}`, + version: "1", + chainId: chain.id, + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + const signature = await owner.signMessage({ message }); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "siwe", message, signature }, + }); + + expect(response.status).toBe(500); + expect(verifySpy).toHaveBeenCalledWith("siwe-panda-503-panda", { message, signature, authType: "siwe" }); + }); + }); + + describe("webauthn", () => { + const assertion = { + id: "I8d7DPRtg1GZ83as5R9LWw", + rawId: "I8d7DPRtg1GZ83as5R9LWw", + response: { + clientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0In0", // cspell:ignore eyJ0eXBlIjoid2ViYXV0aG4uZ2V0In0 + authenticatorData: "5d85uxU17437HNUygAfwlrv58UORvl7p-OfSMVnQe64dAAAAAA", // cspell:ignore 5d85uxU17437HNUygAfwlrv58UORvl7p-OfSMVnQe64dAAAAAA + signature: "MEYCIQD2d5ovtuEXMvfRdJa4JiotIYLnCCR3oEWQRX0xggyfwA", + userHandle: "cv99bMRjY0w-G2076bDKKTbxiLDpv6_iI19xJRifYzM", + }, + clientExtensionResults: {}, + type: "public-key" as const, + }; + + it("returns the statement as the challenge", async () => { + const credentialId = "webauthn-challenge-ok"; + const account = padHex("0xbbd1", { size: 20 }); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array([1, 2, 3]), + account, + factory: inject("ExaAccountFactory"), + pandaId: "webauthn-challenge-panda", + }); + await database.insert(cards).values({ id: "webauthn-challenge-card", credentialId, lastFour: "3377" }); + const nonceSpy = vi.spyOn(panda, "getNonce").mockResolvedValue({ nonce: "unreachable" }); + vi.spyOn(panda, "getCard").mockResolvedValueOnce({ ...cardTemplate, last4: "3377" }); + vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); + + const response = await appClient.index.$get( + { header: {}, query: { scope: "webauthn" } }, + { headers: { "test-credential-id": credentialId } }, + ); + + const expectedStatement = `I authorize the account ${checksumAddress(account)} to be linked with the card ending in 3377 for my user (webauthn-challenge-panda)`; + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ + displayName: `${userTemplate.firstName} ${userTemplate.lastName}`, + expirationMonth: cardTemplate.expirationMonth, + expirationYear: cardTemplate.expirationYear, + lastFour: "3377", + mode: 0, + provider: "panda", + status: "ACTIVE", + limit: cardTemplate.limit, + productId: PLATINUM_PRODUCT_ID, + challenge: expectedStatement, + }); + expect(nonceSpy).not.toHaveBeenCalled(); + }); + + it("returns 403 on challenge when credential has no panda id", async () => { + const credentialId = "webauthn-challenge-no-panda"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array([1, 2, 3]), + account: padHex("0xbbd2", { size: 20 }), + factory: inject("ExaAccountFactory"), + }); + await database.insert(cards).values({ id: "webauthn-challenge-no-panda-card", credentialId, lastFour: "4488" }); + + const response = await appClient.index.$get( + { header: {}, query: { scope: "webauthn" } }, + { headers: { "test-credential-id": credentialId } }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + }); + + it("verifies the webauthn assertion", async () => { + const credentialId = "webauthn-verify-ok"; + const account = padHex("0xbbe1", { size: 20 }); + const factory = inject("ExaAccountFactory"); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array([1, 2, 3]), + account, + factory, + pandaId: "webauthn-verify-panda", + transports: ["internal"], + counter: 5, + }); + await database.insert(cards).values({ id: "webauthn-verify-card", credentialId, lastFour: "3377" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValueOnce({}); + const statement = `I authorize the account ${checksumAddress(account)} to be linked with the card ending in 3377 for my user (webauthn-verify-panda)`; + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "webauthn", assertion }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ verification: "OK" }); + expect(verifySpy).toHaveBeenCalledWith("webauthn-verify-panda", { + authType: "webauthn", + credential: { + publicKey: { type: "Buffer", data: [1, 2, 3] }, + transports: ["internal"], + counter: 5, + }, + assertion, + factory, + statement, + }); + }); + + it("forwards null transports verbatim", async () => { + const credentialId = "webauthn-verify-null-transports"; + const account = padHex("0xbbe3", { size: 20 }); + const factory = inject("ExaAccountFactory"); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array([9, 8, 7]), + account, + factory, + pandaId: "webauthn-null-panda", + counter: 0, + }); + await database.insert(cards).values({ id: "webauthn-null-card", credentialId, lastFour: "2020" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValueOnce({}); + const statement = `I authorize the account ${checksumAddress(account)} to be linked with the card ending in 2020 for my user (webauthn-null-panda)`; + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "webauthn", assertion }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ verification: "OK" }); + expect(verifySpy).toHaveBeenCalledWith("webauthn-null-panda", { + authType: "webauthn", + credential: { publicKey: { type: "Buffer", data: [9, 8, 7] }, transports: null, counter: 0 }, + assertion, + factory, + statement, + }); + }); + + it("returns 403 on verify when credential has no panda id", async () => { + const credentialId = "webauthn-verify-no-panda"; + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array([1]), + account: padHex("0xbbe2", { size: 20 }), + factory: inject("ExaAccountFactory"), + }); + await database.insert(cards).values({ id: "webauthn-verify-no-panda-card", credentialId, lastFour: "3030" }); + const verifySpy = vi.spyOn(panda, "verify").mockResolvedValue({}); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "webauthn", assertion }, + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + expect(verifySpy).not.toHaveBeenCalled(); + }); + + it("returns 400 bad signature when panda rejects webauthn verify with 401", async () => { + const credentialId = "webauthn-panda-401"; + const account = padHex("0xbbe4", { size: 20 }); + const factory = inject("ExaAccountFactory"); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array([1, 2, 3]), + account, + factory, + pandaId: "webauthn-panda-401-panda", + transports: ["internal"], + counter: 0, + }); + await database.insert(cards).values({ id: "webauthn-panda-401-card", credentialId, lastFour: "4141" }); + const verifySpy = vi + .spyOn(panda, "verify") + .mockRejectedValueOnce(new ServiceError("Panda", 401, "invalid signature")); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "webauthn", assertion }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "bad signature" }); + expect(verifySpy).toHaveBeenCalledWith("webauthn-panda-401-panda", { + authType: "webauthn", + credential: { publicKey: { type: "Buffer", data: [1, 2, 3] }, transports: ["internal"], counter: 0 }, + assertion, + factory, + statement: `I authorize the account ${checksumAddress(account)} to be linked with the card ending in 4141 for my user (webauthn-panda-401-panda)`, + }); + }); + + it("propagates non-401 panda errors from webauthn verify", async () => { + const credentialId = "webauthn-panda-503"; + const account = padHex("0xbbe5", { size: 20 }); + const factory = inject("ExaAccountFactory"); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array([4, 5, 6]), + account, + factory, + pandaId: "webauthn-panda-503-panda", + transports: ["internal"], + counter: 0, + }); + await database.insert(cards).values({ id: "webauthn-panda-503-card", credentialId, lastFour: "5151" }); + const verifySpy = vi + .spyOn(panda, "verify") + .mockRejectedValueOnce(new ServiceError("Panda", 503, "service unavailable")); + + const response = await appClient.index.$patch({ + // @ts-expect-error - bad hono patch type + header: { "test-credential-id": credentialId }, + json: { method: "webauthn", assertion }, + }); + + expect(response.status).toBe(500); + expect(verifySpy).toHaveBeenCalledWith("webauthn-panda-503-panda", { + authType: "webauthn", + credential: { publicKey: { type: "Buffer", data: [4, 5, 6] }, transports: ["internal"], counter: 0 }, + assertion, + factory, + statement: `I authorize the account ${checksumAddress(account)} to be linked with the card ending in 5151 for my user (webauthn-panda-503-panda)`, + }); + }); + }); + + it("rejects combined siwe and webauthn scope", async () => { + const credentialId = privateKeyToAddress(padHex("0xc0de")); + await database.insert(credentials).values({ + id: credentialId, + publicKey: new Uint8Array(), + account: padHex("0xbbf1", { size: 20 }), + factory: inject("ExaAccountFactory"), + pandaId: "combined-scope-panda", + }); + await database.insert(cards).values({ id: "combined-scope-card", credentialId, lastFour: "5050" }); + const nonceSpy = vi.spyOn(panda, "getNonce").mockResolvedValue({ nonce: "unreachable" }); + + const response = await appClient.index.$get( + { header: {}, query: { scope: ["siwe", "webauthn"] } }, + { headers: { "test-credential-id": credentialId } }, + ); + + expect(response.status).toBe(400); + expect(nonceSpy).not.toHaveBeenCalled(); + }); + }); + describe("migration", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); diff --git a/server/test/utils/panda.test.ts b/server/test/utils/panda.test.ts index 7cecf2d9f2..631bcfb42e 100644 --- a/server/test/utils/panda.test.ts +++ b/server/test/utils/panda.test.ts @@ -30,3 +30,38 @@ describe("panda request", () => { await expect(rejection).rejects.toMatchObject({ name: "PandaNotFound", status: 404, message: "card" }); }); }); + +describe("siwe", () => { + it("returns the generated nonce", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode('{"nonce":"Db2ItfTPLuZ2dV0ZQ"}').buffer), + } as Response); + + await expect(panda.getNonce("e5cd86bb-a19e-4a66-9728-9e6c5d97e616")).resolves.toStrictEqual({ + nonce: "Db2ItfTPLuZ2dV0ZQ", + }); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/issuing/users/e5cd86bb-a19e-4a66-9728-9e6c5d97e616/signatures/generate-nonce"), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("verify message", async () => { + const payload = { + authType: "siwe" as const, + message: "I authorize the account 0xabc to be linked with the card ending in 1234 for my user (e5cd86bb).", + signature: "0x57d2c1f0c01b9173e080bd3cdd40600924cc0c4c31dfe45353d9d967c35d16944a", + }; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + } as Response); + + await expect(panda.verify("e5cd86bb-a19e-4a66-9728-9e6c5d97e616", payload)).resolves.toStrictEqual({}); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/issuing/users/e5cd86bb-a19e-4a66-9728-9e6c5d97e616/signatures/verify"), + expect.objectContaining({ method: "PUT", body: JSON.stringify(payload) }), + ); + }); +}); diff --git a/server/utils/panda.ts b/server/utils/panda.ts index cbca7e0bcf..d384990787 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -18,7 +18,7 @@ import { type BaseIssue, type BaseSchema, } from "valibot"; -import { BaseError, ContractFunctionZeroDataError } from "viem"; +import { BaseError, ContractFunctionZeroDataError, recoverTypedDataAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { base, optimism } from "viem/chains"; @@ -40,6 +40,8 @@ import verifySignature from "./verifySignature"; import database, { credentials } from "../database"; import publicClient from "../utils/publicClient"; +import type { Hex } from "@exactly/common/validation"; + const plugin = exaPluginAddress.toLowerCase(); if (!process.env.PANDA_API_URL) throw new Error("missing panda api url"); @@ -157,6 +159,35 @@ export async function setPIN(cardId: string, sessionId: string, pin: { data: str ); } +export function getNonce(userId: string) { + return request(object({ nonce: string() }), `/issuing/users/${userId}/signatures/generate-nonce`); +} + +export function verify( + userId: string, + payload: + | { + assertion: { + clientExtensionResults: Record; + id: string; + rawId: string; + response: { authenticatorData: string; clientDataJSON: string; signature: string; userHandle?: string }; + type: "public-key"; + }; + authType: "webauthn"; + credential: { + counter: number; + publicKey: { data: number[]; type: "Buffer" }; + transports: null | string[]; + }; + factory: string; + statement: string; + } + | { authType: "siwe"; message: string; signature: string }, +) { + return request(object({}), `/issuing/users/${userId}/signatures/verify`, {}, payload, "PUT"); +} + async function request>( schema: BaseSchema, url: `/${string}`, @@ -354,6 +385,42 @@ export function signIssuerOp({ account, amount, timestamp }: { account: Address; }); } +export function verifyPandaSignature({ + account, + amount, + timestamp, + signature, +}: { + account: Address; + amount: bigint; + signature: Hex; + timestamp: number; +}) { + return recoverTypedDataAddress({ + domain: { + chainId: chain.id, + name: "IssuerChecker", + version: "1", + verifyingContract: issuerCheckerAddress, + }, + types: { + Collection: [ + { name: "account", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "timestamp", type: "uint40" }, + ], + Refund: [ + { name: "account", type: "address" }, + { name: "amount", type: "uint256" }, + { name: "timestamp", type: "uint40" }, + ], + }, + primaryType: amount < 0n ? "Refund" : "Collection", + message: { account, amount: amount < 0n ? -amount : amount, timestamp }, + signature, + }).then((recovered) => parse(Address, recovered) === parse(Address, "0xB9771269312B32676B77C9db2242c8d1836F1a85")); +} + const mutexes = new Map(); export function createMutex(address: Address) { const mutex = withTimeout( diff --git a/src/components/card/CardDetails.tsx b/src/components/card/CardDetails.tsx index 11482621f3..a0bcc9a3a7 100644 --- a/src/components/card/CardDetails.tsx +++ b/src/components/card/CardDetails.tsx @@ -38,7 +38,7 @@ export default function CardDetails({ open, onClose }: { onClose: () => void; op const { data: card, isPending } = useQuery({ queryKey: ["card", "details"] }); const [details, setDetails] = useState({ pan: "", cvc: "" }); useEffect(() => { - if (card) { + if (card?.encryptedPan && card.encryptedCvc) { Promise.all([ decrypt(card.encryptedPan.data, card.encryptedPan.iv, card.secret), decrypt(card.encryptedCvc.data, card.encryptedCvc.iv, card.secret), diff --git a/src/utils/server.ts b/src/utils/server.ts index 52dc1139f7..57280f4f2e 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -104,6 +104,7 @@ async function getPIN() { const result = await getCard(); if (!result) return null; const { secret, encryptedPan, encryptedCvc, pin } = result; + if (!encryptedPan || !encryptedCvc) return null; const [pan, cvc, decryptedPIN] = await Promise.all([ decrypt(encryptedPan.data, encryptedPan.iv, secret), decrypt(encryptedCvc.data, encryptedCvc.iv, secret),