Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 25 additions & 0 deletions .changeset/eql-v3-drizzle-fail-open-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@cipherstash/stack': minor
---

Close two fail-open paths in the EQL v3 Drizzle adapter.

`ops.contains()` now throws `EncryptionOperatorError` when the search term is
shorter than the match index tokenizer's `token_length` (3 by default). Such a
term produces no ngrams, so its bloom filter is empty — and `stored_bf @> '{}'`
is true for every row. Previously a user searching `"ad"` silently received the
entire table; measured live, the needles `"ad"`, `"a"` and `"x"` each returned
every seeded row, including one in which `"x"` did not appear. The length floor
is shared with the v2 adapter via `matchNeedleError` in `schema/match-defaults`,
since both build byte-identical bloom filters.

`v3FromDriver()` now throws the new `EqlV3CodecError` on a payload that is not
an EQL envelope, instead of surfacing a raw `SyntaxError` for malformed JSON and
passing a bare scalar through unchecked — `v3FromDriver('5')` previously returned
`5` typed as `Encrypted`, which then reached `decrypt` as garbage. The guard
accepts both scalar envelopes (ciphertext at `c`) and SteVec documents
(ciphertext at `sv[0].c`). `EqlV3CodecError` is exported from
`@cipherstash/stack/eql/v3/drizzle` so callers can catch it.

Also removes an unreachable branch in `inArray`/`notInArray`, whose empty-list
guard already throws before it.
95 changes: 86 additions & 9 deletions packages/stack/__tests__/drizzle-v3/codec.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,62 @@
import { describe, expect, it } from 'vitest'
import { v3FromDriver, v3ToDriver } from '@/eql/v3/drizzle/codec'
import {
EqlV3CodecError,
v3FromDriver,
v3ToDriver,
} from '@/eql/v3/drizzle/codec'

// A realistic `public.text_eq` envelope: schema version, table/column
// identifier, mp_base85 ciphertext, HMAC term. The trivial `{v:1,c:'ct'}`
// fixture this replaced could not distinguish an envelope from a bare object.
const ENVELOPE = {
v: 3,
i: { t: 'users', c: 'email' },
c: 'mBbKmbNmNhX@Vv0N%<Ke7fH(=Zc*d4rDy0h3~yPZLq!kGm8x7$2Fw<TnR>1jQvA',
hm: '3a1f9c0e5b7d2a48e6c1f0b93d7a2e58c4b6f19d0e3a7c25b8f14d69a0c3e7b2',
} as const

// SteVec documents carry the root ciphertext at `sv[0].c` and have no top-level
// `c` — the envelope guard must accept them.
const STE_VEC_ENVELOPE = {
v: 3,
k: 'sv',
i: { t: 'users', c: 'profile' },
sv: [{ c: 'mBbKciphertext', s: 'selector', b: 'term' }],
} as const

