Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/wasm-env-credentials.md
Original file line number Diff line number Diff line change
@@ -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.
227 changes: 227 additions & 0 deletions packages/stack/__tests__/wasm-inline-env-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>

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
}
})
})
})
15 changes: 13 additions & 2 deletions packages/stack/__tests__/wasm-inline-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading