Skip to content
Open
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
32 changes: 32 additions & 0 deletions .changeset/stack-protect-ffi-0-26-oidc-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@cipherstash/stack": minor
---

Bump `@cipherstash/protect-ffi` to `0.26.0` and `@cipherstash/auth` to `0.39.0`, and replace the lock-context token ceremony with a strategy-based approach for identity-bound encryption.

**protect-ffi `0.26.0`** supersedes `0.25.0`. The public API is unchanged from `0.25` (internal fixes only). As in `0.25`, `serviceToken` is gone from the encrypt / decrypt / query option types; auth flows through the client's strategy / credentials, and lock contexts travel as `lockContext.identityClaim`. The WASM-inline path takes a single options object with the auth strategy nested under `strategy`, and `Encryption()` config uses **`workspaceCrn`** (`CS_WORKSPACE_CRN`) as the single source of truth — `CS_REGION` is no longer consulted.

**Strategy-based, identity-bound encryption.** `@cipherstash/auth` `0.39` adds `OidcFederationStrategy`, which federates an end user's third-party OIDC JWT (Clerk, Supabase, Auth0, …) into a CTS service token. Pass it as `config.strategy` so every ZeroKMS request authenticates *as that user*, then bind the data key to a claim with `.withLockContext({ identityClaim })`:

```ts
import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"

const client = await Encryption({
schemas: [users],
config: {
strategy: OidcFederationStrategy.create(region, workspaceId, () => getUserJwt()),
},
})

await client
.encrypt("alice@example.com", { column: users.email, table: users })
.withLockContext({ identityClaim: ["sub"] })
```

This replaces the old ceremony (`new LockContext()` → `await lc.identify(jwt)` → `.withLockContext(lc)`), which relied on a per-operation CTS token that protect-ffi removed in `0.25`.

- **`.withLockContext()`** now accepts a plain `{ identityClaim }` object (as well as a `LockContext`) and no longer requires a CTS token or an `identify()` call — it carries the identity claim only.
- **`LockContext.identify()` / `getLockContext()`** are **deprecated** (kept for backwards compatibility); the strategy handles token acquisition.
- **Strategies are re-exported** from `@cipherstash/stack` (`OidcFederationStrategy`, `AccessKeyStrategy`, `AutoStrategy`, `DeviceSessionStrategy`) and from `@cipherstash/stack/wasm-inline` (`OidcFederationStrategy`, `AccessKeyStrategy`) so integrators don't need a separate `@cipherstash/auth` install. `AuthStrategy` remains re-exported for the structural type.

Existing credential / env behaviour is preserved when `config.strategy` is omitted.
11 changes: 6 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ jobs:
run: pnpm exec turbo run test:e2e --filter @cipherstash/e2e

# Verifies @cipherstash/stack/wasm-inline works under Deno — i.e. the
# WASM build of protect-ffi 0.24+ and auth 0.37+ can round-trip an
# WASM build of protect-ffi 0.25+ and auth 0.38+ can round-trip an
# encryption against ZeroKMS / CTS in a runtime with no native
# bindings available. The deno.json deliberately omits --allow-ffi so
# a silent fallback to the NAPI module is impossible.
Expand All @@ -168,10 +168,11 @@ jobs:
permissions:
contents: read

