Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/wasm-encrypt-query.md
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`).
7 changes: 6 additions & 1 deletion .github/workflows/integration-drizzle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ on:
- 'packages/stack/src/schema/**'
- 'packages/schema/**'
- 'packages/stack/integration/**'
# The WASM family suite (integration/wasm/**) exercises this entry:
- 'packages/stack/src/wasm-inline.ts'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.postgres.yml'
Expand All @@ -46,6 +48,8 @@ on:
- 'packages/stack/src/schema/**'
- 'packages/schema/**'
- 'packages/stack/integration/**'
# The WASM family suite (integration/wasm/**) exercises this entry:
- 'packages/stack/src/wasm-inline.ts'
- 'packages/test-kit/**'
- 'packages/cli/src/installer/**'
- 'local/docker-compose.postgres.yml'
Expand Down Expand Up @@ -129,7 +133,8 @@ jobs:
# `identity/matrix-identity` suite.
CS_IT_SUITE: >-
integration/shared/**/*.integration.test.ts,
integration/identity/**/*.integration.test.ts
integration/identity/**/*.integration.test.ts,
integration/wasm/**/*.integration.test.ts

steps:
- uses: actions/checkout@v6
Expand Down
18 changes: 18 additions & 0 deletions e2e/wasm/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

223 changes: 223 additions & 0 deletions e2e/wasm/query-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* 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 — set the CS_* variables (see .env.example) ' +
'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`)
}
Comment thread
coderdan marked this conversation as resolved.

Deno.test({
name: 'stack/wasm-inline: encryptQuery covers every v3 query type',
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 as unknown as string,
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')
},
})
65 changes: 49 additions & 16 deletions e2e/wasm/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
* 4. No FFI permission was granted to the Deno process, so the WASM path is
* the *only* path that could have succeeded.
*
* Skipped when any of the four CS_* env vars is missing — matches the skip
* pattern in `e2e/tests/*.e2e.test.ts`.
* FAILS LOUDLY when any CS_* env var is missing — a silently-skipped
* credential suite reads as green coverage that never ran.
*/

import { assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0'
Expand All @@ -37,21 +37,29 @@ const REQUIRED_ENV = [
'CS_CLIENT_KEY',
] as const

function envOrSkip(): Record<(typeof REQUIRED_ENV)[number], string> | null {
const out: Record<string, string> = {}
for (const name of REQUIRED_ENV) {
const v = Deno.env.get(name)
if (!v) return null
out[name] = v
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)
}
return out as Record<(typeof REQUIRED_ENV)[number], string>
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 — set the CS_* variables (see .env.example) ' +
'or run via the CI job, which injects them.',
)
}
return values
}

const env = envOrSkip()

Deno.test({
name: 'stack/wasm-inline: EQL v3 encrypt → decrypt round-trip via WASM',
ignore: env === null,
permissions: {
env: true,
net: true,
Expand Down Expand Up @@ -82,10 +90,10 @@ Deno.test({
config: {
// CRN is the single source of truth — the region the
// AccessKeyStrategy needs is derived from it.
workspaceCrn: env!.CS_WORKSPACE_CRN,
accessKey: env!.CS_CLIENT_ACCESS_KEY,
clientId: env!.CS_CLIENT_ID,
clientKey: env!.CS_CLIENT_KEY,
workspaceCrn: env.CS_WORKSPACE_CRN,
accessKey: env.CS_CLIENT_ACCESS_KEY,
clientId: env.CS_CLIENT_ID,
clientKey: env.CS_CLIENT_KEY,
},
})

Expand All @@ -104,5 +112,30 @@ Deno.test({

const decrypted = await client.decrypt(encrypted)
assertEquals(decrypted, plaintext, 'round-trip plaintext mismatch')

// 5. (#662) Searchable encryption is reachable on the edge: mint a v3
// QUERY TERM for the column's free-text index. Terms are
// ciphertext-free needles — assert the wire shape, not decryption.
const term = (await client.encryptQuery(plaintext, {
column: users.email,
table: users,
queryType: 'freeTextSearch',
})) as Record<string, unknown> | null
assertExists(term, 'encryptQuery() returned null for live plaintext')
assertEquals(term.v, 3, 'query term is not EQL v3')
assertEquals(
'c' in term,
false,
'query term unexpectedly carries ciphertext',
)

// Bulk form is position-stable, nulls pass through.
const bulk = await client.encryptQueryBulk([
{ value: plaintext, column: users.email, table: users },
{ value: null as unknown as string, column: users.email, table: users },
])
assertEquals(bulk.length, 2)
assertExists(bulk[0])
assertEquals(bulk[1], null)
},
})
Loading
Loading