diff --git a/backend/shared/cache/nonceCache.ts b/backend/shared/cache/nonceCache.ts new file mode 100644 index 00000000..1fe68e8b --- /dev/null +++ b/backend/shared/cache/nonceCache.ts @@ -0,0 +1,52 @@ +import { createClient, RedisClientType } from 'redis'; + +type RedisType = RedisClientType | null; + +export default class NonceCache { + private redis: RedisType = null; + private memory: Map = new Map(); + + constructor() { + const url = process.env.REDIS_URL; + if (url) { + try { + const client = createClient({ url }); + client.connect().catch(() => {}); + this.redis = client; + } catch (e) { + this.redis = null; + } + } + } + + async has(nonce: string): Promise { + if (this.redis) { + try { + const v = await this.redis.get(nonce); + return v !== null; + } catch { + // fallback to memory + } + } + const ts = this.memory.get(nonce); + if (!ts) return false; + if (Date.now() > ts) { + this.memory.delete(nonce); + return false; + } + return true; + } + + async set(nonce: string, ttlSeconds = 600): Promise { + if (this.redis) { + try { + await this.redis.set(nonce, '1', { EX: ttlSeconds }); + return; + } catch { + // fallback + } + } + const expires = Date.now() + ttlSeconds * 1000; + this.memory.set(nonce, expires); + } +} diff --git a/backend/shared/middleware/signature.ts b/backend/shared/middleware/signature.ts new file mode 100644 index 00000000..d42ebb5f --- /dev/null +++ b/backend/shared/middleware/signature.ts @@ -0,0 +1,22 @@ +import { Request, Response, NextFunction } from 'express'; +import SignatureService from '../webhook/SignatureService'; + +export function signatureMiddleware(signatureService: SignatureService) { + return async (req: Request, res: Response, next: NextFunction) => { + try { + // Attempt to obtain raw body; if not available, stringify body + let raw: string; + // Some apps attach rawBody earlier; prefer that. + // @ts-ignore + if (req.rawBody && typeof req.rawBody === 'string') raw = req.rawBody; + else if (typeof req.body === 'string') raw = req.body; + else raw = JSON.stringify(req.body || ''); + + const header = (req.get('X-Signature') || req.get('x-signature') || '') as string; + await signatureService.verify(raw, header); + return next(); + } catch (err) { + return res.status(401).json({ error: 'invalid_signature', message: String(err) }); + } + }; +} diff --git a/backend/shared/webhook/SignatureService.ts b/backend/shared/webhook/SignatureService.ts new file mode 100644 index 00000000..31e5c726 --- /dev/null +++ b/backend/shared/webhook/SignatureService.ts @@ -0,0 +1,81 @@ +import crypto from 'crypto'; +import KeyStore from './keyStore'; +import NonceCache from '../cache/nonceCache'; + +export interface SignatureOptions { + timestampTolerance?: number; // seconds + clockSkewTolerance?: number; // seconds + nonceTtl?: number; // seconds +} + +export default class SignatureService { + private keys: KeyStore; + private nonceCache: NonceCache; + private opts: Required; + + constructor(keyStore: KeyStore, opts?: SignatureOptions) { + this.keys = keyStore; + this.nonceCache = new NonceCache(); + this.opts = { + timestampTolerance: opts?.timestampTolerance ?? 300, + clockSkewTolerance: opts?.clockSkewTolerance ?? 30, + nonceTtl: opts?.nonceTtl ?? 600, + }; + } + + generate(body: string, secret?: string, timestamp?: number, nonce?: string) { + const ts = timestamp ?? Math.floor(Date.now() / 1000); + const n = nonce ?? crypto.randomBytes(12).toString('hex'); + const key = secret ?? this.keys.getCurrent(); + const hmac = crypto.createHmac('sha256', key).update(`${ts}.${body}`).digest(); + const sig = hmac.toString('base64'); + const header = `t=${ts},s=${sig},v=1,n=${n}`; + return { header, sig, ts, nonce: n }; + } + + parseHeader(header: string) { + const parts = header.split(',').map(p => p.trim()); + const map: Record = {}; + for (const part of parts) { + const [k, v] = part.split('='); + if (k && v) map[k] = v; + } + return map; + } + + async verify(rawBody: string, header: string) { + if (!header) throw new Error('missing signature header'); + const parsed = this.parseHeader(header); + const ts = parseInt(parsed.t, 10); + const sig = parsed.s; + const ver = parsed.v; + const nonce = parsed.n; + if (!ts || !sig || !ver || !nonce) throw new Error('invalid signature header'); + if (ver !== '1') throw new Error('unsupported signature version'); + + const now = Math.floor(Date.now() / 1000); + const allowed = this.opts.timestampTolerance + this.opts.clockSkewTolerance; + if (Math.abs(now - ts) > allowed) throw new Error('timestamp outside tolerance'); + + // nonce replay check + if (await this.nonceCache.has(nonce)) { + throw new Error('replay detected'); + } + + // compute hmac against active keys + const keys = this.keys.getActiveKeys(); + let match = false; + for (const k of keys) { + const h = crypto.createHmac('sha256', k).update(`${ts}.${rawBody}`).digest().toString('base64'); + if (h === sig) { + match = true; + break; + } + } + if (!match) throw new Error('signature mismatch'); + + // store nonce + await this.nonceCache.set(nonce, this.opts.nonceTtl); + return true; + } +} diff --git a/backend/shared/webhook/keyStore.ts b/backend/shared/webhook/keyStore.ts new file mode 100644 index 00000000..68bd3fee --- /dev/null +++ b/backend/shared/webhook/keyStore.ts @@ -0,0 +1,28 @@ +export default class KeyStore { + private current: string; + private previous: string | null; + + constructor(initialKey: string) { + this.current = initialKey; + this.previous = null; + } + + getActiveKeys(): string[] { + if (this.previous) return [this.current, this.previous]; + return [this.current]; + } + + rotate(newKey: string) { + this.previous = this.current; + this.current = newKey; + } + + setKeys(current: string, previous: string | null) { + this.current = current; + this.previous = previous; + } + + getCurrent() { + return this.current; + } +} diff --git a/backend/tests/signature.test.ts b/backend/tests/signature.test.ts new file mode 100644 index 00000000..b2d79b94 --- /dev/null +++ b/backend/tests/signature.test.ts @@ -0,0 +1,43 @@ +import KeyStore from '../../backend/shared/webhook/keyStore'; +import SignatureService from '../../backend/shared/webhook/SignatureService'; + +describe('SignatureService', () => { + const initialKey = 'test-secret-1'; + let ks: KeyStore; + let svc: SignatureService; + + beforeEach(() => { + ks = new KeyStore(initialKey); + svc = new SignatureService(ks, { timestampTolerance: 300, clockSkewTolerance: 30, nonceTtl: 2 }); + }); + + test('generates and verifies a signature', async () => { + const body = JSON.stringify({ hi: 'there' }); + const { header } = svc.generate(body); + await expect(svc.verify(body, header)).resolves.toBe(true); + }); + + test('rejects replayed nonce', async () => { + const body = 'payload'; + const { header } = svc.generate(body, undefined, undefined, 'fixednonce'); + await expect(svc.verify(body, header)).resolves.toBe(true); + await expect(svc.verify(body, header)).rejects.toThrow(/replay/); + }); + + test('rejects old timestamp beyond tolerance', async () => { + const body = 'payload'; + const oldTs = Math.floor(Date.now() / 1000) - 10000; // far in past + const { header } = svc.generate(body, undefined, oldTs, 'nonce2'); + await expect(svc.verify(body, header)).rejects.toThrow(/timestamp/); + }); + + test('accepts signature with rotated previous key', async () => { + const body = 'payload2'; + // sign with current key + const s1 = svc.generate(body); + // rotate keys + ks.rotate('new-key'); + // previous key (initial) should still verify + await expect(svc.verify(body, s1.header)).resolves.toBe(true); + }); +}); diff --git a/backend/webhook/controller/signatureController.ts b/backend/webhook/controller/signatureController.ts new file mode 100644 index 00000000..51e63966 --- /dev/null +++ b/backend/webhook/controller/signatureController.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express'; +import KeyStore from '../../shared/webhook/keyStore'; + +export default function signatureController(keyStore: KeyStore) { + return { + getKeys: (req: Request, res: Response) => { + // For admin use only; do not expose in production without auth + res.json({ current: keyStore.getCurrent(), active: keyStore.getActiveKeys() }); + }, + rotate: (req: Request, res: Response) => { + const { newKey } = req.body || {}; + if (!newKey) return res.status(400).json({ error: 'newKey required' }); + keyStore.rotate(newKey); + res.json({ ok: true }); + }, + }; +} diff --git a/package.json b/package.json index ac3e5334..174a8b68 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,8 @@ "react-native-svg": "15.15.4", "zod": "^3.23.8", "zustand": "^4.5.2" + , + "redis": "^4.6.7" }, "devDependencies": { "@babel/core": "^7.29.0",