diff --git a/.changeset/wild-kings-whisper.md b/.changeset/wild-kings-whisper.md new file mode 100644 index 0000000..8c543a1 --- /dev/null +++ b/.changeset/wild-kings-whisper.md @@ -0,0 +1,5 @@ +--- +"@portone/server-sdk": minor +--- + +웹훅 검증 모듈 추가 diff --git a/examples/express/index.js b/examples/express/index.js index a56be63..075d9ea 100644 --- a/examples/express/index.js +++ b/examples/express/index.js @@ -3,11 +3,44 @@ const PortOne = require("@portone/server-sdk"); const app = express(); +const webhookSecret = "pzQGE83cSIRKM4/WH5QY+g=="; + +function rawBody(req, res, next) { + req.setEncoding("utf8"); + req.rawBody = ""; + req.on("data", (chunk) => { + req.rawBody += chunk; + }); + req.on("end", () => { + next(); + }); +} + app.get("/", (_, res) => { if (PortOne) res.end("PortOne Server SDK is available"); else res.end("PortOne Server SDK is not available"); }); +app.get("/webhook", rawBody, async (req, res, next) => { + try { + try { + await PortOne.Webhook.verify(webhookSecret, req.rawBody, req.headers); + } catch (err) { + if (err instanceof PortOne.Webhook.WebhookVerificationError) { + console.log("Invalid webhook payload received:", err.message); + res.status(400).end(); + return; + } + throw e; + } + + console.log("Valid webhook payload received:", JSON.parse(req.rawBody)); + res.status(200).end(); + } catch (err) { + next(err); + } +}); + app.listen(8080, () => { console.log("Server is running on http://localhost:8080"); }); diff --git a/packages/server-sdk/src/error.ts b/packages/server-sdk/src/error.ts new file mode 100644 index 0000000..a515cff --- /dev/null +++ b/packages/server-sdk/src/error.ts @@ -0,0 +1,56 @@ +/** + * 포트원 SDK에서 발생하는 모든 에러의 기반 타입입니다. + * + * PortOneError를 상속하는 모든 에러는 `_tag` 필드를 가지며, + * 해당 필드의 값을 통해 손쉽게 타입 검사를 수행할 수 있습니다. + */ +export abstract class PortOneError extends Error { + /** + * 에러 타입을 구분하기 위한 필드입니다. + * + * Effect 등의 라이브러리를 사용하실 때, 해당 필드를 통해 각 타입에 대한 에러를 구분하여 처리하실 수 있습니다. + */ + abstract readonly _tag: string; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + Object.setPrototypeOf(this, PortOneError.prototype); + this.name = "PortOneError"; + this.stack = new Error(message).stack; + } +} + +/** + * SDK에 전달한 사용자 입력이 잘못되었을 때 발생하는 에러입니다. + * + * 해당 에러는 대부분 사용자의 실수로 발생합니다. + * 에러가 발생하는 경우, 에러가 발생한 함수의 문서를 참고하여 + * 문제를 수정해주시기 바랍니다. + */ +export class InvalidInputError extends PortOneError { + readonly _tag = "PortOneInvalidInputError"; + + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, InvalidInputError.prototype); + this.name = "InvalidInputError"; + } +} + +/** + * SDK 내에서 알 수 없는 오류가 일어났을 때 발생하는 에러입니다. + * + * 해당 에러는 주로 포트원 SDK 혹은 서버 내부 오류로 인해 발생합니다. + * 에러가 발생하는 경우, 포트원 고객센터에 문의하시기 바랍니다. + * + * `cause` 필드에 담긴 에러를 통해 오류가 발생한 원인을 확인할 수 있습니다. + */ +export class UnknownError extends PortOneError { + readonly _tag = "PortOneUnknownError"; + + constructor(cause: unknown, options?: Omit) { + super("알 수 없는 에러가 발생했습니다.", { ...options, cause }); + Object.setPrototypeOf(this, UnknownError.prototype); + this.name = "UnknownError"; + } +} diff --git a/packages/server-sdk/src/index.ts b/packages/server-sdk/src/index.ts index e4ea777..068ef52 100644 --- a/packages/server-sdk/src/index.ts +++ b/packages/server-sdk/src/index.ts @@ -1 +1,7 @@ -export type {}; +export { + PortOneError, + InvalidInputError, + UnknownError, +} from "./error"; + +export * as Webhook from "./webhook"; diff --git a/packages/server-sdk/src/utils/timingSafeEqual.ts b/packages/server-sdk/src/utils/timingSafeEqual.ts new file mode 100644 index 0000000..6446bcf --- /dev/null +++ b/packages/server-sdk/src/utils/timingSafeEqual.ts @@ -0,0 +1,28 @@ +export function timingSafeEqual( + a: ArrayBufferView | ArrayBufferLike | DataView, + b: ArrayBufferView | ArrayBufferLike | DataView, +): boolean { + if (a.byteLength !== b.byteLength) return false; + + const aDataView = + a instanceof DataView + ? a + : ArrayBuffer.isView(a) + ? new DataView(a.buffer, a.byteOffset, a.byteLength) + : new DataView(a); + const bDataView = + b instanceof DataView + ? b + : ArrayBuffer.isView(b) + ? new DataView(b.buffer, b.byteOffset, b.byteLength) + : new DataView(b); + + const length = aDataView.byteLength; + let out = 0; + let i = -1; + while (++i < length) { + out |= aDataView.getUint8(i) ^ bDataView.getUint8(i); + } + + return out === 0; +} diff --git a/packages/server-sdk/src/utils/try.ts b/packages/server-sdk/src/utils/try.ts new file mode 100644 index 0000000..e2ce4f2 --- /dev/null +++ b/packages/server-sdk/src/utils/try.ts @@ -0,0 +1,7 @@ +export function tryCatch(fn: () => T, onError: (e: unknown) => E): T | E { + try { + return fn(); + } catch (e) { + return onError(e); + } +} diff --git a/packages/server-sdk/src/webhook.ts b/packages/server-sdk/src/webhook.ts new file mode 100644 index 0000000..2b68cea --- /dev/null +++ b/packages/server-sdk/src/webhook.ts @@ -0,0 +1,230 @@ +import { InvalidInputError, PortOneError } from "./error"; +import { timingSafeEqual } from "./utils/timingSafeEqual"; +import { tryCatch } from "./utils/try"; + +const WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60; // 5분 + +/** + * 웹훅 검증이 실패했을 때 발생하는 에러입니다. + * + * `reason` 필드를 통해 상세한 실패 원인을 확인할 수 있습니다. + */ +export class WebhookVerificationError extends PortOneError { + readonly _tag = "WebhookVerificationError"; + + /** + * 웹훅 검증이 실패한 상세 사유을 나타냅니다. + */ + readonly reason: WebhookVerificationFailureReason; + + /** + * 웹훅 검증 실패 사유로부터 에러 메시지를 생성합니다. + * + * @param reason 에러 메시지를 생성할 실패 사유 + * @returns 에러 메시지 + */ + static getMessage(reason: WebhookVerificationFailureReason): string { + switch (reason) { + case "MISSING_REQUIRED_HEADERS": + return "필수 헤더가 누락되었습니다."; + case "NO_MATCHING_SIGNATURE": + return "올바른 웹훅 시그니처를 찾을 수 없습니다."; + case "INVALID_SIGNATURE": + return "웹훅 시그니처가 유효하지 않습니다."; + case "TIMESTAMP_TOO_OLD": + return "웹훅 시그니처의 타임스탬프가 만료 기한을 초과했습니다."; + case "TIMESTAMP_TOO_NEW": + return "웹훅 시그니처의 타임스탬프가 미래 시간으로 설정되어 있습니다."; + } + } + + constructor( + reason: WebhookVerificationFailureReason, + options?: ErrorOptions, + ) { + super(WebhookVerificationError.getMessage(reason), options); + Object.setPrototypeOf(this, WebhookVerificationError.prototype); + this.name = "WebhookVerificationError"; + this.reason = reason; + } +} + +/** + * 웹훅 검증 실패 사유입니다. + * + * `WebhookVerificationError.getMessage()`에 전달하여 에러 메시지를 얻을 수 있습니다. + */ +export type WebhookVerificationFailureReason = + | "MISSING_REQUIRED_HEADERS" + | "NO_MATCHING_SIGNATURE" + | "INVALID_SIGNATURE" + | "TIMESTAMP_TOO_OLD" + | "TIMESTAMP_TOO_NEW"; + +/** + * 웹훅 요청에 필수적으로 포함되는 헤더들입니다. + */ +export interface WebhookUnbrandedRequiredHeaders { + "webhook-id": string; + "webhook-timestamp": string; + "webhook-signature": string; +} + +/** + * 웹훅 인스턴스에서 사용할 옵션입니다. + */ +export interface WebhookOptions { + /** + * 웹훅 시크릿의 포맷입니다. + * + * - `"raw"`인 경우, `secret` 파라미터의 값을 그대로 사용합니다. + * - 지정하지 않을 경우, `secret` 파라미터의 값을 base64 문자열로 간주합니다. + */ + format?: "raw"; +} + +const prefix = "whsec_"; + +/** + * 웹훅 페이로드를 검증합니다. + * + * @param secret 웹훅 시크릿 + * @param payload 웹훅 페이로드 + * @param headers 웹훅 요청 시 포함된 헤더 + * @returns 검증 후 디코딩된 웹훅 페이로드를 반환하는 Promise + * @throws {InvalidInputError} 입력받은 시크릿이 유효하지 않을 때 발생합니다. + * @throws {WebhookVerificationError} 웹훅 검증에 실패했을 때 발생합니다. + */ +export async function verify( + secret: string | Uint8Array, + payload: string, + headers: WebhookUnbrandedRequiredHeaders | Record, +): Promise { + const mappedHeaders: Record = Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + + const msgId = mappedHeaders["webhook-id"]; + const msgSignature = mappedHeaders["webhook-signature"]; + const msgTimestamp = mappedHeaders["webhook-timestamp"]; + + if ( + typeof msgId !== "string" || + typeof msgSignature !== "string" || + typeof msgTimestamp !== "string" || + !msgId || + !msgSignature || + !msgTimestamp + ) { + throw new WebhookVerificationError("MISSING_REQUIRED_HEADERS"); + } + + verifyTimestamp(msgTimestamp); + + const expectedSignature = await sign(secret, msgId, msgTimestamp, payload); + + for (const versionedSignature of msgSignature.split(" ")) { + const split = versionedSignature.split(",", 3); + if (split.length < 2) continue; + const [version, signature] = split; + + if (version !== "v1") continue; + + const signatureDecoded = tryCatch( + () => Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)), + () => undefined, + ); + if (signatureDecoded === undefined) continue; + + if (timingSafeEqual(signatureDecoded, expectedSignature)) return; + } + throw new WebhookVerificationError("NO_MATCHING_SIGNATURE"); +} + +/** + * 웹훅 페이로드를 서명하여 웹훅 본문을 생성합니다. + * + * @param msgId 웹훅 본문에 지정할 고유 ID + * @param msgTimestamp 웹훅 생성 시도 시각 + * @param payload 웹훅 페이로드 + * @returns 서명된 웹훅 본문을 반환하는 Promise + * @throws {InvalidInputError} 입력받은 웹훅 페이로드가 유효하지 않을 때 발생합니다. + */ +async function sign( + secret: string | Uint8Array, + msgId: string, + msgTimestamp: string, + payload: string, +): Promise { + const cryptoKey = await getCryptoKeyFromSecret(secret); + const encoder = new TextEncoder(); + const toSign = encoder.encode(`${msgId}.${msgTimestamp}.${payload}`); + + return await crypto.subtle.sign("HMAC", cryptoKey, toSign); +} + +const secrets = new Map(); + +/** + * 웹훅 시크릿 입력으로부터 CryptoKey를 가져옵니다. + * + * @throws {InvalidInputError} 입력받은 웹훅 시크릿이 유효하지 않을 때 발생합니다. + */ +async function getCryptoKeyFromSecret(secret: string | Uint8Array) { + const cryptoKeyCached = secrets.get(secret); // cache based on argument + if (cryptoKeyCached !== undefined) return cryptoKeyCached; + + let rawSecret: Uint8Array; + if (secret instanceof Uint8Array) { + rawSecret = secret; + } else if (typeof secret === "string") { + const secretBase64 = secret.startsWith(prefix) + ? secret.substring(prefix.length) + : secret; + rawSecret = tryCatch( + () => Uint8Array.from(atob(secretBase64), (c) => c.charCodeAt(0)), + () => { + throw new InvalidInputError( + "`secret` 파라미터가 올바른 Base64 문자열이 아닙니다.", + ); + }, + ); + } else { + throw new InvalidInputError("`secret` 파라미터의 타입이 잘못되었습니다."); + } + + if (rawSecret.length === 0) + throw new InvalidInputError("시크릿은 비어 있을 수 없습니다."); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + rawSecret, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + + secrets.set(secret, cryptoKey); + + return cryptoKey; +} + +/** + * 웹훅의 타임스탬프 정보를 검증합니다. + * + * @throws {WebhookVerificationError} 타임스탬프가 유효하지 않을 때 발생합니다. + */ +function verifyTimestamp(timestampHeader: string): void { + const now = Math.floor(Date.now() / 1000); + const timestamp = Number.parseInt(timestampHeader, 10); + if (Number.isNaN(timestamp)) { + throw new WebhookVerificationError("INVALID_SIGNATURE"); + } + + if (now - timestamp > WEBHOOK_TOLERANCE_IN_SECONDS) { + throw new WebhookVerificationError("TIMESTAMP_TOO_OLD"); + } + if (timestamp > now + WEBHOOK_TOLERANCE_IN_SECONDS) { + throw new WebhookVerificationError("TIMESTAMP_TOO_NEW"); + } +} diff --git a/packages/server-sdk/tests/webhook.test.ts b/packages/server-sdk/tests/webhook.test.ts new file mode 100644 index 0000000..ac6a4a4 --- /dev/null +++ b/packages/server-sdk/tests/webhook.test.ts @@ -0,0 +1,193 @@ +import * as sdk from "@portone/server-sdk"; +import { describe, expect, it } from "vitest"; + +const secret = "pzQGE83cSIRKM4/WH5QY+g=="; + +const makeWebhook = async (timestamp = Date.now()) => { + const timestampInSec = Math.floor(timestamp / 1000); + const id = "dummy-webhook-id"; + const payload = JSON.stringify({ test: "test payload" }); + + const encoder = new TextEncoder(); + const toSign = encoder.encode(`${id}.${timestampInSec}.${payload}`); + const key = await crypto.subtle.importKey( + "raw", + Uint8Array.from(atob(secret), (c) => c.charCodeAt(0)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign("HMAC", key, toSign); + const signatureBase64 = btoa( + String.fromCharCode(...new Uint8Array(signature)), + ); + + return { + header: { + "webhook-id": id, + "webhook-signature": `v1,${signatureBase64}`, + "webhook-timestamp": timestampInSec.toString(), + } as Partial, + payload, + }; +}; + +it("should be exported", async () => { + expect(sdk.Webhook).toBeDefined(); +}); + +describe("correct cases", () => { + describe("verify()", () => { + it("valid signature is valid", async () => { + const testWebhook = await makeWebhook(); + + await expect( + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).resolves.toBeUndefined(); + }); + + it("valid unbranded signature is valid", async () => { + const testWebhook = await makeWebhook(); + const unbrandedHeaders: Record = { + "webhook-id": testWebhook.header["webhook-id"], + "webhook-signature": testWebhook.header["webhook-signature"], + "webhook-timestamp": testWebhook.header["webhook-timestamp"], + }; + testWebhook.header = unbrandedHeaders; + + await expect( + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).resolves.toBeUndefined(); + }); + + it("multiple signatures", async () => { + const testWebhook = await makeWebhook(); + const sigs = [ + "v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=", + "v2,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=", + testWebhook.header["webhook-signature"], // valid signature + "v1,Ceo5qEr07ixe2NLpvHk3FH9bwy/WavXrAFQ/9tdO6mc=", + ]; + testWebhook.header["webhook-signature"] = sigs.join(" "); + + await expect( + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).resolves.toBeUndefined(); + }); + + it("handles both with and without signature prefix", async () => { + const testPayload = await makeWebhook(); + + await expect( + sdk.Webhook.verify(secret, testPayload.payload, testPayload.header), + ).resolves.toBeUndefined(); + await expect( + sdk.Webhook.verify(secret, testPayload.payload, testPayload.header), + ).resolves.toBeUndefined(); + }); + }); +}); + +describe("error cases", () => { + it("empty secret", async () => { + const testWebhook = await makeWebhook(); + + await expect(() => + sdk.Webhook.verify("", testWebhook.payload, testWebhook.header), + ).rejects.toThrow(sdk.InvalidInputError); + await expect(() => + // biome-ignore lint/suspicious/noExplicitAny: testing runtime type check + sdk.Webhook.verify(null as any, testWebhook.payload, testWebhook.header), + ).rejects.toThrow(sdk.InvalidInputError); + await expect(() => + sdk.Webhook.verify( + // biome-ignore lint/suspicious/noExplicitAny: testing runtime type check + undefined as any, + testWebhook.payload, + testWebhook.header, + ), + ).rejects.toThrow(sdk.InvalidInputError); + }); + + it("missing id on header", async () => { + const testWebhook = await makeWebhook(); + // biome-ignore lint/performance/noDelete: testing runtime validations + delete testWebhook.header["webhook-id"]; + + await expect(() => + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + }); + + it("missing timestamp on header", async () => { + const testWebhook = await makeWebhook(); + // biome-ignore lint/performance/noDelete: testing runtime validations + delete testWebhook.header["webhook-timestamp"]; + + await expect(() => + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + }); + + it("invalid timestamp on header", async () => { + const testWebhook = await makeWebhook(); + testWebhook.header["webhook-timestamp"] = "hello"; + + await expect(() => + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + }); + + it("missing signature on header", async () => { + const testWebhook = await makeWebhook(); + // biome-ignore lint/performance/noDelete: testing runtime validations + delete testWebhook.header["webhook-signature"]; + + await expect(() => + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + }); + + it("invalid signature on header", async () => { + const testWebhook = await makeWebhook(); + testWebhook.header["webhook-signature"] = "v1,dawfeoifkpqwoekfpqoekf"; + + await expect(() => + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + }); + + it("partial signature on header", async () => { + const testWebhook = await makeWebhook(); + // biome-ignore lint/style/noNonNullAssertion: it is initially non-nullable + testWebhook.header["webhook-signature"] = testWebhook.header[ + "webhook-signature" + ]!.slice(0, 8); + + await expect(() => + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + + testWebhook.header["webhook-signature"] = "v1,"; + + await expect(() => + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + }); + + it("old timestamp", async () => { + const testWebhook = await makeWebhook(Date.now() - 5 * 1000 * 1000 - 1000); + + await expect(() => + sdk.Webhook.verify(secret, testWebhook.payload, testWebhook.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + }); + + it("new timestamp", async () => { + const testPayload = await makeWebhook(Date.now() + 5 * 1000 * 1000 + 1000); + + await expect(() => + sdk.Webhook.verify(secret, testPayload.payload, testPayload.header), + ).rejects.toThrowError(sdk.Webhook.WebhookVerificationError); + }); +});