Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/js-server-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions packages/js-server-sdk/src/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHmac, timingSafeEqual } from 'node:crypto';

import { ServerMessage } from '@fishjam-cloud/fishjam-proto';
import { extractNotifications, ServerNotification } from './notifications';

Expand Down Expand Up @@ -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=<lowercase hex HMAC-SHA256 of the raw body>`. 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);
Comment thread
roznawsk marked this conversation as resolved.
};
34 changes: 33 additions & 1 deletion packages/js-server-sdk/tests/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ServerMessage.encode>[0]): Uint8Array =>
ServerMessage.encode(message).finish();
Expand Down Expand Up @@ -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=<hex> 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);
});
});
Loading