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
52 changes: 52 additions & 0 deletions backend/shared/cache/nonceCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createClient, RedisClientType } from 'redis';

type RedisType = RedisClientType | null;

export default class NonceCache {
private redis: RedisType = null;
private memory: Map<string, number> = 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<boolean> {
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<void> {
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);
}
}
22 changes: 22 additions & 0 deletions backend/shared/middleware/signature.ts
Original file line number Diff line number Diff line change
@@ -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) });
}
};
}
81 changes: 81 additions & 0 deletions backend/shared/webhook/SignatureService.ts
Original file line number Diff line number Diff line change
@@ -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<SignatureOptions>;

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<string, string> = {};
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;
}
}
28 changes: 28 additions & 0 deletions backend/shared/webhook/keyStore.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
43 changes: 43 additions & 0 deletions backend/tests/signature.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 17 additions & 0 deletions backend/webhook/controller/signatureController.ts
Original file line number Diff line number Diff line change
@@ -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 });
},
};
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down