From c8abf4705ca665e1591d87fb1265a61d103f783b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Ro=C5=BCnawski?= Date: Tue, 14 Jul 2026 18:39:15 +0200 Subject: [PATCH 1/4] Add verifyWebhookSignature helper for signed webhooks Verifies the x-fishjam-signature-256 header (sha256= HMAC-SHA256 of the raw body) with a constant-time compare. --- packages/js-server-sdk/src/index.ts | 2 +- packages/js-server-sdk/src/webhook.ts | 38 ++++++++++++++++++++ packages/js-server-sdk/tests/webhook.test.ts | 34 +++++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/js-server-sdk/src/index.ts b/packages/js-server-sdk/src/index.ts index ad698458..f2037d62 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 5e479af1..03b8e84e 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,39 @@ 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 + * (undecoded) request body, the header value, and your secret; the comparison + * is constant-time. 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 efdb1134..74693cf2 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); + }); +}); From cba34a641f686c09d46a928c74c60db8eed5579d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Ro=C5=BCnawski?= Date: Tue, 14 Jul 2026 18:51:45 +0200 Subject: [PATCH 2/4] Address Copilot review comments Qualify the constant-time claim in verifyWebhookSignature docs: the length check short-circuits, leaking only the (public) expected signature length. --- packages/js-server-sdk/src/webhook.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/js-server-sdk/src/webhook.ts b/packages/js-server-sdk/src/webhook.ts index 03b8e84e..4b4254c2 100644 --- a/packages/js-server-sdk/src/webhook.ts +++ b/packages/js-server-sdk/src/webhook.ts @@ -40,7 +40,9 @@ export const decodeServerNotifications = (data: Uint8Array | ArrayBuffer): Serve * the result in the `x-fishjam-signature-256` header as * `sha256=`. Pass the raw * (undecoded) request body, the header value, and your secret; the comparison - * is constant-time. Verify before calling {@link decodeServerNotifications}. + * 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 From d606053ce6aead42fcb9bfe207f742ee768beb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Ro=C5=BCnawski?= Date: Wed, 15 Jul 2026 08:36:06 +0200 Subject: [PATCH 3/4] Fix formatting in webhook.ts Collapses a manually-wrapped function signature to match the project's printWidth (120). --- packages/js-server-sdk/src/webhook.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/js-server-sdk/src/webhook.ts b/packages/js-server-sdk/src/webhook.ts index 4b4254c2..9f407185 100644 --- a/packages/js-server-sdk/src/webhook.ts +++ b/packages/js-server-sdk/src/webhook.ts @@ -58,11 +58,7 @@ export const decodeServerNotifications = (data: Uint8Array | ArrayBuffer): Serve * ``` * @category Notifications */ -export const verifyWebhookSignature = ( - body: Uint8Array | ArrayBuffer, - signature: string, - secret: string, -): boolean => { +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'); From c5bee41f041afb516dcc4ff13f79bdaa25d5ad97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Ro=C5=BCnawski?= Date: Wed, 15 Jul 2026 08:48:28 +0200 Subject: [PATCH 4/4] Fix spellcheck failure in webhook.ts docs "undecoded" isn't a recognized word; reword the JSDoc to avoid it. --- packages/js-server-sdk/src/webhook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/js-server-sdk/src/webhook.ts b/packages/js-server-sdk/src/webhook.ts index 9f407185..a03257cf 100644 --- a/packages/js-server-sdk/src/webhook.ts +++ b/packages/js-server-sdk/src/webhook.ts @@ -38,8 +38,8 @@ export const decodeServerNotifications = (data: Uint8Array | ArrayBuffer): Serve * * 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 - * (undecoded) request body, the header value, and your secret; the comparison + * `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}.