From fa17e9d30ea4ef00410b6fbc6d7a7fd8ad41f204 Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Mon, 28 Oct 2024 15:33:43 +0800 Subject: [PATCH 1/7] feat: Add mTLS and custom HMAC support to webhoks --- .env.test | 14 +- package.json | 1 + src/db/configuration/getConfiguration.ts | 10 +- src/db/webhooks/createWebhook.ts | 26 +--- .../migration.sql | 4 + src/prisma/schema.prisma | 22 ++-- src/server/middleware/auth.ts | 2 +- src/server/routes/index.ts | 2 + src/server/routes/webhooks/create.ts | 51 +++++++- src/server/routes/webhooks/test.ts | 120 ++++++++++++++++++ src/tests/auth.test.ts | 7 +- src/tests/chain.test.ts | 73 ++--------- src/tests/webhook.test.ts | 22 ++++ src/utils/account.ts | 8 +- src/utils/cache/getWallet.ts | 5 +- src/utils/crypto.ts | 4 +- src/utils/env.ts | 10 +- src/utils/webhook.ts | 97 +++++++++----- src/utils/webhook/customAuthHeader.ts | 63 +++++++++ test/e2e/tests/routes/resetNonce.test.ts | 15 +++ yarn.lock | 36 ++---- 21 files changed, 399 insertions(+), 193 deletions(-) create mode 100644 src/prisma/migrations/20241024194728_add_mtls_webhooks/migration.sql create mode 100644 src/server/routes/webhooks/test.ts create mode 100644 src/tests/webhook.test.ts create mode 100644 src/utils/webhook/customAuthHeader.ts create mode 100644 test/e2e/tests/routes/resetNonce.test.ts diff --git a/.env.test b/.env.test index d2acedfb2..5ab9791db 100644 --- a/.env.test +++ b/.env.test @@ -7,11 +7,11 @@ ENABLE_HTTPS="true" REDIS_URL="redis://127.0.0.1:6379/0" THIRDWEB_API_SECRET_KEY="my-thirdweb-secret-key" -TEST_AWS_KMS_KEY_ID="" -TEST_AWS_KMS_ACCESS_KEY_ID="" -TEST_AWS_KMS_SECRET_ACCESS_KEY="" -TEST_AWS_KMS_REGION="" +TEST_AWS_KMS_KEY_ID="UNIMPLEMENTED" +TEST_AWS_KMS_ACCESS_KEY_ID="UNIMPLEMENTED" +TEST_AWS_KMS_SECRET_ACCESS_KEY="UNIMPLEMENTED" +TEST_AWS_KMS_REGION="UNIMPLEMENTED" -TEST_GCP_KMS_RESOURCE_PATH="" -TEST_GCP_KMS_EMAIL="" -TEST_GCP_KMS_PK="" \ No newline at end of file +TEST_GCP_KMS_RESOURCE_PATH="UNIMPLEMENTED" +TEST_GCP_KMS_EMAIL="UNIMPLEMENTED" +TEST_GCP_KMS_PK="UNIMPLEMENTED" \ No newline at end of file diff --git a/package.json b/package.json index 6b10602a1..9cd943785 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "prool": "^0.0.16", "superjson": "^2.2.1", "thirdweb": "5.61.3", + "undici": "^6.20.1", "uuid": "^9.0.1", "winston": "^3.14.1", "zod": "^3.23.8" diff --git a/src/db/configuration/getConfiguration.ts b/src/db/configuration/getConfiguration.ts index 67c47f9a6..9a571f049 100644 --- a/src/db/configuration/getConfiguration.ts +++ b/src/db/configuration/getConfiguration.ts @@ -72,10 +72,7 @@ const toParsedConfig = async (config: Configuration): Promise => { // TODO: Remove backwards compatibility with next breaking change if (awsAccessKeyId && awsSecretAccessKey && awsRegion) { // First try to load the aws secret using the encryption password - let decryptedSecretAccessKey = decrypt( - awsSecretAccessKey, - env.ENCRYPTION_PASSWORD, - ); + let decryptedSecretAccessKey = decrypt(awsSecretAccessKey); // If that fails, try to load the aws secret using the thirdweb api secret key if (!awsSecretAccessKey) { @@ -115,10 +112,7 @@ const toParsedConfig = async (config: Configuration): Promise => { // TODO: Remove backwards compatibility with next breaking change if (gcpApplicationCredentialEmail && gcpApplicationCredentialPrivateKey) { // First try to load the gcp secret using the encryption password - let decryptedGcpKey = decrypt( - gcpApplicationCredentialPrivateKey, - env.ENCRYPTION_PASSWORD, - ); + let decryptedGcpKey = decrypt(gcpApplicationCredentialPrivateKey); // If that fails, try to load the gcp secret using the thirdweb api secret key if (!gcpApplicationCredentialPrivateKey) { diff --git a/src/db/webhooks/createWebhook.ts b/src/db/webhooks/createWebhook.ts index 8e8bb66d7..0b56fc771 100644 --- a/src/db/webhooks/createWebhook.ts +++ b/src/db/webhooks/createWebhook.ts @@ -1,29 +1,17 @@ -import { Webhooks } from "@prisma/client"; -import { createHash, randomBytes } from "crypto"; -import { WebhooksEventTypes } from "../../schema/webhooks"; +import type { Prisma, Webhooks } from "@prisma/client"; +import { createHash, randomBytes } from "node:crypto"; import { prisma } from "../client"; -interface CreateWebhooksParams { - url: string; - name?: string; - eventType: WebhooksEventTypes; -} - -export const insertWebhook = async ({ - url, - name, - eventType, -}: CreateWebhooksParams): Promise => { - // generate random bytes +export const insertWebhook = async ( + args: Omit, +): Promise => { + // Generate a webhook secret. const bytes = randomBytes(4096); - // hash the bytes to create the secret (this will not be stored by itself) const secret = createHash("sha512").update(bytes).digest("base64url"); return prisma.webhooks.create({ data: { - url, - name, - eventType, + ...args, secret, }, }); diff --git a/src/prisma/migrations/20241024194728_add_mtls_webhooks/migration.sql b/src/prisma/migrations/20241024194728_add_mtls_webhooks/migration.sql new file mode 100644 index 000000000..453a85562 --- /dev/null +++ b/src/prisma/migrations/20241024194728_add_mtls_webhooks/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "webhooks" ADD COLUMN "mtlsCaCert" TEXT, +ADD COLUMN "mtlsClientCert" TEXT, +ADD COLUMN "mtlsClientKey" TEXT; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 6ed00bdd8..dc91bd75d 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -172,14 +172,20 @@ model Transactions { } model Webhooks { - id Int @id @default(autoincrement()) @map("id") - name String? @map("name") - url String @map("url") - secret String @map("secret") - eventType String @map("evenType") - createdAt DateTime @default(now()) @map("createdAt") - updatedAt DateTime @updatedAt @map("updatedAt") - revokedAt DateTime? @map("revokedAt") + id Int @id @default(autoincrement()) @map("id") + name String? @map("name") + url String @map("url") + secret String @map("secret") + eventType String @map("evenType") + + mtlsClientCert String? + mtlsClientKey String? + mtlsCaCert String? + + createdAt DateTime @default(now()) @map("createdAt") + updatedAt DateTime @updatedAt @map("updatedAt") + revokedAt DateTime? @map("revokedAt") + ContractSubscriptions ContractSubscriptions[] @@map("webhooks") diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts index 539d36b12..ee1bb086e 100644 --- a/src/server/middleware/auth.ts +++ b/src/server/middleware/auth.ts @@ -5,10 +5,10 @@ import { type ThirdwebAuthUser, } from "@thirdweb-dev/auth/fastify"; import { AsyncWallet } from "@thirdweb-dev/wallets/evm/wallets/async"; -import { createHash } from "crypto"; import type { FastifyInstance } from "fastify"; import type { FastifyRequest } from "fastify/types/request"; import jsonwebtoken, { type JwtPayload } from "jsonwebtoken"; +import { createHash } from "node:crypto"; import { validate as uuidValidate } from "uuid"; import { getPermissions } from "../../db/permissions/getPermissions"; import { createToken } from "../../db/tokens/createToken"; diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 2a950dd27..38cbd7bd4 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -109,6 +109,7 @@ import { createWebhook } from "./webhooks/create"; import { getWebhooksEventTypes } from "./webhooks/events"; import { getAllWebhooksData } from "./webhooks/getAll"; import { revokeWebhook } from "./webhooks/revoke"; +import { testWebhookRoute } from "./webhooks/test"; export const withRoutes = async (fastify: FastifyInstance) => { // Backend Wallets @@ -158,6 +159,7 @@ export const withRoutes = async (fastify: FastifyInstance) => { await fastify.register(createWebhook); await fastify.register(revokeWebhook); await fastify.register(getWebhooksEventTypes); + await fastify.register(testWebhookRoute); // Permissions await fastify.register(getAllPermissions); diff --git a/src/server/routes/webhooks/create.ts b/src/server/routes/webhooks/create.ts index 574ad390a..a4556e8d2 100644 --- a/src/server/routes/webhooks/create.ts +++ b/src/server/routes/webhooks/create.ts @@ -1,8 +1,9 @@ -import { Static, Type } from "@sinclair/typebox"; -import { FastifyInstance } from "fastify"; +import { Type, type Static } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; import { StatusCodes } from "http-status-codes"; import { insertWebhook } from "../../../db/webhooks/createWebhook"; import { WebhooksEventTypes } from "../../../schema/webhooks"; +import { encrypt } from "../../../utils/crypto"; import { createCustomError } from "../../middleware/error"; import { standardResponseSchema } from "../../schemas/sharedApiSchemas"; import { WebhookSchema, toWebhookSchema } from "../../schemas/webhook"; @@ -13,12 +14,26 @@ const requestBodySchema = Type.Object({ description: "Webhook URL", examples: ["https://example.com/webhook"], }), - name: Type.Optional( + name: Type.Optional(Type.String()), + eventType: Type.Enum(WebhooksEventTypes), + mtlsClientCert: Type.Optional( Type.String({ - minLength: 3, + description: + "(For mTLS) The client certificate used to authenticate your to your server.", + }), + ), + mtlsClientKey: Type.Optional( + Type.String({ + description: + "(For mTLS) The private key associated with your client certificate.", + }), + ), + mtlsCaCert: Type.Optional( + Type.String({ + description: + "(For mTLS) The Certificate Authority (CA) that signed your client certificate, used to verify the authenticity of the `mtlsClientCert`. This is only required if using a self-signed certficate.", }), ), - eventType: Type.Enum(WebhooksEventTypes), }); requestBodySchema.examples = [ @@ -76,7 +91,7 @@ export async function createWebhook(fastify: FastifyInstance) { method: "POST", url: "/webhooks/create", schema: { - summary: "Create a webhook", + summary: "Create webhook", description: "Create a webhook to call when certain blockchain events occur.", tags: ["Webhooks"], @@ -88,7 +103,26 @@ export async function createWebhook(fastify: FastifyInstance) { }, }, handler: async (req, res) => { - const { url, name, eventType } = req.body; + const { + url, + name, + eventType, + mtlsClientCert, + mtlsClientKey, + mtlsCaCert, + } = req.body; + + // Assert neither or both required mTLS fields are provided. + if ( + (!mtlsClientCert && mtlsClientKey) || + (mtlsClientCert && !mtlsClientKey) + ) { + throw createCustomError( + `"mtlsClientCert" and "mtlsClientKey" must be set if using mTLS.`, + StatusCodes.BAD_REQUEST, + "BAD_REQUEST", + ); + } if (!isValidHttpUrl(url)) { throw createCustomError( @@ -102,6 +136,9 @@ export async function createWebhook(fastify: FastifyInstance) { url, name, eventType, + mtlsClientCert: mtlsClientCert ? encrypt(mtlsClientCert) : undefined, + mtlsClientKey: mtlsClientKey ? encrypt(mtlsClientKey) : undefined, + mtlsCaCert: mtlsCaCert ? encrypt(mtlsCaCert) : undefined, }); res.status(StatusCodes.OK).send({ diff --git a/src/server/routes/webhooks/test.ts b/src/server/routes/webhooks/test.ts new file mode 100644 index 000000000..68bc341c6 --- /dev/null +++ b/src/server/routes/webhooks/test.ts @@ -0,0 +1,120 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { FastifyInstance } from "fastify"; +import { StatusCodes } from "http-status-codes"; +import { getWebhook } from "../../../db/webhooks/getWebhook"; +import { sendWebhookRequest } from "../../../utils/webhook"; +import { createCustomError } from "../../middleware/error"; +import { NumberStringSchema } from "../../schemas/number"; +import { standardResponseSchema } from "../../schemas/sharedApiSchemas"; +import type { TransactionSchema } from "../../schemas/transaction"; + +const paramsSchema = Type.Object({ + webhookId: NumberStringSchema, +}); + +const responseBodySchema = Type.Object({ + result: Type.Object({ + ok: Type.Boolean(), + status: Type.Number(), + body: Type.String(), + }), +}); + +export async function testWebhookRoute(fastify: FastifyInstance) { + fastify.route<{ + Params: Static; + Reply: Static; + }>({ + method: "POST", + url: "/webhooks/:webhookId/test", + schema: { + summary: "Test webhook", + description: "Send a test payload to a webhook.", + tags: ["Webhooks"], + operationId: "testWebhook", + params: paramsSchema, + response: { + ...standardResponseSchema, + [StatusCodes.OK]: responseBodySchema, + }, + }, + handler: async (req, res) => { + const { webhookId } = req.params; + + const webhook = await getWebhook(Number.parseInt(webhookId)); + if (!webhook) { + throw createCustomError( + "Webhook not found.", + StatusCodes.BAD_REQUEST, + "NOT_FOUND", + ); + } + + const webhookBody: Static = { + // Queue details + queueId: "1411246e-b1c8-4f5d-9a25-8c1f40b54e55", + status: "mined", + onchainStatus: "success", + queuedAt: "2023-09-29T22:01:31.031Z", + sentAt: "2023-09-29T22:01:41.580Z", + minedAt: "2023-09-29T22:01:44.000Z", + errorMessage: null, + cancelledAt: null, + retryCount: 0, + + // Onchain details + chainId: "80002", + fromAddress: "0x3ecdbf3b911d0e9052b64850693888b008e18373", + toAddress: "0x365b83d67d5539c6583b9c0266a548926bf216f4", + data: "0xa9059cbb0000000000000000000000003ecdbf3b911d0e9052b64850693888b008e183730000000000000000000000000000000000000000000000000000000000000064", + value: "0x00", + nonce: 1786, + gasLimit: "39580", + maxFeePerGas: "2063100466", + maxPriorityFeePerGas: "1875545856", + gasPrice: "1875545871", + transactionType: 2, + transactionHash: + "0xc3ffa42dd4734b017d483e1158a2e936c8a97dd1aa4e4ce11df80ac8e81d2c7e", + sentAtBlockNumber: 40660021, + blockNumber: 40660026, + + // User operation (account abstraction) details + signerAddress: null, + accountAddress: null, + accountFactoryAddress: null, + target: null, + sender: null, + initCode: null, + callData: null, + callGasLimit: null, + verificationGasLimit: null, + preVerificationGas: null, + paymasterAndData: null, + userOpHash: null, + accountSalt: null, + + // Off-chain details + functionName: "transfer", + functionArgs: "0x3ecdbf3b911d0e9052b64850693888b008e18373,100", + extension: "none", + deployedContractAddress: null, + deployedContractType: null, + + // Deprecated + retryGasValues: null, + retryMaxFeePerGas: null, + retryMaxPriorityFeePerGas: null, + effectiveGasPrice: null, + cumulativeGasUsed: null, + onChainTxStatus: 1, + }; + + const resp = await sendWebhookRequest(webhook, webhookBody); + + res.status(StatusCodes.OK).send({ + result: resp, + }); + }, + }); +} diff --git a/src/tests/auth.test.ts b/src/tests/auth.test.ts index 8e6e774e9..25506bac4 100644 --- a/src/tests/auth.test.ts +++ b/src/tests/auth.test.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { LocalWallet } from "@thirdweb-dev/wallets"; -import { FastifyRequest } from "fastify/types/request"; +import type { FastifyRequest } from "fastify/types/request"; import jsonwebtoken from "jsonwebtoken"; import { getPermissions } from "../db/permissions/getPermissions"; import { WebhooksEventTypes } from "../schema/webhooks"; @@ -275,11 +275,6 @@ describe("Websocket requests", () => { session: { permissions: Permission.Admin }, }); - const mockSocket = { - write: vi.fn(), - destroy: vi.fn(), - }; - const defaultConfig = await getConfig(); mockGetConfig.mockResolvedValueOnce({ ...defaultConfig, diff --git a/src/tests/chain.test.ts b/src/tests/chain.test.ts index 4a3684a6c..c337c8144 100644 --- a/src/tests/chain.test.ts +++ b/src/tests/chain.test.ts @@ -11,8 +11,6 @@ vi.mock("../utils/cache/getConfig"); vi.mock("@thirdweb-dev/chains"); const mockGetConfig = vi.mocked(getConfig); - -const mockGetChainByChainIdAsync = vi.mocked(getChainByChainIdAsync); const mockGetChainBySlugAsync = vi.mocked(getChainBySlugAsync); describe("getChainIdFromChain", () => { @@ -21,65 +19,16 @@ describe("getChainIdFromChain", () => { vi.clearAllMocks(); }); - it("should return the chainId from chainOverrides if it exists by slug", async () => { + it("should return the chainId from chainOverrides if input is an id", async () => { // @ts-ignore mockGetConfig.mockResolvedValueOnce({ - chainOverrides: JSON.stringify([ + chainOverridesParsed: [ { - slug: "Polygon", - chainId: 137, + id: 137, + name: "Polygon", + rpc: "https://test-rpc-url.com", }, - ]), - }); - - const result = await getChainIdFromChain("Polygon"); - - expect(result).toBe(137); - expect(getChainByChainIdAsync).not.toHaveBeenCalled(); - expect(getChainBySlugAsync).not.toHaveBeenCalled(); - }); - - it("should return the chainId from chainOverrides if it exists by slug, case-insensitive", async () => { - // @ts-ignore - mockGetConfig.mockResolvedValueOnce({ - chainOverrides: JSON.stringify([ - { - slug: "Polygon", - chainId: 137, - }, - ]), - }); - - const result = await getChainIdFromChain("polygon"); - - expect(result).toBe(137); - expect(getChainByChainIdAsync).not.toHaveBeenCalled(); - expect(getChainBySlugAsync).not.toHaveBeenCalled(); - }); - - it("should return the chainId from chainOverrides if it exists", async () => { - // @ts-ignore - mockGetConfig.mockResolvedValueOnce({ - chainOverrides: JSON.stringify([ - { - slug: "Polygon", - chainId: 137, - }, - ]), - }); - - const result = await getChainIdFromChain("Polygon"); - - expect(result).toBe(137); - expect(getChainByChainIdAsync).not.toHaveBeenCalled(); - expect(getChainBySlugAsync).not.toHaveBeenCalled(); - }); - - it("should return the chainId from getChainByChainIdAsync if chain is a valid numeric string", async () => { - // @ts-ignore - mockGetChainByChainIdAsync.mockResolvedValueOnce({ - name: "Polygon", - chainId: 137, + ], }); const result = await getChainIdFromChain("137"); @@ -89,20 +38,17 @@ describe("getChainIdFromChain", () => { expect(getChainBySlugAsync).not.toHaveBeenCalled(); }); - it("should return the chainId from getChainBySlugAsync if chain is a valid string", async () => { - // @ts-ignore - mockGetConfig.mockResolvedValueOnce({}); + it("should return the chainId from getChainByChainIdAsync if input is a slug", async () => { // @ts-ignore mockGetChainBySlugAsync.mockResolvedValueOnce({ name: "Polygon", chainId: 137, + status: "active", }); const result = await getChainIdFromChain("Polygon"); expect(result).toBe(137); - expect(getChainBySlugAsync).toHaveBeenCalledWith("polygon"); - expect(getChainByChainIdAsync).not.toHaveBeenCalled(); }); it("should throw an error for an invalid chain", async () => { @@ -110,7 +56,8 @@ describe("getChainIdFromChain", () => { mockGetConfig.mockResolvedValueOnce({}); await expect(getChainIdFromChain("not_a_real_chain")).rejects.toEqual({ - message: "Chain not_a_real_chain is not found", + message: + "Invalid chain: not_a_real_chain. If this is a custom chain, add it to chain overrides.", statusCode: 400, code: "INVALID_CHAIN", }); diff --git a/src/tests/webhook.test.ts b/src/tests/webhook.test.ts new file mode 100644 index 000000000..eac1923e2 --- /dev/null +++ b/src/tests/webhook.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { generateSecretHmac256 } from "../utils/webhook/customAuthHeader"; + +describe("generateSecretHmac256", () => { + it("should generate a valid MAC header with correct structure and values", () => { + const timestampSeconds = new Date("2024-01-01").getTime() / 1000; + const nonce = "6b98df0d-5f33-4121-96cb-77a0b9df2bbe"; + + const result = generateSecretHmac256({ + webhookUrl: "https://example.com/webhook", + body: { bodyArgName: "bodyArgValue" }, + timestampSeconds, + nonce, + clientId: "testClientId", + clientSecret: "testClientSecret", + }); + + expect(result).toEqual( + `MAC id="testClientId" ts="1704067200000" nonce="6b98df0d-5f33-4121-96cb-77a0b9df2bbe" bodyhash="4Mknknli8NGCwC28djVf/Qa8vN3wtvfeRGKVha0MgjQ=" mac="Qbe9H5yeVvywoL3l1RFLBDC0YvDOCQnytNSlbTWXzEk="`, + ); + }); +}); diff --git a/src/utils/account.ts b/src/utils/account.ts index 8eb5b55bc..7394e9998 100644 --- a/src/utils/account.ts +++ b/src/utils/account.ts @@ -13,7 +13,6 @@ import { getConfig } from "./cache/getConfig"; import { getSmartWalletV5 } from "./cache/getSmartWalletV5"; import { getChain } from "./chain"; import { decrypt } from "./crypto"; -import { env } from "./env"; import { thirdwebClient } from "./sdk"; export const _accountsCache = new LRUMap(2048); @@ -58,7 +57,7 @@ export const getAccount = async (args: { // try to decrypt the secret access key in walletDetails (if found) // otherwise fallback to global config const secretAccessKey = walletDetails.awsKmsSecretAccessKey - ? decrypt(walletDetails.awsKmsSecretAccessKey, env.ENCRYPTION_PASSWORD) + ? decrypt(walletDetails.awsKmsSecretAccessKey) : config.walletConfiguration.aws?.awsSecretAccessKey; if (!secretAccessKey) { @@ -97,10 +96,7 @@ export const getAccount = async (args: { // otherwise fallback to global config const gcpApplicationCredentialPrivateKey = walletDetails.gcpApplicationCredentialPrivateKey - ? decrypt( - walletDetails.gcpApplicationCredentialPrivateKey, - env.ENCRYPTION_PASSWORD, - ) + ? decrypt(walletDetails.gcpApplicationCredentialPrivateKey) : config.walletConfiguration.gcp?.gcpApplicationCredentialPrivateKey; if (!gcpApplicationCredentialPrivateKey) { diff --git a/src/utils/cache/getWallet.ts b/src/utils/cache/getWallet.ts index ae0397375..43532ac09 100644 --- a/src/utils/cache/getWallet.ts +++ b/src/utils/cache/getWallet.ts @@ -99,10 +99,7 @@ export const getWallet = async ({ walletDetails.gcpApplicationCredentialEmail ?? config.walletConfiguration.gcp?.gcpApplicationCredentialEmail; const privateKey = walletDetails.gcpApplicationCredentialPrivateKey - ? decrypt( - walletDetails.gcpApplicationCredentialPrivateKey, - env.ENCRYPTION_PASSWORD, - ) + ? decrypt(walletDetails.gcpApplicationCredentialPrivateKey) : config.walletConfiguration.gcp?.gcpApplicationCredentialPrivateKey; if (!(email && privateKey)) { diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 7b9052dd4..caa94128d 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -1,12 +1,12 @@ -import crypto from "crypto"; import CryptoJS from "crypto-js"; +import crypto from "node:crypto"; import { env } from "./env"; export const encrypt = (data: string): string => { return CryptoJS.AES.encrypt(data, env.ENCRYPTION_PASSWORD).toString(); }; -export const decrypt = (data: string, password: string) => { +export const decrypt = (data: string, password = env.ENCRYPTION_PASSWORD) => { return CryptoJS.AES.decrypt(data, password).toString(CryptoJS.enc.Utf8); }; diff --git a/src/utils/env.ts b/src/utils/env.ts index f323822a7..9cd6b44b4 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -68,7 +68,6 @@ export const env = createEnv({ .default("https://c.thirdweb.com/event"), SDK_BATCH_TIME_LIMIT: z.coerce.number().default(0), SDK_BATCH_SIZE_LIMIT: z.coerce.number().default(100), - ENABLE_KEYPAIR_AUTH: boolEnvSchema(false), CONTRACT_SUBSCRIPTIONS_DELAY_SECONDS: z.coerce .number() .nonnegative() @@ -102,6 +101,12 @@ export const env = createEnv({ QUEUE_FAIL_HISTORY_COUNT: z.coerce.number().default(10_000), // Sets the number of recent nonces to map to queue IDs. NONCE_MAP_COUNT: z.coerce.number().default(10_000), + + /** + * Enable experimental or custom features. + */ + ENABLE_KEYPAIR_AUTH: boolEnvSchema(false), + ENABLE_CUSTOM_HMAC_AUTH: boolEnvSchema(false), }, clientPrefix: "NEVER_USED", client: {}, @@ -124,7 +129,6 @@ export const env = createEnv({ CLIENT_ANALYTICS_URL: process.env.CLIENT_ANALYTICS_URL, SDK_BATCH_TIME_LIMIT: process.env.SDK_BATCH_TIME_LIMIT, SDK_BATCH_SIZE_LIMIT: process.env.SDK_BATCH_SIZE_LIMIT, - ENABLE_KEYPAIR_AUTH: process.env.ENABLE_KEYPAIR_AUTH, CONTRACT_SUBSCRIPTIONS_DELAY_SECONDS: process.env.CONTRACT_SUBSCRIPTIONS_DELAY_SECONDS, REDIS_URL: process.env.REDIS_URL, @@ -142,6 +146,8 @@ export const env = createEnv({ NONCE_MAP_COUNT: process.env.NONCE_MAP_COUNT, METRICS_PORT: process.env.METRICS_PORT, METRICS_ENABLED: process.env.METRICS_ENABLED, + ENABLE_KEYPAIR_AUTH: process.env.ENABLE_KEYPAIR_AUTH, + ENABLE_CUSTOM_HMAC_AUTH: process.env.ENABLE_CUSTOM_HMAC_AUTH, }, onValidationError: (error: ZodError) => { console.error( diff --git a/src/utils/webhook.ts b/src/utils/webhook.ts index d21eaebcd..d5d7c9521 100644 --- a/src/utils/webhook.ts +++ b/src/utils/webhook.ts @@ -1,49 +1,67 @@ -import { Webhooks } from "@prisma/client"; -import crypto from "crypto"; +import type { Webhooks } from "@prisma/client"; +import crypto, { randomUUID } from "node:crypto"; +import { Agent, fetch } from "undici"; import { - WalletBalanceWebhookSchema, WebhooksEventTypes, + type WalletBalanceWebhookSchema, } from "../schema/webhooks"; import { getWebhooksByEventType } from "./cache/getWebhook"; +import { decrypt } from "./crypto"; +import { env } from "./env"; +import { prettifyError } from "./error"; import { logger } from "./logger"; +import { generateSecretHmac256 } from "./webhook/customAuthHeader"; let balanceNotificationLastSentAt = -1; -export const generateSignature = ( - body: Record, - timestamp: string, +const generateSignature = ( + body: Record, + timestampSeconds: number, secret: string, ): string => { const _body = JSON.stringify(body); - const payload = `${timestamp}.${_body}`; + const payload = `${timestampSeconds}.${_body}`; return crypto.createHmac("sha256", secret).update(payload).digest("hex"); }; -export const createWebhookRequestHeaders = async ( +const generateAuthorization = (args: { + webhook: Webhooks; + timestampSeconds: number; + body: Record; +}): string => { + const { webhook, timestampSeconds, body } = args; + if (env.ENABLE_CUSTOM_HMAC_AUTH) { + return generateSecretHmac256({ + webhookUrl: webhook.url, + body, + timestampSeconds, + nonce: randomUUID(), + // DEBUG + clientId: "@TODO: UNIMPLEMENTED", + clientSecret: "@TODO: UNIMPLEMENTED", + }); + } + return `Bearer ${webhook.secret}`; +}; + +const generateRequestHeaders = ( webhook: Webhooks, - body: Record, -): Promise => { - const headers: { - Accept: string; - "Content-Type": string; - Authorization?: string; - "x-engine-signature"?: string; - "x-engine-timestamp"?: string; - } = { + body: Record, +): HeadersInit => { + const timestampSeconds = Math.floor(Date.now() / 1000); + const signature = generateSignature(body, timestampSeconds, webhook.secret); + const authorization = generateAuthorization({ + webhook, + timestampSeconds, + body, + }); + return { Accept: "application/json", "Content-Type": "application/json", + Authorization: authorization, + "x-engine-signature": signature, + "x-engine-timestamp": timestampSeconds.toString(), }; - - if (webhook.secret) { - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signature = generateSignature(body, timestamp, webhook.secret); - - headers["Authorization"] = `Bearer ${webhook.secret}`; - headers["x-engine-signature"] = signature; - headers["x-engine-timestamp"] = timestamp; - } - - return headers; }; export interface WebhookResponse { @@ -54,14 +72,29 @@ export interface WebhookResponse { export const sendWebhookRequest = async ( webhook: Webhooks, - body: Record, + body: Record, ): Promise => { try { - const headers = await createWebhookRequestHeaders(webhook, body); + // If mTLS is enabled, provide the certificate with this request. + const dispatcher = + webhook.mtlsClientCert && webhook.mtlsClientKey + ? new Agent({ + connect: { + cert: decrypt(webhook.mtlsClientCert), + key: decrypt(webhook.mtlsClientKey), + ca: webhook.mtlsCaCert ? decrypt(webhook.mtlsCaCert) : undefined, + // Validate the server's certificate. + rejectUnauthorized: true, + }, + }) + : undefined; + + const headers = await generateRequestHeaders(webhook, body); const resp = await fetch(webhook.url, { method: "POST", headers: headers, body: JSON.stringify(body), + dispatcher, }); return { @@ -69,11 +102,11 @@ export const sendWebhookRequest = async ( status: resp.status, body: await resp.text(), }; - } catch (e: any) { + } catch (e) { return { ok: false, status: 500, - body: e.toString(), + body: prettifyError(e), }; } }; diff --git a/src/utils/webhook/customAuthHeader.ts b/src/utils/webhook/customAuthHeader.ts new file mode 100644 index 000000000..37a254d07 --- /dev/null +++ b/src/utils/webhook/customAuthHeader.ts @@ -0,0 +1,63 @@ +import { createHmac } from "node:crypto"; + +/** + * Generates an HMAC-256 secret to set in the "Authorization" header. + * + * @param webhookUrl - The URL to call. + * @param body - The request body. + * @param timestampSeconds - The timestamp in seconds. + * @param nonce - A unique string for this request. Should not be re-used. + * @param clientId - Your application's client id. + * @param clientSecret - Your application's client secret. + * @returns + */ +export const generateSecretHmac256 = (args: { + webhookUrl: string; + body: Record; + timestampSeconds: number; + nonce: string; + clientId: string; + clientSecret: string; +}): string => { + const { webhookUrl, body, timestampSeconds, nonce, clientId, clientSecret } = + args; + + // Create the body hash by hashing the payload. + const bodyHash = createHmac("sha256", clientSecret) + .update(JSON.stringify(body), "utf8") + .digest("base64"); + + // Create the signature hash by hashing the signature. + const ts = timestampSeconds * 1000; // timestamp expected in milliseconds + const httpMethod = "POST"; + const url = new URL(webhookUrl); + const resourcePath = url.pathname; + const host = url.hostname; + const port = url.port + ? Number.parseInt(url.port) + : url.protocol === "https:" + ? 443 + : 80; + + const signature = [ + ts, + nonce, + httpMethod, + resourcePath, + host, + port, + bodyHash, + "", // to insert a newline at the end + ].join("\n"); + const signatureHash = createHmac("sha256", clientSecret) + .update(signature, "utf8") + .digest("base64"); + + return [ + `MAC id="${clientId}"`, + `ts="${ts}"`, + `nonce="${nonce}"`, + `bodyhash="${bodyHash}"`, + `mac="${signatureHash}"`, + ].join(" "); +}; diff --git a/test/e2e/tests/routes/resetNonce.test.ts b/test/e2e/tests/routes/resetNonce.test.ts new file mode 100644 index 000000000..72615cd2e --- /dev/null +++ b/test/e2e/tests/routes/resetNonce.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { anvil } from "thirdweb/chains"; +import { setup } from "../setup"; + +describe("resetNonceRoute", () => { + test("Nonce is updated after resetting.", async () => { + const { engine, backendWallet } = await setup(); + + const nonce1 = await engine.backendWallet.getNonce( + anvil.id.toString(), + backendWallet, + ); + expect(nonce1).toEqual(0); + }); +}); diff --git a/yarn.lock b/yarn.lock index e6b36e81d..4e5850173 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11577,16 +11577,7 @@ strict-uri-encode@^2.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -11623,14 +11614,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12089,6 +12073,11 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici@^6.20.1: + version "6.20.1" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.20.1.tgz#fbb87b1e2b69d963ff2d5410a40ffb4c9e81b621" + integrity sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA== + unenv@^1.9.0: version "1.10.0" resolved "https://registry.yarnpkg.com/unenv/-/unenv-1.10.0.tgz#c3394a6c6e4cfe68d699f87af456fe3f0db39571" @@ -12686,7 +12675,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -12704,15 +12693,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 1772f5a5abe03991cbf739045891cec01b06472c Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Mon, 28 Oct 2024 15:35:13 +0800 Subject: [PATCH 2/7] remove resetNonce --- test/e2e/tests/routes/resetNonce.test.ts | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 test/e2e/tests/routes/resetNonce.test.ts diff --git a/test/e2e/tests/routes/resetNonce.test.ts b/test/e2e/tests/routes/resetNonce.test.ts deleted file mode 100644 index 72615cd2e..000000000 --- a/test/e2e/tests/routes/resetNonce.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { anvil } from "thirdweb/chains"; -import { setup } from "../setup"; - -describe("resetNonceRoute", () => { - test("Nonce is updated after resetting.", async () => { - const { engine, backendWallet } = await setup(); - - const nonce1 = await engine.backendWallet.getNonce( - anvil.id.toString(), - backendWallet, - ); - expect(nonce1).toEqual(0); - }); -}); From 0d6902c2f510f82b2dba498d658bcf44fe868985 Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Thu, 31 Oct 2024 09:12:09 +0800 Subject: [PATCH 3/7] use env vars for client id/secret --- src/utils/env.ts | 4 ++++ src/utils/webhook.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/utils/env.ts b/src/utils/env.ts index 9cd6b44b4..9e871ca72 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -107,6 +107,8 @@ export const env = createEnv({ */ ENABLE_KEYPAIR_AUTH: boolEnvSchema(false), ENABLE_CUSTOM_HMAC_AUTH: boolEnvSchema(false), + CUSTOM_HMAC_AUTH_CLIENT_ID: z.string().optional(), + CUSTOM_HMAC_AUTH_CLIENT_SECRET: z.string().optional(), }, clientPrefix: "NEVER_USED", client: {}, @@ -148,6 +150,8 @@ export const env = createEnv({ METRICS_ENABLED: process.env.METRICS_ENABLED, ENABLE_KEYPAIR_AUTH: process.env.ENABLE_KEYPAIR_AUTH, ENABLE_CUSTOM_HMAC_AUTH: process.env.ENABLE_CUSTOM_HMAC_AUTH, + CUSTOM_HMAC_AUTH_CLIENT_ID: process.env.CUSTOM_HMAC_AUTH_CLIENT_ID, + CUSTOM_HMAC_AUTH_CLIENT_SECRET: process.env.CUSTOM_HMAC_AUTH_CLIENT_SECRET, }, onValidationError: (error: ZodError) => { console.error( diff --git a/src/utils/webhook.ts b/src/utils/webhook.ts index 54ea60f7b..d7f5988f9 100644 --- a/src/utils/webhook.ts +++ b/src/utils/webhook.ts @@ -1,4 +1,5 @@ import type { Webhooks } from "@prisma/client"; +import assert from "node:assert"; import crypto, { randomUUID } from "node:crypto"; import { Agent, fetch } from "undici"; import { getConfig } from "./cache/getConfig"; @@ -24,14 +25,22 @@ const generateAuthorization = (args: { }): string => { const { webhook, timestampSeconds, body } = args; if (env.ENABLE_CUSTOM_HMAC_AUTH) { + assert( + env.CUSTOM_HMAC_AUTH_CLIENT_ID, + 'Missing "CUSTOM_HMAC_AUTH_CLIENT_ID".', + ); + assert( + env.CUSTOM_HMAC_AUTH_CLIENT_SECRET, + 'Missing "CUSTOM_HMAC_AUTH_CLIENT_SECRET"', + ); + return generateSecretHmac256({ webhookUrl: webhook.url, body, timestampSeconds, nonce: randomUUID(), - // DEBUG - clientId: "@TODO: UNIMPLEMENTED", - clientSecret: "@TODO: UNIMPLEMENTED", + clientId: env.CUSTOM_HMAC_AUTH_CLIENT_ID, + clientSecret: env.CUSTOM_HMAC_AUTH_CLIENT_SECRET, }); } return `Bearer ${webhook.secret}`; From 0bcea20e623bfd1bbdf0b082dad22bafbb362c6f Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Thu, 31 Oct 2024 10:02:27 +0800 Subject: [PATCH 4/7] update tests --- src/prisma/schema.prisma | 11 ++++---- src/server/routes/webhooks/create.ts | 20 +++----------- src/tests/webhook.test.ts | 39 +++++++++++++++++++++++++-- src/utils/env.ts | 12 --------- src/utils/webhook.ts | 31 +++++++++++---------- src/utils/webhook/customAuthHeader.ts | 9 +++---- 6 files changed, 66 insertions(+), 56 deletions(-) diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index de62d7524..265306031 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -175,12 +175,11 @@ model Transactions { } model Webhooks { - id Int @id @default(autoincrement()) @map("id") - name String? @map("name") - url String @map("url") - secret String @map("secret") - eventType String @map("evenType") - + id Int @id @default(autoincrement()) @map("id") + name String? @map("name") + url String @map("url") + secret String @map("secret") + eventType String @map("evenType") createdAt DateTime @default(now()) @map("createdAt") updatedAt DateTime @updatedAt @map("updatedAt") revokedAt DateTime? @map("revokedAt") diff --git a/src/server/routes/webhooks/create.ts b/src/server/routes/webhooks/create.ts index f138df0b8..4dd6e2c52 100644 --- a/src/server/routes/webhooks/create.ts +++ b/src/server/routes/webhooks/create.ts @@ -13,26 +13,12 @@ const requestBodySchema = Type.Object({ description: "Webhook URL. Non-HTTPS URLs are not supported.", examples: ["https://example.com/webhook"], }), - name: Type.Optional(Type.String()), - eventType: Type.Enum(WebhooksEventTypes), - mtlsClientCert: Type.Optional( - Type.String({ - description: - "(For mTLS) The client certificate used to authenticate your to your server.", - }), - ), - mtlsClientKey: Type.Optional( + name: Type.Optional( Type.String({ - description: - "(For mTLS) The private key associated with your client certificate.", - }), - ), - mtlsCaCert: Type.Optional( - Type.String({ - description: - "(For mTLS) The Certificate Authority (CA) that signed your client certificate, used to verify the authenticity of the `mtlsClientCert`. This is only required if using a self-signed certficate.", + minLength: 3, }), ), + eventType: Type.Enum(WebhooksEventTypes), }); requestBodySchema.examples = [ diff --git a/src/tests/webhook.test.ts b/src/tests/webhook.test.ts index eac1923e2..b4eaf706e 100644 --- a/src/tests/webhook.test.ts +++ b/src/tests/webhook.test.ts @@ -1,15 +1,18 @@ +import type { Webhooks } from "@prisma/client"; import { describe, expect, it } from "vitest"; +import { WebhooksEventTypes } from "../schema/webhooks"; +import { generateRequestHeaders } from "../utils/webhook"; import { generateSecretHmac256 } from "../utils/webhook/customAuthHeader"; describe("generateSecretHmac256", () => { it("should generate a valid MAC header with correct structure and values", () => { - const timestampSeconds = new Date("2024-01-01").getTime() / 1000; + const timestamp = new Date("2024-01-01"); const nonce = "6b98df0d-5f33-4121-96cb-77a0b9df2bbe"; const result = generateSecretHmac256({ webhookUrl: "https://example.com/webhook", body: { bodyArgName: "bodyArgValue" }, - timestampSeconds, + timestamp, nonce, clientId: "testClientId", clientSecret: "testClientSecret", @@ -20,3 +23,35 @@ describe("generateSecretHmac256", () => { ); }); }); + +describe("generateRequestHeaders", () => { + const webhook: Webhooks = { + id: 42, + name: "test webhook", + url: "https://www.example.com/webhook", + secret: "test-secret-string", + eventType: WebhooksEventTypes.SENT_TX, + createdAt: new Date(), + updatedAt: new Date(), + revokedAt: null, + }; + const body = { + name: "Alice", + age: 25, + occupation: ["Founder", "Developer"], + }; + const timestamp = new Date("2024-01-01"); + + it("Generate a consistent webhook header", () => { + const result = generateRequestHeaders({ webhook, body, timestamp }); + + expect(result).toEqual({ + Accept: "application/json", + Authorization: "Bearer test-secret-string", + "Content-Type": "application/json", + "x-engine-signature": + "ca272da65f1145b9cfadab6d55086ee458eccc03a2c5f7f5ea84094d95b219cc", + "x-engine-timestamp": "1704067200", + }); + }); +}); diff --git a/src/utils/env.ts b/src/utils/env.ts index 9e871ca72..224e75c05 100644 --- a/src/utils/env.ts +++ b/src/utils/env.ts @@ -6,18 +6,6 @@ import { z } from "zod"; const path = process.env.NODE_ENV === "test" ? ".env.test" : ".env"; dotenv.config({ path }); -export const JsonSchema = z.string().refine( - (value) => { - try { - JSON.parse(value); - return true; - } catch { - return false; - } - }, - { message: "Invalid JSON string" }, -); - const boolEnvSchema = (defaultBool: boolean) => z .string() diff --git a/src/utils/webhook.ts b/src/utils/webhook.ts index d7f5988f9..3b56b8a33 100644 --- a/src/utils/webhook.ts +++ b/src/utils/webhook.ts @@ -20,10 +20,10 @@ const generateSignature = ( const generateAuthorization = (args: { webhook: Webhooks; - timestampSeconds: number; + timestamp: Date; body: Record; }): string => { - const { webhook, timestampSeconds, body } = args; + const { webhook, timestamp, body } = args; if (env.ENABLE_CUSTOM_HMAC_AUTH) { assert( env.CUSTOM_HMAC_AUTH_CLIENT_ID, @@ -37,7 +37,7 @@ const generateAuthorization = (args: { return generateSecretHmac256({ webhookUrl: webhook.url, body, - timestampSeconds, + timestamp, nonce: randomUUID(), clientId: env.CUSTOM_HMAC_AUTH_CLIENT_ID, clientSecret: env.CUSTOM_HMAC_AUTH_CLIENT_SECRET, @@ -46,17 +46,16 @@ const generateAuthorization = (args: { return `Bearer ${webhook.secret}`; }; -const generateRequestHeaders = ( - webhook: Webhooks, - body: Record, -): HeadersInit => { - const timestampSeconds = Math.floor(Date.now() / 1000); +export const generateRequestHeaders = (args: { + webhook: Webhooks; + body: Record; + timestamp: Date; +}): HeadersInit => { + const { webhook, body, timestamp } = args; + + const timestampSeconds = Math.floor(timestamp.getTime() / 1000); const signature = generateSignature(body, timestampSeconds, webhook.secret); - const authorization = generateAuthorization({ - webhook, - timestampSeconds, - body, - }); + const authorization = generateAuthorization({ webhook, timestamp, body }); return { Accept: "application/json", "Content-Type": "application/json", @@ -92,7 +91,11 @@ export const sendWebhookRequest = async ( }) : undefined; - const headers = await generateRequestHeaders(webhook, body); + const headers = await generateRequestHeaders({ + webhook, + body, + timestamp: new Date(), + }); const resp = await fetch(webhook.url, { method: "POST", headers: headers, diff --git a/src/utils/webhook/customAuthHeader.ts b/src/utils/webhook/customAuthHeader.ts index 37a254d07..c84f9e964 100644 --- a/src/utils/webhook/customAuthHeader.ts +++ b/src/utils/webhook/customAuthHeader.ts @@ -5,7 +5,7 @@ import { createHmac } from "node:crypto"; * * @param webhookUrl - The URL to call. * @param body - The request body. - * @param timestampSeconds - The timestamp in seconds. + * @param timestamp - The request timestamp. * @param nonce - A unique string for this request. Should not be re-used. * @param clientId - Your application's client id. * @param clientSecret - Your application's client secret. @@ -14,13 +14,12 @@ import { createHmac } from "node:crypto"; export const generateSecretHmac256 = (args: { webhookUrl: string; body: Record; - timestampSeconds: number; + timestamp: Date; nonce: string; clientId: string; clientSecret: string; }): string => { - const { webhookUrl, body, timestampSeconds, nonce, clientId, clientSecret } = - args; + const { webhookUrl, body, timestamp, nonce, clientId, clientSecret } = args; // Create the body hash by hashing the payload. const bodyHash = createHmac("sha256", clientSecret) @@ -28,7 +27,7 @@ export const generateSecretHmac256 = (args: { .digest("base64"); // Create the signature hash by hashing the signature. - const ts = timestampSeconds * 1000; // timestamp expected in milliseconds + const ts = timestamp.getTime(); // timestamp expected in milliseconds const httpMethod = "POST"; const url = new URL(webhookUrl); const resourcePath = url.pathname; From 0a92d323ba7c07ba11a746a4b165e0b0ce657497 Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Thu, 16 Jan 2025 17:58:29 +0800 Subject: [PATCH 5/7] debug steps --- .../routes/configuration/auth/update.ts | 12 +++------ src/shared/utils/custom-auth-header.ts | 3 ++- src/shared/utils/webhook.ts | 26 +++++++++---------- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/server/routes/configuration/auth/update.ts b/src/server/routes/configuration/auth/update.ts index acbf2492d..9ae70d081 100644 --- a/src/server/routes/configuration/auth/update.ts +++ b/src/server/routes/configuration/auth/update.ts @@ -43,10 +43,8 @@ export async function updateAuthConfiguration(fastify: FastifyInstance) { if (mtlsCertificate) { if ( - !( - mtlsCertificate.startsWith("-----BEGIN CERTIFICATE-----\n") && - mtlsCertificate.endsWith("\n-----END CERTIFICATE-----") - ) + !mtlsCertificate.startsWith("-----BEGIN CERTIFICATE-----\n") || + !mtlsCertificate.endsWith("\n-----END CERTIFICATE-----") ) { throw createCustomError( "Invalid mtlsCertificate.", @@ -57,10 +55,8 @@ export async function updateAuthConfiguration(fastify: FastifyInstance) { } if (mtlsPrivateKey) { if ( - !( - mtlsPrivateKey.startsWith("-----BEGIN PRIVATE KEY-----\n") && - mtlsPrivateKey.endsWith("\n-----END PRIVATE KEY-----") - ) + !mtlsPrivateKey.startsWith("-----BEGIN PRIVATE KEY-----\n") || + !mtlsPrivateKey.endsWith("\n-----END PRIVATE KEY-----") ) { throw createCustomError( "Invalid mtlsPrivateKey.", diff --git a/src/shared/utils/custom-auth-header.ts b/src/shared/utils/custom-auth-header.ts index c84f9e964..ac5fd54f5 100644 --- a/src/shared/utils/custom-auth-header.ts +++ b/src/shared/utils/custom-auth-header.ts @@ -48,6 +48,7 @@ export const generateSecretHmac256 = (args: { bodyHash, "", // to insert a newline at the end ].join("\n"); + const signatureHash = createHmac("sha256", clientSecret) .update(signature, "utf8") .digest("base64"); @@ -58,5 +59,5 @@ export const generateSecretHmac256 = (args: { `nonce="${nonce}"`, `bodyhash="${bodyHash}"`, `mac="${signatureHash}"`, - ].join(" "); + ].join(","); }; diff --git a/src/shared/utils/webhook.ts b/src/shared/utils/webhook.ts index 359f1ba51..c83f589fd 100644 --- a/src/shared/utils/webhook.ts +++ b/src/shared/utils/webhook.ts @@ -24,6 +24,7 @@ const generateAuthorization = (args: { body: Record; }): string => { const { webhook, timestamp, body } = args; + if (env.ENABLE_CUSTOM_HMAC_AUTH) { assert( env.CUSTOM_HMAC_AUTH_CLIENT_ID, @@ -43,6 +44,7 @@ const generateAuthorization = (args: { clientSecret: env.CUSTOM_HMAC_AUTH_CLIENT_SECRET, }); } + return `Bearer ${webhook.secret}`; }; @@ -53,21 +55,16 @@ export const generateRequestHeaders = (args: { }): HeadersInit => { const { webhook, body, timestamp } = args; - const headers: HeadersInit = { + const timestampSeconds = Math.floor(timestamp.getTime() / 1000); + const signature = generateSignature(body, timestampSeconds, webhook.secret); + const authorization = generateAuthorization({ webhook, timestamp, body }); + return { Accept: "application/json", "Content-Type": "application/json", + Authorization: authorization, + "x-engine-signature": signature, + "x-engine-timestamp": timestampSeconds.toString(), }; - - if (webhook.secret) { - const timestampSeconds = Math.floor(timestamp.getTime() / 1000); - const signature = generateSignature(body, timestampSeconds, webhook.secret); - - headers.Authorization = `Bearer ${webhook.secret}`; - headers["x-engine-signature"] = signature; - headers["x-engine-timestamp"] = timestampSeconds.toString(); - } - - return headers; }; export interface WebhookResponse { @@ -88,8 +85,8 @@ export const sendWebhookRequest = async ( config.mtlsCertificate && config.mtlsPrivateKey ? new Agent({ connect: { - cert: decrypt(config.mtlsCertificate), - key: decrypt(config.mtlsPrivateKey), + cert: config.mtlsCertificate, + key: config.mtlsPrivateKey, // Validate the server's certificate. rejectUnauthorized: true, }, @@ -101,6 +98,7 @@ export const sendWebhookRequest = async ( body, timestamp: new Date(), }); + const resp = await fetch(webhook.url, { method: "POST", headers: headers, From 4c27b64d3f8cfb60aac471db48909d8de660a88d Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Thu, 23 Jan 2025 22:20:23 +0800 Subject: [PATCH 6/7] remove async --- src/server/routes/configuration/auth/update.ts | 4 ++-- src/shared/utils/webhook.ts | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/server/routes/configuration/auth/update.ts b/src/server/routes/configuration/auth/update.ts index 9ae70d081..a482d60c9 100644 --- a/src/server/routes/configuration/auth/update.ts +++ b/src/server/routes/configuration/auth/update.ts @@ -43,8 +43,8 @@ export async function updateAuthConfiguration(fastify: FastifyInstance) { if (mtlsCertificate) { if ( - !mtlsCertificate.startsWith("-----BEGIN CERTIFICATE-----\n") || - !mtlsCertificate.endsWith("\n-----END CERTIFICATE-----") + !mtlsCertificate.includes("-----BEGIN CERTIFICATE-----\n") || + !mtlsCertificate.includes("\n-----END CERTIFICATE-----") ) { throw createCustomError( "Invalid mtlsCertificate.", diff --git a/src/shared/utils/webhook.ts b/src/shared/utils/webhook.ts index c83f589fd..fc497d360 100644 --- a/src/shared/utils/webhook.ts +++ b/src/shared/utils/webhook.ts @@ -3,7 +3,6 @@ import assert from "node:assert"; import crypto, { randomUUID } from "node:crypto"; import { Agent, fetch } from "undici"; import { getConfig } from "./cache/get-config"; -import { decrypt } from "./crypto"; import { env } from "./env"; import { prettifyError } from "./error"; import { generateSecretHmac256 } from "./custom-auth-header"; @@ -48,11 +47,11 @@ const generateAuthorization = (args: { return `Bearer ${webhook.secret}`; }; -export const generateRequestHeaders = (args: { +export function generateRequestHeaders(args: { webhook: Webhooks; body: Record; timestamp: Date; -}): HeadersInit => { +}): HeadersInit { const { webhook, body, timestamp } = args; const timestampSeconds = Math.floor(timestamp.getTime() / 1000); @@ -65,7 +64,7 @@ export const generateRequestHeaders = (args: { "x-engine-signature": signature, "x-engine-timestamp": timestampSeconds.toString(), }; -}; +} export interface WebhookResponse { ok: boolean; @@ -93,7 +92,7 @@ export const sendWebhookRequest = async ( }) : undefined; - const headers = await generateRequestHeaders({ + const headers = generateRequestHeaders({ webhook, body, timestamp: new Date(), From 2c7c0bf9e4a69a4c7ca035ccf605c7aa3c6cb1cd Mon Sep 17 00:00:00 2001 From: Phillip Ho Date: Thu, 23 Jan 2025 22:34:56 +0800 Subject: [PATCH 7/7] remove unneeded changes --- src/prisma/schema.prisma | 17 ++--- .../routes/configuration/auth/update.ts | 3 +- .../db/configuration/get-configuration.ts | 14 +++- src/shared/db/webhooks/create-webhook.ts | 24 ++++-- src/shared/utils/crypto.ts | 12 +-- src/shared/utils/webhook.ts | 12 +-- tests/unit/chain.test.ts | 73 ++++++++++++++++--- 7 files changed, 113 insertions(+), 42 deletions(-) diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index c728533ba..44608303b 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -179,15 +179,14 @@ model Transactions { } model Webhooks { - id Int @id @default(autoincrement()) @map("id") - name String? @map("name") - url String @map("url") - secret String @map("secret") - eventType String @map("evenType") - createdAt DateTime @default(now()) @map("createdAt") - updatedAt DateTime @updatedAt @map("updatedAt") - revokedAt DateTime? @map("revokedAt") - + id Int @id @default(autoincrement()) @map("id") + name String? @map("name") + url String @map("url") + secret String @map("secret") + eventType String @map("evenType") + createdAt DateTime @default(now()) @map("createdAt") + updatedAt DateTime @updatedAt @map("updatedAt") + revokedAt DateTime? @map("revokedAt") ContractSubscriptions ContractSubscriptions[] @@map("webhooks") diff --git a/src/server/routes/configuration/auth/update.ts b/src/server/routes/configuration/auth/update.ts index a482d60c9..3a1243679 100644 --- a/src/server/routes/configuration/auth/update.ts +++ b/src/server/routes/configuration/auth/update.ts @@ -12,7 +12,8 @@ export const requestBodySchema = Type.Partial( Type.Object({ authDomain: Type.String(), mtlsCertificate: Type.String({ - description: "Engine certificate used for outbound mTLS requests.", + description: + "Engine certificate used for outbound mTLS requests. Must provide the full certificate chain.", }), mtlsPrivateKey: Type.String({ description: "Engine private key used for outbound mTLS requests.", diff --git a/src/shared/db/configuration/get-configuration.ts b/src/shared/db/configuration/get-configuration.ts index 7304de3ea..71776b87b 100644 --- a/src/shared/db/configuration/get-configuration.ts +++ b/src/shared/db/configuration/get-configuration.ts @@ -72,7 +72,10 @@ const toParsedConfig = async (config: Configuration): Promise => { // TODO: Remove backwards compatibility with next breaking change if (awsAccessKeyId && awsSecretAccessKey && awsRegion) { // First try to load the aws secret using the encryption password - let decryptedSecretAccessKey = decrypt(awsSecretAccessKey); + let decryptedSecretAccessKey = decrypt( + awsSecretAccessKey, + env.ENCRYPTION_PASSWORD, + ); // If that fails, try to load the aws secret using the thirdweb api secret key if (!awsSecretAccessKey) { @@ -112,7 +115,10 @@ const toParsedConfig = async (config: Configuration): Promise => { // TODO: Remove backwards compatibility with next breaking change if (gcpApplicationCredentialEmail && gcpApplicationCredentialPrivateKey) { // First try to load the gcp secret using the encryption password - let decryptedGcpKey = decrypt(gcpApplicationCredentialPrivateKey); + let decryptedGcpKey = decrypt( + gcpApplicationCredentialPrivateKey, + env.ENCRYPTION_PASSWORD, + ); // If that fails, try to load the gcp secret using the thirdweb api secret key if (!gcpApplicationCredentialPrivateKey) { @@ -167,10 +173,10 @@ const toParsedConfig = async (config: Configuration): Promise => { legacyWalletType_removeInNextBreakingChange, }, mtlsCertificate: config.mtlsCertificateEncrypted - ? decrypt(config.mtlsCertificateEncrypted) + ? decrypt(config.mtlsCertificateEncrypted, env.ENCRYPTION_PASSWORD) : null, mtlsPrivateKey: config.mtlsPrivateKeyEncrypted - ? decrypt(config.mtlsPrivateKeyEncrypted) + ? decrypt(config.mtlsPrivateKeyEncrypted, env.ENCRYPTION_PASSWORD) : null, }; }; diff --git a/src/shared/db/webhooks/create-webhook.ts b/src/shared/db/webhooks/create-webhook.ts index 0b56fc771..ee07d770f 100644 --- a/src/shared/db/webhooks/create-webhook.ts +++ b/src/shared/db/webhooks/create-webhook.ts @@ -1,17 +1,29 @@ -import type { Prisma, Webhooks } from "@prisma/client"; +import type { Webhooks } from "@prisma/client"; import { createHash, randomBytes } from "node:crypto"; +import type { WebhooksEventTypes } from "../../schemas/webhooks"; import { prisma } from "../client"; -export const insertWebhook = async ( - args: Omit, -): Promise => { - // Generate a webhook secret. +interface CreateWebhooksParams { + url: string; + name?: string; + eventType: WebhooksEventTypes; +} + +export const insertWebhook = async ({ + url, + name, + eventType, +}: CreateWebhooksParams): Promise => { + // generate random bytes const bytes = randomBytes(4096); + // hash the bytes to create the secret (this will not be stored by itself) const secret = createHash("sha512").update(bytes).digest("base64url"); return prisma.webhooks.create({ data: { - ...args, + url, + name, + eventType, secret, }, }); diff --git a/src/shared/utils/crypto.ts b/src/shared/utils/crypto.ts index 2fe27aef1..a6e8c4e8a 100644 --- a/src/shared/utils/crypto.ts +++ b/src/shared/utils/crypto.ts @@ -2,19 +2,19 @@ import CryptoJS from "crypto-js"; import crypto from "node:crypto"; import { env } from "./env"; -export const encrypt = (data: string): string => { +export function encrypt(data: string): string { return CryptoJS.AES.encrypt(data, env.ENCRYPTION_PASSWORD).toString(); -}; +} -export const decrypt = (data: string, password = env.ENCRYPTION_PASSWORD) => { +export function decrypt(data: string, password: string) { return CryptoJS.AES.decrypt(data, password).toString(CryptoJS.enc.Utf8); -}; +} -export const isWellFormedPublicKey = (key: string) => { +export function isWellFormedPublicKey(key: string) { try { crypto.createPublicKey(key); return true; } catch (_e) { return false; } -}; +} diff --git a/src/shared/utils/webhook.ts b/src/shared/utils/webhook.ts index fc497d360..1997502e6 100644 --- a/src/shared/utils/webhook.ts +++ b/src/shared/utils/webhook.ts @@ -7,21 +7,21 @@ import { env } from "./env"; import { prettifyError } from "./error"; import { generateSecretHmac256 } from "./custom-auth-header"; -const generateSignature = ( +function generateSignature( body: Record, timestampSeconds: number, secret: string, -): string => { +): string { const _body = JSON.stringify(body); const payload = `${timestampSeconds}.${_body}`; return crypto.createHmac("sha256", secret).update(payload).digest("hex"); -}; +} -const generateAuthorization = (args: { +function generateAuthorization(args: { webhook: Webhooks; timestamp: Date; body: Record; -}): string => { +}): string { const { webhook, timestamp, body } = args; if (env.ENABLE_CUSTOM_HMAC_AUTH) { @@ -45,7 +45,7 @@ const generateAuthorization = (args: { } return `Bearer ${webhook.secret}`; -}; +} export function generateRequestHeaders(args: { webhook: Webhooks; diff --git a/tests/unit/chain.test.ts b/tests/unit/chain.test.ts index 95c23b97e..9db0fca24 100644 --- a/tests/unit/chain.test.ts +++ b/tests/unit/chain.test.ts @@ -11,6 +11,8 @@ vi.mock("../utils/cache/getConfig"); vi.mock("@thirdweb-dev/chains"); const mockGetConfig = vi.mocked(getConfig); + +const mockGetChainByChainIdAsync = vi.mocked(getChainByChainIdAsync); const mockGetChainBySlugAsync = vi.mocked(getChainBySlugAsync); describe("getChainIdFromChain", () => { @@ -19,16 +21,65 @@ describe("getChainIdFromChain", () => { vi.clearAllMocks(); }); - it("should return the chainId from chainOverrides if input is an id", async () => { + it("should return the chainId from chainOverrides if it exists by slug", async () => { // @ts-ignore mockGetConfig.mockResolvedValueOnce({ - chainOverridesParsed: [ + chainOverrides: JSON.stringify([ { - id: 137, - name: "Polygon", - rpc: "https://test-rpc-url.com", + slug: "Polygon", + chainId: 137, }, - ], + ]), + }); + + const result = await getChainIdFromChain("Polygon"); + + expect(result).toBe(137); + expect(getChainByChainIdAsync).not.toHaveBeenCalled(); + expect(getChainBySlugAsync).not.toHaveBeenCalled(); + }); + + it("should return the chainId from chainOverrides if it exists by slug, case-insensitive", async () => { + // @ts-ignore + mockGetConfig.mockResolvedValueOnce({ + chainOverrides: JSON.stringify([ + { + slug: "Polygon", + chainId: 137, + }, + ]), + }); + + const result = await getChainIdFromChain("polygon"); + + expect(result).toBe(137); + expect(getChainByChainIdAsync).not.toHaveBeenCalled(); + expect(getChainBySlugAsync).not.toHaveBeenCalled(); + }); + + it("should return the chainId from chainOverrides if it exists", async () => { + // @ts-ignore + mockGetConfig.mockResolvedValueOnce({ + chainOverrides: JSON.stringify([ + { + slug: "Polygon", + chainId: 137, + }, + ]), + }); + + const result = await getChainIdFromChain("Polygon"); + + expect(result).toBe(137); + expect(getChainByChainIdAsync).not.toHaveBeenCalled(); + expect(getChainBySlugAsync).not.toHaveBeenCalled(); + }); + + it("should return the chainId from getChainByChainIdAsync if chain is a valid numeric string", async () => { + // @ts-ignore + mockGetChainByChainIdAsync.mockResolvedValueOnce({ + name: "Polygon", + chainId: 137, }); const result = await getChainIdFromChain("137"); @@ -38,17 +89,20 @@ describe("getChainIdFromChain", () => { expect(getChainBySlugAsync).not.toHaveBeenCalled(); }); - it("should return the chainId from getChainByChainIdAsync if input is a slug", async () => { + it("should return the chainId from getChainBySlugAsync if chain is a valid string", async () => { + // @ts-ignore + mockGetConfig.mockResolvedValueOnce({}); // @ts-ignore mockGetChainBySlugAsync.mockResolvedValueOnce({ name: "Polygon", chainId: 137, - status: "active", }); const result = await getChainIdFromChain("Polygon"); expect(result).toBe(137); + expect(getChainBySlugAsync).toHaveBeenCalledWith("polygon"); + expect(getChainByChainIdAsync).not.toHaveBeenCalled(); }); it("should throw an error for an invalid chain", async () => { @@ -56,8 +110,7 @@ describe("getChainIdFromChain", () => { mockGetConfig.mockResolvedValueOnce({}); await expect(getChainIdFromChain("not_a_real_chain")).rejects.toEqual({ - message: - "Invalid chain: not_a_real_chain. If this is a custom chain, add it to chain overrides.", + message: "Chain not_a_real_chain is not found", statusCode: 400, code: "INVALID_CHAIN", });