Skip to content
Closed
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
31 changes: 31 additions & 0 deletions WEBHOOK_VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,37 @@ Implement webhook signature verification for all incoming webhooks to prevent sp
## Complexity Estimate
200 points - Verification, secure storage.

## Key Rotation: Overlap, Retention, and Replay Tolerance

`WebhookKeyRegistry` (`backend/src/services/webhookKeys.ts`) governs how long a
rotated secret keeps working and how it is retired. Get these windows wrong
and either genuine events get rejected mid-rotation, or revoked secrets keep
verifying signatures long after they should not.

- **`overlapSeconds`** (default `DEFAULT_OVERLAP_SECONDS` = 72h) — passed to
`registry.rotate({ overlapSeconds })`. For this long after a secret is
rotated, the *previous* secret still verifies signatures alongside the new
one. Set this to at least as long as the slowest provider takes to pick up
a newly configured secret (e.g. Stripe can take a while to fully cut over
after a dashboard change) — too short, and in-flight webhooks signed with
the old secret start failing verification before the provider has switched.
- **`retentionSeconds`** (default `DEFAULT_RETENTION_SECONDS` = 7 days) — how
long an expired or revoked key is kept around (rather than purged) so that
verification attempts against it can still be logged and classified by
reason, instead of just vanishing. This is operational/audit retention, not
a security control — a key past its `overlapSeconds` window no longer
verifies anything regardless of retention.
- **`toleranceSeconds`** (default `DEFAULT_TOLERANCE_SECONDS` = 300) — the
replay-protection clock-skew window: a webhook's timestamp must be within
this many seconds of `registry` time, in either direction, or verification
rejects it as expired/replayed. Widen this only to accommodate real clock
drift between you and the provider, since every second added is a second a
captured signature + body could be replayed.

All three are constructor options on `WebhookKeyRegistry` (`new
WebhookKeyRegistry({ overlapSeconds, retentionSeconds, toleranceSeconds })`)
and default to the constants above when omitted.

## Implementation Plan
1. Create webhook verification service with HMAC-SHA256
2. Implement per-provider secret management
Expand Down
12 changes: 8 additions & 4 deletions backend/src/routes/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,17 +296,21 @@ stripeRouter.get('/rates', asyncHandler(async (req, res) => {
res.json({ from, to, rate, updatedAt: new Date().toISOString() });
}));

stripeRouter.post('/convert/lock-rate', asyncHandler(async (req, res) => {
stripeRouter.post('/convert/lock-rate', asyncHandler(async (req: any, res) => {
const { fromCurrency = 'USD', toAsset = 'USDC', amount } = req.body;
if (!amount) return res.status(400).json({ error: 'amount required' });
const lock = await lockRate(fromCurrency, toAsset, amount);
const userId = req.user?.id ?? req.headers['x-actor-id']?.toString();
if (!userId) return res.status(401).json({ error: 'authentication required' });
const lock = await lockRate(userId, fromCurrency, toAsset, amount);
res.json(lock);
}));

stripeRouter.post('/convert/confirm', asyncHandler(async (req, res) => {
stripeRouter.post('/convert/confirm', asyncHandler(async (req: any, res) => {
const { rateLockId, stripePaymentIntentId } = req.body;
if (!rateLockId || !stripePaymentIntentId) return res.status(400).json({ error: 'rateLockId and stripePaymentIntentId required' });
const conversion = await confirmConversion(rateLockId, stripePaymentIntentId);
const userId = req.user?.id ?? req.headers['x-actor-id']?.toString();
if (!userId) return res.status(401).json({ error: 'authentication required' });
const conversion = await confirmConversion(rateLockId, userId, stripePaymentIntentId);
res.json(conversion);
}));

Expand Down
30 changes: 30 additions & 0 deletions backend/src/services/__tests__/fiat-crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { lockRate, confirmConversion, getConversion } from '../fiat-crypto.js';

const USER_A = 'user_a';
const USER_B = 'user_b';

describe('fiat-crypto conversions', () => {
it('ties a confirmed conversion to the user who locked the rate', async () => {
const lock = await lockRate(USER_A, 'USD', 'USDC', 100);
const conversion = await confirmConversion(lock.id, USER_A, 'pi_123');
expect(conversion.userId).toBe(USER_A);
});

it('rejects confirming a rate lock that belongs to a different user', async () => {
const lock = await lockRate(USER_A, 'USD', 'USDC', 100);
await expect(confirmConversion(lock.id, USER_B, 'pi_123')).rejects.toThrow();
});

it('rejects confirming the same rate lock twice, even for its own owner', async () => {
const lock = await lockRate(USER_A, 'USD', 'USDC', 100);
const first = await confirmConversion(lock.id, USER_A, 'pi_first');
await expect(confirmConversion(lock.id, USER_A, 'pi_second')).rejects.toThrow(
'Rate lock has already been used',
);

// Only the first confirmation produced a conversion.
const stored = await getConversion(first.id);
expect(stored?.stripePaymentIntentId).toBe('pi_first');
});
});
18 changes: 15 additions & 3 deletions backend/src/services/fiat-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ export type ConversionStatus = 'pending' | 'rate_locked' | 'completed' | 'failed

export interface RateLock {
id: string;
userId: string;
fromCurrency: string;
toAsset: string;
rate: number;
amount: number;
expiresAt: number;
locked: boolean;
/** Set once this lock has been consumed by `confirmConversion`, so it cannot be reused. */
consumedAt?: number;
}

export interface ConversionRecord {
Expand Down Expand Up @@ -44,10 +47,11 @@ function getCurrentRate(fromCurrency: string, toAsset: string): number {
return 1.0;
}

export async function lockRate(fromCurrency: string, toAsset: string, amount: number): Promise<RateLock> {
export async function lockRate(userId: string, fromCurrency: string, toAsset: string, amount: number): Promise<RateLock> {
const rate = getCurrentRate(fromCurrency, toAsset);
const lock: RateLock = {
id: randomUUID(),
userId,
fromCurrency,
toAsset,
rate,
Expand All @@ -59,15 +63,23 @@ export async function lockRate(fromCurrency: string, toAsset: string, amount: nu
return lock;
}

export async function confirmConversion(rateLockId: string, stripePaymentIntentId: string): Promise<ConversionRecord> {
export async function confirmConversion(rateLockId: string, userId: string, stripePaymentIntentId: string): Promise<ConversionRecord> {
const lock = rateLocks.get(rateLockId);
if (!lock) throw new Error('Rate lock not found');
// A lock belongs to whoever created it, and can be spent exactly once -- otherwise
// one locked rate could be confirmed repeatedly (duplicate conversions from a single
// amount) or confirmed by a user who never created it.
if (lock.userId !== userId) throw new Error('Rate lock not found');
if (lock.consumedAt !== undefined) throw new Error('Rate lock has already been used');
if (Date.now() > lock.expiresAt) throw new Error('Rate lock has expired');

lock.consumedAt = Date.now();
rateLocks.set(lock.id, lock);

const cryptoAmount = (lock.amount * lock.rate).toFixed(7);
const conversion: ConversionRecord = {
id: randomUUID(),
userId: 'user_default',
userId: lock.userId,
fromCurrency: lock.fromCurrency,
toAsset: lock.toAsset,
fiatAmount: lock.amount,
Expand Down
Loading
Loading