diff --git a/.vscode/settings.json b/.vscode/settings.json index 775973a21e..eda6e4f469 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,6 +13,7 @@ "Cdfc", "checksummable", "chinthakagodawita", + "Ciphertext", "cjsx", "clsx", "cmdk", @@ -37,6 +38,7 @@ "fkey", "frontends", "geoip", + "healthcheck", "hookform", "hostable", "INBUCKET", @@ -44,6 +46,7 @@ "Jwks", "JWTs", "katex", + "localstack", "lucide", "Luma", "midfix", @@ -94,6 +97,7 @@ "typecheck", "typehack", "Uncapitalize", + "unhashed", "unindexed", "Unmigrated", "unsubscribers", diff --git a/apps/backend/.env b/apps/backend/.env index c62e3063b5..6be4449b21 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -58,6 +58,13 @@ STACK_S3_ACCESS_KEY_ID= STACK_S3_SECRET_ACCESS_KEY= STACK_S3_BUCKET= +# AWS configuration +STACK_AWS_REGION= +STACK_AWS_KMS_ENDPOINT= +STACK_AWS_ACCESS_KEY_ID= +STACK_AWS_SECRET_ACCESS_KEY= + + # Misc, optional STACK_ACCESS_TOKEN_EXPIRATION_TIME=# enter the expiration time for the access token here. Optional, don't specify it for default value diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 39c2a8ea2a..bebe26f78f 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -56,3 +56,9 @@ STACK_S3_REGION=us-east-1 STACK_S3_ACCESS_KEY_ID=s3mockroot STACK_S3_SECRET_ACCESS_KEY=s3mockroot STACK_S3_BUCKET=stack-storage + +# AWS region defaults to LocalStack +STACK_AWS_REGION=us-east-1 +STACK_AWS_KMS_ENDPOINT=http://localhost:8124 +STACK_AWS_ACCESS_KEY_ID=test +STACK_AWS_SECRET_ACCESS_KEY=test diff --git a/apps/backend/prisma/migrations/20250830000849_data_vault/migration.sql b/apps/backend/prisma/migrations/20250830000849_data_vault/migration.sql new file mode 100644 index 0000000000..9534a71e01 --- /dev/null +++ b/apps/backend/prisma/migrations/20250830000849_data_vault/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "DataVaultEntry" ( + "id" UUID NOT NULL, + "tenancyId" UUID NOT NULL, + "storeId" TEXT NOT NULL, + "hashedKey" TEXT NOT NULL, + "encrypted" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DataVaultEntry_pkey" PRIMARY KEY ("tenancyId","id") +); + +-- CreateIndex +CREATE INDEX "DataVaultEntry_tenancyId_storeId_idx" ON "DataVaultEntry"("tenancyId", "storeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DataVaultEntry_tenancyId_storeId_hashedKey_key" ON "DataVaultEntry"("tenancyId", "storeId", "hashedKey"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index dc699b846a..b07257fcad 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -771,3 +771,17 @@ model ItemQuantityChange { @@id([tenancyId, id]) @@index([tenancyId, customerId, expiresAt]) } + +model DataVaultEntry { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + storeId String + hashedKey String + encrypted Json // Contains { edkBase64, ciphertextBase64 } from encryptWithKms() + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([tenancyId, id]) + @@unique([tenancyId, storeId, hashedKey]) + @@index([tenancyId, storeId]) +} diff --git a/apps/backend/src/app/api/latest/data-vault/stores/[id]/get/route.tsx b/apps/backend/src/app/api/latest/data-vault/stores/[id]/get/route.tsx new file mode 100644 index 0000000000..ac3b0ec8cd --- /dev/null +++ b/apps/backend/src/app/api/latest/data-vault/stores/[id]/get/route.tsx @@ -0,0 +1,77 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { decryptWithKms } from "@stackframe/stack-shared/dist/helpers/vault/server-side"; +import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Retrieve encrypted value from data vault", + description: "Retrieves and decrypts a value from the data vault using a hashed key", + tags: ["DataVault"], + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + params: yupObject({ + id: yupString().defined(), + }).defined(), + body: yupObject({ + hashed_key: yupString().defined().nonEmpty(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + encrypted_value: yupString().defined(), + }).defined(), + }), + async handler({ auth: { tenancy }, params: { id: storeId }, body: { hashed_key: hashedKey } }) { + // Check if data vault is configured for this store + if (!(storeId in tenancy.config.dataVault.stores)) { + throw new KnownErrors.DataVaultStoreDoesNotExist(storeId); + } + + const prisma = await getPrismaClientForTenancy(tenancy); + + // Retrieve the entry + const entry = await prisma.dataVaultEntry.findUnique({ + where: { + tenancyId_storeId_hashedKey: { + tenancyId: tenancy.id, + storeId, + hashedKey, + }, + }, + }); + + if (!entry) { + throw new KnownErrors.DataVaultStoreHashedKeyDoesNotExist(storeId, hashedKey); + } + + const encryptedData = entry.encrypted as { edkBase64?: string, ciphertextBase64?: string }; + if (!encryptedData.edkBase64 || !encryptedData.ciphertextBase64) { + throw new StackAssertionError("Corrupted encrypted data", encryptedData); + } + + const decryptedValue = await decryptWithKms({ + edkBase64: encryptedData.edkBase64, + ciphertextBase64: encryptedData.ciphertextBase64, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + // This looks confusing, but it's actually correct. `encrypted_value` refers to the fact that it is encrypted + // with client-side encryption, while `decryptedValue` refers to the fact that it has been decrypted with + // server-side encryption. + encrypted_value: decryptedValue, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/data-vault/stores/[id]/set/route.tsx b/apps/backend/src/app/api/latest/data-vault/stores/[id]/set/route.tsx new file mode 100644 index 0000000000..61ad25db87 --- /dev/null +++ b/apps/backend/src/app/api/latest/data-vault/stores/[id]/set/route.tsx @@ -0,0 +1,68 @@ +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { encryptWithKms } from "@stackframe/stack-shared/dist/helpers/vault/server-side"; +import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Store encrypted value in data vault", + description: "Stores a hashed key and encrypted value in the data vault for a specific store", + tags: ["DataVault"], + }, + request: yupObject({ + auth: yupObject({ + type: serverOrHigherAuthTypeSchema, + tenancy: adaptSchema, + }).defined(), + params: yupObject({ + id: yupString().defined(), + }).defined(), + body: yupObject({ + hashed_key: yupString().defined().nonEmpty(), + encrypted_value: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + async handler({ auth: { tenancy }, params: { id: storeId }, body: { hashed_key: hashedKey, encrypted_value: encryptedValue } }) { + // Check if data vault is configured for this store + if (!(storeId in tenancy.config.dataVault.stores)) { + throw new KnownErrors.DataVaultStoreDoesNotExist(storeId); + } + + const prisma = await getPrismaClientForTenancy(tenancy); + + // Encrypt the value with KMS + // note that encryptedValue is encrypted by client-side encryption, while encrypted is encrypted by both client-side + // and server-side encryption. + const encrypted = await encryptWithKms(encryptedValue); + + // Store or update the entry + await prisma.dataVaultEntry.upsert({ + where: { + tenancyId_storeId_hashedKey: { + tenancyId: tenancy.id, + storeId, + hashedKey, + }, + }, + update: { + encrypted, + }, + create: { + tenancyId: tenancy.id, + storeId, + hashedKey, + encrypted, + }, + }); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page-client.tsx new file mode 100644 index 0000000000..c98c06fb76 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page-client.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { CodeBlock } from "@/components/code-block"; +import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; +import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Input, Label, toast } from "@stackframe/stack-ui"; +import { ArrowLeft, Check, Copy, Edit2, Trash2, X } from "lucide-react"; +import { useState } from "react"; +import { useRouter } from "../../../../../../../../components/router"; +import { PageLayout } from "../../../page-layout"; +import { useAdminApp } from "../../../use-admin-app"; + +type PageClientProps = { + storeId: string, +} + +export default function PageClient({ storeId }: PageClientProps) { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const router = useRouter(); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isEditingName, setIsEditingName] = useState(false); + const [editedDisplayName, setEditedDisplayName] = useState(""); + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + + const config = project.useConfig(); + const store = config.dataVault.stores[storeId]; + + if (!(storeId in config.dataVault.stores)) { + return ( + +
+

