From 92631f9e772eac16c577d685abd47f202010ab40 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 23 Jul 2026 23:02:43 +1000 Subject: [PATCH 1/2] docs(stack-drizzle): conversion-focused README rewrite The package README documented mechanics without ever making the case. Rewritten along the lines of the root-README refresh in #526: a bold searchable-encryption lead, the zero-knowledge trust line up front, a ciphertext-queries example before any setup, a query-type table mapping the ops.* surface to the docs, an account-first quick start, a How-it-works section on EQL payloads and searchable terms, and reference-style links with UTM attribution (stack_drizzle_readme). The quickstart, indexing, and v2-legacy sections are retained. All API claims match the shipped surface (ops.matches/contains, not the doc-driven ilike forms from #526's root draft). No code changes. Claude-Session: https://claude.ai/code/session_01BkEpKJC3975NHsKgMrCT8R --- .changeset/stack-drizzle-readme.md | 9 ++ packages/stack-drizzle/README.md | 152 +++++++++++++++++++++-------- 2 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 .changeset/stack-drizzle-readme.md diff --git a/.changeset/stack-drizzle-readme.md b/.changeset/stack-drizzle-readme.md new file mode 100644 index 00000000..a234e7e0 --- /dev/null +++ b/.changeset/stack-drizzle-readme.md @@ -0,0 +1,9 @@ +--- +'@cipherstash/stack-drizzle': patch +--- + +README overhaul: lead with what the package does and why — application-side +encryption with per-value keys, queryable ciphertext (equality, range, +ORDER BY, fuzzy text, encrypted JSON), drizzle-kit-native types and index +derivation — plus a How-it-works section on EQL payloads and searchable +terms. No code changes. diff --git a/packages/stack-drizzle/README.md b/packages/stack-drizzle/README.md index 83e184a3..9f7f15b7 100644 --- a/packages/stack-drizzle/README.md +++ b/packages/stack-drizzle/README.md @@ -1,40 +1,93 @@ # @cipherstash/stack-drizzle -Drizzle ORM integration for [CipherStash Stack](https://www.npmjs.com/package/@cipherstash/stack) — -searchable, application-layer field-level encryption for PostgreSQL. +**Searchable, application-level encryption for [Drizzle ORM][drizzle-orm] and PostgreSQL.** +Encrypt fields in your app and keep them fully queryable in Postgres — equality, range, +`ORDER BY`, free-text match, encrypted JSON — with zero-knowledge key management built in, +from [CipherStash Stack][stack-repo]. -Depends on `@cipherstash/stack`; install both: +> **CipherStash never sees your plaintext — or your keys.** Every value is encrypted in your +> application with a unique key via [ZeroKMS][zerokms], so a database breach — dump, backup, +> replica, logs — leaks only ciphertext. [See the security architecture →][security-architecture] + +## Encrypted columns. Real Drizzle queries. + +The `email` and `age` columns below are stored as ciphertext with a unique key per row — and the +queries still work, because they run on the ciphertext. No decrypt-and-scan, no query rewriting +layer, no proxy in the query path. + +```ts +export const users = pgTable('users', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + email: types.TextSearch('email'), // → eql_v3_text_search — the type is the config + age: types.IntegerOrd('age'), // → eql_v3_integer_ord +}) + +const rows = await db + .select() + .from(users) + .where(await ops.and( + ops.contains(users.email, 'alice'), // free-text match, on ciphertext + ops.between(users.age, 18, 65), // range, on ciphertext + )) + .orderBy(ops.asc(users.age)) // ordered by encrypted value +``` + +The operators mirror Drizzle's and encrypt their operands transparently: + +| Query type | Operators | Docs | +|---|---|---| +| **Equality** | `ops.eq`, `ops.ne`, `ops.inArray` | [Equality queries →][query-equality] | +| **Range & ordering** | `ops.gt`/`gte`/`lt`/`lte`, `ops.between`, `ops.asc`/`desc` | [Range & ordering →][query-range] | +| **Free-text match** | `ops.matches`, `ops.contains` | [Text search →][query-match] | +| **Encrypted JSON** | `ops.contains` (containment), `ops.selector(col, path)` | [JSON →][query-json] | + +Each column's query capabilities are fixed by its type, so an unsupported operation is rejected +loudly instead of silently scanning. + +## Quick start + +About five minutes, starting on the **free developer tier** ([sign up][signup]). The setup wizard +handles authentication, the EQL install, and your schema: + +```bash +npx stash init +``` + +Or install manually (this package depends on `@cipherstash/stack`; install both), then run +`stash eql install` once — or generate a migration with `stash eql migration --drizzle`: ```bash npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm ``` -## EQL v3 (`/v3` subpath) +Full guide: [Drizzle quickstart →][drizzle-docs] + +## Full example (EQL v3, the `/v3` subpath) -Each encrypted column is a concrete `public.eql_v3_*` Postgres domain whose query -capabilities are fixed by the `types.*` factory you choose — no per-column config -object. Install the domains once with `stash eql install --eql-version 3`. +Each encrypted column is a concrete `public.eql_v3_*` Postgres domain whose query capabilities +are fixed by the `types.*` factory you choose — no per-column config object: ```ts import { pgTable, integer } from 'drizzle-orm/pg-core' import { EncryptionV3 } from '@cipherstash/stack/v3' import { - types as encryptedTypes, + types, extractEncryptionSchemaV3, createEncryptionOperatorsV3, } from '@cipherstash/stack-drizzle/v3' const users = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - email: encryptedTypes.TextSearch('email'), // equality + order/range + free-text - age: encryptedTypes.IntegerOrd('age'), // equality + order/range + email: types.TextSearch('email'), // equality + order/range + free-text + age: types.IntegerOrd('age'), // equality + order/range }) const schema = extractEncryptionSchemaV3(users) const client = await EncryptionV3({ schemas: [schema] }) const ops = createEncryptionOperatorsV3(client) -// Insert — encrypt models first +// Insert — encrypt models first (bulk helpers batch key operations +// through ZeroKMS instead of one round trip per row) const enc = await client.bulkEncryptModels( [{ email: 'alice@example.com', age: 30 }], schema, @@ -45,28 +98,24 @@ if (!enc.failure) await db.insert(users).values(enc.data) const rows = await db .select() .from(users) - .where(await ops.and( - ops.contains(users.email, 'alice'), // free-text containment over ciphertext - ops.between(users.age, 18, 65), - )) - .orderBy(ops.asc(users.age)) + .where(await ops.eq(users.email, 'alice@example.com')) // Decrypt after select const dec = await client.bulkDecryptModels(rows, schema) ``` -For a `types.Json` column, `ops.selector(column, path)` supports encrypted -comparisons and ordering at a scalar JSONPath leaf. For example, +For a `types.Json` column, `ops.selector(column, path)` supports encrypted comparisons and +ordering at a scalar JSONPath leaf. For example, `.orderBy(await ops.selector(users.profile, '$.age').asc())` lowers to `ORDER BY eql_v3.ord_term(...)` over the selected encrypted entry. ### Indexing encrypted columns -Encrypted predicates only use an index if one exists over the matching -`eql_v3.*` term-extractor expression — otherwise every encrypted query -sequential-scans. `encryptedIndexes` derives the recommended indexes for -every encrypted column in a table; spread it into `pgTable`'s third-argument -callback and `drizzle-kit generate` picks the indexes up like any others: +Encrypted predicates only use an index if one exists over the matching `eql_v3.*` +term-extractor expression — otherwise every encrypted query sequential-scans. +`encryptedIndexes` derives the recommended indexes for every encrypted column in a table; +spread it into `pgTable`'s third-argument callback and `drizzle-kit generate` picks the +indexes up like any others: ```ts import { integer, pgTable } from 'drizzle-orm/pg-core' @@ -84,30 +133,57 @@ export const users = pgTable( ``` Each column gets indexes matching its domain's capabilities, named -`__` (equality btree, ordering btree, free-text -GIN, JSON containment GIN); storage-only and non-encrypted columns get none. -After the migration applies, run `ANALYZE
` — expression indexes have -no statistics until then. For custom names, subsets, or field-level selector -indexes on encrypted JSON, declare individual expression indexes instead; -the bundled `stash-indexing` agent skill has the full recipes. +`
__` (equality btree, ordering btree, free-text GIN, JSON +containment GIN); storage-only and non-encrypted columns get none. After the migration +applies, run `ANALYZE
` — expression indexes have no statistics until then. For custom +names, subsets, or field-level selector indexes on encrypted JSON, declare individual +expression indexes instead; the bundled `stash-indexing` agent skill has the full recipes. + +## How it works + +Every value is encrypted into an [EQL][eql] payload: the ciphertext plus the *searchable +terms* its column type declares — an HMAC term for equality, an order-preserving term for +range and sorting, a bloom filter for text match, a structured-encryption vector for JSON. +The EQL SQL bundle defines the Postgres domains, operators, and term-extractor functions, so +`WHERE email = $1` resolves to a comparison of equality terms and engages a functional index +over the extractor. Keys come from [ZeroKMS][zerokms] — one per value — so bulk operations, +key revocation, and [identity-bound decryption][identity] (lock contexts) work without the +database ever holding a secret. Runs on plain PostgreSQL, Supabase, and RDS/Aurora; the SQL +install needs no superuser. ## EQL v2 (package root) — legacy -The v2 integration predates the typed v3 domains and is kept for existing -projects. New projects should use v3 above. +The v2 integration predates the typed v3 domains and is kept for existing projects. New +projects should use v3 above. ```ts import { encryptedType, extractEncryptionSchema, createEncryptionOperators } from '@cipherstash/stack-drizzle' import { Encryption } from '@cipherstash/stack' ``` -`encryptedType` defines an `eql_v2_encrypted` column; `createEncryptionOperators` -returns query operators (`eq`, `like`, `gt`, `inArray`, …) that transparently -encrypt search values. +`encryptedType` defines an `eql_v2_encrypted` column; `createEncryptionOperators` returns +query operators (`eq`, `like`, `gt`, `inArray`, …) that transparently encrypt search values. ## Docs -Full guide: https://cipherstash.com/docs/integrations/drizzle — see also the -bundled `stash-drizzle` agent skill. - -> Not to be confused with `@cipherstash/drizzle`, the older `@cipherstash/protect`-based package — deprecated and no longer maintained; this package replaces it. +- [Drizzle integration guide →][drizzle-docs] +- [Searchable encryption concepts →][searchable-encryption] +- [Security architecture →][security-architecture] +- The bundled `stash-drizzle` and `stash-indexing` agent skills, installed into your repo by `stash init` + +> Not to be confused with `@cipherstash/drizzle`, the older `@cipherstash/protect`-based +> package — deprecated and no longer maintained; this package replaces it. + +[drizzle-orm]: https://orm.drizzle.team +[stack-repo]: https://github.com/cipherstash/stack +[eql]: https://github.com/cipherstash/encrypt-query-language +[signup]: https://cipherstash.com/signup?utm_source=github&utm_medium=stack_drizzle_readme +[zerokms]: https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_drizzle_readme +[security-architecture]: https://cipherstash.com/docs/stack/reference/security-architecture?utm_source=github&utm_medium=stack_drizzle_readme +[drizzle-docs]: https://cipherstash.com/docs/stack/cipherstash/encryption/drizzle?utm_source=github&utm_medium=stack_drizzle_readme +[searchable-encryption]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_drizzle_readme +[identity]: https://cipherstash.com/docs/stack/cipherstash/encryption/identity?utm_source=github&utm_medium=stack_drizzle_readme +[query-equality]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_drizzle_readme#equality +[query-range]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_drizzle_readme#range-and-ordering +[query-match]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_drizzle_readme#free-text-search +[query-json]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_drizzle_readme#json From b668412444ce838ea470acba8bd53ef3b4db8811 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 23 Jul 2026 23:10:26 +1000 Subject: [PATCH 2/2] =?UTF-8?q?docs(stack-drizzle):=20open=20with=20the=20?= =?UTF-8?q?Why=20=E2=80=94=20threat=20model,=20searchable=20indexes,=20bou?= =?UTF-8?q?nded=20trade-off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the trust blockquote with the team's threat-model-first framing: who normally sees everything, per-value identity-derived keys with audited decryption, how queries still work (deterministic encryption / ORE / bloom filters over native Postgres indexes), and the explicit leakage boundary — equality and order relationships, nothing else; not FHE. Claude-Session: https://claude.ai/code/session_01BkEpKJC3975NHsKgMrCT8R --- packages/stack-drizzle/README.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/stack-drizzle/README.md b/packages/stack-drizzle/README.md index 9f7f15b7..841726a3 100644 --- a/packages/stack-drizzle/README.md +++ b/packages/stack-drizzle/README.md @@ -1,13 +1,22 @@ # @cipherstash/stack-drizzle -**Searchable, application-level encryption for [Drizzle ORM][drizzle-orm] and PostgreSQL.** -Encrypt fields in your app and keep them fully queryable in Postgres — equality, range, -`ORDER BY`, free-text match, encrypted JSON — with zero-knowledge key management built in, +**Searchable, application-level encryption for [Drizzle ORM][drizzle-orm] and PostgreSQL**, from [CipherStash Stack][stack-repo]. -> **CipherStash never sees your plaintext — or your keys.** Every value is encrypted in your -> application with a unique key via [ZeroKMS][zerokms], so a database breach — dump, backup, -> replica, logs — leaks only ciphertext. [See the security architecture →][security-architecture] +## Why + +Anyone with database access — a DBA, a leaked service account, a SQL injection — normally sees +everything. CipherStash encrypts each value with its own key, derived at query time from the +caller's identity. So a dump, an injection, or a compromised box yields ciphertext; you can +only decrypt what you're explicitly authorized to, and every decryption is audited. + +The trick is queries still work: we build searchable encrypted indexes using deterministic +encryption, ORE, and bloom filters, so equality, range, and fuzzy-text queries run against +native Postgres indexes in milliseconds without decrypting the table. + +The trade-off is explicit and bounded: the indexes leak equality and order relationships, +nothing else — it's not FHE, and we don't pretend it is. +[Security architecture →][security-architecture] ## Encrypted columns. Real Drizzle queries.