Skip to content

Commit a8faaf2

Browse files
authored
Add webhook HMAC signature verification, nonce replay protection, and key rotation support (#701)
1 parent 35149b0 commit a8faaf2

7 files changed

Lines changed: 246 additions & 0 deletions

File tree

backend/shared/cache/nonceCache.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createClient, RedisClientType } from 'redis';
2+
3+
type RedisType = RedisClientType | null;
4+
5+
export default class NonceCache {
6+
private redis: RedisType = null;
7+
private memory: Map<string, number> = new Map();
8+
9+
constructor() {
10+
const url = process.env.REDIS_URL;
11+
if (url) {
12+
try {
13+
const client = createClient({ url });
14+
client.connect().catch(() => {});
15+
this.redis = client;
16+
} catch (e) {
17+
this.redis = null;
18+
}
19+
}
20+
}
21+
22+
async has(nonce: string): Promise<boolean> {
23+
if (this.redis) {
24+
try {
25+
const v = await this.redis.get(nonce);
26+
return v !== null;
27+
} catch {
28+
// fallback to memory
29+
}
30+
}
31+
const ts = this.memory.get(nonce);
32+
if (!ts) return false;
33+
if (Date.now() > ts) {
34+
this.memory.delete(nonce);
35+
return false;
36+
}
37+
return true;
38+
}
39+
40+
async set(nonce: string, ttlSeconds = 600): Promise<void> {
41+
if (this.redis) {
42+
try {
43+
await this.redis.set(nonce, '1', { EX: ttlSeconds });
44+
return;
45+
} catch {
46+
// fallback
47+
}
48+
}
49+
const expires = Date.now() + ttlSeconds * 1000;
50+
this.memory.set(nonce, expires);
51+
}
52+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import SignatureService from '../webhook/SignatureService';
3+
4+
export function signatureMiddleware(signatureService: SignatureService) {
5+
return async (req: Request, res: Response, next: NextFunction) => {
6+
try {
7+
// Attempt to obtain raw body; if not available, stringify body
8+
let raw: string;
9+
// Some apps attach rawBody earlier; prefer that.
10+
// @ts-ignore
11+
if (req.rawBody && typeof req.rawBody === 'string') raw = req.rawBody;
12+
else if (typeof req.body === 'string') raw = req.body;
13+
else raw = JSON.stringify(req.body || '');
14+
15+
const header = (req.get('X-Signature') || req.get('x-signature') || '') as string;
16+
await signatureService.verify(raw, header);
17+
return next();
18+
} catch (err) {
19+
return res.status(401).json({ error: 'invalid_signature', message: String(err) });
20+
}
21+
};
22+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import crypto from 'crypto';
2+
import KeyStore from './keyStore';
3+
import NonceCache from '../cache/nonceCache';
4+
5+
export interface SignatureOptions {
6+
timestampTolerance?: number; // seconds
7+
clockSkewTolerance?: number; // seconds
8+
nonceTtl?: number; // seconds
9+
}
10+
11+
export default class SignatureService {
12+
private keys: KeyStore;
13+
private nonceCache: NonceCache;
14+
private opts: Required<SignatureOptions>;
15+
16+
constructor(keyStore: KeyStore, opts?: SignatureOptions) {
17+
this.keys = keyStore;
18+
this.nonceCache = new NonceCache();
19+
this.opts = {
20+
timestampTolerance: opts?.timestampTolerance ?? 300,
21+
clockSkewTolerance: opts?.clockSkewTolerance ?? 30,
22+
nonceTtl: opts?.nonceTtl ?? 600,
23+
};
24+
}
25+
26+
generate(body: string, secret?: string, timestamp?: number, nonce?: string) {
27+
const ts = timestamp ?? Math.floor(Date.now() / 1000);
28+
const n = nonce ?? crypto.randomBytes(12).toString('hex');
29+
const key = secret ?? this.keys.getCurrent();
30+
const hmac = crypto.createHmac('sha256', key).update(`${ts}.${body}`).digest();
31+
const sig = hmac.toString('base64');
32+
const header = `t=${ts},s=${sig},v=1,n=${n}`;
33+
return { header, sig, ts, nonce: n };
34+
}
35+
36+
parseHeader(header: string) {
37+
const parts = header.split(',').map(p => p.trim());
38+
const map: Record<string, string> = {};
39+
for (const part of parts) {
40+
const [k, v] = part.split('=');
41+
if (k && v) map[k] = v;
42+
}
43+
return map;
44+
}
45+
46+
async verify(rawBody: string, header: string) {
47+
if (!header) throw new Error('missing signature header');
48+
const parsed = this.parseHeader(header);
49+
const ts = parseInt(parsed.t, 10);
50+
const sig = parsed.s;
51+
const ver = parsed.v;
52+
const nonce = parsed.n;
53+
if (!ts || !sig || !ver || !nonce) throw new Error('invalid signature header');
54+
if (ver !== '1') throw new Error('unsupported signature version');
55+
56+
const now = Math.floor(Date.now() / 1000);
57+
const allowed = this.opts.timestampTolerance + this.opts.clockSkewTolerance;
58+
if (Math.abs(now - ts) > allowed) throw new Error('timestamp outside tolerance');
59+
60+
// nonce replay check
61+
if (await this.nonceCache.has(nonce)) {
62+
throw new Error('replay detected');
63+
}
64+
65+
// compute hmac against active keys
66+
const keys = this.keys.getActiveKeys();
67+
let match = false;
68+
for (const k of keys) {
69+
const h = crypto.createHmac('sha256', k).update(`${ts}.${rawBody}`).digest().toString('base64');
70+
if (h === sig) {
71+
match = true;
72+
break;
73+
}
74+
}
75+
if (!match) throw new Error('signature mismatch');
76+
77+
// store nonce
78+
await this.nonceCache.set(nonce, this.opts.nonceTtl);
79+
return true;
80+
}
81+
}

backend/shared/webhook/keyStore.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export default class KeyStore {
2+
private current: string;
3+
private previous: string | null;
4+
5+
constructor(initialKey: string) {
6+
this.current = initialKey;
7+
this.previous = null;
8+
}
9+
10+
getActiveKeys(): string[] {
11+
if (this.previous) return [this.current, this.previous];
12+
return [this.current];
13+
}
14+
15+
rotate(newKey: string) {
16+
this.previous = this.current;
17+
this.current = newKey;
18+
}
19+
20+
setKeys(current: string, previous: string | null) {
21+
this.current = current;
22+
this.previous = previous;
23+
}
24+
25+
getCurrent() {
26+
return this.current;
27+
}
28+
}

backend/tests/signature.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import KeyStore from '../../backend/shared/webhook/keyStore';
2+
import SignatureService from '../../backend/shared/webhook/SignatureService';
3+
4+
describe('SignatureService', () => {
5+
const initialKey = 'test-secret-1';
6+
let ks: KeyStore;
7+
let svc: SignatureService;
8+
9+
beforeEach(() => {
10+
ks = new KeyStore(initialKey);
11+
svc = new SignatureService(ks, { timestampTolerance: 300, clockSkewTolerance: 30, nonceTtl: 2 });
12+
});
13+
14+
test('generates and verifies a signature', async () => {
15+
const body = JSON.stringify({ hi: 'there' });
16+
const { header } = svc.generate(body);
17+
await expect(svc.verify(body, header)).resolves.toBe(true);
18+
});
19+
20+
test('rejects replayed nonce', async () => {
21+
const body = 'payload';
22+
const { header } = svc.generate(body, undefined, undefined, 'fixednonce');
23+
await expect(svc.verify(body, header)).resolves.toBe(true);
24+
await expect(svc.verify(body, header)).rejects.toThrow(/replay/);
25+
});
26+
27+
test('rejects old timestamp beyond tolerance', async () => {
28+
const body = 'payload';
29+
const oldTs = Math.floor(Date.now() / 1000) - 10000; // far in past
30+
const { header } = svc.generate(body, undefined, oldTs, 'nonce2');
31+
await expect(svc.verify(body, header)).rejects.toThrow(/timestamp/);
32+
});
33+
34+
test('accepts signature with rotated previous key', async () => {
35+
const body = 'payload2';
36+
// sign with current key
37+
const s1 = svc.generate(body);
38+
// rotate keys
39+
ks.rotate('new-key');
40+
// previous key (initial) should still verify
41+
await expect(svc.verify(body, s1.header)).resolves.toBe(true);
42+
});
43+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Request, Response } from 'express';
2+
import KeyStore from '../../shared/webhook/keyStore';
3+
4+
export default function signatureController(keyStore: KeyStore) {
5+
return {
6+
getKeys: (req: Request, res: Response) => {
7+
// For admin use only; do not expose in production without auth
8+
res.json({ current: keyStore.getCurrent(), active: keyStore.getActiveKeys() });
9+
},
10+
rotate: (req: Request, res: Response) => {
11+
const { newKey } = req.body || {};
12+
if (!newKey) return res.status(400).json({ error: 'newKey required' });
13+
keyStore.rotate(newKey);
14+
res.json({ ok: true });
15+
},
16+
};
17+
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@
116116
"react-native-screens": "~4.24.0",
117117
"react-native-svg": "15.15.4",
118118
"zod": "^3.23.8",
119+
"zustand": "^4.5.2"
120+
,
121+
"redis": "^4.6.7"
119122
"zustand": "^5.0.0"
120123
},
121124
"devDependencies": {

0 commit comments

Comments
 (0)