This data vault store does not exist.

+ +
+
+ ); + } + + const handleDeleteStore = async () => { + if (deleteConfirmation !== storeId) { + alert("Please type the store ID to confirm deletion"); + return; + } + + setIsDeleting(true); + try { + const { [storeId]: _, ...remainingStores } = config.dataVault.stores; + + await project.updateConfig({ + [`dataVault.stores.${storeId}`]: null, + }); + + toast({ title: "Data vault store deleted successfully" }); + router.push(`/projects/${project.id}/data-vault/stores`); + } finally { + setIsDeleting(false); + } + }; + + const handleUpdateDisplayName = async () => { + await project.updateConfig({ + [`dataVault.stores.${storeId}`]: { + ...store, + displayName: editedDisplayName.trim() || store.displayName, + }, + }); + + toast({ title: "Display name updated successfully" }); + setIsEditingName(false); + }; + + const startEditingName = () => { + setEditedDisplayName(store.displayName || ""); + setIsEditingName(true); + }; + + const copyToClipboard = async (text: string) => { + await navigator.clipboard.writeText(text); + toast({ title: "Copied to clipboard" }); + }; + + + const serverExample = deindent` + // In your .env file or environment variables: + // STACK_DATA_VAULT_SECRET=insert-a-randomly-generated-secret-here + + const store = await stackServerApp.getDataVaultStore(${JSON.stringify(storeId)}); + + // Each store is a key-value store. You can use any string as a key, for example user IDs + const key = user.id; + + // Get a value for a specific key + const value = await store.getValue(key, { + secret: process.env.STACK_DATA_VAULT_SECRET, + }); + + // Set a value for a specific key + await store.setValue(key, "my-value", { + secret: process.env.STACK_DATA_VAULT_SECRET, + }); + `; + + + return ( + +
+
+
+ +
+ {storeId} + +
+
+ +
+ + {isEditingName ? ( +
+ setEditedDisplayName(e.target.value)} + placeholder="Enter display name" + className="max-w-sm" + /> + + +
+ ) : ( +
+

+ {store.displayName || "No display name set"} +

+ +
+ )} +
+ +
+ +
+

