Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
38 changes: 38 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,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=<lowercase hex HMAC-SHA256 of the raw body>`. 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);
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