diff --git a/backend/src/encryption/index.ts b/backend/src/encryption/index.ts index 8f6cfdef..60aae910 100644 --- a/backend/src/encryption/index.ts +++ b/backend/src/encryption/index.ts @@ -34,6 +34,15 @@ const PII_FIELDS: Record> = { AuditLog: { ipAddress: {}, }, + // Provider credentials at rest (#1010). Webhook signing secrets and push auth + // keys are the provider credentials persisted through Prisma today; the + // provider-credential vault (`services/provider-credentials`) covers the rest. + Webhook: { + secret: {}, + }, + PushSubscription: { + auth: {}, + }, }; // --------------------------------------------------------------------------- diff --git a/backend/src/index.ts b/backend/src/index.ts index ff123155..1f66b3a0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,7 @@ import { formsRouter } from './routes/forms.js'; import { twoFactorAuthRouter } from './routes/2fa.js'; import { webhooksRouter } from './routes/webhooks.js'; import { webhookHandlersRouter } from './routes/webhookHandlers.js'; +import { webhookJsonParser } from './middleware/webhookVerification.js'; import { startJobs, getJobScheduler } from './jobs/index.js'; import { batchProcessor } from './services/batch.js'; import { featureFlags } from './config/featureFlags.js'; @@ -134,6 +135,7 @@ import { getScheduledTasks } from './config/scheduled-tasks.js'; import { bullMQMonitorRouter } from './routes/bullmq-monitor.js'; import { fileUploadRouter } from './routes/file-upload.js'; import { credentialRotationRouter } from './routes/credential-rotation.js'; +import { providerCredentialsRouter } from './routes/provider-credentials.js'; import zkIdentityRouter from './routes/zk-identity.js'; import { coldStartMonitorRouter } from './routes/cold-start-monitor.js'; import { rateLimitAnalyticsRouter } from './routes/rate-limit-analytics.js'; @@ -230,6 +232,10 @@ app.use(httpLogger); // Incoming webhooks: raw body capture before global JSON parser (#393) app.use('/webhooks', webhookHandlersRouter); +// Bank-partner payout webhooks need the raw body for HMAC verification (#1006). +// Mounted before the global JSON parser so `req.rawBody` is populated. +app.use('/api/v1/fiat-payments/webhooks', webhookJsonParser); + // KYC document uploads carry base64 file contents (up to 10 MB decoded) (#921) app.use('/api/v1/kyc', express.json({ limit: '15mb' })); app.use(express.json()); @@ -392,6 +398,7 @@ app.use('/api/v1/uploads', fileUploadRouter); // Credential rotation management with dual-key overlap and audit trail (Issue #395) app.use('/api/v1/credentials', credentialRotationRouter); +app.use('/api/v1/provider-credentials', providerCredentialsRouter); // Microservices service mesh — registry, discovery, circuit breakers app.use('/api/v1/service-mesh', serviceMeshRouter); diff --git a/backend/src/middleware/webhookVerification.ts b/backend/src/middleware/webhookVerification.ts index edc7e0d5..2646af6d 100644 --- a/backend/src/middleware/webhookVerification.ts +++ b/backend/src/middleware/webhookVerification.ts @@ -9,6 +9,7 @@ import { verifyGithubProviderWebhook, verifyPaypalProviderWebhook, verifyCustomProviderWebhook, + verifyBankPartnerWebhook, type ProviderVerificationResult, } from '../services/webhooks/providers.js'; import { isReplayEvent } from '../services/webhooks/replay.js'; @@ -49,10 +50,18 @@ const providerVerifiers: Record = { }; export function verifyWebhookProvider(provider: WebhookProvider) { + return createWebhookVerifier(provider, providerVerifiers[provider]); +} + +/** + * Builds a verification middleware around any provider verifier. Extracted so + * additional partners (e.g. the bank-partner payout webhook, #1006) can reuse the + * same store/replay/queue behaviour without widening the `WebhookProvider` enum. + */ +export function createWebhookVerifier(provider: WebhookProvider, verify: ProviderVerifier) { return async (req: Request, res: Response, next: NextFunction) => { try { const rawBody = req.rawBody ?? (typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {})); - const verify = providerVerifiers[provider]; const result = verify(req, rawBody); storeWebhookPayload({ @@ -62,6 +71,7 @@ export function verifyWebhookProvider(provider: WebhookProvider) { signature: (req.headers['stripe-signature'] || req.headers['x-hub-signature-256'] || req.headers['x-signature'] || + req.headers['x-bank-signature'] || '') as string, verified: result.isValid, error: result.error, @@ -116,3 +126,6 @@ export const webhookVerifiers = { github: verifyWebhookProvider('github'), custom: verifyWebhookProvider('custom'), }; + +/** Bank-partner payout webhooks use the shared replay/queue machinery (#1006). */ +export const bankPartnerWebhookVerifier = createWebhookVerifier('custom', verifyBankPartnerWebhook); diff --git a/backend/src/routes/fiat-payments.ts b/backend/src/routes/fiat-payments.ts index 40ce9803..f7701f11 100644 --- a/backend/src/routes/fiat-payments.ts +++ b/backend/src/routes/fiat-payments.ts @@ -14,6 +14,7 @@ import { FiatPaymentStatus, fiatPaymentsService, } from '../services/fiat-payments.js'; +import { bankPartnerWebhookVerifier } from '../middleware/webhookVerification.js'; export const fiatPaymentsRouter = Router(); @@ -168,6 +169,7 @@ fiatPaymentsRouter.get( fiatPaymentsRouter.post( '/webhooks/bank-partner', + bankPartnerWebhookVerifier, validate(bankWebhookSchema), asyncHandler(async (req, res) => { const payment = fiatPaymentsService.handleWebhook(req.body); diff --git a/backend/src/routes/payouts.ts b/backend/src/routes/payouts.ts index 58c68e05..348ab997 100644 --- a/backend/src/routes/payouts.ts +++ b/backend/src/routes/payouts.ts @@ -318,6 +318,9 @@ function asValidationError(error: unknown): AppError { if (code === 'PAYOUT_ANOMALY_ALREADY_REVIEWED') { return new AppError(409, 'Anomaly flag has already been reviewed', 'ALREADY_REVIEWED'); } + if (code === 'IDEMPOTENCY_KEY_REUSE_MISMATCH') { + return new AppError(409, 'Idempotency-Key was reused with a different request body', code); + } if (!/^[A-Z_]+$/.test(code)) return new AppError(400, 'Invalid request', 'VALIDATION_ERROR'); return new AppError(400, code.toLowerCase().replace(/_/g, ' '), code); } @@ -341,6 +344,10 @@ payoutsRouter.post( if (typeof asset !== 'string' || !SUPPORTED_ASSETS.includes(asset)) { throw new AppError(400, `asset must be one of: ${SUPPORTED_ASSETS.join(', ')}`, 'VALIDATION_ERROR'); } + // The service-level idempotency store (#1005) outlives the middleware cache; pass + // the header through so a retry after an eviction still returns the original payout. + const idempotencyKey = + (req.get('Idempotency-Key') as string | undefined) ?? (req.get('X-Idempotency-Key') as string | undefined); try { const payout = payoutBatcher.schedule({ merchantId: requireId(merchantId, 'merchantId'), @@ -350,6 +357,7 @@ payoutsRouter.post( scheduledFor: optionalDate(scheduledFor, 'scheduledFor'), ...(provider !== undefined ? { provider } : {}), ...(reference !== undefined ? { reference } : {}), + ...(idempotencyKey ? { idempotencyKey } : {}), actorId: principal.id, }); res.status(201).json({ data: payout }); diff --git a/backend/src/routes/provider-credentials.ts b/backend/src/routes/provider-credentials.ts new file mode 100644 index 00000000..b4e39066 --- /dev/null +++ b/backend/src/routes/provider-credentials.ts @@ -0,0 +1,114 @@ +/** + * provider-credentials.ts (routes) — Issue #1010 + * + * Admin surface for the encrypted-at-rest provider credential vault: + * - GET /provider-credentials → list metadata (no secrets) + * - PUT /provider-credentials/:provider/:name → store / replace a secret + * - GET /provider-credentials/:provider/:name → metadata for one credential + * - POST /provider-credentials/:provider/:name/reveal → decrypt a secret (audited) + * - DELETE /provider-credentials/:provider/:name → remove a secret + * - POST /provider-credentials/rotate-key → re-encrypt under the current master key + */ + +import { Router, type Request } from 'express'; +import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { providerCredentialVault as vault } from '../services/provider-credentials.js'; + +export const providerCredentialsRouter = Router(); + +/** Best-effort actor identity for the audit trail; never used for authorisation here. */ +function actorId(req: Request): string { + const fromUser = req.user?.id; + if (typeof fromUser === 'string' && fromUser.length > 0) return fromUser; + const header = req.headers['x-actor-id']; + return typeof header === 'string' && header.length > 0 ? header : 'unknown'; +} + +function param(value: string | string[]): string { + return Array.isArray(value) ? value[0] : value; +} + +providerCredentialsRouter.get( + '/', + asyncHandler(async (req, res) => { + const provider = typeof req.query.provider === 'string' ? req.query.provider : undefined; + const credentials = vault.list(provider ? { provider } : {}); + res.json({ data: credentials, count: credentials.length }); + }), +); + +providerCredentialsRouter.post( + '/rotate-key', + asyncHandler(async (req, res) => { + const result = vault.rotateMasterKey(actorId(req)); + if (result.failed.length > 0 && result.rotated === 0) { + throw new AppError(500, 'Credential re-encryption failed', 'PROVIDER_CREDENTIAL_ROTATION_FAILED', result); + } + res.json({ data: result }); + }), +); + +providerCredentialsRouter.put( + '/:provider/:name', + asyncHandler(async (req, res) => { + const { value, ttlMs } = (req.body ?? {}) as { value?: unknown; ttlMs?: unknown }; + if (typeof value !== 'string' || value.length === 0) { + throw new AppError(400, 'value is required', 'VALIDATION_ERROR'); + } + if (ttlMs !== undefined && (typeof ttlMs !== 'number' || !Number.isFinite(ttlMs) || ttlMs <= 0)) { + throw new AppError(400, 'ttlMs must be a positive number', 'VALIDATION_ERROR'); + } + + try { + const credential = vault.put({ + provider: param(req.params.provider), + name: param(req.params.name), + value, + ...(ttlMs !== undefined ? { ttlMs: ttlMs as number } : {}), + actorId: actorId(req), + }); + res.status(201).json({ data: credential }); + } catch (error) { + const code = error instanceof Error ? error.message : 'PROVIDER_CREDENTIAL_ERROR'; + throw new AppError(400, 'Invalid provider credential', code); + } + }), +); + +providerCredentialsRouter.get( + '/:provider/:name', + asyncHandler(async (req, res) => { + const credential = vault.get({ + provider: param(req.params.provider), + name: param(req.params.name), + }); + if (!credential) throw new AppError(404, 'Provider credential not found', 'NOT_FOUND'); + res.json({ data: credential }); + }), +); + +providerCredentialsRouter.post( + '/:provider/:name/reveal', + asyncHandler(async (req, res) => { + const value = vault.reveal({ + provider: param(req.params.provider), + name: param(req.params.name), + actorId: actorId(req), + }); + if (value === undefined) throw new AppError(404, 'Provider credential not found', 'NOT_FOUND'); + res.json({ data: { value } }); + }), +); + +providerCredentialsRouter.delete( + '/:provider/:name', + asyncHandler(async (req, res) => { + const removed = vault.delete({ + provider: param(req.params.provider), + name: param(req.params.name), + actorId: actorId(req), + }); + if (!removed) throw new AppError(404, 'Provider credential not found', 'NOT_FOUND'); + res.status(204).send(); + }), +); diff --git a/backend/src/routes/wallet.ts b/backend/src/routes/wallet.ts index 0f366301..abd585de 100644 --- a/backend/src/routes/wallet.ts +++ b/backend/src/routes/wallet.ts @@ -95,6 +95,33 @@ walletRouter.get( }), ); +// ─── Per-currency balances (#1009) ──────────────────────────────────────────── + +walletRouter.get( + '/balances', + asyncHandler(async (req: Request, res: Response) => { + const userId = String(req.query.userId || ''); + if (!userId) { + return res.status(400).json({ success: false, error: { message: 'userId is required' } }); + } + + const currency = typeof req.query.currency === 'string' ? req.query.currency : undefined; + if (currency) { + const balance = await WalletAggregationService.getCurrencyBalance(userId, currency); + if (!balance) { + return res.status(404).json({ + success: false, + error: { message: `No ${currency.toUpperCase()} balance for this user` }, + }); + } + return res.status(200).json({ success: true, data: balance }); + } + + const balances = await WalletAggregationService.getBalancesByCurrency(userId); + res.status(200).json({ success: true, data: balances }); + }), +); + // ─── Routing ────────────────────────────────────────────────────────────────── walletRouter.get( diff --git a/backend/src/services/__tests__/provider-credentials.test.ts b/backend/src/services/__tests__/provider-credentials.test.ts new file mode 100644 index 00000000..955c18bb --- /dev/null +++ b/backend/src/services/__tests__/provider-credentials.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ProviderCredentialVault } from '../provider-credentials.js'; +import { auditService } from '../auditService.js'; +import { evictTenantKeyCache, isEncrypted } from '../../encryption/column-encryptor.js'; + +interface RawVault { + credentials: Map; +} + +const ORIGINAL_KEY = process.env.COLUMN_ENCRYPTION_MASTER_KEY; +const ORIGINAL_OLD_KEY = process.env.COLUMN_ENCRYPTION_OLD_MASTER_KEY; + +function rawCiphertext(vault: ProviderCredentialVault, provider: string, name: string): string { + const raw = vault as unknown as RawVault; + return raw.credentials.get(`${provider}\u0000${name}`)?.ciphertext ?? ''; +} + +describe('ProviderCredentialVault (#1010)', () => { + let vault: ProviderCredentialVault; + + beforeEach(() => { + delete process.env.COLUMN_ENCRYPTION_MASTER_KEY; + delete process.env.COLUMN_ENCRYPTION_OLD_MASTER_KEY; + evictTenantKeyCache(); + vault = new ProviderCredentialVault(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + evictTenantKeyCache(); + if (ORIGINAL_KEY === undefined) delete process.env.COLUMN_ENCRYPTION_MASTER_KEY; + else process.env.COLUMN_ENCRYPTION_MASTER_KEY = ORIGINAL_KEY; + if (ORIGINAL_OLD_KEY === undefined) delete process.env.COLUMN_ENCRYPTION_OLD_MASTER_KEY; + else process.env.COLUMN_ENCRYPTION_OLD_MASTER_KEY = ORIGINAL_OLD_KEY; + }); + + it('stores the value as ciphertext, never plaintext', () => { + const secret = 'sk_live_super_secret_stripe_key'; + vault.put({ provider: 'stripe', name: 'secret_key', value: secret }); + + const ciphertext = rawCiphertext(vault, 'stripe', 'secret_key'); + expect(isEncrypted(ciphertext)).toBe(true); + expect(ciphertext).not.toContain(secret); + expect(JSON.stringify(vault.list())).not.toContain(secret); + }); + + it('round-trips a stored credential', () => { + vault.put({ provider: 'stripe', name: 'secret_key', value: 'sk_live_value' }); + expect(vault.reveal({ provider: 'stripe', name: 'secret_key' })).toBe('sk_live_value'); + }); + + it('rejects missing or oversized values', () => { + expect(() => vault.put({ provider: 'stripe', name: 'secret_key', value: '' })).toThrow( + /INVALID_PROVIDER_CREDENTIAL_VALUE/, + ); + expect(() => vault.put({ provider: 'stripe', name: 'secret_key', value: 'x'.repeat(9000) })).toThrow( + /INVALID_PROVIDER_CREDENTIAL_VALUE/, + ); + }); + + it('updates in place and bumps the version', () => { + const first = vault.put({ provider: 'paypal', name: 'client_secret', value: 'one' }); + const second = vault.put({ provider: 'paypal', name: 'client_secret', value: 'two' }); + + expect(second.id).toBe(first.id); + expect(second.version).toBe(2); + expect(vault.reveal({ provider: 'paypal', name: 'client_secret' })).toBe('two'); + }); + + it('isolates credentials per provider and name', () => { + vault.put({ provider: 'stripe', name: 'secret_key', value: 'stripe_value' }); + vault.put({ provider: 'paypal', name: 'secret_key', value: 'paypal_value' }); + + expect(vault.reveal({ provider: 'stripe', name: 'secret_key' })).toBe('stripe_value'); + expect(vault.reveal({ provider: 'paypal', name: 'secret_key' })).toBe('paypal_value'); + expect(vault.reveal({ provider: 'stripe', name: 'missing' })).toBeUndefined(); + }); + + it('deletes a credential and reports whether anything was removed', () => { + vault.put({ provider: 'stripe', name: 'secret_key', value: 'value' }); + expect(vault.delete({ provider: 'stripe', name: 'secret_key' })).toBe(true); + expect(vault.delete({ provider: 'stripe', name: 'secret_key' })).toBe(false); + expect(vault.reveal({ provider: 'stripe', name: 'secret_key' })).toBeUndefined(); + }); + + it('reports presence through has()', () => { + expect(vault.has('stripe', 'secret_key')).toBe(false); + vault.put({ provider: 'stripe', name: 'secret_key', value: 'value' }); + expect(vault.has('stripe', 'secret_key')).toBe(true); + }); + + it('re-encrypts every credential when the master key rotates', () => { + process.env.COLUMN_ENCRYPTION_MASTER_KEY = 'a'.repeat(64); + vault.put({ provider: 'stripe', name: 'secret_key', value: 'stripe_value' }); + vault.put({ provider: 'paypal', name: 'client_secret', value: 'paypal_value' }); + const ciphertextBefore = rawCiphertext(vault, 'stripe', 'secret_key'); + + // Rotate: the previous key is supplied for decryption, the new key for encryption. + process.env.COLUMN_ENCRYPTION_OLD_MASTER_KEY = 'a'.repeat(64); + process.env.COLUMN_ENCRYPTION_MASTER_KEY = 'b'.repeat(64); + + const result = vault.rotateMasterKey('ops_user'); + expect(result.rotated).toBe(2); + expect(result.failed).toEqual([]); + + expect(rawCiphertext(vault, 'stripe', 'secret_key')).not.toBe(ciphertextBefore); + expect(vault.reveal({ provider: 'stripe', name: 'secret_key' })).toBe('stripe_value'); + expect(vault.reveal({ provider: 'paypal', name: 'client_secret' })).toBe('paypal_value'); + }); + + it('audits writes, reads, rotations and deletions without logging plaintext', () => { + const audit = vi.spyOn(auditService, 'logAction').mockResolvedValue({} as never); + + vault.put({ provider: 'stripe', name: 'secret_key', value: 'super_secret_value', actorId: 'admin_1' }); + vault.reveal({ provider: 'stripe', name: 'secret_key', actorId: 'admin_1' }); + vault.delete({ provider: 'stripe', name: 'secret_key', actorId: 'admin_1' }); + + const actions = audit.mock.calls.map(([params]) => params.action); + expect(actions).toContain('provider_credential.create'); + expect(actions).toContain('provider_credential.reveal'); + expect(actions).toContain('provider_credential.delete'); + expect(JSON.stringify(audit.mock.calls)).not.toContain('super_secret_value'); + }); +}); diff --git a/backend/src/services/__tests__/wallet-multi-currency.test.ts b/backend/src/services/__tests__/wallet-multi-currency.test.ts new file mode 100644 index 00000000..fd022499 --- /dev/null +++ b/backend/src/services/__tests__/wallet-multi-currency.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { WalletAggregationService } from '../walletAggregation.js'; + +describe('Multi-currency wallet balances (#1009)', () => { + it('keeps each currency isolated and aggregates by currency', async () => { + const userId = `user_multi_${Date.now()}`; + WalletAggregationService.connectWallet(userId, 'stellar', 'GSTELLARADDRESS123', 'freighter'); + WalletAggregationService.connectWallet(userId, 'ethereum', '0xETHADDRESS123', 'metamask'); + + const aggregate = await WalletAggregationService.getAggregatedBalance(userId); + const currencies = Object.fromEntries(aggregate.byCurrency.map((c) => [c.currency, c])); + + // Each currency is its own bucket... + expect(currencies.XLM.amount).toBeCloseTo(1250.5); + expect(currencies.ETH.amount).toBeCloseTo(0.42); + // ...and the same symbol on two chains is summed, with both chains recorded. + expect(currencies.USDC.amount).toBeCloseTo(350); + expect(currencies.USDC.chains.sort()).toEqual(['ethereum', 'stellar']); + + // The aggregate USD total still exists as a headline figure. + const sumOfCurrencies = aggregate.byCurrency.reduce((sum, c) => sum + c.balanceUSD, 0); + expect(aggregate.totalUSD).toBeCloseTo(Number(sumOfCurrencies.toFixed(2)), 1); + }); + + it('reports a single currency without leaking others', async () => { + const userId = `user_single_${Date.now()}`; + WalletAggregationService.connectWallet(userId, 'stellar', 'GSTELLARONLY123', 'freighter'); + + const balances = await WalletAggregationService.getBalancesByCurrency(userId); + const currencyCodes = balances.currencies.map((c) => c.currency); + + expect(currencyCodes).toContain('XLM'); + expect(currencyCodes).not.toContain('ETH'); + + const xlm = await WalletAggregationService.getCurrencyBalance(userId, 'xlm'); + expect(xlm?.currency).toBe('XLM'); + expect(xlm?.includesNative).toBe(true); + + const missing = await WalletAggregationService.getCurrencyBalance(userId, 'btc'); + expect(missing).toBeUndefined(); + }); + + it('returns an empty currency list for a user with no wallets', async () => { + const balances = await WalletAggregationService.getBalancesByCurrency(`user_none_${Date.now()}`); + expect(balances.currencies).toEqual([]); + expect(balances.totalUSD).toBe(0); + }); +}); diff --git a/backend/src/services/payouts/__tests__/payout-idempotency.test.ts b/backend/src/services/payouts/__tests__/payout-idempotency.test.ts new file mode 100644 index 00000000..84dd66d6 --- /dev/null +++ b/backend/src/services/payouts/__tests__/payout-idempotency.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { PayoutBatcher } from '../payout-batcher.js'; +import { + PayoutIdempotencyStore, + fingerprintPayoutRequest, +} from '../payout-idempotency.js'; + +describe('PayoutIdempotencyStore (#1005)', () => { + it('returns the original value for a replayed key', () => { + const store = new PayoutIdempotencyStore(); + const fingerprint = 'fp-1'; + expect(store.lookup('k', fingerprint)).toEqual({ status: 'miss' }); + store.save('k', fingerprint, 'original'); + expect(store.lookup('k', fingerprint)).toEqual({ status: 'replay', value: 'original' }); + }); + + it('flags a key reused with a different fingerprint', () => { + const store = new PayoutIdempotencyStore(); + store.save('k', 'fp-1', 'original'); + expect(store.lookup('k', 'fp-2')).toEqual({ status: 'mismatch' }); + }); + + it('expires keys after the TTL and purges them', () => { + let now = 1_000; + const store = new PayoutIdempotencyStore(500, 100, () => now); + store.save('k', 'fp', 'value'); + now += 501; + expect(store.lookup('k', 'fp')).toEqual({ status: 'miss' }); + store.save('expired', 'fp', 'value'); + now += 501; + expect(store.purgeExpired()).toBe(1); + }); + + it('bounds the number of remembered keys', () => { + const store = new PayoutIdempotencyStore(10_000, 2); + store.save('a', 'fp', 1); + store.save('b', 'fp', 2); + store.save('c', 'fp', 3); + expect(store.size()).toBe(2); + expect(store.lookup('a', 'fp')).toEqual({ status: 'miss' }); + }); + + it('fingerprints are stable across object key ordering', () => { + expect(fingerprintPayoutRequest({ a: 1, b: 2 })).toBe(fingerprintPayoutRequest({ b: 2, a: 1 })); + }); +}); + +describe('PayoutBatcher idempotency (#1005)', () => { + const baseInput = { + merchantId: 'merch_1', + recipient: 'acct_1', + amount: 250, + asset: 'USDC', + } as const; + + it('returns the original payout when a key is replayed', () => { + const batcher = new PayoutBatcher(); + const first = batcher.schedule({ ...baseInput, idempotencyKey: 'idem-1' }); + const second = batcher.schedule({ ...baseInput, idempotencyKey: 'idem-1' }); + + expect(second.id).toBe(first.id); + expect(batcher.listPayouts()).toHaveLength(1); + }); + + it('rejects a key reused with a different body', () => { + const batcher = new PayoutBatcher(); + batcher.schedule({ ...baseInput, idempotencyKey: 'idem-2' }); + + expect(() => + batcher.schedule({ ...baseInput, amount: 999, idempotencyKey: 'idem-2' }), + ).toThrow('IDEMPOTENCY_KEY_REUSE_MISMATCH'); + }); + + it('treats different keys as different payouts', () => { + const batcher = new PayoutBatcher(); + const first = batcher.schedule({ ...baseInput, idempotencyKey: 'idem-3a' }); + const second = batcher.schedule({ ...baseInput, idempotencyKey: 'idem-3b' }); + + expect(second.id).not.toBe(first.id); + expect(batcher.listPayouts()).toHaveLength(2); + }); + + it('creates a fresh payout once a key expires', () => { + let clock = Date.now(); + const batcher = new PayoutBatcher({ idempotencyTtlMs: 1_000, now: () => new Date(clock) }); + + const first = batcher.schedule({ ...baseInput, idempotencyKey: 'idem-4' }); + clock += 1_001; + const second = batcher.schedule({ ...baseInput, idempotencyKey: 'idem-4' }); + + expect(second.id).not.toBe(first.id); + expect(batcher.purgeExpiredIdempotencyKeys()).toBe(1); + }); + + it('does not deduplicate when no key is supplied', () => { + const batcher = new PayoutBatcher(); + const first = batcher.schedule({ ...baseInput }); + const second = batcher.schedule({ ...baseInput }); + expect(second.id).not.toBe(first.id); + }); +}); diff --git a/backend/src/services/payouts/payout-batcher.ts b/backend/src/services/payouts/payout-batcher.ts index ac9032d3..3efad972 100644 --- a/backend/src/services/payouts/payout-batcher.ts +++ b/backend/src/services/payouts/payout-batcher.ts @@ -26,6 +26,11 @@ import { type PayoutAnomalyDetector, type PayoutAnomalyFlag, } from './payout-anomaly-detector.js'; +import { + DEFAULT_PAYOUT_IDEMPOTENCY_TTL_MS, + PayoutIdempotencyStore, + fingerprintPayoutRequest, +} from './payout-idempotency.js'; export const DEFAULT_PAYOUT_PROVIDER = 'default'; export const DEFAULT_MAX_BATCH_SIZE = 100; @@ -45,6 +50,11 @@ export interface ScheduleScheduledPayoutInput { /** Caller reference, echoed back in reports. */ reference?: string; actorId?: string; + /** + * Idempotency key (#1005). Scheduling twice with the same key and body returns the + * original payout; the same key with a different body is rejected. + */ + idempotencyKey?: string; } export interface ScheduledPayout { @@ -146,6 +156,8 @@ export interface PayoutBatcherOptions { feePerProviderCall?: number; auditLog?: MoneyMovementAuditLog; anomalyDetector?: PayoutAnomalyDetector; + /** How long a payout-creation idempotency key is remembered. Defaults to 24h. */ + idempotencyTtlMs?: number; now?: () => Date; } @@ -178,6 +190,7 @@ export class PayoutBatcher { private readonly providers: Record; private readonly auditLog?: MoneyMovementAuditLog; private readonly anomalyDetector?: PayoutAnomalyDetector; + private readonly idempotency: PayoutIdempotencyStore; private readonly now: () => Date; private runInFlight = false; @@ -202,6 +215,13 @@ export class PayoutBatcher { this.auditLog = options.auditLog; this.anomalyDetector = options.anomalyDetector; this.now = options.now ?? (() => new Date()); + // Share the batcher clock with the idempotency store so an injected `now` drives + // key expiry as well as payout timestamps. + this.idempotency = new PayoutIdempotencyStore( + options.idempotencyTtlMs ?? DEFAULT_PAYOUT_IDEMPOTENCY_TTL_MS, + undefined, + () => this.now().getTime(), + ); this.anomalyDetector?.onReview((flag) => this.onAnomalyReviewed(flag)); } @@ -221,6 +241,28 @@ export class PayoutBatcher { throw new Error('INVALID_PAYOUT_SCHEDULED_FOR'); } + // Idempotency (#1005): replay an earlier scheduling result verbatim, and reject a + // key that is being reused with a different body rather than silently paying twice. + const idempotencyKey = + input.idempotencyKey !== undefined + ? requireText(input.idempotencyKey, 'INVALID_PAYOUT_IDEMPOTENCY_KEY') + : undefined; + const fingerprint = fingerprintPayoutRequest({ + merchantId, + recipient, + asset, + provider, + amount: input.amount, + reference: input.reference, + scheduledFor: input.scheduledFor ? scheduledFor.toISOString() : undefined, + }); + + if (idempotencyKey) { + const existing = this.idempotency.lookup(idempotencyKey, fingerprint); + if (existing.status === 'mismatch') throw new Error('IDEMPOTENCY_KEY_REUSE_MISMATCH'); + if (existing.status === 'replay') return snapshotPayout(existing.value); + } + const createdAt = this.now(); const payout: ScheduledPayout = { id: `spo_${randomUUID()}`, @@ -255,7 +297,14 @@ export class PayoutBatcher { this.audit('system', 'payout.held', payout, { rules: flags.map((flag) => flag.rule) }); } - return snapshotPayout(payout); + const snapshot = snapshotPayout(payout); + if (idempotencyKey) this.idempotency.save(idempotencyKey, fingerprint, snapshot); + return snapshot; + } + + /** Drops expired payout-creation idempotency keys. Call from a scheduled job. */ + purgeExpiredIdempotencyKeys(): number { + return this.idempotency.purgeExpired(); } /** Cancels a payout that has not been sent yet. */ @@ -342,6 +391,7 @@ export class PayoutBatcher { this.payouts.clear(); this.batches.clear(); this.runs.clear(); + this.idempotency.clear(); } // ── internals ────────────────────────────────────────────────────────────── diff --git a/backend/src/services/payouts/payout-idempotency.ts b/backend/src/services/payouts/payout-idempotency.ts new file mode 100644 index 00000000..56e7ca9b --- /dev/null +++ b/backend/src/services/payouts/payout-idempotency.ts @@ -0,0 +1,114 @@ +/** + * payout-idempotency.ts — Issue #1005 + * + * A TTL-bounded idempotency store for payout creation. HTTP callers may retry a + * `POST /api/v1/payouts/scheduled`, and a retry that arrives after the middleware + * cache has been evicted must not create a second payout. + * + * Semantics: + * - The first request for a key records a fingerprint of the request body and the + * result it produced. + * - A later request with the same key and the same fingerprint gets the original + * result back verbatim. + * - A later request with the same key but a *different* body is rejected rather + * than silently replaying the earlier payout. + * - Entries expire after `ttlMs` (default 24h) and the store is capped so a flood + * of unique keys cannot exhaust memory. + */ + +import { createHash } from 'node:crypto'; + +/** How long an idempotency key is remembered. */ +export const DEFAULT_PAYOUT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; + +/** Hard cap on remembered keys. */ +export const DEFAULT_PAYOUT_IDEMPOTENCY_MAX_ENTRIES = 10_000; + +export type PayoutIdempotencyLookup = + | { status: 'miss' } + | { status: 'replay'; value: T } + | { status: 'mismatch' }; + +interface Entry { + fingerprint: string; + value: T; + expiresAt: number; +} + +function stableStringify(value: unknown): string { + if (value === undefined) return 'undefined'; + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + return `{${Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`) + .join(',')}}`; +} + +/** SHA-256 fingerprint of the parts of a request that must not change on replay. */ +export function fingerprintPayoutRequest(input: unknown): string { + return createHash('sha256').update(stableStringify(input)).digest('hex'); +} + +export class PayoutIdempotencyStore { + private readonly entries = new Map>(); + private readonly now: () => number; + + constructor( + private readonly ttlMs: number = DEFAULT_PAYOUT_IDEMPOTENCY_TTL_MS, + private readonly maxEntries: number = DEFAULT_PAYOUT_IDEMPOTENCY_MAX_ENTRIES, + now: () => number = Date.now, + ) { + if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error('INVALID_PAYOUT_IDEMPOTENCY_TTL'); + if (!Number.isFinite(maxEntries) || maxEntries < 1) throw new Error('INVALID_PAYOUT_IDEMPOTENCY_MAX_ENTRIES'); + this.now = now; + } + + /** Returns the stored result for a key, or signals a miss / payload mismatch. */ + lookup(key: string, fingerprint: string): PayoutIdempotencyLookup { + const entry = this.entries.get(key); + if (!entry) return { status: 'miss' }; + if (entry.expiresAt <= this.now()) { + this.entries.delete(key); + return { status: 'miss' }; + } + if (entry.fingerprint !== fingerprint) return { status: 'mismatch' }; + return { status: 'replay', value: entry.value }; + } + + /** Records the result a key produced so a replay returns it unchanged. */ + save(key: string, fingerprint: string, value: T): void { + this.entries.set(key, { fingerprint, value, expiresAt: this.now() + this.ttlMs }); + this.enforceLimit(); + } + + /** Drops expired keys; returns how many were removed. */ + purgeExpired(): number { + const now = this.now(); + let purged = 0; + for (const [key, entry] of this.entries) { + if (entry.expiresAt <= now) { + this.entries.delete(key); + purged += 1; + } + } + return purged; + } + + size(): number { + return this.entries.size; + } + + clear(): void { + this.entries.clear(); + } + + private enforceLimit(): void { + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next(); + if (oldest.done) return; + this.entries.delete(oldest.value); + } + } +} diff --git a/backend/src/services/provider-credentials.ts b/backend/src/services/provider-credentials.ts new file mode 100644 index 00000000..340df320 --- /dev/null +++ b/backend/src/services/provider-credentials.ts @@ -0,0 +1,291 @@ +/** + * provider-credentials.ts — Issue #1010 + * + * Encrypted-at-rest vault for third-party provider credentials — Stripe secret + * keys, PayPal client secrets, webhook signing secrets, OAuth client secrets and + * anything else that lets AgenticPay act on a provider's behalf. + * + * Design: + * - Every value is sealed with the shared column-encryption primitive + * (`services/encryption/column-encryptor`): AES-256-GCM with a per-provider + * data-encryption key derived via HKDF from the managed master key + * (`COLUMN_ENCRYPTION_MASTER_KEY`). Only `$enc1$…` ciphertext is ever kept in + * the store, so a datastore dump, a heap inspection or a stray log line cannot + * leak a plaintext secret. + * - Master-key rotation is supported. `rotateMasterKey()` re-encrypts every + * stored value from the previous key (`COLUMN_ENCRYPTION_OLD_MASTER_KEY`) to + * the current one, then drops the derived-key cache so the new key is used + * everywhere. + * - Every write, read, reveal, rotation and deletion is written to the + * tamper-evident audit trail. Only credential metadata is recorded — never the + * plaintext or the ciphertext. + */ + +import { randomUUID } from 'node:crypto'; +import { + decrypt, + encrypt, + evictTenantKeyCache, + isEncrypted, + reEncrypt, +} from '../encryption/column-encryptor.js'; +import { auditService } from './auditService.js'; + +/** Tenant the DEK is derived for. One key per provider would also be valid; a single + * vault tenant keeps rotation a single-pass operation across every provider. */ +export const PROVIDER_CREDENTIAL_TENANT = 'provider-credentials'; + +/** Default lifetime of a stored credential before it is considered stale. */ +export const DEFAULT_PROVIDER_CREDENTIAL_TTL_MS = 90 * 24 * 60 * 60 * 1000; + +export type ProviderCredentialId = string; + +export interface ProviderCredentialMetadata { + id: ProviderCredentialId; + provider: string; + /** Logical name within the provider, e.g. `secret_key`, `webhook_signing_secret`. */ + name: string; + /** Present when the value was last re-encrypted after a master-key rotation. */ + rotatedAt?: Date; + lastUsedAt?: Date; + /** Monotonic version, bumped on every write or rotation. */ + version: number; + createdAt: Date; + updatedAt: Date; + expiresAt?: Date; +} + +export interface PutProviderCredentialInput { + provider: string; + name: string; + value: string; + actorId?: string; + /** Optional expiry; omit to fall back to {@link DEFAULT_PROVIDER_CREDENTIAL_TTL_MS}. */ + ttlMs?: number; +} + +export interface GetProviderCredentialInput { + provider: string; + name: string; + actorId?: string; +} + +export interface ProviderCredentialAuditQuery { + provider?: string; + name?: string; + limit?: number; +} + +interface StoredProviderCredential extends ProviderCredentialMetadata { + /** `$enc1$…` ciphertext. Never exposed to callers. */ + ciphertext: string; +} + +export interface ProviderCredentialRotationResult { + rotated: number; + failed: Array<{ provider: string; name: string; error: string }>; +} + +const MAX_IDENTIFIER_LENGTH = 128; +const MAX_SECRET_LENGTH = 8_192; + +function assertIdentifier(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0 || value.length > MAX_IDENTIFIER_LENGTH) { + throw new Error(`INVALID_PROVIDER_CREDENTIAL_${field.toUpperCase()}`); + } + return value.trim(); +} + +function assertSecret(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.length > MAX_SECRET_LENGTH) { + throw new Error('INVALID_PROVIDER_CREDENTIAL_VALUE'); + } + return value; +} + +/** Key within the vault: stable identity per (provider, name). */ +function credentialKey(provider: string, name: string): string { + return `${provider}\u0000${name}`; +} + +/** + * Encrypted credential vault. Values live in memory behind ciphertext here, but the + * same primitive (`encrypt`/`reEncrypt`) is what the Prisma column-encryption + * middleware applies to secrets persisted in the datastore, so a caller can swap + * this store for a database-backed one without changing the crypto. + */ +export class ProviderCredentialVault { + private readonly credentials = new Map(); + private readonly now: () => Date; + + constructor(options: { now?: () => Date } = {}) { + this.now = options.now ?? (() => new Date()); + } + + /** Stores (or replaces) a credential, sealing the value before it hits memory. */ + put(input: PutProviderCredentialInput): ProviderCredentialMetadata { + const provider = assertIdentifier(input.provider, 'provider'); + const name = assertIdentifier(input.name, 'name'); + const plaintext = assertSecret(input.value); + const createdAt = this.now(); + + const existing = this.credentials.get(credentialKey(provider, name)); + const ciphertext = encrypt(plaintext, PROVIDER_CREDENTIAL_TENANT); + if (!isEncrypted(ciphertext)) { + // Defensive: a misconfigured primitive must never fall through to plaintext. + throw new Error('PROVIDER_CREDENTIAL_ENCRYPTION_FAILED'); + } + + const record: StoredProviderCredential = { + id: existing?.id ?? `pcv_${randomUUID()}`, + provider, + name, + ciphertext, + version: (existing?.version ?? 0) + 1, + createdAt: existing?.createdAt ?? createdAt, + updatedAt: createdAt, + expiresAt: new Date(createdAt.getTime() + (input.ttlMs ?? DEFAULT_PROVIDER_CREDENTIAL_TTL_MS)), + }; + this.credentials.set(credentialKey(provider, name), record); + + this.audit(input.actorId ?? 'system', existing ? 'provider_credential.update' : 'provider_credential.create', provider, name, { + version: record.version, + }); + + return this.toMetadata(record); + } + + /** + * Decrypts and returns a credential value. The decrypt itself is audited by the + * encryption primitive; an explicit access entry records who read it. + */ + reveal(input: GetProviderCredentialInput): string | undefined { + const provider = assertIdentifier(input.provider, 'provider'); + const name = assertIdentifier(input.name, 'name'); + const record = this.credentials.get(credentialKey(provider, name)); + if (!record) return undefined; + + const plaintext = decrypt(record.ciphertext, PROVIDER_CREDENTIAL_TENANT, name, { + userId: input.actorId, + resource: `provider_credential:${provider}`, + }); + + record.lastUsedAt = this.now(); + + this.audit(input.actorId ?? 'system', 'provider_credential.reveal', provider, name, { + version: record.version, + }); + + return plaintext; + } + + /** Metadata for one credential, without decrypting it. */ + get(input: GetProviderCredentialInput): ProviderCredentialMetadata | undefined { + const provider = assertIdentifier(input.provider, 'provider'); + const name = assertIdentifier(input.name, 'name'); + const record = this.credentials.get(credentialKey(provider, name)); + return record ? this.toMetadata(record) : undefined; + } + + /** Metadata for every stored credential, optionally scoped to a provider. */ + list(filter: { provider?: string } = {}): ProviderCredentialMetadata[] { + return [...this.credentials.values()] + .filter((record) => !filter.provider || record.provider === filter.provider) + .map((record) => this.toMetadata(record)); + } + + /** True when the vault holds a non-expired credential for (provider, name). */ + has(provider: string, name: string): boolean { + const record = this.credentials.get(credentialKey(provider, name)); + if (!record) return false; + return !record.expiresAt || record.expiresAt.getTime() > this.now().getTime(); + } + + delete(input: GetProviderCredentialInput): boolean { + const provider = assertIdentifier(input.provider, 'provider'); + const name = assertIdentifier(input.name, 'name'); + const removed = this.credentials.delete(credentialKey(provider, name)); + if (removed) { + this.audit(input.actorId ?? 'system', 'provider_credential.delete', provider, name); + } + return removed; + } + + /** + * Re-seals every credential under the current master key. + * + * Set `COLUMN_ENCRYPTION_OLD_MASTER_KEY` to the key the values were written with + * before calling this; the derived-key cache is cleared first so the current key + * is used for the new ciphertext. A single bad record is reported but does not + * abort the rotation of the rest. + */ + rotateMasterKey(actorId = 'system'): ProviderCredentialRotationResult { + evictTenantKeyCache(); + const result: ProviderCredentialRotationResult = { rotated: 0, failed: [] }; + + for (const [key, record] of [...this.credentials.entries()]) { + try { + const resealed = reEncrypt(record.ciphertext, PROVIDER_CREDENTIAL_TENANT, record.name); + record.ciphertext = resealed; + record.version += 1; + record.rotatedAt = this.now(); + record.updatedAt = this.now(); + this.credentials.set(key, record); + this.audit(actorId, 'provider_credential.rotate', record.provider, record.name, { + version: record.version, + }); + result.rotated += 1; + } catch (error) { + result.failed.push({ + provider: record.provider, + name: record.name, + error: error instanceof Error ? error.message : 'unknown rotation error', + }); + } + } + + // Force re-derivation with the new master key on the next operation. + evictTenantKeyCache(); + return result; + } + + /** Clears the vault. Intended for tests. */ + reset(): void { + this.credentials.clear(); + } + + private toMetadata(record: StoredProviderCredential): ProviderCredentialMetadata { + return { + id: record.id, + provider: record.provider, + name: record.name, + ...(record.rotatedAt ? { rotatedAt: new Date(record.rotatedAt) } : {}), + ...(record.lastUsedAt ? { lastUsedAt: new Date(record.lastUsedAt) } : {}), + version: record.version, + createdAt: new Date(record.createdAt), + updatedAt: new Date(record.updatedAt), + ...(record.expiresAt ? { expiresAt: new Date(record.expiresAt) } : {}), + }; + } + + private audit( + actor: string, + action: string, + provider: string, + name: string, + details: Record = {}, + ): void { + // Fire-and-forget: a slow audit sink must never block a credential read, and the + // plaintext is never part of `details`. + void auditService.logAction({ + userId: actor, + action, + resource: 'provider_credential', + resourceId: `${provider}:${name}`, + details: { provider, name, ...details }, + }); + } +} + +/** Process-wide vault. */ +export const providerCredentialVault = new ProviderCredentialVault(); diff --git a/backend/src/services/walletAggregation.ts b/backend/src/services/walletAggregation.ts index 083a4ef1..db05a22a 100644 --- a/backend/src/services/walletAggregation.ts +++ b/backend/src/services/walletAggregation.ts @@ -54,11 +54,29 @@ export interface TokenBalance { export interface AggregatedBalance { totalUSD: number; + /** Balances isolated per currency, aggregated across chains (#1009). */ + byCurrency: CurrencyBalance[]; chains: WalletBalance[]; topAssets: TokenBalance[]; lastUpdated: string; } +/** + * Balance for a single currency, isolated from every other currency (#1009). + * Tokens with the same symbol on different chains are summed only when they are + * genuinely the same currency; the contributing chains are reported so callers can + * split them apart again if they need to. + */ +export interface CurrencyBalance { + currency: string; + /** Summed units across every chain that holds this currency. */ + amount: number; + balanceUSD: number; + chains: ChainType[]; + /** True when this entry includes a native (gas) balance. */ + includesNative: boolean; +} + export interface CrossChainTransaction { id: string; sourceChain: ChainType; @@ -263,6 +281,10 @@ export class WalletAggregationService { const totalUSD = chainBalances.reduce((sum, b) => sum + b.nativeBalanceUSD, 0) + chainBalances.reduce((sum, b) => sum + b.tokens.reduce((ts, t) => ts + t.balanceUSD, 0), 0); + // Per-currency isolation: every currency keeps its own amount instead of being + // folded into a single USD total (#1009). + const byCurrency = this.aggregateByCurrency(chainBalances); + // Aggregate top assets across all chains const assetMap = new Map(); for (const chain of chainBalances) { @@ -282,12 +304,73 @@ export class WalletAggregationService { return { totalUSD: Number(totalUSD.toFixed(2)), + byCurrency, chains: chainBalances, topAssets, lastUpdated: new Date().toISOString(), }; } + /** + * Per-currency report for a user (#1009): each currency's amount and USD value, + * plus the aggregate USD total so a caller can still show a single headline figure. + */ + static async getBalancesByCurrency(userId: string): Promise<{ + currencies: CurrencyBalance[]; + totalUSD: number; + lastUpdated: string; + }> { + const aggregated = await this.getAggregatedBalance(userId); + return { + currencies: aggregated.byCurrency, + totalUSD: aggregated.totalUSD, + lastUpdated: aggregated.lastUpdated, + }; + } + + /** + * Isolated balance for one currency. `undefined` when the user holds none, so a + * caller cannot accidentally treat "no balance" as zero of another currency. + */ + static async getCurrencyBalance(userId: string, currency: string): Promise { + const upper = currency.toUpperCase(); + const { currencies } = await this.getBalancesByCurrency(userId); + return currencies.find((entry) => entry.currency === upper); + } + + /** Groups native and token balances by currency code, preserving one bucket each. */ + private static aggregateByCurrency(chainBalances: WalletBalance[]): CurrencyBalance[] { + const buckets = new Map(); + + const add = (currency: string, amount: number, balanceUSD: number, chain: ChainType, native: boolean) => { + const key = currency.toUpperCase(); + const existing = buckets.get(key) ?? { + currency: key, + amount: 0, + balanceUSD: 0, + chains: [], + includesNative: false, + }; + existing.amount += amount; + existing.balanceUSD += balanceUSD; + if (!existing.chains.includes(chain)) existing.chains.push(chain); + existing.includesNative = existing.includesNative || native; + buckets.set(key, existing); + }; + + for (const chain of chainBalances) { + const nativeSymbol = SUPPORTED_CHAINS[chain.chainType]?.nativeCurrencySymbol ?? chain.chainType.toUpperCase(); + add(nativeSymbol, Number(chain.nativeBalance) || 0, chain.nativeBalanceUSD, chain.chainType, true); + for (const token of chain.tokens) { + add(token.assetCode, Number(token.balance) || 0, token.balanceUSD, chain.chainType, false); + } + } + + return Array.from(buckets.values()) + .map((bucket) => ({ ...bucket, balanceUSD: Number(bucket.balanceUSD.toFixed(2)) })) + .sort((a, b) => b.balanceUSD - a.balanceUSD || a.currency.localeCompare(b.currency)); + } + /** * Get optimal routes for a cross-chain transfer. */ diff --git a/backend/src/services/webhooks/__tests__/bank-partner-verification.test.ts b/backend/src/services/webhooks/__tests__/bank-partner-verification.test.ts new file mode 100644 index 00000000..9522728c --- /dev/null +++ b/backend/src/services/webhooks/__tests__/bank-partner-verification.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { Request } from 'express'; +import { verifyBankPartnerWebhook, verifyCustomProviderWebhook } from '../providers.js'; +import { createWebhookSecret, clearWebhookSecrets } from '../verification.js'; +import { + BANK_PARTNER_FIXTURE_SECRET, + bankPartnerWebhookFixture, + customWebhookFixture, + unsignedWebhookFixture, +} from '../../../tests/fixtures/webhooks.js'; + +function makeReq(headers: Record): Request { + return { headers } as unknown as Request; +} + +describe('provider webhook verification fixtures (#1006)', () => { + beforeEach(() => { + clearWebhookSecrets(); + createWebhookSecret('custom', BANK_PARTNER_FIXTURE_SECRET); + }); + + describe('bank partner', () => { + it('accepts a correctly signed bank-partner payload', () => { + const fixture = bankPartnerWebhookFixture(); + const result = verifyBankPartnerWebhook(makeReq(fixture.headers), fixture.body); + + expect(result.isValid).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.payload).toEqual(JSON.parse(fixture.body)); + }); + + it('rejects a tampered payload', () => { + const fixture = bankPartnerWebhookFixture(); + const result = verifyBankPartnerWebhook(makeReq(fixture.headers), `${fixture.body} `); + + expect(result.isValid).toBe(false); + expect(result.error).toMatch(/signature verification failed/i); + }); + + it('rejects an unsigned payload', () => { + const fixture = unsignedWebhookFixture(); + const result = verifyBankPartnerWebhook(makeReq(fixture.headers), fixture.body); + + expect(result.isValid).toBe(false); + expect(result.error).toMatch(/missing/i); + }); + + it('rejects a signature outside the replay window', () => { + const staleTimestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + const fixture = bankPartnerWebhookFixture({ timestamp: staleTimestamp }); + const result = verifyBankPartnerWebhook(makeReq(fixture.headers), fixture.body); + + expect(result.isValid).toBe(false); + expect(result.error).toMatch(/tolerance/i); + }); + + it('accepts the sha256= prefixed signature form', () => { + const fixture = bankPartnerWebhookFixture(); + const headers = { ...fixture.headers, 'x-bank-signature': `sha256=${fixture.signature}` }; + const result = verifyBankPartnerWebhook(makeReq(headers), fixture.body); + + expect(result.isValid).toBe(true); + }); + }); + + describe('generic custom provider', () => { + it('accepts a fixture signed with the default custom secret', () => { + clearWebhookSecrets(); + const fixture = customWebhookFixture(); + createWebhookSecret('custom', fixture.secret); + + const result = verifyCustomProviderWebhook(makeReq(fixture.headers), fixture.body); + expect(result.isValid).toBe(true); + }); + }); +}); diff --git a/backend/src/services/webhooks/providers.ts b/backend/src/services/webhooks/providers.ts index 7653f581..8047c440 100644 --- a/backend/src/services/webhooks/providers.ts +++ b/backend/src/services/webhooks/providers.ts @@ -194,6 +194,23 @@ export function verifyCustomProviderWebhook(req: Request, rawBody: string): Prov }; } +/** + * Bank-partner payout webhooks (#1006). + * + * Partners sign the raw body with HMAC-SHA256 and send it in `x-bank-signature` + * alongside `x-bank-timestamp`. Signatures are verified against the registered + * secret set with the same replay tolerance as the other providers. + */ +export function verifyBankPartnerWebhook(req: Request, rawBody: string): ProviderVerificationResult { + return verifyGenericProviderWebhook( + 'custom', + req, + rawBody, + 'x-bank-signature', + 'x-bank-timestamp', + ); +} + /** Dev/test helper: sign outbound webhooks with AgenticPay format */ export function signTestWebhook(payload: string, secret: string, timestamp: string): string { return generateWebhookSignature(payload, secret, timestamp); diff --git a/backend/src/tests/fixtures/webhooks.ts b/backend/src/tests/fixtures/webhooks.ts new file mode 100644 index 00000000..88a18d2a --- /dev/null +++ b/backend/src/tests/fixtures/webhooks.ts @@ -0,0 +1,74 @@ +/** + * Signed provider webhook fixtures (#1006). + * + * These fixtures let the verification tests exercise the real code path with + * captured-looking payloads instead of hand-rolling a signature in every test. + */ + +import { generateWebhookSignature } from '../../services/webhooks/verification.js'; + +export interface SignedWebhookFixture { + /** Provider the fixture is signed for. */ + provider: 'stripe' | 'paypal' | 'github' | 'custom'; + /** Raw body exactly as the provider would send it. */ + body: string; + /** Secret the fixture was signed with. */ + secret: string; + timestamp: string; + signature: string; + headers: Record; +} + +/** Secret used by the bank-partner fixtures. Test-only. */ +export const BANK_PARTNER_FIXTURE_SECRET = 'whsec_test_bank_partner_secret_32_chars_min'; + +export const BANK_PARTNER_PAYLOAD = JSON.stringify({ + event: 'payout.settled', + data: { paymentId: 'bp_pay_123', status: 'completed', amount: 1500 }, +}); + +/** Sign a body the way a bank partner signs its payout webhooks. */ +export function signBankPartner(body: string, timestamp: string, secret = BANK_PARTNER_FIXTURE_SECRET): string { + return generateWebhookSignature(body, secret, timestamp); +} + +/** A valid, freshly-signed bank-partner webhook. */ +export function bankPartnerWebhookFixture(overrides: Partial = {}): SignedWebhookFixture { + const body = overrides.body ?? BANK_PARTNER_PAYLOAD; + const timestamp = overrides.timestamp ?? new Date().toISOString(); + const secret = overrides.secret ?? BANK_PARTNER_FIXTURE_SECRET; + const signature = overrides.signature ?? signBankPartner(body, timestamp, secret); + return { + provider: 'custom', + body, + secret, + timestamp, + signature, + headers: { + 'x-bank-signature': signature, + 'x-bank-timestamp': timestamp, + ...(overrides.headers ?? {}), + }, + }; +} + +/** A generic AgenticPay-signed webhook (`x-signature` / `x-timestamp`). */ +export function customWebhookFixture(overrides: Partial = {}): SignedWebhookFixture { + const body = overrides.body ?? JSON.stringify({ event: 'payment.captured', data: { id: 'evt_1' } }); + const timestamp = overrides.timestamp ?? new Date().toISOString(); + const secret = overrides.secret ?? 'whsec_test_default_custom_secret_key_32_chars_min'; + const signature = overrides.signature ?? generateWebhookSignature(body, secret, timestamp); + return { + provider: 'custom', + body, + secret, + timestamp, + signature, + headers: { 'x-signature': signature, 'x-timestamp': timestamp, ...(overrides.headers ?? {}) }, + }; +} + +/** Headers-only variant used to prove unsigned payloads are rejected. */ +export function unsignedWebhookFixture(headers: Record = {}): { body: string; headers: Record } { + return { body: BANK_PARTNER_PAYLOAD, headers }; +} diff --git a/docs/MULTI_CURRENCY_BALANCES.md b/docs/MULTI_CURRENCY_BALANCES.md new file mode 100644 index 00000000..ad486718 --- /dev/null +++ b/docs/MULTI_CURRENCY_BALANCES.md @@ -0,0 +1,45 @@ +# Multi-currency wallet balances + +Balances are tracked **per currency**, not as a single blended number, so an XLM +balance can never be spent as USDC. + +## Isolation + +`WalletAggregationService.getAggregatedBalance(userId)` returns a `byCurrency` +array. Each entry is one currency with: + +- `currency` — the currency code, +- `amount` — the sum of that currency across every chain that holds it, +- `balanceUSD` — that currency's USD value, +- `chains` — which chains contributed, +- `includesNative` — whether a native (gas) balance is part of the entry. + +`getCurrencyBalance(userId, currency)` returns a single isolated balance, or +`undefined` when the user holds none — "no balance" is never silently coerced into +`0` of a different currency. + +## Reporting + +`getBalancesByCurrency(userId)` returns the per-currency report plus the aggregate +`totalUSD` headline, so reports can aggregate by currency while still showing a +single portfolio figure. + +| Method | Path | Purpose | +| ------ | ---- | ------- | +| `GET` | `/api/v1/wallet/aggregated` | All chains plus `byCurrency` | +| `GET` | `/api/v1/wallet/balances` | Per-currency report | +| `GET` | `/api/v1/wallet/balances?currency=usdc` | One isolated currency (404 if none) | + +## UI + +`frontend/components/wallet/WalletManager.tsx` renders a **Balances by Currency** +table from `aggregatedBalance.byCurrency`, alongside the per-chain breakdown and +the USD headline. The wallet slice lives in `frontend/store/web3Store.ts` +(`fetchAggregatedBalance`). + +## Tests + +`backend/src/services/__tests__/wallet-multi-currency.test.ts` asserts that +currencies are isolated, the same symbol on two chains is summed with both chains +recorded, a single currency can be fetched without leaking others, and an empty +wallet returns no currencies. diff --git a/docs/PAYOUT_IDEMPOTENCY.md b/docs/PAYOUT_IDEMPOTENCY.md new file mode 100644 index 00000000..00560998 --- /dev/null +++ b/docs/PAYOUT_IDEMPOTENCY.md @@ -0,0 +1,46 @@ +# Payout idempotency + +Retries must never create a second payout. Payout creation is protected at two +layers. + +## 1. HTTP middleware + +`idempotency()` (`backend/src/middleware/idempotency.ts`) is applied to every +money-moving route. When a request carries `Idempotency-Key` (or the legacy +`X-Idempotency-Key`): + +- the first request's response is cached for **24 hours**, +- a replay with the same body returns the original response and status code, +- reusing the key with a **different** body is rejected with + `409 idempotency_key_reuse_mismatch`, +- a concurrent request with the same key that is still in flight is rejected with + `409 idempotency_key_in_use`. + +`purgeExpiredIdempotencyKeys()` — intended to run from a scheduled job — drops +expired entries so memory stays bounded. + +## 2. Service-level store + +The middleware cache can be evicted (process restart, TTL, memory ceiling). The +payout batcher keeps its own TTL-bounded idempotency store +(`backend/src/services/payouts/payout-idempotency.ts`) so a retry that arrives +after the cache is gone still returns the **original** scheduled payout. + +`POST /api/v1/payouts/scheduled` passes the `Idempotency-Key` header through to +`PayoutBatcher.schedule()`, which: + +- fingerprints the normalised request body, +- returns the original payout for a replayed key, +- throws `IDEMPOTENCY_KEY_REUSE_MISMATCH` when the key is reused with a different + body (mapped to `409`), +- remembers keys for 24 hours by default (`idempotencyTtlMs` option) and caps the + store so a flood of unique keys cannot exhaust memory. + +Expired keys are dropped by `PayoutBatcher.purgeExpiredIdempotencyKeys()`. + +## Tests + +- `backend/src/middleware/__tests__/idempotency.test.ts` — replay, mismatch, + in-flight collision, eviction. +- `backend/src/services/payouts/__tests__/payout-idempotency.test.ts` — store + semantics and the batcher integration, including TTL expiry and body mismatch. diff --git a/docs/README.md b/docs/README.md index 74df759c..751a9676 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,8 +19,10 @@ Use this page as the entry point for the repository's maintained guides. API-spe - [Payment correctness](PAYMENT_CORRECTNESS.md) - [Payment provider routing](PAYMENT_ROUTING.md) - [Payout approvals](PAYOUT_APPROVALS.md) +- [Payout idempotency](PAYOUT_IDEMPOTENCY.md) - [Payout reliability](PAYOUT_RELIABILITY.md) - [Subscription usage billing](SUBSCRIPTION_USAGE_BILLING.md) +- [Multi-currency balances](MULTI_CURRENCY_BALANCES.md) - [Wallet payments](WALLET_PAYMENTS.md) - [Withdrawal approval security](WITHDRAWAL_SECURITY.md) @@ -38,6 +40,7 @@ Use this page as the entry point for the repository's maintained guides. API-spe - [Logging](logging.md) - [Security policy](security-policy.md) - [Audit logging](security/audit-logging.md) +- [Provider credentials encryption](security/provider-credentials-encryption.md) - [Configuration reference](config/CONFIGURATION.md) ## Keeping this index healthy diff --git a/docs/security/provider-credentials-encryption.md b/docs/security/provider-credentials-encryption.md new file mode 100644 index 00000000..b58fd1f3 --- /dev/null +++ b/docs/security/provider-credentials-encryption.md @@ -0,0 +1,65 @@ +# Provider credential encryption at rest + +Third-party provider credentials — Stripe secret keys, PayPal client secrets, +webhook signing secrets and OAuth client secrets — are never stored in plaintext. + +## Where credentials live + +- **Database columns** (e.g. `Webhook.secret`, `PushSubscription.auth`) are sealed + transparently by the column-encryption Prisma extension. The field registry is + defined in `backend/src/encryption/index.ts`; adding a new secret column means + adding it to `PII_FIELDS`, not calling the crypto inline. +- **The provider credential vault** (`backend/src/services/provider-credentials.ts`) + covers credentials that need to be read back in plaintext to call a provider. + `ProviderCredentialVault.put()` seals the value before it is retained, and only + `$enc1$…` ciphertext is ever kept. + +## Cryptographic design + +- Algorithm: **AES-256-GCM** with a random 96-bit IV per value. +- Envelope encryption: a per-tenant data-encryption key (DEK) is derived via + **HKDF-SHA256** from the managed master key (`COLUMN_ENCRYPTION_MASTER_KEY`, + hex-encoded 32 bytes). +- The managed master key is required in production; the development fallback exists + only so local runs work and logs a warning. +- Decryption is authenticated: a tampered ciphertext throws rather than returning a + partial or forged value. + +## API + +| Method | Path | Purpose | +| ------ | ---- | ------- | +| `GET` | `/api/v1/provider-credentials` | List credential metadata (never secrets) | +| `PUT` | `/api/v1/provider-credentials/:provider/:name` | Store or replace a secret | +| `GET` | `/api/v1/provider-credentials/:provider/:name` | Metadata for one credential | +| `POST` | `/api/v1/provider-credentials/:provider/:name/reveal` | Decrypt a secret (audited) | +| `DELETE` | `/api/v1/provider-credentials/:provider/:name` | Remove a secret | +| `POST` | `/api/v1/provider-credentials/rotate-key` | Re-encrypt everything under the current key | + +## Key rotation + +1. Provision the new master key as `COLUMN_ENCRYPTION_MASTER_KEY`. +2. Keep the previous key as `COLUMN_ENCRYPTION_OLD_MASTER_KEY`. +3. `POST /api/v1/provider-credentials/rotate-key`. + +`rotateMasterKey()` drops the derived-key cache, decrypts each value with the old +key, re-encrypts it with the new key and bumps its version. A record that fails to +re-seal is reported in `failed` instead of aborting the whole rotation. Once every +value is rotated, remove `COLUMN_ENCRYPTION_OLD_MASTER_KEY`. + +## Auditing + +Every `create`, `update`, `reveal`, `rotate` and `delete` is written to the +tamper-evident audit trail (`auditService`) with the actor, provider and credential +name. The plaintext and the ciphertext are **never** part of an audit entry. Reads +are also logged field-level by the encryption primitive itself. + +## Tests + +`backend/src/services/__tests__/provider-credentials.test.ts` asserts that: + +- stored values are ciphertext and never appear in `list()` output, +- round-tripping returns the original value, +- attributes are isolated per `(provider, name)`, +- master-key rotation re-encrypts every credential and values remain readable, +- the audit trail records the operations without leaking the secret. diff --git a/docs/security/webhook-signatures.md b/docs/security/webhook-signatures.md index bd82c139..36a6d2b4 100644 --- a/docs/security/webhook-signatures.md +++ b/docs/security/webhook-signatures.md @@ -29,3 +29,26 @@ const valid = verifyWebhookSignature({ ``` Webhook bodies also include `webhook.signature` for systems that cannot inspect headers. Prefer header verification when possible because it binds the raw request body. + +## Inbound provider webhooks + +Every inbound provider webhook is verified before its handler runs; a payload with a +missing, malformed or stale signature is rejected with `401` +`WEBHOOK_VERIFICATION_FAILED` and queued for audit. + +| Source | Signature header | Timestamp header | +| ------ | ---------------- | ---------------- | +| Stripe (`POST /api/v1/stripe/webhooks`) | `Stripe-Signature` (Stripe SDK) | embedded in the signature | +| AgenticPay providers (`POST /webhooks/:provider`) | `X-Signature`, `X-Hub-Signature-256` (GitHub) | `X-Timestamp` / provider | +| Intercom | `X-Hub-Signature` (HMAC-SHA1) | n/a | +| Bank partner (`POST /api/v1/fiat-payments/webhooks/bank-partner`) | `X-Bank-Signature` | `X-Bank-Timestamp` | + +- Signatures are HMAC-SHA256 over `${timestamp}.${rawBody}` and compared with + `crypto.timingSafeEqual`; a non-hex or wrong-length signature fails closed. +- The **replay window** is enforced on every provider: a timestamp outside + `toleranceSeconds` (default 300s) of server time is rejected, and a repeated + `eventId` is rejected as a duplicate delivery. +- Provider fixtures used by the test suite live in + [`backend/src/tests/fixtures/webhooks.ts`](../../backend/src/tests/fixtures/webhooks.ts). + They cover valid, tampered, unsigned and stale payloads. + diff --git a/frontend/components/wallet/WalletManager.tsx b/frontend/components/wallet/WalletManager.tsx index b91c5dc4..630cdb3b 100644 --- a/frontend/components/wallet/WalletManager.tsx +++ b/frontend/components/wallet/WalletManager.tsx @@ -226,6 +226,39 @@ export function WalletManager({ userId }: { userId: string }) { + {/* Per-currency balances (#1009) */} + {aggregatedBalance.byCurrency && aggregatedBalance.byCurrency.length > 0 && ( +
+
+

Balances by Currency

+
+
+ + + + + + + + + + {aggregatedBalance.byCurrency.map((balance) => ( + + + + + + ))} + +
CurrencyBalanceValue (USD)
{balance.currency} + {balance.amount.toLocaleString(undefined, { maximumFractionDigits: 6 })} + + ${balance.balanceUSD.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+
+ )} + {/* Top assets across all chains */} {aggregatedBalance.topAssets && aggregatedBalance.topAssets.length > 0 && (
diff --git a/frontend/store/web3Store.ts b/frontend/store/web3Store.ts index 22f5eb66..cb48e9f8 100644 --- a/frontend/store/web3Store.ts +++ b/frontend/store/web3Store.ts @@ -8,6 +8,65 @@ import { devtools, persist, subscribeWithSelector, createJSONStorage } from 'zus export type ProviderType = 'injected' | 'walletconnect' | 'web3auth' | null; export type TxStatus = 'pending' | 'confirmed' | 'failed'; +// ─── Multi-chain wallet / balance types (#1009) ──────────────────────────────── + +export type SupportedChain = 'stellar' | 'ethereum' | 'polygon' | 'base' | 'arbitrum' | 'optimism'; + +export interface ConnectedWallet { + id: string; + userId: string; + chainType: SupportedChain; + address: string; + providerName: string; + connectedAt: string; + lastUsedAt: string; + isActive: boolean; +} + +/** Balance for a single currency, isolated from every other currency. */ +export interface CurrencyBalance { + currency: string; + amount: number; + balanceUSD: number; + chains: SupportedChain[]; + includesNative?: boolean; +} + +export interface WalletChainBalance { + chainId: string; + chainType: SupportedChain; + address: string; + nativeBalance: string; + nativeBalanceUSD: number; +} + +export interface WalletTokenBalance { + assetCode: string; + balance: string; + balanceUSD: number; +} + +export interface AggregatedBalance { + totalUSD: number; + /** Per-currency balances. Rendered so each currency is shown on its own line. */ + byCurrency: CurrencyBalance[]; + chains: WalletChainBalance[]; + topAssets: WalletTokenBalance[]; + lastUpdated?: string; + isLoading: boolean; + error?: string; +} + +const EMPTY_AGGREGATED_BALANCE: AggregatedBalance = { + totalUSD: 0, + byCurrency: [], + chains: [], + topAssets: [], + isLoading: false, +}; + +const WALLET_API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + export interface TransactionRecord { hash: string; status: TxStatus; @@ -28,6 +87,9 @@ interface Web3State { providerType: ProviderType; connectionError: string | null; transactions: TransactionRecord[]; + connectedWallets: ConnectedWallet[]; + aggregatedBalance: AggregatedBalance; + activeChain: SupportedChain | null; } interface Web3Actions { @@ -41,6 +103,13 @@ interface Web3Actions { addTransaction: (tx: Omit) => void; updateTransaction: (hash: string, status: TxStatus) => void; clearTransactions: () => void; + /** Adds or replaces a connected wallet in local state. */ + addWallet: (wallet: ConnectedWallet) => void; + /** Removes a wallet locally and best-effort disconnects it on the API. */ + removeWallet: (walletId: string) => void; + setActiveChain: (chain: SupportedChain | null) => void; + /** Loads the per-currency aggregated balance for a user (#1009). */ + fetchAggregatedBalance: (userId: string) => Promise; } export type Web3Store = Web3State & Web3Actions; @@ -122,6 +191,9 @@ const INITIAL_STATE: Web3State = { providerType: null, connectionError: null, transactions: [], + connectedWallets: [], + aggregatedBalance: EMPTY_AGGREGATED_BALANCE, + activeChain: null, }; // ─── Store ──────────────────────────────────────────────────────────────────── @@ -188,6 +260,73 @@ export const useWeb3Store = create()( clearTransactions: () => set({ transactions: [] }, false, 'web3/clearTransactions'), + + addWallet: (wallet) => + set( + (state) => ({ + connectedWallets: [...state.connectedWallets.filter((w) => w.id !== wallet.id), wallet], + }), + false, + 'web3/addWallet' + ), + + removeWallet: (walletId) => { + set( + (state) => ({ + connectedWallets: state.connectedWallets.filter((w) => w.id !== walletId), + }), + false, + 'web3/removeWallet' + ); + // Best-effort server-side disconnect; local state is already updated. + void fetch(`${WALLET_API_BASE}/api/v1/wallet/${walletId}`, { method: 'DELETE' }).catch(() => {}); + }, + + setActiveChain: (activeChain) => set({ activeChain }, false, 'web3/setActiveChain'), + + fetchAggregatedBalance: async (userId) => { + set( + (state) => ({ + aggregatedBalance: { ...state.aggregatedBalance, isLoading: true, error: undefined }, + }), + false, + 'web3/fetchAggregatedBalance/start' + ); + try { + const response = await fetch( + `${WALLET_API_BASE}/api/v1/wallet/aggregated?userId=${encodeURIComponent(userId)}` + ); + if (!response.ok) throw new Error(`Failed to fetch balances (${response.status})`); + const body = await response.json(); + const data = (body?.data ?? {}) as Partial; + set( + { + aggregatedBalance: { + totalUSD: Number(data.totalUSD ?? 0), + byCurrency: data.byCurrency ?? [], + chains: data.chains ?? [], + topAssets: data.topAssets ?? [], + lastUpdated: data.lastUpdated, + isLoading: false, + }, + }, + false, + 'web3/fetchAggregatedBalance/success' + ); + } catch (error) { + set( + (state) => ({ + aggregatedBalance: { + ...state.aggregatedBalance, + isLoading: false, + error: (error as Error).message, + }, + }), + false, + 'web3/fetchAggregatedBalance/error' + ); + } + }, }), { name: 'agenticpay-web3',