diff --git a/packages/js-server-sdk/src/index.ts b/packages/js-server-sdk/src/index.ts index ad69845..f2037d6 100644 --- a/packages/js-server-sdk/src/index.ts +++ b/packages/js-server-sdk/src/index.ts @@ -23,7 +23,7 @@ export { export { ServerMessage } from '@fishjam-cloud/fishjam-proto'; export { FishjamWSNotifier } from './ws_notifier'; -export { decodeServerNotifications } from './webhook'; +export { decodeServerNotifications, verifyWebhookSignature } from './webhook'; export type { Track, ServerNotification, diff --git a/packages/js-server-sdk/src/webhook.ts b/packages/js-server-sdk/src/webhook.ts index 5e479af..a03257c 100644 --- a/packages/js-server-sdk/src/webhook.ts +++ b/packages/js-server-sdk/src/webhook.ts @@ -1,3 +1,5 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + import { ServerMessage } from '@fishjam-cloud/fishjam-proto'; import { extractNotifications, ServerNotification } from './notifications'; @@ -30,3 +32,37 @@ import { extractNotifications, ServerNotification } from './notifications'; */ export const decodeServerNotifications = (data: Uint8Array | ArrayBuffer): ServerNotification[] => extractNotifications(ServerMessage.decode(data instanceof Uint8Array ? data : new Uint8Array(data))); + +/** + * Verify the signature of a raw Fishjam webhook request. + * + * Fishjam signs each webhook delivery with the room's signing secret and sends + * the result in the `x-fishjam-signature-256` header as + * `sha256=`. Pass the raw, + * not-yet-decoded request body, the header value, and your secret; the comparison + * is constant-time (signatures of the wrong length are rejected early, which + * leaks only the expected signature length — public knowledge for SHA-256). + * Verify before calling {@link decodeServerNotifications}. + * + * @example + * ```ts + * import { verifyWebhookSignature, decodeServerNotifications } from '@fishjam-cloud/js-server-sdk'; + * + * declare const body: Uint8Array; + * declare const signatureHeader: string; + * // ---cut--- + * if (!verifyWebhookSignature(body, signatureHeader, process.env.WEBHOOK_SECRET!)) { + * throw new Error('Invalid webhook signature'); + * } + * const notifications = decodeServerNotifications(body); + * ``` + * @category Notifications + */ +export const verifyWebhookSignature = (body: Uint8Array | ArrayBuffer, signature: string, secret: string): boolean => { + const expected = createHmac('sha256', secret) + .update(body instanceof Uint8Array ? body : new Uint8Array(body)) + .digest('hex'); + const provided = Buffer.from(signature.trim().replace(/^sha256=/, ''), 'utf8'); + const wanted = Buffer.from(expected, 'utf8'); + return provided.length === wanted.length && timingSafeEqual(provided, wanted); +}; diff --git a/packages/js-server-sdk/tests/webhook.test.ts b/packages/js-server-sdk/tests/webhook.test.ts index efdb113..74693cf 100644 --- a/packages/js-server-sdk/tests/webhook.test.ts +++ b/packages/js-server-sdk/tests/webhook.test.ts @@ -1,6 +1,8 @@ +import { createHmac } from 'node:crypto'; + import { describe, it, expect } from 'vitest'; import { ServerMessage, ServerMessage_PeerType, TrackType } from '@fishjam-cloud/fishjam-proto'; -import { decodeServerNotifications } from '../src/webhook'; +import { decodeServerNotifications, verifyWebhookSignature } from '../src/webhook'; const encode = (message: Parameters[0]): Uint8Array => ServerMessage.encode(message).finish(); @@ -94,3 +96,33 @@ describe('decodeServerNotifications', () => { expect(decodeServerNotifications(arrayBuffer)).toHaveLength(1); // ArrayBuffer }); }); + +describe('verifyWebhookSignature', () => { + const secret = 'test-secret'; + const body = encode(peerConnected); + // Mirrors the server: Base.encode16(:crypto.mac(:hmac, :sha256, secret, body), case: :lower) + const validHex = createHmac('sha256', secret).update(body).digest('hex'); + + it('accepts the sha256= header format sent by the server', () => { + expect(verifyWebhookSignature(body, `sha256=${validHex}`, secret)).toBe(true); + }); + + it('accepts a bare hex signature and an ArrayBuffer body', () => { + const arrayBuffer = new Uint8Array(body).buffer as ArrayBuffer; + expect(verifyWebhookSignature(arrayBuffer, validHex, secret)).toBe(true); + }); + + it('rejects a signature computed with a different secret', () => { + const other = createHmac('sha256', 'other-secret').update(body).digest('hex'); + expect(verifyWebhookSignature(body, `sha256=${other}`, secret)).toBe(false); + }); + + it('rejects a tampered body', () => { + expect(verifyWebhookSignature(encode(trackAdded), `sha256=${validHex}`, secret)).toBe(false); + }); + + it('rejects malformed signatures without throwing', () => { + expect(verifyWebhookSignature(body, '', secret)).toBe(false); + expect(verifyWebhookSignature(body, 'sha256=deadbeef', secret)).toBe(false); + }); +});