diff --git a/apps/api/src/api/controllers/brla.controller.test.ts b/apps/api/src/api/controllers/brla.controller.test.ts index 2f013b63d..26d60a8c9 100644 --- a/apps/api/src/api/controllers/brla.controller.test.ts +++ b/apps/api/src/api/controllers/brla.controller.test.ts @@ -15,6 +15,7 @@ import QuoteTicket from "../../models/quoteTicket.model"; import User from "../../models/user.model"; import { hashTaxReference } from "../services/avenia/avenia-customer.service"; import { SupabaseAuthService } from "../services/auth"; +import { validateSubaccountCreation } from "../middlewares/validators"; import { createSubaccount, createKybDocument, @@ -1861,6 +1862,76 @@ describe("createSubaccount", () => { expect(subaccountInfoMock).toHaveBeenCalledWith("new-subaccount"); }); + // The route runs validateSubaccountCreation ahead of the controller; drive both in order so the + // assertions cover what a real request reaches. + async function submitThroughRoute(body: unknown, userId = "squatter-user") { + const req = { body, userId } as any; + const res = createResponse(); + let validated = false; + validateSubaccountCreation(req, res as any, () => { + validated = true; + }); + if (validated) await createSubaccount(req, res as any); + return res; + } + + it("never reaches the provider or the database for malformed input", async () => { + mockBrlaApi(); + const findOne = mock(async () => null); + ProviderCustomer.findOne = findOne as unknown as typeof ProviderCustomer.findOne; + + const malformed = [ + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "12345678901" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "abc" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: 8786985906 }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "11222333000181" }, + { accountType: AveniaAccountType.COMPANY, name: "Squatter Ltda", taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: " ", taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, taxId: "08786985906" }, + { accountType: AveniaAccountType.INDIVIDUAL, name: "Squatter", taxId: "08786985907" } + ]; + for (const body of malformed) { + const res = await submitThroughRoute(body); + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + } + + expect(createAveniaSubaccountMock).not.toHaveBeenCalled(); + expect(FinancialOperation.findOrCreate).not.toHaveBeenCalled(); + expect(sequelize.transaction).not.toHaveBeenCalled(); + expect(findOne).not.toHaveBeenCalled(); + }); + + it("stores the normalized tax id when the client sends a formatted valid CPF", async () => { + mockBrlaApi(); + const providerCreateMock = mock(async (values: Record) => ({ ...values })); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + ProviderCustomer.create = providerCreateMock as unknown as typeof ProviderCustomer.create; + + const res = await submitThroughRoute({ ...validBody, taxId: "087.869.859-06" }, "new-user"); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(createAveniaSubaccountMock).toHaveBeenCalledTimes(1); + expect(providerCreateMock.mock.calls[0]?.[0]).toMatchObject({ + taxReference: "08786985906", + taxReferenceHash: hashTaxReference("08786985906") + }); + }); + + it("sends the provider the trimmed name the validator measured", async () => { + mockBrlaApi(); + ProviderCustomer.findOne = mock(async () => null) as typeof ProviderCustomer.findOne; + ProviderCustomer.create = mock(async (values: Record) => ({ + ...values + })) as unknown as typeof ProviderCustomer.create; + const name = "a".repeat(255); + + const res = await submitThroughRoute({ ...validBody, name: ` ${name} ` }, "new-user"); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(createAveniaSubaccountMock).toHaveBeenCalledWith(AveniaAccountType.INDIVIDUAL, name); + }); + it("rejects overwrite when a started record belongs to another entity", async () => { mockBrlaApi(); createAveniaSubaccountMock.mockClear(); @@ -2104,6 +2175,52 @@ describe("newKyc", () => { expect(getInstance).not.toHaveBeenCalled(); }); + describe("tax id binding", () => { + function mockOwnedIndividual() { + CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; + ProviderCustomer.findOne = mock(async () => ({ + customerEntityId: "entity-user-1", + customerType: "individual", + id: "customer-1", + provider: "avenia", + providerSubaccountId: "subaccount-1", + taxReferenceHash: hashTaxReference("08786985906") + })) as unknown as typeof ProviderCustomer.findOne; + const getInstance = mock(() => ({}) as BrlaApiService); + BrlaApiService.getInstance = getInstance; + // Anything past the binding check opens the KYC claim transaction; fail loudly if reached. + const transaction = mock(async () => { + throw new Error("reached the KYC claim"); + }); + sequelize.transaction = transaction as unknown as typeof sequelize.transaction; + return { getInstance, transaction }; + } + + it("rejects a taxIdNumber that differs from the claimed CPF before any provider call", async () => { + const { getInstance, transaction } = mockOwnedIndividual(); + + for (const taxIdNumber of ["52998224725", "", undefined, 8786985906]) { + const res = createResponse(); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber }, userId: "user-1" } as any, res as any); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "taxIdNumber does not match the tax ID claimed for this subaccount." }); + } + expect(getInstance).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it("lets a formatted equivalent of the claimed CPF through to the submission", async () => { + const { transaction } = mockOwnedIndividual(); + + const res = createResponse(); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "087.869.859-06" }, userId: "user-1" } as any, res as any); + + expect(transaction).toHaveBeenCalled(); + expect(res.statusCode).not.toBe(httpStatus.BAD_REQUEST); + }); + }); + it("rejects an imported-method case before provider document or submission calls", async () => { CustomerEntity.findAll = mock(async () => [{ id: "entity-user-1" }]) as unknown as typeof CustomerEntity.findAll; ProviderCustomer.findOne = mock(async () => ({ @@ -2111,7 +2228,8 @@ describe("newKyc", () => { customerType: "individual", id: "customer-1", provider: "avenia", - providerSubaccountId: "subaccount-1" + providerSubaccountId: "subaccount-1", + taxReferenceHash: hashTaxReference("08786985906") })) as unknown as typeof ProviderCustomer.findOne; KycCase.findAll = mock(async () => [{ id: "case-1", verificationMethod: "sumsub_share_token" }]) as unknown as typeof KycCase.findAll; sequelize.transaction = mock(async callback => @@ -2129,7 +2247,7 @@ describe("newKyc", () => { ); const res = createResponse(); - await newKyc({ body: { subAccountId: "subaccount-1" }, userId: "user-1" } as any, res as any); + await newKyc({ body: { subAccountId: "subaccount-1", taxIdNumber: "08786985906" }, userId: "user-1" } as any, res as any); expect(res.statusCode).toBe(httpStatus.CONFLICT); expect(getUploadedDocuments).not.toHaveBeenCalled(); @@ -2146,6 +2264,7 @@ describe("newKyc", () => { provider: "avenia", providerSubaccountId: "subaccount-1", status: VerificationStatus.InReview, + taxReferenceHash: hashTaxReference("08786985906"), update: customerUpdate }; ProviderCustomer.findOne = mock(async () => customer) as unknown as typeof ProviderCustomer.findOne; @@ -2203,6 +2322,7 @@ describe("newKyc", () => { { body: { subAccountId: "subaccount-1", + taxIdNumber: "087.869.859-06", uploadedDocumentId: "document-1", uploadedSelfieId: "selfie-1" }, @@ -2297,6 +2417,7 @@ describe("Avenia API KYB", () => { providerSubaccountId: "subaccount-1", status: VerificationStatus.Pending, statusExternal: null, + taxReferenceHash: hashTaxReference(validSubmission.taxIdentificationNumberTin), update })) as unknown as typeof ProviderCustomer.findOne; return update; @@ -2430,6 +2551,45 @@ describe("Avenia API KYB", () => { expect(createUbo).not.toHaveBeenCalled(); }); + it("rejects a TIN that differs from the claimed CNPJ before any provider call", async () => { + const { customerUpdate, submit } = mockInitialSubmission(); + const getInstance = mock(() => ({ submitKybLevel1: submit }) as unknown as BrlaApiService); + BrlaApiService.getInstance = getInstance; + + const res = createResponse(); + await submitKybLevel1Api( + { + body: { ...validSubmission, taxIdentificationNumberTin: "11222333000181" }, + query: { subAccountId: "subaccount-1" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error: "taxIdentificationNumberTin does not match the tax ID claimed for this subaccount." }); + expect(getInstance).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); + expect(customerUpdate).not.toHaveBeenCalled(); + }); + + it("accepts a formatted TIN equivalent to the claimed CNPJ", async () => { + const { submit } = mockInitialSubmission(); + + const res = createResponse(); + await submitKybLevel1Api( + { + body: { ...validSubmission, taxIdentificationNumberTin: "42.731.085/0001-67" }, + query: { subAccountId: "subaccount-1" }, + userId: "user-1" + } as any, + res as any + ); + + expect(res.statusCode).toBe(httpStatus.OK); + expect(submit).toHaveBeenCalledTimes(1); + }); + it("submits ready company documents and persists the pending attempt", async () => { const { caseUpdate, customerUpdate, submit } = mockInitialSubmission(); diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index 7fa04159d..4021432ae 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -371,7 +371,9 @@ export const createSubaccount = async ( res: Response ): Promise => { try { - const { name, taxId, accountType: requestAccountType } = req.body; + const { taxId, accountType: requestAccountType } = req.body; + // validateSubaccountCreation bounded the trimmed name, so every use below sends that same value. + const name = req.body.name.trim(); const effectiveUserId = getEffectiveUserId(req); // Reject callers that do not resolve to a user (anonymous requests @@ -449,7 +451,7 @@ export const createSubaccount = async ( provider: "avenia", request: { accountType, - name: name.trim(), + name, ownerProfileId: effectiveUserId, taxReferenceHash }, @@ -459,7 +461,7 @@ export const createSubaccount = async ( let companyName: string | null = null; if (accountType === AveniaAccountType.COMPANY) { - companyName = name.trim(); + companyName = name; try { const account = await brlaApiService.subaccountInfo(id); companyName = account?.accountInfo.name?.trim() || account?.accountInfo.fullName?.trim() || companyName; @@ -906,6 +908,12 @@ export const newKyc = async ( res.status(httpStatus.BAD_REQUEST).json({ error: "Individual KYC requires an individual customer account." }); return; } + // The provider approves whoever the submitted documents belong to; the CPF claimed at + // createSubaccount must be that same identity, or the approval would attach to the wrong tax id. + if (typeof req.body.taxIdNumber !== "string" || hashTaxReference(req.body.taxIdNumber) !== record.taxReferenceHash) { + res.status(httpStatus.BAD_REQUEST).json({ error: "taxIdNumber does not match the tax ID claimed for this subaccount." }); + return; + } const response = await submitStandardAveniaKyc({ actorProfileId, @@ -1068,6 +1076,13 @@ export const submitKybLevel1Api = async ( ): Promise => { try { const record = await resolveAveniaKybAccount(req, req.query.subAccountId); + // Same binding as newKyc: the submitted TIN must be the CNPJ claimed for this subaccount. + if (hashTaxReference(req.body.taxIdentificationNumberTin) !== record.taxReferenceHash) { + res + .status(httpStatus.BAD_REQUEST) + .json({ error: "taxIdentificationNumberTin does not match the tax ID claimed for this subaccount." }); + return; + } const subAccountId = record.providerSubaccountId as string; const brlaApiService = BrlaApiService.getInstance(); if (record.status === VerificationStatus.Approved) { diff --git a/apps/api/src/api/middlewares/validators.test.ts b/apps/api/src/api/middlewares/validators.test.ts index 97e9e5f48..30ff2426c 100644 --- a/apps/api/src/api/middlewares/validators.test.ts +++ b/apps/api/src/api/middlewares/validators.test.ts @@ -1,4 +1,4 @@ -import { BrDocumentType, Networks, QuoteError, RampDirection } from "@vortexfi/shared"; +import { AveniaAccountType, BrDocumentType, Networks, QuoteError, RampDirection } from "@vortexfi/shared"; import { describe, expect, it, mock } from "bun:test"; import type { NextFunction, Request, Response } from "express"; import httpStatus from "http-status"; @@ -9,7 +9,8 @@ import { validateAveniaKybLevel1, validateAveniaKybUbo, validateCreateBestQuoteInput, - validateKycSubmission + validateKycSubmission, + validateSubaccountCreation } from "./validators"; function buildRes() { @@ -283,3 +284,84 @@ describe("validateKycSubmission", () => { expect(res.statusCode).toBeUndefined(); }); }); + +describe("validateSubaccountCreation", () => { + // Synthetic identifiers with valid check digits (never real people or companies). + const VALID_CPF = "52998224725"; + const VALID_CPF_FORMATTED = "529.982.247-25"; + const VALID_CNPJ = "11222333000181"; + const VALID_CNPJ_FORMATTED = "11.222.333/0001-81"; + + function validate(body: unknown) { + const req = { body } as unknown as Request; + const res = buildRes(); + const next = mock(() => undefined) as unknown as NextFunction; + + validateSubaccountCreation(req, res, next); + + return { next, res }; + } + + function expectRejected(body: unknown, error: string) { + const { next, res } = validate(body); + expect(res.statusCode).toBe(httpStatus.BAD_REQUEST); + expect(res.body).toEqual({ error }); + expect(next).not.toHaveBeenCalled(); + } + + const individual = { accountType: AveniaAccountType.INDIVIDUAL, name: "Ana Maria Silva", taxId: VALID_CPF }; + const company = { accountType: AveniaAccountType.COMPANY, name: "Acme Ltda", taxId: VALID_CNPJ }; + + it("accepts a valid CPF, plain or formatted, for an individual", () => { + for (const taxId of [VALID_CPF, VALID_CPF_FORMATTED, ` ${VALID_CPF} `]) { + const { next, res } = validate({ ...individual, taxId }); + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBeUndefined(); + } + }); + + it("accepts a valid CNPJ, plain or formatted, for a company", () => { + for (const taxId of [VALID_CNPJ, VALID_CNPJ_FORMATTED]) { + const { next, res } = validate({ ...company, taxId }); + expect(next).toHaveBeenCalledTimes(1); + expect(res.statusCode).toBeUndefined(); + } + }); + + it("rejects checksum-invalid and trivial identifiers", () => { + for (const taxId of ["52998224724", "12345678901", "11111111111", "abc", "", "529.982.247-2"]) { + expectRejected({ ...individual, taxId }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + } + expectRejected({ ...company, taxId: "11222333000182" }, "taxId must be a valid CNPJ for COMPANY accounts."); + }); + + it("rejects a CPF for a company and a CNPJ for an individual", () => { + expectRejected({ ...company, taxId: VALID_CPF }, "taxId must be a valid CNPJ for COMPANY accounts."); + expectRejected({ ...individual, taxId: VALID_CNPJ }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + }); + + it("rejects a missing or non-string taxId", () => { + for (const taxId of [undefined, null, 52998224725, [VALID_CPF], { value: VALID_CPF }]) { + expectRejected({ ...individual, taxId }, "taxId must be a valid CPF for INDIVIDUAL accounts."); + } + }); + + it("rejects a missing, non-string, blank or oversized name", () => { + const error = "name must be a non-empty string of at most 255 characters."; + for (const name of [undefined, null, 42, ["Ana"], "", " ", "x".repeat(256)]) { + expectRejected({ ...individual, name }, error); + } + const { next } = validate({ ...individual, name: "x".repeat(255) }); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("rejects an unknown or missing accountType before looking at the other fields", () => { + for (const accountType of [undefined, "BUSINESS", 1]) { + expectRejected({ ...individual, accountType }, "Invalid accountType."); + } + }); + + it("rejects a missing body instead of throwing", () => { + expectRejected(undefined, "Invalid accountType."); + }); +}); diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 5d1741135..fb699e916 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -1,4 +1,5 @@ import { + AveniaAccountType, BrDocumentType, BrKYCDataUploadRequest, BrKybLevel1Payload, @@ -12,6 +13,7 @@ import { getCaseSensitiveNetwork, isSupportedFiatCurrency, isValidAveniaAccountType, + isValidCnpj, isValidCpf, isValidCurrencyForDirection, isValidDirection, @@ -354,16 +356,36 @@ export const validateSiweValidate: RequestHandler = (req, res, next) => { next(); }; +// provider_customers.company_name is VARCHAR(255); the controller stores the trimmed name there after the provider call. +const SUBACCOUNT_NAME_MAX_LENGTH = 255; + +// Runs before any provider call or DB write: a subaccount reserves its tax id exclusively, so only a +// well-formed CPF (INDIVIDUAL) or CNPJ (COMPANY) may reach the controller. export const validateSubaccountCreation: RequestHandler = (req, res, next) => { - const { accountType } = req.body as CreateAveniaSubaccountRequest; + const { accountType, name, taxId } = (req.body ?? {}) as Partial>; - if (!accountType || !isValidAveniaAccountType(accountType)) { + if (typeof accountType !== "string" || !isValidAveniaAccountType(accountType)) { res.status(httpStatus.BAD_REQUEST).json({ error: "Invalid accountType." }); return; } + if (typeof name !== "string" || name.trim().length === 0 || name.trim().length > SUBACCOUNT_NAME_MAX_LENGTH) { + res.status(httpStatus.BAD_REQUEST).json({ + error: `name must be a non-empty string of at most ${SUBACCOUNT_NAME_MAX_LENGTH} characters.` + }); + return; + } + + const isCompany = accountType === AveniaAccountType.COMPANY; + if (typeof taxId !== "string" || !(isCompany ? isValidCnpj(taxId.trim()) : isValidCpf(taxId.trim()))) { + res.status(httpStatus.BAD_REQUEST).json({ + error: `taxId must be a valid ${isCompany ? "CNPJ" : "CPF"} for ${accountType} accounts.` + }); + return; + } + next(); }; diff --git a/docs/README.md b/docs/README.md index c9fdfc620..a956d2bda 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ The smaller set of general project documents stays directly in `docs/`: | [`architecture-email-notifications.md`](architecture-email-notifications.md) | Current transactional/auth email architecture: queue, dispatch, producers | | [`architecture-identity-model.md`](architecture-identity-model.md) | Current cross-module identity and ownership architecture | | [`architecture-monerium-b2b-onramp.md`](architecture-monerium-b2b-onramp.md) | Current end-to-end architecture of the B2B EUR onramp: onboarding, deposit-to-payout, batching, fees, data model | +| [`operations-brl-tax-id-claim-release.md`](operations-brl-tax-id-claim-release.md) | Operator runbook for releasing a BRL tax ID squatted through `createSubaccount` (RISK-026) | | [`operations-demo-environment.md`](operations-demo-environment.md) | Setup and runbook for the sandbox sales-demo account | | [`operations-legacy-schema-cleanup.md`](operations-legacy-schema-cleanup.md) | Deployment gates and recovery runbook for irreversible migrations 060-061 | | [`operations-monerium-b2b-rollout.md`](operations-monerium-b2b-rollout.md) | Launch gates, deploy checklist, and terms inputs for the B2B onramp pilot | diff --git a/docs/api/openapi/vortex.openapi.d.ts b/docs/api/openapi/vortex.openapi.d.ts index 3a9a82016..b7529567d 100644 --- a/docs/api/openapi/vortex.openapi.d.ts +++ b/docs/api/openapi/vortex.openapi.d.ts @@ -109,7 +109,7 @@ export interface paths { put?: never; /** * Create user or retry KYC - * @description `companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ` + * @description Creates the provider subaccount for a Brazilian individual (`INDIVIDUAL`, CPF) or company (`COMPANY`, CNPJ). The tax ID is reserved for the calling account as soon as this succeeds, so `taxId` must be the CPF/CNPJ of the person or company being onboarded; it is validated (format and check digits) before anything is created. Repeating the call for a tax ID the account already owns returns the existing `subAccountId`. * * `quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists. * @@ -2249,6 +2249,7 @@ export interface components { /** @enum {string} */ sourceOfFundsAndIncome: "business_loans" | "grants" | "inter_company_funds" | "investment_proceeds" | "legal_settlement" | "owners_capital" | "pension_retirement" | "sale_of_assets" | "sales_of_goods_and_services" | "third_party_funds" | "treasury_reserves"; taxIdentificationDocumentId: string; + /** @description The CNPJ the company subaccount was created with. Punctuation is ignored; a different value is rejected with `400`. */ taxIdentificationNumberTin: string; uboIds: string[]; /** Format: uri */ @@ -2365,11 +2366,14 @@ export interface components { CreateSubaccountRequest: { /** @enum {string} */ accountType: "INDIVIDUAL" | "COMPANY"; - /** @description Individual full name or company legal name. */ + /** @description Individual full name or company legal name (1 to 255 characters after trimming). */ name: string; quoteId?: string; sessionId?: string; - /** @description CPF for an individual or CNPJ for a company. */ + /** + * @description CPF for an `INDIVIDUAL` account or CNPJ for a `COMPANY` account. Check digits are validated; punctuation is optional (`529.982.247-25` and `52998224725` are equivalent). + * @example 529.982.247-25 + */ taxId: string; }; CreateSubaccountResponse: { @@ -2767,6 +2771,7 @@ export interface components { state: string; streetAddress: string; subAccountId: string; + /** @description The CPF the subaccount was created with. Punctuation is ignored; a different CPF is rejected with `400`. */ taxIdNumber: string; uploadedDocumentId: string; uploadedSelfieId: string; @@ -3831,8 +3836,9 @@ export interface operations { }; /** * @description Bad Request. Possible reasons: - * - Missing required fields (cpf, cnpj, companyName, startDate) - * - Subaccount already created and KYC level > 0 + * - `accountType` is not `INDIVIDUAL` or `COMPANY` + * - `name` is missing, blank, or longer than 255 characters + * - `taxId` is missing, is not a string, or is not a valid CPF (`INDIVIDUAL`) / CNPJ (`COMPANY`); punctuation is optional, the check digits are verified * - Other invalid request details */ 400: { @@ -4383,7 +4389,7 @@ export interface operations { "application/json": components["schemas"]["KycLevel1Response"]; }; }; - /** @description Invalid submission or document state. */ + /** @description Invalid submission or document state, including a `taxIdentificationNumberTin` that does not match the CNPJ the company subaccount was created with (punctuation is ignored). */ 400: { headers: { [name: string]: unknown; @@ -4738,7 +4744,7 @@ export interface operations { "application/json": components["schemas"]["KycLevel1Response"]; }; }; - /** @description Validation failure. */ + /** @description Validation failure, including a `taxIdNumber` that does not match the CPF the subaccount was created with (punctuation is ignored). Submit the same CPF used for `createSubaccount`. */ 400: { headers: { [name: string]: unknown; diff --git a/docs/api/openapi/vortex.openapi.json b/docs/api/openapi/vortex.openapi.json index 67ff4cf38..d3f0757f7 100644 --- a/docs/api/openapi/vortex.openapi.json +++ b/docs/api/openapi/vortex.openapi.json @@ -653,6 +653,7 @@ "type": "string" }, "taxIdentificationNumberTin": { + "description": "The CNPJ the company subaccount was created with. Punctuation is ignored; a different value is rejected with `400`.", "type": "string" }, "uboIds": { @@ -1004,7 +1005,9 @@ "type": "string" }, "name": { - "description": "Individual full name or company legal name.", + "description": "Individual full name or company legal name (1 to 255 characters after trimming).", + "maxLength": 255, + "minLength": 1, "type": "string" }, "quoteId": { @@ -1014,7 +1017,8 @@ "type": "string" }, "taxId": { - "description": "CPF for an individual or CNPJ for a company.", + "description": "CPF for an `INDIVIDUAL` account or CNPJ for a `COMPANY` account. Check digits are validated; punctuation is optional (`529.982.247-25` and `52998224725` are equivalent).", + "example": "529.982.247-25", "type": "string" } }, @@ -2238,6 +2242,7 @@ "type": "string" }, "taxIdNumber": { + "description": "The CPF the subaccount was created with. Punctuation is ignored; a different CPF is rejected with `400`.", "type": "string" }, "uploadedDocumentId": { @@ -4606,7 +4611,7 @@ "/v1/brl/createSubaccount": { "post": { "deprecated": false, - "description": "`companyName`, `startDate` and `cnpj` are only required when taxIdType is `CNPJ`\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", + "description": "Creates the provider subaccount for a Brazilian individual (`INDIVIDUAL`, CPF) or company (`COMPANY`, CNPJ). The tax ID is reserved for the calling account as soon as this succeeds, so `taxId` must be the CPF/CNPJ of the person or company being onboarded; it is validated (format and check digits) before anything is created. Repeating the call for a tax ID the account already owns returns the existing `subAccountId`.\n\n`quoteId` is optional: pass it in the normal ramp flow, or omit it for the quote-less KYB deep link where business verification starts before any quote exists.\n\n**Auth:** secret `X-API-Key` or Supabase Bearer session.", "operationId": "createSubaccount", "parameters": [ { @@ -4643,7 +4648,7 @@ } } }, - "description": "Bad Request. Possible reasons:\n- Missing required fields (cpf, cnpj, companyName, startDate)\n- Subaccount already created and KYC level > 0\n- Other invalid request details", + "description": "Bad Request. Possible reasons:\n- `accountType` is not `INDIVIDUAL` or `COMPANY`\n- `name` is missing, blank, or longer than 255 characters\n- `taxId` is missing, is not a string, or is not a valid CPF (`INDIVIDUAL`) / CNPJ (`COMPANY`); punctuation is optional, the check digits are verified\n- Other invalid request details", "headers": {} }, "401": { @@ -5426,7 +5431,7 @@ } } }, - "description": "Invalid submission or document state." + "description": "Invalid submission or document state, including a `taxIdentificationNumberTin` that does not match the CNPJ the company subaccount was created with (punctuation is ignored)." }, "401": { "$ref": "#/components/responses/ManagedSelectorUnauthorized", @@ -5909,7 +5914,7 @@ } } }, - "description": "Validation failure.", + "description": "Validation failure, including a `taxIdNumber` that does not match the CPF the subaccount was created with (punctuation is ignored). Submit the same CPF used for `createSubaccount`.", "headers": {} }, "401": { diff --git a/docs/api/pages/09-fiat-corridors.md b/docs/api/pages/09-fiat-corridors.md index 5cfca6ca7..100265516 100644 --- a/docs/api/pages/09-fiat-corridors.md +++ b/docs/api/pages/09-fiat-corridors.md @@ -37,6 +37,8 @@ BRL routes settle over PIX and require user onboarding with Vortex's local payme Use `/v1/brl/*` for BRL account and verification operations. The previous `/v1/brla/*` prefix remains supported as an equivalent migration alias. +`POST /v1/brl/createSubaccount` reserves the tax ID for the calling account, so it validates the request before anything is created: `name` must be 1 to 255 characters and `taxId` must be a check-digit-valid CPF for `INDIVIDUAL` or CNPJ for `COMPANY` (punctuation optional). Anything else returns `400`. The submission that follows must carry the same tax ID: `taxIdNumber` on `newKyc` and `taxIdentificationNumberTin` on the business submission are compared with the tax ID the subaccount was created with (punctuation ignored), and a different value returns `400` before anything reaches the provider. + Level 1 onboarding collects basic identity information and enables lower-limit BRL flows. Level 2 adds document and liveness verification and may be required for higher limits or stricter compliance rules. The user must have completed KYC on the same account whose key registers the ramp; otherwise the ramp may fail or require additional account-management steps. A normal partner key cannot select an arbitrary user. An enabled managed-profile manager may use a secret `sk_*` key or Supabase session with `X-Managed-Profile-Id` to drive supported BR KYC operations for its directly managed child when the manager has the `BR` corridor and the child's immutable type is allowed by both current manager policy and Vortex's BR capability matrix. A null manager customer-type policy adds no further restriction. A public `pk_*` key is insufficient. When possible, use the Vortex application or hosted widget to complete onboarding before ramp execution. Business users can be sent straight into verification with the [KYB Deep Link](https://api-docs.vortexfinance.co/kyb-deep-link). diff --git a/docs/api/pages/14-managed-profiles.md b/docs/api/pages/14-managed-profiles.md index 4335fb2a4..4e60de1a2 100644 --- a/docs/api/pages/14-managed-profiles.md +++ b/docs/api/pages/14-managed-profiles.md @@ -91,7 +91,7 @@ X-API-Key: sk_live_... X-Managed-Profile-Id: 00000000-0000-0000-0000-000000000002 Content-Type: application/json -{ "accountType": "INDIVIDUAL", "name": "Ana Maria Silva", "taxId": "12345678901" } +{ "accountType": "INDIVIDUAL", "name": "Ana Maria Silva", "taxId": "52998224725" } ``` ```http diff --git a/docs/operations-brl-tax-id-claim-release.md b/docs/operations-brl-tax-id-claim-release.md new file mode 100644 index 000000000..937749783 --- /dev/null +++ b/docs/operations-brl-tax-id-claim-release.md @@ -0,0 +1,117 @@ +# Releasing A Squatted BRL Tax ID + +Status: current operator runbook. It backs RISK-026 in +[`security-spec/RISK-REGISTER.md`](security-spec/RISK-REGISTER.md); the behavior it works +around is specified in [`security-spec/05-integrations/brla.md`](security-spec/05-integrations/brla.md) +(invariant 48). + +## When to use it + +`POST /v1/brl/createSubaccount` reserves a CPF/CNPJ for the first authenticated caller. The +unique index `ux_provider_customers_tax_hash` makes that reservation exclusive at once, before +the provider has verified who the caller is. If someone claimed a tax ID that is not theirs, the +real owner's `createSubaccount` fails with `409 A subaccount already exists for this taxId` and +has no self-service recovery. Use this runbook when a person (or company) shows that a tax ID +they own is held by a profile that is not theirs. + +Do not use it when the holder's row is `approved`: the provider matched documents to that tax +ID, so the holder is most likely the genuine owner. Escalate to engineering and compliance. + +## Ground rules + +- Reads use the read-only connection. Changes need a write connection and a second person who + reviews the verified row IDs before the change runs. +- Record the tax ID only as its hash in tickets and logs. Never paste the raw CPF/CNPJ. +- Run the change in one transaction and check every affected-row count against the read steps + before committing. + +## 1. Identify the claim + +Compute the hash the same way `hashTaxReference` does (SHA-256 of the digits-only value) and look +up the holder: + +```sql +SELECT encode(sha256(convert_to('', 'UTF8')), 'hex') AS tax_hash; + +SELECT pc.id, pc.customer_type, pc.status, pc.status_external, pc.provider_subaccount_id, + pc.created_at, ce.profile_id AS holder_profile_id +FROM provider_customers pc +JOIN customer_entities ce ON ce.id = pc.customer_entity_id +WHERE pc.provider = 'avenia' AND pc.tax_reference_hash = ''; +``` + +Expect exactly one row and a status other than `approved`. Note `pc.id`, +`provider_subaccount_id` and `holder_profile_id`. + +## 2. Check what the holder has started + +```sql +SELECT id, type, status, status_external, provider_case_id, submitted_at, + verification_method, verification_submission->>'status' AS submission_status +FROM kyc_cases +WHERE provider = 'avenia' AND provider_customer_id = ''; + +SELECT id, current_phase, created_at FROM ramp_states WHERE user_id = ''; +``` + +- A case with `provider_case_id` set, `in_review` status, or a `submitted`/`confirmed` submission + means an attempt exists at the provider. Ask the provider about that attempt before going on; + do not release while it could still be approved for this tax ID. +- The ramp query is expected to return no rows: onboarding must be approved before a ramp can be + registered. Stop and escalate if it returns any. + +## 3. Find the exactly-once claim + +`createSubaccount` records a `financial_operations` row keyed by the tax hash. If it is left in +place, the real owner's retry hits it with a different request hash (the owner profile is part +of the request) and fails with `409 ... already claimed with different inputs`. + +```sql +SELECT id, status, external_id, created_at +FROM financial_operations +WHERE scope_type = 'profile' AND scope_id = '' + AND phase = 'createSubaccount' AND provider = 'avenia'; +``` + +Expect exactly one row whose `external_id` equals the `provider_subaccount_id` from step 1. Any +other result means the state is not the expected squat; stop and escalate. + +One variant is still a squat: step 1 returns no row, but this query returns a row. The holder's +provider call or local write did not finish, so only the claim remains and it blocks the real +owner the same way. If its `status` is `submitted` or `unknown`, a provider subaccount may exist +without any local record; ask the provider before deleting the row, and treat `external_id` (if +set) as the subaccount for step 4. Skip the `provider_customers` and `kyc_cases` deletes in +step 5. + +## 4. Decide on the provider subaccount + +Vortex has no call to close or delete a provider subaccount, and the create call carries only +account type and name, so the provider learns the tax ID only if a KYC attempt was submitted +(step 2). Ask the provider to close or flag the subaccount id from step 1, or record it as +abandoned in the operations log. Never reassign it to the real owner: it carries the holder's +unapproved state and data. The real owner's retry creates a fresh subaccount. + +## 5. Release + +Only after steps 1-4 match, in one transaction: + +```sql +BEGIN; +DELETE FROM kyc_cases + WHERE provider = 'avenia' AND provider_customer_id = '' AND status <> 'approved'; -- rows from step 2 +DELETE FROM provider_customers + WHERE id = '' AND provider = 'avenia' AND status <> 'approved'; -- exactly 1 +DELETE FROM financial_operations + WHERE id = '' AND scope_id = '' AND phase = 'createSubaccount'; -- exactly 1 +-- compare each reported count with the read steps, then COMMIT or ROLLBACK +``` + +The holder's `customer_entities` row is the profile's own entity and stays. Decide separately +whether the holder profile should be suspended under the abuse policy. + +## 6. Verify and follow up + +- Ask the real owner to retry onboarding. `createSubaccount` should return `200` with a new + `subAccountId`, and the tax hash should now resolve to a row owned by the owner's profile. +- Log the tax hash, the deleted row IDs, the approver and the provider ticket. A second release + within a quarter is the trigger in RISK-026 to make the reservation exclusive only on approval. diff --git a/docs/security-spec/05-integrations/brla.md b/docs/security-spec/05-integrations/brla.md index 17eaeb65b..1f647081a 100644 --- a/docs/security-spec/05-integrations/brla.md +++ b/docs/security-spec/05-integrations/brla.md @@ -47,6 +47,8 @@ The catalog resolves every supported EVM source through `BrlOfframpBase`. Its so Avenia requires a subaccount per user, identified by tax ID (CPF for individuals, CNPJ for businesses). The system creates and manages subaccounts through canonical `provider_customers` rows (`provider = 'avenia'`) owned by the user's `customer_entities` row. `provider_subaccount_id` stores the Avenia subaccount identifier, while normalized tax-ID lookup uses `tax_reference_hash`. +`createSubaccount` is a **reserving flow**: the unique `ux_provider_customers_tax_hash` index makes the first authenticated caller the exclusive owner of a tax ID immediately, before the provider has verified who that caller is (invariants 5, 18, 48; RISK-026). Possession of the CPF/CNPJ is proven only later, by provider approval. + `POST /v1/brl/createSubaccount` accepts an **optional** `quoteId`. In the normal ramp flow it is the quote that triggered onboarding; in quote-less onboarding paths such as the **KYB deep link** (`?kyb` / `?kybLocked` widget entry, where business verification starts before any quote exists) and authenticated dashboard sender onboarding, it is omitted. Quote provenance is not persisted because those write-only fields were dropped in the `provider_customers` cutover. The value is never used as an authorization input, so its absence does not weaken any access check: the ownership guard (below) and authenticated user context gate subaccount creation independently of whether a quote is present. ### Inbound verification webhook (`POST /v1/webhooks/avenia`) @@ -151,7 +153,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 2. **PIX payout amount MUST equal `quote.outputAmount`** — `createPayOutQuote.outputAmount` is derived from the immutable stored quote; the user receives exactly the quoted net BRL (after Avenia anchor fee). 3. **The on-chain BRLA transfer amount MUST equal `quote.metadata.nablaSwapEvm.outputAmountRaw`** — This guarantees the full Nabla output reaches Avenia; Avenia keeps the anchor fee and pays the user the net amount. 4. **`brlaPayoutOnBase` MUST NOT initiate the PIX payout until the Avenia balance reflects the deposit** — The balance poll prevents calling `createPixOutputTicket` against funds that have not yet been credited. -5. **User tax ID (CPF) MUST be validated** — CPF format validation at ramp registration, not at payout time. +5. **A tax ID MUST be checksum-validated before it is claimed** — `validateSubaccountCreation` runs ahead of the `createSubaccount` controller in every environment, including sandbox. It rejects with `400`, before any `financial_operations` claim, provider POST, or `provider_customers`/`customer_entities` write: an unknown `accountType`; a `name` that is missing, not a string, blank after trimming, or longer than 255 characters (the `provider_customers.company_name` width); and a `taxId` that is missing, not a string, or not a check-digit-valid CPF for `INDIVIDUAL` / CNPJ for `COMPANY` (punctuation optional, surrounding whitespace ignored). Ramp registration does not re-run the checksum: it derives the account from the authenticated profile and treats a supplied `taxId` only as an equality cross-check (invariant 16). 6. **Avenia subaccount creation MUST be idempotent** — A durable `financial_operations` claim keyed by the normalized tax-ID hash is persisted before the provider POST. Confirmed results can therefore repair a failed local write without another POST, while submitted/unknown outcomes stop with a reconciliation error because Avenia accepts no idempotency key. Persistence is additionally serialized by normalized tax ID across API instances. If an owned canonical row already has a provider subaccount ID, retries return it unchanged, repair a missing local KYC case, and MUST NOT call Avenia, replace the ID, or reset verification. Ramp registration snapshots the chosen subaccount ID into persisted block facts so execution and recovery cannot drift to a later mutable lookup. 7. **PIX payment confirmation MUST be verified before advancing on-ramp** — `brlaOnrampMint` polls the Base ephemeral balance; advancement only on confirmed BRLA arrival. 8. **Avenia API responses MUST be validated** — Status codes, ticket IDs, and amount confirmations must be checked. `AveniaTicketStatus.FAILED` must throw an unrecoverable error; `AveniaTicketStatus.PARTIAL_FAILED` must be handled as a ticket-specific partial failure and must not be polled indefinitely or treated as a generic success. @@ -164,7 +166,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 15. **BRL→EVM quote output precision MUST match the destination token** — For supported EVM destinations, `quote.outputAmount` MUST preserve the destination token's decimal precision, and `evmToEvm.outputAmountRaw` MUST represent the destination token's raw units. The Squid bridge input remains Base USDC raw (`evmToEvm.inputAmountRaw`), but final delivery uses destination-token decimals. 16. **BRL register paths MUST derive tax ID / subaccount from the effective user** — The catalog flow's `AveniaMint.register` and `AveniaOfframpPayout.register` hooks resolve the Avenia account via `resolveAveniaAccountForRamp(userId, additionalData.taxId)` and call the block-owned `createAveniaOnrampTicket` / `validateAveniaOfframpRecipient` logic in `phases/blocks/core/avenia-registration.ts`. That module owns pending-BRL aggregation, BRL/global limit enforcement, PIX-owner masked-tax-ID matching, trusted subaccount wallet resolution, and onramp ticket creation. A client-supplied `additionalData.taxId` is accepted only when it matches the derived value (enforced identically on the onramp and offramp paths); mismatches return `400`. `additionalData.receiverTaxId` may legitimately differ from the sender and is validated downstream against the PIX key owner. `RampService` only dispatches through the flow recorded in quote metadata and projects phase-owned facts/artifacts into legacy ramp state; it does not own Avenia registration operations. 17. **`/v1/brla/getUser` and `/v1/brla/getUserRemainingLimit` MUST scope reads to the effective user** — When a `taxId` query is provided, the matching Avenia `provider_customers` row MUST belong to a `customer_entities` row owned by `getEffectiveUserId(req)`. When `taxId` is omitted, the endpoint derives the user's Avenia account via the resolver and returns `400` for zero or multiple KYC-completed matches. The legacy partner-key exemption that allowed reading any taxId has been removed; bare partner keys without a profile binding and fully anonymous callers are rejected with `400`. -18. **`/v1/brl/createSubaccount` MUST require an authenticated principal and use only canonical identity** — The route uses `requirePartnerOrUserAuth()` and the controller requires an effective user. Bare partner keys and anonymous callers receive `400`; the Avenia API is not called and no `provider_customers` row is created. Existing-tax-ID conflict and reuse decisions inspect only canonical Avenia `provider_customers` ownership. The controller does not query or adopt rows from `tax_ids`. +18. **`/v1/brl/createSubaccount` MUST require an authenticated principal and use only canonical identity** — The route uses `requirePartnerOrUserAuth()` and the controller requires an effective user. Bare partner keys and anonymous callers receive `400`; the Avenia API is not called and no `provider_customers` row is created. Existing-tax-ID conflict and reuse decisions inspect only canonical Avenia `provider_customers` ownership. The controller does not query or adopt rows from `tax_ids`. The claim is validated first (invariant 5), but it remains first-come (invariant 48). 19. **BRL quote creation MUST remain anonymous-eligible while register/start remain user-gated** — `POST /v1/quotes` and `POST /v1/quotes/best` accept BRL corridors from anonymous callers and partner-key callers (with or without a `userId` binding). The Avenia `createPayInQuote` calls used by the BRL engines do not require a user-bound principal. The actual Avenia subaccount/taxId resolution still happens server-side at register time via `resolveAveniaAccountForRamp(effectiveUserId, additionalData.taxId)`. `POST /v1/ramp/register` requires Supabase or secret-key credentials, and `RampService.registerRamp` rejects provider-backed ramps without an effective user with `400 Invalid quote`. **An anonymous BRL quote may be claimed by an authenticated caller** (the normal web-app funnel: quote before login, register after) — claiming is not an escalation because the anonymous quote carries no owner and the Avenia identity is derived from the claimer's own KYC records, never from the quote or request body. 20. **`brlaPayoutOnBase` MUST verify the ephemeral's BRLA balance before the first broadcast of the presigned transfer** — The presigned payout is single-use (its nonce is consumed even on revert), so the handler calls `ensurePresignedTransferFunded` before `sendRawTransaction`: sender/token/amount are decoded from the signed raw tx and the ephemeral balance is polled (3-minute timeout); a shortfall raises a recoverable error instead of burning the nonce. The Avenia-side balance poll (invariant 4) runs after this on-chain transfer and does not replace it. See `03-ramp-engine/ramp-phase-flows.md` invariant 12. 21. **Avenia company KYB completion MUST be provider-confirmed and ownership-bound** — `POST /v1/brla/kyb/new-level-1/web-sdk` stores the returned Avenia `attemptId` as the owned business `kyc_cases.provider_case_id`. Every exact-attempt read, including authenticated status, dashboard reconciliation, and the notification fallback worker, MUST send both that attempt ID and the owning business account's `provider_subaccount_id` and MUST reject a mismatched response ID. `GET /v1/brla/kyb/attempt-status` accepts only a case owned by the effective user, persists normalized status on both the case and provider customer, and returns only `status`, `retryable`, optional `result`, and optional normalized `failureReason`; dashboard reconciliation uses the same locked provider-customer-before-case persistence path and cannot downgrade terminal state. The two paths MUST settle a terminal outcome identically: whichever wins the race also persists the normalized failure reason on both canonical rows and enqueues the outcome notification in the same transaction, idempotently keyed on the attempt id — the loser (the route answers `409` on a settled case; the worker skips terminal cases) can no longer write either. Client-side events cannot assert completion: only provider `COMPLETED` plus `APPROVED` may complete onboarding; `REJECTED`, `EXPIRED`, `PENDING`, and `PROCESSING` must not pass the parent verification gate. @@ -182,7 +184,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 33. **The webhook body MUST be runtime-validated before any property is read** — A valid signature proves only that Avenia sent the bytes. `JSON.parse` alone admits `null`, arrays, scalars, and attempts missing the fields an email is rendered from, so the receiver accepts Avenia's two documented envelopes (top-level `subAccountId` or nested `event.accountId`), normalizes them, and validates the account id plus `subscription` and, when one is present, the attempt (`id`, `status`, `updatedAt` as non-empty strings; `result` and `resultMessage` as strings when present) before the first property access or database lookup. Anything failing that returns a deterministic `400` and enqueues nothing. An unrecognised *value* of `status` or `result` is not a validation failure: it is a well-formed event with no email mapped to it, and is acknowledged `200` so Avenia does not retry it indefinitely. 34. **A provider-confirmed paid initial PIX ramp on a runtime-enabled flow MUST be recoverable without current managed-profile authorization** — The client start deadline and current managed corridor/type policy continue to govern public update/start calls. The unhandled-payment worker separately compares the ramp's exact persisted Avenia ticket with the provider's `PAID` tickets. Runtime-enabled initial ramps remain pollable through the worker's three-day age window when the ticket is absent, has an unknown/non-paid status, or carries a historical unhandled-payment alert flag. When a signed, still-`initial` ramp is paid, the worker starts the persisted flow under a row lock without applying the expired client deadline or re-authorizing the now-committed manager policy. Only successful recovery suppresses later worker cycles; a failed automatic attempt remains eligible and alerts operations. Moonbeam-dependent AssetHub flows are excluded under RISK-020: the worker does not poll, recover, or alert on them, and operations must reconcile them manually. Startup compatibility checks retain the ramp's persisted flow version while such a payable ticket exists. 35. **Ramp updates MUST NOT modify persisted Avenia recovery identity** — `POST /v1/ramp/update` accepts only the documented client-reported transaction-hash fields in `additionalData`. It rejects every other key with `400`, including the registration-owned `taxId`, `subAccountId`, `aveniaTicketId`, and nested `blockState`. The unhandled-payment worker therefore compares paid provider tickets against the immutable identity snapshotted by Avenia registration. -36. **KYC preflight MUST NOT reserve a client-asserted tax identity** — `POST /v1/brla/kyc/record-attempt` may validate authentication, managed BR authorization, quote ownership, and the BRL corridor for compatibility, but MUST NOT create a `provider_customers` or `kyc_cases` row from its client-supplied CPF/CNPJ. Quote ownership proves only quote ownership. The globally unique Avenia tax hash is persisted only by the authenticated subaccount creation flow that establishes the canonical provider account. +36. **KYC preflight MUST NOT reserve a client-asserted tax identity** — `POST /v1/brla/kyc/record-attempt` may validate authentication, managed BR authorization, quote ownership, and the BRL corridor for compatibility, but MUST NOT create a `provider_customers` or `kyc_cases` row from its client-supplied CPF/CNPJ. Quote ownership proves only quote ownership. The globally unique Avenia tax hash is persisted only by the authenticated subaccount creation flow that establishes the canonical provider account. That flow reserves first-come (invariant 48); removing the preflight reservation did not remove the squatting exposure, it narrowed it to the validated `createSubaccount` path (RISK-026). 37. **Concurrent Avenia KYB case creation within one API process MUST converge on one operation** — `getOrCreateAveniaKybCase` coalesces in-flight creation by provider-customer ID before calling Sequelize, so simultaneous submissions handled by the same process receive the same canonical case. The entry is removed after success or failure so later reads and retries still consult the database. This is intentionally process-local and does not provide a cross-replica database uniqueness guarantee. 38. **UBO creation MUST fail closed after an ambiguous provider outcome** — Before sending an Avenia UBO creation request, Vortex locks the provider customer, requires one canonical KYB case, and records a `prepared` submission using one-way identity and full-payload fingerprints; raw UBO identity payloads and document IDs are not persisted in this state. A parsed provider response records `confirmed` and its UBO ID. Transport errors, timeouts, rate limits, conflicts, and provider failures record `ambiguous`, and subsequent requests for that identity return `409` without another provider POST until an operator reconciles the outcome. Deterministic client rejections record `failed` and may be corrected and retried. 39. **Active-attempt reconciliation MUST NOT overwrite terminal KYB state** — Reconciliation locks and rereads both the provider customer and KYB case before applying `pending` or `in_review`. If either row has become approved or rejected since the provider attempt list was fetched, the stale active response is ignored and terminal status and lifecycle metadata remain intact. @@ -194,6 +196,7 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou 45. **The Avenia webhook MUST remain notification-only for imported KYC** — A signed event may enqueue an idempotent notification but MUST NOT approve or otherwise mutate verification state. Exact-attempt polling remains the authoritative persistence path. 46. **Consent evidence MUST remain explicit and provisional** — The request requires literal `consentAttested: true`; Vortex appends an entry containing actor, subject, timestamp, and server-controlled policy `sumsub-share-v1` to `verification_submission.consentAttestations` without the raw token. Replacing a provider-`401` failed claim with a new idempotency key MUST preserve prior entries and append the new attestation. This policy is enabled provisionally and MUST NOT be represented as replacing legal basis, applicant disclosure, biometric/special-category consent, or data-transfer obligations while legal/provider confirmations remain unresolved. 47. **Standard individual KYC submission MUST be durable, request-bound, authorized at claim time, and privacy-safe** — Vortex MUST persist the submission JSON on the locked canonical case before the Avenia Level 1 POST and atomically persist its complete attempt baseline and case `submitted_at` when claiming it as `submitted`. Deterministic provider client rejections create no attempt, so they record `failed` with a fixed `400` and may be corrected and retried, mirroring UBO creation. Timeouts, rate limits, conflicts, 5xx, and local-confirmation outcomes become `ambiguous`; reconciliation uses that timestamp, excludes baseline and attempts bound to other cases, fails closed when it is absent, and never sends a second POST for an active claim. Avenia's own status and detail MUST NOT be forwarded to the caller. Prepared, submitted, ambiguous, and confirmed reuse MUST match actor, subject, and the SHA-256 digest of the canonical flat payload before reconciliation or replay; mismatches return a fixed `409` without a provider POST. Unmanaged calls MUST have `actor === subject` with no managed selectors. Managed calls MUST identify the controlling manager separately, allow only `actor === controlling manager` or `actor === subject`, and lock and revalidate the controlling active manager, exact active manager/subject/relationship ID, `BR` permission, null-means-all individual policy, managed subject, and expected active individual entity during preparation and immediately before `prepared -> submitted`. Second-check revocation or a concurrent canonical approval MUST prevent the provider POST. Confirmation MUST bind its exact attempt ID even when approval won concurrently, without downgrading terminal state or timestamps. A provider-confirmed retryable terminal may replace the prior confirmed JSON with a prepared claim carrying the new request fingerprint; confirmation clears prior case approval/rejection timestamps and failure reasons before returning it to pending. A standard provider state of `COMPLETED` without `APPROVED` or `REJECTED` MUST fail closed before either direct-status or onboarding persistence. The standard identity payload MUST use the Avenia client's sensitive-body mode, and raw payloads, digest inputs, request logs, provider error details, and thrown errors MUST NOT contain identity values echoed by Avenia. +48. **A tax-ID claim MUST be bound to the identity later verified on that subaccount** — The unique tax hash is exclusive from the first `createSubaccount`, so the identity the claimant submits through the API MUST be that tax ID. `newKyc` MUST return `400` before any provider call or KYC claim when the normalized `taxIdNumber` (non-string values included) does not hash, via `hashTaxReference`, to the resolved record's `tax_reference_hash`. `submitKybLevel1Api` MUST apply the same rule to `taxIdentificationNumberTin` before any provider read or write. Formatted and plain forms of one tax ID are equal. The claim is released only by an operator (`docs/operations-brl-tax-id-claim-release.md`); no request path releases another profile's unapproved claim. This binds only the API-submitted identity: Avenia subaccount creation sends only `accountType` and `name`, so the hosted KYB flow (`initiateKybLevel1`) and provider approval are not compared with the claimed tax ID, and `assertAveniaImportedTaxIdentity` (Sumsub token import) passes when the provider returns no tax ID (RISK-026). ## Threat Vectors & Mitigations @@ -220,6 +223,8 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou | **Company KYB status bypass or cross-user attempt lookup** | A browser asserts that hosted verification finished, or probes another user's Avenia attempt ID and receives provider submission metadata. | Initiation binds the attempt to the authenticated user's KYB case; every exact lookup checks that binding and scopes the provider request with the owning subaccount before the call, rejects a mismatched response ID, minimizes its response, and accepts only provider-confirmed `COMPLETED` + `APPROVED`. | | **Duplicate KYB attempt while provider processing is active** | A caller starts another API or hosted KYB attempt while Avenia is already processing one for the company. | Both creation paths list attempts for the ownership-verified subaccount. Hosted creation rejects an active attempt. API creation transactionally binds exactly one active attempt and returns it without POSTing; ambiguous multiple-active results fail closed. A provider `409` triggers the same scoped re-query and exact-one reconciliation. Terminal attempts are left to Avenia's retry rules. | | **Tax-ID reservation through KYC preflight** | An authenticated attacker owns a BRL quote but submits a victim's valid CPF/CNPJ to the initial-attempt endpoint, attempting to occupy the globally unique Avenia tax hash. | The endpoint retains its empty compatibility response and quote checks but performs no identity persistence. Only canonical subaccount creation may create the globally reserving provider-customer row. | +| **CPF/CNPJ squatting through subaccount creation** | An authenticated attacker (open email-OTP signup) submits a target's valid CPF/CNPJ to `createSubaccount` before the owner does. The unique tax hash gives the attacker exclusive ownership; the real owner gets `409` and cannot recover unaided. | Mitigated, not eliminated (RISK-026). Checksum, type-match, and `name` validation reject junk ids, including the `sha256("")` bind and the TypeError path (invariant 5); claiming is not capped per principal, because it is indistinguishable from a partner onboarding new customers; the identity submitted through `newKyc` / the KYB API must match the claim (invariant 48; the hosted KYB flow is not bound); an unapproved claim is releasable by the operator runbook. Residual: an attacker with many sessions or IPs can still claim many valid ids, and the claim stays exclusive until an operator releases it. The full fix (exclusive only once the provider approves) is not implemented. | +| **Tax-ID existence enumeration** | An attacker probes `getUser`, `getKycStatus`, `getUserRemainingLimit`, `getUploadUrls`, or `getSelfieLivenessUrl` with candidate ids and reads the differing answers for unknown vs. other-owned tax IDs. | Ownership checks are unchanged; probing is bounded only by the global 100 req/min per IP limit. Residual (RISK-026): status codes are deliberately not collapsed (`packages/kyc` treats `404` from `getUser` as "create the subaccount"; the SDK treats `404` from `getUserRemainingLimit` as "skip pre-flight"), so whether a tax ID is registered to another profile stays observable, and many principals or IPs multiply the per-IP limit.| **KYC/KYB identity swap on a claimed tax ID** | An attacker claims CPF/CNPJ X, then submits KYC/KYB with different documents and a different tax ID Y so the provider approves Y while Vortex stores X. | `newKyc` and `submitKybLevel1Api` reject a mismatching `taxIdNumber` / `taxIdentificationNumberTin` with `400` before any provider call (invariant 48). The provider remains the authority on whether the documents match the submitted tax ID. Residual: the hosted KYB flow and provider approval are not compared with the claimed tax ID (invariant 48, RISK-026). | | **Share-token replay or ambiguous duplicate import** | A timeout or malformed provider response causes the caller to resend a bearer-like identity-transfer token, potentially creating multiple attempts or transferring data twice. | A durable pre-send claim and token digest serialize submission. Same-key/same-token retries may reconcile through provider reads without another POST or token send. Deterministic provider rejections created no attempt, so they are failed/retriable with a new key; other unresolved outcomes remain quarantined and are never automatically replayed. | | **Share-token disclosure** | Request/error logging, telemetry, provider errors, or support tooling captures the token and enables unauthorized identity-data transfer. | Sensitive-body provider mode, flat sanitized errors, strict observability exclusion, request-memory-only handling, and digest-only persistence prevent raw-token persistence or emission. | | **KYC completion spoofing** | A caller treats token possession, import acceptance, a Sumsub result, or a webhook as proof of approval. | The canonical case binds one exact Avenia attempt; only exact polling of Avenia `COMPLETED + APPROVED` can approve. `EXPIRED` remains non-approved and pending reconciliation, and the webhook is notification-only. | @@ -231,7 +236,9 @@ The invariant `transferAmount ≥ payoutAmount` must hold (transfer covers payou - [x] BRL↔AssetHub runtime-disabled. **PASS** — quote eligibility rejects both directions, registration/start return unavailable, and the phase processor holds persisted flow identities before a lock or executor. The catalog remains only for decoding and history compatibility. - [x] `brlaPayoutOnBase` PIX amount equals `quote.outputAmount`. **PASS** — `createPayOutQuote.outputAmount = amountForQuote = new Big(quote.outputAmount).round(2,0)`. - [x] On-chain BRLA transfer amount equals the subsidy-adjusted full swap output. **PASS** — `metadata.blocks.aveniaOfframpPayout.transferAmountRaw` is derived from the post-subsidy BRLA phase input and is used unchanged by the payout transaction preparer; the PIX amount remains immutable `quote.outputAmount`. -- [x] User CPF/tax ID is validated at ramp registration (not at payout). **PASS** — CPF validation present in registration flow. +- [x] A CPF/CNPJ is checksum-validated before it is claimed. **PASS** — `validateSubaccountCreation` rejects an invalid, wrongly typed, or non-string `taxId` and an invalid `name` with `400` before the controller in every environment; `validators.test.ts` and the `createSubaccount` controller tests assert that malformed bodies never reach the provider mock or the database. Ramp registration does not re-run the checksum (it derives the account from the profile). +- [x] `newKyc` and the KYB API submission are bound to the claimed tax ID. **PASS** — a `taxIdNumber` / `taxIdentificationNumberTin` that is not a string or does not match the claimed tax ID (punctuation ignored) returns `400` before any provider call; controller tests cover mismatch, non-string, and formatted-equivalent inputs. +- [ ] Tax-ID squatting and enumeration are fully closed. **OPEN (RISK-026)** — accepted lean mitigation only; a valid unclaimed tax ID can still be claimed exclusively by any signed-in session until an operator releases it. - [x] Avenia subaccount creation is idempotent. **PASS** — returns an owned canonical account unchanged; otherwise a durable provider-operation claim makes confirmed results replayable and ambiguous results reconciliation-only before the provider call can be repeated. - [x] Paid initial PIX ramps are recovered automatically. **PASS** — the unhandled-payment worker keeps absent, unknown, non-paid, and historically alerted initial tickets pollable through the three-day age window, starts the persisted flow only after Avenia reports the exact ticket `PAID`, suppresses successful recovery, and retries failed attempts while alerting operations. - [x] Ramp updates cannot replace Avenia recovery identity. **PASS** — `RampService.updateRamp` allowlists client-reported transaction hashes and rejects registration-owned identity and block state. diff --git a/docs/security-spec/RISK-REGISTER.md b/docs/security-spec/RISK-REGISTER.md index 0d68a7221..f4ccaef16 100644 --- a/docs/security-spec/RISK-REGISTER.md +++ b/docs/security-spec/RISK-REGISTER.md @@ -46,6 +46,7 @@ register and the owning module specification. | RISK-023 | Accepted | High | Payments Platform + Operations | The active Monerium EUR onramp attributes settlement from the linked owner's EURe balance increasing by the quoted post-fee amount. It does not correlate a provider issue order or mint transaction to the ramp, so an unrelated, duplicate, late, concurrent, or replacement-ramp credit can satisfy the delta. | Registration snapshots the owner balance after resolving one approved profile/Polygon EOA/IBAN match and rejects a second live ramp for the same owner (which would share the permit nonce and race for one credit); IBAN moves are refused while a ramp waits on the current wallet; execution transfers only the exact quoted amount; excess stays with the owner; caller-controlled provider identity is rejected. | Implement deterministic provider-order or mint-transaction correlation before increasing Monerium volume, operating concurrent/replacement ramps for one owner, or claiming payment-level attribution. | | RISK-024 | Accepted | High | Payments Platform + Product | The Monerium owner permit expires one week after preparation (the swap presign deadline) and can become stale if its nonce is consumed before SEPA settlement. There is no automatic reauthorization, refund, or recovery path, so late settlement can leave EURe in the owner's wallet and require manual resolution. | Payment instructions are withheld until the exact permit and downstream presigns validate; execution rechecks nonce, deadline, allowance, and balances and fails closed rather than broadening authorization. | Add a safe re-sign/recovery/refund flow and define the accepted SEPA settlement window before unattended operation at material volume. | | RISK-025 | Accepted | Medium | Payments Platform + Product | Monerium profiles onboarded through the OAuth application are invisible to the white-label application, so their EUR readiness and ramp registration depend on an access/refresh token pair that exists only in backend memory. A backend restart, token revocation, or refresh failure makes such a user unregisterable until they reconnect Monerium. | Reads and registration fail closed with `MONERIUM_REAUTHENTICATION_REQUIRED`; the dashboard and widget prompt a reconnect; no token is persisted, so nothing at rest can be stolen. | Add an encrypted refresh-token store or migrate OAuth profiles into the white-label application before relying on unattended re-registration or observing reconnect prompts at material volume. | +| RISK-026 | Accepted | Medium | Payments Platform + Product + Operations | Brazilian tax-ID squatting and enumeration. `createSubaccount` reserves a CPF/CNPJ exclusively for the first authenticated caller (unique `ux_provider_customers_tax_hash`) before the provider has verified who the caller is, and Supabase sign-up is open OTP. Any signed-in session can therefore claim a valid, unclaimed tax ID; the real owner then receives `409` and cannot recover without an operator. The tax-keyed lookup routes also answer differently for unknown and other-owned tax IDs, so they can be used to test whether a CPF is registered. The product owner accepted a lean mitigation instead of making the reservation exclusive only on provider approval. | Checksum, account-type and `name` validation before any provider call or write; `newKyc` / KYB submission bound to the claimed tax ID (the hosted KYB flow and provider approval are not bound); the global 100 req/min per IP limiter, which is the only bound on tax-ID existence probing; unchanged ownership checks and status codes; operator release runbook ([`operations-brl-tax-id-claim-release.md`](../operations-brl-tax-id-claim-release.md)). | Implement exclusivity only on provider approval (unapproved claims contestable or expiring) before BRL onboarding volume grows materially, when a second squat needs an operator release, or when tax-ID enumeration is observed (then answer other-owned and unregistered tax IDs identically, or cap probes per principal in the database). | ## Review cadence