diff --git a/README.md b/README.md index 85bc066..0a4336e 100644 --- a/README.md +++ b/README.md @@ -116,25 +116,50 @@ const { batch, messages } = await textbee.getSmsBatch(deviceId, smsBatchId) ## Verifying webhooks -textbee signs each webhook delivery with HMAC-SHA256 and sends the hex digest in the `X-Signature` header. Pass the raw request body, not a re-serialized object, whenever your framework gives you access to it. +textbee signs each webhook delivery with your signing secret and sends a timestamped signature in the `X-Textbee-Signature` header: + +``` +X-Textbee-Signature: t=1762300000,v1=5f2b... +``` + +`v1` is the hex HMAC-SHA256 of `.`. `verifyWebhookRequest` reads the signature header, checks `v1` against the raw body in constant time, and rejects the request when `t` is more than 300 seconds from now. The signed timestamp makes a captured delivery useless after that window. + +Verify the raw body exactly as received. A parsed and re-serialized body can differ by one byte and fail. ```js -import { verifyWebhookSignature } from '@textbee/sdk' +import express from 'express' +import { verifyWebhookRequest } from '@textbee/sdk' + +const app = express() app.post('/webhooks/textbee', express.raw({ type: 'application/json' }), async (req, res) => { - const valid = await verifyWebhookSignature({ - payload: req.body.toString('utf8'), - signature: req.get('x-signature'), + const valid = await verifyWebhookRequest({ + payload: req.body, // a Buffer, from express.raw + headers: req.headers, signingSecret: process.env.TEXTBEE_WEBHOOK_SECRET, }) if (!valid) return res.sendStatus(401) const event = JSON.parse(req.body.toString('utf8')) + // Retries carry the same idempotencyKey. Skip keys you have already handled. res.sendStatus(200) }) ``` +`payload` takes a string or bytes (`Buffer`, `Uint8Array`). `headers` takes Node's `req.headers`, a plain object, or a Fetch `Headers` object, and header names match in any case. + +Options: + +- `toleranceSeconds`: the largest allowed distance between `t` and now, in either direction. Defaults to `300`. Keep your server clock in sync (NTP). +- `allowLegacy`: an explicit opt-in for old receivers. Off by default. When it is `true` and the signature header is absent, the request is checked against `X-Signature` instead, which has no replay protection. + +To check the signature header value yourself, call `verifyTimestampedWebhookSignature({ payload, signatureHeader, signingSecret })`. It takes the same `toleranceSeconds` option. + +Retries and duplicates: a failed delivery is retried with a fresh `t`, so each attempt passes the freshness check. Every payload carries an `idempotencyKey`. Store the keys you have processed and skip repeats. + +**Deprecated:** `verifyWebhookSignature` checks only `X-Signature`. It still works because textbee still sends that header, but it has no replay protection. See [textbee.dev/docs/webhooks](https://textbee.dev/docs/webhooks) for the upgrade steps. + ## SMS utilities Pure helpers for working with SMS text and phone numbers. No API key, no network calls, and they are useful with any SMS provider, not just textbee. Import only what you need and the rest is tree-shaken away. diff --git a/package.json b/package.json index 1291c64..eb71b75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@textbee/sdk", - "version": "0.4.0", + "version": "0.5.0", "description": "Official JavaScript SDK for textbee.dev, the open source SMS gateway", "license": "MIT", "packageManager": "pnpm@9.14.2", diff --git a/src/index.ts b/src/index.ts index 7c52bf5..52d190e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,11 @@ export { normalizePhoneNumber, sanitizeForGsm7, } from './sms-utils' -export { verifyWebhookSignature } from './webhooks' +export { + verifyTimestampedWebhookSignature, + verifyWebhookRequest, + verifyWebhookSignature, +} from './webhooks' export type { NormalizePhoneNumberOptions, @@ -16,7 +20,12 @@ export type { SmsEncoding, SmsSegmentInfo, } from './sms-utils' -export type { VerifyWebhookSignatureOptions } from './webhooks' +export type { + VerifyTimestampedWebhookSignatureOptions, + VerifyWebhookRequestOptions, + VerifyWebhookSignatureOptions, + WebhookRequestHeaders, +} from './webhooks' export type { Device, IterateMessagesOptions, diff --git a/src/webhooks.ts b/src/webhooks.ts index 67223bb..4fe1bee 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -1,3 +1,8 @@ +/** Default maximum distance, in seconds, between a signed timestamp and now. */ +const DEFAULT_TOLERANCE_SECONDS = 300 + +const encoder = new TextEncoder() + export interface VerifyWebhookSignatureOptions { /** * The webhook request body. Prefer the raw body string. @@ -15,11 +20,61 @@ export interface VerifyWebhookSignatureOptions { signingSecret: string } +export interface VerifyTimestampedWebhookSignatureOptions { + /** The raw request body, exactly as received. Do not parse and re-serialize it. */ + payload: string | Uint8Array + + /** Value of the `X-Textbee-Signature` request header: `t=,v1=`. */ + signatureHeader: string | null | undefined + + /** Signing secret shown with the webhook subscription in the dashboard. */ + signingSecret: string + + /** Maximum difference, in seconds, between the signed timestamp and now. Defaults to 300. */ + toleranceSeconds?: number + + /** Current time in unix seconds. Defaults to the system clock. */ + now?: number +} + +/** Request headers as a Fetch `Headers` object or a plain object such as Node's `req.headers`. */ +export type WebhookRequestHeaders = + | { get(name: string): string | null } + | Record + +export interface VerifyWebhookRequestOptions { + /** The raw request body, exactly as received. Do not parse and re-serialize it. */ + payload: string | Uint8Array + + /** The request headers. Names are matched case-insensitively. */ + headers: WebhookRequestHeaders + + /** Signing secret shown with the webhook subscription in the dashboard. */ + signingSecret: string + + /** Maximum difference, in seconds, between the signed timestamp and now. Defaults to 300. */ + toleranceSeconds?: number + + /** Current time in unix seconds. Defaults to the system clock. */ + now?: number + + /** + * Accept the `X-Signature` header when `X-Textbee-Signature` is absent. + * Off by default, because `X-Signature` has no replay protection. Turn it on + * only for old receivers that cannot read the signature header yet. + */ + allowLegacy?: boolean +} + /** - * Check that a webhook really came from textbee. + * Check that a webhook really came from textbee, using the `X-Signature` header. * * textbee signs deliveries with HMAC-SHA256 over the request body and sends the * hex digest in the `X-Signature` header. Comparison is constant time. + * + * @deprecated This header has no timestamp, so a captured delivery can be + * replayed. textbee still sends it, so this keeps working. Use + * {@link verifyWebhookRequest} or {@link verifyTimestampedWebhookSignature}. */ export async function verifyWebhookSignature( options: VerifyWebhookSignatureOptions, @@ -31,13 +86,157 @@ export async function verifyWebhookSignature( } const body = typeof payload === 'string' ? payload : JSON.stringify(payload) - const expected = await hmacSha256Hex(signingSecret, body) + const expected = await hmacSha256Hex(signingSecret, encoder.encode(body)) return constantTimeEqual(expected, signature.toLowerCase()) } -async function hmacSha256Hex(secret: string, message: string): Promise { - const encoder = new TextEncoder() +/** + * Check the timestamped signature in the `X-Textbee-Signature` header. + * + * The header is `t=,v1=`, where the hex value is + * HMAC-SHA256 over `.`. Any matching `v1` entry passes. Returns + * false when the header is malformed, no `v1` entry matches, or `t` is more + * than `toleranceSeconds` away from now in either direction. + */ +export async function verifyTimestampedWebhookSignature( + options: VerifyTimestampedWebhookSignatureOptions, +): Promise { + const { + payload, + signatureHeader, + signingSecret, + toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, + now = Math.floor(Date.now() / 1000), + } = options + + const body = toBytes(payload) + if (!signatureHeader || !signingSecret || !body) { + return false + } + + const parsed = parseSignatureHeader(signatureHeader) + if (!parsed) { + return false + } + + // Written as a negated <= so a NaN tolerance or clock rejects instead of passing. + if (!(Math.abs(now - Number(parsed.timestamp)) <= toleranceSeconds)) { + return false + } + + const prefix = encoder.encode(`${parsed.timestamp}.`) + const signed = new Uint8Array(prefix.length + body.length) + signed.set(prefix) + signed.set(body, prefix.length) + + const expected = await hmacSha256Hex(signingSecret, signed) + + let matched = false + for (const candidate of parsed.signatures) { + if (constantTimeEqual(expected, candidate.toLowerCase())) { + matched = true + } + } + + return matched +} + +/** + * Check that a webhook request came from textbee, from its headers. + * + * Verifies the timestamped signature in `X-Textbee-Signature`. When that + * header is absent and `allowLegacy` is true, falls back to `X-Signature`, + * which has no replay protection. + */ +export async function verifyWebhookRequest( + options: VerifyWebhookRequestOptions, +): Promise { + const { payload, headers, signingSecret, toleranceSeconds, now, allowLegacy = false } = options + + const signatureHeader = getHeader(headers, 'x-textbee-signature') + if (signatureHeader !== undefined) { + return verifyTimestampedWebhookSignature({ + payload, + signatureHeader, + signingSecret, + toleranceSeconds, + now, + }) + } + + if (!allowLegacy) { + return false + } + + const legacySignature = getHeader(headers, 'x-signature') + const body = toBytes(payload) + if (!legacySignature || !signingSecret || !body) { + return false + } + + const expected = await hmacSha256Hex(signingSecret, body) + + return constantTimeEqual(expected, legacySignature.toLowerCase()) +} + +function parseSignatureHeader( + header: string, +): { timestamp: string; signatures: string[] } | null { + let timestamp: string | undefined + const signatures: string[] = [] + + for (const part of header.split(',')) { + const separator = part.indexOf('=') + if (separator === -1) { + continue + } + + const key = part.slice(0, separator).trim() + const value = part.slice(separator + 1).trim() + + if (key === 't') { + if (timestamp !== undefined || !/^\d+$/.test(value)) { + return null + } + timestamp = value + } else if (key === 'v1' && value) { + signatures.push(value) + } + } + + if (timestamp === undefined || signatures.length === 0) { + return null + } + + return { timestamp, signatures } +} + +function getHeader(headers: WebhookRequestHeaders, name: string): string | undefined { + if (typeof headers.get === 'function') { + return (headers as { get(name: string): string | null }).get(name) ?? undefined + } + + // Every case variant counts, so a second copy of a header cannot hide behind the first. + const values: string[] = [] + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === name && value !== undefined) { + values.push(...(Array.isArray(value) ? value : [value])) + } + } + + return values.length > 0 ? values.join(',') : undefined +} + +function toBytes(payload: unknown): Uint8Array | undefined { + if (typeof payload === 'string') { + return encoder.encode(payload) + } + // A body parser that skipped the request can leave undefined or {} here. + return payload instanceof Uint8Array ? payload : undefined +} + +async function hmacSha256Hex(secret: string, message: Uint8Array): Promise { const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), @@ -45,7 +244,8 @@ async function hmacSha256Hex(secret: string, message: string): Promise { false, ['sign'], ) - const digest = await crypto.subtle.sign('HMAC', key, encoder.encode(message)) + // Copied so a view over a SharedArrayBuffer or a larger Buffer pool is safe to sign. + const digest = await crypto.subtle.sign('HMAC', key, new Uint8Array(message)) return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, '0')) diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts index a834685..4fa2668 100644 --- a/test/webhooks.test.ts +++ b/test/webhooks.test.ts @@ -1,6 +1,10 @@ import { createHmac } from 'node:crypto' import { describe, expect, it } from 'vitest' -import { verifyWebhookSignature } from '../src/index' +import { + verifyTimestampedWebhookSignature, + verifyWebhookRequest, + verifyWebhookSignature, +} from '../src/index' const SIGNING_SECRET = 'whsec_test_secret' @@ -91,3 +95,290 @@ describe('verifyWebhookSignature', () => { ).resolves.toBe(false) }) }) + +const NOW = 1_762_300_000 +const rawBody = JSON.stringify({ ...payload, idempotencyKey: 'evt_1' }) + +/** Exactly how the api builds X-Textbee-Signature. */ +function signTimestamped(body: string, t: number | string = NOW, secret = SIGNING_SECRET): string { + const v1 = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex') + return `t=${t},v1=${v1}` +} + +function verify(signatureHeader: string, overrides: { payload?: string | Uint8Array; now?: number } = {}) { + return verifyTimestampedWebhookSignature({ + payload: overrides.payload ?? rawBody, + signatureHeader, + signingSecret: SIGNING_SECRET, + now: overrides.now ?? NOW, + }) +} + +describe('verifyTimestampedWebhookSignature', () => { + it('accepts a header produced the way the api produces it', async () => { + await expect(verify(signTimestamped(rawBody))).resolves.toBe(true) + }) + + it('accepts a raw body as bytes', async () => { + await expect( + verify(signTimestamped(rawBody), { payload: Buffer.from(rawBody, 'utf8') }), + ).resolves.toBe(true) + }) + + it('accepts a non-ASCII body', async () => { + const body = JSON.stringify({ ...payload, message: 'café 👍' }) + await expect(verify(signTimestamped(body), { payload: body })).resolves.toBe(true) + }) + + it('rejects the wrong signing secret', async () => { + await expect( + verify(signTimestamped(rawBody, NOW, 'whsec_other_secret')), + ).resolves.toBe(false) + }) + + it('rejects a tampered body', async () => { + await expect( + verify(signTimestamped(rawBody), { payload: rawBody.replace('hello', 'tampered') }), + ).resolves.toBe(false) + }) + + it('rejects a timestamp older than 300 seconds and accepts one at the limit', async () => { + const header = signTimestamped(rawBody) + await expect(verify(header, { now: NOW + 300 })).resolves.toBe(true) + await expect(verify(header, { now: NOW + 301 })).resolves.toBe(false) + }) + + it('rejects a timestamp too far in the future', async () => { + const header = signTimestamped(rawBody) + await expect(verify(header, { now: NOW - 300 })).resolves.toBe(true) + await expect(verify(header, { now: NOW - 301 })).resolves.toBe(false) + }) + + it('honors a custom tolerance', async () => { + await expect( + verifyTimestampedWebhookSignature({ + payload: rawBody, + signatureHeader: signTimestamped(rawBody), + signingSecret: SIGNING_SECRET, + toleranceSeconds: 10, + now: NOW + 11, + }), + ).resolves.toBe(false) + }) + + it('accepts a timestamp inside a custom tolerance above 300 seconds', async () => { + await expect( + verifyTimestampedWebhookSignature({ + payload: rawBody, + signatureHeader: signTimestamped(rawBody), + signingSecret: SIGNING_SECRET, + toleranceSeconds: 600, + now: NOW + 301, + }), + ).resolves.toBe(true) + }) + + it('rejects a timestamp re-signed under a different t', async () => { + const v1 = signTimestamped(rawBody).split('v1=')[1] + await expect(verify(`t=${NOW + 1},v1=${v1}`)).resolves.toBe(false) + }) + + it('rejects malformed headers', async () => { + const v1 = signTimestamped(rawBody).split('v1=')[1] + const malformed = [ + '', + 'garbage', + `v1=${v1}`, + `t=${NOW}`, + `t=${NOW},v1=`, + `t=-${NOW},v1=${v1}`, + `t=${NOW}.5,v1=${v1}`, + `t=abc,v1=${v1}`, + `t=,v1=${v1}`, + ] + for (const header of malformed) { + await expect(verify(header)).resolves.toBe(false) + } + await expect( + verifyTimestampedWebhookSignature({ + payload: rawBody, + signatureHeader: undefined, + signingSecret: SIGNING_SECRET, + now: NOW, + }), + ).resolves.toBe(false) + }) + + it('accepts any matching v1 entry, in any position, and ignores unknown keys', async () => { + const v1 = signTimestamped(rawBody).split('v1=')[1] + const other = 'a'.repeat(64) + await expect(verify(`t=${NOW},v1=${other},v1=${v1}`)).resolves.toBe(true) + await expect(verify(`t=${NOW}, v1=${v1}, v1=${other}`)).resolves.toBe(true) + await expect(verify(`t=${NOW},v0=${other},v1=${v1}`)).resolves.toBe(true) + await expect(verify(`t=${NOW},v1=${other},v1=${other}`)).resolves.toBe(false) + }) + + it('rejects a duplicate t', async () => { + const v1 = signTimestamped(rawBody).split('v1=')[1] + await expect(verify(`t=${NOW},t=${NOW},v1=${v1}`)).resolves.toBe(false) + await expect(verify(`t=${NOW},v1=${v1},t=${NOW - 1000}`)).resolves.toBe(false) + }) + + it('rejects a non-ASCII v1 without throwing', async () => { + await expect(verify(`t=${NOW},v1=${'é'.repeat(64)}`)).resolves.toBe(false) + }) + + it('accepts an uppercase hex v1', async () => { + const v1 = signTimestamped(rawBody).split('v1=')[1]! + await expect(verify(`t=${NOW},v1=${v1.toUpperCase()}`)).resolves.toBe(true) + }) +}) + +describe('verifyWebhookRequest', () => { + const legacySignature = createHmac('sha256', SIGNING_SECRET).update(rawBody).digest('hex') + + it('verifies X-Textbee-Signature from a plain headers object', async () => { + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: { 'x-textbee-signature': signTimestamped(rawBody) }, + signingSecret: SIGNING_SECRET, + now: NOW, + }), + ).resolves.toBe(true) + }) + + it('matches header names case-insensitively', async () => { + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: { 'X-TEXTBEE-SIGNATURE': signTimestamped(rawBody) }, + signingSecret: SIGNING_SECRET, + now: NOW, + }), + ).resolves.toBe(true) + + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: new Headers({ 'X-Textbee-Signature': signTimestamped(rawBody) }), + signingSecret: SIGNING_SECRET, + now: NOW, + }), + ).resolves.toBe(true) + }) + + it('rejects a stale timestamped signature', async () => { + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: { 'x-textbee-signature': signTimestamped(rawBody) }, + signingSecret: SIGNING_SECRET, + now: NOW + 301, + }), + ).resolves.toBe(false) + }) + + it('ignores X-Signature by default', async () => { + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: { 'x-signature': legacySignature }, + signingSecret: SIGNING_SECRET, + now: NOW, + }), + ).resolves.toBe(false) + }) + + it('falls back to X-Signature only when allowLegacy is true', async () => { + await expect( + verifyWebhookRequest({ + payload: Buffer.from(rawBody, 'utf8'), + headers: { 'X-Signature': legacySignature }, + signingSecret: SIGNING_SECRET, + allowLegacy: true, + }), + ).resolves.toBe(true) + + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: { 'x-signature': sign(payload, 'whsec_other_secret') }, + signingSecret: SIGNING_SECRET, + allowLegacy: true, + }), + ).resolves.toBe(false) + }) + + it('does not fall back when X-Textbee-Signature is present but invalid', async () => { + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: { + 'x-textbee-signature': signTimestamped(rawBody), + 'x-signature': legacySignature, + }, + signingSecret: SIGNING_SECRET, + now: NOW + 301, + allowLegacy: true, + }), + ).resolves.toBe(false) + }) + + it('does not fall back when X-Textbee-Signature is empty', async () => { + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: { 'x-textbee-signature': '', 'x-signature': legacySignature }, + signingSecret: SIGNING_SECRET, + now: NOW, + allowLegacy: true, + }), + ).resolves.toBe(false) + }) + + it('reads every case variant of the signature header in a plain object', async () => { + const valid = signTimestamped(rawBody) + const v1 = valid.split('v1=')[1] + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: { 'X-Textbee-Signature': valid, 'x-textbee-signature': `t=${NOW - 1000},v1=${v1}` }, + signingSecret: SIGNING_SECRET, + now: NOW, + }), + ).resolves.toBe(false) + }) + + it('returns false instead of throwing when the body was not read', async () => { + for (const body of [undefined, {}]) { + await expect( + verifyWebhookRequest({ + payload: body as unknown as string, + headers: { 'x-textbee-signature': signTimestamped(rawBody), 'x-signature': legacySignature }, + signingSecret: SIGNING_SECRET, + now: NOW, + }), + ).resolves.toBe(false) + await expect( + verifyWebhookRequest({ + payload: body as unknown as string, + headers: { 'x-signature': legacySignature }, + signingSecret: SIGNING_SECRET, + allowLegacy: true, + }), + ).resolves.toBe(false) + } + }) + + it('rejects a request with no signature headers', async () => { + await expect( + verifyWebhookRequest({ + payload: rawBody, + headers: {}, + signingSecret: SIGNING_SECRET, + allowLegacy: true, + }), + ).resolves.toBe(false) + }) +})