# CS_WORKSPACE_CRN deliberately not exposed here: the WASM client
# doesn't read it. A separate ticket tracks adding parity with the
# Node entry, at which point the CRN should be re-added.
# CS_WORKSPACE_CRN is the single source of truth for workspace
# identity and region — the stack /wasm-inline config requires it and
# derives the AccessKeyStrategy region from it.
env:
CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }}
CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }}
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}
Expand Down Expand Up @@ -222,7 +223,7 @@ jobs:
# Fail loudly instead.
- name: Assert CS_* secrets are present
run: |
for v in CS_CLIENT_ID CS_CLIENT_KEY CS_CLIENT_ACCESS_KEY; do
for v in CS_WORKSPACE_CRN CS_CLIENT_ID CS_CLIENT_KEY CS_CLIENT_ACCESS_KEY; do
if [ -z "${!v}" ]; then
echo "::error::Required secret $v is not set on this runner — the WASM smoke test would silently skip."
exit 1
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Three rules to remember when editing CI or pnpm config:
- `bulkEncryptModels(models[], table)` / `bulkDecryptModels(models[])`
- `encryptQuery(value, { table, column, queryType?, returnType? })` for searchable queries
- `encryptQuery(terms[])` for batch query encryption
- **Identity-aware encryption**: Use `LockContext` from `@cipherstash/stack/identity` and chain `.withLockContext()` on operations. Same context must be used for both encrypt and decrypt.
- **Identity-aware encryption**: Authenticate the client as the end user with `OidcFederationStrategy` (`config.strategy`, re-exported from `@cipherstash/stack`), then chain `.withLockContext({ identityClaim })` on operations to bind the data key to a claim. The same claim must be used for encrypt and decrypt. (`LockContext.identify()` from `@cipherstash/stack/identity` is deprecated — the strategy now handles token acquisition; `.withLockContext()` also accepts a `LockContext`.)
- **Integrations**:
- **Drizzle ORM**: `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack/drizzle`
- **Supabase**: `encryptedSupabase` from `@cipherstash/stack/supabase`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ bun add @cipherstash/stack
- **[Searchable encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption)**: query encrypted data with equality, free text search, range, and [JSONB queries](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption#jsonb-queries-with-searchablejson).
- **[Type-safe schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema)**: define encrypted tables and columns with `encryptedTable` / `encryptedColumn`
- **[Model & bulk operations](https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt#model-operations)**: encrypt and decrypt entire objects or batches with `encryptModel` / `bulkEncryptModels`.
- **[Identity-aware encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/identity)**: bind encryption to user identity with lock contexts for policy-based access control.
- **[Identity-aware encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/identity)**: authenticate as the end user with `OidcFederationStrategy` and bind the data key to their identity with `.withLockContext({ identityClaim })` for policy-based access control.

## Integrations

Expand Down
4 changes: 2 additions & 2 deletions e2e/wasm/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"imports": {
"@cipherstash/stack/wasm-inline": "../../packages/stack/dist/wasm-inline.js",
"@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.24.0/wasm-inline",
"@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.38.0/wasm-inline"
"@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.26.0/wasm-inline",
"@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.39.0/wasm-inline"
}
}
15 changes: 8 additions & 7 deletions e2e/wasm/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ import {
isEncrypted,
} from '@cipherstash/stack/wasm-inline'

// `CS_WORKSPACE_CRN` is intentionally not in this list — the WASM
// client doesn't read it (workspace identity comes from the access-key
// token). A separate ticket tracks adding parity with the Node entry,
// at which point CRN should be added back here.
// `CS_WORKSPACE_CRN` is the single source of truth for workspace
// identity and region — the stack `/wasm-inline` config requires it and
// derives the `AccessKeyStrategy` region from it. `CS_REGION` is not
// consulted.
const REQUIRED_ENV = [
'CS_WORKSPACE_CRN',
'CS_CLIENT_ACCESS_KEY',
'CS_CLIENT_ID',
'CS_CLIENT_KEY',
Expand Down Expand Up @@ -69,9 +70,9 @@ Deno.test({
const client = await Encryption({
schemas: [users],
config: {
// Default region in the stack is ap-southeast-2.aws; the WASM
// entry needs an explicit region for AccessKeyStrategy.
region: 'ap-southeast-2.aws',
// CRN is the single source of truth — the region the
// AccessKeyStrategy needs is derived from it.
workspaceCrn: env!.CS_WORKSPACE_CRN,
accessKey: env!.CS_CLIENT_ACCESS_KEY,
clientId: env!.CS_CLIENT_ID,
clientKey: env!.CS_CLIENT_KEY,
Expand Down
8 changes: 4 additions & 4 deletions examples/supabase-worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
CS_CLIENT_ACCESS_KEY=
CS_CLIENT_ID=
CS_CLIENT_KEY=
CS_REGION=ap-southeast-2.aws

# `CS_WORKSPACE_CRN` is intentionally omitted: the WASM client derives
# workspace identity from the access-key token, not from the CRN. This
# is a known parity gap with the Node entry — tracked separately.
# Workspace CRN, e.g. crn:ap-southeast-2.aws:your-workspace-id. The
# single source of truth for workspace identity and region — the
# AccessKeyStrategy region is derived from it. CS_REGION is not used.
CS_WORKSPACE_CRN=
2 changes: 1 addition & 1 deletion examples/supabase-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pnpm install

```sh
cp .env.example .env.local
# fill in CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY (and optionally CS_REGION)
# fill in CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY

supabase functions serve --env-file .env.local cipherstash-roundtrip
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ Deno.serve(async (_req: Request) => {
const accessKey = Deno.env.get('CS_CLIENT_ACCESS_KEY')
const clientId = Deno.env.get('CS_CLIENT_ID')
const clientKey = Deno.env.get('CS_CLIENT_KEY')
const region = Deno.env.get('CS_REGION') ?? 'ap-southeast-2.aws'
const workspaceCrn = Deno.env.get('CS_WORKSPACE_CRN')

const missing = Object.entries({
CS_WORKSPACE_CRN: workspaceCrn,
CS_CLIENT_ACCESS_KEY: accessKey,
CS_CLIENT_ID: clientId,
CS_CLIENT_KEY: clientKey,
Expand All @@ -52,7 +53,7 @@ Deno.serve(async (_req: Request) => {
const client = await Encryption({
schemas: [users],
config: {
region,
workspaceCrn: workspaceCrn!,
accessKey: accessKey!,
clientId: clientId!,
clientKey: clientKey!,
Expand Down
77 changes: 6 additions & 71 deletions packages/stack/__tests__/encrypt-query-searchable-json.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import 'dotenv/config'
import { EncryptionErrorTypes } from '@/errors'
import { Encryption } from '@/index'
import { beforeAll, describe, expect, it } from 'vitest'

type EncryptionClient = Awaited<ReturnType<typeof Encryption>>
import {
createFailingMockLockContext,
createMockLockContext,
createMockLockContextWithNullContext,
expectFailure,
jsonbSchema,
metadata,
Expand Down Expand Up @@ -450,20 +447,16 @@ describe('searchableJson with LockContext', () => {
expect(typeof operation.withLockContext).toBe('function')
})

it('executes string plaintext with LockContext mock', async () => {
const mockLockContext = createMockLockContext()

it('executes string plaintext bound to an identity claim', async () => {
const operation = protectClient.encryptQuery('$.user.email', {
column: jsonbSchema.metadata,
table: jsonbSchema,
queryType: 'searchableJson',
})

const withContext = operation.withLockContext(mockLockContext as any)
const withContext = operation.withLockContext(createMockLockContext())
const result = await withContext.execute()

expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1)

const data = unwrapResult(result)
expect(data).toMatchObject({
i: { t: 'documents', c: 'metadata' },
Expand All @@ -472,9 +465,7 @@ describe('searchableJson with LockContext', () => {
expectSelector(data)
}, 30000)

it('executes object plaintext with LockContext mock', async () => {
const mockLockContext = createMockLockContext()

it('executes object plaintext bound to an identity claim', async () => {
const operation = protectClient.encryptQuery(
{ role: 'admin' },
{
Expand All @@ -484,17 +475,12 @@ describe('searchableJson with LockContext', () => {
},
)

const withContext = operation.withLockContext(mockLockContext as any)
const withContext = operation.withLockContext(createMockLockContext())
const result = await withContext.execute()

// LockContext should be called even if the actual encryption fails
// with a mock token (ste_vec_term operations may require real auth)
expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1)

// Ensure the operation actually completed (has either data or failure)
expect(result.data !== undefined || result.failure !== undefined).toBe(true)

// The result may fail due to mock token, but we verify LockContext integration worked
if (result.data) {
expect(result.data).toMatchObject({
i: { t: 'documents', c: 'metadata' },
Expand All @@ -504,9 +490,7 @@ describe('searchableJson with LockContext', () => {
}
}, 30000)

it('executes batch with LockContext mock', async () => {
const mockLockContext = createMockLockContext()

it('executes a batch bound to an identity claim', async () => {
const operation = protectClient.encryptQuery([
{
value: '$.user.email',
Expand All @@ -522,62 +506,13 @@ describe('searchableJson with LockContext', () => {
},
])

const withContext = operation.withLockContext(mockLockContext as any)
const withContext = operation.withLockContext(createMockLockContext())
const result = await withContext.execute()

// LockContext should be called even if the actual encryption fails
// with a mock token (ste_vec_term operations may require real auth)
expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1)

// The result may fail due to mock token, but we verify LockContext integration worked
if (result.data) {
expect(result.data).toHaveLength(2)
}
}, 30000)

it('handles LockContext failure gracefully', async () => {
const mockLockContext = createFailingMockLockContext(
EncryptionErrorTypes.CtsTokenError,
'Mock LockContext failure',
)

const operation = protectClient.encryptQuery('$.user.email', {
column: jsonbSchema.metadata,
table: jsonbSchema,
queryType: 'searchableJson',
})

const withContext = operation.withLockContext(mockLockContext as any)
const result = await withContext.execute()

expectFailure(
result,
'Mock LockContext failure',
EncryptionErrorTypes.CtsTokenError,
)
}, 30000)

it('handles explicit null context from getLockContext gracefully', async () => {
const mockLockContext = createMockLockContextWithNullContext()

const operation = protectClient.encryptQuery([
{
value: '$.user.email',
column: jsonbSchema.metadata,
table: jsonbSchema,
queryType: 'searchableJson',
},
])

const withContext = operation.withLockContext(mockLockContext as any)
const result = await withContext.execute()

// Should succeed - null context should not be passed to FFI
const data = unwrapResult(result)
expect(data).toHaveLength(1)
expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
expectSelector(data[0])
}, 30000)
})

describe('searchableJson equivalence', () => {
Expand Down
Loading
Loading