+ Your data vault store has been created. +

+

+ A store securely saves key-value pairs with Stack Auth. Plaintext keys and values are never written to a database; instead, they're encrypted and decrypted on-the-fly using envelope encryption with a rotating master key. +

+

+ To use the store, you'll need a random, unguessable secret. It can be any format, but for strong security it should be at least 32 characters long and provide 256 bits of entropy. Even Stack Auth can't access your data if you lose it, so keep it safe. +

+

+ Stack Auth only stores hashes of your keys, so you can't list all keys in a store. Each value is encrypted with its key, the provided secret, and an additional encryption secret that is kept safe by Stack Auth. +

+
+ + + + + + + Delete Data Vault Store + + This action cannot be undone. All encrypted data in this store will be permanently deleted. + + +
+
+ + setDeleteConfirmation(e.target.value)} + placeholder={storeId} + /> +
+
+ + +
+
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page.tsx new file mode 100644 index 0000000000..91e62259f9 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/[storeId]/page.tsx @@ -0,0 +1,18 @@ +import { Metadata } from "next"; +import PageClient from "./page-client"; + +export const metadata: Metadata = { + title: "Data Vault Store", +}; + +type Params = { + projectId: string, + storeId: string, +}; + +export default async function Page({ params }: { params: Promise }) { + const { storeId } = await params; + return ( + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page-client.tsx new file mode 100644 index 0000000000..0b2898f4d8 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page-client.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { Button, Card, CardContent, CardHeader, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Input, Label, toast } from "@stackframe/stack-ui"; +import { Database, Plus } from "lucide-react"; +import { useState } from "react"; +import { useRouter } from "../../../../../../../components/router"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const router = useRouter(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [newStoreId, setNewStoreId] = useState(""); + const [newStoreDisplayName, setNewStoreDisplayName] = useState(""); + const [isCreating, setIsCreating] = useState(false); + + const config = project.useConfig(); + const stores = config.dataVault.stores; + const storeEntries = typedEntries(stores); + + const handleCreateStore = async () => { + if (!newStoreId.trim()) { + alert("Store ID is required"); + return; + } + + if (!newStoreId.match(/^[a-z0-9-]+$/)) { + alert("Store ID can only contain lowercase letters, numbers, and hyphens"); + return; + } + + if (newStoreId in stores) { + alert("A store with this ID already exists"); + return; + } + + setIsCreating(true); + try { + await project.updateConfig({ + [`dataVault.stores.${newStoreId}`]: { + displayName: newStoreDisplayName.trim() || `Store ${newStoreId}`, + }, + }); + + toast({ title: "Data vault store created successfully" }); + setIsCreateDialogOpen(false); + setNewStoreId(""); + setNewStoreDisplayName(""); + } finally { + setIsCreating(false); + } + }; + + const handleStoreClick = (storeId: string) => { + router.push(`/projects/${project.id}/data-vault/stores/${storeId}`); + }; + + return ( + setIsCreateDialogOpen(true)}> + + Create Store + + } + > +
+
+
+

+ Securely store and manage encrypted data in isolated stores +

+
+
+ + {storeEntries.length === 0 ? ( +
+ +

No data vault stores yet

+

+ Create your first data vault store to start securely storing encrypted data +

+ +
+ ) : ( +
+ {storeEntries.map(([storeId, store]) => ( + handleStoreClick(storeId)} + > + +
+ +
+
+ +
+

{storeId}

+

+ {store.displayName || "No display name"} +

+
+
+
+ ))} +
+ )} + + + + + Create Data Vault Store + + Create a new isolated store for encrypted data + + +
+
+ + setNewStoreId(e.target.value)} + pattern="[a-z0-9-]+" + /> +

