Skip to content

Commit

Permalink
feat: support mTLS certificate upload (#835)
Browse files Browse the repository at this point in the history
* feat: Add mTLS and custom HMAC support to webhoks

* remove resetNonce

* use env vars for client id/secret

* update tests

* debug steps

* remove async

* remove unneeded changes
  • Loading branch information
arcoraven authored Jan 25, 2025
1 parent b468f5d commit be8b644
Show file tree
Hide file tree
Showing 18 changed files with 310 additions and 86 deletions.
14 changes: 7 additions & 7 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
TEST_GCP_KMS_RESOURCE_PATH="UNIMPLEMENTED"
TEST_GCP_KMS_EMAIL="UNIMPLEMENTED"
TEST_GCP_KMS_PK="UNIMPLEMENTED"
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"prom-client": "^15.1.3",
"superjson": "^2.2.1",
"thirdweb": "^5.83.0",
"undici": "^6.20.1",
"uuid": "^9.0.1",
"viem": "^2.21.54",
"winston": "^3.14.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "configuration" ADD COLUMN "mtlsCertificateEncrypted" TEXT,
ADD COLUMN "mtlsPrivateKeyEncrypted" TEXT;
3 changes: 3 additions & 0 deletions src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ model Configuration {
accessControlAllowOrigin String @default("https://thirdweb.com,https://embed.ipfscdn.io") @map("accessControlAllowOrigin")
ipAllowlist String[] @default([]) @map("ipAllowlist")
clearCacheCronSchedule String @default("*/30 * * * * *") @map("clearCacheCronSchedule")
// mTLS support
mtlsCertificateEncrypted String?
mtlsPrivateKeyEncrypted String?
@@map("configuration")
}
Expand Down
10 changes: 6 additions & 4 deletions src/server/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import {
type ThirdwebAuthUser,
} from "@thirdweb-dev/auth/fastify";
import { AsyncWallet } from "@thirdweb-dev/wallets/evm/wallets/async";
import { createHash } from "node: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 "../../shared/db/permissions/get-permissions";
import { createToken } from "../../shared/db/tokens/create-token";
Expand Down Expand Up @@ -123,7 +123,8 @@ export async function withAuth(server: FastifyInstance) {
}
// Allow this request to proceed.
return;
}if (error) {
}
if (error) {
message = error;
}
} catch (err: unknown) {
Expand Down Expand Up @@ -172,10 +173,11 @@ export const onRequest = async ({
const authWallet = await getAuthWallet();
if (publicKey === (await authWallet.getAddress())) {
return await handleAccessToken(jwt, req, getUser);
}if (publicKey === THIRDWEB_DASHBOARD_ISSUER) {
}
if (publicKey === THIRDWEB_DASHBOARD_ISSUER) {
return await handleDashboardAuth(jwt);
}
return await handleKeypairAuth({ jwt, req, publicKey });
return await handleKeypairAuth({ jwt, req, publicKey });
}

// Get the public key hash from the `kid` header.
Expand Down
10 changes: 7 additions & 3 deletions src/server/routes/configuration/auth/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { standardResponseSchema } from "../../../schemas/shared-api-schemas";

export const responseBodySchema = Type.Object({
result: Type.Object({
domain: Type.String(),
authDomain: Type.String(),
mtlsCertificate: Type.Union([Type.String(), Type.Null()]),
// Do not return mtlsPrivateKey.
}),
});

Expand All @@ -27,10 +29,12 @@ export async function getAuthConfiguration(fastify: FastifyInstance) {
},
},
handler: async (_req, res) => {
const config = await getConfig();
const { authDomain, mtlsCertificate } = await getConfig();

res.status(StatusCodes.OK).send({
result: {
domain: config.authDomain,
authDomain,
mtlsCertificate,
},
});
},
Expand Down
55 changes: 50 additions & 5 deletions src/server/routes/configuration/auth/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ import { updateConfiguration } from "../../../../shared/db/configuration/update-
import { getConfig } from "../../../../shared/utils/cache/get-config";
import { standardResponseSchema } from "../../../schemas/shared-api-schemas";
import { responseBodySchema } from "./get";
import { createCustomError } from "../../../middleware/error";
import { encrypt } from "../../../../shared/utils/crypto";

export const requestBodySchema = Type.Object({
domain: Type.String(),
});
export const requestBodySchema = Type.Partial(
Type.Object({
authDomain: Type.String(),
mtlsCertificate: Type.String({
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.",
}),
}),
);

export async function updateAuthConfiguration(fastify: FastifyInstance) {
fastify.route<{
Expand All @@ -29,15 +40,49 @@ export async function updateAuthConfiguration(fastify: FastifyInstance) {
},
},
handler: async (req, res) => {
const { authDomain, mtlsCertificate, mtlsPrivateKey } = req.body;

if (mtlsCertificate) {
if (
!mtlsCertificate.includes("-----BEGIN CERTIFICATE-----\n") ||
!mtlsCertificate.includes("\n-----END CERTIFICATE-----")
) {
throw createCustomError(
"Invalid mtlsCertificate.",
StatusCodes.BAD_REQUEST,
"INVALID_MTLS_CERTIFICATE",
);
}
}
if (mtlsPrivateKey) {
if (
!mtlsPrivateKey.startsWith("-----BEGIN PRIVATE KEY-----\n") ||
!mtlsPrivateKey.endsWith("\n-----END PRIVATE KEY-----")
) {
throw createCustomError(
"Invalid mtlsPrivateKey.",
StatusCodes.BAD_REQUEST,
"INVALID_MTLS_PRIVATE_KEY",
);
}
}

await updateConfiguration({
authDomain: req.body.domain,
authDomain,
mtlsCertificateEncrypted: mtlsCertificate
? encrypt(mtlsCertificate)
: undefined,
mtlsPrivateKeyEncrypted: mtlsPrivateKey
? encrypt(mtlsPrivateKey)
: undefined,
});

const config = await getConfig(false);

res.status(StatusCodes.OK).send({
result: {
domain: config.authDomain,
authDomain: config.authDomain,
mtlsCertificate: config.mtlsCertificate,
},
});
},
Expand Down
2 changes: 1 addition & 1 deletion src/server/routes/webhooks/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export async function createWebhookRoute(fastify: FastifyInstance) {
method: "POST",
url: "/webhooks/create",
schema: {
summary: "Create a webhook",
summary: "Create webhook",
description:
"Create a webhook to call when a specific Engine event occurs.",
tags: ["Webhooks"],
Expand Down
6 changes: 6 additions & 0 deletions src/shared/db/configuration/get-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ const toParsedConfig = async (config: Configuration): Promise<ParsedConfig> => {
gcp: gcpWalletConfiguration,
legacyWalletType_removeInNextBreakingChange,
},
mtlsCertificate: config.mtlsCertificateEncrypted
? decrypt(config.mtlsCertificateEncrypted, env.ENCRYPTION_PASSWORD)
: null,
mtlsPrivateKey: config.mtlsPrivateKeyEncrypted
? decrypt(config.mtlsPrivateKeyEncrypted, env.ENCRYPTION_PASSWORD)
: null,
};
};

Expand Down
2 changes: 1 addition & 1 deletion src/shared/db/configuration/update-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { encrypt } from "../../utils/crypto";
import { prisma } from "../client";

export const updateConfiguration = async (
data: Prisma.ConfigurationUpdateArgs["data"],
data: Prisma.ConfigurationUpdateInput,
) => {
return prisma.configuration.update({
where: {
Expand Down
2 changes: 1 addition & 1 deletion src/shared/db/webhooks/create-webhook.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Webhooks } from "@prisma/client";
import { createHash, randomBytes } from "crypto";
import { createHash, randomBytes } from "node:crypto";
import type { WebhooksEventTypes } from "../../schemas/webhooks";
import { prisma } from "../client";

Expand Down
4 changes: 4 additions & 0 deletions src/shared/schemas/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface ParsedConfig
| "gcpApplicationCredentialEmail"
| "gcpApplicationCredentialPrivateKey"
| "contractSubscriptionsRetryDelaySeconds"
| "mtlsCertificateEncrypted"
| "mtlsPrivateKeyEncrypted"
> {
walletConfiguration: {
aws: AwsWalletConfiguration | null;
Expand All @@ -41,4 +43,6 @@ export interface ParsedConfig
};
contractSubscriptionsRequeryDelaySeconds: string;
chainOverridesParsed: Chain[];
mtlsCertificate: string | null;
mtlsPrivateKey: string | null;
}
14 changes: 7 additions & 7 deletions src/shared/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import crypto from "crypto";
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: string) => {
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;
}
};
}
63 changes: 63 additions & 0 deletions src/shared/utils/custom-auth-header.ts
Original file line number Diff line number Diff line change
@@ -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 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.
* @returns
*/
export const generateSecretHmac256 = (args: {
webhookUrl: string;
body: Record<string, unknown>;
timestamp: Date;
nonce: string;
clientId: string;
clientSecret: string;
}): string => {
const { webhookUrl, body, timestamp, 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 = timestamp.getTime(); // 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(",");
};
23 changes: 9 additions & 14 deletions src/shared/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -68,7 +56,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),
REDIS_URL: z.string(),
SEND_TRANSACTION_QUEUE_CONCURRENCY: z.coerce.number().default(200),
CONFIRM_TRANSACTION_QUEUE_CONCURRENCY: z.coerce.number().default(200),
Expand Down Expand Up @@ -99,6 +86,11 @@ export const env = createEnv({
// Sets the number of recent nonces to map to queue IDs.
NONCE_MAP_COUNT: z.coerce.number().default(10_000),

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(),

/**
* Experimental env vars. These may be renamed or removed in future non-major releases.
*/
Expand Down Expand Up @@ -130,7 +122,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,
REDIS_URL: process.env.REDIS_URL,
SEND_TRANSACTION_QUEUE_CONCURRENCY:
process.env.SEND_TRANSACTION_QUEUE_CONCURRENCY,
Expand All @@ -150,6 +141,10 @@ export const env = createEnv({
process.env.EXPERIMENTAL__MAX_GAS_PRICE_WEI,
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,
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(
Expand Down
Loading

0 comments on commit be8b644

Please sign in to comment.