diff --git a/.changeset/wasm-env-credentials.md b/.changeset/wasm-env-credentials.md new file mode 100644 index 00000000..b5494ab3 --- /dev/null +++ b/.changeset/wasm-env-credentials.md @@ -0,0 +1,41 @@ +--- +'@cipherstash/stack': minor +--- + +`@cipherstash/stack/wasm-inline`: credentials now fall back to the environment. + +The native entry has always resolved an omitted credential from `CS_*` env vars +or `~/.cipherstash`. The WASM entry had no fallback at all, so every edge caller +plumbed four values by hand even in Deno, Node, and Bun where the environment is +right there. + +`config` is now optional, and each field falls back individually: + +| Field | Environment variable | +| --- | --- | +| `clientId` | `CS_CLIENT_ID` | +| `clientKey` | `CS_CLIENT_KEY` | +| `workspaceCrn` | `CS_WORKSPACE_CRN` | +| `accessKey` | `CS_CLIENT_ACCESS_KEY` (or `CS_ACCESS_KEY`) | + +```typescript +// Deno / Node / Bun, with the four CS_* vars set +const client = await Encryption({ schemas: [users] }) +``` + +An explicit config value always wins — the environment only fills gaps — so +nothing changes for callers passing a full config today. `clientId` and +`clientKey` are read as a **pair**: setting only one env var fills neither, +matching the native reader, so a stale half-pair cannot silently combine with a +config value into a mismatched client. + +**This needs a `process.env`, so it works in Deno, Node, and Bun but not in +Cloudflare Workers or browsers.** Workers hand their environment to the fetch +handler rather than exposing a global — read it there and pass the values on +`config`. Missing-credential errors now name the field, the environment variable +that would have supplied it, and say when the runtime has no environment to read +at all. + +Do not rely on the fallback in browser-targeted builds: bundlers commonly inline +`process.env` at build time, which would bake the access key into client-side +JavaScript. Pass a pre-built `authStrategy` instead. diff --git a/packages/stack/__tests__/wasm-inline-env-credentials.test.ts b/packages/stack/__tests__/wasm-inline-env-credentials.test.ts new file mode 100644 index 00000000..241dc4fd --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-env-credentials.test.ts @@ -0,0 +1,227 @@ +/** + * Offline unit tests for the WASM entry's environment-variable credential + * fallback. + * + * The native entry gets this for free: an omitted credential resolves from env + * or `~/.cipherstash` inside protect-ffi. The WASM entry has no filesystem and + * no profile store, so env is the only fallback — and until now there was + * none, which meant every edge caller had to plumb four values by hand even in + * Deno / Node / Bun where the environment is right there. + * + * Two things here are worth more than the happy path: + * + * 1. **The `typeof process` guard.** This module is the edge entry. In a + * Cloudflare Worker or a browser `process` is undeclared, so a bare + * `process.env` read is a `ReferenceError` — a crash on client + * construction, not a missed lookup. `process?.env` would NOT save it; + * optional chaining still evaluates the identifier. The test deletes the + * global to reproduce that runtime honestly. + * 2. **The keypair rule.** `CS_CLIENT_ID` / `CS_CLIENT_KEY` are filled only + * when BOTH are present, matching protect-ffi's native reader. Half a + * keypair is a misconfiguration; silently mixing one env value with one + * config value fails later, inside ZeroKMS, with a far less obvious error. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cipherstash/auth/wasm-inline', () => ({ + AccessKeyStrategy: { + create: vi.fn(() => ({ data: { __mock: 'access-key-strategy' } })), + }, + OidcFederationStrategy: class {}, +})) + +vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ + newClient: vi.fn(), + encrypt: vi.fn(), + decrypt: vi.fn(), + isEncrypted: vi.fn(), +})) + +import { withEnvCredentials } from '../src/wasm-inline' + +const CRN = 'crn:ap-southeast-2.aws:test-workspace' + +const ENV_KEYS = [ + 'CS_CLIENT_ID', + 'CS_CLIENT_KEY', + 'CS_WORKSPACE_CRN', + 'CS_CLIENT_ACCESS_KEY', + 'CS_ACCESS_KEY', +] as const + +let saved: Record + +beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) + for (const k of ENV_KEYS) delete process.env[k] +}) + +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k] + else process.env[k] = saved[k] + } + vi.restoreAllMocks() +}) + +describe('withEnvCredentials', () => { + it('fills every credential from the environment', () => { + process.env.CS_CLIENT_ID = 'env-id' + process.env.CS_CLIENT_KEY = 'env-key' + process.env.CS_WORKSPACE_CRN = CRN + process.env.CS_CLIENT_ACCESS_KEY = 'env-access' + + expect(withEnvCredentials({} as never)).toMatchObject({ + clientId: 'env-id', + clientKey: 'env-key', + workspaceCrn: CRN, + accessKey: 'env-access', + }) + }) + + it('lets an explicit config value win over the environment', () => { + process.env.CS_CLIENT_ID = 'env-id' + process.env.CS_CLIENT_KEY = 'env-key' + process.env.CS_WORKSPACE_CRN = 'crn:env' + process.env.CS_CLIENT_ACCESS_KEY = 'env-access' + + const resolved = withEnvCredentials({ + clientId: 'cfg-id', + clientKey: 'cfg-key', + workspaceCrn: CRN, + accessKey: 'cfg-access', + } as never) + + expect(resolved).toMatchObject({ + clientId: 'cfg-id', + clientKey: 'cfg-key', + workspaceCrn: CRN, + accessKey: 'cfg-access', + }) + }) + + it('fills gaps without disturbing what was supplied', () => { + process.env.CS_CLIENT_ID = 'env-id' + process.env.CS_CLIENT_KEY = 'env-key' + process.env.CS_WORKSPACE_CRN = CRN + + const resolved = withEnvCredentials({ accessKey: 'cfg-access' } as never) + + expect(resolved).toMatchObject({ + clientId: 'env-id', + clientKey: 'env-key', + workspaceCrn: CRN, + accessKey: 'cfg-access', + }) + }) + + describe('the clientId / clientKey keypair', () => { + it('ignores CS_CLIENT_ID when CS_CLIENT_KEY is absent', () => { + process.env.CS_CLIENT_ID = 'env-id' + + const resolved = withEnvCredentials({} as never) + + expect(resolved.clientId).toBeUndefined() + expect(resolved.clientKey).toBeUndefined() + }) + + it('ignores CS_CLIENT_KEY when CS_CLIENT_ID is absent', () => { + process.env.CS_CLIENT_KEY = 'env-key' + + const resolved = withEnvCredentials({} as never) + + expect(resolved.clientId).toBeUndefined() + expect(resolved.clientKey).toBeUndefined() + }) + + it('does not mix one env value with one config value', () => { + // The dangerous case: a stale CS_CLIENT_ID alongside a config clientKey + // would otherwise produce a mismatched pair that fails inside ZeroKMS. + process.env.CS_CLIENT_ID = 'env-id' + + const resolved = withEnvCredentials({ clientKey: 'cfg-key' } as never) + + expect(resolved.clientId).toBeUndefined() + expect(resolved.clientKey).toBe('cfg-key') + }) + }) + + describe('the access-key variable names', () => { + it('prefers CS_CLIENT_ACCESS_KEY, the documented name', () => { + process.env.CS_CLIENT_ACCESS_KEY = 'documented' + process.env.CS_ACCESS_KEY = 'legacy' + + expect(withEnvCredentials({} as never).accessKey).toBe('documented') + }) + + it('accepts CS_ACCESS_KEY, which protect-ffi native reads', () => { + process.env.CS_ACCESS_KEY = 'legacy' + + expect(withEnvCredentials({} as never).accessKey).toBe('legacy') + }) + }) + + describe('the strategy path', () => { + it('does not fill accessKey when an authStrategy is supplied', () => { + // `accessKey` is `never` on the strategy arm, and the two are mutually + // exclusive — filling it from a stray env var would make + // `resolveStrategy` reject a config the caller wrote correctly. + process.env.CS_CLIENT_ACCESS_KEY = 'env-access' + + const resolved = withEnvCredentials({ + authStrategy: { getToken: async () => ({ data: { token: 't' } }) }, + } as never) + + expect(resolved.accessKey).toBeUndefined() + expect(resolved.authStrategy).toBeDefined() + }) + + it('does not fill accessKey for the deprecated strategy alias either', () => { + process.env.CS_CLIENT_ACCESS_KEY = 'env-access' + + const resolved = withEnvCredentials({ + strategy: { getToken: async () => ({ data: { token: 't' } }) }, + } as never) + + expect(resolved.accessKey).toBeUndefined() + }) + + it('still fills the client keypair, which the strategy path needs', () => { + // The strategy authenticates; the client key still encrypts. Both paths + // need clientId / clientKey. + process.env.CS_CLIENT_ID = 'env-id' + process.env.CS_CLIENT_KEY = 'env-key' + + const resolved = withEnvCredentials({ + authStrategy: { getToken: async () => ({ data: { token: 't' } }) }, + } as never) + + expect(resolved).toMatchObject({ + clientId: 'env-id', + clientKey: 'env-key', + }) + }) + }) + + describe('runtimes with no process global', () => { + it('returns undefined rather than throwing a ReferenceError', () => { + // Reproduces a Cloudflare Worker / browser: `process` is not merely + // empty, it is undeclared. A bare `process.env` read here is a crash on + // client construction — and `process?.env` would crash identically, + // which is why the implementation uses `typeof`. + const originalProcess = globalThis.process + // biome-ignore lint/performance/noDelete: assigning undefined leaves the + // binding declared, so `typeof process` still reports "object" and the + // guard under test is never exercised. + delete (globalThis as { process?: unknown }).process + + try { + expect(() => withEnvCredentials({} as never)).not.toThrow() + expect(withEnvCredentials({} as never).clientId).toBeUndefined() + } finally { + globalThis.process = originalProcess + } + }) + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-strategy.test.ts b/packages/stack/__tests__/wasm-inline-strategy.test.ts index 9c393129..8dbffc61 100644 --- a/packages/stack/__tests__/wasm-inline-strategy.test.ts +++ b/packages/stack/__tests__/wasm-inline-strategy.test.ts @@ -149,17 +149,28 @@ describe('wasm-inline resolveStrategy', () => { // JS callers bypass the compile-time union, so the no-strategy arm must // reject a missing CRN or access key instead of forwarding `undefined` // into `AccessKeyStrategy.create`. + // + // The message names only what is actually missing, and the env var that + // would have supplied it. Naming both fields when one was provided sends + // the reader looking at config they already got right. expect(() => // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — no strategy, no accessKey resolveStrategy({ workspaceCrn: CRN } as any), ).toThrowError( - /`config\.workspaceCrn` and `config\.accessKey` are required/, + /`config\.accessKey` \(or `CS_CLIENT_ACCESS_KEY`\) is required/, ) expect(() => // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — no strategy, no workspaceCrn resolveStrategy({ accessKey: 'CSAK.test' } as any), ).toThrowError( - /`config\.workspaceCrn` and `config\.accessKey` are required/, + /`config\.workspaceCrn` \(or `CS_WORKSPACE_CRN`\) is required/, + ) + // Both missing — both named, and the verb agrees. + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — nothing supplied + resolveStrategy({} as any), + ).toThrowError( + /`config\.workspaceCrn` \(or `CS_WORKSPACE_CRN`\) and `config\.accessKey` \(or `CS_CLIENT_ACCESS_KEY`\) are required/, ) // The guard must short-circuit *before* building a strategy. expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled() diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 42d1d75c..e85df3d5 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -214,10 +214,16 @@ export type WasmPlaintext = * `strategy` is retained as a deprecated alias (see below). */ export type WasmClientConfig = { - /** Workspace client identifier — required by the WASM client. */ - clientId: string - /** Workspace client key — required by the WASM client. */ - clientKey: string + /** + * Workspace client identifier. + * + * Optional only because it falls back to `CS_CLIENT_ID`; one or the other + * must resolve. See {@link WasmEncryptionConfig} for the env table and the + * runtimes the fallback works in. + */ + clientId?: string + /** Workspace client key. Falls back to `CS_CLIENT_KEY`. */ + clientKey?: string // Provide exactly one of `accessKey` (we build the strategy) or a // pre-built auth strategy — never both, never neither. } & ( @@ -227,9 +233,12 @@ export type WasmClientConfig = { * `"crn:ap-southeast-2.aws:my-workspace-id"`. Required on the * access-key path — it is the single source of truth for workspace * identity and `AccessKeyStrategy` derives the region from it. + * + * Falls back to `CS_WORKSPACE_CRN`. */ - workspaceCrn: string - accessKey: string + workspaceCrn?: string + /** Falls back to `CS_CLIENT_ACCESS_KEY`. */ + accessKey?: string authStrategy?: never strategy?: never } @@ -282,7 +291,33 @@ export type WasmEncryptionConfig = { /** One or more EQL v3 tables, authored with `types` / `encryptedTable` from * this entry. The WASM entry is EQL v3 only. */ schemas: [AnyV3Table, ...AnyV3Table[]] - config: WasmClientConfig + /** + * Credentials and auth. Every field falls back to an environment variable, + * so this can be omitted entirely when the environment supplies all four: + * + * | Field | Environment variable | + * | --- | --- | + * | `clientId` | `CS_CLIENT_ID` | + * | `clientKey` | `CS_CLIENT_KEY` | + * | `workspaceCrn` | `CS_WORKSPACE_CRN` | + * | `accessKey` | `CS_CLIENT_ACCESS_KEY` | + * + * An explicit value always wins; the environment only fills gaps. This is + * the WASM analogue of the native entry's default credential path, which + * resolves from env or `~/.cipherstash` — with the difference that there is + * no profile store here, only env. + * + * **The fallback needs a `process.env`**, so it works in Deno, Node, and + * Bun, and does NOT work in Cloudflare Workers or a browser. Workers pass + * their environment to the fetch handler rather than exposing a global, so + * read it there and pass the values explicitly. A missing credential is + * reported by name either way. + * + * Do not rely on the fallback in browser-targeted builds: bundlers commonly + * inline `process.env` at build time, which would bake the access key into + * client-side JavaScript. Pass a pre-built `authStrategy` instead. + */ + config?: WasmClientConfig } /** @@ -1370,7 +1405,24 @@ export async function Encryption( buildEncryptConfig(...schemas), ) - const strategy = resolveStrategy(clientConfig) + // Fill credential gaps from the environment before anything reads them, so + // `resolveStrategy` and the client construction below see one resolved + // config rather than each applying its own fallback. + const resolvedConfig = withEnvCredentials(clientConfig ?? ({} as never)) + + if (!resolvedConfig.clientId || !resolvedConfig.clientKey) { + const missing = [ + !resolvedConfig.clientId && '`config.clientId` (or `CS_CLIENT_ID`)', + !resolvedConfig.clientKey && '`config.clientKey` (or `CS_CLIENT_KEY`)', + ].filter(Boolean) + throw new Error( + `[encryption]: ${missing.join(' and ')} ${ + missing.length > 1 ? 'are' : 'is' + } required.${envHint()} Note that \`CS_CLIENT_ID\` and \`CS_CLIENT_KEY\` are read as a pair — setting only one is ignored.`, + ) + } + + const strategy = resolveStrategy(resolvedConfig) // protect-ffi 0.25 takes a single options object with the strategy nested // under `strategy` (0.24 passed the strategy as a separate first argument). @@ -1380,8 +1432,8 @@ export async function Encryption( const client = await wasmNewClient({ strategy, encryptConfig: normalizeCastAs(encryptConfig), - clientId: clientConfig.clientId, - clientKey: clientConfig.clientKey, + clientId: resolvedConfig.clientId, + clientKey: resolvedConfig.clientKey, eqlVersion: 3, } as never) @@ -1545,6 +1597,78 @@ export function __resetStrategyDeprecationWarningForTests(): void { warnedStrategyDeprecated = false } +/** + * Read one environment variable, or `undefined` where there is no environment + * to read. + * + * The `typeof process` guard is the whole reason this is a function. This + * module is the edge entry: it runs in Cloudflare Workers and browsers, where + * `process` is not defined and a bare `process.env` read is a + * `ReferenceError`, not a miss. Optional chaining alone would not save it — + * `process?.env` still throws on an undeclared identifier. + */ +function readEnv(name: string): string | undefined { + return typeof process !== 'undefined' ? process.env?.[name] : undefined +} + +/** + * Appended to a missing-credential error. Without it, a Worker developer sees + * "set `CS_CLIENT_ID`" and reasonably tries exactly that — in a runtime where + * it can never be read, because Workers hand their environment to the fetch + * handler instead of exposing a global. Say so at the point of failure. + */ +function envHint(): string { + return typeof process !== 'undefined' + ? '' + : ' (no `process.env` in this runtime, so the environment-variable fallback is unavailable — pass the value on `config`, e.g. from the Worker `env` argument)' +} + +/** + * Fill missing credentials from the environment. + * + * The WASM analogue of what the native entry gets for free: there, an omitted + * credential resolves from env or `~/.cipherstash` inside protect-ffi. This + * entry has no filesystem and no profile store, so env is the only fallback — + * and it is a real one, since Deno, Node, and Bun all provide `process.env`. + * + * An explicit config value always wins. Only gaps are filled, so passing a + * pre-built `authStrategy` and nothing else still works: `accessKey` stays + * unset because the strategy path never reads it. + * + * `clientId` and `clientKey` are a KEYPAIR — filled only when the environment + * supplies both. Half a keypair is a misconfiguration, and mixing one env + * value with one config value would produce a client that fails inside + * ZeroKMS with something far less obvious than "you set one of two". + * + * `CS_CLIENT_ACCESS_KEY` is the name this repo documents and the one this + * file's own example uses. `CS_ACCESS_KEY` is accepted after it because + * protect-ffi's native JS credential reader uses that spelling, so both names + * are in circulation; taking the documented one first keeps the docs true. + */ +export function withEnvCredentials(cfg: WasmClientConfig): WasmClientConfig { + const envClientId = readEnv('CS_CLIENT_ID') + const envClientKey = readEnv('CS_CLIENT_KEY') + const hasEnvKeypair = envClientId !== undefined && envClientKey !== undefined + + return { + ...cfg, + clientId: cfg.clientId ?? (hasEnvKeypair ? envClientId : undefined), + clientKey: cfg.clientKey ?? (hasEnvKeypair ? envClientKey : undefined), + workspaceCrn: cfg.workspaceCrn ?? readEnv('CS_WORKSPACE_CRN'), + // Only on the access-key arm: `accessKey` is `never` on the strategy arm, + // and filling it there would trip the mutual-exclusion guard below for a + // caller who set an env var they are not using. + ...(cfg.authStrategy || cfg.strategy + ? {} + : { + accessKey: + cfg.accessKey ?? + readEnv('CS_CLIENT_ACCESS_KEY') ?? + readEnv('CS_ACCESS_KEY'), + }), + } as WasmClientConfig +} + /** * Resolve the auth strategy for the WASM client from its config: an explicit * `config.authStrategy` (or the deprecated `config.strategy` alias), or — for @@ -1583,8 +1707,14 @@ export function resolveStrategy(cfg: WasmClientConfig): WasmAuthStrategy { // so plain JS / Deno callers that bypass the compile-time union fail loudly // instead of forwarding `undefined` into `AccessKeyStrategy.create`. if (!cfg.workspaceCrn || !cfg.accessKey) { + const missing = [ + !cfg.workspaceCrn && '`config.workspaceCrn` (or `CS_WORKSPACE_CRN`)', + !cfg.accessKey && '`config.accessKey` (or `CS_CLIENT_ACCESS_KEY`)', + ].filter(Boolean) throw new Error( - '[encryption]: `config.workspaceCrn` and `config.accessKey` are required when no auth strategy is provided.', + `[encryption]: ${missing.join(' and ')} ${ + missing.length > 1 ? 'are' : 'is' + } required when no auth strategy is provided.${envHint()}`, ) } // `AccessKeyStrategy.create` takes the full workspace CRN — the region is diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 227df064..ac21509e 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -434,17 +434,35 @@ import { Encryption, encryptedTable, types } from "@cipherstash/stack/wasm-inlin const users = encryptedTable("users", { email: types.TextEq("email") }) -const client = await Encryption({ - schemas: [users], - config: { - workspaceCrn: Deno.env.get("CS_WORKSPACE_CRN")!, - accessKey: Deno.env.get("CS_CLIENT_ACCESS_KEY")!, - clientId: Deno.env.get("CS_CLIENT_ID")!, - clientKey: Deno.env.get("CS_CLIENT_KEY")!, +// In Deno, Node, or Bun the four `CS_*` vars are read for you — `config` can +// be omitted entirely, or used to override individual fields. +const client = await Encryption({ schemas: [users] }) +``` + +**Cloudflare Workers and browsers have no `process.env`**, so the fallback is +unavailable there and every field must be passed. A Worker gets its environment +as the fetch handler's `env` argument: + +```typescript +export default { + async fetch(req: Request, env: Env) { + const client = await Encryption({ + schemas: [users], + config: { + workspaceCrn: env.CS_WORKSPACE_CRN, + accessKey: env.CS_CLIENT_ACCESS_KEY, + clientId: env.CS_CLIENT_ID, + clientKey: env.CS_CLIENT_KEY, + }, + }) }, -}) +} ``` +Never rely on the env fallback in a **browser** build: bundlers routinely inline +`process.env` at build time, which would ship your access key to every visitor. +Pass a pre-built `authStrategy` instead. + ```typescript const encrypted = await client.bulkEncrypt([ { plaintext: "alice@example.com", table: users, column: users.email },