diff --git a/.bumpy/encrypted-env-blob.md b/.bumpy/encrypted-env-blob.md new file mode 100644 index 000000000..2e3aa2221 --- /dev/null +++ b/.bumpy/encrypted-env-blob.md @@ -0,0 +1,7 @@ +--- +"varlock": minor +"@varlock/nextjs-integration": patch +"@varlock/vite-integration": patch +--- + +add _VARLOCK_ENV_KEY support to encrypt env blob in build output diff --git a/AGENTS.md b/AGENTS.md index acdf2d54a..533ffca6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,11 +33,6 @@ - This monorepo uses **bumpy** (`@varlock/bumpy`) for version management - Changeset files live in `.bumpy/` and are created with `bunx @varlock/bumpy add` (or `bun run bumpy:add`) - Standard bump types: `major`, `minor`, `patch` -- **Isolated bump types**: `minor-isolated` and `patch-isolated` are natively supported - - These suppress dependency propagation — the package itself gets bumped but dependents are **not** automatically bumped - - Use **`minor-isolated`** for minor bumps that don't affect the library API consumed by dependents (e.g., CLI-only features in `varlock` that plugins/integrations don't depend on). This is the most common use case — because all packages are still on `0.x`, `^0.y.z` ranges treat minor bumps as out-of-range, which would otherwise cascade bumps to all dependents. - - `patch-isolated` exists but is rarely needed — patch bumps on `0.x` stay within `^` ranges and don't cascade - - `major-isolated` is intentionally **not** supported (major bumps must propagate to keep semver ranges valid) - Non-interactive changeset creation (for CI/AI): `bumpy add --packages "pkg:minor" --message "description" --name "changeset-name"` ## Linting diff --git a/framework-tests/frameworks/nextjs/nextjs-shared.ts b/framework-tests/frameworks/nextjs/nextjs-shared.ts index 81dc33f04..749ea2537 100644 --- a/framework-tests/frameworks/nextjs/nextjs-shared.ts +++ b/framework-tests/frameworks/nextjs/nextjs-shared.ts @@ -258,6 +258,32 @@ export function defineNextjsTests(nextVersion: number, testDir: string) { ], }); + nextEnv.describeScenario('encrypted env blob with _VARLOCK_ENV_KEY', { + command: buildCommand, + env: { _VARLOCK_ENV_KEY: '846a4cbdf4fefeff0da38d8f3766ffe50d8db12f8ce32849bb1e1a60ecb4ba0d' }, + templateFiles: { + 'app/page.tsx': 'pages/basic-page.tsx', + }, + fileAssertions: [ + { + description: 'runtime files contain encrypted blob (varlock:v1: prefix) instead of plaintext', + fileGlob: '.next/server/**/*runtime*.js', + shouldContain: ['varlock:v1:'], + shouldNotContain: ['super-secret-var'], + }, + { + description: 'prerendered HTML still has correct values (build uses plaintext env)', + fileGlob: '.next/**/*.html', + shouldContain: [ + 'next-prefixed-public-var', + 'unprefixed-public-var', + 'sensitive-var-available', + ], + shouldNotContain: ['super-secret-value'], + }, + ], + }); + nextEnv.describeScenario('leaky edge page', { command: buildCommand, templateFiles: { diff --git a/framework-tests/frameworks/vite/files/vite-configs/vite.config.resolved-env.ts b/framework-tests/frameworks/vite/files/vite-configs/vite.config.resolved-env.ts new file mode 100644 index 000000000..d5638c7e8 --- /dev/null +++ b/framework-tests/frameworks/vite/files/vite-configs/vite.config.resolved-env.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import { varlockVitePlugin } from '@varlock/vite-integration'; + +export default defineConfig({ + plugins: [varlockVitePlugin({ ssrInjectMode: 'resolved-env' })], +}); diff --git a/framework-tests/frameworks/vite/vite.test.ts b/framework-tests/frameworks/vite/vite.test.ts index 99e155762..9ec6e5604 100644 --- a/framework-tests/frameworks/vite/vite.test.ts +++ b/framework-tests/frameworks/vite/vite.test.ts @@ -245,6 +245,37 @@ describe('Vite', () => { }); }); + // ---- Encrypted env blob ---- + + describe('encrypted env blob', () => { + viteEnv.describeScenario('SSR build with _VARLOCK_ENV_KEY encrypts the blob', { + command: 'vite build --ssr src/ssr-entry.ts', + env: { _VARLOCK_ENV_KEY: '846a4cbdf4fefeff0da38d8f3766ffe50d8db12f8ce32849bb1e1a60ecb4ba0d' }, + templateFiles: { + 'vite.config.ts': 'vite-configs/vite.config.resolved-env.ts', + 'index.html': 'html/basic.html', + 'src/ssr-entry.ts': 'pages/ssr-entry.ts', + }, + fileAssertions: [ + { + description: 'SSR output contains encrypted blob (varlock:v1: prefix)', + fileGlob: 'dist/*.js', + shouldContain: ['varlock:v1:'], + }, + { + description: 'SSR output does not contain plaintext secret', + fileGlob: 'dist/*.js', + shouldNotContain: ['super-secret-value'], + }, + { + description: 'public vars are still statically replaced', + fileGlob: 'dist/*.js', + shouldContain: ['public-test-value'], + }, + ], + }); + }); + // ---- Dev server ---- describe('dev server', () => { diff --git a/packages/integrations/nextjs/src/turbopack-runtime-inject.ts b/packages/integrations/nextjs/src/turbopack-runtime-inject.ts index 0e0c1c5fd..5b680b1b7 100644 --- a/packages/integrations/nextjs/src/turbopack-runtime-inject.ts +++ b/packages/integrations/nextjs/src/turbopack-runtime-inject.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { encryptEnvBlobSync } from 'varlock/encrypt-env'; function debug(...args: Array) { if (!process.env.DEBUG_VARLOCK_NEXT_INTEGRATION) return; @@ -25,6 +26,11 @@ export function injectVarlockInitIntoTurbopackRuntime(nextDirPath: string) { return; } + let envPayload = rawEnv; + if (process.env._VARLOCK_ENV_KEY) { + envPayload = encryptEnvBlobSync(rawEnv, process.env._VARLOCK_ENV_KEY); + } + // Find turbopack runtime files ([turbopack]_runtime.js) and edge-wrapper files. // Node.js SSR/build uses [turbopack]_runtime.js, while edge runtime uses // edge-wrapper JS files (no [turbopack]_runtime.js exists for edge). @@ -58,7 +64,7 @@ export function injectVarlockInitIntoTurbopackRuntime(nextDirPath: string) { // Load both init bundles — server (full, node:zlib/node:http) and edge (no node builtins) const initServerSrc = fs.readFileSync(require.resolve('varlock/init-server'), 'utf8'); const initEdgeSrc = fs.readFileSync(require.resolve('varlock/init-edge'), 'utf8'); - const envInline = `process.env.__VARLOCK_ENV = process.env.__VARLOCK_ENV || ${JSON.stringify(rawEnv)};`; + const envInline = `process.env.__VARLOCK_ENV = process.env.__VARLOCK_ENV || ${JSON.stringify(envPayload)};`; // The CJS init bundles use `exports.X = ...` at the end, so we must provide // a dummy `exports` object when wrapping in an IIFE to avoid ReferenceError. diff --git a/packages/integrations/nextjs/src/webpack-plugin.ts b/packages/integrations/nextjs/src/webpack-plugin.ts index 469e116de..20b805354 100644 --- a/packages/integrations/nextjs/src/webpack-plugin.ts +++ b/packages/integrations/nextjs/src/webpack-plugin.ts @@ -6,6 +6,7 @@ import { import { patchGlobalServerResponse } from 'varlock/patch-server-response'; import { type SerializedEnvGraph } from 'varlock'; +import { encryptEnvBlobSync } from 'varlock/encrypt-env'; import type { NextConfig } from 'next'; const WEBPACK_PLUGIN_NAME = 'VarlockNextWebpackPlugin'; @@ -173,8 +174,12 @@ export function createWebpackConfigFn( // inline the resolved env so it's baked into the build // this removes the need for a .env.production.local file on platforms like Vercel const rawEnv = process.env.__VARLOCK_ENV; - const envInline = rawEnv - ? `process.env.__VARLOCK_ENV = process.env.__VARLOCK_ENV || ${JSON.stringify(rawEnv)};` + let envPayload = rawEnv; + if (rawEnv && process.env._VARLOCK_ENV_KEY) { + envPayload = encryptEnvBlobSync(rawEnv, process.env._VARLOCK_ENV_KEY); + } + const envInline = envPayload + ? `process.env.__VARLOCK_ENV = process.env.__VARLOCK_ENV || ${JSON.stringify(envPayload)};` : ''; const updatedSourceStr = [ diff --git a/packages/integrations/vite/src/index.ts b/packages/integrations/vite/src/index.ts index a044005f1..85c4e31a7 100644 --- a/packages/integrations/vite/src/index.ts +++ b/packages/integrations/vite/src/index.ts @@ -10,6 +10,7 @@ import { patchGlobalServerResponse } from 'varlock/patch-server-response'; import { patchGlobalResponse } from 'varlock/patch-response'; import { createDebug, type SerializedEnvGraph } from 'varlock'; import { execSyncVarlock } from 'varlock/exec-sync-varlock'; +import { encryptEnvBlobSync } from 'varlock/encrypt-env'; import { createReplacerTransformFn, SUPPORTED_FILES } from './transform'; @@ -274,7 +275,13 @@ See https://varlock.dev/integrations/vite/ for more details. ); } else { if (ssrInjectMode === 'resolved-env') { - injectCode.push(`globalThis.__varlockLoadedEnv = ${JSON.stringify(varlockLoadedEnv)};`); + const serialized = JSON.stringify(varlockLoadedEnv); + if (process.env._VARLOCK_ENV_KEY) { + const encrypted = encryptEnvBlobSync(serialized, process.env._VARLOCK_ENV_KEY); + injectCode.push(`globalThis.__varlockLoadedEnv = ${JSON.stringify(encrypted)};`); + } else { + injectCode.push(`globalThis.__varlockLoadedEnv = ${JSON.stringify(varlockLoadedEnv)};`); + } } // inject custom entry code from integrations diff --git a/packages/varlock-website/src/content/docs/integrations/nextjs.mdx b/packages/varlock-website/src/content/docs/integrations/nextjs.mdx index ec3c3a255..0ab7cbbe0 100644 --- a/packages/varlock-website/src/content/docs/integrations/nextjs.mdx +++ b/packages/varlock-website/src/content/docs/integrations/nextjs.mdx @@ -325,6 +325,51 @@ varlock run -- node .next/standalone/server.js ``` +--- + +## Encrypting the env blob ||encrypting-the-env-blob|| + +When deploying to platforms like Vercel, varlock injects the fully resolved env data into your build output so it's available at runtime without needing the CLI or filesystem access. By default, this blob is plaintext JSON — meaning anyone with access to the build artifact can read your secrets. + +To encrypt this blob, set the `_VARLOCK_ENV_KEY` environment variable with a 256-bit hex key. When present at build time, the blob is encrypted with AES-256-GCM before being injected. At runtime, the init bundle decrypts it using the same key from the runtime environment. + + + +1. **Generate and set the key on Vercel** + + This one-liner generates a key and sets it as a sensitive env var on Vercel for all environments: + + ```bash + varlock generate-key --plain | vercel env add _VARLOCK_ENV_KEY production preview development --sensitive + ``` + + Or generate and set it manually on your platform — the key must be available at both **build time** (for encryption) and **runtime** (for decryption): + + + +3. **Optionally add it to your `.env.local` for local builds** + + If you want local builds to also encrypt the blob (e.g., to test the flow), add the key to `.env.local`: + + ```env title=".env.local" + _VARLOCK_ENV_KEY=your-64-char-hex-key-here + ``` + + + +:::tip +You can define `_VARLOCK_ENV_KEY` in your `.env.schema` to enable validation (e.g., required in production). It will automatically be excluded from the injected blob and from type generation — it's infrastructure, not application config. + +```env-spec title=".env.schema" +# @sensitive +_VARLOCK_ENV_KEY= +``` +::: + +:::note +The encryption key is **never baked into the build**. It must always come from the runtime environment (e.g., a Vercel environment variable). The encrypted blob is useless without it. +::: + --- ## Troubleshooting diff --git a/packages/varlock-website/src/content/docs/integrations/vite.mdx b/packages/varlock-website/src/content/docs/integrations/vite.mdx index baf4da6b1..56186699e 100644 --- a/packages/varlock-website/src/content/docs/integrations/vite.mdx +++ b/packages/varlock-website/src/content/docs/integrations/vite.mdx @@ -118,7 +118,7 @@ varlockVitePlugin({ ssrInjectMode: 'auto-load' }) - `init-only` - injects varlock initialization code, but does not load the env vars. You must still boot your app via `varlock run` in this mode. - `auto-load` - injects `import 'varlock/auto-load';` to load your resolved env via the varlock CLI -- `resolved-env` - injects the fully resolved env data into your built code. This is useful in environments like Vercel/Cloudflare/etc where you have no control over your build command, and limited access to use CLI commands or the filesystem +- `resolved-env` - injects the fully resolved env data into your built code. This is useful in environments like Vercel/Cloudflare/etc where you have no control over your build command, and limited access to use CLI commands or the filesystem. See the [encrypting the env blob](#encrypting-the-env-blob) section for more information. **If not specified, we will attempt to infer the correct mode based on the presence of other vite plugins and environment variables, which give us hints about how your application will be run.** Otherwise defaulting to `init-only`. @@ -241,6 +241,57 @@ All non-sensitive items are bundled at build time via `ENV`, while `import.meta. +--- + +## Encrypting the env blob ||encrypting-the-env-blob|| + +When using `ssrInjectMode: 'resolved-env'`, varlock injects the fully resolved env data into your SSR build output. By default, this blob is plaintext JSON — meaning anyone with access to the build artifact can read your secrets. + +To encrypt this blob, set the `_VARLOCK_ENV_KEY` environment variable with a 256-bit hex key. When present at build time, the blob is encrypted with AES-256-GCM before being injected. At runtime, it's decrypted using the same key from the runtime environment. + + + +1. **Generate and set the key on your platform** + + For Vercel, this one-liner generates a key and sets it as a sensitive env var for all environments: + + ```bash + varlock generate-key --plain | vercel env add _VARLOCK_ENV_KEY production preview development --sensitive + ``` + + Or generate and set it manually — the key must be available at both **build time** (for encryption) and **runtime** (for decryption): + + + +2. **Ensure you're using `resolved-env` mode** + + Encryption only applies when the blob is inlined into the build: + + ```ts title="vite.config.ts" + varlockVitePlugin({ ssrInjectMode: 'resolved-env' }) + ``` + +3. **Optionally add it to your `.env.local` for local builds** + + ```env title=".env.local" + _VARLOCK_ENV_KEY=your-64-char-hex-key-here + ``` + + + +:::tip +You can define `_VARLOCK_ENV_KEY` in your `.env.schema` to enable validation. It will automatically be excluded from the injected blob and from type generation. + +```env-spec title=".env.schema" +# @sensitive +_VARLOCK_ENV_KEY= +``` +::: + +:::note +The encryption key is **never baked into the build**. It must always come from the runtime environment. The encrypted blob is useless without it. +::: + --- ## Reference diff --git a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx index 33c53b847..ba9129a37 100644 --- a/packages/varlock-website/src/content/docs/reference/cli-commands.mdx +++ b/packages/varlock-website/src/content/docs/reference/cli-commands.mdx @@ -319,6 +319,16 @@ You can also temporarily opt out by setting the `VARLOCK_TELEMETRY_DISABLED` env
+### `varlock generate-key` ||generate-key|| + +Generates a random 256-bit encryption key for use with `_VARLOCK_ENV_KEY`. This key is used to encrypt the resolved env blob that gets baked into your build output on certain frameworks/platforms. + +```bash +varlock generate-key +``` + +See the [Next.js](/integrations/nextjs/#encrypting-the-env-blob) and [Vite](/integrations/vite/#encrypting-the-env-blob) integration docs for setup instructions. + ### `varlock help` ||help|| Displays general help information, alias for `varlock --help` diff --git a/packages/varlock/package.json b/packages/varlock/package.json index 4716f701b..97134ee54 100644 --- a/packages/varlock/package.json +++ b/packages/varlock/package.json @@ -95,6 +95,11 @@ "types": "./dist/runtime/init-edge.d.cts", "default": "./dist/runtime/init-edge.cjs" }, + "./encrypt-env": { + "ts-src": "./src/runtime/crypto.ts", + "types": "./dist/runtime/crypto.d.ts", + "default": "./dist/runtime/crypto.js" + }, "./exec-sync-varlock": { "ts-src": "./src/lib/exec-sync-varlock.ts", "types": "./dist/lib/exec-sync-varlock.d.ts", diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index 37ad06b4d..6c490cb60 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -23,6 +23,7 @@ import { commandSpec as explainCommandSpec } from './commands/explain.command'; import { commandSpec as scanCommandSpec } from './commands/scan.command'; import { commandSpec as typegenCommandSpec } from './commands/typegen.command'; import { commandSpec as installPluginCommandSpec } from './commands/install-plugin.command'; +import { commandSpec as generateKeyCommandSpec } from './commands/generate-key.command'; // import { commandSpec as loginCommandSpec } from './commands/login.command'; // import { commandSpec as pluginCommandSpec } from './commands/plugin.command'; @@ -60,6 +61,7 @@ subCommands.set('telemetry', buildLazyCommand(telemetryCommandSpec, async () => subCommands.set('scan', buildLazyCommand(scanCommandSpec, async () => await import('./commands/scan.command'))); subCommands.set('typegen', buildLazyCommand(typegenCommandSpec, async () => await import('./commands/typegen.command'))); subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, async () => await import('./commands/install-plugin.command'))); +subCommands.set('generate-key', buildLazyCommand(generateKeyCommandSpec, async () => await import('./commands/generate-key.command'))); // subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command'))); // subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command'))); diff --git a/packages/varlock/src/cli/commands/generate-key.command.ts b/packages/varlock/src/cli/commands/generate-key.command.ts new file mode 100644 index 000000000..13a2fc1a7 --- /dev/null +++ b/packages/varlock/src/cli/commands/generate-key.command.ts @@ -0,0 +1,24 @@ +import { define } from 'gunshi'; + +import { generateEncryptionKeyHex } from '../../runtime/crypto'; +import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; + +export const commandSpec = define({ + name: 'generate-key', + description: 'Generate an encryption key for encrypting the env blob in deployments', + args: {}, +}); + +export const commandFn: TypedGunshiCommandFn = async () => { + const key = generateEncryptionKeyHex(); + + console.log(''); + console.log('Generated _VARLOCK_ENV_KEY:'); + console.log(''); + console.log(` ${key}`); + console.log(''); + console.log('Set this as an environment variable on your deployment platform (e.g., Vercel, Cloudflare).'); + console.log('When _VARLOCK_ENV_KEY is present at build time, the resolved env blob will be'); + console.log('encrypted before being injected into the build output, and decrypted at runtime.'); + console.log(''); +}; diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 62c272562..9554483a4 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -544,6 +544,9 @@ export class EnvGraph { }); } for (const itemKey of this.sortedConfigKeys) { + // _VARLOCK_ENV_KEY is used to encrypt/decrypt the blob itself — including it + // would be redundant (the runtime already has it via process.env) and wasteful. + if (itemKey === '_VARLOCK_ENV_KEY') continue; const item = this.configSchema[itemKey]; serializedGraph.config[itemKey] = { value: item.resolvedValue, diff --git a/packages/varlock/src/env-graph/lib/type-generation.ts b/packages/varlock/src/env-graph/lib/type-generation.ts index 553dce1e0..6353b0a8c 100644 --- a/packages/varlock/src/env-graph/lib/type-generation.ts +++ b/packages/varlock/src/env-graph/lib/type-generation.ts @@ -241,6 +241,8 @@ export async function generateTypes(graph: EnvGraph, lang: string, typesPath: st // Skip items that exist only in env-specific sources const items: Array = []; for (const itemKey of graph.sortedConfigKeys) { + // _VARLOCK_ENV_KEY is infrastructure — not accessed via ENV proxy + if (itemKey === '_VARLOCK_ENV_KEY') continue; const configItem = graph.configSchema[itemKey]; if (!configItem.defsForTypeGeneration.length) continue; items.push(await configItem.getTypeGenInfo()); diff --git a/packages/varlock/src/runtime/crypto.ts b/packages/varlock/src/runtime/crypto.ts new file mode 100644 index 000000000..bf054c5a0 --- /dev/null +++ b/packages/varlock/src/runtime/crypto.ts @@ -0,0 +1,105 @@ +/** + * Encrypt/decrypt utilities for the varlock env blob. + * + * Uses AES-256-GCM with a 12-byte random IV. + * Encrypted format: "varlock:v1:" + base64(iv[12] + ciphertext + authTag[16]) + * + * Sync versions use Node.js `node:crypto` (build-time + init-server). + * Async version uses Web Crypto API (init-edge, where node:crypto is unavailable). + */ + +const ENCRYPTED_PREFIX = 'varlock:v1:'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const KEY_LENGTH_HEX = 64; // 32 bytes = 64 hex chars + +export function isEncryptedBlob(value: string): boolean { + return value.startsWith(ENCRYPTED_PREFIX); +} + +function validateHexKey(hexKey: string): void { + if (hexKey.length !== KEY_LENGTH_HEX || !/^[0-9a-f]+$/i.test(hexKey)) { + throw new Error(`[varlock] _VARLOCK_ENV_KEY must be a ${KEY_LENGTH_HEX}-character hex string (256 bits)`); + } +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +// -- Sync (Node.js node:crypto) ------------------------------------------ + +export function encryptEnvBlobSync(json: string, hexKey: string): string { + validateHexKey(hexKey); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const crypto = require('node:crypto'); + const keyBytes = hexToBytes(hexKey); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv('aes-256-gcm', keyBytes, iv); + const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + const combined = Buffer.concat([iv, encrypted, authTag]); + return ENCRYPTED_PREFIX + combined.toString('base64'); +} + +export function decryptEnvBlobSync(encrypted: string, hexKey: string): string { + validateHexKey(hexKey); + if (!isEncryptedBlob(encrypted)) { + throw new Error('[varlock] expected encrypted blob with varlock:v1: prefix'); + } + // eslint-disable-next-line @typescript-eslint/no-require-imports + const crypto = require('node:crypto'); + const combined = Buffer.from(encrypted.slice(ENCRYPTED_PREFIX.length), 'base64'); + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(combined.length - AUTH_TAG_LENGTH); + const ciphertext = combined.subarray(IV_LENGTH, combined.length - AUTH_TAG_LENGTH); + const decipher = crypto.createDecipheriv('aes-256-gcm', hexToBytes(hexKey), iv); + decipher.setAuthTag(authTag); + return decipher.update(ciphertext, undefined, 'utf8') + decipher.final('utf8'); +} + +// -- Async (Web Crypto API, edge-compatible) ------------------------------ +// Currently unused — all init paths use the sync version since every major edge +// runtime now supports node:crypto (Vercel Edge, Cloudflare with nodejs_compat, Deno). +// Kept as a public export in case consumers need to decrypt in a pure Web Crypto context. + +export async function decryptEnvBlobAsync(encrypted: string, hexKey: string): Promise { + validateHexKey(hexKey); + if (!isEncryptedBlob(encrypted)) { + throw new Error('[varlock] expected encrypted blob with varlock:v1: prefix'); + } + const raw = atob(encrypted.slice(ENCRYPTED_PREFIX.length)); + const combined = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) combined[i] = raw.charCodeAt(i); + + const iv = combined.slice(0, IV_LENGTH); + // Web Crypto expects ciphertext + authTag concatenated (no separation needed) + const ciphertextWithTag = combined.slice(IV_LENGTH); + + const keyBytes = hexToBytes(hexKey); + const key = await globalThis.crypto.subtle.importKey( + 'raw', + keyBytes.buffer as ArrayBuffer, + { name: 'AES-GCM' }, + false, + ['decrypt'], + ); + const decrypted = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertextWithTag, + ); + return new TextDecoder().decode(decrypted); +} + +// -- Key generation ------------------------------------------------------- + +export function generateEncryptionKeyHex(): string { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const crypto = require('node:crypto'); + return (crypto.randomBytes(32) as Buffer).toString('hex'); +} diff --git a/packages/varlock/src/runtime/env.ts b/packages/varlock/src/runtime/env.ts index 4ae45773c..6d74c5623 100644 --- a/packages/varlock/src/runtime/env.ts +++ b/packages/varlock/src/runtime/env.ts @@ -247,7 +247,24 @@ export function initVarlockEnv(opts?: { let serializedEnvData: SerializedEnvGraph; // when we inject resolved config at build time, we store it here if ((globalThis as any).__varlockLoadedEnv) { - serializedEnvData = (globalThis as any).__varlockLoadedEnv; + let loaded = (globalThis as any).__varlockLoadedEnv; + // if the blob was encrypted at build time, it will be a string rather than an object + if (typeof loaded === 'string') { + // Lazy-import to avoid pulling node:crypto into browser/frontend bundles. + // The init bundles (init-server/init-edge) handle the process.env.__VARLOCK_ENV + // path themselves, so this branch only fires for the globalThis path + // (Vite resolved-env, Cloudflare). + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { isEncryptedBlob, decryptEnvBlobSync } = require('./crypto'); + if (isEncryptedBlob(loaded)) { + const key = processExists ? process.env._VARLOCK_ENV_KEY : undefined; + if (!key) throw new Error('[varlock] __varlockLoadedEnv is encrypted but _VARLOCK_ENV_KEY is not set'); + loaded = decryptEnvBlobSync(loaded, key); + } + loaded = JSON.parse(loaded); + (globalThis as any).__varlockLoadedEnv = loaded; + } + serializedEnvData = loaded; // otherwise if we inject via `varlock run` or have already loaded, it will be in process.env } else if (processExists && process.env.__VARLOCK_ENV) { diff --git a/packages/varlock/src/runtime/init-edge.ts b/packages/varlock/src/runtime/init-edge.ts index 032e66ed2..7e477d2ab 100644 --- a/packages/varlock/src/runtime/init-edge.ts +++ b/packages/varlock/src/runtime/init-edge.ts @@ -6,6 +6,16 @@ import { initVarlockEnv } from '../runtime/env'; import { patchGlobalConsole } from '../runtime/patch-console'; import { patchGlobalResponse } from '../runtime/patch-response'; +import { isEncryptedBlob, decryptEnvBlobSync } from '../runtime/crypto'; + +// Decrypt the env blob if it was encrypted at build time. +// Modern edge runtimes (Vercel Edge, Cloudflare with nodejs_compat) support node:crypto, +// so we use the sync version here. Pure Web Crypto async path is available in env.ts as fallback. +if (process.env.__VARLOCK_ENV && isEncryptedBlob(process.env.__VARLOCK_ENV)) { + const key = process.env._VARLOCK_ENV_KEY; + if (!key) throw new Error('[varlock] __VARLOCK_ENV is encrypted but _VARLOCK_ENV_KEY is not set'); + process.env.__VARLOCK_ENV = decryptEnvBlobSync(process.env.__VARLOCK_ENV, key); +} initVarlockEnv(); patchGlobalConsole(); diff --git a/packages/varlock/src/runtime/init-server.ts b/packages/varlock/src/runtime/init-server.ts index e1e3b3a17..d4afae951 100644 --- a/packages/varlock/src/runtime/init-server.ts +++ b/packages/varlock/src/runtime/init-server.ts @@ -6,6 +6,14 @@ import { initVarlockEnv } from '../runtime/env'; import { patchGlobalConsole } from '../runtime/patch-console'; import { patchGlobalServerResponse } from '../runtime/patch-server-response'; import { patchGlobalResponse } from '../runtime/patch-response'; +import { isEncryptedBlob, decryptEnvBlobSync } from '../runtime/crypto'; + +// Decrypt the env blob if it was encrypted at build time +if (process.env.__VARLOCK_ENV && isEncryptedBlob(process.env.__VARLOCK_ENV)) { + const key = process.env._VARLOCK_ENV_KEY; + if (!key) throw new Error('[varlock] __VARLOCK_ENV is encrypted but _VARLOCK_ENV_KEY is not set'); + process.env.__VARLOCK_ENV = decryptEnvBlobSync(process.env.__VARLOCK_ENV, key); +} initVarlockEnv(); patchGlobalConsole(); diff --git a/packages/varlock/src/runtime/test/crypto.test.ts b/packages/varlock/src/runtime/test/crypto.test.ts new file mode 100644 index 000000000..2b488942a --- /dev/null +++ b/packages/varlock/src/runtime/test/crypto.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { + encryptEnvBlobSync, + decryptEnvBlobSync, + decryptEnvBlobAsync, + isEncryptedBlob, + generateEncryptionKeyHex, +} from '../crypto'; + +const TEST_KEY = 'a'.repeat(64); // valid 256-bit hex key +const TEST_JSON = JSON.stringify({ + config: { API_KEY: { value: 'secret-123', isSensitive: true } }, + sources: [], + settings: {}, +}); + +describe('crypto', () => { + describe('isEncryptedBlob', () => { + it('returns true for varlock:v1: prefixed strings', () => { + expect(isEncryptedBlob('varlock:v1:abc')).toBe(true); + }); + it('returns false for plain JSON', () => { + expect(isEncryptedBlob('{"config":{}}')).toBe(false); + }); + }); + + describe('generateEncryptionKeyHex', () => { + it('generates a 64-character hex string', () => { + const key = generateEncryptionKeyHex(); + expect(key).toMatch(/^[0-9a-f]{64}$/); + }); + it('generates unique keys', () => { + const a = generateEncryptionKeyHex(); + const b = generateEncryptionKeyHex(); + expect(a).not.toBe(b); + }); + }); + + describe('sync encrypt/decrypt', () => { + it('round-trips correctly', () => { + const encrypted = encryptEnvBlobSync(TEST_JSON, TEST_KEY); + expect(isEncryptedBlob(encrypted)).toBe(true); + const decrypted = decryptEnvBlobSync(encrypted, TEST_KEY); + expect(decrypted).toBe(TEST_JSON); + }); + + it('produces different ciphertext each time (random IV)', () => { + const a = encryptEnvBlobSync(TEST_JSON, TEST_KEY); + const b = encryptEnvBlobSync(TEST_JSON, TEST_KEY); + expect(a).not.toBe(b); + }); + + it('rejects invalid key length', () => { + expect(() => encryptEnvBlobSync(TEST_JSON, 'tooshort')).toThrow('64-character hex string'); + }); + + it('rejects wrong key on decrypt', () => { + const encrypted = encryptEnvBlobSync(TEST_JSON, TEST_KEY); + const wrongKey = 'b'.repeat(64); + expect(() => decryptEnvBlobSync(encrypted, wrongKey)).toThrow(); + }); + + it('rejects non-encrypted input on decrypt', () => { + expect(() => decryptEnvBlobSync('plain text', TEST_KEY)).toThrow('varlock:v1: prefix'); + }); + }); + + describe('async decrypt (Web Crypto)', () => { + it('decrypts what sync encrypted', async () => { + const encrypted = encryptEnvBlobSync(TEST_JSON, TEST_KEY); + const decrypted = await decryptEnvBlobAsync(encrypted, TEST_KEY); + expect(decrypted).toBe(TEST_JSON); + }); + + it('rejects wrong key', async () => { + const encrypted = encryptEnvBlobSync(TEST_JSON, TEST_KEY); + const wrongKey = 'b'.repeat(64); + await expect(decryptEnvBlobAsync(encrypted, wrongKey)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/varlock/tsup.config.ts b/packages/varlock/tsup.config.ts index 56fa7c54e..4a059d610 100644 --- a/packages/varlock/tsup.config.ts +++ b/packages/varlock/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig([ 'src/runtime/patch-console.ts', 'src/runtime/patch-response.ts', + 'src/runtime/crypto.ts', 'src/env.ts', 'src/auto-load.ts', 'src/dotenv-compat.ts', // exposed under `/config` to match dotenv