Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion src/lib/webhookSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
9 changes: 9 additions & 0 deletions src/middleware/webhookAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/middleware/webhookAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,14 @@ export function kycWebhookAuth(options: Partial<WebhookAuthOptions> = {}): 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,
Expand Down
73 changes: 73 additions & 0 deletions src/routes/kycWebhooks.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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/);
});
});
88 changes: 88 additions & 0 deletions src/routes/kycWebhooks.ts
Original file line number Diff line number Diff line change
@@ -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<typeof kycWebhookAuth>[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<void> => {
const authReq = req as WebhookAuthenticatedRequest;
const body = req.body as Partial<KycWebhookEvent>;

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;
}
Loading