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
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,42 @@ await email.ping();
await api.ping();
```

### Webhook Verification

Use `Webhook` to verify incoming webhooks. Use the webhook signing secret, not an API token.
Pass the raw request body as a string or `Buffer`. Do not parse or change the body before verification.

```typescript
import { Webhook, WebhookVerificationError } from 'lettermint';

const webhook = new Webhook(process.env.LETTERMINT_WEBHOOK_SECRET!);

try {
const payload = webhook.verifyHeaders(request.headers, rawBody);
// Process the verified payload here.
} catch (error) {
if (error instanceof WebhookVerificationError) {
// Reject the request. Do not process its payload.
} else {
throw error;
}
}
```

`verifyHeaders(headers, rawBody)` accepts Node.js request headers. It requires
`X-Lettermint-Signature` and `X-Lettermint-Delivery`. Header names are case-insensitive.
The delivery timestamp must match the timestamp in the signature.

You can also call `webhook.verify(rawBody, signatureHeader, deliveryTimestamp?)` directly.
Both methods check HMAC-SHA256 signatures with a constant-time comparison and return
the decoded JSON as `unknown`. Check the payload structure before use.

The default timestamp tolerance is 300 seconds in either direction. To change it,
use `new Webhook(secret, { tolerance: 60 })`. The tolerance must be a non-negative
integer in seconds. A value of `0` only accepts the current second; it does not
disable the timestamp check. A valid signature does not prevent repeat delivery
within this period. Track processed events if you must prevent duplicate work.

## API Reference

### Lettermint Class
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './endpoints/api';
export * from './types';
export * from './utils/errors';
export * from './lettermint';
export * from './webhook';
2 changes: 2 additions & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ export class ClientError extends HttpRequestError {
super(message, 400, responseBody);
}
}

export class WebhookVerificationError extends LettermintError {}
132 changes: 132 additions & 0 deletions src/webhook.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { createHmac } from 'node:crypto';
import { LettermintError, Webhook, WebhookVerificationError } from './index';

const now = 1700000000;
const secret = 'test-webhook-secret';
const body = '{"event":"message.delivered","data":{"subject":"Hello 🌍"}}';
const sign = (payload: string | Buffer = body, timestamp = now, key = secret) =>
`t=${timestamp},v1=${createHmac('sha256', key).update(`${timestamp}.`).update(payload).digest('hex')}`;

describe('Webhook', () => {
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(now * 1000);
});
afterEach(() => jest.restoreAllMocks());

it('exports the verifier and SDK error type', () => {
expect(new WebhookVerificationError('test')).toBeInstanceOf(LettermintError);
expect(new Webhook(secret).verify(body, sign())).toEqual(JSON.parse(body));
});

it('verifies exact UTF-8 bytes from a Buffer', () => {
const raw = Buffer.from(` ${body}\n`);
expect(new Webhook(secret).verify(raw, sign(raw))).toEqual(JSON.parse(body));
expect(() => new Webhook(secret).verify(body, sign(raw))).toThrow(WebhookVerificationError);
});

it.each([-300, 0, 300])('accepts timestamps within tolerance: %i', (offset) => {
expect(new Webhook(secret).verify(body, sign(body, now + offset))).toEqual(JSON.parse(body));
});

it.each([-301, 301])('rejects timestamps outside tolerance: %i', (offset) => {
expect(() => new Webhook(secret).verify(body, sign(body, now + offset))).toThrow(
'outside the allowed range'
);
});

it('uses a custom tolerance and keeps the check enabled at zero', () => {
expect(() => new Webhook(secret, { tolerance: 60 }).verify(body, sign(body, now - 61))).toThrow(
WebhookVerificationError
);
const webhook = new Webhook(secret, { tolerance: 0 });
expect(webhook.verify(body, sign())).toEqual(JSON.parse(body));
expect(() => webhook.verify(body, sign(body, now - 1))).toThrow(WebhookVerificationError);
});

it.each([-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid tolerance: %s',
(tolerance) => {
expect(() => new Webhook(secret, { tolerance })).toThrow(WebhookVerificationError);
}
);

it('rejects an empty secret', () => {
expect(() => new Webhook('')).toThrow(WebhookVerificationError);
});

it('rejects a changed payload or wrong secret', () => {
expect(() => new Webhook(secret).verify('{}', sign())).toThrow('does not match');
expect(() => new Webhook('wrong').verify(body, sign())).toThrow('does not match');
});

it.each([
'',
'v1=abc',
`t=${now}`,
`t=${now},v1=abc`,
`t=${now},v1=${'g'.repeat(64)}`,
`t=${now},v1=${'a'.repeat(63)}`,
`t=${now},v1=${'a'.repeat(65)}`,
`t=${now},${sign()}`,
sign().replace(`t=${now}`, 't=NaN'),
sign().replace(`t=${now}`, 't=1e9'),
sign().replace(`t=${now}`, 't=-1'),
sign().replace(`t=${now}`, 't=9007199254740992'),
sign().replace(`t=${now}`, `t=${now}=extra`),
`${sign()}=extra`,
])('rejects invalid signature headers: %s', (signature) => {
expect(() => new Webhook(secret).verify(body, signature)).toThrow(WebhookVerificationError);
});

it('accepts any matching v1 signature and ignores unsupported versions', () => {
const signature = `v2=ignored, v1=${'0'.repeat(64)}, ${sign()}, v1=malformed`;
expect(new Webhook(secret).verify(body, signature)).toEqual(JSON.parse(body));
});

it('rejects empty bodies and invalid signed JSON', () => {
expect(() => new Webhook(secret).verify('', sign(''))).toThrow(WebhookVerificationError);
expect(() => new Webhook(secret).verify('invalid', sign('invalid'))).toThrow('not valid JSON');
expect(() => new Webhook(secret).verify('invalid', sign())).toThrow('does not match');
});

it('checks the optional delivery timestamp', () => {
const webhook = new Webhook(secret);
expect(webhook.verify(body, sign(), now)).toEqual(JSON.parse(body));
for (const timestamp of [now + 1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(() => webhook.verify(body, sign(), timestamp)).toThrow('do not match');
}
});

it('accepts Node.js headers with case-insensitive names', () => {
expect(
new Webhook(secret).verifyHeaders(
{
'X-Lettermint-Signature': sign(),
'x-lettermint-delivery': String(now),
host: 'localhost',
},
Buffer.from(body)
)
).toEqual(JSON.parse(body));
});

it.each([
{},
{ 'x-lettermint-signature': sign() },
{ 'x-lettermint-delivery': String(now) },
{ 'x-lettermint-signature': [sign()], 'x-lettermint-delivery': String(now) },
{ 'x-lettermint-signature': sign(), 'x-lettermint-delivery': '' },
{ 'x-lettermint-signature': sign(), 'x-lettermint-delivery': `${now}junk` },
{ 'x-lettermint-signature': sign(), 'x-lettermint-delivery': String(now + 1) },
{ 'x-lettermint-signature': sign(), 'x-lettermint-delivery': '9007199254740992' },
{
'x-lettermint-signature': sign(),
'X-Lettermint-Signature': sign(),
'x-lettermint-delivery': String(now),
},
])('rejects missing, ambiguous, or invalid headers: %j', (headers) => {
expect(() => new Webhook(secret).verifyHeaders(headers, body)).toThrow(
WebhookVerificationError
);
});
});
94 changes: 94 additions & 0 deletions src/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { WebhookVerificationError } from './utils/errors';

export interface WebhookOptions {
/** Maximum timestamp difference in seconds. The default is 300. */
tolerance?: number;
}

export type WebhookHeaders = Record<string, string | string[] | undefined>;

export class Webhook {
private readonly tolerance: number;

constructor(
private readonly secret: string,
options: WebhookOptions = {}
) {
if (typeof secret !== 'string' || secret.length === 0) {
throw new WebhookVerificationError('The webhook secret is required.');
}
this.tolerance = options.tolerance ?? 300;
if (!Number.isSafeInteger(this.tolerance) || this.tolerance < 0) {
throw new WebhookVerificationError('The tolerance must be a non-negative integer.');
}
}

/** Verify the raw request body before JSON decoding. */
public verify(payload: string | Buffer, signature: string, timestamp?: number): unknown {
if ((typeof payload !== 'string' && !Buffer.isBuffer(payload)) || payload.length === 0) {
throw new WebhookVerificationError('The raw request body is required.');
}
if (typeof signature !== 'string' || signature.length === 0) {
throw new WebhookVerificationError('The signature header is required.');
}

let signedTimestamp: string | undefined;
const hashes: Buffer[] = [];
for (const part of signature.split(',')) {
const entry = part.trim();
const separator = entry.indexOf('=');
if (separator === -1) continue;
const key = entry.slice(0, separator);
const value = entry.slice(separator + 1);
if (key === 't') {
if (signedTimestamp !== undefined || !/^\d+$/.test(value ?? '')) {
throw new WebhookVerificationError('The signature timestamp is invalid.');
}
signedTimestamp = value;
} else if (key === 'v1' && /^[a-fA-F0-9]{64}$/.test(value ?? '')) {
hashes.push(Buffer.from(value, 'hex'));
}
}
const seconds = Number(signedTimestamp);
if (signedTimestamp === undefined || !Number.isSafeInteger(seconds) || hashes.length === 0) {
throw new WebhookVerificationError('The signature header is invalid.');
}
if (timestamp !== undefined && (!Number.isSafeInteger(timestamp) || timestamp !== seconds)) {
throw new WebhookVerificationError('The signature and delivery timestamps do not match.');
}
if (Math.abs(Math.floor(Date.now() / 1000) - seconds) > this.tolerance) {
throw new WebhookVerificationError('The signature timestamp is outside the allowed range.');
}

const expected = createHmac('sha256', this.secret)
.update(`${signedTimestamp}.`)
.update(payload)
.digest();
if (!hashes.some((hash) => timingSafeEqual(hash, expected))) {
throw new WebhookVerificationError('The webhook signature does not match.');
}
try {
return JSON.parse(typeof payload === 'string' ? payload : payload.toString('utf8'));
} catch {
throw new WebhookVerificationError('The webhook payload is not valid JSON.');
}
}

/** Verify Node.js request headers and the raw request body. Header names are case-insensitive. */
public verifyHeaders(headers: WebhookHeaders, payload: string | Buffer): unknown {
const readHeader = (name: string): string => {
const matches = Object.entries(headers).filter(([key]) => key.toLowerCase() === name);
if (matches.length !== 1 || typeof matches[0][1] !== 'string') {
throw new WebhookVerificationError(`A single ${name} header is required.`);
}
return matches[0][1];
};
const signature = readHeader('x-lettermint-signature');
const delivery = readHeader('x-lettermint-delivery');
if (!/^\d+$/.test(delivery)) {
throw new WebhookVerificationError('The delivery timestamp is invalid.');
}
return this.verify(payload, signature, Number(delivery));
}
}
Loading