diff --git a/src/index.ts b/src/index.ts index 72d60ed6..501dbda4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -726,6 +726,13 @@ export function createApp(dependencies: AppDependencies = {}): express.Express { // Mount taxation routes for per-lot cost-basis tax reporting app.use(API_VERSION_PREFIX + '/taxation', taxationRouter); + // KYC vendor webhooks — dual-key signature rotation (#676) + // Only mount when a primary secret is configured so local/test boots stay quiet. + if (process.env.KYC_WEBHOOK_SECRET || process.env.KYC_WEBHOOK_KEY) { + const { createKycWebhookRouter } = require('./routes/kycWebhooks'); + app.use(API_VERSION_PREFIX + '/webhooks/kyc', createKycWebhookRouter()); + } + app.use(API_VERSION_PREFIX, apiRouter); app.use((_req, _res, next) => next(Errors.notFound("Route not found"))); app.use(errorHandler); diff --git a/src/lib/webhookSignature.ts b/src/lib/webhookSignature.ts index 619c4b0f..04789ba3 100644 --- a/src/lib/webhookSignature.ts +++ b/src/lib/webhookSignature.ts @@ -359,7 +359,16 @@ export function verifyWebhookPayloadDualKey( if (config.nextSecret) { const expiryMs = parseExpiryTimestamp(config.nextSecretExpiry); - const isExpired = expiryMs !== undefined && Date.now() > expiryMs; + // Fail closed: secondary key without a parseable expiry is treated as expired + // so old-key acceptance cannot linger indefinitely (issue #676). + if (expiryMs === undefined) { + if (verifyWebhookPayload(config.nextSecret, payload, signature)) { + return { valid: false, expired: true }; + } + return { valid: false }; + } + + const isExpired = Date.now() > expiryMs; if (!isExpired && verifyWebhookPayload(config.nextSecret, payload, signature)) { return { valid: true, verifiedByKey: 'next' }; diff --git a/src/middleware/webhookAuth.test.ts b/src/middleware/webhookAuth.test.ts index 6162e0e6..b43e2d35 100644 --- a/src/middleware/webhookAuth.test.ts +++ b/src/middleware/webhookAuth.test.ts @@ -1403,6 +1403,7 @@ describe('kycWebhookAuth & Dual-Key Signature Rotation', () => { it('should reject when signature does not match primary or next key', () => { process.env.KYC_WEBHOOK_SECRET = PRIMARY_KEY; process.env.KYC_WEBHOOK_KEY_NEXT = NEXT_KEY; + process.env.KYC_WEBHOOK_KEY_NEXT_EXPIRY = String(Date.now() + 86400000); const signature = signWebhookPayload('bogus-secret', TEST_PAYLOAD_STRING); mockReq.headers['x-revora-signature'] = signature; @@ -1414,6 +1415,14 @@ describe('kycWebhookAuth & Dual-Key Signature Rotation', () => { expect(mockRes.status).toHaveBeenCalledWith(403); }); + it('should throw when next key is set without a hard expiry deadline', () => { + process.env.KYC_WEBHOOK_SECRET = PRIMARY_KEY; + process.env.KYC_WEBHOOK_KEY_NEXT = NEXT_KEY; + delete process.env.KYC_WEBHOOK_KEY_NEXT_EXPIRY; + + expect(() => kycWebhookAuth()).toThrow(/KYC_WEBHOOK_KEY_NEXT_EXPIRY/); + }); + it('should support provider returning dual-key configuration object', async () => { const signature = signWebhookPayload(NEXT_KEY, TEST_PAYLOAD_STRING); mockReq.headers['x-revora-signature'] = signature; diff --git a/src/middleware/webhookAuth.ts b/src/middleware/webhookAuth.ts index 07263cb6..bfe31dc2 100644 --- a/src/middleware/webhookAuth.ts +++ b/src/middleware/webhookAuth.ts @@ -281,6 +281,14 @@ export function kycWebhookAuth(options: Partial = {}): Reque const nextSecretExpiry = options.nextSecretExpiry ?? process.env.KYC_WEBHOOK_KEY_NEXT_EXPIRY; const metricName = options.metricName ?? 'kyc.webhook.verified_by_key'; + // Fail closed: a dual-key window without a hard deadline would leave the + // secondary key accepted forever (issue #676). + if (nextSecret && (nextSecretExpiry === undefined || nextSecretExpiry === '')) { + throw new Error( + 'KYC_WEBHOOK_KEY_NEXT_EXPIRY is required when KYC_WEBHOOK_KEY_NEXT is set' + ); + } + return webhookAuth({ secret, nextSecret, diff --git a/src/routes/kycWebhooks.test.ts b/src/routes/kycWebhooks.test.ts new file mode 100644 index 00000000..629b28b1 --- /dev/null +++ b/src/routes/kycWebhooks.test.ts @@ -0,0 +1,73 @@ +import express from 'express'; +import request from 'supertest'; +import { createKycWebhookRouter } from './kycWebhooks'; +import { signWebhookPayload } from '../lib/webhookSignature'; + +const PRIMARY = 'kyc-primary-secret-key-32bytes!!'; +const NEXT = 'kyc-next-secret-key-32bytes!!!!!!'; + +function buildApp(authOptions?: Record) { + const app = express(); + app.use(express.json()); + app.use( + '/webhooks/kyc', + createKycWebhookRouter({ + authOptions: { + secret: PRIMARY, + nextSecret: NEXT, + nextSecretExpiry: Date.now() + 86_400_000, + ...authOptions, + }, + }) + ); + return app; +} + +describe('KYC webhook route (dual-key)', () => { + const payload = { id: 'evt-1', event: 'kyc.approved', data: { investorId: 'i-1' } }; + + it('accepts a payload signed with the current key', async () => { + const app = buildApp(); + const body = JSON.stringify(payload); + const res = await request(app) + .post('/webhooks/kyc') + .set('x-revora-signature', signWebhookPayload(PRIMARY, body)) + .set('Content-Type', 'application/json') + .send(payload); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('accepts a payload signed with the next key inside the window', async () => { + const app = buildApp(); + const body = JSON.stringify(payload); + const res = await request(app) + .post('/webhooks/kyc') + .set('x-revora-signature', signWebhookPayload(NEXT, body)) + .set('Content-Type', 'application/json') + .send(payload); + + expect(res.status).toBe(200); + }); + + it('rejects next-key deliveries after the hard deadline', async () => { + const app = buildApp({ nextSecretExpiry: Date.now() - 1000 }); + const body = JSON.stringify(payload); + const res = await request(app) + .post('/webhooks/kyc') + .set('x-revora-signature', signWebhookPayload(NEXT, body)) + .set('Content-Type', 'application/json') + .send(payload); + + expect(res.status).toBe(403); + }); + + it('throws when next key is configured without an expiry', () => { + expect(() => + createKycWebhookRouter({ + authOptions: { secret: PRIMARY, nextSecret: NEXT }, + }) + ).toThrow(/KYC_WEBHOOK_KEY_NEXT_EXPIRY/); + }); +}); diff --git a/src/routes/kycWebhooks.ts b/src/routes/kycWebhooks.ts new file mode 100644 index 00000000..3a579e5a --- /dev/null +++ b/src/routes/kycWebhooks.ts @@ -0,0 +1,88 @@ +/** + * KYC vendor webhook receiver with dual-key signature rotation (#676). + * + * Mounts POST /webhooks/kyc protected by `kycWebhookAuth()`, which accepts + * current + next keys during a hard-deadline rotation window and emits + * `kyc.webhook.verified_by_key`. + */ + +import { Router, Request, Response } from 'express'; +import { + kycWebhookAuth, + WebhookAuthenticatedRequest, +} from '../middleware/webhookAuth'; +import { globalLogger } from '../lib/logger'; + +export interface KycWebhookEvent { + id: string; + event: string; + data: unknown; + timestamp?: string; +} + +export type KycWebhookHandler = ( + event: KycWebhookEvent, + verifiedByKey: 'current' | 'next' +) => Promise<{ success: boolean; message: string }>; + +const defaultHandler: KycWebhookHandler = async (event, verifiedByKey) => { + globalLogger.info('KYC webhook received', { + eventId: event.id, + event: event.event, + verifiedByKey, + }); + return { success: true, message: `KYC event ${event.event} accepted` }; +}; + +export interface KycWebhookRouterOptions { + /** Optional override handler (defaults to structured log ack). */ + handler?: KycWebhookHandler; + /** Forwarded to kycWebhookAuth (tests / DI). */ + authOptions?: Parameters[0]; +} + +/** + * @notice Create the KYC vendor webhook router. + * @dev Signature verification runs before JSON body handlers see the event. + */ +export function createKycWebhookRouter(options: KycWebhookRouterOptions = {}): Router { + const handler = options.handler ?? defaultHandler; + const router = Router(); + + router.post( + '/', + kycWebhookAuth(options.authOptions), + async (req: Request, res: Response): Promise => { + const authReq = req as WebhookAuthenticatedRequest; + const body = req.body as Partial; + + if (!body || typeof body !== 'object' || !body.id || !body.event) { + res.status(400).json({ + error: 'Invalid KYC webhook payload', + code: 'INVALID_PAYLOAD', + }); + return; + } + + try { + const result = await handler( + { + id: String(body.id), + event: String(body.event), + data: body.data, + timestamp: body.timestamp, + }, + authReq.webhook?.verifiedByKey ?? 'current' + ); + res.status(result.success ? 200 : 500).json(result); + } catch (err) { + globalLogger.error('KYC webhook handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + res.status(500).json({ success: false, message: 'Handler failure' }); + } + } + ); + + return router; +}