diff --git a/.changeset/icy-islands-dress.md b/.changeset/icy-islands-dress.md new file mode 100644 index 00000000000..d099bb7f5a1 --- /dev/null +++ b/.changeset/icy-islands-dress.md @@ -0,0 +1,5 @@ +--- +"@thirdweb-dev/service-utils": patch +--- + +Add encryption utilities diff --git a/.changeset/loud-mails-peel.md b/.changeset/loud-mails-peel.md new file mode 100644 index 00000000000..5f462db9409 --- /dev/null +++ b/.changeset/loud-mails-peel.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Make vault access token optional diff --git a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx index 01b23707ae3..97ec1dd59b8 100644 --- a/apps/dashboard/src/@/components/project/create-project-modal/index.tsx +++ b/apps/dashboard/src/@/components/project/create-project-modal/index.tsx @@ -38,6 +38,7 @@ import { createProjectClient } from "@/hooks/useApi"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { projectDomainsSchema, projectNameSchema } from "@/schema/validations"; import { toArrFromList } from "@/utils/string"; +import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; const ALL_PROJECT_SERVICES = SERVICES.filter( (srv) => srv.name !== "relayer" && srv.name !== "chainsaw", @@ -63,6 +64,10 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => { { const res = await createProjectClient(props.teamId, params); + await createVaultAccountAndAccessToken({ + project: res.project, + projectSecretKey: res.secret, + }); return { project: res.project, secret: res.secret, diff --git a/apps/dashboard/src/@/hooks/useApi.ts b/apps/dashboard/src/@/hooks/useApi.ts index 5f7e75e9431..ca7d4864167 100644 --- a/apps/dashboard/src/@/hooks/useApi.ts +++ b/apps/dashboard/src/@/hooks/useApi.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useActiveAccount } from "thirdweb/react"; import { apiServerProxy } from "@/actions/proxies"; import type { Project } from "@/api/projects"; +import { createVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client"; import { accountKeys, authorizedWallets } from "../query-keys/cache-keys"; // FIXME: We keep repeating types, API server should provide them @@ -311,26 +312,35 @@ export type RotateSecretKeyAPIReturnType = { data: { secret: string; secretMasked: string; + secretHash: string; }; }; -export async function rotateSecretKeyClient(params: { - teamId: string; - projectId: string; -}) { +export async function rotateSecretKeyClient(params: { project: Project }) { const res = await apiServerProxy({ body: JSON.stringify({}), headers: { "Content-Type": "application/json", }, method: "POST", - pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`, + pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`, }); if (!res.ok) { throw new Error(res.error); } + try { + // if the project has a vault admin key, rotate it as well + await createVaultAccountAndAccessToken({ + project: params.project, + projectSecretKey: res.data.data.secret, + projectSecretHash: res.data.data.secretHash, + }); + } catch (error) { + console.error("Failed to rotate vault admin key", error); + } + return res.data; } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx index c7b42c1c6f8..44bb77edba4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/ProjectFTUX.tsx @@ -62,9 +62,8 @@ function IntegrateAPIKeySection({ {secretKeyMasked && ( )} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/SecretKeySection.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/SecretKeySection.tsx index 0dd204a0820..dcb5fa6f7f9 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/SecretKeySection.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/components/ProjectFTUX/SecretKeySection.tsx @@ -1,13 +1,13 @@ "use client"; import { useState } from "react"; +import type { Project } from "@/api/projects"; import { rotateSecretKeyClient } from "@/hooks/useApi"; import { RotateSecretKeyButton } from "../../settings/ProjectGeneralSettingsPage"; export function SecretKeySection(props: { secretKeyMasked: string; - teamId: string; - projectId: string; + project: Project; }) { const [secretKeyMasked, setSecretKeyMasked] = useState(props.secretKeyMasked); @@ -31,8 +31,7 @@ export function SecretKeySection(props: { }} rotateSecretKey={async () => { return rotateSecretKeyClient({ - projectId: props.projectId, - teamId: props.teamId, + project: props.project, }); }} /> diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.stories.tsx index ee6a3ea0c6c..4dec460db9f 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.stories.tsx @@ -47,6 +47,7 @@ function Story(props: { isOwnerAccount: boolean }) { data: { secret: `sk_${new Array(86).fill("x").join("")}`, secretMasked: "sk_123...4567", + secretHash: "sk_123...4567", }, }; }} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx index 5446c232d2f..8a55ee8f95e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/settings/ProjectGeneralSettingsPage.tsx @@ -135,8 +135,7 @@ export function ProjectGeneralSettingsPage(props: { project={props.project} rotateSecretKey={async () => { return rotateSecretKeyClient({ - projectId: props.project.id, - teamId: props.project.teamId, + project: props.project, }); }} showNebulaSettings={props.showNebulaSettings} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx index 01435e37191..dca92156fd0 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/analytics/ftux.client.tsx @@ -1,6 +1,6 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import type { ThirdwebClient } from "thirdweb"; import type { Project } from "@/api/projects"; import { type Step, StepsCard } from "@/components/blocks/StepsCard"; @@ -9,7 +9,6 @@ import { CreateVaultAccountButton } from "../../vault/components/create-vault-ac import CreateServerWallet from "../server-wallets/components/create-server-wallet.client"; import type { Wallet } from "../server-wallets/wallet-table/types"; import { SendTestTransaction } from "./send-test-tx.client"; -import { deleteUserAccessToken } from "./utils"; interface Props { managementAccessToken: string | undefined; @@ -19,27 +18,12 @@ interface Props { teamSlug: string; testTxWithWallet?: string | undefined; client: ThirdwebClient; + isManagedVault: boolean; } export const EngineChecklist: React.FC = (props) => { - const [userAccessToken, setUserAccessToken] = useState(); - const finalSteps = useMemo(() => { const steps: Step[] = []; - steps.push({ - children: ( - setUserAccessToken(token)} - project={props.project} - teamSlug={props.teamSlug} - /> - ), - completed: !!props.managementAccessToken, - description: - "Vault is thirdweb's key management system. It allows you to create secure server wallets and manage access tokens.", - showCompletedChildren: false, - title: "Create a Vault Admin Account", - }); steps.push({ children: ( = (props) => { teamSlug={props.teamSlug} /> ), - completed: props.wallets.length > 0, + completed: props.wallets.length > 0 || props.hasTransactions, description: "Server wallets are smart wallets, they don't require any gas funds to send transactions.", showCompletedChildren: false, @@ -58,10 +42,10 @@ export const EngineChecklist: React.FC = (props) => { steps.push({ children: ( ), @@ -78,9 +62,9 @@ export const EngineChecklist: React.FC = (props) => { props.project, props.wallets, props.hasTransactions, - userAccessToken, props.teamSlug, props.client, + props.isManagedVault, ]); const isComplete = useMemo( @@ -91,10 +75,10 @@ export const EngineChecklist: React.FC = (props) => { if (props.testTxWithWallet) { return ( @@ -102,8 +86,6 @@ export const EngineChecklist: React.FC = (props) => { } if (finalSteps.length === 0 || isComplete) { - // clear token from local storage after FTUX is complete - deleteUserAccessToken(props.project.id); return null; } return ( @@ -122,10 +104,7 @@ function CreateVaultAccountStep(props: { }) { return (
- + + {/* Secret Key Input Modal */} + + + + Create Your Vault + + +
+
+ +

+ Lets you use your project secret key to access your vault like + any other thirdweb service (recommended). +

+ setSecretKey(e.target.value)} + disabled={ + initialiseProjectWithVaultMutation.isPending || + manageSelfChecked + } + /> +

+ Your project secret key was generated when you created your + project. If you lost it, you can regenerate one in your project + settings. +

+
+ +
+
+ + setManageSelfChecked(!!v)} + disabled={initialiseProjectWithVaultMutation.isPending} + /> + I want to manage my Vault keys myself (advanced) + +
+ + {errorMessage && ( + + {errorMessage} + + )} +
+ +
+ + +
+
+
+ + {/* Keys Display Modal */} @@ -212,32 +279,6 @@ export function CreateVaultAccountButton(props: {

- - {/*
-

- Vault Access Token -

-
- -

- This access token is used to sign transactions and - messages from your backend. Can be revoked and recreated - with your admin key. -

-
-
*/} Secure your admin key @@ -285,6 +326,6 @@ export function CreateVaultAccountButton(props: { ) : null} - + ); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/key-management.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/key-management.tsx index f362e3033f6..176f6a99021 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/key-management.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/key-management.tsx @@ -9,15 +9,17 @@ import RotateAdminKeyButton from "./rotate-admin-key.client"; export function KeyManagement({ maskedAdminKey, project, + isManagedVault, }: { maskedAdminKey?: string; project: Project; + isManagedVault: boolean; }) { return (
{!maskedAdminKey && } - {maskedAdminKey && ( + {maskedAdminKey && !isManagedVault && ( <>
@@ -37,13 +39,42 @@ export function KeyManagement({ {maskedAdminKey}

- +
)} + + {isManagedVault && ( +
+
+

+ Managed Vault +

+

+ Your vault is currently managed by Thirdweb so you can access it + via you project secret key. You can eject and manage your own + vault keys at any time. Doing so means you'll need to pass your + own vault access token to the transactions API additionally to + your project secret key. +

+
+
+
+

+ {maskedAdminKey} +

+
+ +
+
+
+ )}
); } diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx index 3a4733f3919..473b2a95255 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx @@ -1,12 +1,12 @@ "use client"; import { useMutation } from "@tanstack/react-query"; -import { rotateServiceAccount } from "@thirdweb-dev/vault-sdk"; import { CheckIcon, CircleAlertIcon, DownloadIcon, Loader2Icon, + LogOutIcon, RefreshCcwIcon, } from "lucide-react"; import { useState } from "react"; @@ -27,13 +27,14 @@ import { Spinner } from "@/components/ui/Spinner/Spinner"; import { useDashboardRouter } from "@/lib/DashboardRouter"; import { cn } from "@/lib/utils"; import { - createManagementAccessToken, - createWalletAccessToken, - initVaultClient, + createVaultAccountAndAccessToken, maskSecret, } from "../../transactions/lib/vault.client"; -export default function RotateAdminKeyButton(props: { project: Project }) { +export default function RotateAdminKeyButton(props: { + project: Project; + isManagedVault: boolean; +}) { const [modalOpen, setModalOpen] = useState(false); const [keysConfirmed, setKeysConfirmed] = useState(false); const [keysDownloaded, setKeysDownloaded] = useState(false); @@ -41,55 +42,15 @@ export default function RotateAdminKeyButton(props: { project: Project }) { const rotateAdminKeyMutation = useMutation({ mutationFn: async () => { - const vaultClient = await initVaultClient(); - const rotationCode = props.project.services.find( - (service) => service.name === "engineCloud", - )?.rotationCode; - - if (!rotationCode) { - throw new Error("Rotation code not found"); - } - - const rotateServiceAccountRes = await rotateServiceAccount({ - client: vaultClient, - request: { - auth: { - rotationCode, - }, - }, - }); - - if (rotateServiceAccountRes.error) { - throw new Error(rotateServiceAccountRes.error.message); - } - - // need to recreate the management access token with the new admin key - const managementAccessTokenPromise = createManagementAccessToken({ - adminKey: rotateServiceAccountRes.data.newAdminKey, + // passing no secret key means we're rotating the admin key and deleting any stored keys + const result = await createVaultAccountAndAccessToken({ project: props.project, - rotationCode: rotateServiceAccountRes.data.newRotationCode, - vaultClient, }); - const userAccesTokenPromise = createWalletAccessToken({ - adminKey: rotateServiceAccountRes.data.newAdminKey, - project: props.project, - vaultClient, - }); - - const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([ - userAccesTokenPromise, - managementAccessTokenPromise, - ]); - - if (!managementAccessTokenRes.success || !userAccessTokenRes.success) { - throw new Error("Failed to create access token"); - } - return { - adminKey: rotateServiceAccountRes.data.newAdminKey, + adminKey: result.adminKey, success: true, - userAccessToken: userAccessTokenRes.data, + userAccessToken: result.walletToken, }; }, onError: (error) => { @@ -144,8 +105,12 @@ export default function RotateAdminKeyButton(props: { project: Project }) { variant="outline" > {isLoading && } - {!isLoading && } - Rotate Admin Key + {!isLoading && props.isManagedVault ? ( + + ) : ( + + )} + {props.isManagedVault ? "Eject From Managed Vault" : "Rotate Admin Key"} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx index 3869505fc2f..c5f8e95aff4 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/page.tsx @@ -22,11 +22,13 @@ export default async function VaultPage(props: { ); const maskedAdminKey = projectEngineCloudService?.maskedAdminKey; + const isManagedVault = !!projectEngineCloudService?.encryptedAdminKey; return (
diff --git a/packages/service-utils/src/core/api.ts b/packages/service-utils/src/core/api.ts index 368c9707468..672a1fe6f84 100644 --- a/packages/service-utils/src/core/api.ts +++ b/packages/service-utils/src/core/api.ts @@ -226,6 +226,8 @@ export type ProjectService = maskedAdminKey?: string | null; managementAccessToken?: string | null; rotationCode?: string | null; + encryptedAdminKey?: string | null; + encryptedWalletAccessToken?: string | null; } | ProjectBundlerService | ProjectEmbeddedWalletsService; diff --git a/packages/service-utils/src/core/encryption.ts b/packages/service-utils/src/core/encryption.ts new file mode 100644 index 00000000000..c4feb4db12d --- /dev/null +++ b/packages/service-utils/src/core/encryption.ts @@ -0,0 +1,96 @@ +export async function encrypt(secret: string, password: string) { + // Generate random salt and IV + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Derive key from password using PBKDF2 + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveBits", "deriveKey"], + ); + + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + + // Encrypt the secret + const encodedSecret = new TextEncoder().encode(secret); + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + encodedSecret, + ); + + // Combine salt, IV, and encrypted data + const result = new Uint8Array(salt.length + iv.length + encrypted.byteLength); + result.set(salt, 0); + result.set(iv, salt.length); + result.set(new Uint8Array(encrypted), salt.length + iv.length); + + // Return as base64 string + return btoa(String.fromCharCode(...result)); +} + +export async function decrypt(encryptedData: string, password: string) { + // Decode base64 string + const data = new Uint8Array( + atob(encryptedData) + .split("") + .map((char) => char.charCodeAt(0)), + ); + + // Extract salt, IV, and encrypted data + const salt = data.slice(0, 16); + const iv = data.slice(16, 28); + const encrypted = data.slice(28); + + // Derive key from password using PBKDF2 + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(password), + "PBKDF2", + false, + ["deriveBits", "deriveKey"], + ); + + const key = await crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + + // Decrypt the data + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + encrypted, + ); + + // Return as string + return new TextDecoder().decode(decrypted); +} diff --git a/packages/service-utils/src/index.ts b/packages/service-utils/src/index.ts index d32c3d55ff5..78074d711ec 100644 --- a/packages/service-utils/src/index.ts +++ b/packages/service-utils/src/index.ts @@ -15,7 +15,7 @@ export type { } from "./core/api.js"; export { fetchTeamAndProject } from "./core/api.js"; export { authorizeBundleId, authorizeDomain } from "./core/authorize/client.js"; +export { decrypt, encrypt } from "./core/encryption.js"; export { rateLimit } from "./core/rateLimit/index.js"; - export { rateLimitSlidingWindow } from "./core/rateLimit/strategies/sliding-window.js"; export * from "./core/services.js"; diff --git a/packages/thirdweb/src/engine/server-wallet.ts b/packages/thirdweb/src/engine/server-wallet.ts index bcd51f9f417..d69405caa8c 100644 --- a/packages/thirdweb/src/engine/server-wallet.ts +++ b/packages/thirdweb/src/engine/server-wallet.ts @@ -35,9 +35,10 @@ export type ServerWalletOptions = { */ client: ThirdwebClient; /** - * The vault access token to use your server wallet. + * Optional vault access token to use your server wallet. + * If not provided, the server wallet will use the project secret key to authenticate. */ - vaultAccessToken: string; + vaultAccessToken?: string; /** * The server wallet address to use for sending transactions inside engine. */ @@ -152,9 +153,11 @@ export function serverWallet(options: ServerWalletOptions): ServerWallet { const { client, vaultAccessToken, address, chain, executionOptions } = options; - const headers: HeadersInit = { - "x-vault-access-token": vaultAccessToken, - }; + const headers: HeadersInit = vaultAccessToken + ? { + "x-vault-access-token": vaultAccessToken, + } + : {}; const getExecutionOptionsWithChainId = ( chainId: number,