describe('v3 codec', () => {
it('serialises an object to a jsonb string', () => {
expect(v3ToDriver({ v: 1, c: 'ct' })).toBe('{"v":1,"c":"ct"}')
it('serialises an envelope to a jsonb string', () => {
expect(v3ToDriver(ENVELOPE as never)).toBe(JSON.stringify(ENVELOPE))
})

it('maps null/undefined to SQL NULL (JS null), never the JSON null literal', () => {
expect(v3ToDriver(null)).toBeNull()
expect(v3ToDriver(undefined)).toBeNull()
})

it('parses a jsonb string back to an object', () => {
expect(v3FromDriver('{"v":1,"c":"ct"}')).toEqual({ v: 1, c: 'ct' })
it('round-trips a realistic envelope through both directions', () => {
const serialised = v3ToDriver(ENVELOPE as never)
expect(v3FromDriver(serialised)).toEqual(ENVELOPE)
})

it('round-trips unicode and a long ciphertext', () => {
const envelope = {
...ENVELOPE,
i: { t: 'users', c: 'café_☕' },
c: 'z'.repeat(8192),
}
expect(v3FromDriver(v3ToDriver(envelope as never))).toEqual(envelope)
})

it('passes an already-parsed envelope through unchanged', () => {
expect(v3FromDriver(ENVELOPE as never)).toBe(ENVELOPE)
})

it('passes an already-parsed object through unchanged', () => {
const obj = { v: 1 }
expect(v3FromDriver(obj)).toBe(obj)
it('accepts a SteVec document, whose ciphertext lives at sv[0].c', () => {
expect(v3FromDriver(STE_VEC_ENVELOPE as never)).toBe(STE_VEC_ENVELOPE)
expect(v3FromDriver(JSON.stringify(STE_VEC_ENVELOPE))).toEqual(
STE_VEC_ENVELOPE,
)
})

it('normalises null/undefined to SQL NULL (JS null) on read', () => {
Expand All @@ -26,6 +65,44 @@ describe('v3 codec', () => {
})

it('does not throw on a stray bigint in the envelope (defensive)', () => {
expect(v3ToDriver({ v: 1n } as never)).toBe('{"v":"1"}')
expect(v3ToDriver({ ...ENVELOPE, v: 3n } as never)).toContain('"v":"3"')
})

// Before these, a malformed string surfaced a raw SyntaxError and a
// wrong-shape payload was returned as-is — `v3FromDriver('5')` yielded `5`
// typed as an envelope, which then reached `decrypt` as garbage.
describe('malformed and non-envelope payloads', () => {
it.each([
'{bad',
'',
'{"v":3,',
])('throws EqlV3CodecError on unparseable jsonb %j', (raw) => {
expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError)
expect(() => v3FromDriver(raw)).toThrow(/Failed to parse/)
})

it.each([
'5',
'"a string"',
'true',
'[]',
'[{"v":3,"c":"ct"}]',
])('throws on valid JSON %j that is not an envelope', (raw) => {
expect(() => v3FromDriver(raw)).toThrow(EqlV3CodecError)
})

it('throws on an object missing the schema version', () => {
expect(() => v3FromDriver({ c: 'ct' })).toThrow(/missing "v"/)
})

it('throws on an object carrying no ciphertext', () => {
expect(() => v3FromDriver({ v: 3, i: { t: 'u', c: 'e' } })).toThrow(
/missing a ciphertext/,
)
})

it('throws on an array', () => {
expect(() => v3FromDriver([ENVELOPE] as never)).toThrow(/got an array/)
})
})
})
41 changes: 41 additions & 0 deletions packages/stack/__tests__/drizzle-v3/column.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,45 @@ describe('makeEqlV3Column', () => {
expect(pgColumn?.getSQLType()).toBe(eqlType)
expect(getEqlV3Column(columnName, pgColumn)?.getEqlType()).toBe(eqlType)
})

// The `toDriver`/`fromDriver` closures wired into `customType` (column.ts) are
// otherwise only tested via the standalone codec functions, so the wiring
// itself — including the SQL-NULL safety net — was never exercised.
describe('codec wiring on the built column', () => {
type Mapper = {
mapToDriverValue(value: unknown): unknown
mapFromDriverValue(value: unknown): unknown
}
const mapperFor = (name: string): Mapper => {
const table = pgTable('users', {
[name]: makeEqlV3Column(v3Types.TextEq(name)),
} as never)
return (table as unknown as Record<string, Mapper>)[name]
}

const ENVELOPE = {
v: 3,
i: { t: 'users', c: 'nickname' },
c: 'mBbKciphertext',
hm: 'hmac',
}

it('serialises an envelope on write and parses it back on read', () => {
const column = mapperFor('nickname')
const driverValue = column.mapToDriverValue(ENVELOPE)

expect(driverValue).toBe(JSON.stringify(ENVELOPE))
expect(column.mapFromDriverValue(driverValue)).toEqual(ENVELOPE)
})

it('maps a null envelope to SQL NULL on write', () => {
expect(mapperFor('nickname').mapToDriverValue(null)).toBeNull()
})

it('rejects a non-envelope driver value rather than passing it through', () => {
expect(() => mapperFor('nickname').mapFromDriverValue('5')).toThrow(
/EQL encrypted envelope/,
)
})
})
})
29 changes: 29 additions & 0 deletions packages/stack/__tests__/drizzle-v3/exports.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import { describe, expect, it } from 'vitest'
import * as barrel from '@/eql/v3/drizzle'

// Exhaustive, so ADDING an export fails here too. The previous version asserted
// only four names, leaving the codec/column re-exports unpinned and letting a
// new export slip into the public surface unnoticed.
const PUBLIC_SURFACE = [
'EncryptionOperatorError',
'EqlV3CodecError',
'createEncryptionOperatorsV3',
'extractEncryptionSchemaV3',
'getEqlV3Column',
'isEqlV3Column',
'makeEqlV3Column',
'types',
'v3FromDriver',
'v3ToDriver',
] as const

describe('v3 drizzle barrel', () => {
it('exports exactly the public surface', () => {
expect(Object.keys(barrel).sort()).toEqual([...PUBLIC_SURFACE])
})

it('exports the public surface', () => {
expect(typeof barrel.createEncryptionOperatorsV3).toBe('function')
expect(typeof barrel.extractEncryptionSchemaV3).toBe('function')
expect(typeof barrel.EncryptionOperatorError).toBe('function')
expect(typeof barrel.EqlV3CodecError).toBe('function')
expect(typeof barrel.types.TextSearch).toBe('function')
expect(barrel.types.IntegerOrd('age')).toBeDefined()
})

it('re-exports the codec and column helpers as callables', () => {
expect(typeof barrel.v3ToDriver).toBe('function')
expect(typeof barrel.v3FromDriver).toBe('function')
expect(typeof barrel.getEqlV3Column).toBe('function')
expect(typeof barrel.isEqlV3Column).toBe('function')
expect(typeof barrel.makeEqlV3Column).toBe('function')
})
})
Loading
Loading