diff --git a/.changeset/calm-tigers-stop.md b/.changeset/calm-tigers-stop.md new file mode 100644 index 000000000..1d9d4d795 --- /dev/null +++ b/.changeset/calm-tigers-stop.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +🔥 drop header from encrypted kyc diff --git a/.changeset/cuddly-streets-like.md b/.changeset/cuddly-streets-like.md new file mode 100644 index 000000000..336464b4c --- /dev/null +++ b/.changeset/cuddly-streets-like.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +✨ improve nonce usage for encrypted kyc diff --git a/.changeset/four-numbers-worry.md b/.changeset/four-numbers-worry.md new file mode 100644 index 000000000..6d50428b8 --- /dev/null +++ b/.changeset/four-numbers-worry.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ implement kyc data submission diff --git a/.changeset/sharp-squids-push.md b/.changeset/sharp-squids-push.md new file mode 100644 index 000000000..02c1901c8 --- /dev/null +++ b/.changeset/sharp-squids-push.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🗃️ add role to organization database table diff --git a/.changeset/upset-seas-sink.md b/.changeset/upset-seas-sink.md new file mode 100644 index 000000000..e46154ae8 --- /dev/null +++ b/.changeset/upset-seas-sink.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add kyc role to organizations diff --git a/cspell.json b/cspell.json index 81cd7eb01..eae95e10d 100644 --- a/cspell.json +++ b/cspell.json @@ -40,6 +40,7 @@ "checksummed", "CLABE", "clippy", + "ciphertext", "codegen", "codepoint", "colocating", diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index 8571a80b0..4f6152946 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -10,6 +10,9 @@ Then the owner can add members with admin role and those admins will be able to Better auth client and viem are the recommended libraries to use for authentication and signing using SIWE. +> ⚠️ **Note:** +> If you need to perform an encrypted KYC operation, please ask the exa team for `kyc` permissions. + ## SIWE Authentication Example code to authenticate using SIWE, it will create the user if doesn't exist. @@ -219,3 +222,107 @@ authClient.siwe }); ``` + +## How to create the encrypted KYC payload with SIWE statement + + +```typescript +import crypto from "node:crypto"; +import { getAddress, sha256 } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage, generateSiweNonce } from "viem/siwe"; + +const chainId = optimismSepolia.id; + +const owner = mnemonicToAccount("test test test test test test test test test test test siwe"); + +function encrypt(payload: string) { + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv); + + const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY-----`; + + const key = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + aesKey, + ); + + return { + key: key.toString("base64"), + iv: iv.toString("base64"), + ciphertext: ciphertext.toString("base64"), + tag: tag.toString("base64"), + hash: sha256(ciphertext), + }; +} + +const data = { + email: "john.doe@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "123456789", + birthDate: "1990-05-15", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main Street", + line2: "Apt 4B", + city: "New York", + region: "NY", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "192.168.1.100", + occupation: "11-1011", + annualSalary: "75000", + accountPurpose: "Personal Banking", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true, +}; +const encryptedPayload = encrypt(JSON.stringify(data)); +const exaAccountUserAddress = "0xa7d5e73027844145A538F4bfD7b8d9b41d8B89d3"; +const statement = `I apply for KYC approval on behalf of address ${getAddress(exaAccountUserAddress)} with payload hash ${encryptedPayload.hash}`; +const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", +}); +owner.signMessage({ message }) + .then((signature) => { + const verify = { + message, + signature, + walletAddress: owner.address, + chainId, + }; + const { hash, ...payload } = encryptedPayload; + console.log("application payload", { ...payload, verify }); + }) + .catch((error: unknown) => { + console.error("error", error); + }); + ``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4397976b..b058b2b9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -778,6 +778,9 @@ importers: bullmq: specifier: ^5.71.1 version: 5.71.1 + canonicalize: + specifier: ^2.1.0 + version: 2.1.0 debug: specifier: ^4.4.3 version: 4.4.3 @@ -7194,6 +7197,10 @@ packages: caniuse-lite@1.0.30001781: resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + canonicalize@2.1.0: + resolution: {integrity: sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==} + hasBin: true + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -21929,6 +21936,8 @@ snapshots: caniuse-lite@1.0.30001781: {} + canonicalize@2.1.0: {} + ccount@2.0.1: {} chai@6.2.2: {} diff --git a/server/api/card.ts b/server/api/card.ts index dee223d5d..3aaa86d50 100644 --- a/server/api/card.ts +++ b/server/api/card.ts @@ -31,7 +31,17 @@ import { Address } from "@exactly/common/validation"; import database, { cards, credentials } from "../database"; 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, + getApplicationStatus, + getCard, + getPIN, + getSecrets, + getUser, + setPIN, + updateCard, +} from "../utils/panda"; import { addCapita, deriveAssociateId } from "../utils/pax"; import { getAccount } from "../utils/persona"; import { customer } from "../utils/sardine"; @@ -292,7 +302,12 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str 403: { description: "Forbidden", content: { - "application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) }, + "application/json": { + schema: resolver( + union([object({ code: literal("no panda") }), object({ code: literal("kyc not approved") })]), + { errorMode: "ignore" }, + ), + }, }, }, }, @@ -317,6 +332,10 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str setUser({ id: account }); if (!credential.pandaId) return c.json({ code: "no panda" }, 403); + const kyc = await getApplicationStatus(credential.pandaId); + if (kyc.applicationStatus !== "approved") { + return c.json({ code: "kyc not approved" }, 403); + } let isUpgradeFromPlatinum = credential.cards.some( ({ status, productId }) => status === "DELETED" && productId === PLATINUM_PRODUCT_ID, diff --git a/server/api/kyc.ts b/server/api/kyc.ts index f864858aa..8d8e947fb 100644 --- a/server/api/kyc.ts +++ b/server/api/kyc.ts @@ -1,22 +1,32 @@ import { captureException, setContext, setUser, startSpan } from "@sentry/node"; +import canonicalize from "canonicalize"; import createDebug from "debug"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import { validator as vValidator } from "hono-openapi/valibot"; -import { literal, object, optional, parse, picklist, string } from "valibot"; -import { getAddress } from "viem"; +import * as honoOpenapi from "hono-openapi"; +import { resolver, validator as vValidator } from "hono-openapi/valibot"; +import { array, literal, metadata, number, object, optional, parse, picklist, pipe, string, union } from "valibot"; +import { getAddress, sha256, verifyMessage } from "viem"; +import { parseSiweMessage } from "viem/siwe"; import accountInit from "@exactly/common/accountInit"; -import { +import chain, { exaAccountFactoryAddress, exaPluginAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; -import { Address } from "@exactly/common/validation"; +import { Address, Hex } from "@exactly/common/validation"; -import database, { credentials } from "../database/index"; +import database, { credentials, walletAddresses } from "../database/index"; import auth from "../middleware/auth"; import decodePublicKey from "../utils/decodePublicKey"; +import { + Application, + UpdateApplicationRequest as ApplicationUpdate, + getApplicationStatus, + submitApplication, + updateApplication, +} from "../utils/panda"; import { createInquiry, CRYPTOMATE_TEMPLATE, @@ -28,11 +38,32 @@ import { scopeValidationErrors, } from "../utils/persona"; import publicClient from "../utils/publicClient"; +import ServiceError from "../utils/ServiceError"; import validatorHook from "../utils/validatorHook"; const debug = createDebug("exa:kyc"); Object.assign(debug, { inspectOpts: { depth: undefined } }); +const KYCStatusResponse = object({ + code: pipe(string(), metadata({ examples: ["ok"] })), + legacy: pipe(string(), metadata({ examples: ["ok"] })), + status: pipe(string(), metadata({ examples: ["approved", "rejected"] })), + reason: pipe(string(), metadata({ examples: ["", "BAD_SELFIE"] })), +}); + +const BadRequestCodes = { + ALREADY_STARTED: "already started", + NOT_STARTED: "not started", + BAD_REQUEST: "bad request", +} as const; + +function buildBaseResponse(example = "string") { + return object({ + code: pipe(string(), metadata({ examples: [example] })), + legacy: pipe(string(), metadata({ examples: [example] })), + }); +} + export default new Hono() .get( "/", @@ -184,6 +215,388 @@ export default new Hono() throw new Error("Unknown inquiry status"); } }, + ) + .post( + "/application", + auth(), + honoOpenapi.describeRoute({ + summary: "Submit KYC application", + description: ` +Submit information for KYC application. + +**Encrypted kyc payload** + +When the payload includes the \`ciphertext\` field (alongside \`key\`, \`iv\`, \`tag\`), it is treated as encrypted. Encryption is auto-detected from the payload shape. + +The steps to encrypt are: + +1. Generate AES Key: Create a random 256-bit AES key +2. Encrypt Payload: Use AES-256-GCM to encrypt your KYC JSON data +3. Encrypt AES Key: Use Rain-provided RSA public key with OAEP padding +4. Encode Components: Base64-encode all encrypted components +5. Submit Request + +KYC Encryption Public Key for sandbox is: + +\`\`\` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY----- +\`\`\` + +KYC Encryption Public Key for production needs to be provided. + +A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) + +**Payload structure before encryption** + +1. Personal information (name, date of birth, address) +2. Identity verification documents +3. Compliance information (occupation, income, etc.) +4. Terms of service acceptance + +Here's the markdown table with object notation for nested fields: + +| fieldName | type | example | notes | +|-----------|------|---------|-------| +| email | string | user@domain.com | | +| lastName | string | Doe | | +| firstName | string | John | | +| nationalId | string | 123456789 | | +| birthDate | string | 1970-01-01 | | +| countryOfIssue | string | US | | +| phoneCountryCode | string | 1 | | +| phoneNumber | string | 5551234567 | | +| address.line1 | string | 123 Main Street | | +| address.line2 | string | Apt 4B | | +| address.city | string | New York | | +| address.region | string | NY | | +| address.postalCode | string | 10001 | | +| address.countryCode | string | US | | +| ipAddress | string | 192.168.1.100 | | +| occupation | string | 11-1011 | Ask for the mandatory occupation codes | +| annualSalary | string | 75000 | | +| accountPurpose | string | Personal Banking | | +| expectedMonthlyVolume | string | 5000 | | +| isTermsOfServiceAccepted | boolean | true | | + +**Authentication and organization verification** + +The exa account needs to be authenticated but also a member of the organization that submit the KYC application needs to probe that +belong to the organization and needs to have *kyc* permission, every owner and admin of an organization has this permission. + +To probe the member of the organization needs to generate a SIWE message with the following statement and viem library is recommended: + +"I apply for KYC approval on behalf of address [checksum address] with payload hash [hash]"; + +The hash is sha256(encryptedPayload.ciphertext) + +The siwe message will be: + +| fieldName | type | example | notes | +|-----------|------|---------|-------| +| verify.message | string | SIWE message that includes the statement | | +| verify.signature | Hex | signature of the message | | +| verify.walletAddress | Address | address of the member of the organization that signed the message | | +| verify.chainId | number | 11155420 | | + +A working and tested [example is available in here](../../../organization-authentication/#how-to-create-the-encrypted-kyc-payload-with-siwe-statement) + +Note that the member of the organization must be created, the organization must exist and the member must be added as admin by another admin or owner. + +Working example about how to login is [here](../../../organization-authentication/#siwe-authentication) + +The admin should add a member using [addMember method](https://www.better-auth.com/docs/plugins/organization#add-member). +`, + tags: ["KYC"], + responses: { + 200: { + description: "KYC application submitted successfully", + content: { + "application/json": { + schema: resolver(object({ status: string() }), { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: picklist(["invalid encryption", "no account", "bad chain"]), message: string() }), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { + errorMode: "ignore", + }, + ), + }, + }, + }, + 401: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + object({ + code: literal("invalid payload"), + message: string(), + }), + object({ + code: string(), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + 409: { + description: "Conflict", + content: { + "application/json": { + schema: resolver(object({ code: literal(BadRequestCodes.ALREADY_STARTED) }), { errorMode: "ignore" }), + }, + }, + }, + 403: { + description: "Forbidden", + content: { + "application/json": { + schema: resolver( + object({ + code: picklist(["no permission", "no organization"]), + message: optional(string()), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + validateResponse: true, + }), + vValidator( + "json", + union([ + object({ + ...Application.entries, + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), + }), + object({ + key: string(), + iv: string(), + ciphertext: string(), + tag: string(), + verify: object({ message: string(), signature: Hex, walletAddress: Address, chainId: number() }), + }), + ]), + validatorHook({ debug }), + ), + async (c) => { + const payload = c.req.valid("json"); + const { message, signature, walletAddress: address } = payload.verify; + + if (!(await verifyMessage({ address, message, signature }))) { + return c.json({ code: "no permission", message: "invalid signature" }, 403); + } + const account = await database.query.walletAddresses.findFirst({ + where: eq(walletAddresses.address, address), + with: { + user: { + columns: { id: true }, + with: { + members: { + columns: { role: true }, + with: { organization: { columns: { id: true, role: true } } }, + }, + }, + }, + }, + }); + + if (!account) return c.json({ code: "no account", message: `no account found for address ${address}` }, 400); + const member = account.user.members[0]; + if (!member) return c.json({ code: "no organization" }, 403); + if (member.role !== "admin" && member.role !== "owner") return c.json({ code: "no permission" }, 403); + if (member.organization.role !== "kyc") return c.json({ code: "no permission" }, 403); + + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + + const siweMessage = parseSiweMessage(payload.verify.message); + + if (siweMessage.chainId !== chain.id) + return c.json({ code: "bad chain", message: `expected ${chain.id} but got ${siweMessage.chainId}` }, 400); + + const { verify, ...body } = payload; + const hash = + "ciphertext" in body + ? sha256(Buffer.from(body.ciphertext, "base64")) + : sha256( + Buffer.from( + (() => { + const canon = canonicalize(body); + if (!canon) throw new Error("bad body"); + return canon; + })(), + "utf8", + ), + ); + + if ( + siweMessage.statement !== + `I apply for KYC approval on behalf of address ${parse(Address, credential.account)} with payload hash ${hash}` + ) { + return c.json({ code: "no permission", message: "invalid statement" }, 403); + } + + if (credential.pandaId) return c.json({ code: BadRequestCodes.ALREADY_STARTED }, 409); + + try { + const application = await submitApplication(body); + await database + .update(credentials) + .set({ pandaId: application.id, source: member.organization.id }) + .where(eq(credentials.id, credentialId)); + return c.json({ status: application.applicationStatus }, 200); + } catch (error) { + if (error instanceof ServiceError) { + switch (error.status) { + case 400: + return c.json({ code: "invalid encryption", message: error.message }, 400); + case 401: + return c.json({ code: "invalid payload", message: error.message }, 401); + } + } + throw error; + } + }, + ) + .patch( + "/application", + auth(), + honoOpenapi.describeRoute({ + summary: "Update KYC application", + description: "Update the KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application updated successfully", + content: { + "application/json": { + schema: resolver(buildBaseResponse("ok"), { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + validateResponse: true, + }), + vValidator("json", ApplicationUpdate, validatorHook({ debug })), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const payload = c.req.valid("json"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + if (!credential.pandaId) { + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + } + await updateApplication(credential.pandaId, payload); + return c.json({ code: "ok", legacy: "ok" }, 200); + }, + ) + .get( + "/application", + auth(), + honoOpenapi.describeRoute({ + summary: "Get KYC application status", + description: "Get the status of the KYC application", + tags: ["KYC"], + responses: { + 200: { + description: "KYC application status", + content: { + "application/json": { + schema: resolver(KYCStatusResponse, { errorMode: "ignore" }), + }, + }, + }, + 400: { + description: "Bad request", + content: { + "application/json": { + schema: resolver( + union([ + buildBaseResponse(BadRequestCodes.NOT_STARTED), + object({ + ...buildBaseResponse(BadRequestCodes.BAD_REQUEST).entries, + message: optional(array(string())), + }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const { credentialId } = c.req.valid("cookie"); + const credential = await database.query.credentials.findFirst({ + columns: { id: true, account: true, pandaId: true }, + where: eq(credentials.id, credentialId), + }); + if (!credential) return c.json({ code: "no credential", legacy: "no credential" }, 500); + setUser({ id: parse(Address, credential.account) }); + setContext("exa", { credential }); + if (!credential.pandaId) { + return c.json({ code: BadRequestCodes.NOT_STARTED, legacy: BadRequestCodes.NOT_STARTED }, 400); + } + const status = await getApplicationStatus(credential.pandaId); + return c.json( + { code: "ok", legacy: "ok", status: status.applicationStatus, reason: status.applicationReason ?? "unknown" }, + 200, + ); + }, ); async function isLegacy( diff --git a/server/database/schema.ts b/server/database/schema.ts index 6fb1d0457..68684f87d 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -214,6 +214,7 @@ export const organizations = pgTable("organizations", { logo: text("logo"), createdAt: timestamp("created_at").notNull(), metadata: text("metadata"), + role: text("role"), }); export const members = pgTable( diff --git a/server/package.json b/server/package.json index a6391b032..06107c08f 100644 --- a/server/package.json +++ b/server/package.json @@ -47,6 +47,7 @@ "async-mutex": "^0.5.0", "better-auth": "^1.6.3", "bullmq": "^5.71.1", + "canonicalize": "^2.1.0", "debug": "^4.4.3", "drizzle-orm": "^0.45.2", "graphql": "^16.13.2", diff --git a/server/test/api/card.test.ts b/server/test/api/card.test.ts index 7f5c24490..8928c988e 100644 --- a/server/test/api/card.test.ts +++ b/server/test/api/card.test.ts @@ -95,6 +95,7 @@ describe("authenticated", () => { afterEach(() => vi.resetAllMocks()); it("returns 404 card not found", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, { headers: { "test-credential-id": "404" } }, @@ -115,14 +116,13 @@ describe("authenticated", () => { }); it("returns panda card as default platinum product", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); vi.spyOn(panda, "getPIN").mockResolvedValueOnce(pinTemplate); vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); vi.spyOn(panda, "getUser").mockResolvedValueOnce(userTemplate); - vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); - const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, { headers: { "test-credential-id": "default" } }, @@ -190,11 +190,6 @@ describe("authenticated", () => { factory: inject("ExaAccountFactory"), }, ]); - await database.insert(cards).values([{ id: `card-${foo}`, credentialId: foo, lastFour: "4567" }]); - - vi.spyOn(panda, "getSecrets").mockResolvedValueOnce(panTemplate); - vi.spyOn(panda, "getCard").mockResolvedValueOnce(cardTemplate); - vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); const response = await appClient.index.$get( { header: { sessionid: "fakeSession" } }, @@ -202,6 +197,7 @@ describe("authenticated", () => { ); expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); }); it("returns 403 when panda user is not found", async () => { @@ -344,6 +340,7 @@ describe("authenticated", () => { }); it("returns 403 when panda user exists but is not approved", async () => { + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "denied" }); const credentialId = "not-approved"; await database.insert(credentials).values({ id: credentialId, @@ -353,104 +350,13 @@ describe("authenticated", () => { pandaId: credentialId, }); - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => - Promise.resolve('{"message":"User exists, but is not approved","error":"ForbiddenError","statusCode":403}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).not.toHaveBeenCalled(); - }); - - it("returns 403 when createCard fails with plain-text not approved", async () => { - const credentialId = "not-approved-plain"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4043", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => Promise.resolve("user exists but is not approved"), - } as Response); - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); + await expect(response.json()).resolves.toStrictEqual({ code: "kyc not approved" }); expect(captureException).not.toHaveBeenCalled(); }); - it("returns 403 when createCard fails with panda user not found", async () => { - const credentialId = "panda-user-not-found"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4042", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve('{"message":"User not found","error":"NotFoundError","statusCode":404}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("returns 403 when createCard fails with panda user not found and empty body", async () => { - const credentialId = "panda-user-not-found-empty"; - await database.insert(credentials).values({ - id: credentialId, - publicKey: new Uint8Array(), - account: padHex("0x4044", { size: 20 }), - factory: inject("ExaAccountFactory"), - pandaId: credentialId, - }); - - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 404, - text: () => Promise.resolve(""), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": credentialId } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - - it("captures forbidden no-user on createCard when credential has card history", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: false, - status: 403, - text: () => - Promise.resolve('{"message":"User exists, but is not approved","error":"ForbiddenError","statusCode":403}'), - } as Response); - - const response = await appClient.index.$post({ header: { "test-credential-id": "404" } }); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toStrictEqual({ code: "no panda" }); - expect(captureException).toHaveBeenCalledOnce(); - }); - it("throws when createCard fails with empty-body 403", async () => { const credentialId = "not-approved-empty"; await database.insert(credentials).values({ @@ -489,6 +395,7 @@ describe("authenticated", () => { it("creates a panda debit card with signature product id", async () => { vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCard" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const sigCredential = await database.query.credentials.findFirst({ columns: { account: true }, where: eq(credentials.id, "sig"), @@ -517,6 +424,7 @@ describe("authenticated", () => { it("creates a panda credit card with signature product id", async () => { vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "createCreditCard", last4: "1224" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const ethCredential = await database.query.credentials.findFirst({ columns: { account: true }, where: eq(credentials.id, "eth"), @@ -615,7 +523,7 @@ describe("authenticated", () => { }, }, }; - + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "pax-card", last4: "5555" }); @@ -650,6 +558,7 @@ describe("authenticated", () => { pandaId: "new-user-panda", }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, @@ -733,7 +642,7 @@ describe("authenticated", () => { }, }, }; - + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(persona, "getAccount").mockResolvedValueOnce(mockAccount); vi.spyOn(pax, "addCapita").mockRejectedValueOnce(new Error("pax api error")); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "error-card", last4: "6666" }); @@ -763,6 +672,7 @@ describe("authenticated", () => { productId: PLATINUM_PRODUCT_ID, }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(pax, "addCapita").mockResolvedValueOnce({}); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "no-account-card", last4: "7777" }); @@ -777,6 +687,7 @@ describe("authenticated", () => { const cardResponse = { ...cardTemplate, id: "cardForCancel", last4: "1224", status: "active" as const }; vi.spyOn(panda, "createCard").mockResolvedValueOnce(cardResponse); vi.spyOn(panda, "updateCard").mockResolvedValueOnce({ ...cardResponse, status: "canceled" }); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); const response = await appClient.index.$post({ header: { "test-credential-id": "eth" } }); @@ -801,6 +712,7 @@ describe("authenticated", () => { it("creates a panda card having a cm card with upgraded plugin", async () => { await database.insert(cards).values([{ id: "cm", credentialId: "default", lastFour: "1234" }]); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "getCard").mockRejectedValueOnce(new ServiceError("Panda", 404, "card not found")); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:cm" }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); @@ -818,6 +730,7 @@ describe("authenticated", () => { it("creates a panda card having a cm card with invalid uuid", async () => { await database.insert(cards).values([{ id: "not-uuid", credentialId: "default", lastFour: "1234" }]); + vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ id: "pandaId", applicationStatus: "approved" }); vi.spyOn(panda, "createCard").mockResolvedValueOnce({ ...cardTemplate, id: "migration:not-uuid" }); vi.spyOn(panda, "isPanda").mockResolvedValueOnce(true); diff --git a/server/test/api/kyc.test.ts b/server/test/api/kyc.test.ts index 0855ea299..a78da448b 100644 --- a/server/test/api/kyc.test.ts +++ b/server/test/api/kyc.test.ts @@ -3,15 +3,27 @@ import "../mocks/deployments"; import "../mocks/sentry"; import { captureException } from "@sentry/node"; +import canonicalize from "canonicalize"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { afterEach, beforeEach, describe, expect, inject, it, vi } from "vitest"; +import crypto from "node:crypto"; +import { getAddress, sha256 } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; +import { createSiweMessage, generateSiweNonce } from "viem/siwe"; +import { afterEach, beforeAll, beforeEach, describe, expect, inject, it, vi } from "vitest"; + +import chain from "@exactly/common/generated/chain"; import app from "../../api/kyc"; -import database, { credentials } from "../../database"; +import database, { credentials, organizations, sources } from "../../database"; +import auth from "../../utils/auth"; +import * as panda from "../../utils/panda"; import * as persona from "../../utils/persona"; import { scopeValidationErrors } from "../../utils/persona"; import publicClient from "../../utils/publicClient"; +import ServiceError from "../../utils/ServiceError"; + +import type * as v from "valibot"; const appClient = testClient(app); @@ -1262,6 +1274,705 @@ describe("authenticated", () => { }); }); }); + + describe("application", () => { + describe("with organization", () => { + const owner = mnemonicToAccount("test test test test test test test test test test test kyc"); + const ownerHeaders: Headers = new Headers(); + const outsider = mnemonicToAccount("test test test test test test test test test test test bob"); + const outsiderHeaders: Headers = new Headers(); + const account = "bob"; + + const applicationPayload = { + email: "test@example.com", + lastName: "Doe", + firstName: "John", + nationalId: "12345678", + birthDate: "1990-01-01", + countryOfIssue: "US", + phoneCountryCode: "1", + phoneNumber: "5551234567", + address: { + line1: "123 Main St", + city: "New York", + region: "NY", + country: "US", + postalCode: "10001", + countryCode: "US", + }, + ipAddress: "127.0.0.1", + occupation: "Engineer", + annualSalary: "100000", + accountPurpose: "Personal", + expectedMonthlyVolume: "5000", + isTermsOfServiceAccepted: true as const, + }; + + let organizationId: string; + + beforeAll(async () => { + const adminNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + + const statement = "I accept Exa terms and conditions"; + const ownerMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: adminNonceResult.nonce, + uri: `https://localhost`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + + const ownerLogin = await auth.api.verifySiweMessage({ + body: { + message: ownerMessage, + signature: await owner.signMessage({ message: ownerMessage }), + walletAddress: owner.address, + chainId: chain.id, + }, + request: new Request("https://localhost"), + asResponse: true, + }); + ownerHeaders.set("cookie", ownerLogin.headers.get("set-cookie") ?? ""); + + const externalOrganization = await auth.api.createOrganization({ + headers: ownerHeaders, + body: { + name: "Organization", + slug: "organization", + keepCurrentActiveOrganization: false, + }, + }); + organizationId = externalOrganization.id; + await database.update(organizations).set({ role: "kyc" }).where(eq(organizations.id, organizationId)); + + await auth.api + .getSiweNonce({ + body: { walletAddress: outsider.address, chainId: chain.id }, + }) + .then((result) => { + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: result.nonce, + uri: `https://localhost`, + address: outsider.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + return outsider.signMessage({ message }).then((signature) => { + return auth.api + .verifySiweMessage({ + body: { message, signature, walletAddress: outsider.address, chainId: chain.id }, + request: new Request("https://localhost"), + asResponse: true, + }) + .then((response) => { + outsiderHeaders.set("cookie", response.headers.get("set-cookie") ?? ""); + }); + }); + }); + }); + + describe("status", () => { + it("returns status", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const getApplicationStatus = vi.spyOn(panda, "getApplicationStatus").mockResolvedValueOnce({ + id: "pandaId", + applicationStatus: "approved", + applicationReason: "", + }); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + await expect(response.json()).resolves.toStrictEqual({ + code: "ok", + legacy: "ok", + status: "approved", + reason: "", + }); + expect(getApplicationStatus).toHaveBeenCalledWith("pandaId"); + expect(response.status).toBe(200); + }); + + it("returns not started when no panda id", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$get( + { query: {} }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + }); + + describe("submit", () => { + beforeAll(async () => { + await database.insert(sources).values([ + { + id: organizationId, + config: { + type: "uphold", + secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, + webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + }, + }, + ]); + }); + + it("returns ok when payload is valid and kyc is not started", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user`), + expect.objectContaining({ + method: "POST", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual(applicationPayload); + await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); + }); + + it("returns 409 when kyc is already started", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + const submitApplication = vi.spyOn(panda, "submitApplication"); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toStrictEqual({ + code: "already started", + }); + expect(submitApplication).not.toHaveBeenCalled(); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$post( + { json: {} as unknown as NonNullable[0]["json"]> }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + + it("returns 400 if terms of service are not accepted", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify, isTermsOfServiceAccepted: false } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + }); + + describe("with encrypted payload", () => { + const publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZixoAuo015iMt+JND0y +usAvU2iJhtKRM+7uAxd8iXq7Z/3kXlGmoOJAiSNfpLnBAG0SCWslNCBzxf9+2p5t +HGbQUkZGkfrYvpAzmXKsoCrhWkk1HKk9f7hMHsyRlOmXbFmIgQHggEzEArjhkoXD +pl2iMP1ykCY0YAS+ni747DqcDOuFqLrNA138AxLNZdFsySHbxn8fzcfd3X0J/m/T +2dZuy6ChfDZhGZxSJMjJcintFyXKv7RkwrYdtXuqD3IQYakY3u6R1vfcKVZl0yGY +S2kN/NOykbyVL4lgtUzf0IfkwpCHWOrrpQA4yKk3kQRAenP7rOZThdiNNzz4U2BE +2wIDAQAB +-----END PUBLIC KEY-----`; + + function encrypt(payload: string) { + const aesKey = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", aesKey, iv); + const ciphertext = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + const key = crypto.publicEncrypt( + { + key: publicKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + aesKey, + ); + + return { key, iv, ciphertext, tag }; + } + + it("returns ok when payload is valid", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + + const verify = { + message, + signature, + walletAddress: owner.address, + chainId: chain.id, + }; + + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => + Promise.resolve( + new TextEncoder().encode( + JSON.stringify({ + id: "pandaId", + applicationStatus: "approved", + }), + ).buffer, + ), + } as Response); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const updatedCredential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + expect(updatedCredential?.pandaId).toBe("pandaId"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/issuing/applications/user"), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ encrypted: "true" }), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + }); + await expect(response.json()).resolves.toStrictEqual({ status: "approved" }); + }); + + it("returns 403 no organization", async () => { + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: outsider.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify: { + message, + signature: await outsider.signMessage({ message }), + walletAddress: outsider.address, + chainId: chain.id, + }, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(403); + }); + + it("returns 403 no permission when organization role is not kyc", async () => { + await database.update(organizations).set({ role: null }).where(eq(organizations.id, organizationId)); + try { + const credential = await database.query.credentials.findFirst({ where: eq(credentials.id, account) }); + const encryptedPayload = encrypt(JSON.stringify(applicationPayload)); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(encryptedPayload.ciphertext)}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + + const response = await appClient.application.$post( + { + json: { + key: encryptedPayload.key.toString("base64"), + iv: encryptedPayload.iv.toString("base64"), + ciphertext: encryptedPayload.ciphertext.toString("base64"), + tag: encryptedPayload.tag.toString("base64"), + verify: { + message, + signature: await owner.signMessage({ message }), + walletAddress: owner.address, + chainId: chain.id, + }, + }, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no permission" }); + } finally { + await database.update(organizations).set({ role: "kyc" }).where(eq(organizations.id, organizationId)); + } + }); + }); + + describe("panda errors", () => { + it("returns invalid encryption on bad request", async () => { + vi.spyOn(panda, "submitApplication").mockRejectedValueOnce( + new ServiceError("Panda", 400, '{"message":"bad encryption"}', undefined, "bad encryption"), + ); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const initialCalls = vi.mocked(captureException).mock.calls.length; + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "invalid encryption", + message: "bad encryption", + }); + expect(vi.mocked(captureException).mock.calls.slice(initialCalls)).toStrictEqual([]); + }); + + it("returns invalid payload on unauthorized", async () => { + vi.spyOn(panda, "submitApplication").mockRejectedValueOnce( + new ServiceError("Panda", 401, '{"message":"invalid data"}', undefined, "invalid data"), + ); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const initialCalls = vi.mocked(captureException).mock.calls.length; + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toStrictEqual({ + code: "invalid payload", + message: "invalid data", + }); + expect(vi.mocked(captureException).mock.calls.slice(initialCalls)).toStrictEqual([]); + }); + + it("propagates panda errors with unexpected status to global handler", async () => { + vi.spyOn(panda, "submitApplication").mockRejectedValueOnce( + new ServiceError("Panda", 500, '{"message":"server error"}', undefined, "server error"), + ); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(500); + }); + + it("propagates non-panda errors to global handler", async () => { + vi.spyOn(panda, "submitApplication").mockRejectedValueOnce(new Error("network failure")); + const credential = await database.query.credentials.findFirst({ + where: eq(credentials.id, account), + }); + const statement = `I apply for KYC approval on behalf of address ${getAddress(credential?.account ?? "")} with payload hash ${sha256(Buffer.from(canonicalize(applicationPayload) ?? "", "utf8"))}`; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: generateSiweNonce(), + uri: `https://sandbox.exactly.app`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "sandbox.exactly.app", + }); + const signature = await owner.signMessage({ message }); + const verify = { message, signature, walletAddress: owner.address, chainId: chain.id }; + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + + const response = await appClient.application.$post( + { json: { ...applicationPayload, verify } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(500); + }); + }); + }); + + describe("update", () => { + it("returns ok when kyc is started", async () => { + await database.update(credentials).set({ pandaId: "pandaId" }).where(eq(credentials.id, account)); + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode("{}").buffer), + } as Response); + + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + const calls = mockFetch.mock.calls; + const body = calls[0]?.[1]?.body; + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toStrictEqual({ code: "ok", legacy: "ok" }); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(`/issuing/applications/user/pandaId`), + expect.objectContaining({ + method: "PATCH", + }), + ); + expect(JSON.parse(body as string)).toStrictEqual({ firstName: "john-updated" }); + }); + + it("returns 400 when kyc is not started", async () => { + await database.update(credentials).set({ pandaId: null }).where(eq(credentials.id, account)); + const response = await appClient.application.$patch( + { json: { firstName: "john-updated" } }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "not started", + legacy: "not started", + }); + }); + + it("returns 400 when payload is invalid", async () => { + const response = await appClient.application.$patch( + { + json: { + address: { + line1: "123 main street", + }, + } as unknown as v.InferOutput, + }, + { headers: { "test-credential-id": account, SessionID: "fakeSession" } }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ + code: "bad request", + legacy: "bad request", + message: expect.any(Array), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); + }); + }); + }); + }); }); const basicAccount = { diff --git a/server/test/e2e.ts b/server/test/e2e.ts index 84b86b0a0..b4512e9b5 100644 --- a/server/test/e2e.ts +++ b/server/test/e2e.ts @@ -108,6 +108,7 @@ vi.mock("../utils/panda", async (importOriginal: () => Promise) => }); }), getUser: vi.fn().mockImplementation((userId: string) => Promise.resolve(users.get(userId))), + getApplicationStatus: vi.fn().mockResolvedValue({ id: "pandaId", applicationStatus: "approved" }), isPanda: vi.fn().mockResolvedValue(true), setPIN: vi.fn().mockResolvedValue({}), signIssuerOp: vi.fn().mockResolvedValue("0x" + "ab".repeat(65)), diff --git a/server/utils/auth.ts b/server/utils/auth.ts index ef68eea5e..cbe51b40e 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -17,6 +17,7 @@ import { authAdapter } from "../database/index"; const ac = createAccessControl({ ...defaultStatements, webhook: ["create", "delete", "read"], + kyc: ["create", "delete", "read"], }); export default betterAuth({ @@ -54,16 +55,21 @@ export default betterAuth({ roles: { admin: ac.newRole({ webhook: ["create", "delete", "read"], + kyc: ["create"], ...adminAc.statements, }), owner: ac.newRole({ webhook: ["create", "delete", "read"], + kyc: ["create"], ...ownerAc.statements, }), member: ac.newRole({ ...memberAc.statements, }), }, + additionalFields: { + role: { type: "string", required: false, input: false }, + }, allowUserToCreateOrganization: () => true, }), ], diff --git a/server/utils/panda.ts b/server/utils/panda.ts index cbca7e0bc..4eec355be 100644 --- a/server/utils/panda.ts +++ b/server/utils/panda.ts @@ -3,20 +3,31 @@ import { Mutex, withTimeout, type MutexInterface } from "async-mutex"; import { eq } from "drizzle-orm"; import { boolean, + check, + email, + ipv4, + ipv6, length, literal, maxLength, + metadata, minLength, nullable, number, object, + omit, + optional, parse, + partial, picklist, pipe, + regex, string, transform, + union, type BaseIssue, type BaseSchema, + type InferInput, } from "valibot"; import { BaseError, ContractFunctionZeroDataError } from "viem"; import { privateKeyToAccount } from "viem/accounts"; @@ -47,7 +58,6 @@ const baseURL = process.env.PANDA_API_URL; if (!process.env.PANDA_API_KEY) throw new Error("missing panda api key"); const key = process.env.PANDA_API_KEY; -export default key; export async function createCard(userId: string, productId: typeof PLATINUM_PRODUCT_ID | typeof SIGNATURE_PRODUCT_ID) { return await request( @@ -366,3 +376,126 @@ export function createMutex(address: Address) { export function getMutex(address: Address) { return mutexes.get(address); } + +export async function submitApplication(payload: InferInput) { + return request( + ApplicationResponse, + "/issuing/applications/user", + { ...("ciphertext" in payload && { encrypted: "true" }) }, + payload, + "POST", + 10_000, + ); +} + +export async function getApplicationStatus(applicationId: string) { + return request( + ApplicationStatusResponse, + `/issuing/applications/user/${applicationId}`, + {}, + undefined, + "GET", + 10_000, + ); +} + +export async function updateApplication(applicationId: string, payload: InferInput) { + return request(object({}), `/issuing/applications/user/${applicationId}`, {}, payload, "PATCH", 10_000); +} + +const AddressSchema = object({ + line1: pipe(string(), minLength(1), maxLength(100)), + line2: optional(pipe(string(), minLength(1), maxLength(100))), + city: pipe(string(), minLength(1), maxLength(50)), + region: pipe(string(), minLength(1), maxLength(50)), + country: optional(pipe(string(), minLength(1), maxLength(50))), + postalCode: pipe(string(), minLength(1), maxLength(15), regex(/^[a-z0-9]{1,15}$/i)), + countryCode: pipe(string(), length(2), regex(/^[A-Z]{2}$/i)), +}); + +export const Application = object({ + email: pipe( + string(), + email("Invalid email address"), + metadata({ description: "Email address", examples: ["user@domain.com"] }), + ), + lastName: pipe(string(), maxLength(50), metadata({ description: "The person's last name" })), + firstName: pipe(string(), maxLength(50), metadata({ description: "The person's first name" })), + nationalId: pipe(string(), maxLength(50), metadata({ description: "The person's national ID" })), + birthDate: pipe( + string(), + regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD format"), + check((value) => { + const date = new Date(value); + return !Number.isNaN(date.getTime()); + }, "must be a valid date"), + metadata({ description: "Birth date (YYYY-MM-DD)", examples: ["1970-01-01"] }), + ), + countryOfIssue: pipe( + string(), + length(2), + regex(/^[A-Z]{2}$/i, "Must be exactly 2 letters"), + metadata({ description: "The person's country of issue of their national id, as a 2-letter country code" }), + ), + phoneCountryCode: pipe( + string(), + minLength(1), + maxLength(3), + regex(/^\d{1,3}$/, "Must be a valid country code"), + metadata({ description: "The user's phone country code" }), + ), + phoneNumber: pipe( + string(), + minLength(1), + maxLength(15), + regex(/^\d{1,15}$/, "Must be a valid phone number"), + metadata({ description: "The user's phone number" }), + ), + address: pipe(AddressSchema, metadata({ description: "The person's address" })), + ipAddress: pipe( + union([pipe(string(), maxLength(50), ipv4()), pipe(string(), maxLength(50), ipv6())]), + metadata({ description: "The user's IP address (IPv4 or IPv6)" }), + ), + occupation: pipe(string(), maxLength(50), metadata({ description: "The user's occupation" })), + annualSalary: pipe(string(), maxLength(50), metadata({ description: "The user's annual salary" })), + accountPurpose: pipe(string(), maxLength(50), metadata({ description: "The user's account purpose" })), + expectedMonthlyVolume: pipe(string(), maxLength(50), metadata({ description: "The user's expected monthly volume" })), + isTermsOfServiceAccepted: pipe( + boolean(), + literal(true), + metadata({ description: "Whether the user has accepted the terms of service" }), + ), +}); + +export const SubmitApplicationRequest = union([ + Application, + object({ key: string(), iv: string(), ciphertext: string(), tag: string() }), +]); + +export const UpdateApplicationRequest = object({ + ...partial(omit(Application, ["email", "phoneCountryCode", "phoneNumber", "address"])).entries, + address: optional(AddressSchema), +}); + +const ApplicationResponse = object({ + id: pipe(string(), maxLength(50)), + applicationStatus: pipe(string(), maxLength(50)), +}); + +export const kycStatus = [ + "needsVerification", + "needsInformation", + "manualReview", + "notStarted", + "approved", + "canceled", + "pending", + "denied", + "locked", +] as const; + +const ApplicationStatusResponse = object({ + id: string(), + applicationStatus: picklist(kycStatus), + applicationReason: optional(string()), +});