-
Notifications
You must be signed in to change notification settings - Fork 5
feat(stack): expose encryptQuery on the WASM entry — searchable encryption on the edge #676
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
b085f66
feat(stack): expose encryptQuery on the WASM entry — searchable encry…
coderdan bd75f16
fix(stack): omit undefined queryOp — WASM serde rejects unit values
coderdan 57bee46
fix(stack): WASM bulk query field is `queries`, not `terms`
coderdan 3041e64
docs(stack): full TSDoc + per-query-type Deno e2e for the WASM query …
coderdan 3a67715
fix(stack): use the shared resolveIndexType in the WASM query surface
coderdan cf06c2d
fix(stack): selector queries return the bare selector hash, not an en…
coderdan 3755700
fix(stack): containment needles are {sv:[...]} with no version envelope
coderdan 12165ac
test(stack): adapter-grade WASM e2e via test-kit; fail loud on missin…
coderdan dac01cc
fix(stack): bind WASM payloads as raw objects; fix Deno env binding
coderdan 1a26d8c
fix(stack): break ordering ties by row_key in the WASM adapter
coderdan a2cb138
fix(stack): code-review round on #676 — validation parity and test ha…
coderdan 804fe3b
test(stack): pin that containment needles carry no version envelope
coderdan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| --- | ||
| '@cipherstash/stack': minor | ||
| --- | ||
|
|
||
| `@cipherstash/stack/wasm-inline` now exposes `encryptQuery` and | ||
| `encryptQueryBulk` on `WasmEncryptionClient` (#662) — searchable encryption | ||
| is reachable on Deno/edge runtimes. Previously the WASM entry exposed only | ||
| `encrypt`/`decrypt`/`isEncrypted`, so encrypted WHERE-clause search was | ||
| architecturally impossible on the edge even though the underlying protect-ffi | ||
| WASM build carries the capability. | ||
|
|
||
| The new methods mint ciphertext-free EQL v3 query terms — equality, | ||
| free-text match, ORE range, and JSON containment/selector — with the same | ||
| index-type resolution as the native client (explicit `queryType`, or | ||
| inference from the column's configured indexes). Cast the term to the | ||
| column's `eql_v3.query_<domain>` type in SQL to reach the indexed operators. | ||
| Errors throw, consistent with the WASM surface's `encrypt`/`decrypt`; the | ||
| bulk form is position-stable (`null` values pass through as `null`). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,235 @@ | ||
| /** | ||
| * Query-type matrix for `@cipherstash/stack/wasm-inline`'s `encryptQuery` / | ||
| * `encryptQueryBulk` (#662) — the WASM twin of the per-query-type coverage | ||
| * the Drizzle/Supabase adapters have. | ||
| * | ||
| * Runs under Deno against real CipherStash credentials, one live term per | ||
| * query type the surface supports: | ||
| * | ||
| * | queryType | column domain | FFI index | | ||
| * |------------------|----------------------|------------| | ||
| * | equality | eql_v3_text_eq | unique | | ||
| * | freeTextSearch | eql_v3_text_search | match | | ||
| * | orderAndRange | eql_v3_integer_ord | ore | | ||
| * | searchableJson | eql_v3_json | ste_vec | | ||
| * | (string value) | → ste_vec_selector | | | ||
| * | (object value) | → ste_vec_term | | | ||
| * | ||
| * Wire shapes differ by kind — which is exactly what this suite pins: | ||
| * SCALAR terms are v3 envelopes (`{v: 3, i, …}`); a JSON CONTAINMENT | ||
| * needle is a strict `{sv: [query-entry]}` with no version field; a | ||
| * SELECTOR query returns the BARE selector-hash string (v3 has no | ||
| * encrypted-selector envelope — bind as the text argument of `->`/`->>`). | ||
| * All are CIPHERTEXT-FREE. The boundary bugs this suite's first runs | ||
| * caught (undefined fields rejected by serde; the bulk field is `queries`; | ||
| * the ore→ope swap; both JSON shapes) are exactly why each type needs a | ||
| * live crossing, not a mock. | ||
| * | ||
| * FAILS LOUDLY when any CS_* env var is missing, matching `roundtrip.test.ts`. | ||
| */ | ||
|
|
||
| import { assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0' | ||
| import { | ||
| Encryption, | ||
| encryptedTable, | ||
| types, | ||
| } from '@cipherstash/stack/wasm-inline' | ||
|
|
||
| const REQUIRED_ENV = [ | ||
| 'CS_WORKSPACE_CRN', | ||
| 'CS_CLIENT_ACCESS_KEY', | ||
| 'CS_CLIENT_ID', | ||
| 'CS_CLIENT_KEY', | ||
| ] as const | ||
|
|
||
| function requireEnv(): Record<(typeof REQUIRED_ENV)[number], string> { | ||
| const values = {} as Record<(typeof REQUIRED_ENV)[number], string> | ||
| const missing: string[] = [] | ||
| for (const key of REQUIRED_ENV) { | ||
| const value = Deno.env.get(key) | ||
| if (value) values[key] = value | ||
| else missing.push(key) | ||
| } | ||
| if (missing.length > 0) { | ||
| // FAIL, don't skip: a silently-skipped credential suite reads as green | ||
| // coverage that never ran (same doctrine as the repo's integration | ||
| // harness — no skipIf credential gates). | ||
| throw new Error( | ||
| `Missing required env: ${missing.join(', ')}. This suite needs real ` + | ||
| 'CipherStash credentials — export the four CS_* variables (or put them ' + | ||
| 'in a repo-root .env; see AGENTS.md "Environment variables") or run ' + | ||
| 'via the CI job, which injects them.', | ||
| ) | ||
| } | ||
| return values | ||
| } | ||
|
|
||
| const catalog = encryptedTable('wasm_query_matrix', { | ||
| email: types.TextEq('email'), // equality only | ||
| bio: types.TextSearch('bio'), // free-text (also carries eq + ore) | ||
| age: types.IntegerOrd('age'), // eq + order/range | ||
| prefs: types.Json('prefs'), // searchable JSON (ste_vec) | ||
| }) | ||
|
|
||
| /** A v3 SCALAR query term: versioned envelope (`v: 3`), NO ciphertext. */ | ||
| function assertV3Term(term: unknown, label: string) { | ||
| assertExists(term, `${label}: encryptQuery returned null`) | ||
| const obj = term as Record<string, unknown> | ||
| assertEquals(obj.v, 3, `${label}: term is not EQL v3`) | ||
| assertEquals('c' in obj, false, `${label}: term carries ciphertext`) | ||
| } | ||
|
|
||
| /** A v3 JSON CONTAINMENT needle: strict `{sv: [query-entry]}` — no `v` | ||
| * envelope, no ciphertext (per `is_valid_ste_vec_query_payload`). */ | ||
| function assertContainmentNeedle(term: unknown, label: string) { | ||
| assertExists(term, `${label}: encryptQuery returned null`) | ||
| const obj = term as Record<string, unknown> | ||
| assertEquals( | ||
| Array.isArray(obj.sv), | ||
| true, | ||
| `${label}: expected an {sv: [...]} containment needle`, | ||
| ) | ||
| assertEquals('c' in obj, false, `${label}: needle carries ciphertext`) | ||
| assertEquals('v' in obj, false, `${label}: needle carries a version envelope`) | ||
| } | ||
|
|
||
| Deno.test({ | ||
| name: 'stack/wasm-inline: encryptQuery covers every v3 query type', | ||
| permissions: { | ||
| env: true, | ||
| net: true, | ||
| read: true, | ||
| sys: true, | ||
| // No FFI permission, same pin as roundtrip.test.ts: if protect-ffi ever | ||
| // silently fell back to a native binding under Deno, the call would | ||
| // reject — so a green run proves the WASM path minted every term. | ||
| ffi: false, | ||
| }, | ||
| async fn() { | ||
| const env = requireEnv() | ||
| const client = await Encryption({ | ||
| schemas: [catalog], | ||
| config: { | ||
| workspaceCrn: env.CS_WORKSPACE_CRN, | ||
| accessKey: env.CS_CLIENT_ACCESS_KEY, | ||
| clientId: env.CS_CLIENT_ID, | ||
| clientKey: env.CS_CLIENT_KEY, | ||
| }, | ||
| }) | ||
|
|
||
| // equality → unique index | ||
| assertV3Term( | ||
| await client.encryptQuery('alice@example.com', { | ||
| table: catalog, | ||
| column: catalog.email, | ||
| queryType: 'equality', | ||
| }), | ||
| 'equality', | ||
| ) | ||
|
|
||
| // freeTextSearch → match index | ||
| assertV3Term( | ||
| await client.encryptQuery('needle phrase', { | ||
| table: catalog, | ||
| column: catalog.bio, | ||
| queryType: 'freeTextSearch', | ||
| }), | ||
| 'freeTextSearch', | ||
| ) | ||
|
|
||
| // orderAndRange → ore index (numeric) | ||
| assertV3Term( | ||
| await client.encryptQuery(42, { | ||
| table: catalog, | ||
| column: catalog.age, | ||
| queryType: 'orderAndRange', | ||
| }), | ||
| 'orderAndRange', | ||
| ) | ||
|
|
||
| // searchableJson, string value → ste_vec_selector (JSONPath). By | ||
| // contract the selector "term" is a BARE selector-hash string — no | ||
| // envelope — bound as the text argument of `->` / `->>`. | ||
| const selector = await client.encryptQuery('$.theme', { | ||
| table: catalog, | ||
| column: catalog.prefs, | ||
| queryType: 'searchableJson', | ||
| }) | ||
| assertExists(selector, 'selector: encryptQuery returned null') | ||
| assertEquals( | ||
| typeof selector, | ||
| 'string', | ||
| 'selector: expected the bare selector-hash string', | ||
| ) | ||
| assertEquals( | ||
| (selector as unknown as string).length > 0, | ||
| true, | ||
| 'selector: empty selector hash', | ||
| ) | ||
|
|
||
| // searchableJson, object value → ste_vec_term (containment needle: | ||
| // strict {sv: [...]}, no version envelope — per the eql_v3.query_jsonb | ||
| // wire contract) | ||
| assertContainmentNeedle( | ||
| await client.encryptQuery( | ||
| { theme: 'dark' }, | ||
| { | ||
| table: catalog, | ||
| column: catalog.prefs, | ||
| queryType: 'searchableJson', | ||
| }, | ||
| ), | ||
| 'searchableJson/containment', | ||
| ) | ||
|
|
||
| // Omitted queryType → inference from the column's indexes (TextEq has | ||
| // exactly one: unique), mirroring the native client. | ||
| assertV3Term( | ||
| await client.encryptQuery('bob@example.com', { | ||
| table: catalog, | ||
| column: catalog.email, | ||
| }), | ||
| 'inference', | ||
| ) | ||
|
|
||
| // Bulk: one round trip across mixed query types, position-stable with | ||
| // nulls passing through. | ||
| const bulk = await client.encryptQueryBulk([ | ||
| { | ||
| value: 'alice@example.com', | ||
| table: catalog, | ||
| column: catalog.email, | ||
| queryType: 'equality', | ||
| }, | ||
| { | ||
| value: null, | ||
| table: catalog, | ||
| column: catalog.email, | ||
| }, | ||
| { | ||
| value: 'needle', | ||
| table: catalog, | ||
| column: catalog.bio, | ||
| queryType: 'freeTextSearch', | ||
| }, | ||
| { | ||
| value: 7, | ||
| table: catalog, | ||
| column: catalog.age, | ||
| queryType: 'orderAndRange', | ||
| }, | ||
| { | ||
| value: { theme: 'dark' }, | ||
| table: catalog, | ||
| column: catalog.prefs, | ||
| queryType: 'searchableJson', | ||
| }, | ||
| ]) | ||
| assertEquals(bulk.length, 5) | ||
| assertV3Term(bulk[0], 'bulk/equality') | ||
| assertEquals(bulk[1], null, 'bulk: null value must yield null') | ||
| assertV3Term(bulk[2], 'bulk/freeTextSearch') | ||
| assertV3Term(bulk[3], 'bulk/orderAndRange') | ||
| assertContainmentNeedle(bulk[4], 'bulk/searchableJson') | ||
| }, | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.