diff --git a/deployment/.env.example b/deployment/.env.example index 77934985..40fb9e72 100644 --- a/deployment/.env.example +++ b/deployment/.env.example @@ -10,9 +10,15 @@ DATABASE_URL=postgres://user:password@your-postgres-host:5432/techulus # Authentication BETTER_AUTH_SECRET=your-secret-key-here -# Encryption (32 bytes as 64-character hex string) +# Encryption: use a local 32-byte key by default. ENCRYPTION_KEY=your-64-character-hex-string +# AWS KMS BYOK alternative (requires an AWS role; remove ENCRYPTION_KEY for a +# fresh installation). Keep ENCRYPTION_KEY for the first restart only when +# migrating existing encrypted data. +# ENCRYPTION_KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/your-key-id +# AWS_REGION=us-east-1 + # Victoria Logs Authentication VL_USERNAME=admin VL_PASSWORD=your-secure-logs-password diff --git a/deployment/install.sh b/deployment/install.sh index 1bae1421..6be3364c 100755 --- a/deployment/install.sh +++ b/deployment/install.sh @@ -317,7 +317,36 @@ configure_interactive() { done BETTER_AUTH_SECRET="$(openssl rand -hex 32)" - ENCRYPTION_KEY="$(openssl rand -hex 32)" + + echo "" + log_info "Secret encryption configuration" + echo -e " ${BOLD}1)${NC} Local encryption key" + echo -e " ${BOLD}2)${NC} Customer-managed AWS KMS key (AWS-hosted control plane)" + echo "" + + local encryption_choice + while true; do + read -rp "$(echo -e "${CYAN}Choose encryption option [1]: ${NC}")" encryption_choice + encryption_choice="${encryption_choice:-1}" + case "$encryption_choice" in + 1) + ENCRYPTION_SETTINGS="ENCRYPTION_KEY=$(openssl rand -hex 32)" + break + ;; + 2) + prompt_value ENCRYPTION_KMS_KEY_ARN "Enter the full AWS KMS key ARN" + prompt_value AWS_REGION "Enter the AWS region containing the KMS key" + ENCRYPTION_SETTINGS="ENCRYPTION_KMS_KEY_ARN=${ENCRYPTION_KMS_KEY_ARN} +AWS_REGION=${AWS_REGION}" + log_info "The web service must have an AWS role granting access to this KMS key." + break + ;; + *) + log_warn "Please enter 1 or 2" + ;; + esac + done + VL_USERNAME="admin" VL_PASSWORD="$(openssl rand -hex 16)" VM_USERNAME="admin" @@ -367,7 +396,7 @@ ACME_EMAIL=${ACME_EMAIL} DATABASE_URL=${DATABASE_URL} BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} -ENCRYPTION_KEY=${ENCRYPTION_KEY} +${ENCRYPTION_SETTINGS} VL_USERNAME=${VL_USERNAME} VL_PASSWORD=${VL_PASSWORD} diff --git a/docs/installation.mdx b/docs/installation.mdx index e9663467..817aa41d 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -121,7 +121,121 @@ use the common commands below when investigating a self-hosted service. | `ACME_EMAIL` | Email for Let's Encrypt certificates | | `DATABASE_URL` | PostgreSQL connection string (e.g., `postgres://user:pass@postgres:5432/techulus`) | | `BETTER_AUTH_SECRET` | Secret key for authentication | -| `ENCRYPTION_KEY` | 32 bytes as a 64-character hex string | +| `ENCRYPTION_KEY` | 32 bytes as a 64-character hex string. Required unless you configure AWS KMS BYOK. | + +### AWS KMS BYOK + +AWS KMS BYOK is available for AWS-hosted dedicated control planes. The control plane can run in your AWS account or in the Techulus AWS account. Attach an EC2 instance profile or ECS task role to the web service. Do not store static AWS credentials in `.env`. + +Set these variables instead of `ENCRYPTION_KEY` for a new installation: + +```env +ENCRYPTION_KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/your-key-id +AWS_REGION=us-east-1 +``` + +The role needs `kms:GenerateDataKey`, `kms:Encrypt`, `kms:Decrypt`, and `kms:DescribeKey` for the configured key. Restrict the cryptographic operations to the role, the exact key, and this encryption context: + +```json +{ + "StringEquals": { + "kms:EncryptionContext:techulus:purpose": "service-secret-dek" + } +} +``` + +#### Control plane in the Techulus AWS account + +You can keep the KMS key and compute agents in your AWS account while running the dedicated control plane in the Techulus AWS account. Grant the Techulus control-plane role direct cross-account access to the KMS key. The application uses that role through the standard AWS credential chain. It does not call AWS STS `AssumeRole`. + +Merge statements like these into the KMS key policy in your AWS account. Replace the account IDs and role name. `DescribeKey` is separate because that operation does not accept an encryption context: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowTechulusControlPlaneDescribe", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::111122223333:role/techulus-control-plane-customer-name" + }, + "Action": "kms:DescribeKey", + "Resource": "*" + }, + { + "Sid": "AllowTechulusControlPlaneCrypto", + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::111122223333:role/techulus-control-plane-customer-name" + }, + "Action": [ + "kms:GenerateDataKey", + "kms:Encrypt", + "kms:Decrypt" + ], + "Resource": "*", + "Condition": { + "StringEquals": { + "kms:EncryptionContext:techulus:purpose": "service-secret-dek" + } + } + } + ] +} +``` + +The Techulus control-plane role also needs this identity policy. Set `Resource` to the full ARN of your KMS key: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DescribeCustomerEncryptionKey", + "Effect": "Allow", + "Action": "kms:DescribeKey", + "Resource": "arn:aws:kms:us-east-1:444455556666:key/your-key-id" + }, + { + "Sid": "UseCustomerEncryptionKey", + "Effect": "Allow", + "Action": [ + "kms:GenerateDataKey", + "kms:Encrypt", + "kms:Decrypt" + ], + "Resource": "arn:aws:kms:us-east-1:444455556666:key/your-key-id", + "Condition": { + "StringEquals": { + "kms:EncryptionContext:techulus:purpose": "service-secret-dek" + } + } + } + ] +} +``` + +Configure `ENCRYPTION_KMS_KEY_ARN` with the customer-account key ARN and set `AWS_REGION` to that key's region. Your compute agents do not need AWS credentials or KMS permissions. They continue receiving the data encryption key during registration. + +You can revoke future cold-start unwraps by removing the Techulus role from the key policy or disabling the key. This does not erase a key already cached by a running control-plane process or persisted by an existing agent. + +The first secret operation generates a 32-byte data encryption key. The control plane stores only its KMS-wrapped form in PostgreSQL. Each web process unwraps and caches the key when first needed. The secret ciphertext format and agent behavior remain unchanged. + +To migrate an existing installation: + +1. Back up PostgreSQL and `.env`. +2. Add `ENCRYPTION_KMS_KEY_ARN` and `AWS_REGION`. Keep the existing `ENCRYPTION_KEY` temporarily. +3. Restart the web service and verify that you can reveal an existing secret and register an agent. +4. Remove `ENCRYPTION_KEY` and restart all web replicas. + +When a wrapped key already exists, any remaining `ENCRYPTION_KEY` must contain the same key bytes. The control plane fails closed if they differ. Do not add a newly generated `ENCRYPTION_KEY` to an existing KMS installation. + +Do not change `ENCRYPTION_KMS_KEY_ARN` after initialization. Automatic rotation of the same KMS key is transparent. Moving to another KMS key requires a reviewed rewrap procedure. + +If KMS is unavailable, cold-started web processes return a service-unavailable response for secret operations and agent registration. Other control-plane features remain available. Warm processes and existing agents can continue using keys already held in memory or local agent configuration. + +Back up both PostgreSQL and continued access to the KMS key. Deleting the KMS key can make a database restore unrecoverable. Disabling the key blocks future unwraps, but it does not erase keys already cached by running processes or registered agents. ### Control plane deployment diff --git a/web/.env.example b/web/.env.example index 24da6e3e..cca92dac 100644 --- a/web/.env.example +++ b/web/.env.example @@ -5,9 +5,15 @@ DATABASE_URL=postgres://techulus:password@localhost:5432/techulus BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=your-secret-key-here -# Encryption (32 bytes as 64-character hex string) +# Encryption: use a local 32-byte key by default. ENCRYPTION_KEY=your-64-character-hex-string +# AWS KMS BYOK alternative (requires an AWS role; remove ENCRYPTION_KEY for a +# fresh installation). Keep ENCRYPTION_KEY for the first restart only when +# migrating existing encrypted data. +# ENCRYPTION_KMS_KEY_ARN=arn:aws:kms:us-east-1:123456789012:key/your-key-id +# AWS_REGION=us-east-1 + # Public URL APP_URL=http://localhost:3000 diff --git a/web/SELF-HOSTING.md b/web/SELF-HOSTING.md index d995bcab..55a5bd46 100644 --- a/web/SELF-HOSTING.md +++ b/web/SELF-HOSTING.md @@ -68,7 +68,15 @@ mutable tags such as `latest` or `tip`. | `POSTGRES_DB` | PostgreSQL database name | | `DATABASE_URL` | Full connection string (e.g., `postgres://user:pass@postgres:5432/db`) | | `BETTER_AUTH_SECRET` | Secret key for authentication | -| `ENCRYPTION_KEY` | 32 bytes as 64-character hex string | +| `ENCRYPTION_KEY` | 32 bytes as 64-character hex string. Required unless AWS KMS BYOK is configured. | +| `ENCRYPTION_KMS_KEY_ARN` | Optional full ARN of a symmetric AWS KMS key. Enables BYOK. | +| `AWS_REGION` | Required with `ENCRYPTION_KMS_KEY_ARN`. | + +For KMS BYOK, run the dedicated control plane in AWS with an instance profile or task role. The role needs `kms:GenerateDataKey`, `kms:Encrypt`, `kms:Decrypt`, and `kms:DescribeKey`. Do not put static AWS credentials in `.env`. + +The KMS key can be in another AWS account. For direct cross-account access, grant the control-plane role access in both the customer's KMS key policy and the role's identity policy. Scope cryptographic operations to the exact key and the `techulus:purpose=service-secret-dek` encryption context. The application uses its existing role credentials and does not call STS `AssumeRole`. Compute agents need no KMS permissions. See the installation documentation for policy examples. + +On a fresh KMS installation, omit `ENCRYPTION_KEY`. To migrate existing data, configure KMS while retaining `ENCRYPTION_KEY` for one restart. Verify an existing secret, then remove the raw key and restart every web replica. If a wrapped key already exists, a remaining `ENCRYPTION_KEY` must match it or encryption operations fail closed. The wrapped data encryption key is stored in PostgreSQL, so database recovery also requires access to the same KMS key. ### Victoria Logs diff --git a/web/actions/secrets.ts b/web/actions/secrets.ts index 4ed90ed1..6972b071 100644 --- a/web/actions/secrets.ts +++ b/web/actions/secrets.ts @@ -47,14 +47,14 @@ export async function createSecretsBatch( if (existingSecret) { toUpdate.push({ id: existingSecret.id, - encryptedValue: encryptSecret(item.value), + encryptedValue: await encryptSecret(item.value), }); } else { toInsert.push({ id: randomUUID(), serviceId, key: item.key, - encryptedValue: encryptSecret(item.value), + encryptedValue: await encryptSecret(item.value), }); } } diff --git a/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts b/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts index d71b8b97..ff8ecbda 100644 --- a/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts +++ b/web/app/api/services/[id]/secrets/[secretId]/reveal/route.ts @@ -5,6 +5,7 @@ import { db } from "@/db"; import { secrets } from "@/db/schema"; import { requireRequestDeveloperRole } from "@/lib/api-auth"; import { decryptSecret } from "@/lib/crypto"; +import { EncryptionKeyUnavailableError } from "@/lib/kms"; export async function POST( request: Request, @@ -28,7 +29,7 @@ export async function POST( } try { - const decryptedValue = decryptSecret(secret[0].encryptedValue); + const decryptedValue = await decryptSecret(secret[0].encryptedValue); return new Response(JSON.stringify({ value: decryptedValue }), { status: 200, @@ -40,7 +41,14 @@ export async function POST( Vary: "Authorization", }, }); - } catch { + } catch (error) { + if (error instanceof EncryptionKeyUnavailableError) { + console.error("Secret encryption key unavailable:", error); + return Response.json( + { error: "Secret encryption service unavailable" }, + { status: 503 }, + ); + } return Response.json( { error: "Failed to decrypt secret" }, { status: 500 }, diff --git a/web/app/api/v1/agent/register/route.ts b/web/app/api/v1/agent/register/route.ts index cf4186cc..7801be88 100644 --- a/web/app/api/v1/agent/register/route.ts +++ b/web/app/api/v1/agent/register/route.ts @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; import { servers } from "@/db/schema"; import { HOUR_IN_MILLISECONDS, subtractMilliseconds } from "@/lib/date"; +import { EncryptionKeyUnavailableError, resolveEncryptionKey } from "@/lib/kms"; import { agentRegisterSchema } from "@/lib/schemas"; import { formatZodErrors } from "@/lib/utils"; import { assignSubnet } from "@/lib/wireguard"; @@ -56,9 +57,12 @@ export async function POST(request: NextRequest) { ); } + const encryptionKeyBuffer = await resolveEncryptionKey(); + const encryptionKey = encryptionKeyBuffer.toString("hex"); + const { subnetId, wireguardIp } = await assignSubnet(); - await db + const claimedServers = await db .update(servers) .set({ wireguardPublicKey, @@ -72,13 +76,28 @@ export async function POST(request: NextRequest) { status: "online", lastHeartbeat: now, }) - .where(eq(servers.id, server.id)); + .where( + and( + eq(servers.id, server.id), + eq(servers.agentToken, token), + isNull(servers.tokenUsedAt), + gt(servers.tokenCreatedAt, expiryThreshold), + ), + ) + .returning({ id: servers.id }); + + if (claimedServers.length === 0) { + return NextResponse.json( + { error: "Invalid, expired, or already used token" }, + { status: 401 }, + ); + } return NextResponse.json({ serverId: server.id, subnetId, wireguardIp, - encryptionKey: process.env.ENCRYPTION_KEY, + encryptionKey, loggingEndpoint: process.env.VICTORIA_LOGS_URL ?? null, metricsEndpoint: process.env.VICTORIA_METRICS_URL ?? null, registryUrl: process.env.REGISTRY_URL ?? null, @@ -88,6 +107,12 @@ export async function POST(request: NextRequest) { }); } catch (error) { console.error("Agent registration error:", error); + if (error instanceof EncryptionKeyUnavailableError) { + return NextResponse.json( + { error: "Secret encryption service unavailable" }, + { status: 503 }, + ); + } return NextResponse.json( { error: "Internal server error" }, { status: 500 }, diff --git a/web/lib/crypto.ts b/web/lib/crypto.ts index 8cffb1df..eb55eee0 100644 --- a/web/lib/crypto.ts +++ b/web/lib/crypto.ts @@ -1,25 +1,18 @@ import { - randomBytes, - verify, - createPublicKey, createCipheriv, createDecipheriv, + createPublicKey, + randomBytes, + verify, } from "node:crypto"; +import { resolveEncryptionKey } from "@/lib/kms"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; -function getEncryptionKey(): Buffer { - const key = process.env.ENCRYPTION_KEY; - if (!key || key.length !== 64) { - throw new Error("ENCRYPTION_KEY must be 64 hex characters (32 bytes)"); - } - return Buffer.from(key, "hex"); -} - -export function encryptSecret(plaintext: string): string { - const key = getEncryptionKey(); +export async function encryptSecret(plaintext: string): Promise { + const key = await resolveEncryptionKey(); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv); @@ -32,8 +25,8 @@ export function encryptSecret(plaintext: string): string { return Buffer.concat([iv, authTag, encrypted]).toString("base64"); } -export function decryptSecret(encryptedBase64: string): string { - const key = getEncryptionKey(); +export async function decryptSecret(encryptedBase64: string): Promise { + const key = await resolveEncryptionKey(); const data = Buffer.from(encryptedBase64, "base64"); const iv = data.subarray(0, IV_LENGTH); diff --git a/web/lib/kms.ts b/web/lib/kms.ts new file mode 100644 index 00000000..3c8f352e --- /dev/null +++ b/web/lib/kms.ts @@ -0,0 +1,247 @@ +import { timingSafeEqual } from "node:crypto"; +import { + DecryptCommand, + DescribeKeyCommand, + EncryptCommand, + GenerateDataKeyCommand, + KMSClient, +} from "@aws-sdk/client-kms"; +import { eq, isNotNull } from "drizzle-orm"; +import { db } from "@/db"; +import { secrets, servers, settings } from "@/db/schema"; +import { + type KmsEncryptionConfig, + kmsEncryptionConfigSchema, + SETTING_KEYS, +} from "@/lib/settings-keys"; + +const ENCRYPTION_CONTEXT = { + "techulus:purpose": "service-secret-dek", +} as const; +const DEK_LENGTH = 32; +const KMS_KEY_ARN_PATTERN = + /^arn:(aws|aws-us-gov|aws-cn):kms:[a-z0-9-]+:\d{12}:key\/[A-Za-z0-9-]+$/; + +export class EncryptionKeyUnavailableError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "EncryptionKeyUnavailableError"; + } +} + +let encryptionKeyPromise: Promise | undefined; + +function parseLocalKey(): Buffer | null { + const value = process.env.ENCRYPTION_KEY; + if (!value) return null; + if (!/^[0-9a-fA-F]{64}$/.test(value)) { + throw new EncryptionKeyUnavailableError( + "ENCRYPTION_KEY must be 64 hex characters (32 bytes)", + ); + } + return Buffer.from(value, "hex"); +} + +function decodeWrappedDek(value: string): Buffer { + const decoded = Buffer.from(value, "base64"); + if (!value || decoded.length === 0 || decoded.toString("base64") !== value) { + throw new EncryptionKeyUnavailableError( + "Stored KMS encryption configuration contains an invalid wrapped DEK", + ); + } + return decoded; +} + +async function readConfig(): Promise { + const rows = await db + .select({ value: settings.value }) + .from(settings) + .where(eq(settings.key, SETTING_KEYS.KMS_ENCRYPTION_CONFIG)) + .limit(1); + if (!rows[0]) return null; + + const parsed = kmsEncryptionConfigSchema.safeParse(rows[0].value); + if (!parsed.success) { + throw new EncryptionKeyUnavailableError( + "Stored KMS encryption configuration is invalid", + { cause: parsed.error }, + ); + } + return parsed.data; +} + +async function hasExistingEncryptionState(): Promise { + const [secretRows, registeredServerRows] = await Promise.all([ + db.select({ id: secrets.id }).from(secrets).limit(1), + db + .select({ id: servers.id }) + .from(servers) + .where(isNotNull(servers.tokenUsedAt)) + .limit(1), + ]); + return secretRows.length > 0 || registeredServerRows.length > 0; +} + +async function createCandidate( + client: KMSClient, + keyArn: string, +): Promise { + const localKey = parseLocalKey(); + if (localKey) { + const result = await client.send( + new EncryptCommand({ + KeyId: keyArn, + Plaintext: localKey, + EncryptionContext: ENCRYPTION_CONTEXT, + }), + ); + if (!result.CiphertextBlob) { + throw new Error("KMS Encrypt did not return a ciphertext blob"); + } + console.info("[kms] wrapped the existing encryption key"); + return { + version: 1, + keyArn, + wrappedDekBase64: Buffer.from(result.CiphertextBlob).toString("base64"), + wrappedAt: new Date().toISOString(), + origin: "migrated", + }; + } + const hasExistingState = await hasExistingEncryptionState(); + if (hasExistingState) { + throw new EncryptionKeyUnavailableError( + "ENCRYPTION_KEY is required to migrate existing secrets or registered agents to AWS KMS", + ); + } + + const result = await client.send( + new GenerateDataKeyCommand({ + KeyId: keyArn, + KeySpec: "AES_256", + EncryptionContext: ENCRYPTION_CONTEXT, + }), + ); + if (!result.CiphertextBlob) { + throw new Error("KMS GenerateDataKey did not return a ciphertext blob"); + } + console.info("[kms] generated a new encryption key"); + return { + version: 1, + keyArn, + wrappedDekBase64: Buffer.from(result.CiphertextBlob).toString("base64"), + wrappedAt: new Date().toISOString(), + origin: "generated", + }; +} + +async function initializeEncryptionKey(): Promise { + const keyArn = process.env.ENCRYPTION_KMS_KEY_ARN; + if (!keyArn) { + const localKey = parseLocalKey(); + if (!localKey) { + throw new EncryptionKeyUnavailableError( + "ENCRYPTION_KEY must be 64 hex characters (32 bytes)", + ); + } + return localKey; + } + if (!process.env.AWS_REGION) { + throw new EncryptionKeyUnavailableError( + "AWS_REGION is required when ENCRYPTION_KMS_KEY_ARN is configured", + ); + } + if (!KMS_KEY_ARN_PATTERN.test(keyArn)) { + throw new EncryptionKeyUnavailableError( + "ENCRYPTION_KMS_KEY_ARN must be a full AWS KMS key ARN", + ); + } + + try { + const client = new KMSClient({ region: process.env.AWS_REGION }); + let config = await readConfig(); + if (config && config.keyArn !== keyArn) { + throw new EncryptionKeyUnavailableError( + `Configured KMS key ARN does not match the stored key ARN (${config.keyArn})`, + ); + } + + const description = await client.send( + new DescribeKeyCommand({ KeyId: keyArn }), + ); + if ( + description.KeyMetadata?.KeyUsage !== "ENCRYPT_DECRYPT" || + description.KeyMetadata?.KeySpec !== "SYMMETRIC_DEFAULT" + ) { + throw new Error("KMS key must be a symmetric encryption key"); + } + if (description.KeyMetadata.KeyState !== "Enabled") { + throw new Error( + `KMS key must be enabled (current state: ${description.KeyMetadata.KeyState ?? "unknown"})`, + ); + } + + if (!config) { + const candidate = await createCandidate(client, keyArn); + await db + .insert(settings) + .values({ + key: SETTING_KEYS.KMS_ENCRYPTION_CONFIG, + value: candidate, + }) + .onConflictDoNothing({ target: settings.key }); + config = await readConfig(); + if (!config) { + throw new Error("Failed to read stored KMS encryption configuration"); + } + } + + if (config.keyArn !== keyArn) { + throw new EncryptionKeyUnavailableError( + `Configured KMS key ARN does not match the stored key ARN (${config.keyArn})`, + ); + } + const result = await client.send( + new DecryptCommand({ + KeyId: keyArn, + CiphertextBlob: decodeWrappedDek(config.wrappedDekBase64), + EncryptionContext: ENCRYPTION_CONTEXT, + }), + ); + const plaintext = result.Plaintext && Buffer.from(result.Plaintext); + if (!plaintext || plaintext.length !== DEK_LENGTH) { + throw new Error("KMS returned an invalid encryption key length"); + } + const localKey = parseLocalKey(); + if (localKey) { + if (!timingSafeEqual(localKey, plaintext)) { + throw new EncryptionKeyUnavailableError( + "ENCRYPTION_KEY does not match the stored KMS-wrapped encryption key", + ); + } + console.warn( + "[kms] ENCRYPTION_KEY matches the wrapped key and can be removed after migration verification", + ); + } + return plaintext; + } catch (error) { + if (error instanceof EncryptionKeyUnavailableError) throw error; + throw new EncryptionKeyUnavailableError( + "AWS KMS encryption key is unavailable", + { cause: error }, + ); + } +} + +export function resolveEncryptionKey(): Promise { + if (!encryptionKeyPromise) { + encryptionKeyPromise = initializeEncryptionKey().catch((error) => { + encryptionKeyPromise = undefined; + throw error; + }); + } + return encryptionKeyPromise; +} + +export function resetEncryptionKeyCacheForTests(): void { + encryptionKeyPromise = undefined; +} diff --git a/web/lib/settings-keys.ts b/web/lib/settings-keys.ts index ad8ed5b9..1fe9fa66 100644 --- a/web/lib/settings-keys.ts +++ b/web/lib/settings-keys.ts @@ -9,8 +9,19 @@ export const SETTING_KEYS = { CONTROL_PLANE_UPDATE_STATE: "control_plane_update_state", CONTROL_PLANE_LAST_UPDATE_CHECK_AT: "control_plane_last_update_check_at", CONTROL_PLANE_UPGRADE_STATE: "control_plane_upgrade_state", + KMS_ENCRYPTION_CONFIG: "kms_encryption_config", } as const; +export const kmsEncryptionConfigSchema = z.object({ + version: z.literal(1), + keyArn: z.string().min(1), + wrappedDekBase64: z.string().min(1), + wrappedAt: z.string().datetime(), + origin: z.enum(["generated", "migrated"]), +}); + +export type KmsEncryptionConfig = z.infer; + export const DEFAULT_BUILD_TIMEOUT_MINUTES = 30; export const DEFAULT_BACKUP_RETENTION_DAYS = 7; diff --git a/web/package.json b/web/package.json index 4894ca0d..87967c96 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "db:studio": "drizzle-kit studio" }, "dependencies": { + "@aws-sdk/client-kms": "^3.1070.0", "@aws-sdk/client-s3": "^3.968.0", "@base-ui/react": "^1.0.0", "@better-auth/api-key": "1.6.23", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index f2a769d0..dc11523b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -9,12 +9,17 @@ overrides: '@types/react-dom': 19.2.3 patchedDependencies: - drizzle-kit@0.31.10: 3ebd38e94e42a539a2e289c2b932c00c0ee0c1cee99a6bc49147abb5d3cd2eb2 + drizzle-kit@0.31.10: + hash: 3ebd38e94e42a539a2e289c2b932c00c0ee0c1cee99a6bc49147abb5d3cd2eb2 + path: patches/drizzle-kit@0.31.10.patch importers: .: dependencies: + '@aws-sdk/client-kms': + specifier: ^3.1070.0 + version: 3.1070.0 '@aws-sdk/client-s3': specifier: ^3.968.0 version: 3.1070.0 @@ -210,6 +215,10 @@ packages: resolution: {integrity: sha512-RMCrCteiUwYTEv2G9zfP/BEuKHv57665vVieJyp9cf8VgilWxP/KrWVtMdfdDlIH8nFhvu3rIMc29z3ebGEZ1w==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-kms@3.1070.0': + resolution: {integrity: sha512-ELJH11w1IU0V5kqQ/eg2iu4HqaXOJQ/3nC8I7y93+mZW39osINlOHFKgYQNizwZA5KoSxSgVYmLPnnF/i0oKmw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3@3.1070.0': resolution: {integrity: sha512-B/OUiCqGQ4Zr7v9gFFyiuitKN2c0PIgvOlQb5bYg1SM2y0F8a5JQ7FNsjRcl+d2PqYWLHwHx12CvZDyLn4KxIw==} engines: {node: '>=20.0.0'} @@ -218,38 +227,74 @@ packages: resolution: {integrity: sha512-P5JAHvn4dTi96UsAGS67LVOqqpUNNRhnfFXqzCYtdBIGZtqBue4CXvRr9YenOO7PALj/Pn8uuyw53FBCiCYw8w==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.975.1': + resolution: {integrity: sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.47': resolution: {integrity: sha512-3YoPwJczcc+MtX2xxXaYaOOWO6xKUJr1ZIIDIFuninr51BYONVVcF/CP8K2xfVRC/PztJjqKWxNGFH7BWQAw1Q==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.57': + resolution: {integrity: sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.49': resolution: {integrity: sha512-2UtGUPy+x3lqyceHrtC1uEuVxBZbDalPF6KAFqBwYgm4edWdBrZKNnCqzDs7KynWUvEC6mrR+ojRk+ZgQz9C2w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.59': + resolution: {integrity: sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.54': resolution: {integrity: sha512-Hx4gO4YRjFwitf3MVl3cDwYe1aryJthC4txVl9b+JAURovA50M2ywf9r8j1E/Q6SCTPT4qQpjOAbKYIC9CG+Vw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.1': + resolution: {integrity: sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.53': resolution: {integrity: sha512-+71sluhkgPqdhbbD3UDwUpj24GCkng9HQx6z7qoBFb8dwkF4ktpOcVKDeHpgg8PvBgLYwAnUYLTEGRC/PniCiQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.63': + resolution: {integrity: sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.56': resolution: {integrity: sha512-iI+4o0dvQQ4NHel4FMDiFy5q2gaU/ryLK3niOsoPccAt9WLFRkV4XTYPWRr9XvmBUqEzXG73S4p/8gm0Lu/W3A==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.67': + resolution: {integrity: sha512-oYlzWst56rlhhjbYnexwv5hVLYe1cW4liLObhDfxDLI4RAQzleMVHQgQgx7XsC4HKj4e3kjT8v9DId+Pi/dndw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.47': resolution: {integrity: sha512-tAizPm9IFo/PHn06c+LQJlzfY2AGOlyF0CUljFejrU6LcZBjnk8pmbZK3/xoIDdnIzjEdbClfvY3mXfr818ZEg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.57': + resolution: {integrity: sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.53': resolution: {integrity: sha512-pUXE3fu4tfEDV8BksIgf4dXvuIH10FhwHMl/wu8rBD5T1sMpryQWFVitH3kdPS90wlgrGYJQ/meQTSPacyZfeg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.1': + resolution: {integrity: sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.53': resolution: {integrity: sha512-JmMGlhVvSj8uSG9CpeDkJAXT35H89tc6v84iMgEIE75q4yp1MKVVKvopv6Gg28HJIR7hMNkojRF8H2m5W44wyg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.63': + resolution: {integrity: sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-flexible-checksums@3.974.31': resolution: {integrity: sha512-Yzj6NRYVZdBaCp7o1BwHGyeDBfixdeToLIAMprshIITEdl9wKVSiidVOfeaiH8FyeC1hBmBfDZFvs/aH1Y3xpw==} engines: {node: '>=20.0.0'} @@ -262,18 +307,34 @@ packages: resolution: {integrity: sha512-eC7Vl7Qom/BGhZjG9GEqPwdQ/fk45hg1t5LP4EUxG5d1fdshLbaxCiwh/tszUzDX/4mW40mu2QsbeJJRPBbqUw==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.31': + resolution: {integrity: sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.35': resolution: {integrity: sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.39': + resolution: {integrity: sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1069.0': resolution: {integrity: sha512-ks4X+kngC3PA5howV7Qu1TgG4bfC4jPykKdvw3nmBSXR9yZxRJouBholFSNQ5kY3L+Fgwyw+LCjzQmNi+KR91g==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1083.0': + resolution: {integrity: sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.13': resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.0': + resolution: {integrity: sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-locate-window@3.965.8': resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} engines: {node: '>=20.0.0'} @@ -282,10 +343,18 @@ packages: resolution: {integrity: sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.34': + resolution: {integrity: sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -2380,14 +2449,26 @@ packages: resolution: {integrity: sha512-TTD6el7tvKyafkXBf7XO3jLOE+qVxOTrLjp/fEGiV3BMfUHK/LfdYlQO9YgZvzxC7kqA3H/IhJXNqQgnbgjb7A==} engines: {node: '>=18.0.0'} + '@smithy/core@3.29.3': + resolution: {integrity: sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.0': resolution: {integrity: sha512-pPQmNdEvMJttv9z2kdYxoui83p/nr32zjMf0aMfmzmGmFEgKXUfy0vXiNg0fx4R5XLQzmJBLM9Wg0guEq2/q8A==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.8': + resolution: {integrity: sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.5.0': resolution: {integrity: sha512-OG8kBYAgX7lf32+xLzgirvuLffn1KNoszaSiButt45i2cRa5irk8LQXLYQ5Smij1SBTN4KMNcBsRwRrLPfIGyA==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.5': + resolution: {integrity: sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g==} + engines: {node: '>=18.0.0'} + '@smithy/is-array-buffer@2.2.0': resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} @@ -2396,14 +2477,26 @@ packages: resolution: {integrity: sha512-Mq7TNt/VhlEWiYRLQGpzUWeUxh899UGpjKh7Ru0WVIDIjnE+cTRAn0NYlFQ6bWfsQnKnpCbWJj86HzmcG0qEdg==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.5': + resolution: {integrity: sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.5.0': resolution: {integrity: sha512-vW6UdK7e7gV2wU/tXRsPq4pMQMusb8VymdVOyIFNA1FtyRmEClRFkYDtYI8UcO/HM0wK3qqjvvQs3HOlbgMbdg==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.4': + resolution: {integrity: sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.15.0': resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@smithy/util-buffer-from@2.2.0': resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} @@ -6036,20 +6129,20 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 '@aws-sdk/util-locate-window': 3.965.8 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6059,7 +6152,7 @@ snapshots: '@aws-crypto/sha256-js': 5.2.0 '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 '@aws-sdk/util-locate-window': 3.965.8 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6067,7 +6160,7 @@ snapshots: '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 tslib: 2.8.1 '@aws-crypto/supports-web-crypto@5.2.0': @@ -6076,7 +6169,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -6085,10 +6178,23 @@ snapshots: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.21 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-kms@3.1070.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-node': 3.972.67 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/fetch-http-handler': 5.6.5 + '@smithy/node-http-handler': 4.9.5 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/client-s3@3.1070.0': @@ -6110,36 +6216,65 @@ snapshots: '@aws-sdk/core@3.974.21': dependencies: - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 '@aws-sdk/xml-builder': 3.972.30 '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.25.0 + '@smithy/core': 3.29.3 '@smithy/signature-v4': 5.5.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.975.1': + dependencies: + '@aws-sdk/types': 3.974.0 + '@aws-sdk/xml-builder': 3.972.34 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.3 + '@smithy/signature-v4': 5.6.4 + '@smithy/types': 4.16.1 bowser: 2.14.1 tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.972.47': dependencies: - '@aws-sdk/core': 3.974.21 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.57': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.972.49': dependencies: - '@aws-sdk/core': 3.974.21 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/fetch-http-handler': 5.5.0 - '@smithy/node-http-handler': 4.8.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/fetch-http-handler': 5.6.5 + '@smithy/node-http-handler': 4.9.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/fetch-http-handler': 5.6.5 + '@smithy/node-http-handler': 4.9.5 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-ini@3.972.54': dependencies: - '@aws-sdk/core': 3.974.21 + '@aws-sdk/core': 3.975.1 '@aws-sdk/credential-provider-env': 3.972.47 '@aws-sdk/credential-provider-http': 3.972.49 '@aws-sdk/credential-provider-login': 3.972.53 @@ -6147,19 +6282,44 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.972.53 '@aws-sdk/credential-provider-web-identity': 3.972.53 '@aws-sdk/nested-clients': 3.997.21 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 '@smithy/credential-provider-imds': 4.4.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.1': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-login': 3.972.63 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/credential-provider-imds': 4.4.8 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-login@3.972.53': dependencies: - '@aws-sdk/core': 3.974.21 + '@aws-sdk/core': 3.975.1 '@aws-sdk/nested-clients': 3.997.21 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.63': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-node@3.972.56': @@ -6170,37 +6330,78 @@ snapshots: '@aws-sdk/credential-provider-process': 3.972.47 '@aws-sdk/credential-provider-sso': 3.972.53 '@aws-sdk/credential-provider-web-identity': 3.972.53 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 '@smithy/credential-provider-imds': 4.4.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.67': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-ini': 3.973.1 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/credential-provider-imds': 4.4.8 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-process@3.972.47': dependencies: - '@aws-sdk/core': 3.974.21 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.57': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-sso@3.972.53': dependencies: - '@aws-sdk/core': 3.974.21 + '@aws-sdk/core': 3.975.1 '@aws-sdk/nested-clients': 3.997.21 '@aws-sdk/token-providers': 3.1069.0 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.1': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/token-providers': 3.1083.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-web-identity@3.972.53': dependencies: - '@aws-sdk/core': 3.974.21 + '@aws-sdk/core': 3.975.1 '@aws-sdk/nested-clients': 3.997.21 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.63': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/middleware-flexible-checksums@3.974.31': @@ -6210,45 +6411,77 @@ snapshots: '@aws-sdk/middleware-sdk-s3@3.972.52': dependencies: - '@aws-sdk/core': 3.974.21 + '@aws-sdk/core': 3.975.1 '@aws-sdk/signature-v4-multi-region': 3.996.35 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/nested-clients@3.997.21': dependencies: '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.21 + '@aws-sdk/core': 3.975.1 '@aws-sdk/signature-v4-multi-region': 3.996.35 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/fetch-http-handler': 5.5.0 - '@smithy/node-http-handler': 4.8.0 - '@smithy/types': 4.15.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/fetch-http-handler': 5.6.5 + '@smithy/node-http-handler': 4.9.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.31': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/fetch-http-handler': 5.6.5 + '@smithy/node-http-handler': 4.9.5 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.35': dependencies: - '@aws-sdk/types': 3.973.13 + '@aws-sdk/types': 3.974.0 '@smithy/signature-v4': 5.5.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.39': + dependencies: + '@aws-sdk/types': 3.974.0 + '@smithy/signature-v4': 5.6.4 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/token-providers@3.1069.0': dependencies: - '@aws-sdk/core': 3.974.21 + '@aws-sdk/core': 3.975.1 '@aws-sdk/nested-clients': 3.997.21 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1083.0': + dependencies: + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/types@3.973.13': dependencies: - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.0': + dependencies: + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.965.8': @@ -6257,12 +6490,19 @@ snapshots: '@aws-sdk/xml-builder@3.972.30': dependencies: - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 fast-xml-parser: 5.7.3 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.34': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -8262,19 +8502,36 @@ snapshots: '@smithy/core@3.25.0': dependencies: '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/core@3.29.3': + dependencies: + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/credential-provider-imds@4.4.0': dependencies: - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.8': + dependencies: + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/fetch-http-handler@5.5.0': dependencies: - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.5': + dependencies: + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -8283,20 +8540,36 @@ snapshots: '@smithy/node-http-handler@4.8.0': dependencies: - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.5': + dependencies: + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/signature-v4@5.5.0': dependencies: - '@smithy/core': 3.25.0 - '@smithy/types': 4.15.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.4': + dependencies: + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@smithy/types@4.15.0': dependencies: tslib: 2.8.1 + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@smithy/util-buffer-from@2.2.0': dependencies: '@smithy/is-array-buffer': 2.2.0 @@ -9574,7 +9847,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.9 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) @@ -9597,7 +9870,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -9612,14 +9885,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -9634,7 +9907,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 diff --git a/web/tests/crypto.test.ts b/web/tests/crypto.test.ts new file mode 100644 index 00000000..26c10b66 --- /dev/null +++ b/web/tests/crypto.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { decryptSecret, encryptSecret } from "@/lib/crypto"; +import { + resetEncryptionKeyCacheForTests, + resolveEncryptionKey, +} from "@/lib/kms"; + +const KEY = "ab".repeat(32); + +describe("secret encryption", () => { + beforeEach(() => { + delete process.env.ENCRYPTION_KMS_KEY_ARN; + process.env.ENCRYPTION_KEY = KEY; + resetEncryptionKeyCacheForTests(); + }); + + afterEach(() => { + delete process.env.ENCRYPTION_KEY; + resetEncryptionKeyCacheForTests(); + }); + + it("preserves the AES-256-GCM framing and round trips", async () => { + const encrypted = await encryptSecret("top secret"); + const decrypted = await decryptSecret(encrypted); + const framed = Buffer.from(encrypted, "base64"); + const encryptionKey = await resolveEncryptionKey(); + + expect(framed.length).toBe(12 + 16 + Buffer.byteLength("top secret")); + expect(decrypted).toBe("top secret"); + expect(encryptionKey.toString("hex")).toBe(KEY); + }); + + it("fails authentication when the key changes", async () => { + const encrypted = await encryptSecret("top secret"); + process.env.ENCRYPTION_KEY = "cd".repeat(32); + resetEncryptionKeyCacheForTests(); + + await expect(decryptSecret(encrypted)).rejects.toThrow(); + }); +}); diff --git a/web/tests/kms.test.ts b/web/tests/kms.test.ts new file mode 100644 index 00000000..ed4e1d74 --- /dev/null +++ b/web/tests/kms.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + config: null as unknown, + hasRegisteredServer: false, + hasSecret: false, + send: vi.fn(), +})); + +vi.mock("@aws-sdk/client-kms", () => { + class Command { + constructor(public input: unknown) {} + } + return { + DecryptCommand: class DecryptCommand extends Command {}, + DescribeKeyCommand: class DescribeKeyCommand extends Command {}, + EncryptCommand: class EncryptCommand extends Command {}, + GenerateDataKeyCommand: class GenerateDataKeyCommand extends Command {}, + KMSClient: class KMSClient { + send = mocks.send; + }, + }; +}); + +vi.mock("@/db", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn((table) => { + const tableName = table[Symbol.for("drizzle:Name")]; + const rows = () => { + if (tableName === "settings") { + return mocks.config ? [{ value: mocks.config }] : []; + } + if (tableName === "secrets") + return mocks.hasSecret ? [{ id: "secret" }] : []; + return mocks.hasRegisteredServer ? [{ id: "server" }] : []; + }; + return { + limit: vi.fn(async () => rows()), + where: vi.fn(() => ({ limit: vi.fn(async () => rows()) })), + }; + }), + })), + insert: vi.fn(() => ({ + values: vi.fn((row: { value: unknown }) => ({ + onConflictDoNothing: vi.fn(async () => { + mocks.config ??= row.value; + }), + })), + })), + }, +})); + +import { + EncryptionKeyUnavailableError, + resetEncryptionKeyCacheForTests, + resolveEncryptionKey, +} from "@/lib/kms"; + +const KEY_ARN = "arn:aws:kms:us-east-1:123456789012:key/example"; +const DEK = Buffer.alloc(32, 7); +const WRAPPED_DEK = Buffer.from("wrapped-dek"); + +describe("KMS encryption key resolution", () => { + beforeEach(() => { + mocks.config = null; + mocks.hasRegisteredServer = false; + mocks.hasSecret = false; + mocks.send.mockReset(); + process.env.ENCRYPTION_KMS_KEY_ARN = KEY_ARN; + process.env.AWS_REGION = "us-east-1"; + delete process.env.ENCRYPTION_KEY; + resetEncryptionKeyCacheForTests(); + }); + + afterEach(() => { + delete process.env.ENCRYPTION_KMS_KEY_ARN; + delete process.env.AWS_REGION; + delete process.env.ENCRYPTION_KEY; + resetEncryptionKeyCacheForTests(); + }); + + it("bootstraps, stores, and decrypts a generated DEK", async () => { + mocks.send.mockImplementation(async (command) => { + if (command.constructor.name === "DescribeKeyCommand") { + return { + KeyMetadata: { + KeyState: "Enabled", + KeySpec: "SYMMETRIC_DEFAULT", + KeyUsage: "ENCRYPT_DECRYPT", + }, + }; + } + if (command.constructor.name === "GenerateDataKeyCommand") { + return { CiphertextBlob: WRAPPED_DEK, Plaintext: Buffer.alloc(32, 9) }; + } + return { Plaintext: DEK }; + }); + + await expect(resolveEncryptionKey()).resolves.toEqual(DEK); + expect(mocks.config).toMatchObject({ + version: 1, + keyArn: KEY_ARN, + origin: "generated", + wrappedDekBase64: WRAPPED_DEK.toString("base64"), + }); + expect(mocks.send).toHaveBeenCalledTimes(3); + }); + + it("wraps the exact local key during migration", async () => { + process.env.ENCRYPTION_KEY = DEK.toString("hex"); + mocks.send.mockImplementation(async (command) => { + if (command.constructor.name === "DescribeKeyCommand") { + return { + KeyMetadata: { + KeyState: "Enabled", + KeySpec: "SYMMETRIC_DEFAULT", + KeyUsage: "ENCRYPT_DECRYPT", + }, + }; + } + if (command.constructor.name === "EncryptCommand") { + expect(Buffer.from(command.input.Plaintext)).toEqual(DEK); + return { CiphertextBlob: WRAPPED_DEK }; + } + return { Plaintext: DEK }; + }); + + await expect(resolveEncryptionKey()).resolves.toEqual(DEK); + expect(mocks.config).toMatchObject({ origin: "migrated" }); + }); + + it("refuses to generate a new key when encrypted state already exists", async () => { + mocks.hasSecret = true; + mocks.send.mockResolvedValueOnce({ + KeyMetadata: { + KeyState: "Enabled", + KeySpec: "SYMMETRIC_DEFAULT", + KeyUsage: "ENCRYPT_DECRYPT", + }, + }); + + await expect(resolveEncryptionKey()).rejects.toThrow( + "ENCRYPTION_KEY is required to migrate existing secrets", + ); + expect(mocks.config).toBeNull(); + expect(mocks.send).toHaveBeenCalledTimes(1); + }); + + it("rejects a leftover local key that does not match the stored key", async () => { + process.env.ENCRYPTION_KEY = Buffer.alloc(32, 8).toString("hex"); + mocks.config = { + version: 1, + keyArn: KEY_ARN, + origin: "generated", + wrappedAt: new Date().toISOString(), + wrappedDekBase64: WRAPPED_DEK.toString("base64"), + }; + mocks.send + .mockResolvedValueOnce({ + KeyMetadata: { + KeyState: "Enabled", + KeySpec: "SYMMETRIC_DEFAULT", + KeyUsage: "ENCRYPT_DECRYPT", + }, + }) + .mockResolvedValueOnce({ Plaintext: DEK }); + + await expect(resolveEncryptionKey()).rejects.toThrow( + "ENCRYPTION_KEY does not match the stored KMS-wrapped encryption key", + ); + }); + + it("fails without replacing a config recorded for another ARN", async () => { + mocks.config = { + version: 1, + keyArn: `${KEY_ARN}-other`, + origin: "generated", + wrappedAt: new Date().toISOString(), + wrappedDekBase64: WRAPPED_DEK.toString("base64"), + }; + + await expect(resolveEncryptionKey()).rejects.toBeInstanceOf( + EncryptionKeyUnavailableError, + ); + expect(mocks.send).not.toHaveBeenCalled(); + }); + + it("clears a failed initialization so the next operation retries", async () => { + mocks.config = { + version: 1, + keyArn: KEY_ARN, + origin: "generated", + wrappedAt: new Date().toISOString(), + wrappedDekBase64: WRAPPED_DEK.toString("base64"), + }; + mocks.send + .mockResolvedValueOnce({ + KeyMetadata: { + KeyState: "Enabled", + KeySpec: "SYMMETRIC_DEFAULT", + KeyUsage: "ENCRYPT_DECRYPT", + }, + }) + .mockRejectedValueOnce(new Error("KMS unavailable")) + .mockResolvedValueOnce({ + KeyMetadata: { + KeyState: "Enabled", + KeySpec: "SYMMETRIC_DEFAULT", + KeyUsage: "ENCRYPT_DECRYPT", + }, + }) + .mockResolvedValueOnce({ Plaintext: DEK }); + + await expect(resolveEncryptionKey()).rejects.toBeInstanceOf( + EncryptionKeyUnavailableError, + ); + await expect(resolveEncryptionKey()).resolves.toEqual(DEK); + expect(mocks.send).toHaveBeenCalledTimes(4); + }); +});