diff --git a/README.md b/README.md index e924922..05e7aad 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Next.js app for the Stacks Wars arena UI, Neon Auth, and app-user sync against t - Next.js 16 / React 19 / Tailwind CSS v4 - Neon Auth (`@neondatabase/auth`) — same flow as Chill Flow -- Custodial Stacks wallets via `@stacks/wallet-sdk` + Google Cloud KMS +- Custodial Stacks wallets via `@stacks/wallet-sdk` (`CUSTODIAL_DEV_SECRET` locally; Google Cloud KMS in production) - Posts synced users to the Rust API (`POST /users`) with Bearer JWT ## Develop diff --git a/env.example b/env.example index 5545b91..8aaf1dc 100644 --- a/env.example +++ b/env.example @@ -28,13 +28,11 @@ STACKS_WARS_KEY=word1 word2 ... word24 # Stacks network for custodial wallets / wait-for-tx NEXT_PUBLIC_NETWORK=mainnet -# Google Cloud KMS — required to encrypt custodial mnemonics at rest -GOOGLE_CLOUD_PROJECT=your-gcp-project-id -KMS_LOCATION=global -KMS_KEY_RING=stacks-wars -KMS_CRYPTO_KEY=custodial-wallet-keys -# Generate: jq -c . service-account-key.json -GOOGLE_SERVICE_ACCOUNT_KEY={"type":"service_account",...} +# Custodial mnemonic encryption (local). Generate: openssl rand -base64 32 +CUSTODIAL_DEV_SECRET=your-secret-at-least-16-characters + +# Shared with backend for S2S / lobby TTL cron +INTERNAL_API_SECRET= # USDCx / min entry / withdraw limits are hardcoded in # frontend/lib/vault/config.ts (and backend config.rs). diff --git a/lib/kms/envelope.ts b/lib/kms/envelope.ts index 5aac0f8..ac7a5a8 100644 --- a/lib/kms/envelope.ts +++ b/lib/kms/envelope.ts @@ -1,9 +1,75 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from "node:crypto" + import { KeyManagementServiceClient } from "@google-cloud/kms" import { getKmsConfig } from "@/lib/kms/config" +/** Prefix marks AES-GCM blobs sealed with `CUSTODIAL_DEV_SECRET` (local only). */ +const DEV_CIPHER_PREFIX = "dev1:" +const DEV_KEY_VERSION = "local:dev1" + let kmsClient: KeyManagementServiceClient | null = null +function getDevSecret(): string | null { + const secret = process.env.CUSTODIAL_DEV_SECRET?.trim() + if (!secret) return null + if (process.env.NODE_ENV === "production") { + throw new Error( + "CUSTODIAL_DEV_SECRET cannot be used in production. Configure Google Cloud KMS instead." + ) + } + if (secret.length < 16) { + throw new Error( + "CUSTODIAL_DEV_SECRET must be at least 16 characters. Generate with: openssl rand -base64 32" + ) + } + return secret +} + +function deriveDevKey(secret: string): Buffer { + return createHash("sha256").update(secret, "utf8").digest() +} + +function encryptWithDevSecret(plaintext: string, secret: string) { + const key = deriveDevKey(secret) + const iv = randomBytes(12) + const cipher = createCipheriv("aes-256-gcm", key, iv) + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]) + const tag = cipher.getAuthTag() + const payload = Buffer.concat([iv, tag, encrypted]).toString("base64") + return { + ciphertext: `${DEV_CIPHER_PREFIX}${payload}`, + kmsKeyVersion: DEV_KEY_VERSION, + } +} + +function decryptWithDevSecret(ciphertextBase64: string, secret: string) { + const raw = ciphertextBase64.startsWith(DEV_CIPHER_PREFIX) + ? ciphertextBase64.slice(DEV_CIPHER_PREFIX.length) + : ciphertextBase64 + const buf = Buffer.from(raw, "base64") + if (buf.length < 12 + 16 + 1) { + throw new Error("Invalid CUSTODIAL_DEV_SECRET ciphertext.") + } + const iv = buf.subarray(0, 12) + const tag = buf.subarray(12, 28) + const encrypted = buf.subarray(28) + const decipher = createDecipheriv("aes-256-gcm", deriveDevKey(secret), iv) + decipher.setAuthTag(tag) + return Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString("utf8") +} + function getServiceAccountCredentials() { const raw = process.env.GOOGLE_SERVICE_ACCOUNT_KEY?.trim() if (!raw) { @@ -29,7 +95,7 @@ function requireKmsConfig() { !process.env.GOOGLE_SERVICE_ACCOUNT_KEY?.trim() ) { throw new Error( - "Google Cloud KMS is required for custodial wallets. Set GOOGLE_CLOUD_PROJECT, KMS_KEY_RING, KMS_CRYPTO_KEY, and GOOGLE_SERVICE_ACCOUNT_KEY." + "Custodial wallet encryption is not configured. For local development set CUSTODIAL_DEV_SECRET (openssl rand -base64 32)." ) } } @@ -48,6 +114,11 @@ function getKmsClient() { } export async function encryptWithKms(plaintext: string) { + const devSecret = getDevSecret() + if (devSecret) { + return encryptWithDevSecret(plaintext, devSecret) + } + requireKmsConfig() const { cryptoKeyName } = getKmsConfig() const [result] = await getKmsClient().encrypt({ @@ -65,8 +136,28 @@ export async function encryptWithKms(plaintext: string) { } } -/** Symmetric Cloud KMS ciphertext carries its own key version. */ +/** Symmetric Cloud KMS ciphertext carries its own key version. Local blobs use `dev1:`. */ export async function decryptWithKms(ciphertextBase64: string) { + if (ciphertextBase64.startsWith(DEV_CIPHER_PREFIX)) { + const secret = process.env.CUSTODIAL_DEV_SECRET?.trim() + if (!secret) { + throw new Error( + "This wallet was encrypted with CUSTODIAL_DEV_SECRET, but the secret is not set." + ) + } + if (process.env.NODE_ENV === "production") { + throw new Error( + "CUSTODIAL_DEV_SECRET cannot be used in production. Configure Google Cloud KMS instead." + ) + } + if (secret.length < 16) { + throw new Error( + "CUSTODIAL_DEV_SECRET must be at least 16 characters." + ) + } + return decryptWithDevSecret(ciphertextBase64, secret) + } + requireKmsConfig() const { cryptoKeyName } = getKmsConfig() const [result] = await getKmsClient().decrypt({