Skip to content

Commit

Permalink
[javascript] Switch to WebCrypto API
Browse files Browse the repository at this point in the history
  • Loading branch information
arjunyel committed Jan 19, 2024
1 parent 94c1ecf commit fcc02bf
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 93 deletions.
3 changes: 1 addition & 2 deletions javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@
"lint:fix": "yarn run lint:prettier --write && yarn run lint:eslint --fix"
},
"dependencies": {
"js-base64": "^3.7.5",
"fast-sha256": "^1.3.0"
"js-base64": "^3.7.5"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.19.0",
Expand Down
33 changes: 23 additions & 10 deletions javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export * from "./openapi/models/all";
export * from "./openapi/apis/exception";
import { timingSafeEqual } from "./timing_safe_equal";
import { toUint8Array, fromUint8Array } from "js-base64";
import * as sha256 from "fast-sha256";

const WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60; // 5 minutes
const VERSION = "1.15.0";
Expand Down Expand Up @@ -793,17 +792,18 @@ export interface WebhookOptions {

export class Webhook {
private static prefix = "whsec_";
private readonly key: Uint8Array;
readonly #key: Uint8Array;
#cachedKey?: CryptoKey;

constructor(secret: string | Uint8Array, options?: WebhookOptions) {
if (!secret) {
throw new Error("Secret can't be empty.");
}
if (options?.format === "raw") {
if (secret instanceof Uint8Array) {
this.key = secret;
this.#key = secret;
} else {
this.key = Uint8Array.from(secret, (c) => c.charCodeAt(0));
this.#key = Uint8Array.from(secret, (c) => c.charCodeAt(0));
}
} else {
if (typeof secret !== "string") {
Expand All @@ -812,17 +812,17 @@ export class Webhook {
if (secret.startsWith(Webhook.prefix)) {
secret = secret.substring(Webhook.prefix.length);
}
this.key = toUint8Array(secret);
this.#key = toUint8Array(secret);
}
}

public verify(
public async verify(
payload: string,
headers_:
| WebhookRequiredHeaders
| WebhookUnbrandedRequiredHeaders
| Record<string, string>
): unknown {
): Promise<unknown> {
const headers: Record<string, string> = {};
for (const key of Object.keys(headers_)) {
headers[key.toLowerCase()] = (headers_ as Record<string, string>)[key];
Expand All @@ -844,7 +844,7 @@ export class Webhook {

const timestamp = this.verifyTimestamp(msgTimestamp);

const computedSignature = this.sign(msgId, timestamp, payload);
const computedSignature = await this.sign(msgId, timestamp, payload);
const expectedSignature = computedSignature.split(",")[1];

const passedSignatures = msgSignature.split(" ");
Expand All @@ -862,7 +862,7 @@ export class Webhook {
throw new WebhookVerificationError("No matching signature found");
}

public sign(msgId: string, timestamp: Date, payload: string): string {
public async sign(msgId: string, timestamp: Date, payload: string): Promise<string> {
if (typeof payload !== "string") {
throw new Error(
"Expected payload to be of type string. Please refer to https://docs.svix.com/receiving/verifying-payloads/how for more information."
Expand All @@ -871,7 +871,20 @@ export class Webhook {

const timestampNumber = Math.floor(timestamp.getTime() / 1000);
const toSign = encoder.encode(`${msgId}.${timestampNumber}.${payload}`);
const expectedSignature = fromUint8Array(sha256.hmac(this.key, toSign));
if (!this.#cachedKey) {
this.#cachedKey = await globalThis.crypto.subtle.importKey(
"raw",
this.#key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
}
const signature = new Uint8Array(
await globalThis.crypto.subtle.sign("HMAC", this.#cachedKey, toSign)
);

const expectedSignature = fromUint8Array(signature);
return `v1,${expectedSignature}`;
}

Expand Down
164 changes: 88 additions & 76 deletions javascript/src/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { expect, test } from "vitest";
import { fromUint8Array, toUint8Array } from "js-base64";
import * as sha256 from "fast-sha256";

import { Webhook, WebhookVerificationError } from "./index";

Expand All @@ -12,33 +11,46 @@ const defaultSecret = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";

const tolerance_in_ms = 5 * 60 * 1000;

class TestPayload {
public id: string;
public timestamp: number;
public header: Record<string, string>;
public secret: string;
public payload: string;
public signature: string;

public constructor(timestamp = Date.now()) {
this.id = defaultMsgID;
this.timestamp = Math.floor(timestamp / 1000);

this.payload = defaultPayload;
this.secret = defaultSecret;

const toSign = textEncoder.encode(`${this.id}.${this.timestamp}.${this.payload}`);
this.signature = fromUint8Array(sha256.hmac(toUint8Array(this.secret), toSign));

this.header = {
"svix-id": this.id,
"svix-signature": "v1," + this.signature,
"svix-timestamp": this.timestamp.toString(),
};
}
async function TestPayload(timestamp = Date.now()): Promise<{
id: string;
timestamp: number;
header: Record<string, string>;
payload: string;
signature: string;
}> {
const id = defaultMsgID;
timestamp = Math.floor(timestamp / 1000);
const payload = defaultPayload;

const key = await crypto.subtle.importKey(
"raw",
toUint8Array(defaultSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);

const toSign = textEncoder.encode(`${id}.${timestamp}.${payload}`);

const signature = fromUint8Array(
new Uint8Array(await globalThis.crypto.subtle.sign("HMAC", key, toSign))
);
const header = {
"svix-id": id,
"svix-signature": "v1," + signature,
"svix-timestamp": timestamp.toString(),
};

return {
id,
timestamp,
header,
payload,
signature,
};
}

test("empty key raises error", () => {
test("empty key raises error", async () => {
expect(() => {
new Webhook("");
}).toThrowError(Error);
Expand All @@ -50,107 +62,107 @@ test("empty key raises error", () => {
}).toThrowError(Error);
});

test("missing id raises error", () => {
test("missing id raises error", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload();
const testPayload = await TestPayload();
delete testPayload.header["svix-id"];

expect(() => {
wh.verify(testPayload.payload, testPayload.header);
}).toThrowError(WebhookVerificationError);
expect(async () => {
await wh.verify(testPayload.payload, testPayload.header);
}).rejects.toThrow(WebhookVerificationError);
});

test("missing timestamp raises error", () => {
test("missing timestamp raises error", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload();
const testPayload = await TestPayload();
delete testPayload.header["svix-timestamp"];

expect(() => {
wh.verify(testPayload.payload, testPayload.header);
}).toThrowError(WebhookVerificationError);
expect(async () => {
await wh.verify(testPayload.payload, testPayload.header);
}).rejects.toThrow(WebhookVerificationError);
});

test("invalid timestamp throws error", () => {
test("invalid timestamp throws error", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload();
const testPayload = await TestPayload();
testPayload.header["svix-timestamp"] = "hello";

expect(() => {
wh.verify(testPayload.payload, testPayload.header);
}).toThrowError(WebhookVerificationError);
expect(async () => {
await wh.verify(testPayload.payload, testPayload.header);
}).rejects.toThrow(WebhookVerificationError);
});

test("missing signature raises error", () => {
test("missing signature raises error", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload();
const testPayload = await TestPayload();
delete testPayload.header["svix-signature"];

expect(() => {
wh.verify(testPayload.payload, testPayload.header);
}).toThrowError(WebhookVerificationError);
expect(async () => {
await wh.verify(testPayload.payload, testPayload.header);
}).rejects.toThrow(WebhookVerificationError);
});

test("invalid signature throws error", () => {
test("invalid signature throws error", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload();
const testPayload = await TestPayload();
testPayload.header["svix-signature"] = "v1,dawfeoifkpqwoekfpqoekf";

expect(() => {
wh.verify(testPayload.payload, testPayload.header);
}).toThrowError(WebhookVerificationError);
expect(async () => {
await wh.verify(testPayload.payload, testPayload.header);
}).rejects.toThrow(WebhookVerificationError);
});

test("valid signature is valid and returns valid json", () => {
test("valid signature is valid and returns valid json", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload();
const testPayload = await TestPayload();

wh.verify(testPayload.payload, testPayload.header);
await wh.verify(testPayload.payload, testPayload.header);
});

test("valid unbranded signature is valid and returns valid json", () => {
test("valid unbranded signature is valid and returns valid json", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload();
const testPayload = await TestPayload();
const unbrandedHeaders: Record<string, string> = {
"webhook-id": testPayload.header["svix-id"],
"webhook-signature": testPayload.header["svix-signature"],
"webhook-timestamp": testPayload.header["svix-timestamp"],
};
testPayload.header = unbrandedHeaders;

wh.verify(testPayload.payload, testPayload.header);
await wh.verify(testPayload.payload, testPayload.header);
});

test("old timestamp fails", () => {
test("old timestamp fails", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload(Date.now() - tolerance_in_ms - 1000);
const testPayload = await TestPayload(Date.now() - tolerance_in_ms - 1000);

expect(() => {
wh.verify(testPayload.payload, testPayload.header);
}).toThrowError(WebhookVerificationError);
expect(async () => {
await wh.verify(testPayload.payload, testPayload.header);
}).rejects.toThrow(WebhookVerificationError);
});

test("new timestamp fails", () => {
test("new timestamp fails", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload(Date.now() + tolerance_in_ms + 1000);
const testPayload = await TestPayload(Date.now() + tolerance_in_ms + 1000);

expect(() => {
wh.verify(testPayload.payload, testPayload.header);
}).toThrowError(WebhookVerificationError);
expect(async () => {
await wh.verify(testPayload.payload, testPayload.header);
}).rejects.toThrow(WebhookVerificationError);
});

test("multi sig payload is valid", () => {
test("multi sig payload is valid", async () => {
const wh = new Webhook("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");

const testPayload = new TestPayload();
const testPayload = await TestPayload();
const sigs = [
"v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=",
"v2,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=",
Expand All @@ -159,20 +171,20 @@ test("multi sig payload is valid", () => {
];
testPayload.header["svix-signature"] = sigs.join(" ");

wh.verify(testPayload.payload, testPayload.header);
await wh.verify(testPayload.payload, testPayload.header);
});

test("verification works with and without signature prefix", () => {
const testPayload = new TestPayload();
test("verification works with and without signature prefix", async () => {
const testPayload = await TestPayload();

let wh = new Webhook(defaultSecret);
wh.verify(testPayload.payload, testPayload.header);
await wh.verify(testPayload.payload, testPayload.header);

wh = new Webhook("whsec_" + defaultSecret);
wh.verify(testPayload.payload, testPayload.header);
await wh.verify(testPayload.payload, testPayload.header);
});

test("sign function works", () => {
test("sign function works", async () => {
const key = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
const msgId = "msg_p5jXN8AQM9LWM0D4loKWxJek";
const timestamp = new Date(1614265330 * 1000);
Expand All @@ -181,6 +193,6 @@ test("sign function works", () => {

const wh = new Webhook(key);

const signature = wh.sign(msgId, timestamp, payload);
const signature = await wh.sign(msgId, timestamp, payload);
expect(signature).toBe(expected);
});
5 changes: 0 additions & 5 deletions javascript/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -918,11 +918,6 @@ fast-levenshtein@^2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==

fast-sha256@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/fast-sha256/-/fast-sha256-1.3.0.tgz#7916ba2054eeb255982608cccd0f6660c79b7ae6"
integrity sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==

fastq@^1.6.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.16.0.tgz#83b9a9375692db77a822df081edb6a9cf6839320"
Expand Down

0 comments on commit fcc02bf

Please sign in to comment.