diff --git a/.changeset/auth-strategy-jsdoc-unwrap.md b/.changeset/auth-strategy-jsdoc-unwrap.md new file mode 100644 index 000000000..07bdd8c81 --- /dev/null +++ b/.changeset/auth-strategy-jsdoc-unwrap.md @@ -0,0 +1,19 @@ +--- +'@cipherstash/stack': patch +--- + +Fix the `Encryption()` auth-strategy doc examples to unwrap `create()`'s Result. + +Since `@cipherstash/auth` 0.41, `AccessKeyStrategy.create()` and +`OidcFederationStrategy.create()` return a `Result`, +but the JSDoc examples on the native entry still passed the return value +straight into `config.authStrategy` — demonstrating exactly the mistake that +fails opaquely at the first `getToken()` call. The examples now check +`.failure` and pass `.data`, matching the WASM entry's docs and the +integration tests. + +Also corrects the lock-context wording in the `WasmEncryptionClient` doc +comment: a lock context decides who can *retrieve a value's data key* (the +claim from the encrypting caller's service token is bound to the key), not +"which key the value is encrypted under" — key material is identical either +way; the binding gates retrieval. diff --git a/.changeset/eql-migration-prisma-not-needed.md b/.changeset/eql-migration-prisma-not-needed.md new file mode 100644 index 000000000..2371c0cb7 --- /dev/null +++ b/.changeset/eql-migration-prisma-not-needed.md @@ -0,0 +1,13 @@ +--- +'stash': patch +--- + +`stash eql migration --prisma`: say "not needed", not "not yet". + +The command's registry copy, error message, and the `stash-cli` skill all +described a Prisma Next emitter as a coming follow-up. Prisma Next doesn't +need one — its extension pack installs the EQL bundle through prisma-next's +own migration framework (the `migrations/cipherstash/` contract space). The +`--prisma` flag now exists purely to route people there: the error explains +the mechanism and points at `prisma-next migration plan` / `prisma-next +migrate`. diff --git a/.changeset/keyset-default-doc-precision.md b/.changeset/keyset-default-doc-precision.md new file mode 100644 index 000000000..0bcfc8fd2 --- /dev/null +++ b/.changeset/keyset-default-doc-precision.md @@ -0,0 +1,15 @@ +--- +'@cipherstash/stack': patch +--- + +Correct the `config.keyset` docs on what omitting the option resolves to. + +The `Encryption()` and `ClientConfig.keyset` doc comments said omitting +`config.keyset` uses "the workspace's default keyset". The actual resolution +is the **client's** default keyset — the keyset the ZeroKMS client behind +your credentials was created against. The two coincide when the client was +created without naming a keyset — as with the profile credentials in a dev +environment — but a client created against a named keyset defaults to that +keyset, so two processes that both omit `config.keyset` share a keyspace +only if their clients default to the same keyset. The docs now say exactly +that. diff --git a/.changeset/prisma-next-skill-accuracy.md b/.changeset/prisma-next-skill-accuracy.md new file mode 100644 index 000000000..e7930e811 --- /dev/null +++ b/.changeset/prisma-next-skill-accuracy.md @@ -0,0 +1,33 @@ +--- +'stash': patch +--- + +Correct the `stash-prisma-next` skill against the current adapter, and fix a +stale constructor name in `stash init --prisma-next`'s next steps. + +The skill was verified line-by-line against `@cipherstash/prisma-next` on +main (constructors, domains, operators, `rawSql` shape, EQL function names, +CLI commands — all confirmed current). Two real errors fixed: + +- The config example imported `defineConfig` from `'prisma-next'` — no such + package exists; it comes from `@prisma-next/cli/config-types`. +- The bundling section suggested `@cipherstash/stack/wasm-inline` for edge + runtimes — the Prisma Next adapter is native-only (`cipherstashFromStack` + constructs the native stack client; there is no WASM variant), so the + advice was a dead end. It now says so. + +The column-type section now carries the **complete 31-constructor catalog** +(plaintext TS type × capability tier) instead of a six-row sample presented +as the whole surface (#756): every family (`Text*`, `Integer*`, `Smallint*`, +`BigInt*`, `Numeric*`, `Real*`, `Double*`, `Date*`, `Timestamp*`, `Boolean`, +`Json`) with its plaintext type — the column that distinguishes +`IntegerOrd` (JS `number`) from `BigIntOrd` (JS `bigint`) and would have +prevented the integer-cents trap the issue reports. Also states that `*Ord` +includes equality, `TextMatch` is free-text only, and the `*OrdOre` variants +are deliberately unexposed. + +Also documented the `column-types` subpath (camelCase factories for +TS-authored contracts), and fixed `stash init --prisma-next`'s next-steps +message, which still told users to declare columns with the old +`cipherstash.Encrypted*()` constructor names (current: `cipherstash.TextSearch()`, +`cipherstash.DateOrd()`, …). diff --git a/.changeset/skills-keyset-auth-wording-pass.md b/.changeset/skills-keyset-auth-wording-pass.md new file mode 100644 index 000000000..da1abb151 --- /dev/null +++ b/.changeset/skills-keyset-auth-wording-pass.md @@ -0,0 +1,35 @@ +--- +'stash': patch +--- + +Correct the keyset/credential model in five shipped skills to match the new +canonical sources. + +`stash-edge`, `stash-cli`, `stash-postgres`, and `stash-supabase` all carried +a "credential-identity rule": EQL index terms deriving from the ZeroKMS +client key, so rows written under one credential would "decrypt correctly +but never match a query — silently". That model is wrong. Index terms come +from a per-**keyset** key, so every client **bound** to the same keyset +derives the same terms — credential strings never matter. The keyset can +still miss silently, though: encrypt and query use the client's bound +keyset while decrypt follows each payload's keyset subject to grants, so a +reader granted the writer's keyset but bound to a different one decrypts +fine while its searches return zero rows. The old *credential* diagnostic +could never fire; the *keyset-binding* check replaces it, alongside the +other real causes of zero-row queries (operand casts, predicate forms, +missing indexes). + +All five sites now state the keyset model and defer to `stash-zerokms` +(keysets/grants) and `stash-auth` (credentials/lock context) as canonical. +`stash-deployment`'s backfill-keyset guidance gets the same pass: bound +keyset (not a mere grant) is what routes the backfill's writes, the failure +table and troubleshooting rows distinguish the no-grant case (decrypt fails) +from the granted-but-differently-bound case (decrypt works, search silently +misses), and `stash-cli`'s backfill precondition now names the credential +*resolution* order — `CS_*` variables when present, else the local +`~/.cipherstash` profile via native auto auth. +`stash-encryption` also drops its claim that identity-bound encryption on +the edge is "configured via `config.authStrategy`" (an auth strategy decides +who the client is; a lock context gates retrieval of a value's data key — +the edge entry simply lacks lock context, #797), and its auth, lock-context, +and keysets sections now point at the canonical skills. diff --git a/.changeset/stash-auth-skill.md b/.changeset/stash-auth-skill.md new file mode 100644 index 000000000..ca5eb34a0 --- /dev/null +++ b/.changeset/stash-auth-skill.md @@ -0,0 +1,45 @@ +--- +'stash': minor +'@cipherstash/wizard': patch +--- + +Add a `stash-auth` agent skill and install it for every integration (#794). + +Authentication had no canonical home: the guidance was scattered across +skills that each mention one slice of it, and the gap had already produced a +wrong explanation in shipped material (conflating `config.authStrategy` with +lock context). The new skill is the single source of truth; other skills +should point at it rather than restate it. + +What it documents: + +- The service-token model: every request to a CipherStash service carries a + short-lived JWT minted by CTS; access keys and IdP JWTs are exchanged at + CTS, never sent to ZeroKMS directly. The token carries the workspace, the + role-derived scopes, and the regional ZeroKMS endpoint in its `services` + claim — which is why endpoints are never hand-configured and `CS_*_HOST` + stays debug-only. +- The three separable concerns (client credentials, end-user identity, + key binding) and the canonical statement that an auth strategy decides who + the client is while a lock context decides who can retrieve a value's data + key — the claim from the encrypting caller's service token is bound to the + key, and retrieval requires presenting the same claim. Orthogonal, and + only combined deliberately. +- The `@cipherstash/auth` strategies (`AutoStrategy`, `AccessKeyStrategy`, + `OidcFederationStrategy`, `DeviceSessionStrategy`), including the Result + trap: `create()` returns `Result` and + `config.authStrategy` takes the unwrapped `.data`, plus the `AuthFailure` + codes worth recognising (`NOT_AUTHENTICATED`, `WORKSPACE_MISMATCH`, …). +- Credential discovery vs explicit config (native env/profile vs the WASM + entry's explicit four values), the mutual-exclusion rule on the WASM entry, + the four `CS_*` variables and `stash env`, and client lifetime with + user-scoped strategies (one client per request — a shared client binds + whoever arrived first). +- Lock context usage and the deprecations around it + (`LockContext.identify()` / `getLockContext()`, `config.strategy`), the + explicit rule that agents never read `~/.cipherstash`, and a note that + Proxy authentication is a different path (dedicated skill to come). + +`stash-zerokms` gains a companion section: ZeroKMS accepts only CTS-minted +service tokens, runs in multiple regions, and its endpoint is determined by +CTS — with the bulk deferred to `stash-auth`. diff --git a/.changeset/stash-zerokms-skill.md b/.changeset/stash-zerokms-skill.md new file mode 100644 index 000000000..77ad282ec --- /dev/null +++ b/.changeset/stash-zerokms-skill.md @@ -0,0 +1,44 @@ +--- +'stash': minor +'@cipherstash/wizard': patch +--- + +Add a `stash-zerokms` agent skill and install it for every integration. + +The keyset/client access model had no canonical home: several skills described +credentials and keysets in passing, and some of that wording contradicts how +ZeroKMS actually works. The new skill is the single source of truth for the +model, and other skills should point at it rather than restate it. + +What it documents: + +- The four-level key hierarchy (root key → per-keyset authority key → + per-client client key → per-value data key) and why revoking one client + blocks all of its future key operations immediately, without + re-encryption (not retroactive — already-held plaintext is beyond recall, + but per-value keys bound the blast radius to what was already accessed). +- The scoping rule and its asymmetry: encrypt and query always use the + client's **bound** keyset, while decrypt follows each payload's keyset + subject to grants. An unreachable bound keyset (no grant, revoked, + disabled) fails loudly at the ZeroKMS round trip, as does decrypting a + payload under an ungranted keyset. The one **silent** case is a reader + granted the writer's keyset but bound to a different one: decrypt works, + while its query terms derive under its own keyset and encrypted search + returns zero rows. The same-keyset rule therefore binds writers and query + readers; decrypt-only readers need just a grant. +- Clients and grants: creation binds a client to one keyset (the workspace + default unless named), `grant`/`revoke` manage further access per + (client, keyset) pair, and two different credentials interoperate fully as + long as both reach the encrypting keyset — "identical credentials + everywhere" was never the requirement. +- The workspace default keyset (`default`, reserved name, cannot be disabled + or renamed) and multi-tenant isolation via `config.keyset` with one + `Encryption()` client per tenant. +- The ZeroKMS API surface for automation (`/create-keyset`, `/grant-keyset`, + `/revoke-keyset`, `/list-clients`, …) with required token scopes, the exact + failure surfaces (404 no-grant, 403 disabled-keyset, 403 missing-scopes, + per-value lock-context denials), and a diagnostic runbook that separates the + client-level keyset gate from the value-level lock-context gate. + +`stash-zerokms` joins the set every integration installs, alongside +`stash-encryption`, `stash-indexing`, `stash-deployment` and `stash-cli`. diff --git a/AGENTS.md b/AGENTS.md index 78d86cfcf..27c1f749b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ If these variables are missing, tests that require live encryption will fail or - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). -- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-deployment`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`) +- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-deployment`, `stash-zerokms`, `stash-auth`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`) ## Agent Skills — these ship to customers @@ -115,6 +115,8 @@ nothing type-checks them, and the damage lands in a customer's repo, not ours. | The deploy sequencing / deploy-gate story, `stash env`, or platform-specific deployment guidance | `skills/stash-deployment` | | The `@cipherstash/eql` pin, `eql install`/`eql migration` behaviour, or index-related SQL guidance | `skills/stash-indexing` | | The EQL operator/domain surface (`eql_v3.query_*` casts, predicate forms) | `skills/stash-postgres` | +| The keyset/client model (`config.keyset`, grants, the ZeroKMS access story) | `skills/stash-zerokms` — the canonical source; other skills should point here rather than restate it | +| Auth strategies (`config.authStrategy`), the `CS_*` variables, lock context, `stash env` / `auth login` behaviour | `skills/stash-auth` — the canonical source; other skills should point here rather than restate it | | `packages/stack/src/wasm-inline.ts`, the WASM entry's exports, or `stash env` | `skills/stash-edge` | | pnpm config, CI workflows, dependency policy | `skills/stash-supply-chain-security` | | The durable agent rules themselves | `packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md` | diff --git a/examples/prisma/prisma/schema.prisma b/examples/prisma/prisma/schema.prisma index ed2709d86..5be400e4e 100644 --- a/examples/prisma/prisma/schema.prisma +++ b/examples/prisma/prisma/schema.prisma @@ -8,22 +8,22 @@ // Operator visibility per column follows from the domain's query // capabilities (see the package README's operator surface table): // -// - email (EncryptedTextSearch → eql_v3_text_search) +// - email (TextSearch → eql_v3_text_search) // equality + order/range + free-text search: // eqlEq / Neq / In / NotIn / // Gt / Gte / Lt / Lte / Between / NotBetween / // Match, eqlAsc / Desc. -// - salary (EncryptedDoubleOrd → eql_v3_double_ord) +// - salary (DoubleOrd → eql_v3_double_ord) // equality + order/range. -// - accountId (EncryptedBigIntOrd → eql_v3_bigint_ord) +// - accountId (BigIntOrd → eql_v3_bigint_ord) // equality + order/range. -// - birthday (EncryptedDateOrd → eql_v3_date_ord) +// - birthday (DateOrd → eql_v3_date_ord) // equality + order/range. -// - emailVerified (EncryptedBoolean → eql_v3_boolean) +// - emailVerified (Boolean → eql_v3_boolean) // STORAGE-ONLY: no search operators at all — every // eql* predicate throws // EncryptionOperatorError on this column. -// - preferences (EncryptedJson → eql_v3_json_search) +// - preferences (Json → eql_v3_json_search) // searchable JSON: eqlJsonContains // (encrypted jsonb `@>` containment). // diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index dcbe319c0..6eb715609 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -324,7 +324,7 @@ export const registry: CommandGroup[] = [ { name: 'eql migration', summary: - 'Generate an EQL v3 install migration for your ORM (Drizzle now; Prisma Next soon)', + 'Generate an EQL v3 install migration for your ORM (Drizzle; Prisma Next installs EQL through its own migrations)', examples: [ 'eql migration --drizzle', 'eql migration --drizzle --supabase', @@ -338,7 +338,7 @@ export const registry: CommandGroup[] = [ { name: '--prisma', description: - 'Emit a Prisma Next migration — not available yet; the emitter is a follow-up tracked in cipherstash/stack#690.', + 'Not needed: Prisma Next installs EQL through its own migration framework — run `prisma-next migrate` instead.', }, { name: '--supabase', diff --git a/packages/cli/src/commands/eql/__tests__/migration.test.ts b/packages/cli/src/commands/eql/__tests__/migration.test.ts index 6b21ffcfc..b6718b854 100644 --- a/packages/cli/src/commands/eql/__tests__/migration.test.ts +++ b/packages/cli/src/commands/eql/__tests__/migration.test.ts @@ -165,11 +165,7 @@ describe('eqlMigrationCommand — target selection', () => { { drizzle: true, prisma: true }, () => messages.eql.migrationOneTarget, ], - [ - '--prisma', - { prisma: true }, - () => messages.eql.migrationPrismaUnavailable, - ], + ['--prisma', { prisma: true }, () => messages.eql.migrationPrismaNotNeeded], // `--supabase` is a modifier, not a target. [ '--supabase alone', diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts index ff0633bcc..96efb1fdd 100644 --- a/packages/cli/src/commands/eql/migration.ts +++ b/packages/cli/src/commands/eql/migration.ts @@ -85,7 +85,10 @@ function cleanupMigrationFile(filePath: string | undefined): void { export interface EqlMigrationOptions { /** Emit a Drizzle custom migration. */ drizzle?: boolean - /** Emit a Prisma Next migration (not yet available — see issue #690). */ + /** + * Rejected with a pointer — Prisma Next installs EQL through its own + * migration framework, so there is nothing for this command to emit. + */ prisma?: boolean /** Append the Supabase role grants (`eql_v3` + `eql_v3_internal`). */ supabase?: boolean @@ -161,11 +164,11 @@ export async function eqlMigrationCommand( } if (options.prisma) { - // The Prisma Next emitter is a follow-up (tracked in #690): it will write - // the install migration in the framework `Migration` shape and let - // prisma-next drop its baked install baseline. Until it lands, fail loudly - // with a pointer rather than emit a broken/empty file. - p.log.error(messages.eql.migrationPrismaUnavailable) + // Prisma Next does not need an emitted install migration: its extension + // pack contributes the `migrations/cipherstash/` contract space, which + // installs the EQL bundle through prisma-next's own migration framework. + // The flag exists only to route people who try it to that mechanism. + p.log.error(messages.eql.migrationPrismaNotNeeded) throw new CliExit(1) } diff --git a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md index 32b41ca7b..ec56cf110 100644 --- a/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md +++ b/packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md @@ -47,4 +47,4 @@ The CipherStash setup skills carry the API details. Use them when you need speci - `.codex/skills//SKILL.md` (Codex) - Under `## Skill references` at the bottom of this file (editor agents, or when a skills directory could not be written — if the section exists, it is the current copy) -Skills relevant to this project depend on the integration. Common ones: `stash-encryption` (encryption API), `stash-indexing` (indexes on encrypted columns), `stash-deployment` (taking a rollout to production), `stash-cli` (`stash` commands), and one of `stash-drizzle` / `stash-supabase` / `stash-prisma-next` / `stash-dynamodb` for the chosen ORM. +Skills relevant to this project depend on the integration. Common ones: `stash-encryption` (encryption API), `stash-indexing` (indexes on encrypted columns), `stash-deployment` (taking a rollout to production), `stash-zerokms` (keysets, clients, and key management), `stash-auth` (credentials, auth strategies, lock context), `stash-cli` (`stash` commands), and one of `stash-drizzle` / `stash-supabase` / `stash-prisma-next` / `stash-dynamodb` for the chosen ORM. diff --git a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts index 6a75c0e89..5f9f93e11 100644 --- a/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/install-skills.test.ts @@ -129,6 +129,8 @@ describe('skillsFor', () => { 'stash-prisma-next', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ]) }) @@ -140,6 +142,8 @@ describe('skillsFor', () => { 'stash-encryption', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ]) }) @@ -164,6 +168,8 @@ describe('installSkills', () => { 'stash-drizzle', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ]) expect(failed).toEqual([]) @@ -207,6 +213,8 @@ describe('installSkills', () => { 'stash-drizzle', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], }) @@ -233,6 +241,8 @@ describe('installSkills', () => { 'stash-encryption', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ]) expect(failed).toEqual(['stash-drizzle']) @@ -254,6 +264,8 @@ describe('installSkills', () => { 'stash-drizzle', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ]) }) diff --git a/packages/cli/src/commands/init/lib/install-skills.ts b/packages/cli/src/commands/init/lib/install-skills.ts index f44039cac..989db8a17 100644 --- a/packages/cli/src/commands/init/lib/install-skills.ts +++ b/packages/cli/src/commands/init/lib/install-skills.ts @@ -16,6 +16,8 @@ export const SKILL_MAP: Record = { 'stash-drizzle', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], // Supabase gets the raw-SQL and edge skills on top of its own: Edge @@ -29,6 +31,8 @@ export const SKILL_MAP: Record = { 'stash-postgres', 'stash-edge', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], 'prisma-next': [ @@ -36,6 +40,8 @@ export const SKILL_MAP: Record = { 'stash-prisma-next', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], // The no-ORM path: `stash-postgres` (binding + predicate forms) and `stash-edge` @@ -47,6 +53,8 @@ export const SKILL_MAP: Record = { 'stash-postgres', 'stash-edge', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], } @@ -56,6 +64,8 @@ const BASE_SKILLS: readonly string[] = [ 'stash-encryption', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ] diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 51b2c6964..fc18a3f12 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -204,6 +204,10 @@ const SKILL_PURPOSES: Record = { 'the `@cipherstash/stack/wasm-inline` entry for Deno / Supabase Edge Functions / Workers: imports, `CS_*` credentials, the credential-identity rule', 'stash-deployment': 'taking a rollout to a live environment — the multi-deploy ladder and its gates, backfilling against a production database, `CS_*` credentials at build and run time, Prisma Postgres/Compute specifics', + 'stash-zerokms': + 'the ZeroKMS key model — keysets scope every encrypt/decrypt/query, clients and grants, the default keyset, multi-tenant `config.keyset`, and diagnosing keyset-access failures', + 'stash-auth': + 'authenticating to CipherStash — the CTS service-token model, `@cipherstash/auth` strategies (`config.authStrategy`), the four `CS_*` variables and `stash env`, lock context, and auth failure codes', 'stash-dynamodb': 'DynamoDB encryption: per-item encrypt/decrypt, HMAC attribute keys, audit logging', 'stash-cli': diff --git a/packages/cli/src/commands/init/providers/prisma-next.ts b/packages/cli/src/commands/init/providers/prisma-next.ts index 551ec433f..be1e5a698 100644 --- a/packages/cli/src/commands/init/providers/prisma-next.ts +++ b/packages/cli/src/commands/init/providers/prisma-next.ts @@ -14,7 +14,7 @@ export function createPrismaNextProvider(): InitProvider { const stash = runnerCommand(pm, 'stash') const prismaNext = runnerCommand(pm, 'prisma-next') return [ - 'Declare encrypted columns in prisma/schema.prisma using cipherstash.Encrypted*()', + 'Declare encrypted columns in prisma/schema.prisma using cipherstash.TextSearch(), cipherstash.DateOrd(), …', 'Register the extension: add `cipherstash` to `extensionPacks` in prisma-next.config.ts', `Generate the contract: ${prismaNext} contract emit`, `Plan + apply (installs the EQL bundle alongside your app schema): ${prismaNext} migration plan && ${prismaNext} migrate`, diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 38da7141b..d3b2f9c73 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -73,13 +73,14 @@ export const messages = { migrationOneTarget: 'Pass exactly one target: `--drizzle` or `--prisma`, not both.', /** - * `--prisma` is registered but its Prisma Next emitter isn't built yet — - * it's a follow-up (tracked in #690) that will emit the install migration - * in the framework `Migration` shape and let prisma-next drop its baked - * install baseline. Fail with a pointer rather than a silent no-op. + * `--prisma` is registered only to route people to the right mechanism: + * Prisma Next installs the EQL bundle through its own migration framework + * (the `migrations/cipherstash/` contract space), so there is no emitter + * here and never needs to be. Fail with a pointer rather than a silent + * no-op. */ - migrationPrismaUnavailable: - '`stash eql migration --prisma` is not available yet — the Prisma Next emitter is a follow-up (tracked in cipherstash/stack#690). Use `--drizzle` today.', + migrationPrismaNotNeeded: + 'Prisma Next does not need `stash eql migration` — its extension pack installs the EQL bundle through its own migration framework (the `migrations/cipherstash/` contract space). Run `prisma-next migration plan` and `prisma-next migrate` instead.', /** `--name` carried characters outside `[A-Za-z0-9_-]`. */ migrationBadName: 'Migration name must contain only letters, numbers, dashes, and underscores.', diff --git a/packages/stack/src/encryption/index.ts b/packages/stack/src/encryption/index.ts index c4ea6b095..4073fcf13 100644 --- a/packages/stack/src/encryption/index.ts +++ b/packages/stack/src/encryption/index.ts @@ -731,10 +731,15 @@ export function __resetStrategyDeprecationWarningForTests(): void { * ```typescript * import { Encryption, AccessKeyStrategy } from "@cipherstash/stack" * + * // `create()` returns a `Result` — unwrap it; `config.authStrategy` takes the + * // strategy itself (the object with `getToken()`), not the `Result` envelope. + * const strategy = AccessKeyStrategy.create(workspaceCrn, accessKey) + * if (strategy.failure) throw new Error(strategy.failure.error.message) + * * const client = await Encryption({ * schemas: [users], * config: { - * authStrategy: AccessKeyStrategy.create(workspaceCrn, accessKey), + * authStrategy: strategy.data, * }, * }) * ``` @@ -749,10 +754,14 @@ export function __resetStrategyDeprecationWarningForTests(): void { * import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" * * // Authenticate every ZeroKMS request as the signed-in user. + * // `create()` returns a `Result` — unwrap it before passing the strategy. + * const federation = OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()) + * if (federation.failure) throw new Error(federation.failure.error.message) + * * const client = await Encryption({ * schemas: [users], * config: { - * authStrategy: OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()), + * authStrategy: federation.data, * }, * }) * ``` @@ -781,7 +790,11 @@ export function __resetStrategyDeprecationWarningForTests(): void { * that gives each tenant its own cryptographic isolation, so data encrypted under one keyset cannot * be decrypted under another. Create and manage keysets in the * [dashboard](https://dashboard.cipherstash.com/workspaces/_/keysets) (the `_` in the URL resolves - * to whichever workspace you select); omit `config.keyset` to use the workspace's default keyset. + * to whichever workspace you select). Omitting `config.keyset` uses the default keyset of the + * ZeroKMS client behind your credentials — the keyset that client was created against, which is the + * workspace's `default` keyset if using the profile credentials in a dev environment. Two processes + * that both omit `config.keyset` therefore share a keyspace only if their clients default to the + * same keyset. * * ```typescript * // `users` is the schema from the first example above. diff --git a/packages/stack/src/types.ts b/packages/stack/src/types.ts index 9b1a8be34..c63295d32 100644 --- a/packages/stack/src/types.ts +++ b/packages/stack/src/types.ts @@ -132,8 +132,12 @@ export type ClientConfig = { * Specify by name (`{ name: "tenant-a" }`) or UUID (`{ id: "..." }`). * Keysets are created and managed in the * [dashboard](https://dashboard.cipherstash.com/workspaces/_/keysets); omit to - * use the workspace's default keyset. A client is bound to one keyset for its - * lifetime, so use one client per tenant. + * use the default keyset of the ZeroKMS client behind your credentials — the + * keyset it was created against, which is the workspace's `default` keyset if + * using the profile credentials in a dev environment. A client is bound to + * one keyset for its lifetime — encrypt and query always use it, while + * decrypt follows each payload's own keyset (subject to grants) — so use one + * client per tenant. * * @see {@link Encryption} for the full keysets walkthrough. */ diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index c1ad98a1a..16080d1ae 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -706,15 +706,18 @@ const INTERNAL_CONSTRUCT = Symbol('cs-wasm-client') * identity-bound encryption is "configured at client construction via * `config.authStrategy` instead" (#663 context). That is wrong, and the * conflation is worth naming because it is the one people arrive with: - * an auth strategy decides WHO THE CLIENT IS; a lock context decides WHICH - * KEY THE VALUE IS ENCRYPTED UNDER. They are orthogonal, and only the first - * exists on this entry. + * an auth strategy decides WHO THE CLIENT IS; a lock context decides WHO CAN + * RETRIEVE A VALUE'S DATA KEY (the claim from the encrypting caller's service + * token is bound to the key, and retrieval requires presenting the same + * claim). They are orthogonal, and only the first exists on this entry. * * The consequences are silent, which is why they are stated here rather than - * left to a failed decrypt: values written from this entry are encrypted under - * the workspace key even when the client is authenticated as an end user, and - * this entry cannot read anything the native entry wrote under a lock context, - * since decrypt requires the same context. `skills/stash-edge` documents both. + * left to a failed decrypt: values written from this entry carry no identity + * condition on key retrieval — any client with keyset access can decrypt + * them — even when the client is authenticated as an end user, and this + * entry cannot read anything the native entry wrote under a lock context, + * since key retrieval requires the same claim. `skills/stash-auth` is + * canonical for the model; `skills/stash-edge` documents this entry's gap. * * The binding accepts a lock context on both paths — `lockContext` is an * option field on the single calls and per-payload-item on the bulk ones — so diff --git a/packages/wizard/src/__tests__/install-skills.test.ts b/packages/wizard/src/__tests__/install-skills.test.ts index cc72a2157..0dfffe5ea 100644 --- a/packages/wizard/src/__tests__/install-skills.test.ts +++ b/packages/wizard/src/__tests__/install-skills.test.ts @@ -48,6 +48,8 @@ describe('maybeInstallSkills', () => { 'stash-drizzle', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ]) expect(warnings()).toContain('Could not create ./.claude/skills/') @@ -68,6 +70,8 @@ describe('maybeInstallSkills', () => { 'stash-encryption', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ]) expect(result.failed).toEqual(['stash-drizzle']) diff --git a/packages/wizard/src/lib/install-skills.ts b/packages/wizard/src/lib/install-skills.ts index c55ce56a4..68c6831cf 100644 --- a/packages/wizard/src/lib/install-skills.ts +++ b/packages/wizard/src/lib/install-skills.ts @@ -17,6 +17,8 @@ export const SKILL_MAP: Record = { 'stash-drizzle', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], // `stash-postgres` / `stash-edge` mirror the CLI's SKILL_MAP — see the comments @@ -28,6 +30,8 @@ export const SKILL_MAP: Record = { 'stash-postgres', 'stash-edge', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], prisma: [ @@ -35,6 +39,8 @@ export const SKILL_MAP: Record = { 'stash-prisma-next', 'stash-indexing', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], generic: [ @@ -43,6 +49,8 @@ export const SKILL_MAP: Record = { 'stash-postgres', 'stash-edge', 'stash-deployment', + 'stash-zerokms', + 'stash-auth', 'stash-cli', ], } diff --git a/skills/stash-auth/SKILL.md b/skills/stash-auth/SKILL.md new file mode 100644 index 000000000..8b206dc57 --- /dev/null +++ b/skills/stash-auth/SKILL.md @@ -0,0 +1,300 @@ +--- +name: stash-auth +description: How a CipherStash client authenticates — the canonical skill for credentials, auth strategies, and lock context. Covers the service-token model (every request to a CipherStash service carries a CTS-minted token; strategies exist to obtain one), the three separable concerns people conflate (client credentials, end-user identity, key binding), the `@cipherstash/auth` strategies (`AutoStrategy`, `AccessKeyStrategy`, `OidcFederationStrategy`, `DeviceSessionStrategy`) and the Result-unwrap trap on `create()`, the four `CS_*` variables and `stash env`, credential discovery vs explicit config on the native and WASM entries, client lifetime with user-scoped strategies, `.withLockContext` and what is deprecated around it, and CTS failure codes. Use when an operation fails with 401/NOT_AUTHENTICATED/WORKSPACE_MISMATCH, when choosing between an access key and OIDC federation, when wiring per-user identity-bound encryption, when minting deployment credentials, or whenever another skill mentions "credentials" or `config.authStrategy` — this skill is the canonical source for that model. +--- + +# Authenticating to CipherStash + +Every request a Stack client makes to a CipherStash service authenticates +with a **CipherStash service token**: a short-lived signed JWT minted by +**CTS** (the CipherStash token service). That is the only credential ZeroKMS +accepts. Access keys and identity-provider JWTs are never sent to ZeroKMS — +they are *exchanged* at CTS for a service token, and everything in this +skill is machinery for doing that exchange correctly and automatically. + +This skill is canonical for authentication. Where other skills +(`stash-edge`, `stash-encryption`, `stash-cli`, `stash-deployment`) touch +credentials in passing and disagree with this one, this one wins. For what a +token *authorizes* — keysets, clients, grants — see `stash-zerokms`: +authentication and keyset access are separate gates, and a perfectly valid +token still fails operations on a keyset its client was never granted. + +## The three concerns (do not merge them) + +| Concern | What it decides | Where it's configured | +|---|---|---| +| **Client credentials** | Which workspace (`CS_WORKSPACE_CRN`) and — more granular than the workspace — which ZeroKMS *client* is acting (`CS_CLIENT_ID`, `CS_CLIENT_KEY`) and the access key that authenticates it (`CS_CLIENT_ACCESS_KEY`) | `CS_*` variables / dev profile / `config.accessKey` | +| **End-user identity** | Authenticating the client *as* a specific user | `config.authStrategy: OidcFederationStrategy` | +| **Key binding (lock context)** | Who can *retrieve a value's data key* — binds key access to a claim | `.withLockContext({ identityClaim })` per operation | + +Note the granularity in the first row: only the CRN is about the +*workspace*. The other three variables identify a specific **client** within +it — its id, its key material, and its access key — so "the credentials" +always means a particular client's identity, not a workspace-wide secret +(see `stash-zerokms` for what a client is and what it can reach). + +The classic conflation is between the last two: **an auth strategy decides +who the client is; a lock context decides who can get a value's data key +back.** They are orthogonal. Authenticating as a user via +`OidcFederationStrategy` does not by itself bind any value to that user — +without `.withLockContext()`, any client with the right keyset access can +retrieve the value's key. Lock context *requires* `OidcFederationStrategy` +on the Stack surface (there must be a user claim in the service token to +bind to), but not the other way around. + +## The token exchange (CTS) + +CTS's authorize endpoint exchanges exactly one of two credential kinds for a +service token: + +- **A CipherStash access key** (`CSAK…`) — machine identity, for + services/CI. +- **An OIDC JWT** from an identity provider configured for the workspace + (Clerk, Supabase, Auth0, or Okta — or CipherStash's own login) — end-user + identity. CTS verifies it against the provider's JWKS and checks workspace + membership. + +The minted token carries: the subject (`CS|` or `CS|CSAK`), +the workspace, scopes derived from the credential's role (member or admin — +see `stash-zerokms` for what the scopes gate), and a built-in **service +discovery mechanism** — the token itself tells the client where the +workspace's services (ZeroKMS) live. The workspace CRN +(`crn:.:`) names the region, CTS resolves +the regional endpoints, and the client reads them from the token. You don't +need to know how it works; the one fact that matters is that **a +CipherStash-issued service token is only valid for services in the region it +was issued for** — it cannot be replayed against another region. Endpoints +are never hand-configured — the `CS_*_HOST` override variables are +debug-only and must not appear in CI, examples, or docs. + +CipherStash runs in a variety of regions across the world, and the set is +**subject to change** — `stash auth regions` (add `--json` for +machine-readable output) lists the current ones; see `stash-cli`. As of this +writing: + +| Region | Location | +|---|---| +| `us-east-1` | Virginia, USA | +| `us-east-2` | Ohio, USA | +| `us-west-1` | California, USA | +| `us-west-2` | Oregon, USA | +| `eu-west-1` | Dublin, Ireland | +| `eu-central-1` | Frankfurt, Germany | +| `ap-southeast-2` | Sydney, Australia | + +A workspace lives in one region, chosen at creation (the `stash auth login` +region picker / `--region`); the CRN records it thereafter. + +CTS failures are deliberate and descriptive: `401` with a reason ("Principal +X is not a member of workspace Y", "No OIDC provider found for issuer: …", +"Access key was malformed: …"), and `402` ("Insufficient balance…") when the +organisation is over its usage limit. A 402 is a billing problem, not a +credentials problem — don't rotate keys over it. + +## The strategies + +From `@cipherstash/auth`, re-exported by `@cipherstash/stack` so no separate +install is needed. Every strategy has one job — `getToken()` — and handles +caching and refresh internally; you never store or refresh a service token +yourself. + +| Strategy | Use for | Credential source | +|---|---|---| +| `AutoStrategy` (the default when `config.authStrategy` is unset) | Most apps | `CS_CLIENT_ACCESS_KEY` **and** `CS_WORKSPACE_CRN` env vars, else the dev profile (`~/.cipherstash/auth.json`), else fails `NOT_AUTHENTICATED` | +| `AccessKeyStrategy` | Services, CI, backfill jobs | Explicit workspace CRN + access key | +| `OidcFederationStrategy` | Per-user (identity-bound) encryption | Your IdP's JWT, re-fetched via a callback on every federation | +| `DeviceSessionStrategy` | CLI-adjacent tooling | The device-code session `stash auth login` created | + +`auto`'s access-key arm needs **both** variables, and the two +missing-variable cases behave differently: with `CS_CLIENT_ACCESS_KEY` +unset, detection falls back to the dev profile; with the access key set but +`CS_WORKSPACE_CRN` missing, it fails `MISSING_WORKSPACE_CRN` rather than +silently falling back — a half-configured environment errors instead of +quietly authenticating as whatever profile is on the machine. + +```typescript +import { Encryption, AccessKeyStrategy } from "@cipherstash/stack" + +// create() returns a Result — UNWRAP IT (see below). +const strategy = AccessKeyStrategy.create(workspaceCrn, accessKey) +if (strategy.failure) { + throw new Error(`auth: ${strategy.failure.type}: ${strategy.failure.error.message}`) +} + +const client = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data }, +}) +``` + +For end users, `OidcFederationStrategy.create(workspaceCrn, getJwt)` — the +`getJwt` callback is invoked on *every* federation (initial and every +re-federation after the CTS token expires) and must return the **current** +IdP JWT, not a captured stale one. On the WASM/edge path, +`createWithStore(workspaceCrn, getJwt, loadToken, saveToken)` persists the +federated token (e.g. in an HTTP-only cookie) so it survives across +requests. + +Federation only works if the JWT's **issuer is registered with the +workspace**. Add your OIDC provider — Clerk, Supabase, Auth0, or Okta — in +the [dashboard](https://dashboard.cipherstash.com) under the workspace's +OIDC providers; the registered issuer URL must match the JWT's `iss` claim +exactly. An unregistered issuer fails the exchange with `No OIDC provider +found for issuer: …`. A workspace can register multiple OIDC providers, +subject to billing conditions / plan level. + +### The Result trap + +As of `@cipherstash/auth` 0.41, **`create()` returns a +`Result`** (`{ data }` or `{ failure }`), and so does +every `getToken()`. `config.authStrategy` expects the strategy itself — the +thing with `getToken()` on it. Passing the un-unwrapped `Result` is the +single most common auth mistake: it type-errors in TS, but plain-JS callers +find out only later, as an opaque failure when the client tries to call +`getToken` on an object that doesn't have one. Always: check `.failure`, +pass `.data`. + +`AuthFailure` is discriminated on `type`. The ones worth recognising: + +| `failure.type` | Meaning / fix | +|---|---| +| `NOT_AUTHENTICATED` | No credentials found — set the `CS_*` variables, or run `npx stash auth login` for a dev profile | +| `MISSING_WORKSPACE_CRN` | `CS_CLIENT_ACCESS_KEY` is set but `CS_WORKSPACE_CRN` isn't — the access-key path needs both | +| `WORKSPACE_MISMATCH` (`expected` / `actual`) | The access key belongs to a different workspace than the CRN — you've mixed environments' credentials | +| `INVALID_ACCESS_KEY` / `INVALID_CRN` | Malformed values — check for truncation/quoting in the secret store | +| `EXPIRED_TOKEN` / `INVALID_GRANT` | The underlying session/JWT expired — for `OidcFederationStrategy`, check that `getJwt` returns a *fresh* token | +| `ACCESS_DENIED` | CTS refused the exchange — membership, provider config, or (as an HTTP 402) billing | + +Every failure also carries the live `error` and, when available, `help` and +`url` fields — surface them; don't swallow them into a generic message. + +## Credentials: discovery vs explicit + +**Native entry (`@cipherstash/stack`)**: the default `auto` strategy +discovers credentials — the `CS_*` environment variables first, then the +local dev profile created by `npx stash auth login`. During local +development you typically set nothing at all. + +**WASM entry (`@cipherstash/stack/wasm-inline`)**: no environment +discovery, no profile, no device login — Workers and edge runtimes have +none of those. All four values are passed explicitly in config, or you +construct a strategy yourself and pass `config.authStrategy` plus +`clientId`/`clientKey`. On this entry, `config.authStrategy` and +`config.accessKey` are **mutually exclusive — the client throws** if both +are set. (On the native entry an explicit strategy simply takes precedence +over `config.accessKey`.) See `stash-edge` for the full edge story. + +On both entries, `clientKey` is always required for encryption regardless of +strategy: the service token authenticates *requests*; the client key is +cryptographic *key material* (level 3 of the hierarchy — `stash-zerokms`) +and is never sent to any service. Auth strategies do not replace it. + +### The four `CS_*` variables + +| Variable | What it is | Secret? | +|---|---|---| +| `CS_WORKSPACE_CRN` | The workspace Cloud Resource Name — carries the region | No | +| `CS_CLIENT_ID` | The ZeroKMS client's identifier | No | +| `CS_CLIENT_KEY` | The client's key material (hex) — combined with key seeds to derive data keys | **Yes** | +| `CS_CLIENT_ACCESS_KEY` | The access key exchanged at CTS for service tokens | **Yes** | + +Mint a deployment set with **`stash env --name `**: it creates a +fresh ZeroKMS client and an access key from your logged-in session and +prints exactly this dotenv block on stdout (progress goes to stderr, so +`stash env --name x > prod.env` and pipes into secret stores are safe). The +access key is minted with the member role — the CLI never mints admin keys — +and is shown exactly once. Give each environment its own minted set; see +`stash-deployment` for where each environment's credentials live. + +## Client lifetime (user-scoped strategies) + +An `OidcFederationStrategy` instance holds **one cached CTS token**: +`getToken()` federates once, then returns the cached token until it expires +— `getJwt` is *not* consulted again while the cache is warm. That makes the +strategy, and any client built on it, **scoped to a single user by design**. +Share that client across requests and every caller rides whichever user's +token is currently cached — initially the first user's, then whoever's JWT +the next re-federation picks up. With lock context that means operating (and +being audit-logged) under the wrong user's identity. That is a cross-tenant +data hazard, not a performance nuance: construct **one `Encryption()` client +per request/user**. `AccessKeyStrategy` and `auto` authenticate a service, +not a user, and are safe to share for the process lifetime. + +## Lock context + +Layered on top of `OidcFederationStrategy`, per operation: + +```typescript +const result = await client + .encrypt(email, { table: users, column: users.email }) + .withLockContext({ identityClaim: ["sub"] }) +``` + +- The mechanism is **key-access binding**, and it lives in ZeroKMS, not in + the ciphertext math: when the data key is generated or retrieved, the + named claim (typically `sub`) is read from the caller's **service token** + and bound to that key. Retrieving the key later — which is what decrypt + does — requires presenting a service token carrying **exactly the same + claim value**. Any other caller is refused the key by ZeroKMS; the value + never decrypts. So if John encrypts with `identityClaim: ["sub"]`, only a + caller whose token carries John's `sub` gets the key back. +- This is **orthogonal to keysets**: keyset access (`stash-zerokms`) decides + which clients can operate on a keyspace at all; lock context adds a + per-value, per-caller condition on key retrieval *within* it. Both gates + must pass. +- `AccessKeyStrategy` is invalid for lock context on the Stack surface — a + service token minted from an access key has no user `sub` to bind to. (The + underlying mechanism binds verified token claims generally, not only user + subjects, but `.withLockContext({ identityClaim })` is the user-claim + path.) +- The WASM entry does not currently expose `.withLockContext()` (a known + gap, stack#797) — values written there are not identity-bound, and it + cannot read values the native entry wrote under a lock context. +- A lock-context denial is per-value and per-caller (the "gate 2" failure in + `stash-zerokms`): ZeroKMS refused to release that key to that caller. It + does not mean the credentials are wrong. + +### Deprecated, and why + +- `config.strategy` → renamed `config.authStrategy` (the old name still + works with a runtime warning; `authStrategy` wins if both are set). +- `LockContext.identify(jwt)` and `getLockContext()` — the old ceremony + fetched a per-operation CTS token. protect-ffi 0.25 removed per-operation + tokens, so the token `identify()` fetches is no longer consumed by + anything. The strategy handles token acquisition now; `.withLockContext()` + accepts a plain `{ identityClaim }` (a `LockContext` instance still works). + If you're reading example code that calls `identify()`, it predates this — + don't copy it. + +## Never read `~/.cipherstash` + +The dev profile (`auth.json`, `secretkey.json`, device JWTs) is owned by the +CLI and the auth strategies. Agents and application code must **never read +those files directly** — not to "check whether the user is logged in", not +to extract a token, not to debug. Run `stash auth login` / `stash env` and +let the strategies do the reading. A skill about authentication is exactly +where you'd be tempted; don't. + +## Proxy + +Authentication through CipherStash Proxy is a different path entirely: the +application connects to the Proxy with ordinary PostgreSQL credentials, and +the Proxy holds the CipherStash credentials and performs the CTS exchange +server-side. A dedicated proxy skill covers it — nothing in this skill's +client-side strategy model applies to apps behind the Proxy. + +## Quick diagnosis + +1. **`NOT_AUTHENTICATED` locally** → `npx stash auth login` (dev profile), + or export the four `CS_*` variables. +2. **Works locally, 401 in CI/production** → the deployed environment lacks + the `CS_*` variables, or they're from the wrong workspace — mint with + `stash env`, compare `CS_WORKSPACE_CRN`. +3. **`WORKSPACE_MISMATCH`** → the access key and the CRN name different + workspaces; re-mint rather than mix-and-match. +4. **`No OIDC provider found for issuer`** → add your IdP at + dashboard.cipherstash.com → workspace → OIDC providers, and check the + issuer URL matches the JWT's `iss` exactly. +5. **HTTP 402** → billing/usage, not credentials. +6. **Token is fine but operations fail 404/403** → that's keyset access, + not authentication — switch to the `stash-zerokms` runbook. diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 26f982302..6dbb9a987 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -368,7 +368,7 @@ stash eql migration --drizzle --supabase # also grant eql_v3 to anon/authentic | Flag | Description | |---|---| | `--drizzle` | Emit a Drizzle custom migration (via `drizzle-kit generate --custom`, then inject the SQL). Requires `drizzle-kit`. | -| `--prisma` | Emit a Prisma Next migration. **Not available yet** — the emitter is a follow-up (tracked in GitHub issue #690); fails with a pointer for now. Use `--drizzle` today. | +| `--prisma` | **Not needed** — Prisma Next installs the EQL bundle through its own migration framework (the extension pack's `migrations/cipherstash/` contract space; run `prisma-next migrate`). The flag exists only to say so and point you there. | | `--supabase` | Append the Supabase role grants (`eql_v3` + `eql_v3_internal` → `anon`, `authenticated`, `service_role`). Harmless when you connect directly as `postgres`; needed when the same tables are reached via PostgREST/RLS. | | `--name ` | Migration name (Drizzle). Default `install-eql`. Letters, numbers, `-`, and `_` only — anything else is rejected. | | `--out ` | Output directory (Drizzle). Default `drizzle`. Passed straight to `drizzle-kit --out`, so set it to match your `drizzle.config.ts` if that writes elsewhere. | @@ -440,7 +440,7 @@ Backfill requires a `public.eql_v3_*` target column, records version 3 and the ` **Dual-write precondition.** The application must already write both `` and `_encrypted` on every insert and update. Otherwise rows written *during* the backfill land in plaintext only, silently. The first run prompts (interactive) or requires `--confirm-dual-writes-deployed` (non-interactive), then records `dual_writing`. Resumes don't re-prompt. -**Credential precondition — run the backfill with the *application's* credentials.** Backfill encrypts through whichever `CS_*` credentials are in its environment, and EQL index terms derive from the ZeroKMS client key. Backfilling from a laptop on the local device profile, then querying from an app using credentials minted by `stash env`, produces rows that decrypt correctly and **never match a query** — with no error. Export the target environment's `CS_*` values in the shell running the backfill. See [`env`](#env) and `stash-edge` § The Credential-Identity Rule. +**Keyset precondition — the backfill's client must resolve to the same keyset as the application's.** Backfill encrypts through whatever credentials its environment *resolves*: `CS_*` variables when present, otherwise the native auto strategy falls back to the local `~/.cipherstash` dev profile — so a shell without the variables silently runs as your laptop's client. What must match between backfill and app is the **keyset** their clients resolve to, not the credential strings (`stash-zerokms` is canonical). Two clients bound to the same keyset interoperate fully — search included, since index terms come from a per-keyset key. A backfill bound to a *different* keyset is the quiet failure: the app can still decrypt those rows if granted that keyset, but its query terms derive under its own keyset, so encrypted search returns zero rows for them — no error. Export the target environment's `CS_*` values in the shell running the backfill (non-negotiable in CI/production) so the ciphertext lands in that environment's keyspace and the run is attributed to its client. See [`env`](#env) and `stash-auth`. | Flag | Description | |---|---| @@ -485,13 +485,18 @@ CS_CLIENT_KEY= CS_CLIENT_ACCESS_KEY=CSAK… ``` -> **Every writer of a searchable column must use these same credentials** — -> including `stash encrypt backfill`, seed scripts, and admin tools — or their -> rows decrypt but never match a query. EQL index terms derive from the ZeroKMS -> client key, so a row written under one credential and queried under another -> decrypts correctly and silently fails every search. Mint one credential per -> environment and export it for **every** process that writes that -> environment's data. See `stash-edge` § The Credential-Identity Rule. +> **What must match between writers and readers is the keyset, not the +> credential string.** The client minted here is created against the +> workspace default keyset, so it interoperates — search included — with any +> other client resolving to that keyset (index terms come from a per-keyset +> key; `stash-zerokms` is canonical). A client bound to a *different* keyset +> fails loudly where it lacks a grant — but where it *is* granted, decrypt +> still works while its searches run in its own keyspace and silently return +> zero rows (the asymmetry `stash-zerokms` documents). Still mint one +> credential set per +> environment and export it for every process touching that environment's +> data — for isolation, attribution, and revocability (`stash-auth`), not +> because credential strings must be identical. Things to know: diff --git a/skills/stash-deployment/SKILL.md b/skills/stash-deployment/SKILL.md index 9148fecdf..1d32ba023 100644 --- a/skills/stash-deployment/SKILL.md +++ b/skills/stash-deployment/SKILL.md @@ -149,9 +149,14 @@ dual-writes are live** — that is the entire reason for Gate 1. Two hard requirements: - **Encrypt under the same keyset the deployed app uses.** The credentials do - not have to be identical — any client granted access to that keyset works — - but the ciphertext must land under the keyset the app resolves, or the app - **cannot decrypt** it, with no error until read time. The trap: `CS_*` env + not have to be identical — any client **bound** to that keyset works; a mere + grant is not enough, because encrypt always lands under the client's bound + keyset — but the ciphertext must land under the keyset the app resolves. + Nothing fails at write time; what breaks at read time depends on grants: + with no grant the app **cannot decrypt** those rows, and if the app *is* + granted the stray keyset it decrypts them fine while its encrypted searches + **silently miss them** (the routing asymmetry `stash-zerokms` documents). + The trap: `CS_*` env vars beat the local `~/.cipherstash` profile, so a backfill that silently authenticates as your laptop profile can resolve a different keyset. Exporting the app's own `CS_*` vars for the run is the simplest way to guarantee a @@ -242,7 +247,7 @@ notes below), reproduce that property: the drop and the coverage check must be i |---|---| | Twin column + dual-write + backfill + read switch in one deploy | Rows written between migration-apply and code-live have no ciphertext. Reads return null/garbage for them. | | Backfill before dual-writes are live in production | Every row written during and after the backfill window stays plaintext-only. Silent; found later by a user. | -| Backfill under credentials that resolve a different keyset (e.g. the laptop profile) | Ciphertext lands under a keyset the app has no grant on. Decrypt fails in production only. No error at write time. | +| Backfill under credentials that resolve a different keyset (e.g. the laptop profile) | Ciphertext lands under the wrong keyset. No error at write time; in production the app either cannot decrypt those rows (no grant) or — if granted that keyset — decrypts them fine while encrypted search silently misses them. | | Drop plaintext in the same deploy as the read switch | No rollback. If the read path is wrong, the source data is already gone. | | Drop plaintext in the same deploy that removes dual-writes | Migrations usually apply before the new code is live: the still-deployed dual-writing code writes to a dropped column, and every insert/update fails until the rollout completes. | | Drop without an apply-time coverage re-check | Rows written by a missed dual-write path are destroyed by the drop. | @@ -287,11 +292,11 @@ Rules that bite in practice: authenticates during the build. Static-generation and page-data collection do exactly this. A build without `CS_*` fails with `Not authenticated`. Local builds mask it, because the `~/.cipherstash` device profile authenticates silently. -- **Keyset consistency governs decryptability.** Everything that writes ciphertext - for an environment — the app, the backfill, one-off scripts — must resolve to the - same keyset; the credentials themselves may differ, provided each client is - granted that keyset. Mismatches are silent at write time. The model lives in - `stash-zerokms`. +- **Keyset consistency governs decryptability and searchability.** Everything that + writes ciphertext for an environment — the app, the backfill, one-off scripts — + must resolve to the same keyset; the credentials themselves may differ, provided + each client is **bound** to that keyset (a grant alone routes only decrypt). + Mismatches are silent at write time. The model lives in `stash-zerokms`. - **Never print or log the values.** They are secrets, and so is any one-time database connection URL used alongside them. @@ -448,7 +453,8 @@ id is printed in the check output itself. | Native module fails to load (edge runtime, bundled serverless) | The default entry needs native `require`. Bundle `@cipherstash/stack/wasm-inline` instead — see `stash-edge`. | | Writes fail with `column "…" does not exist` after the drop | The drop was applied while dual-writing code was still deployed. Deploy the dual-write removal (Deploy 3) before applying the drop. | | Rows read back as `null` / garbage after cutover | Uncovered rows: a write path that never dual-wrote, or a backfill that ran before dual-writes were live. Re-run the backfill with `--force`. | -| Decrypt fails only in production | Keyset mismatch — ciphertext written under a different keyset than the app resolves (see `stash-zerokms`). | +| Decrypt fails only in production | Keyset mismatch — ciphertext written under a different keyset than the app resolves, with no grant covering it (see `stash-zerokms`). | +| Decrypt works but encrypted search returns zero rows | Reader bound to a different keyset than the writer while granted the writer's (see `stash-zerokms`), or an index/predicate issue (`stash-indexing`, `stash-postgres`). | | Raw EQL payloads reaching end users | Read path not wired through decryption. | | Deploy fails with a destructive-operation policy error | Additive-only deploy policy. Apply the authored migration out-of-band, ideally before merging. | | Migration applied but the deploy still fails identically | It was applied to the wrong (preview) database. | diff --git a/skills/stash-edge/SKILL.md b/skills/stash-edge/SKILL.md index 12ca34fc3..ae4a11efb 100644 --- a/skills/stash-edge/SKILL.md +++ b/skills/stash-edge/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-edge -description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, the four mandatory `CS_*` variables and minting them with `stash env`, the credential-identity rule (rows written under different credentials decrypt but never match a query), how the WASM client surface differs from the native typed client, and why an EQL v3 schema module cannot be shared across the two entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally. +description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, the four mandatory `CS_*` variables and minting them with `stash env`, how keysets and credentials interact on the edge (what must match is the keyset — `stash-zerokms` is canonical), how the WASM client surface differs from the native typed client, and why an EQL v3 schema module cannot be shared across the two entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally. --- # Encryption on the Edge (WASM entry) @@ -26,7 +26,7 @@ together. bundler chokes trying to include it. - Wiring `CS_*` credentials into an edge deploy, or minting them at all. - Encrypted search works locally but returns **zero rows** in the deployed - function — see [The Credential-Identity Rule](#the-credential-identity-rule-a-silent-data-footgun). + function — see [Keysets and Credentials](#keysets-and-credentials-when-search-returns-zero-rows). - A schema module shared with Node tooling fails to typecheck against the edge client. @@ -160,49 +160,44 @@ wrangler secret put CS_CLIENT_KEY # repeat per variable vercel env add CS_CLIENT_KEY production ``` -## The Credential-Identity Rule (a silent data footgun) - -> **Every writer of a searchable column must use the same credentials as every -> reader — including `stash encrypt backfill`, seed scripts, and admin tools. -> Rows written under different credentials decrypt correctly but never match a -> query.** - -EQL's searchable-encryption index terms (the `hm`, `op`, `bf` fields in the -stored payload) derive from the **ZeroKMS client key**, not from the workspace -or keyset. Two clients in the same workspace with different `CS_CLIENT_ID` / -`CS_CLIENT_KEY` pairs therefore produce **different terms for the same -plaintext**. - -The consequences are asymmetric, which is what makes this hard to spot: - -- **Decryption still works.** The data key is wrapped through ZeroKMS against - the workspace, so any authorised client in the workspace can decrypt the - row. Round-trip tests pass. -- **Search silently fails.** An equality or match predicate compares the - query term against the stored term. Different client keys, different terms, - no match — and no error. The query returns zero rows exactly as though the - data were absent. - -The classic way to hit it: run `stash encrypt backfill` from a laptop (using -the local device-profile credentials), then query those rows from an Edge -Function using `CS_*` values minted by `stash env`. Every row decrypts. No -search ever matches. - -**What to do:** - -- Mint one credential per *environment*, and use it for **every** process that - touches that environment's data — the app, the backfill, seed scripts, admin - jobs, and one-off scripts alike. -- Before running `stash encrypt backfill` against an environment, export that - environment's `CS_*` values into the shell running it. -- If rows have already been written under the wrong credentials, re-encrypt - them with the correct client: read (decryption still works), then write back - through a client built with the target credentials. - -**Diagnosing it:** if `decrypt` returns the right plaintext but an equality -query on the same row returns nothing, compare the stored term against a -freshly minted one for the same plaintext. Matching plaintext with differing -`hm` values is this bug, not an indexing problem. +## Keysets and Credentials (when search returns zero rows) + +An earlier version of this section described a "credential-identity rule": +index terms deriving from the ZeroKMS client key, so rows written under one +credential would decrypt but silently never match a query. **That model is +wrong.** The scoping unit is the **keyset**, and `stash-zerokms` is the +canonical skill for it. What actually holds: + +- Search terms are produced with a per-**keyset** index key. Every client + **bound** to the keyset derives the *same* index key, so rows written by + one credential match queries from another — different `CS_CLIENT_ID` / + `CS_CLIENT_KEY` pairs interoperate fully as long as both clients resolve + to the same keyset. +- The routing is asymmetric (`stash-zerokms` has the full model): encrypt + and query always use the client's **bound** keyset — unreachable (no + grant, revoked, disabled) means client construction fails loudly at the + index-key load — while **decrypt follows each payload's keyset**, subject + to grants, with an ungranted payload failing loudly (ZeroKMS 404). +- The old cautionary scenario — `stash encrypt backfill` from a laptop, then + querying from an Edge Function with `stash env`-minted values — is fine + when both clients resolve to the same keyset (the common case: both + created against the workspace default). If they resolve to *different* + keysets, how it fails depends on grants: no grant across them and decrypt + fails loudly too; but a reader *granted* the writer's keyset while bound + to its own decrypts fine and **silently searches the wrong keyspace — + zero rows, no error**. Watch the keyset-less nuance from `stash-zerokms`: + an operation with no explicit keyset resolves to **that client's default + keyset**, so two clients created against different keysets don't share a + keyspace even in the same workspace. + +**If decrypt works but a query returns zero rows, it is never the credential +strings.** Check, in order: the reader's bound keyset against the writer's +(`stash-zerokms`), the operand cast / predicate form (`stash-postgres`), and +that the extractor index exists and is used (`stash-indexing`). + +Environment hygiene still matters, for the reasons `stash-auth` and +`stash-deployment` give: mint one credential set per environment with +`stash env`, and don't point laptop profile credentials at production data. ## The Client Surface @@ -250,19 +245,23 @@ them is the standard mistake. Identity-bound encryption needs both: `config.authStrategy`. The client then acts as that user for its lifetime. **Available on this entry**, and shown below. 2. **Bind the data key to a claim** — chain `.withLockContext({ identityClaim })` - on the operation. *This* is what changes key derivation. **Not available on - this entry** ([#797](https://github.com/cipherstash/stack/issues/797)). + on the operation. *This* is what binds key retrieval to the user's claim. + **Not available on this entry** + ([#797](https://github.com/cipherstash/stack/issues/797)). > [!IMPORTANT] > **An auth strategy alone does not produce identity-bound data.** It decides -> *who the client is*; a lock context decides *which key the value is encrypted -> under*. Only the first exists here, so on this entry today: +> *who the client is*; a lock context decides *who can retrieve a value's +> data key* (the claim from the encrypting caller's service token is bound to +> the key — `stash-auth` is canonical). Only the first exists here, so on +> this entry today: > -> - Values you write are encrypted under the **workspace key**, not the user's -> — even with a per-user `authStrategy`. +> - Values you write carry **no identity condition on key retrieval** — any +> client with keyset access can decrypt them — even with a per-user +> `authStrategy`. > - You **cannot read** anything the native entry wrote under a lock context, -> because decrypt needs the same context. That is a silent split in what the -> two entries can read, on top of the schema incompatibility below. +> because key retrieval requires the same claim. That is a silent split in +> what the two entries can read, on top of the schema incompatibility below. > > If a value must be bound to an end-user claim, encrypt and decrypt it on the > native entry. Don't reach for `as any` to force a lock context through here — @@ -288,8 +287,8 @@ const client = await Encryption({ config: { authStrategy: strategy.data, clientId, clientKey }, }) -// Authenticated as the end user — but the value is still encrypted under the -// workspace key. There is no `.withLockContext()` on this entry to bind it. +// Authenticated as the end user — but the value carries no identity condition +// on key retrieval. There is no `.withLockContext()` on this entry to bind it. const enc = await client.encrypt('alice@example.com', { table: users, column: users.email, @@ -432,10 +431,12 @@ function was served without `--env-file`. Validate all four at handler entry and return an actionable error rather than letting client construction fail opaquely; the example in `examples/supabase-worker` does exactly this. -**Encryption works, search returns zero rows** — the credential-identity rule -above. Second most likely: a missing index (see `stash-indexing`) makes it -slow, not empty, so empty results point at credentials or an untyped operand -(`stash-postgres`). +**Encryption works, search returns zero rows** — not a credential problem: a +keyset mismatch fails everything loudly, decrypt included (see [Keysets and +Credentials](#keysets-and-credentials-when-search-returns-zero-rows) and +`stash-zerokms`). Empty results with working decrypt point at an untyped +operand or wrong predicate form (`stash-postgres`); a missing index (see +`stash-indexing`) makes queries slow, not empty. **Search needle rejected** — free-text needles must be at least 3 characters; shorter ones tokenize to nothing. @@ -446,6 +447,8 @@ user-scoped, so it is reused across invocations on a warm isolate. ## Reference +- `stash-zerokms` — keysets, clients, grants, and the key hierarchy (canonical). +- `stash-auth` — credentials, auth strategies, and lock context (canonical). - `stash-postgres` — the raw-SQL predicate cookbook and driver binding rules. - `stash-encryption` — schema authoring, the `types.*` domain catalog, and the rollout/cutover lifecycle. diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 39b1392a3..b2b776c61 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -481,7 +481,7 @@ if (decrypted.failure) throw new Error(decrypted.failure.message) `null` / `undefined` entries yield `null` at the same index without reaching ZeroKMS. Because each entry names its own column, one call can cover several columns across many rows. When items fail to decrypt, `failure.message` names every failing index. -**The model helpers are available on the WASM entry too**: `encryptModel(model, table)`, `decryptModel(model, table)`, `bulkEncryptModels(models, table)`, `bulkDecryptModels(models, table)`. They run the same schema walk as the native client — declared columns are encrypted (matched by **JS property name**; nested fields via the column's dotted path), everything else passes through, `null`/`undefined` fields are preserved without reaching ZeroKMS, and the caller's model is never mutated — and a call that touches at least one field is **one ZeroKMS round trip** no matter how many fields or models it covers (an empty batch, or one whose models carry no schema fields, is short-circuited and makes **zero** calls). `table` must be one the client was built with (`Encryption({ schemas })`), else the call fails, as on the native client. `types.Date` / `types.Timestamp` columns round-trip `Date` → `Date` (on the wire they travel as ISO strings); because matching is by JS property name, a row keyed by raw DB column names (e.g. a raw `SELECT` returning `created_on`) still decrypts, but its date fields come back as ISO strings — key your models by the schema's property names. Differences from the native typed client: every method returns a plain `Promise` of the `{ data } | { failure }` Result (no `.audit()` chaining), and there is **no lock-context argument** — identity-bound encryption on the edge is configured at client construction via `config.authStrategy`. Decrypt failures name every failing field: `bulkDecryptModels` prefixes the model index (`[model 1] profile.ssn`), `decryptModel` names the field alone (`profile.ssn`). +**The model helpers are available on the WASM entry too**: `encryptModel(model, table)`, `decryptModel(model, table)`, `bulkEncryptModels(models, table)`, `bulkDecryptModels(models, table)`. They run the same schema walk as the native client — declared columns are encrypted (matched by **JS property name**; nested fields via the column's dotted path), everything else passes through, `null`/`undefined` fields are preserved without reaching ZeroKMS, and the caller's model is never mutated — and a call that touches at least one field is **one ZeroKMS round trip** no matter how many fields or models it covers (an empty batch, or one whose models carry no schema fields, is short-circuited and makes **zero** calls). `table` must be one the client was built with (`Encryption({ schemas })`), else the call fails, as on the native client. `types.Date` / `types.Timestamp` columns round-trip `Date` → `Date` (on the wire they travel as ISO strings); because matching is by JS property name, a row keyed by raw DB column names (e.g. a raw `SELECT` returning `created_on`) still decrypts, but its date fields come back as ISO strings — key your models by the schema's property names. Differences from the native typed client: every method returns a plain `Promise` of the `{ data } | { failure }` Result (no `.audit()` chaining), and there is **no lock-context argument** — a known gap ([#797](https://github.com/cipherstash/stack/issues/797)), not a different mechanism. `config.authStrategy` decides *who the client is*; it does not bind values to the user (a lock context gates retrieval of a value's data key by a claim — `stash-auth` is canonical). Values written on the edge therefore carry no identity condition, and the edge cannot read values the native entry wrote under a lock context. Decrypt failures name every failing field: `bulkDecryptModels` prefixes the model index (`[model 1] profile.ssn`), `decryptModel` names the field alone (`profile.ssn`). ```typescript const row = await client.encryptModel({ id: 1, email: "alice@example.com" }, users) @@ -638,7 +638,7 @@ See the `stash-drizzle` and `stash-supabase` skills for the full integration gui ## Authentication -The client authenticates to ZeroKMS through `config.authStrategy`. Leave it unset for the default **auto** strategy: in local development, authenticate once with `npx stash auth login` (preferred — no credentials in your environment; `npx stash init` is the agent-assisted flow that also sets up schema and database); in CI/production, set the `CS_*` environment variables. Two explicit strategies cover the other cases: +The client authenticates to ZeroKMS through `config.authStrategy` (`stash-auth` is the canonical skill for credentials, strategies, and failure codes). Leave it unset for the default **auto** strategy: in local development, authenticate once with `npx stash auth login` (preferred — no credentials in your environment; `npx stash init` is the agent-assisted flow that also sets up schema and database); in CI/production, set the `CS_*` environment variables. Two explicit strategies cover the other cases: - **`AccessKeyStrategy`** — service-to-service / CI. Authenticates a *service* with a CipherStash access key. - **`OidcFederationStrategy`** — authenticates the client **as the end user** by federating a third-party OIDC JWT (Clerk, Supabase, Auth0, Okta, ...) into a CipherStash service token: @@ -672,7 +672,7 @@ Authentication stands on its own — an OIDC-authenticated client encrypts and d ## Identity-Aware Encryption (Lock Contexts) -Bind a data key to a claim from the end user's JWT, so only that user can decrypt. Chain `.withLockContext({ identityClaim })` on any operation: +Bind a data key to a claim from the end user's JWT, so only that user can decrypt — the claim is bound to the key at encrypt time, and ZeroKMS releases the key only to a caller presenting the same claim (`stash-auth` is the canonical skill for this model). Chain `.withLockContext({ identityClaim })` on any operation: ```typescript // Requires a client authenticated with OidcFederationStrategy (see @@ -737,7 +737,12 @@ const client = await Encryption({ }) ``` -Each keyset provides full cryptographic isolation between tenants. +Each keyset provides full cryptographic isolation between tenants. Encrypt +and query always use the client's bound keyset (one `Encryption()` client +per tenant); decrypt follows each payload's own keyset, subject to grants. +Omitting `config.keyset` resolves to the *client's* default keyset. The +`stash-zerokms` skill is canonical for keysets, clients, grants, and the +failure modes. ## Operation Chaining diff --git a/skills/stash-postgres/SKILL.md b/skills/stash-postgres/SKILL.md index 5cfd92e01..35f422d5c 100644 --- a/skills/stash-postgres/SKILL.md +++ b/skills/stash-postgres/SKILL.md @@ -407,11 +407,15 @@ payload (1–2 KB per row) and spills. Group on the extractor — sequential-scans. Adding the functional index over the extractor is a separate step — see `stash-indexing`. -**Every writer needs the same credentials.** Index terms derive from the -ZeroKMS client key, so rows written by a client with different `CS_*` -credentials decrypt correctly but never match a query — silently. This -includes `stash encrypt backfill` and seed scripts. See `stash-edge` § -The Credential-Identity Rule. +**Every writer and query reader must resolve to the same keyset** — the +credential strings themselves may differ. Index terms come from a per-*keyset* +key, so any client bound to the keyset produces matching terms. Decrypt is +looser: it follows each payload's keyset and needs only a grant, which makes +one silent case possible — a reader granted the writer's keyset but bound to +a different one decrypts fine while its queries return zero rows. If decrypt +works but a query returns zero rows, check the reader's bound keyset against +the writer's (`stash-zerokms`), then the operand cast or predicate form on +this page, then the index (`stash-indexing`) — never the credential strings. ## Troubleshooting @@ -458,8 +462,9 @@ SELECT column_name, domain_schema, domain_name and the client API, the rollout/cutover lifecycle. - `stash-indexing` — functional indexes over the term extractors, and the `EXPLAIN` checklist. -- `stash-edge` — the WASM entry, `CS_*` credentials, and the - credential-identity rule. +- `stash-edge` — the WASM entry and running encryption from edge runtimes. +- `stash-zerokms` — keysets, clients, and grants (canonical for keyset scoping). +- `stash-auth` — credentials, auth strategies, and lock context (canonical). - `stash-cli` — `stash eql install`, `stash db validate`, `stash encrypt backfill`. Upstream: diff --git a/skills/stash-prisma-next/SKILL.md b/skills/stash-prisma-next/SKILL.md index fef869875..35f02688d 100644 --- a/skills/stash-prisma-next/SKILL.md +++ b/skills/stash-prisma-next/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-prisma-next -description: Integrate CipherStash searchable field-level encryption with Prisma Next using @cipherstash/prisma-next (EQL v3). Covers the domain-named encrypted column types in schema.prisma (TextSearch, DoubleOrd, BigIntOrd, DateOrd, Boolean, Json), the one-call cipherstashFromStack wiring, the runtime value envelopes (EncryptedString/Number/BigInt/Date/Boolean/Json) and decryptAll, the eql* query operators (eqlEq, eqlMatch, eqlGt, eqlBetween, eqlIn, eqlJsonContains, eqlAsc/eqlDesc, eqlJsonPathAsc/eqlJsonPathDesc), EQL bundle installation via prisma-next migrate, and authentication. Use when adding encryption to a Prisma Next project or querying encrypted columns. +description: Integrate CipherStash searchable field-level encryption with Prisma Next using @cipherstash/prisma-next (EQL v3). Covers the full 31-constructor catalog of domain-named encrypted column types in schema.prisma (per plaintext type × capability tier — Text/TextEq/TextOrd/TextMatch/TextSearch, Integer/Smallint/BigInt/Numeric/Real/Double × Eq/Ord, Date/Timestamp × Eq/Ord, Boolean, Json), the one-call cipherstashFromStack wiring, the runtime value envelopes (EncryptedString/Number/BigInt/Date/Boolean/Json) and decryptAll, the eql* query operators (eqlEq, eqlMatch, eqlGt, eqlBetween, eqlIn, eqlJsonContains, eqlAsc/eqlDesc, eqlJsonPathAsc/eqlJsonPathDesc), EQL bundle installation via prisma-next migrate, and authentication. Use when adding encryption to a Prisma Next project, choosing a column type, or querying encrypted columns. --- # CipherStash Stack — Prisma Next Integration @@ -58,29 +58,55 @@ model User { } ``` -| Column type | Domain | Query capability | -|---|---|---| -| `TextSearch()` | `eql_v3_text_search` | equality, range, free-text, ORDER BY | -| `DoubleOrd()` | `eql_v3_double_ord` | equality, range, ORDER BY | -| `BigIntOrd()` | `eql_v3_bigint_ord` | equality, range, ORDER BY | -| `DateOrd()` | `eql_v3_date_ord` | equality, range, ORDER BY | -| `Boolean()` | `eql_v3_boolean` | storage-only (no operators) | -| `Json()` | `eql_v3_json_search` | containment + JSONPath equality/range | - -Choose the column type by the queries you need: a value you only store and -decrypt (never search) can use a storage-only domain; a value you filter or sort -on needs the matching `*Ord` / `TextSearch` domain. The type is fixed at the -column — there is no capability tuner. +The example shows six types; the **full catalog is 31 constructors** — one +per exposed `public.eql_v3_*` domain, derived mechanically from the domain +registry. Pick by **plaintext TypeScript type first**, then by the queries +you need: + +| Plaintext (TS type) | Storage-only | Equality | Order + range | Free-text | Everything | +|---|---|---|---|---|---| +| `string` | `Text()` | `TextEq()` | `TextOrd()` | `TextMatch()` | `TextSearch()` | +| `number` (int4) | `Integer()` | `IntegerEq()` | `IntegerOrd()` | — | — | +| `number` (int2) | `Smallint()` | `SmallintEq()` | `SmallintOrd()` | — | — | +| `bigint` (int8) | `BigInt()` | `BigIntEq()` | `BigIntOrd()` | — | — | +| `number` (numeric) | `Numeric()` | `NumericEq()` | `NumericOrd()` | — | — | +| `number` (float4) | `Real()` | `RealEq()` | `RealOrd()` | — | — | +| `number` (float8) | `Double()` | `DoubleEq()` | `DoubleOrd()` | — | — | +| `Date` (date) | `Date()` | `DateEq()` | `DateOrd()` | — | — | +| `Date` (timestamp) | `Timestamp()` | `TimestampEq()` | `TimestampOrd()` | — | — | +| `boolean` | `Boolean()` | — | — | — | — | +| JSON document | `Json()` — searchable JSON: containment + JSONPath equality/range/ORDER BY | | | | | + +Reading the table: + +- Each constructor maps 1:1 to the domain named after it: + `IntegerOrd()` → `eql_v3_integer_ord`, `Text()` → `eql_v3_text`, and so on + (`Json()` → `eql_v3_json_search`). +- **Every `*Ord` domain includes equality** (equality + range + ORDER BY); + every `*Eq` domain is equality only; the bare family name is storage-only + (encrypt/decrypt, no operators). `TextMatch` is free-text **only** — no + equality. `TextSearch` carries all three text capabilities. +- **The plaintext type matters as much as the capability.** Money stored as + integer cents wants `IntegerOrd()` (JS `number`) — not `DoubleOrd()` + (float semantics) and not `BigIntOrd()`, whose plaintext is a JS `bigint` + and rejects `number` values. +- The `*OrdOre` variants exist in the database bundle but are deliberately + not exposed as constructors (their btree opclass is superuser-gated — see + `stash-indexing`). + +The type is fixed at the column — there is no capability tuner. A value you +only store and decrypt can use a storage-only domain; a value you filter or +sort needs the matching `*Eq` / `*Ord` / text-search domain. ### 2. Register the extension pack in `prisma-next.config.ts` ```typescript import cipherstash from '@cipherstash/prisma-next/control' -import { defineConfig } from 'prisma-next' +import { defineConfig } from '@prisma-next/cli/config-types' import { prismaContract } from '@prisma-next/sql-contract-psl/provider' import postgresPack from '@prisma-next/target-postgres/pack' import { postgresCreateNamespace } from '@prisma-next/target-postgres/types' -// ... family, target, adapter +// ... family, target, driver, adapter export default defineConfig({ // ... your existing config @@ -233,11 +259,12 @@ await decryptAll(rows) // batches one SDK round-trip pe console.log(await rows[0]?.email.decrypt()) // 'alice@example.com' ``` -The envelope for a `double` column is `EncryptedNumber` (JS `number`); the schema -column type is `DoubleOrd`. Envelope ↔ column pairing: `EncryptedString` -↔ `TextSearch`, `EncryptedNumber` ↔ `DoubleOrd`, -`EncryptedBigInt` ↔ `BigIntOrd`, `EncryptedDate` ↔ `DateOrd`, -`EncryptedBoolean` ↔ `Boolean`, `EncryptedJson` ↔ `Json`. +Envelopes pair by **plaintext type**, not by column name — one envelope +covers every domain of its family: `EncryptedString` ↔ all `Text*` columns, +`EncryptedNumber` ↔ all `number` families (`Integer*`, `Smallint*`, +`Numeric*`, `Real*`, `Double*`), `EncryptedBigInt` ↔ `BigInt*`, +`EncryptedDate` ↔ `Date*` and `Timestamp*`, `EncryptedBoolean` ↔ `Boolean`, +`EncryptedJson` ↔ `Json`. ## Query operators (`eql*`) @@ -292,8 +319,9 @@ Same credential model as the rest of Stack: - **Local dev:** `npx stash auth login` (device-code flow; token in `~/.cipherstash`). - **CI / production:** the four `CS_*` env vars (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, - `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`). See the `stash-cli` and - `stash-encryption` skills for how to obtain them from your device session. + `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`), minted with `stash env`. The + `stash-auth` skill is canonical for credentials and auth strategies; + `stash-zerokms` for keysets and what the credentials can reach. `cipherstashFromStack` resolves `CS_*` when present, else the local profile. @@ -301,8 +329,11 @@ Same credential model as the rest of Stack: `@cipherstash/stack` wraps a native FFI module and must be excluded from bundling (`serverExternalPackages`, esbuild `external`, etc.) — see the `stash-encryption` -skill's bundling section. For edge/serverless runtimes without the native module, -use `@cipherstash/stack/wasm-inline`. +skill's bundling section. The Prisma Next adapter is **native-only**: +`cipherstashFromStack` constructs the native `@cipherstash/stack` client, and +there is no `wasm-inline` variant of this adapter — the WASM entry is a +different client for non-Prisma edge paths (`stash-edge`), not a drop-in here. +Run Prisma Next apps on a Node runtime where the native module loads. ## Subpath exports @@ -312,6 +343,7 @@ use `@cipherstash/stack/wasm-inline`. | `@cipherstash/prisma-next/control` | The extension pack for `extensionPacks: [...]` | | `@cipherstash/prisma-next/runtime` | Envelope classes, `decryptAll`, `eql*` operators, `EncryptedString.from()`… | | `@cipherstash/prisma-next/stack` | One-call setup against `@cipherstash/stack`: `cipherstashFromStack` | +| `@cipherstash/prisma-next/column-types` | camelCase factories (`textSearch`, `bigIntOrd`, …) for **TS-authored** contracts — emits byte-identical `contract.json` to the PSL constructors | ## Gotchas diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 0afdc32b4..36616a04f 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -56,11 +56,15 @@ this is also how **Supabase Edge Functions** get credentials in local dev — `stash env --name edge-dev --write` and pass `--env-file`, or `supabase secrets set` them for deploys. -> **One credential per environment, used by everything that writes.** EQL index -> terms derive from the ZeroKMS client key, so rows written by a client with -> different `CS_*` values decrypt correctly but never match a query — silently. -> That includes `stash encrypt backfill`, seed scripts, and Edge Functions. -> Encryption *inside* an Edge Function (Deno, no native modules) uses the +> **One credential set per environment — but what must match between writers +> and query readers is the keyset, not the credential string.** Index terms +> come from a per-*keyset* key, so any client bound to the keyset produces +> matching terms. Decrypt is looser — it follows each payload's keyset and +> needs only a grant — so a reader granted the writer's keyset but bound to +> a different one decrypts fine while its searches silently return zero +> rows. `stash-zerokms` is canonical for keyset scoping, `stash-auth` for +> credentials. Encryption *inside* an +> Edge Function (Deno, no native modules) uses the > `@cipherstash/stack/wasm-inline` entry — see the `stash-edge` skill; SQL > written by hand in a migration or RPC is covered by `stash-postgres`. @@ -496,8 +500,10 @@ const { data, error } = await es ``` `identityClaim` is an array of JWT claim *names* (`["sub"]`), not values; the same -claim must be used to encrypt and decrypt. `.withLockContext()` also accepts a -`LockContext` instance. +claim must be presented to decrypt — the claim gates retrieval of the value's +data key at ZeroKMS. `.withLockContext()` also accepts a `LockContext` +instance. `stash-auth` is the canonical skill for the lock-context model and +the auth strategies. > **Deprecated: `LockContext.identify()`.** Older code did > `new LockContext().identify(userJwt)` to fetch a per-operation CTS token. Those diff --git a/skills/stash-zerokms/SKILL.md b/skills/stash-zerokms/SKILL.md new file mode 100644 index 000000000..dfee7d5f6 --- /dev/null +++ b/skills/stash-zerokms/SKILL.md @@ -0,0 +1,293 @@ +--- +name: stash-zerokms +description: The ZeroKMS key model — keysets, clients, client keys, and the grant/revoke lifecycle. Covers the four-level key hierarchy, why every encrypt/decrypt/query is scoped to a keyset, the exact failure surface when a client lacks keyset access (unreachable keysets fail loudly; the one silent case is a reader granted the writer's keyset but bound to a different one — decrypt works, encrypted search returns zero rows), the workspace default keyset, multi-tenant isolation via `config.keyset`, and the ZeroKMS API for creating, granting, and revoking keyset access. Use when a decrypt or query fails with a ZeroKMS error, when planning multi-tenant key isolation, when deciding which credentials a backfill job or edge function should use, when rotating or revoking a compromised credential, or whenever another skill's guidance touches "credentials", "keysets", or "who can decrypt what" — this skill is the canonical source for that model. +--- + +# ZeroKMS: keysets, clients, and key management + +ZeroKMS is the key service behind every CipherStash Stack operation. This +skill is the canonical description of its access model. Other skills +(`stash-edge`, `stash-deployment`, `stash-cli`, `stash-postgres`, +`stash-supabase`) touch credentials and keysets in passing; where their +wording and this skill disagree, **this skill wins**. + +## Authentication and regions (see `stash-auth`) + +ZeroKMS accepts exactly one credential: a **CipherStash service token** — a +short-lived signed JWT minted by CTS, the CipherStash token service. Access +keys and IdP JWTs are never sent to ZeroKMS directly; they are exchanged at +CTS for a service token first. The auth strategies do this for you — +`stash-auth` is the canonical skill for that whole surface (strategies, +`CS_*` variables, token contents, failure codes). + +ZeroKMS runs in a number of regions (the current list: `stash auth regions`, +or the table in `stash-auth`). Your workspace's region is part of its CRN +(`crn:.:`), and CTS resolves the matching +ZeroKMS endpoint and stamps it into the service token — the client discovers +where ZeroKMS is from the token itself. You never configure a ZeroKMS URL, +which is also why the `CS_*_HOST` override variables are debug-only and must +not appear in CI or examples. + +## The model in one paragraph + +Every value is encrypted **under a keyset**. A **client** (an application +credential with its own client key) is **bound to one keyset** — the one +named in its config (`config.keyset`) or, when omitted, its default keyset, +the one it was created against — and can additionally be **granted access** +to others. The routing is asymmetric, and everything below follows from it: + +- **Encrypt and query always use the client's bound keyset.** Search terms + are produced with a per-*keyset* index key the client loads from ZeroKMS + at initialization. A client whose bound keyset is unreachable (no grant, + revoked, disabled) fails **loudly** right there — construction, encrypt, + and query alike. +- **Decrypt follows each payload's own keyset**, whichever client wrote it, + and succeeds for any keyset the decrypting client is granted. A payload + under a keyset the client has no grant for fails **loudly** at the ZeroKMS + round trip (404). + +Two corollaries that follow directly, and that agents get wrong most often: + +1. **"Same credentials everywhere" is stronger than what's required.** Two + different clients — each with its own client key — interoperate completely, + search included, as long as both are **bound to the same keyset**. A + backfill job and the deployed app may use different access keys; what must + match is the *keyset* the operations run under, not the credential + strings. Be precise about how that keyset is chosen: when an operation + names one (`config.keyset`), it's that keyset; when it doesn't, ZeroKMS + uses **that client's default keyset** — the keyset the client was bound to + when it was created. So two clients that both omit `config.keyset` only + interoperate if their *default keysets* are the same keyset. +2. **One silent failure mode exists, and it is exactly the asymmetry above.** + A reader that is *granted* the writer's keyset but *bound* to a different + one decrypts the writer's rows fine — while its query terms derive under + its own bound keyset and match nothing: **zero rows, no error**. So + "decrypt works but search returns zero rows" has three suspects, in + order: the reader's bound keyset vs the writer's (this skill), the + extractor index (`stash-indexing`), the operand cast / predicate form + (`stash-postgres`). What can *not* cause it is the credential string — + every client bound to the same keyset derives the same index key. The + same-keyset rule therefore binds **writers and query readers**; + decrypt-only readers need just a grant. + +## The key hierarchy + +Four levels, each narrowing scope; no single component holds enough material +to derive a data key alone. + +| Level | Key | Held by | Purpose | +|---|---|---|---| +| 1 | Root key | HSM / hardware root of trust | Protects all downstream key material. Never exported. | +| 2 | Authority key (per keyset) | ZeroKMS | Derives key seeds for a keyset. Materialized per client grant, so every granted client resolves the same keyspace. | +| 3 | Client key (per client / device) | Application runtime only | Never transmitted to ZeroKMS. Multiple clients can share a keyset, each with its own key. | +| 4 | Data key (per value) | Derived in-process, ephemeral | Derived from client key + key seed during encrypt/decrypt. Never stored or transmitted. | + +ZeroKMS uses proxy symmetric re-encryption: it sends key *seeds*, never +usable keys, and the client combines a seed with its own client key to derive +each per-value data key. Because the data key requires both halves: + +- **ZeroKMS never possesses a usable data key** (zero-knowledge). +- **Revoking one client blocks all of its future key operations, instantly + and without re-encryption.** ZeroKMS stops issuing seeds to that client; + its client key cannot derive data keys from seeds issued to other clients. + No effect on other clients. Like any key service, revocation is not + retroactive — plaintext or per-value data keys the client already held in + memory are beyond recall — but because keys are per *value*, the blast + radius is exactly the values that client already accessed, never the + keyspace. + +## Keysets + +A keyset is the isolation unit: data encrypted under one keyset can never be +decrypted or queried with another keyset's keys. Every operation runs under +the data's own keyset, and only clients granted that keyset can perform it. +Keysets belong to a workspace. + +- **Naming**: 1–64 characters, ASCII letters/digits plus `_`, `-`, `/`. The + name `default` is reserved (case-insensitively) for the workspace default + keyset. Descriptions are 1–256 characters. +- **The workspace default keyset**: every workspace has exactly one, named + `default`, created automatically the first time it's needed. It cannot be + renamed or disabled. A client created without naming a keyset is bound to + it. +- **Every client also has its own default keyset**: the keyset it was bound + to at creation (`default` unless another was named). An operation that + doesn't specify a keyset resolves to **the client's default keyset** — not + automatically the workspace's. The two coincide when the client was + created without naming a keyset — as with the profile credentials in a + dev environment — which is why single-tenant apps that never mention + keysets still work. But a client created against `tenant-a` defaults to + `tenant-a`, and a client with no default at all (possible via the API) + gets `404 — "Client (…) has no default keyset"` on any keyset-less + operation. +- **Disabled keysets**: a keyset (other than the default) can be disabled as + a reversible kill-switch. While disabled, *every* operation under it fails + for *every* client with `403 — "Keyset disabled: request could not be + processed because the keyset has been disabled"`. Re-enabling restores + access; no data is touched. + +## Clients and grants + +A **client** is a credential identity in ZeroKMS: it has an id, a client key +(generated at creation, returned once, held only by the application), and a +set of keyset grants. + +- Creating a client binds it to one keyset immediately (the default keyset + unless another is named at creation). +- **Grant** adds access to a further keyset. **Revoke** removes one grant. + **Deleting a client** removes the client and all of its grants. +- Grants are per *(client, keyset)* pair. There is no wildcard and no + transitive access. + +**The device client** is a special case worth knowing about: after +`stash auth login`, the CLI generates a **device identity** (a device +identifier and name, stored in the profile), provisions a client in ZeroKMS +named after the device — bound to the workspace default keyset, at most one +device client per device per workspace — and persists the resulting key to +`~/.cipherstash/secretkey.json`. That key is a **standard client key**; only +its encapsulation differs (a JSON profile file holding the client id and key +material, rather than the hex `CS_CLIENT_KEY` form a deployed app uses). It +is what makes local dev work with no environment variables: everything this +skill says about clients — the default keyset, grants, revocation — applies +to the device client like any other. The profile files are read only by the +CLI and the auth strategies; agents never read them directly (see +`stash-auth`). + +Manage all of this in the +[dashboard](https://dashboard.cipherstash.com/workspaces/_/keysets) (the `_` +resolves to your selected workspace). The underlying ZeroKMS API, for +automation: + +| Endpoint (POST) | Effect | Required scope | +|---|---|---| +| `/create-keyset` | Create a keyset (optionally with a client in one call) | `keyset:create` (+ `client:create` if bundling a client) | +| `/list-keysets` | List the workspace's keysets | `keyset:list` | +| `/modify-keyset`, `/enable-keyset`, `/disable-keyset` | Rename/describe, re-enable, kill-switch | `keyset:modify` / `keyset:enable` / `keyset:disable` | +| `/create-client` | Create a client bound to a keyset (default if unnamed) | `client:create` (+ `keyset:grant` when naming a keyset) | +| `/list-clients` | List clients and their keyset grants (filterable by keyset) | `client:list` | +| `/grant-keyset` | Grant an existing client access to a keyset (by name or UUID) | `keyset:grant` | +| `/revoke-keyset` | Remove one client's access to one keyset | `keyset:revoke` | +| `/delete-client` | Delete a client and all its grants | `client:delete` | + +`/list-clients` is the check an agent can run to answer "does this client +have a grant for that keyset?" — it returns each client with the keyset ids +it can reach. + +Scope strings in existing tokens may use the legacy `dataset:` prefix +(`dataset:create`, `dataset:grant`, …) — it is the same permission family as +`keyset:`; ZeroKMS accepts both spellings. Scopes are assigned by CTS when +the service token is minted, based on the credential's role — see +`stash-auth`. + +## Keysets in the Stack + +```typescript +const client = await Encryption({ + schemas: [users], + config: { + keyset: { name: "tenant-a" }, // or { id: "" } + }, +}) +``` + +- Omit `config.keyset` → **the client's default keyset** (the keyset the + ZeroKMS client behind your `CS_CLIENT_*` credentials was created against — + the workspace `default` keyset if using the profile credentials in a dev + environment). +- **Encrypt and query always use the bound keyset.** A client is bound to + one keyset for its lifetime, and there is no per-operation keyset option + in `@cipherstash/stack` — the underlying Rust SDK accepts a per-call + keyset on decrypt, but the FFI does not expose it. Multi-tenant + applications create one `Encryption()` client per tenant. +- **Decrypt is per-payload automatically.** Every encrypted payload embeds + the id of the keyset it was encrypted under, and decryption routes key + retrieval to that keyset — so one client can decrypt rows from several + keysets, provided it holds a grant for each. No option needed; without + the grant the decrypt fails as usual. (This is why cross-tenant *reads* + can be centralized in one suitably-granted client while writes and + queries still require the per-tenant client.) +- Keysets are orthogonal to `authStrategy` and lock context: a keyset + isolates a whole keyspace (coarse, fixed per client); lock context binds + retrieval of an individual value's data key to a claim from the caller's + service token (fine-grained, per operation — see `stash-auth`). They + compose. +- `stash login` binds your device to the workspace's default keyset, which is + why CLI operations (`stash encrypt backfill`, dev-time tooling) work + without any keyset configuration. + +## Two gates, two very different failures + +Keyset access and lock context are **independent gates**, and their failures +look different. Do not diagnose one as the other. + +**Gate 1 — keyset access (client-level, wholesale).** Checked first, on +every request. No grant for the requested keyset means ZeroKMS cannot even +locate key material for the client: + +| Cause | ZeroKMS response | What the application sees | +|---|---|---| +| Client has no grant for the keyset (or keyset name/id doesn't exist in this workspace) | `404 — "Not Found: no record found with id=…"` | `Encryption()` init fails (the per-keyset index key cannot be loaded); if a payload names an unreachable keyset, encrypt/decrypt return `{ failure }` (`EncryptionError` / `DecryptionError`) | +| Keyset disabled | `403 — "Keyset disabled: …"` | Same surface — init or operation failure, for every client of that keyset | +| Token missing scopes | `403 — "Not permitted"` | Operation failure; fix the credential's scopes, not the grants | + +**Gate 2 — lock context / decryption policy (value-level, per identity).** +Only reached when gate 1 passes. A value encrypted under a lock context has +its data key bound to a claim from the encrypting caller's service token; a +caller whose token doesn't carry the same claim is refused **that value's +data key** (`403`, surfaced as a `{ failure }` on decrypt). Encrypting and +other values are unaffected, and every denial is recorded in the access +log. See `stash-auth` for the lock-context model and usage. + +The practical tell: gate-1 failures are *total* (the client can do nothing +under that keyset — encrypt, decrypt, and query all fail), gate-2 failures +are *selective* (specific values, specific callers). + +## Diagnostic runbook + +Encrypted operations failing with a ZeroKMS error? Check in this order — +each step's failure explains everything after it: + +1. **Credentials present and pointing at the right workspace?** The four + `CS_CLIENT_*` / `CS_WORKSPACE_CRN` variables (see `stash-edge` for the + list). A keyset name resolves *within a workspace* — the same name in + another workspace is a different keyset. +2. **Does the keyset exist there?** Dashboard, or `/list-keysets`. Typos in + `config.keyset.name` surface as the 404 above, not as a helpful "no such + keyset". +3. **Does this client have a grant?** `/list-clients` filtered by the + keyset, or the dashboard's keyset page. If no keyset is being specified, + check which keyset each client *defaults* to — a writer and a reader that + both omit `config.keyset` can still be on different keysets if their + clients were created against different ones. +4. **Is the keyset disabled?** The 403 message says so explicitly. +5. **Only decrypt of specific values failing, for specific callers?** + That's lock context (gate 2), not keyset access — check that the decrypt + call carries the same lock context the value was encrypted with. +6. **Decrypt fine but queries return zero rows?** First check the reader's + *bound* keyset against the writer's — a reader granted the writer's + keyset but bound to a different one decrypts fine while its query terms + derive under its own keyspace (the silent case from "The model in one + paragraph"). Bound keysets match? Then it's not a key problem: + `stash-indexing` (is the extractor index there and used?) and + `stash-postgres` (is the operand cast/predicate form right?). + +## Operational rules of thumb + +- **Environments should not share keysets.** Give production its own keyset + (or workspace); a dev credential then can't decrypt production rows even + if it leaks. `stash-deployment` covers where each environment's + credentials come from. +- **Backfills and one-off jobs**: the job's client must reach the same + keyset the deployed application uses. Same explicit `config.keyset` is the + safe form; if both sides omit it, each resolves to *its own client's + default keyset*, so verify the two clients were created against the same + keyset — same workspace is necessary but not sufficient. The credential + string itself may differ. +- **Suspected credential compromise**: revoke the client (or delete it). + Revocation is immediate — ZeroKMS stops issuing seeds, and the revoked + client key is useless against seeds issued to others. No re-encryption is + needed and no other client is affected. +- **Retiring a keyset**: disable first (reversible, proves nothing still + uses it), then deal with the data. There is no cross-keyset decrypt — data + moves between keysets only by decrypting under the old and re-encrypting + under the new.