Skip to content

Commit be8b644

Browse files
authored
feat: support mTLS certificate upload (#835)
* 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
1 parent b468f5d commit be8b644

File tree

18 files changed

+310
-86
lines changed

18 files changed

+310
-86
lines changed

.env.test

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ ENABLE_HTTPS="true"
77
REDIS_URL="redis://127.0.0.1:6379/0"
88
THIRDWEB_API_SECRET_KEY="my-thirdweb-secret-key"
99

10-
TEST_AWS_KMS_KEY_ID=""
11-
TEST_AWS_KMS_ACCESS_KEY_ID=""
12-
TEST_AWS_KMS_SECRET_ACCESS_KEY=""
13-
TEST_AWS_KMS_REGION=""
10+
TEST_AWS_KMS_KEY_ID="UNIMPLEMENTED"
11+
TEST_AWS_KMS_ACCESS_KEY_ID="UNIMPLEMENTED"
12+
TEST_AWS_KMS_SECRET_ACCESS_KEY="UNIMPLEMENTED"
13+
TEST_AWS_KMS_REGION="UNIMPLEMENTED"
1414

15-
TEST_GCP_KMS_RESOURCE_PATH=""
16-
TEST_GCP_KMS_EMAIL=""
17-
TEST_GCP_KMS_PK=""
15+
TEST_GCP_KMS_RESOURCE_PATH="UNIMPLEMENTED"
16+
TEST_GCP_KMS_EMAIL="UNIMPLEMENTED"
17+
TEST_GCP_KMS_PK="UNIMPLEMENTED"

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"prom-client": "^15.1.3",
6969
"superjson": "^2.2.1",
7070
"thirdweb": "^5.83.0",
71+
"undici": "^6.20.1",
7172
"uuid": "^9.0.1",
7273
"viem": "^2.21.54",
7374
"winston": "^3.14.1",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- AlterTable
2+
ALTER TABLE "configuration" ADD COLUMN "mtlsCertificateEncrypted" TEXT,
3+
ADD COLUMN "mtlsPrivateKeyEncrypted" TEXT;

src/prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ model Configuration {
5050
accessControlAllowOrigin String @default("https://thirdweb.com,https://embed.ipfscdn.io") @map("accessControlAllowOrigin")
5151
ipAllowlist String[] @default([]) @map("ipAllowlist")
5252
clearCacheCronSchedule String @default("*/30 * * * * *") @map("clearCacheCronSchedule")
53+
// mTLS support
54+
mtlsCertificateEncrypted String?
55+
mtlsPrivateKeyEncrypted String?
5356
5457
@@map("configuration")
5558
}

src/server/middleware/auth.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import {
55
type ThirdwebAuthUser,
66
} from "@thirdweb-dev/auth/fastify";
77
import { AsyncWallet } from "@thirdweb-dev/wallets/evm/wallets/async";
8-
import { createHash } from "node:crypto";
98
import type { FastifyInstance } from "fastify";
109
import type { FastifyRequest } from "fastify/types/request";
1110
import jsonwebtoken, { type JwtPayload } from "jsonwebtoken";
11+
import { createHash } from "node:crypto";
1212
import { validate as uuidValidate } from "uuid";
1313
import { getPermissions } from "../../shared/db/permissions/get-permissions";
1414
import { createToken } from "../../shared/db/tokens/create-token";
@@ -123,7 +123,8 @@ export async function withAuth(server: FastifyInstance) {
123123
}
124124
// Allow this request to proceed.
125125
return;
126-
}if (error) {
126+
}
127+
if (error) {
127128
message = error;
128129
}
129130
} catch (err: unknown) {
@@ -172,10 +173,11 @@ export const onRequest = async ({
172173
const authWallet = await getAuthWallet();
173174
if (publicKey === (await authWallet.getAddress())) {
174175
return await handleAccessToken(jwt, req, getUser);
175-
}if (publicKey === THIRDWEB_DASHBOARD_ISSUER) {
176+
}
177+
if (publicKey === THIRDWEB_DASHBOARD_ISSUER) {
176178
return await handleDashboardAuth(jwt);
177179
}
178-
return await handleKeypairAuth({ jwt, req, publicKey });
180+
return await handleKeypairAuth({ jwt, req, publicKey });
179181
}
180182

181183
// Get the public key hash from the `kid` header.

src/server/routes/configuration/auth/get.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { standardResponseSchema } from "../../../schemas/shared-api-schemas";
66

77
export const responseBodySchema = Type.Object({
88
result: Type.Object({
9-
domain: Type.String(),
9+
authDomain: Type.String(),
10+
mtlsCertificate: Type.Union([Type.String(), Type.Null()]),
11+
// Do not return mtlsPrivateKey.
1012
}),
1113
});
1214

@@ -27,10 +29,12 @@ export async function getAuthConfiguration(fastify: FastifyInstance) {
2729
},
2830
},
2931
handler: async (_req, res) => {
30-
const config = await getConfig();
32+
const { authDomain, mtlsCertificate } = await getConfig();
33+
3134
res.status(StatusCodes.OK).send({
3235
result: {
33-
domain: config.authDomain,
36+
authDomain,
37+
mtlsCertificate,
3438
},
3539
});
3640
},

src/server/routes/configuration/auth/update.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,21 @@ import { updateConfiguration } from "../../../../shared/db/configuration/update-
55
import { getConfig } from "../../../../shared/utils/cache/get-config";
66
import { standardResponseSchema } from "../../../schemas/shared-api-schemas";
77
import { responseBodySchema } from "./get";
8+
import { createCustomError } from "../../../middleware/error";
9+
import { encrypt } from "../../../../shared/utils/crypto";
810

9-
export const requestBodySchema = Type.Object({
10-
domain: Type.String(),
11-
});
11+
export const requestBodySchema = Type.Partial(
12+
Type.Object({
13+
authDomain: Type.String(),
14+
mtlsCertificate: Type.String({
15+
description:
16+
"Engine certificate used for outbound mTLS requests. Must provide the full certificate chain.",
17+
}),
18+
mtlsPrivateKey: Type.String({
19+
description: "Engine private key used for outbound mTLS requests.",
20+
}),
21+
}),
22+
);
1223

1324
export async function updateAuthConfiguration(fastify: FastifyInstance) {
1425
fastify.route<{
@@ -29,15 +40,49 @@ export async function updateAuthConfiguration(fastify: FastifyInstance) {
2940
},
3041
},
3142
handler: async (req, res) => {
43+
const { authDomain, mtlsCertificate, mtlsPrivateKey } = req.body;
44+
45+
if (mtlsCertificate) {
46+
if (
47+
!mtlsCertificate.includes("-----BEGIN CERTIFICATE-----\n") ||
48+
!mtlsCertificate.includes("\n-----END CERTIFICATE-----")
49+
) {
50+
throw createCustomError(
51+
"Invalid mtlsCertificate.",
52+
StatusCodes.BAD_REQUEST,
53+
"INVALID_MTLS_CERTIFICATE",
54+
);
55+
}
56+
}
57+
if (mtlsPrivateKey) {
58+
if (
59+
!mtlsPrivateKey.startsWith("-----BEGIN PRIVATE KEY-----\n") ||
60+
!mtlsPrivateKey.endsWith("\n-----END PRIVATE KEY-----")
61+
) {
62+
throw createCustomError(
63+
"Invalid mtlsPrivateKey.",
64+
StatusCodes.BAD_REQUEST,
65+
"INVALID_MTLS_PRIVATE_KEY",
66+
);
67+
}
68+
}
69+
3270
await updateConfiguration({
33-
authDomain: req.body.domain,
71+
authDomain,
72+
mtlsCertificateEncrypted: mtlsCertificate
73+
? encrypt(mtlsCertificate)
74+
: undefined,
75+
mtlsPrivateKeyEncrypted: mtlsPrivateKey
76+
? encrypt(mtlsPrivateKey)
77+
: undefined,
3478
});
3579

3680
const config = await getConfig(false);
3781

3882
res.status(StatusCodes.OK).send({
3983
result: {
40-
domain: config.authDomain,
84+
authDomain: config.authDomain,
85+
mtlsCertificate: config.mtlsCertificate,
4186
},
4287
});
4388
},

src/server/routes/webhooks/create.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export async function createWebhookRoute(fastify: FastifyInstance) {
4545
method: "POST",
4646
url: "/webhooks/create",
4747
schema: {
48-
summary: "Create a webhook",
48+
summary: "Create webhook",
4949
description:
5050
"Create a webhook to call when a specific Engine event occurs.",
5151
tags: ["Webhooks"],

src/shared/db/configuration/get-configuration.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,12 @@ const toParsedConfig = async (config: Configuration): Promise<ParsedConfig> => {
172172
gcp: gcpWalletConfiguration,
173173
legacyWalletType_removeInNextBreakingChange,
174174
},
175+
mtlsCertificate: config.mtlsCertificateEncrypted
176+
? decrypt(config.mtlsCertificateEncrypted, env.ENCRYPTION_PASSWORD)
177+
: null,
178+
mtlsPrivateKey: config.mtlsPrivateKeyEncrypted
179+
? decrypt(config.mtlsPrivateKeyEncrypted, env.ENCRYPTION_PASSWORD)
180+
: null,
175181
};
176182
};
177183

src/shared/db/configuration/update-configuration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { encrypt } from "../../utils/crypto";
33
import { prisma } from "../client";
44

55
export const updateConfiguration = async (
6-
data: Prisma.ConfigurationUpdateArgs["data"],
6+
data: Prisma.ConfigurationUpdateInput,
77
) => {
88
return prisma.configuration.update({
99
where: {

src/shared/db/webhooks/create-webhook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Webhooks } from "@prisma/client";
2-
import { createHash, randomBytes } from "crypto";
2+
import { createHash, randomBytes } from "node:crypto";
33
import type { WebhooksEventTypes } from "../../schemas/webhooks";
44
import { prisma } from "../client";
55

src/shared/schemas/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export interface ParsedConfig
3333
| "gcpApplicationCredentialEmail"
3434
| "gcpApplicationCredentialPrivateKey"
3535
| "contractSubscriptionsRetryDelaySeconds"
36+
| "mtlsCertificateEncrypted"
37+
| "mtlsPrivateKeyEncrypted"
3638
> {
3739
walletConfiguration: {
3840
aws: AwsWalletConfiguration | null;
@@ -41,4 +43,6 @@ export interface ParsedConfig
4143
};
4244
contractSubscriptionsRequeryDelaySeconds: string;
4345
chainOverridesParsed: Chain[];
46+
mtlsCertificate: string | null;
47+
mtlsPrivateKey: string | null;
4448
}

src/shared/utils/crypto.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
import crypto from "crypto";
21
import CryptoJS from "crypto-js";
2+
import crypto from "node:crypto";
33
import { env } from "./env";
44

5-
export const encrypt = (data: string): string => {
5+
export function encrypt(data: string): string {
66
return CryptoJS.AES.encrypt(data, env.ENCRYPTION_PASSWORD).toString();
7-
};
7+
}
88

9-
export const decrypt = (data: string, password: string) => {
9+
export function decrypt(data: string, password: string) {
1010
return CryptoJS.AES.decrypt(data, password).toString(CryptoJS.enc.Utf8);
11-
};
11+
}
1212

13-
export const isWellFormedPublicKey = (key: string) => {
13+
export function isWellFormedPublicKey(key: string) {
1414
try {
1515
crypto.createPublicKey(key);
1616
return true;
1717
} catch (_e) {
1818
return false;
1919
}
20-
};
20+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { createHmac } from "node:crypto";
2+
3+
/**
4+
* Generates an HMAC-256 secret to set in the "Authorization" header.
5+
*
6+
* @param webhookUrl - The URL to call.
7+
* @param body - The request body.
8+
* @param timestamp - The request timestamp.
9+
* @param nonce - A unique string for this request. Should not be re-used.
10+
* @param clientId - Your application's client id.
11+
* @param clientSecret - Your application's client secret.
12+
* @returns
13+
*/
14+
export const generateSecretHmac256 = (args: {
15+
webhookUrl: string;
16+
body: Record<string, unknown>;
17+
timestamp: Date;
18+
nonce: string;
19+
clientId: string;
20+
clientSecret: string;
21+
}): string => {
22+
const { webhookUrl, body, timestamp, nonce, clientId, clientSecret } = args;
23+
24+
// Create the body hash by hashing the payload.
25+
const bodyHash = createHmac("sha256", clientSecret)
26+
.update(JSON.stringify(body), "utf8")
27+
.digest("base64");
28+
29+
// Create the signature hash by hashing the signature.
30+
const ts = timestamp.getTime(); // timestamp expected in milliseconds
31+
const httpMethod = "POST";
32+
const url = new URL(webhookUrl);
33+
const resourcePath = url.pathname;
34+
const host = url.hostname;
35+
const port = url.port
36+
? Number.parseInt(url.port)
37+
: url.protocol === "https:"
38+
? 443
39+
: 80;
40+
41+
const signature = [
42+
ts,
43+
nonce,
44+
httpMethod,
45+
resourcePath,
46+
host,
47+
port,
48+
bodyHash,
49+
"", // to insert a newline at the end
50+
].join("\n");
51+
52+
const signatureHash = createHmac("sha256", clientSecret)
53+
.update(signature, "utf8")
54+
.digest("base64");
55+
56+
return [
57+
`MAC id="${clientId}"`,
58+
`ts="${ts}"`,
59+
`nonce="${nonce}"`,
60+
`bodyhash="${bodyHash}"`,
61+
`mac="${signatureHash}"`,
62+
].join(",");
63+
};

src/shared/utils/env.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,6 @@ import { z } from "zod";
66
const path = process.env.NODE_ENV === "test" ? ".env.test" : ".env";
77
dotenv.config({ path });
88

9-
export const JsonSchema = z.string().refine(
10-
(value) => {
11-
try {
12-
JSON.parse(value);
13-
return true;
14-
} catch {
15-
return false;
16-
}
17-
},
18-
{ message: "Invalid JSON string" },
19-
);
20-
219
const boolEnvSchema = (defaultBool: boolean) =>
2210
z
2311
.string()
@@ -68,7 +56,6 @@ export const env = createEnv({
6856
.default("https://c.thirdweb.com/event"),
6957
SDK_BATCH_TIME_LIMIT: z.coerce.number().default(0),
7058
SDK_BATCH_SIZE_LIMIT: z.coerce.number().default(100),
71-
ENABLE_KEYPAIR_AUTH: boolEnvSchema(false),
7259
REDIS_URL: z.string(),
7360
SEND_TRANSACTION_QUEUE_CONCURRENCY: z.coerce.number().default(200),
7461
CONFIRM_TRANSACTION_QUEUE_CONCURRENCY: z.coerce.number().default(200),
@@ -99,6 +86,11 @@ export const env = createEnv({
9986
// Sets the number of recent nonces to map to queue IDs.
10087
NONCE_MAP_COUNT: z.coerce.number().default(10_000),
10188

89+
ENABLE_KEYPAIR_AUTH: boolEnvSchema(false),
90+
ENABLE_CUSTOM_HMAC_AUTH: boolEnvSchema(false),
91+
CUSTOM_HMAC_AUTH_CLIENT_ID: z.string().optional(),
92+
CUSTOM_HMAC_AUTH_CLIENT_SECRET: z.string().optional(),
93+
10294
/**
10395
* Experimental env vars. These may be renamed or removed in future non-major releases.
10496
*/
@@ -130,7 +122,6 @@ export const env = createEnv({
130122
CLIENT_ANALYTICS_URL: process.env.CLIENT_ANALYTICS_URL,
131123
SDK_BATCH_TIME_LIMIT: process.env.SDK_BATCH_TIME_LIMIT,
132124
SDK_BATCH_SIZE_LIMIT: process.env.SDK_BATCH_SIZE_LIMIT,
133-
ENABLE_KEYPAIR_AUTH: process.env.ENABLE_KEYPAIR_AUTH,
134125
REDIS_URL: process.env.REDIS_URL,
135126
SEND_TRANSACTION_QUEUE_CONCURRENCY:
136127
process.env.SEND_TRANSACTION_QUEUE_CONCURRENCY,
@@ -150,6 +141,10 @@ export const env = createEnv({
150141
process.env.EXPERIMENTAL__MAX_GAS_PRICE_WEI,
151142
METRICS_PORT: process.env.METRICS_PORT,
152143
METRICS_ENABLED: process.env.METRICS_ENABLED,
144+
ENABLE_KEYPAIR_AUTH: process.env.ENABLE_KEYPAIR_AUTH,
145+
ENABLE_CUSTOM_HMAC_AUTH: process.env.ENABLE_CUSTOM_HMAC_AUTH,
146+
CUSTOM_HMAC_AUTH_CLIENT_ID: process.env.CUSTOM_HMAC_AUTH_CLIENT_ID,
147+
CUSTOM_HMAC_AUTH_CLIENT_SECRET: process.env.CUSTOM_HMAC_AUTH_CLIENT_SECRET,
153148
},
154149
onValidationError: (error: ZodError) => {
155150
console.error(

0 commit comments

Comments
 (0)