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
41 changes: 41 additions & 0 deletions .changeset/stash-encryption-skill-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@cipherstash/stack": patch
"stash": patch
---

Refresh the bundled `stash-encryption` skill and fix the identity-aware
encryption docs it was copied from.

The skill ships inside the `stash` tarball and `stash init` installs it for
**every** integration (Drizzle, Supabase, plain PostgreSQL), so its errors reach
every user. It was last substantively updated before the auth-strategy rename.

- **Identity-aware encryption rewritten.** The chapter taught
`new LockContext()` + `await lc.identify(userJwt)` + `CS_CTS_ENDPOINT`.
Per-operation CTS tokens were removed in `protect-ffi` 0.25; the current path
is `OidcFederationStrategy` on `config.authStrategy` plus
`.withLockContext({ identityClaim })`.
- **`OidcFederationStrategy.create()` / `AccessKeyStrategy.create()` return a
`Result` and must be unwrapped.** The envelope has no `getToken()`, so passing
it straight to `authStrategy` fails inside the FFI. The JSDoc examples on
`Encryption()` and the live `lock-context` test both did exactly that. Fixed
in both, and documented in the skill and `AGENTS.md`.
- **The `lock-context` live tests passed without running.** They early-returned
when `USER_JWT` was absent, reporting four green assertions that never
executed — which is how the unwrap bug survived. They now `skipIf` out, so an
absent credential reads as *skipped*, not *passed*.
- **Two copy-pasteable SQL bugs.** `CREATE EXTENSION IF NOT EXISTS eql_v2` — no
such extension exists; `eql_v2` is a schema installed by `stash eql install`.
And the fallback DDL declared `email jsonb NOT NULL`, which the shipped agent
doctrine explicitly forbids because it breaks inserts during a rollout.
- **Post-cutover reads are not automatic.** The skill claimed `<col>` decrypts
"transparently" after `stash encrypt cutover`, then contradicted itself one
table row later. That is Proxy-only; SDK users must wire reads through the
encryption client.
- Repoints the closed issue #447 at open #585; documents `stash db activate`
(the additive push is promoted by `db activate`, not by cutover); scopes
`db push` / `db activate` as EQL v2 + Proxy only; adds the missing
`timestamp` data type and the SDK→EQL `cast_as` map; documents `authStrategy`
and `eqlVersion` config, the `wasm-inline` / `v3` subpath exports, and the two
distinct failure shapes (`EncryptionError.message` vs
`AuthFailure.error.message`).
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,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**: 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`.)
- **Identity-aware encryption**: Authenticate the client as the end user with `OidcFederationStrategy` (`config.authStrategy`, 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. `create()` returns a `Result` — unwrap it (`strategy.data`) before passing it to `authStrategy`; the envelope has no `getToken()`. (`config.strategy` is the deprecated name for `authStrategy`. `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
20 changes: 13 additions & 7 deletions packages/stack/__tests__/init-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
* Tests for the optional `config.authStrategy` auth strategy (and its
* deprecated `config.strategy` alias).
*
* protect-ffi (0.25+) lets `newClient` take an `AuthStrategy` (any
* `{ getToken(): Promise<{ token }> }` object — the shape every
* `@cipherstash/auth` strategy satisfies, including
* `OidcFederationStrategy` for per-user identity-bound encryption).
* `Encryption` exposes it via `config.authStrategy`; when provided it must
* reach `newClient` as `opts.strategy` (the FFI's option name), and when
* omitted the option must be absent so the default `auto` strategy is used.
* protect-ffi (0.25+) lets `newClient` take an `AuthStrategy`: any
* `{ getToken(): Promise<{ token }> }` object. `Encryption` exposes it via
* `config.authStrategy`; when provided it must reach `newClient` as
* `opts.strategy` (the FFI's option name), and when omitted the option must be
* absent so the default `auto` strategy is used.
*
* NOTE: since `@cipherstash/auth` 0.41 the real strategies resolve a
* `Result<TokenResult, AuthFailure>` envelope from `getToken()`, not a bare
* `{ token }`. protect-ffi 0.28 accepts both shapes at runtime (Node and WASM),
* but its exported `AuthStrategy` type was never widened, so assigning a real
* strategy to `config.authStrategy` is a type error. See cipherstash/stack#602.
* The stub below is a stand-in for the *FFI's declared contract*, not for an
* auth strategy, so it cannot catch that mismatch.
* The legacy `config.strategy` field is still honoured (with a runtime
* deprecation warning) until it is removed.
*/
Expand Down
264 changes: 136 additions & 128 deletions packages/stack/__tests__/lock-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ import { encryptedColumn, encryptedTable } from '@/schema'
* `.withLockContext({ identityClaim })` — no `identify()`, no CTS token
* passed by hand. Decrypting without the same claim must fail.
*
* Requires `USER_JWT` plus `CS_WORKSPACE_CRN` / `CS_CLIENT_ID` /
* `CS_CLIENT_KEY`; skips silently if `USER_JWT` is absent.
* Requires `USER_JWT` plus `CS_WORKSPACE_CRN` / `CS_CLIENT_ID` / `CS_CLIENT_KEY`.
*
* These `skipIf` out when the credentials are absent. They must never *pass*
* without them: an early `return` would report four green assertions that
* never ran, which is how the `OidcFederationStrategy.create` Result-unwrap
* bug survived here unnoticed.
*/
const LIVE = Boolean(
process.env.USER_JWT &&
process.env.CS_WORKSPACE_CRN &&
process.env.CS_CLIENT_ID &&
process.env.CS_CLIENT_KEY,
)
const users = encryptedTable('users', {
email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
address: encryptedColumn('address').freeTextSearch(),
Expand All @@ -35,134 +45,132 @@ async function userClient(userJwt: string) {
throw new Error('CS_WORKSPACE_CRN must be set for the lock-context tests')
}

// `create` returns a `Result` as of `@cipherstash/auth` 0.41 — unwrap it. The
// envelope has no `getToken()`, so forwarding it to `authStrategy` fails inside
// the FFI.
const strategy = OidcFederationStrategy.create(crn, () =>
Promise.resolve(userJwt),
)
if (strategy.failure) {
throw new Error(
`Failed to construct OidcFederationStrategy (${strategy.failure.type}): ${strategy.failure.error.message}`,
)
}

return Encryption({
schemas: [users],
config: {
authStrategy: OidcFederationStrategy.create(crn, () =>
Promise.resolve(userJwt),
),
},
config: { authStrategy: strategy.data },
})
}

describe('identity-bound encryption via OidcFederationStrategy + lock context', () => {
it('should encrypt and decrypt a payload bound to the user identity', async () => {
const userJwt = process.env.USER_JWT
if (!userJwt) {
console.log('Skipping lock context test - no USER_JWT provided')
return
}

const protectClient = await userClient(userJwt)
const email = 'hello@example.com'

const ciphertext = await protectClient
.encrypt(email, { column: users.email, table: users })
.withLockContext(IDENTITY_CLAIM)

if (ciphertext.failure) {
throw new Error(`[protect]: ${ciphertext.failure.message}`)
}

const plaintext = await protectClient
.decrypt(ciphertext.data)
.withLockContext(IDENTITY_CLAIM)

if (plaintext.failure) {
throw new Error(`[protect]: ${plaintext.failure.message}`)
}

expect(plaintext.data).toEqual(email)
}, 30000)

it('should encrypt and decrypt a model bound to the user identity', async () => {
const userJwt = process.env.USER_JWT
if (!userJwt) {
console.log('Skipping lock context test - no USER_JWT provided')
return
}

const protectClient = await userClient(userJwt)
const decryptedModel = { id: '1', email: 'plaintext' }

const encryptedModel = await protectClient
.encryptModel(decryptedModel, users)
.withLockContext(IDENTITY_CLAIM)

if (encryptedModel.failure) {
throw new Error(`[protect]: ${encryptedModel.failure.message}`)
}

const decryptedResult = await protectClient
.decryptModel(encryptedModel.data)
.withLockContext(IDENTITY_CLAIM)

if (decryptedResult.failure) {
throw new Error(`[protect]: ${decryptedResult.failure.message}`)
}

expect(decryptedResult.data).toEqual({ id: '1', email: 'plaintext' })
}, 30000)

it('should encrypt with context and be unable to decrypt without it', async () => {
const userJwt = process.env.USER_JWT
if (!userJwt) {
console.log('Skipping lock context test - no USER_JWT provided')
return
}

const protectClient = await userClient(userJwt)
const decryptedModel = { id: '1', email: 'plaintext' }

const encryptedModel = await protectClient
.encryptModel(decryptedModel, users)
.withLockContext(IDENTITY_CLAIM)

if (encryptedModel.failure) {
throw new Error(`[protect]: ${encryptedModel.failure.message}`)
}

// Decrypting without the identity claim cannot reproduce the key tag.
try {
await protectClient.decryptModel(encryptedModel.data)
} catch (error) {
const e = error as Error
expect(e.message.startsWith('Failed to retrieve key')).toEqual(true)
}
}, 30000)

it('should bulk encrypt and decrypt models bound to the user identity', async () => {
const userJwt = process.env.USER_JWT
if (!userJwt) {
console.log('Skipping lock context test - no USER_JWT provided')
return
}

const protectClient = await userClient(userJwt)
const decryptedModels = [
{ id: '1', email: 'test' },
{ id: '2', email: 'test2' },
]

const encryptedModels = await protectClient
.bulkEncryptModels(decryptedModels, users)
.withLockContext(IDENTITY_CLAIM)

if (encryptedModels.failure) {
throw new Error(`[protect]: ${encryptedModels.failure.message}`)
}

const decryptedResult = await protectClient
.bulkDecryptModels(encryptedModels.data)
.withLockContext(IDENTITY_CLAIM)

if (decryptedResult.failure) {
throw new Error(`[protect]: ${decryptedResult.failure.message}`)
}

expect(decryptedResult.data).toEqual([
{ id: '1', email: 'test' },
{ id: '2', email: 'test2' },
])
}, 30000)
})
describe.skipIf(!LIVE)(
'identity-bound encryption via OidcFederationStrategy + lock context',
() => {
it('should encrypt and decrypt a payload bound to the user identity', async () => {
const userJwt = process.env.USER_JWT as string

const protectClient = await userClient(userJwt)
const email = 'hello@example.com'

const ciphertext = await protectClient
.encrypt(email, { column: users.email, table: users })
.withLockContext(IDENTITY_CLAIM)

if (ciphertext.failure) {
throw new Error(`[protect]: ${ciphertext.failure.message}`)
}

const plaintext = await protectClient
.decrypt(ciphertext.data)
.withLockContext(IDENTITY_CLAIM)

if (plaintext.failure) {
throw new Error(`[protect]: ${plaintext.failure.message}`)
}

expect(plaintext.data).toEqual(email)
}, 30000)

it('should encrypt and decrypt a model bound to the user identity', async () => {
const userJwt = process.env.USER_JWT as string

const protectClient = await userClient(userJwt)
const decryptedModel = { id: '1', email: 'plaintext' }

const encryptedModel = await protectClient
.encryptModel(decryptedModel, users)
.withLockContext(IDENTITY_CLAIM)

if (encryptedModel.failure) {
throw new Error(`[protect]: ${encryptedModel.failure.message}`)
}

const decryptedResult = await protectClient
.decryptModel(encryptedModel.data)
.withLockContext(IDENTITY_CLAIM)

if (decryptedResult.failure) {
throw new Error(`[protect]: ${decryptedResult.failure.message}`)
}

expect(decryptedResult.data).toEqual({ id: '1', email: 'plaintext' })
}, 30000)

it('should encrypt with context and be unable to decrypt without it', async () => {
const userJwt = process.env.USER_JWT as string

const protectClient = await userClient(userJwt)
const decryptedModel = { id: '1', email: 'plaintext' }

const encryptedModel = await protectClient
.encryptModel(decryptedModel, users)
.withLockContext(IDENTITY_CLAIM)

if (encryptedModel.failure) {
throw new Error(`[protect]: ${encryptedModel.failure.message}`)
}

// Decrypting without the identity claim cannot reproduce the key tag.
// `decryptModel` resolves to a `Result` — it does not throw — so assert on
// the failure branch. A try/catch here would never run, and the test would
// pass without asserting anything.
const decrypted = await protectClient.decryptModel(encryptedModel.data)

expect(decrypted.failure).toBeDefined()
expect(
decrypted.failure?.message.startsWith('Failed to retrieve key'),
).toBe(true)
}, 30000)

it('should bulk encrypt and decrypt models bound to the user identity', async () => {
const userJwt = process.env.USER_JWT as string

const protectClient = await userClient(userJwt)
const decryptedModels = [
{ id: '1', email: 'test' },
{ id: '2', email: 'test2' },
]

const encryptedModels = await protectClient
.bulkEncryptModels(decryptedModels, users)
.withLockContext(IDENTITY_CLAIM)

if (encryptedModels.failure) {
throw new Error(`[protect]: ${encryptedModels.failure.message}`)
}

const decryptedResult = await protectClient
.bulkDecryptModels(encryptedModels.data)
.withLockContext(IDENTITY_CLAIM)

if (decryptedResult.failure) {
throw new Error(`[protect]: ${decryptedResult.failure.message}`)
}

expect(decryptedResult.data).toEqual([
{ id: '1', email: 'test' },
{ id: '2', email: 'test2' },
])
}, 30000)
},
)
Loading
Loading