+ Lowercase letters, numbers, and hyphens only +

+
+
+ + setNewStoreDisplayName(e.target.value)} + /> +
+
+ + +
+
+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page.tsx new file mode 100644 index 0000000000..c075354d0c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/data-vault/stores/page.tsx @@ -0,0 +1,16 @@ +import { Metadata } from "next"; +import PageClient from "./page-client"; + +export const metadata: Metadata = { + title: "Data Vault Stores", +}; + +type Params = { + projectId: string, +}; + +export default async function Page({ params }: { params: Promise }) { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 12964ee8d4..10833ffc55 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -40,7 +40,7 @@ import { SquarePen, User, Users, - Webhook, + Webhook } from "lucide-react"; import { useTheme } from "next-themes"; import { usePathname } from "next/navigation"; diff --git a/apps/dev-launchpad/public/index.html b/apps/dev-launchpad/public/index.html index 62a58fb176..e7fe3ef537 100644 --- a/apps/dev-launchpad/public/index.html +++ b/apps/dev-launchpad/public/index.html @@ -110,11 +110,17 @@

Background services

  • 4318: OTel collector
  • +
  • + 8121: S3 mock +
  • 8122: Freestyle mock
  • - 8121: S3 mock + 8124: LocalStack Gateway (AWS mock) +
  • +
  • + 8150-8199: Reserved for LocalStack (external services)