From fcc02bf00b4e0478f166ce0236f4fe6aeac1174e Mon Sep 17 00:00:00 2001 From: arjunyel Date: Fri, 19 Jan 2024 12:51:25 -0600 Subject: [PATCH] [javascript] Switch to WebCrypto API --- javascript/package.json | 3 +- javascript/src/index.ts | 33 +++++-- javascript/src/webhook.test.ts | 164 ++++++++++++++++++--------------- javascript/yarn.lock | 5 - 4 files changed, 112 insertions(+), 93 deletions(-) diff --git a/javascript/package.json b/javascript/package.json index e0efc666f..fa244854d 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -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", diff --git a/javascript/src/index.ts b/javascript/src/index.ts index aa886c6ab..fd5835569 100644 --- a/javascript/src/index.ts +++ b/javascript/src/index.ts @@ -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"; @@ -793,7 +792,8 @@ 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) { @@ -801,9 +801,9 @@ export class Webhook { } 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") { @@ -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 - ): unknown { + ): Promise { const headers: Record = {}; for (const key of Object.keys(headers_)) { headers[key.toLowerCase()] = (headers_ as Record)[key]; @@ -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(" "); @@ -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 { 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." @@ -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}`; } diff --git a/javascript/src/webhook.test.ts b/javascript/src/webhook.test.ts index 608ded89c..bf4880063 100644 --- a/javascript/src/webhook.test.ts +++ b/javascript/src/webhook.test.ts @@ -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"; @@ -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; - 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; + 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); @@ -50,73 +62,73 @@ 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 = { "webhook-id": testPayload.header["svix-id"], "webhook-signature": testPayload.header["svix-signature"], @@ -124,33 +136,33 @@ test("valid unbranded signature is valid and returns valid json", () => { }; 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=", @@ -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); @@ -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); }); diff --git a/javascript/yarn.lock b/javascript/yarn.lock index f1cb2bede..a6cf66a2e 100644 --- a/javascript/yarn.lock +++ b/javascript/yarn.lock @@ -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"