diff --git a/.changeset/encrypt-query-api.md b/.changeset/encrypt-query-api.md new file mode 100644 index 00000000..a055ec04 --- /dev/null +++ b/.changeset/encrypt-query-api.md @@ -0,0 +1,11 @@ +--- +"@cipherstash/protect": minor +"@cipherstash/drizzle": patch +--- + +Add `encryptQuery` API for encrypting query terms with explicit query type selection. + +- New `encryptQuery()` method replaces `createSearchTerms()` with improved query type handling +- Supports `equality`, `freeTextSearch`, and `orderAndRange` query types +- Deprecates `createSearchTerms()` - use `encryptQuery()` instead +- Updates drizzle operators to use correct index selection via `queryType` parameter diff --git a/examples/dynamo/src/encrypted-key-in-gsi.ts b/examples/dynamo/src/encrypted-key-in-gsi.ts index e35cfc38..6fa9e356 100644 --- a/examples/dynamo/src/encrypted-key-in-gsi.ts +++ b/examples/dynamo/src/encrypted-key-in-gsi.ts @@ -66,21 +66,23 @@ const main = async () => { await dynamoClient.send(putCommand) - const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'abc@example.com', - column: users.email, - table: users, - }, + // Use encryptQuery to create the search term for GSI query + const encryptedResult = await protectClient.encryptQuery([ + { value: 'abc@example.com', column: users.email, table: users, queryType: 'equality' }, ]) - if (searchTermsResult.failure) { + if (encryptedResult.failure) { throw new Error( - `Failed to create search terms: ${searchTermsResult.failure.message}`, + `Failed to encrypt query: ${encryptedResult.failure.message}`, ) } - const [emailHmac] = searchTermsResult.data + // Extract the HMAC for DynamoDB key lookup + const encryptedEmail = encryptedResult.data[0] + if (!encryptedEmail) { + throw new Error('Failed to encrypt query: no result returned') + } + const emailHmac = encryptedEmail.hm const queryCommand = new QueryCommand({ TableName: tableName, diff --git a/examples/dynamo/src/encrypted-partition-key.ts b/examples/dynamo/src/encrypted-partition-key.ts index 0deb8bb5..a3fd6eb0 100644 --- a/examples/dynamo/src/encrypted-partition-key.ts +++ b/examples/dynamo/src/encrypted-partition-key.ts @@ -49,21 +49,23 @@ const main = async () => { await docClient.send(putCommand) - const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'abc@example.com', - column: users.email, - table: users, - }, + // Use encryptQuery to create the search term for partition key lookup + const encryptedResult = await protectClient.encryptQuery([ + { value: 'abc@example.com', column: users.email, table: users, queryType: 'equality' }, ]) - if (searchTermsResult.failure) { + if (encryptedResult.failure) { throw new Error( - `Failed to create search terms: ${searchTermsResult.failure.message}`, + `Failed to encrypt query: ${encryptedResult.failure.message}`, ) } - const [emailHmac] = searchTermsResult.data + // Extract the HMAC for DynamoDB key lookup + const encryptedEmail = encryptedResult.data[0] + if (!encryptedEmail) { + throw new Error('Failed to encrypt query: no result returned') + } + const emailHmac = encryptedEmail.hm const getCommand = new GetCommand({ TableName: tableName, diff --git a/examples/dynamo/src/encrypted-sort-key.ts b/examples/dynamo/src/encrypted-sort-key.ts index 44a1cde6..e8e08175 100644 --- a/examples/dynamo/src/encrypted-sort-key.ts +++ b/examples/dynamo/src/encrypted-sort-key.ts @@ -58,21 +58,23 @@ const main = async () => { await docClient.send(putCommand) - const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'abc@example.com', - column: users.email, - table: users, - }, + // Use encryptQuery to create the search term for sort key range query + const encryptedResult = await protectClient.encryptQuery([ + { value: 'abc@example.com', column: users.email, table: users, queryType: 'equality' }, ]) - if (searchTermsResult.failure) { + if (encryptedResult.failure) { throw new Error( - `Failed to create search terms: ${searchTermsResult.failure.message}`, + `Failed to encrypt query: ${encryptedResult.failure.message}`, ) } - const [emailHmac] = searchTermsResult.data + // Extract the HMAC for DynamoDB key lookup + const encryptedEmail = encryptedResult.data[0] + if (!encryptedEmail) { + throw new Error('Failed to encrypt query: no result returned') + } + const emailHmac = encryptedEmail.hm const getCommand = new GetCommand({ TableName: tableName, diff --git a/examples/typeorm/src/helpers/protect-entity.ts b/examples/typeorm/src/helpers/protect-entity.ts index e1ff3e43..4880998c 100644 --- a/examples/typeorm/src/helpers/protect-entity.ts +++ b/examples/typeorm/src/helpers/protect-entity.ts @@ -1,4 +1,7 @@ -import type { ProtectClient } from '@cipherstash/protect' +import { + type ProtectClient, + encryptedToPgComposite, +} from '@cipherstash/protect' import type { EntityTarget } from 'typeorm' import { AppDataSource } from '../data-source' @@ -199,25 +202,28 @@ export class ProtectEntityHelper { // biome-ignore lint/suspicious/noExplicitAny: Required for Protect.js schema types fieldConfig: { table: any; column: any }, ): Promise { - const searchTermsResult = await this.protectClient.createSearchTerms([ + // Use encryptQuery instead of deprecated createSearchTerms + const encryptedResult = await this.protectClient.encryptQuery([ { value: searchValue, column: fieldConfig.column, table: fieldConfig.table, - returnType: 'composite-literal', + queryType: 'equality', }, ]) - if (searchTermsResult.failure) { + if (encryptedResult.failure) { throw new Error( - `Failed to create search terms: ${searchTermsResult.failure.message}`, + `Failed to encrypt query: ${encryptedResult.failure.message}`, ) } + const [encrypted] = encryptedResult.data + const repository = AppDataSource.getRepository(entityClass) return repository.findOne({ where: { - [fieldName]: searchTermsResult.data[0], + [fieldName]: encryptedToPgComposite(encrypted), // biome-ignore lint/suspicious/noExplicitAny: Required for dynamic field access } as any, }) diff --git a/packages/drizzle/src/pg/operators.ts b/packages/drizzle/src/pg/operators.ts index 7642b77b..9addf834 100644 --- a/packages/drizzle/src/pg/operators.ts +++ b/packages/drizzle/src/pg/operators.ts @@ -4,6 +4,7 @@ import type { ProtectTable, ProtectTableColumn, } from '@cipherstash/protect/client' +import type { QueryTypeName } from '@cipherstash/protect' import { type SQL, type SQLWrapper, @@ -259,6 +260,7 @@ interface ValueToEncrypt { readonly value: string | number readonly column: SQLWrapper readonly columnInfo: ColumnInfo + readonly queryType?: QueryTypeName } /** @@ -267,7 +269,7 @@ interface ValueToEncrypt { */ async function encryptValues( protectClient: ProtectClient, - values: Array<{ value: unknown; column: SQLWrapper }>, + values: Array<{ value: unknown; column: SQLWrapper; queryType?: QueryTypeName }>, protectTable: ProtectTable | undefined, protectTableCache: Map>, ): Promise { @@ -280,7 +282,7 @@ async function encryptValues( const results: unknown[] = new Array(values.length) for (let i = 0; i < values.length; i++) { - const { value, column } = values[i] + const { value, column, queryType } = values[i] const columnInfo = getColumnInfo(column, protectTable, protectTableCache) if ( @@ -298,6 +300,7 @@ async function encryptValues( value: plaintextValue, column, columnInfo, + queryType, }) } @@ -311,13 +314,13 @@ async function encryptValues( { column: ProtectColumn table: ProtectTable - values: Array<{ value: string | number; index: number }> + values: Array<{ value: string | number; index: number; queryType?: QueryTypeName }> resultIndices: number[] } >() let valueIndex = 0 - for (const { value, column, columnInfo } of valuesToEncrypt) { + for (const { value, column, columnInfo, queryType } of valuesToEncrypt) { // Safe access with validation - we know these exist from earlier checks if ( !columnInfo.config || @@ -338,7 +341,7 @@ async function encryptValues( } columnGroups.set(columnName, group) } - group.values.push({ value, index: valueIndex++ }) + group.values.push({ value, index: valueIndex++, queryType }) // Find the original index in the results array const originalIndex = values.findIndex( @@ -359,13 +362,14 @@ async function encryptValues( value: v.value, column: group.column, table: group.table, + queryType: v.queryType, })) - const searchTerms = await protectClient.createSearchTerms(terms) + const encryptedTerms = await protectClient.encryptQuery(terms) - if (searchTerms.failure) { + if (encryptedTerms.failure) { throw new ProtectOperatorError( - `Failed to create search terms for column "${columnName}": ${searchTerms.failure.message}`, + `Failed to encrypt query terms for column "${columnName}": ${encryptedTerms.failure.message}`, { columnName }, ) } @@ -374,7 +378,7 @@ async function encryptValues( for (let i = 0; i < group.values.length; i++) { const resultIndex = group.resultIndices[i] ?? -1 if (resultIndex >= 0 && resultIndex < results.length) { - results[resultIndex] = searchTerms.data[i] + results[resultIndex] = encryptedTerms.data[i] } } } catch (error) { @@ -403,10 +407,11 @@ async function encryptValue( drizzleColumn: SQLWrapper, protectTable: ProtectTable | undefined, protectTableCache: Map>, + queryType?: QueryTypeName, ): Promise { const results = await encryptValues( protectClient, - [{ value, column: drizzleColumn }], + [{ value, column: drizzleColumn, queryType }], protectTable, protectTableCache, ) @@ -423,6 +428,7 @@ async function encryptValue( interface LazyOperator { readonly __isLazyOperator: true readonly operator: string + readonly queryType?: QueryTypeName readonly left: SQLWrapper readonly right: unknown readonly min?: unknown @@ -467,6 +473,7 @@ function createLazyOperator( protectTableCache: Map>, min?: unknown, max?: unknown, + queryType?: QueryTypeName, ): LazyOperator & Promise { let resolvedSQL: SQL | undefined let encryptionPromise: Promise | undefined @@ -474,6 +481,7 @@ function createLazyOperator( const lazyOp: LazyOperator = { __isLazyOperator: true, operator, + queryType, left, right, min, @@ -607,8 +615,8 @@ async function executeLazyOperatorDirect( const [encryptedMin, encryptedMax] = await encryptValues( protectClient, [ - { value: lazyOp.min, column: lazyOp.left }, - { value: lazyOp.max, column: lazyOp.left }, + { value: lazyOp.min, column: lazyOp.left, queryType: lazyOp.queryType }, + { value: lazyOp.max, column: lazyOp.left, queryType: lazyOp.queryType }, ], defaultProtectTable, protectTableCache, @@ -623,6 +631,7 @@ async function executeLazyOperatorDirect( lazyOp.left, defaultProtectTable, protectTableCache, + lazyOp.queryType, ) return lazyOp.execute(encrypted) @@ -664,14 +673,6 @@ function createComparisonOperator( } } - // Create SQL using eql_v2 functions for encrypted columns - const sqlFnMap = { - gt: () => sql`eql_v2.gt(${left}, ${bindIfParam(right, left)})`, - gte: () => sql`eql_v2.gte(${left}, ${bindIfParam(right, left)})`, - lt: () => sql`eql_v2.lt(${left}, ${bindIfParam(right, left)})`, - lte: () => sql`eql_v2.lte(${left}, ${bindIfParam(right, left)})`, - } - // This will be replaced with encrypted value in executeLazyOperator const executeFn = (encrypted: unknown) => { if (encrypted === undefined) { @@ -697,6 +698,9 @@ function createComparisonOperator( protectClient, defaultProtectTable, protectTableCache, + undefined, // min + undefined, // max + 'orderAndRange', ) as Promise } @@ -728,6 +732,9 @@ function createComparisonOperator( protectClient, defaultProtectTable, protectTableCache, + undefined, // min + undefined, // max + 'equality', ) as Promise } @@ -791,6 +798,7 @@ function createRangeOperator( protectTableCache, min, max, + 'orderAndRange', ) as Promise } @@ -847,6 +855,9 @@ function createTextSearchOperator( protectClient, defaultProtectTable, protectTableCache, + undefined, // min + undefined, // max + 'freeTextSearch', ) as Promise } @@ -1323,7 +1334,7 @@ export function createProtectOperators(protectClient: ProtectClient): { // Encrypt all values in the array in a single batch const encryptedValues = await encryptValues( protectClient, - right.map((value) => ({ value, column: left })), + right.map((value) => ({ value, column: left, queryType: 'equality' as const })), defaultProtectTable, protectTableCache, ) @@ -1369,7 +1380,7 @@ export function createProtectOperators(protectClient: ProtectClient): { // Encrypt all values in the array in a single batch const encryptedValues = await encryptValues( protectClient, - right.map((value) => ({ value, column: left })), + right.map((value) => ({ value, column: left, queryType: 'equality' as const })), defaultProtectTable, protectTableCache, ) @@ -1465,6 +1476,7 @@ export function createProtectOperators(protectClient: ProtectClient): { value: unknown column: SQLWrapper columnInfo: ColumnInfo + queryType?: QueryTypeName lazyOpIndex: number isMin?: boolean isMax?: boolean @@ -1481,6 +1493,7 @@ export function createProtectOperators(protectClient: ProtectClient): { value: lazyOp.min, column: lazyOp.left, columnInfo: lazyOp.columnInfo, + queryType: lazyOp.queryType, lazyOpIndex: i, isMin: true, }) @@ -1488,6 +1501,7 @@ export function createProtectOperators(protectClient: ProtectClient): { value: lazyOp.max, column: lazyOp.left, columnInfo: lazyOp.columnInfo, + queryType: lazyOp.queryType, lazyOpIndex: i, isMax: true, }) @@ -1496,6 +1510,7 @@ export function createProtectOperators(protectClient: ProtectClient): { value: lazyOp.right, column: lazyOp.left, columnInfo: lazyOp.columnInfo, + queryType: lazyOp.queryType, lazyOpIndex: i, }) } @@ -1504,7 +1519,7 @@ export function createProtectOperators(protectClient: ProtectClient): { // Batch encrypt all values const encryptedResults = await encryptValues( protectClient, - valuesToEncrypt.map((v) => ({ value: v.value, column: v.column })), + valuesToEncrypt.map((v) => ({ value: v.value, column: v.column, queryType: v.queryType })), defaultProtectTable, protectTableCache, ) @@ -1617,6 +1632,7 @@ export function createProtectOperators(protectClient: ProtectClient): { value: unknown column: SQLWrapper columnInfo: ColumnInfo + queryType?: QueryTypeName lazyOpIndex: number isMin?: boolean isMax?: boolean @@ -1633,6 +1649,7 @@ export function createProtectOperators(protectClient: ProtectClient): { value: lazyOp.min, column: lazyOp.left, columnInfo: lazyOp.columnInfo, + queryType: lazyOp.queryType, lazyOpIndex: i, isMin: true, }) @@ -1640,6 +1657,7 @@ export function createProtectOperators(protectClient: ProtectClient): { value: lazyOp.max, column: lazyOp.left, columnInfo: lazyOp.columnInfo, + queryType: lazyOp.queryType, lazyOpIndex: i, isMax: true, }) @@ -1648,6 +1666,7 @@ export function createProtectOperators(protectClient: ProtectClient): { value: lazyOp.right, column: lazyOp.left, columnInfo: lazyOp.columnInfo, + queryType: lazyOp.queryType, lazyOpIndex: i, }) } @@ -1655,7 +1674,7 @@ export function createProtectOperators(protectClient: ProtectClient): { const encryptedResults = await encryptValues( protectClient, - valuesToEncrypt.map((v) => ({ value: v.value, column: v.column })), + valuesToEncrypt.map((v) => ({ value: v.value, column: v.column, queryType: v.queryType })), defaultProtectTable, protectTableCache, ) diff --git a/packages/protect-dynamodb/src/index.ts b/packages/protect-dynamodb/src/index.ts index 433cda6b..fe18ddfa 100644 --- a/packages/protect-dynamodb/src/index.ts +++ b/packages/protect-dynamodb/src/index.ts @@ -65,6 +65,9 @@ export function protectDynamoDB( ) }, + /** + * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. + */ createSearchTerms(terms: SearchTerm[]) { return new SearchTermsOperation(protectClient, terms, options) }, diff --git a/packages/protect-dynamodb/src/operations/search-terms.ts b/packages/protect-dynamodb/src/operations/search-terms.ts index 74b9032d..22537ae4 100644 --- a/packages/protect-dynamodb/src/operations/search-terms.ts +++ b/packages/protect-dynamodb/src/operations/search-terms.ts @@ -7,6 +7,20 @@ import { type DynamoDBOperationOptions, } from './base-operation' +/** + * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. + * + * @example + * ```typescript + * // Before (deprecated) + * const result = await protectDynamo.createSearchTerms([{ value, column, table }]) + * const hmac = result.data[0] + * + * // After (new API) + * const [encrypted] = await protectClient.encryptQuery([{ value, column, table, queryType: 'equality' }]) + * const hmac = encrypted.hm + * ``` + */ export class SearchTermsOperation extends DynamoDBOperation { private protectClient: ProtectClient private terms: SearchTerm[] diff --git a/packages/protect-dynamodb/src/types.ts b/packages/protect-dynamodb/src/types.ts index 594d6404..32c18de6 100644 --- a/packages/protect-dynamodb/src/types.ts +++ b/packages/protect-dynamodb/src/types.ts @@ -47,5 +47,19 @@ export interface ProtectDynamoDBInstance { protectTable: ProtectTable, ): BulkDecryptModelsOperation + /** + * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. + * + * @example + * ```typescript + * // Before (deprecated) + * const result = await protectDynamo.createSearchTerms([{ value, column, table }]) + * const hmac = result.data[0] + * + * // After (new API) + * const [encrypted] = await protectClient.encryptQuery([{ value, column, table, queryType: 'equality' }]) + * const hmac = encrypted.hm + * ``` + */ createSearchTerms(terms: SearchTerm[]): SearchTermsOperation } diff --git a/packages/protect/__tests__/backward-compat.test.ts b/packages/protect/__tests__/backward-compat.test.ts index 46d39949..d3d3c55e 100644 --- a/packages/protect/__tests__/backward-compat.test.ts +++ b/packages/protect/__tests__/backward-compat.test.ts @@ -47,8 +47,9 @@ describe('k-field backward compatibility', () => { } // Simulate legacy payload by adding k field to the encrypted data + // Use non-null assertion since we've already checked for failure above const legacyPayload = { - ...encrypted.data, + ...encrypted.data!, k: 'ct', // Legacy discriminant field - should be ignored during decryption } diff --git a/packages/protect/__tests__/search-terms.test.ts b/packages/protect/__tests__/deprecated/search-terms.test.ts similarity index 58% rename from packages/protect/__tests__/search-terms.test.ts rename to packages/protect/__tests__/deprecated/search-terms.test.ts index f3cef7fe..96c729ab 100644 --- a/packages/protect/__tests__/search-terms.test.ts +++ b/packages/protect/__tests__/deprecated/search-terms.test.ts @@ -1,14 +1,16 @@ import 'dotenv/config' import { csColumn, csTable } from '@cipherstash/schema' import { describe, expect, it } from 'vitest' -import { type SearchTerm, protect } from '../src' +import { type SearchTerm, protect } from '../../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), address: csColumn('address').freeTextSearch(), + age: csColumn('age').dataType('number').equality(), + score: csColumn('score').dataType('number').equality(), }) -describe('create search terms', () => { +describe('createSearchTerms (deprecated - backward compatibility)', () => { it('should create search terms with default return type', async () => { const protectClient = await protect({ schemas: [users] }) @@ -34,7 +36,7 @@ describe('create search terms', () => { expect(searchTermsResult.data).toEqual( expect.arrayContaining([ expect.objectContaining({ - c: expect.any(String), + v: 2, }), ]), ) @@ -87,4 +89,52 @@ describe('create search terms', () => { expect(unescaped).toMatch(/^\(.*\)$/) expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow() }, 30000) + + it('should create search terms with composite-literal return type for numbers', async () => { + const protectClient = await protect({ schemas: [users] }) + + const searchTerms = [ + { + value: 42, + column: users.age, + table: users, + returnType: 'composite-literal' as const, + }, + ] + + const searchTermsResult = await protectClient.createSearchTerms(searchTerms) + + if (searchTermsResult.failure) { + throw new Error(`[protect]: ${searchTermsResult.failure.message}`) + } + + const result = searchTermsResult.data[0] as string + expect(result).toMatch(/^\(.*\)$/) + expect(() => JSON.parse(result.slice(1, -1))).not.toThrow() + }, 30000) + + it('should create search terms with escaped-composite-literal return type for numbers', async () => { + const protectClient = await protect({ schemas: [users] }) + + const searchTerms = [ + { + value: 99, + column: users.score, + table: users, + returnType: 'escaped-composite-literal' as const, + }, + ] + + const searchTermsResult = await protectClient.createSearchTerms(searchTerms) + + if (searchTermsResult.failure) { + throw new Error(`[protect]: ${searchTermsResult.failure.message}`) + } + + const result = searchTermsResult.data[0] as string + expect(result).toMatch(/^".*"$/) + const unescaped = JSON.parse(result) + expect(unescaped).toMatch(/^\(.*\)$/) + expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow() + }, 30000) }) diff --git a/packages/protect/__tests__/encrypt-query.test.ts b/packages/protect/__tests__/encrypt-query.test.ts new file mode 100644 index 00000000..0a76b354 --- /dev/null +++ b/packages/protect/__tests__/encrypt-query.test.ts @@ -0,0 +1,685 @@ +import 'dotenv/config' +import { describe, expect, it, beforeAll } from 'vitest' +import { protect, ProtectErrorTypes } from '../src' +import type { ProtectClient } from '../src/ffi' +import { + users, + articles, + products, + metadata, + createMockLockContext, + createMockLockContextWithNullContext, + createFailingMockLockContext, + unwrapResult, + expectFailure, +} from './fixtures' + +describe('encryptQuery', () => { + let protectClient: ProtectClient + + beforeAll(async () => { + protectClient = await protect({ schemas: [users, articles, products, metadata] }) + }) + + describe('single value encryption with explicit queryType', () => { + it('encrypts for equality query type', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + }) + + const data = unwrapResult(result) + + expect(data).toMatchObject({ + i: { t: 'users', c: 'email' }, + v: 2, + }) + expect(data).toHaveProperty('hm') + }, 30000) + + it('encrypts for freeTextSearch query type', async () => { + const result = await protectClient.encryptQuery('hello world', { + column: users.bio, + table: users, + queryType: 'freeTextSearch', + }) + + const data = unwrapResult(result) + + expect(data).toMatchObject({ + i: { t: 'users', c: 'bio' }, + v: 2, + }) + expect(data).toHaveProperty('bf') + }, 30000) + + it('encrypts for orderAndRange query type', async () => { + const result = await protectClient.encryptQuery(25, { + column: users.age, + table: users, + queryType: 'orderAndRange', + }) + + const data = unwrapResult(result) + + expect(data).toMatchObject({ + i: { t: 'users', c: 'age' }, + v: 2, + }) + expect(data).toHaveProperty('ob') + }, 30000) + }) + + describe('auto-inference when queryType omitted', () => { + it('auto-infers equality for column with .equality()', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + }) + + const data = unwrapResult(result) + expect(data).toHaveProperty('hm') + }, 30000) + + it('auto-infers freeTextSearch for match-only column', async () => { + const result = await protectClient.encryptQuery('search content', { + column: articles.content, + table: articles, + }) + + const data = unwrapResult(result) + expect(data).toHaveProperty('bf') + }, 30000) + + it('auto-infers orderAndRange for ore-only column', async () => { + const result = await protectClient.encryptQuery(99.99, { + column: products.price, + table: products, + }) + + const data = unwrapResult(result) + expect(data).toHaveProperty('ob') + }, 30000) + }) + + describe('edge cases', () => { + it('handles null values', async () => { + const result = await protectClient.encryptQuery(null, { + column: users.email, + table: users, + queryType: 'equality', + }) + + const data = unwrapResult(result) + expect(data).toBeNull() + }, 30000) + + it('rejects NaN values', async () => { + const result = await protectClient.encryptQuery(NaN, { + column: users.age, + table: users, + queryType: 'orderAndRange', + }) + + expectFailure(result, 'NaN') + }, 30000) + + it('rejects Infinity values', async () => { + const result = await protectClient.encryptQuery(Infinity, { + column: users.age, + table: users, + queryType: 'orderAndRange', + }) + + expectFailure(result, 'Infinity') + }, 30000) + + it('rejects negative Infinity values', async () => { + const result = await protectClient.encryptQuery(-Infinity, { + column: users.age, + table: users, + queryType: 'orderAndRange', + }) + + expectFailure(result, 'Infinity') + }, 30000) + }) + + describe('validation errors', () => { + it('fails when queryType does not match column config', async () => { + const result = await protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'freeTextSearch', // email only has equality + }) + + expectFailure(result, 'not configured') + }, 30000) + + it('fails when column has no indexes configured', async () => { + const result = await protectClient.encryptQuery('raw data', { + column: metadata.raw, + table: metadata, + }) + + expectFailure(result, 'no indexes configured') + }, 30000) + + it('provides descriptive error for queryType mismatch', async () => { + const result = await protectClient.encryptQuery(42, { + column: users.age, + table: users, + queryType: 'equality', // age only has orderAndRange + }) + + expectFailure(result, 'unique') + expectFailure(result, 'not configured', ProtectErrorTypes.EncryptionError) + }, 30000) + }) + + describe('value/index type compatibility', () => { + it('fails when encrypting number with match index (explicit queryType)', async () => { + const result = await protectClient.encryptQuery(123, { + column: articles.content, // match-only column + table: articles, + queryType: 'freeTextSearch', + }) + + expectFailure(result, 'match') + expectFailure(result, 'numeric') + }, 30000) + + it('fails when encrypting number with auto-inferred match index', async () => { + const result = await protectClient.encryptQuery(123, { + column: articles.content, // match-only column, will infer 'match' + table: articles, + }) + + expectFailure(result, 'match') + }, 30000) + + it('fails in batch when number used with match index', async () => { + const result = await protectClient.encryptQuery([ + { value: 123, column: articles.content, table: articles }, + ]) + + expectFailure(result, 'match') + }, 30000) + + it('allows string with match index', async () => { + const result = await protectClient.encryptQuery('search text', { + column: articles.content, + table: articles, + }) + + const data = unwrapResult(result) + expect(data).toHaveProperty('bf') // bloom filter + }, 30000) + + it('allows number with ore index', async () => { + const result = await protectClient.encryptQuery(42, { + column: users.age, + table: users, + queryType: 'orderAndRange', + }) + + const data = unwrapResult(result) + expect(data).toHaveProperty('ob') // order bits + }, 30000) + }) + + describe('numeric edge cases', () => { + it('encrypts MAX_SAFE_INTEGER', async () => { + const result = await protectClient.encryptQuery(Number.MAX_SAFE_INTEGER, { + column: users.age, + table: users, + queryType: 'orderAndRange', + }) + + const data = unwrapResult(result) + expect(data).toMatchObject({ + i: { t: 'users', c: 'age' }, + v: 2, + }) + expect(data).toHaveProperty('ob') + }, 30000) + + it('encrypts MIN_SAFE_INTEGER', async () => { + const result = await protectClient.encryptQuery(Number.MIN_SAFE_INTEGER, { + column: users.age, + table: users, + queryType: 'orderAndRange', + }) + + const data = unwrapResult(result) + expect(data).toMatchObject({ + i: { t: 'users', c: 'age' }, + v: 2, + }) + expect(data).toHaveProperty('ob') + }, 30000) + + it('encrypts negative zero', async () => { + const result = await protectClient.encryptQuery(-0, { + column: users.age, + table: users, + queryType: 'orderAndRange', + }) + + const data = unwrapResult(result) + expect(data).toHaveProperty('ob') + }, 30000) + }) + + describe('string edge cases', () => { + it('encrypts empty string', async () => { + const result = await protectClient.encryptQuery('', { + column: users.email, + table: users, + queryType: 'equality', + }) + + const data = unwrapResult(result) + expect(data).toMatchObject({ + i: { t: 'users', c: 'email' }, + v: 2, + }) + expect(data).toHaveProperty('hm') + }, 30000) + + it('encrypts unicode/emoji strings', async () => { + const result = await protectClient.encryptQuery('Hello δΈ–η•Œ πŸŒπŸš€', { + column: users.bio, + table: users, + queryType: 'freeTextSearch', + }) + + const data = unwrapResult(result) + expect(data).toMatchObject({ + i: { t: 'users', c: 'bio' }, + v: 2, + }) + expect(data).toHaveProperty('bf') + }, 30000) + + it('encrypts strings with SQL special characters', async () => { + const result = await protectClient.encryptQuery("'; DROP TABLE users; --", { + column: users.email, + table: users, + queryType: 'equality', + }) + + const data = unwrapResult(result) + expect(data).toMatchObject({ + i: { t: 'users', c: 'email' }, + v: 2, + }) + expect(data).toHaveProperty('hm') + }, 30000) + }) + + describe('encryptQuery bulk (array overload)', () => { + it('encrypts multiple terms in batch', async () => { + const result = await protectClient.encryptQuery([ + { value: 'user@example.com', column: users.email, table: users, queryType: 'equality' }, + { value: 'search term', column: users.bio, table: users, queryType: 'freeTextSearch' }, + { value: 42, column: users.age, table: users, queryType: 'orderAndRange' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(3) + expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) + expect(data[1]).toMatchObject({ i: { t: 'users', c: 'bio' } }) + expect(data[2]).toMatchObject({ i: { t: 'users', c: 'age' } }) + }, 30000) + + it('handles empty array', async () => { + // Empty arrays without opts are treated as empty batch for backward compatibility + const result = await protectClient.encryptQuery([]) + + const data = unwrapResult(result) + expect(data).toEqual([]) + }, 30000) + + it('handles null values in batch', async () => { + const result = await protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' }, + { value: null, column: users.bio, table: users, queryType: 'freeTextSearch' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(2) + expect(data[0]).not.toBeNull() + expect(data[1]).toBeNull() + }, 30000) + + it('auto-infers queryType when omitted', async () => { + const result = await protectClient.encryptQuery([ + { value: 'user@example.com', column: users.email, table: users }, + { value: 42, column: users.age, table: users }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(2) + expect(data[0]).toHaveProperty('hm') + expect(data[1]).toHaveProperty('ob') + }, 30000) + + it('rejects NaN/Infinity values in batch', async () => { + const result = await protectClient.encryptQuery([ + { value: NaN, column: users.age, table: users, queryType: 'orderAndRange' }, + { value: Infinity, column: users.age, table: users, queryType: 'orderAndRange' }, + ]) + + expect(result.failure).toBeDefined() + }, 30000) + + it('rejects negative Infinity in batch', async () => { + const result = await protectClient.encryptQuery([ + { value: -Infinity, column: users.age, table: users, queryType: 'orderAndRange' }, + ]) + + expectFailure(result, 'Infinity') + }, 30000) + }) + + describe('bulk index preservation', () => { + it('preserves exact positions with multiple nulls interspersed', async () => { + const result = await protectClient.encryptQuery([ + { value: null, column: users.email, table: users, queryType: 'equality' }, + { value: 'user@example.com', column: users.email, table: users, queryType: 'equality' }, + { value: null, column: users.bio, table: users, queryType: 'freeTextSearch' }, + { value: null, column: users.age, table: users, queryType: 'orderAndRange' }, + { value: 42, column: users.age, table: users, queryType: 'orderAndRange' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(5) + expect(data[0]).toBeNull() + expect(data[1]).not.toBeNull() + expect(data[1]).toHaveProperty('hm') + expect(data[2]).toBeNull() + expect(data[3]).toBeNull() + expect(data[4]).not.toBeNull() + expect(data[4]).toHaveProperty('ob') + }, 30000) + + it('handles single-item array', async () => { + const result = await protectClient.encryptQuery([ + { value: 'single@example.com', column: users.email, table: users, queryType: 'equality' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(1) + expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) + expect(data[0]).toHaveProperty('hm') + }, 30000) + + it('handles all-null array', async () => { + const result = await protectClient.encryptQuery([ + { value: null, column: users.email, table: users, queryType: 'equality' }, + { value: null, column: users.bio, table: users, queryType: 'freeTextSearch' }, + { value: null, column: users.age, table: users, queryType: 'orderAndRange' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(3) + expect(data[0]).toBeNull() + expect(data[1]).toBeNull() + expect(data[2]).toBeNull() + }, 30000) + }) + + describe('audit support', () => { + it('passes audit metadata for single query', async () => { + const result = await protectClient + .encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + }) + .audit({ metadata: { userId: 'test-user' } }) + + const data = unwrapResult(result) + expect(data).toMatchObject({ i: { t: 'users', c: 'email' } }) + }, 30000) + + it('passes audit metadata for bulk query', async () => { + const result = await protectClient + .encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' }, + ]) + .audit({ metadata: { userId: 'test-user' } }) + + const data = unwrapResult(result) + expect(data).toHaveLength(1) + }, 30000) + }) + + describe('returnType formatting', () => { + it('returns Encrypted by default (no returnType)', async () => { + const result = await protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(1) + expect(data[0]).toMatchObject({ + i: { t: 'users', c: 'email' }, + v: 2, + }) + expect(typeof data[0]).toBe('object') + }, 30000) + + it('returns composite-literal format when specified', async () => { + const result = await protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality', returnType: 'composite-literal' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(1) + expect(typeof data[0]).toBe('string') + // Format: ("json") + expect(data[0]).toMatch(/^\(".*"\)$/) + }, 30000) + + it('returns escaped-composite-literal format when specified', async () => { + const result = await protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality', returnType: 'escaped-composite-literal' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(1) + expect(typeof data[0]).toBe('string') + // Format: "(\"json\")" - outer quotes with escaped inner quotes + expect(data[0]).toMatch(/^"\(.*\)"$/) + }, 30000) + + it('returns eql format when explicitly specified', async () => { + const result = await protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality', returnType: 'eql' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(1) + expect(data[0]).toMatchObject({ + i: { t: 'users', c: 'email' }, + v: 2, + }) + expect(typeof data[0]).toBe('object') + }, 30000) + + it('handles mixed returnType values in same batch', async () => { + const result = await protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' }, // default + { value: 'search term', column: users.bio, table: users, queryType: 'freeTextSearch', returnType: 'composite-literal' }, + { value: 42, column: users.age, table: users, queryType: 'orderAndRange', returnType: 'escaped-composite-literal' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(3) + + // First: default (Encrypted object) + expect(typeof data[0]).toBe('object') + expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) + + // Second: composite-literal (string) + expect(typeof data[1]).toBe('string') + expect(data[1]).toMatch(/^\(".*"\)$/) + + // Third: escaped-composite-literal (string) + expect(typeof data[2]).toBe('string') + expect(data[2]).toMatch(/^"\(.*\)"$/) + }, 30000) + + it('handles returnType with null values', async () => { + const result = await protectClient.encryptQuery([ + { value: null, column: users.email, table: users, queryType: 'equality', returnType: 'composite-literal' }, + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality', returnType: 'composite-literal' }, + { value: null, column: users.bio, table: users, queryType: 'freeTextSearch', returnType: 'escaped-composite-literal' }, + ]) + + const data = unwrapResult(result) + + expect(data).toHaveLength(3) + expect(data[0]).toBeNull() + expect(typeof data[1]).toBe('string') + expect(data[1]).toMatch(/^\(".*"\)$/) + expect(data[2]).toBeNull() + }, 30000) + }) + + describe('LockContext support', () => { + it('single query with LockContext calls getLockContext', async () => { + const mockLockContext = createMockLockContext() + + const operation = protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + }) + + const withContext = operation.withLockContext(mockLockContext as any) + expect(withContext).toHaveProperty('execute') + expect(typeof withContext.execute).toBe('function') + }, 30000) + + it('bulk query with LockContext calls getLockContext', async () => { + const mockLockContext = createMockLockContext() + + const operation = protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' }, + ]) + + const withContext = operation.withLockContext(mockLockContext as any) + expect(withContext).toHaveProperty('execute') + expect(typeof withContext.execute).toBe('function') + }, 30000) + + it('executes single query with LockContext mock', async () => { + const mockLockContext = createMockLockContext() + + const operation = protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + }) + + const withContext = operation.withLockContext(mockLockContext as any) + const result = await withContext.execute() + + expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) + + const data = unwrapResult(result) + expect(data).toMatchObject({ + i: { t: 'users', c: 'email' }, + v: 2, + }) + expect(data).toHaveProperty('hm') + }, 30000) + + it('executes bulk query with LockContext mock', async () => { + const mockLockContext = createMockLockContext() + + const operation = protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' }, + { value: 42, column: users.age, table: users, queryType: 'orderAndRange' }, + ]) + + const withContext = operation.withLockContext(mockLockContext as any) + const result = await withContext.execute() + + expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) + + const data = unwrapResult(result) + expect(data).toHaveLength(2) + expect(data[0]).toHaveProperty('hm') + expect(data[1]).toHaveProperty('ob') + }, 30000) + + it('handles LockContext failure gracefully', async () => { + const mockLockContext = createFailingMockLockContext( + ProtectErrorTypes.CtsTokenError, + 'Mock LockContext failure' + ) + + const operation = protectClient.encryptQuery('test@example.com', { + column: users.email, + table: users, + queryType: 'equality', + }) + + const withContext = operation.withLockContext(mockLockContext as any) + const result = await withContext.execute() + + expectFailure(result, 'Mock LockContext failure', ProtectErrorTypes.CtsTokenError) + }, 30000) + + it('handles null value with LockContext', async () => { + const mockLockContext = createMockLockContext() + + const operation = protectClient.encryptQuery(null, { + column: users.email, + table: users, + queryType: 'equality', + }) + + const withContext = operation.withLockContext(mockLockContext as any) + const result = await withContext.execute() + + // Null values should return null without calling LockContext + // since there's nothing to encrypt + const data = unwrapResult(result) + expect(data).toBeNull() + }, 30000) + + it('handles explicit null context from getLockContext gracefully', async () => { + // Simulate a runtime scenario where context is null (bypasses TypeScript) + const mockLockContext = createMockLockContextWithNullContext() + + const operation = protectClient.encryptQuery([ + { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' }, + ]) + + const withContext = operation.withLockContext(mockLockContext as any) + const result = await withContext.execute() + + // Should succeed - null context should not be passed to FFI + const data = unwrapResult(result) + expect(data).toHaveLength(1) + expect(data[0]).toHaveProperty('hm') + }, 30000) + }) +}) diff --git a/packages/protect/__tests__/fixtures/index.ts b/packages/protect/__tests__/fixtures/index.ts new file mode 100644 index 00000000..f681037c --- /dev/null +++ b/packages/protect/__tests__/fixtures/index.ts @@ -0,0 +1,121 @@ +import { csColumn, csTable } from '@cipherstash/schema' +import { vi, expect } from 'vitest' + +// ============ Schema Fixtures ============ + +/** + * Users table with multiple index types for testing + */ +export const users = csTable('users', { + email: csColumn('email').equality(), + bio: csColumn('bio').freeTextSearch(), + age: csColumn('age').dataType('number').orderAndRange(), +}) + +/** + * Articles table with only freeTextSearch (for auto-inference test) + */ +export const articles = csTable('articles', { + content: csColumn('content').freeTextSearch(), +}) + +/** + * Products table with only orderAndRange (for auto-inference test) + */ +export const products = csTable('products', { + price: csColumn('price').dataType('number').orderAndRange(), +}) + +/** + * Metadata table with no indexes (for validation error test) + */ +export const metadata = csTable('metadata', { + raw: csColumn('raw'), +}) + +// ============ Mock Factories ============ + +/** + * Creates a mock LockContext with successful response + */ +export function createMockLockContext(overrides?: { + accessToken?: string + expiry?: number + identityClaim?: string[] +}) { + return { + getLockContext: vi.fn().mockResolvedValue({ + data: { + ctsToken: { + accessToken: overrides?.accessToken ?? 'mock-token', + expiry: overrides?.expiry ?? Date.now() + 3600000, + }, + context: { + identityClaim: overrides?.identityClaim ?? ['sub'], + }, + }, + }), + } +} + +/** + * Creates a mock LockContext with explicit null context (simulates runtime edge case) + */ +export function createMockLockContextWithNullContext() { + return { + getLockContext: vi.fn().mockResolvedValue({ + data: { + ctsToken: { + accessToken: 'mock-token', + expiry: Date.now() + 3600000, + }, + context: null, // Explicit null - simulating runtime edge case + }, + }), + } +} + +/** + * Creates a mock LockContext that returns a failure + */ +export function createFailingMockLockContext(errorType: string, message: string) { + return { + getLockContext: vi.fn().mockResolvedValue({ + failure: { type: errorType, message }, + }), + } +} + +// ============ Test Helpers ============ + +/** + * Unwraps a Result type, throwing an error if it's a failure. + * Use this to simplify test assertions when you expect success. + */ +export function unwrapResult(result: { data?: T; failure?: { message: string } }): T { + if (result.failure) { + throw new Error(result.failure.message) + } + return result.data as T +} + +/** + * Asserts that a result is a failure with optional message and type matching + */ +export function expectFailure( + result: { failure?: { message: string; type?: string } }, + messagePattern?: string | RegExp, + expectedType?: string +) { + expect(result.failure).toBeDefined() + if (messagePattern) { + if (typeof messagePattern === 'string') { + expect(result.failure?.message).toContain(messagePattern) + } else { + expect(result.failure?.message).toMatch(messagePattern) + } + } + if (expectedType) { + expect(result.failure?.type).toBe(expectedType) + } +} diff --git a/packages/protect/__tests__/helpers.test.ts b/packages/protect/__tests__/helpers.test.ts index deabb3ed..abc2f6d8 100644 --- a/packages/protect/__tests__/helpers.test.ts +++ b/packages/protect/__tests__/helpers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { bulkModelsToEncryptedPgComposites, + encryptedToCompositeLiteral, encryptedToPgComposite, isEncryptedPayload, modelToEncryptedPgComposites, @@ -29,6 +30,29 @@ describe('helpers', () => { }) }) + describe('encryptedToCompositeLiteral', () => { + it('should convert encrypted payload to pg composite literal string', () => { + const encrypted = { + v: 1, + c: 'ciphertext', + i: { + c: 'iv', + t: 't', + }, + } + + const literal = encryptedToCompositeLiteral(encrypted) + // Should produce PostgreSQL composite literal format: ("json_string") + expect(literal).toMatch(/^\(.*\)$/) + // The inner content should be a valid JSON string (double-stringified) + const innerContent = literal.slice(1, -1) // Remove outer parentheses + expect(() => JSON.parse(innerContent)).not.toThrow() + // Parsing the inner content should give us the original JSON + const parsedJson = JSON.parse(JSON.parse(innerContent)) + expect(parsedJson).toEqual(encrypted) + }) + }) + describe('isEncryptedPayload', () => { it('should return true for valid encrypted payload', () => { const encrypted = { diff --git a/packages/protect/__tests__/infer-index-type.test.ts b/packages/protect/__tests__/infer-index-type.test.ts new file mode 100644 index 00000000..efb07c94 --- /dev/null +++ b/packages/protect/__tests__/infer-index-type.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { csColumn, csTable } from '@cipherstash/schema' +import { inferIndexType, validateIndexType } from '../src/ffi/helpers/infer-index-type' + +describe('infer-index-type helpers', () => { + const users = csTable('users', { + email: csColumn('email').equality(), + bio: csColumn('bio').freeTextSearch(), + age: csColumn('age').orderAndRange(), + name: csColumn('name').equality().freeTextSearch(), + }) + + describe('inferIndexType', () => { + it('returns unique for equality-only column', () => { + expect(inferIndexType(users.email)).toBe('unique') + }) + + it('returns match for freeTextSearch-only column', () => { + expect(inferIndexType(users.bio)).toBe('match') + }) + + it('returns ore for orderAndRange-only column', () => { + expect(inferIndexType(users.age)).toBe('ore') + }) + + it('returns unique when multiple indexes (priority: unique > match > ore)', () => { + expect(inferIndexType(users.name)).toBe('unique') + }) + + it('returns match when freeTextSearch and orderAndRange (priority: match > ore)', () => { + const schema = csTable('t', { col: csColumn('col').freeTextSearch().orderAndRange() }) + expect(inferIndexType(schema.col)).toBe('match') + }) + + it('throws for column with no indexes', () => { + const noIndex = csTable('t', { col: csColumn('col') }) + expect(() => inferIndexType(noIndex.col)).toThrow('no indexes configured') + }) + }) + + describe('validateIndexType', () => { + it('does not throw for valid index type', () => { + expect(() => validateIndexType(users.email, 'unique')).not.toThrow() + }) + + it('throws for unconfigured index type', () => { + expect(() => validateIndexType(users.email, 'match')).toThrow('not configured') + }) + }) +}) diff --git a/packages/protect/__tests__/number-protect.test.ts b/packages/protect/__tests__/number-protect.test.ts index 3ade327a..2f04bc43 100644 --- a/packages/protect/__tests__/number-protect.test.ts +++ b/packages/protect/__tests__/number-protect.test.ts @@ -708,78 +708,20 @@ describe('Nested object encryption', () => { }, 30000) }) -describe('Search terms', () => { - it('should create search terms for number fields', async () => { - const searchTerms = [ - { - value: 25, - column: users.age, - table: users, - }, - { - value: 100, - column: users.score, - table: users, - }, - ] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - expect(searchTermsResult.data).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - c: expect.any(String), - }), - ]), - ) - }, 30000) - - it('should create search terms with composite-literal return type for numbers', async () => { - const searchTerms = [ - { - value: 42, - column: users.age, - table: users, - returnType: 'composite-literal' as const, - }, - ] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(result.slice(1, -1))).not.toThrow() - }, 30000) - - it('should create search terms with escaped-composite-literal return type for numbers', async () => { - const searchTerms = [ - { - value: 99, - column: users.score, - table: users, - returnType: 'escaped-composite-literal' as const, - }, - ] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) +describe('encryptQuery for numbers', () => { + it('should create encrypted query for number fields', async () => { + const result = await protectClient.encryptQuery([ + { value: 25, column: users.age, table: users, queryType: 'equality' }, + { value: 100, column: users.score, table: users, queryType: 'equality' }, + ]) - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) + if (result.failure) { + throw new Error(`[protect]: ${result.failure.message}`) } - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^".*"$/) - const unescaped = JSON.parse(result) - expect(unescaped).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow() + expect(result.data).toHaveLength(2) + expect(result.data[0]).toHaveProperty('v', 2) + expect(result.data[1]).toHaveProperty('v', 2) }, 30000) }) @@ -882,7 +824,7 @@ describe('Invalid or uncoercable values', () => { }) expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Unsupported conversion') + expect(result.failure?.message).toContain('Cannot convert') }, 30000, ) diff --git a/packages/protect/__tests__/supabase.test.ts b/packages/protect/__tests__/supabase.test.ts index f18bdab5..725824fa 100644 --- a/packages/protect/__tests__/supabase.test.ts +++ b/packages/protect/__tests__/supabase.test.ts @@ -264,26 +264,23 @@ describe('supabase', () => { const insertedRecordId = insertResult.data[0].id insertedIds.push(insertedRecordId) - // Create search term for equality query - const searchTerm = await protectClient.createSearchTerms([ - { - value: testAge, - column: table.age, - table: table, - returnType: 'composite-literal', - }, + // Create encrypted query for equality search with composite-literal returnType + const encryptedResult = await protectClient.encryptQuery([ + { value: testAge, column: table.age, table: table, queryType: 'equality', returnType: 'composite-literal' }, ]) - if (searchTerm.failure) { - throw new Error(`[protect]: ${searchTerm.failure.message}`) + if (encryptedResult.failure) { + throw new Error(`[protect]: ${encryptedResult.failure.message}`) } + const [searchTerm] = encryptedResult.data + // Query filtering by both encrypted age AND our specific test run's ID // This ensures we don't pick up stale data from other test runs const { data, error } = await supabase .from('protect-ci') .select('id, age::jsonb, otherField') - .eq('age', searchTerm.data[0]) + .eq('age', searchTerm) .eq('test_run_id', TEST_RUN_ID) if (error) { diff --git a/packages/protect/package.json b/packages/protect/package.json index 0b2267a0..38cde34a 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -68,7 +68,7 @@ }, "dependencies": { "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.19.0", + "@cipherstash/protect-ffi": "0.20.1", "@cipherstash/schema": "workspace:*", "@stricli/core": "^1.2.5", "dotenv": "16.4.7", diff --git a/packages/protect/src/ffi/helpers/infer-index-type.ts b/packages/protect/src/ffi/helpers/infer-index-type.ts new file mode 100644 index 00000000..fcda480b --- /dev/null +++ b/packages/protect/src/ffi/helpers/infer-index-type.ts @@ -0,0 +1,67 @@ +import type { FfiIndexTypeName, QueryTypeName } from '../../types' +import { queryTypeToFfi } from '../../types' +import type { ProtectColumn } from '@cipherstash/schema' + +/** + * Infer the primary index type from a column's configured indexes. + * Priority: unique > match > ore (for scalar queries) + */ +export function inferIndexType(column: ProtectColumn): FfiIndexTypeName { + const config = column.build() + const indexes = config.indexes + + if (!indexes || Object.keys(indexes).length === 0) { + throw new Error(`Column "${column.getName()}" has no indexes configured`) + } + + if (indexes.unique) return 'unique' + if (indexes.match) return 'match' + if (indexes.ore) return 'ore' + + throw new Error( + `Column "${column.getName()}" has no suitable index for scalar queries` + ) +} + +/** + * Validate that the specified index type is configured on the column + */ +export function validateIndexType(column: ProtectColumn, indexType: FfiIndexTypeName): void { + const config = column.build() + const indexes = config.indexes ?? {} + + const indexMap: Record = { + unique: !!indexes.unique, + match: !!indexes.match, + ore: !!indexes.ore, + } + + if (!indexMap[indexType]) { + throw new Error( + `Index type "${indexType}" is not configured on column "${column.getName()}"` + ) + } +} + +/** + * Resolve the index type for a query, either from explicit queryType or by inference. + * Validates the index type is configured on the column when queryType is explicit. + * + * @param column - The column to resolve the index type for + * @param queryType - Optional explicit query type (if provided, validates against column config) + * @returns The FFI index type name to use for the query + */ +export function resolveIndexType( + column: ProtectColumn, + queryType?: QueryTypeName +): FfiIndexTypeName { + const indexType = queryType + ? queryTypeToFfi[queryType] + : inferIndexType(column) + + if (queryType) { + validateIndexType(column, indexType) + } + + return indexType +} diff --git a/packages/protect/src/ffi/helpers/type-guards.ts b/packages/protect/src/ffi/helpers/type-guards.ts new file mode 100644 index 00000000..2108eb27 --- /dev/null +++ b/packages/protect/src/ffi/helpers/type-guards.ts @@ -0,0 +1,18 @@ +import type { ScalarQueryTerm } from '../../types' + +/** + * Type guard to check if a value is an array of ScalarQueryTerm objects. + * Used to discriminate between single value and bulk encryption in encryptQuery overloads. + */ +export function isScalarQueryTermArray( + value: unknown +): value is readonly ScalarQueryTerm[] { + return ( + Array.isArray(value) && + value.length > 0 && + typeof value[0] === 'object' && + value[0] !== null && + 'column' in value[0] && + 'table' in value[0] + ) +} diff --git a/packages/protect/src/ffi/helpers/validation.ts b/packages/protect/src/ffi/helpers/validation.ts new file mode 100644 index 00000000..c0b21b7b --- /dev/null +++ b/packages/protect/src/ffi/helpers/validation.ts @@ -0,0 +1,94 @@ +import { type ProtectError, ProtectErrorTypes } from '../..' +import type { Result } from '@byteslice/result' +import type { FfiIndexTypeName } from '../../types' + +/** + * Validates that a value is not NaN or Infinity. + * Returns a failure Result if validation fails, undefined otherwise. + * Use this in async flows that return Result types. + * + * Uses `never` as the success type so the result can be assigned to any Result. + * + * @internal + */ +export function validateNumericValue( + value: unknown +): Result | undefined { + if (typeof value === 'number' && Number.isNaN(value)) { + return { + failure: { + type: ProtectErrorTypes.EncryptionError, + message: '[protect]: Cannot encrypt NaN value', + }, + } + } + if (typeof value === 'number' && !Number.isFinite(value)) { + return { + failure: { + type: ProtectErrorTypes.EncryptionError, + message: '[protect]: Cannot encrypt Infinity value', + }, + } + } + return undefined +} + +/** + * Validates that a value is not NaN or Infinity. + * Throws an error if validation fails. + * Use this in sync flows where exceptions are caught. + * + * @internal + */ +export function assertValidNumericValue(value: unknown): void { + if (typeof value === 'number' && Number.isNaN(value)) { + throw new Error('[protect]: Cannot encrypt NaN value') + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error('[protect]: Cannot encrypt Infinity value') + } +} + +/** + * Validates that the value type is compatible with the index type. + * Match index (freeTextSearch) only supports string values. + * Returns a failure Result if validation fails, undefined otherwise. + * Use this in async flows that return Result types. + * + * @internal + */ +export function validateValueIndexCompatibility( + value: unknown, + indexType: FfiIndexTypeName, + columnName: string +): Result | undefined { + if (typeof value === 'number' && indexType === 'match') { + return { + failure: { + type: ProtectErrorTypes.EncryptionError, + message: `[protect]: Cannot use 'match' index with numeric value on column "${columnName}". The 'freeTextSearch' index only supports string values. Configure the column with 'orderAndRange()' or 'equality()' for numeric queries.`, + }, + } + } + return undefined +} + +/** + * Validates that the value type is compatible with the index type. + * Match index (freeTextSearch) only supports string values. + * Throws an error if validation fails. + * Use this in sync flows where exceptions are caught. + * + * @internal + */ +export function assertValueIndexCompatibility( + value: unknown, + indexType: FfiIndexTypeName, + columnName: string +): void { + if (typeof value === 'number' && indexType === 'match') { + throw new Error( + `[protect]: Cannot use 'match' index with numeric value on column "${columnName}". The 'freeTextSearch' index only supports string values. Configure the column with 'orderAndRange()' or 'equality()' for numeric queries.` + ) + } +} diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index 8f7fa35d..dc92a4c9 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -10,25 +10,30 @@ import { type ProtectError, ProtectErrorTypes } from '..' import { loadWorkSpaceId } from '../../../utils/config' import { logger } from '../../../utils/logger' import { toFfiKeysetIdentifier } from '../helpers' +import { isScalarQueryTermArray } from './helpers/type-guards' import type { BulkDecryptPayload, BulkEncryptPayload, Client, Decrypted, EncryptOptions, + EncryptQueryOptions, Encrypted, KeysetIdentifier, + ScalarQueryTerm, SearchTerm, } from '../types' import { BulkDecryptOperation } from './operations/bulk-decrypt' import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' import { BulkEncryptOperation } from './operations/bulk-encrypt' import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models' +import { BatchEncryptQueryOperation } from './operations/batch-encrypt-query' import { DecryptOperation } from './operations/decrypt' import { DecryptModelOperation } from './operations/decrypt-model' import { EncryptOperation } from './operations/encrypt' import { EncryptModelOperation } from './operations/encrypt-model' -import { SearchTermsOperation } from './operations/search-terms' +import { EncryptQueryOperation } from './operations/encrypt-query' +import { SearchTermsOperation } from './operations/deprecated/search-terms' export const noClientError = () => new Error( @@ -178,6 +183,90 @@ export class ProtectClient { return new EncryptOperation(this.client, plaintext, opts) } + /** + * Encrypt a query value - returns a promise which resolves to an encrypted query value. + * + * @param plaintext - The plaintext value to be encrypted for querying. Can be null. + * @param opts - Options specifying the column, table, and optional queryType for encryption. + * @returns An EncryptQueryOperation that can be awaited or chained with additional methods. + * + * @example + * The following example demonstrates how to encrypt a query value using the Protect client. + * + * ```typescript + * // Define encryption schema + * import { csTable, csColumn } from "@cipherstash/protect" + * const userSchema = csTable("users", { + * email: csColumn("email").equality(), + * }); + * + * // Initialize Protect client + * const protectClient = await protect({ schemas: [userSchema] }) + * + * // Encrypt a query value + * const encryptedResult = await protectClient.encryptQuery( + * "person@example.com", + * { column: userSchema.email, table: userSchema, queryType: 'equality' } + * ) + * + * // Handle encryption result + * if (encryptedResult.failure) { + * throw new Error(`Encryption failed: ${encryptedResult.failure.message}`); + * } + * + * console.log("Encrypted query:", encryptedResult.data); + * ``` + * + * @example + * The queryType can be auto-inferred from the column's configured indexes: + * + * ```typescript + * // When queryType is omitted, it will be inferred from the column's indexes + * const encryptedResult = await protectClient.encryptQuery( + * "person@example.com", + * { column: userSchema.email, table: userSchema } + * ) + * ``` + * + * @see {@link EncryptQueryOperation} + */ + encryptQuery( + plaintext: JsPlaintext | null, + opts: EncryptQueryOptions, + ): EncryptQueryOperation + + /** + * Encrypt multiple values for use in queries (batch operation). + * @param terms - Array of query terms to encrypt + */ + encryptQuery( + terms: readonly ScalarQueryTerm[], + ): BatchEncryptQueryOperation + + encryptQuery( + plaintextOrTerms: JsPlaintext | null | readonly ScalarQueryTerm[], + opts?: EncryptQueryOptions, + ): EncryptQueryOperation | BatchEncryptQueryOperation { + // Discriminate between ScalarQueryTerm[] and JsPlaintext (which can also be an array) + // using a type guard function + if (isScalarQueryTermArray(plaintextOrTerms)) { + return new BatchEncryptQueryOperation(this.client, plaintextOrTerms) + } + + // Handle empty arrays: if opts provided, treat as single value; otherwise batch mode + // This maintains backward compatibility for encryptQuery([]) while allowing + // encryptQuery([], opts) to encrypt an empty array as a single value + if (Array.isArray(plaintextOrTerms) && plaintextOrTerms.length === 0 && !opts) { + return new BatchEncryptQueryOperation(this.client, [] as readonly ScalarQueryTerm[]) + } + + return new EncryptQueryOperation( + this.client, + plaintextOrTerms as JsPlaintext | null, + opts!, + ) + } + /** * Decryption - returns a promise which resolves to a decrypted value. * @@ -308,6 +397,22 @@ export class ProtectClient { /** * Create search terms to use in a query searching encrypted data + * + * @deprecated Use `encryptQuery(terms)` instead. + * + * Migration example: + * ```typescript + * // Before (deprecated) + * const result = await client.createSearchTerms([ + * { value: 'test', column: users.email, table: users } + * ]) + * + * // After + * const result = await client.encryptQuery([ + * { value: 'test', column: users.email, table: users, queryType: 'equality' } + * ]) + * ``` + * * Usage: * await eqlClient.createSearchTerms(searchTerms) * await eqlClient.createSearchTerms(searchTerms).withLockContext(lockContext) diff --git a/packages/protect/src/ffi/operations/batch-encrypt-query.ts b/packages/protect/src/ffi/operations/batch-encrypt-query.ts new file mode 100644 index 00000000..ba95fb3f --- /dev/null +++ b/packages/protect/src/ffi/operations/batch-encrypt-query.ts @@ -0,0 +1,212 @@ +import { type Result, withResult } from '@byteslice/result' +import { + type JsPlaintext, + encryptQueryBulk as ffiEncryptQueryBulk, + type QueryPayload, +} from '@cipherstash/protect-ffi' +import { type ProtectError, ProtectErrorTypes } from '../..' +import { logger } from '../../../../utils/logger' +import type { Context, LockContext } from '../../identify' +import type { Encrypted as CipherStashEncrypted } from '@cipherstash/protect-ffi' +import type { Client, EncryptedQueryResult, ScalarQueryTerm } from '../../types' +import { noClientError } from '../index' +import { ProtectOperation } from './base-operation' +import { resolveIndexType } from '../helpers/infer-index-type' +import { assertValidNumericValue, assertValueIndexCompatibility } from '../helpers/validation' +import { encryptedToCompositeLiteral, encryptedToEscapedCompositeLiteral } from '../../helpers' + +/** + * Separates null values from non-null terms in the input array. + * Returns a set of indices where values are null and an array of non-null terms with their original indices. + */ +function filterNullTerms( + terms: readonly ScalarQueryTerm[], +): { + nullIndices: Set + nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[] +} { + const nullIndices = new Set() + const nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[] = [] + + terms.forEach((term, index) => { + if (term.value === null) { + nullIndices.add(index) + } else { + nonNullTerms.push({ term, originalIndex: index }) + } + }) + + return { nullIndices, nonNullTerms } +} + +/** + * Validates and transforms a single term into a QueryPayload. + * Throws an error if the value is NaN or Infinity. + * Optionally includes lockContext if provided. + */ +function buildQueryPayload( + term: ScalarQueryTerm, + lockContext?: Context, +): QueryPayload { + assertValidNumericValue(term.value) + + const indexType = resolveIndexType(term.column, term.queryType) + + // Validate value/index compatibility + assertValueIndexCompatibility( + term.value, + indexType, + term.column.getName() + ) + + const payload: QueryPayload = { + plaintext: term.value as JsPlaintext, + column: term.column.getName(), + table: term.table.tableName, + indexType, + } + + if (lockContext != null) { + payload.lockContext = lockContext + } + + return payload +} + +/** + * Reconstructs the results array with nulls in their original positions. + * Non-null encrypted values are placed at their original indices. + * Applies formatting based on term.returnType. + */ +function assembleResults( + totalLength: number, + encryptedValues: CipherStashEncrypted[], + nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[], +): EncryptedQueryResult[] { + const results: EncryptedQueryResult[] = new Array(totalLength).fill(null) + + // Fill in encrypted values at their original positions, applying formatting + nonNullTerms.forEach(({ term, originalIndex }, i) => { + const encrypted = encryptedValues[i] + + if (term.returnType === 'composite-literal') { + results[originalIndex] = encryptedToCompositeLiteral(encrypted) + } else if (term.returnType === 'escaped-composite-literal') { + results[originalIndex] = encryptedToEscapedCompositeLiteral(encrypted) + } else { + results[originalIndex] = encrypted + } + }) + + return results +} + +/** + * @internal Use {@link ProtectClient.encryptQuery} with array input instead. + */ +export class BatchEncryptQueryOperation extends ProtectOperation { + constructor( + private client: Client, + private terms: readonly ScalarQueryTerm[], + ) { + super() + } + + public withLockContext(lockContext: LockContext): BatchEncryptQueryOperationWithLockContext { + return new BatchEncryptQueryOperationWithLockContext(this.client, this.terms, lockContext, this.auditMetadata) + } + + public async execute(): Promise> { + logger.debug('Encrypting query terms', { count: this.terms.length }) + + if (this.terms.length === 0) { + return { data: [] } + } + + const { nullIndices, nonNullTerms } = filterNullTerms(this.terms) + + if (nonNullTerms.length === 0) { + return { data: this.terms.map(() => null) } + } + + return await withResult( + async () => { + if (!this.client) throw noClientError() + + const { metadata } = this.getAuditData() + + const queries: QueryPayload[] = nonNullTerms.map(({ term }) => buildQueryPayload(term)) + + const encrypted = await ffiEncryptQueryBulk(this.client, { + queries, + unverifiedContext: metadata, + }) + + return assembleResults(this.terms.length, encrypted, nonNullTerms) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + }), + ) + } +} + +/** + * @internal Use {@link ProtectClient.encryptQuery} with array input and `.withLockContext()` instead. + */ +export class BatchEncryptQueryOperationWithLockContext extends ProtectOperation { + constructor( + private client: Client, + private terms: readonly ScalarQueryTerm[], + private lockContext: LockContext, + auditMetadata?: Record, + ) { + super() + this.auditMetadata = auditMetadata + } + + public async execute(): Promise> { + logger.debug('Encrypting query terms with lock context', { count: this.terms.length }) + + if (this.terms.length === 0) { + return { data: [] } + } + + // Check for all-null terms BEFORE fetching lockContext to avoid unnecessary network call + const { nullIndices, nonNullTerms } = filterNullTerms(this.terms) + + if (nonNullTerms.length === 0) { + return { data: this.terms.map(() => null) } + } + + const lockContextResult = await this.lockContext.getLockContext() + if (lockContextResult.failure) { + return { failure: lockContextResult.failure } + } + + const { ctsToken, context } = lockContextResult.data + + return await withResult( + async () => { + if (!this.client) throw noClientError() + + const { metadata } = this.getAuditData() + + const queries: QueryPayload[] = nonNullTerms.map(({ term }) => buildQueryPayload(term, context)) + + const encrypted = await ffiEncryptQueryBulk(this.client, { + queries, + serviceToken: ctsToken, + unverifiedContext: metadata, + }) + + return assembleResults(this.terms.length, encrypted, nonNullTerms) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + }), + ) + } +} diff --git a/packages/protect/src/ffi/operations/deprecated/search-terms.ts b/packages/protect/src/ffi/operations/deprecated/search-terms.ts new file mode 100644 index 00000000..2122df22 --- /dev/null +++ b/packages/protect/src/ffi/operations/deprecated/search-terms.ts @@ -0,0 +1,120 @@ +import { type Result, withResult } from '@byteslice/result' +import { encryptQueryBulk, type QueryPayload } from '@cipherstash/protect-ffi' +import { type ProtectError, ProtectErrorTypes } from '../../..' +import { logger } from '../../../../../utils/logger' +import type { Client, EncryptedSearchTerm, SearchTerm } from '../../../types' +import { noClientError } from '../..' +import { ProtectOperation } from '../base-operation' +import { inferIndexType } from '../../helpers/infer-index-type' +import type { LockContext } from '../../../identify' + +/** + * @deprecated Use `BatchEncryptQueryOperation` instead. + * This class is maintained for backward compatibility only. + */ +export class SearchTermsOperation extends ProtectOperation { + constructor( + private client: Client, + private terms: SearchTerm[], + ) { + super() + } + + public withLockContext(lockContext: LockContext): SearchTermsOperationWithLockContext { + return new SearchTermsOperationWithLockContext(this, lockContext) + } + + public async execute(): Promise> { + logger.debug('Creating search terms (deprecated API)', { count: this.terms.length }) + + return await withResult( + async () => { + if (!this.client) throw noClientError() + + const { metadata } = this.getAuditData() + + const queries: QueryPayload[] = this.terms.map((term) => ({ + plaintext: term.value, + column: term.column.getName(), + table: term.table.tableName, + indexType: inferIndexType(term.column), + })) + + const encryptedTerms = await encryptQueryBulk(this.client, { + queries, + unverifiedContext: metadata, + }) + + return this.terms.map((term, index) => { + if (term.returnType === 'composite-literal') { + return `(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})` + } + if (term.returnType === 'escaped-composite-literal') { + return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})`)}` + } + return encryptedTerms[index] + }) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + }), + ) + } +} + +export class SearchTermsOperationWithLockContext extends ProtectOperation { + constructor( + private operation: SearchTermsOperation, + private lockContext: LockContext, + ) { + super() + this.auditMetadata = (operation as any).auditMetadata + } + + public async execute(): Promise> { + const lockContextResult = await this.lockContext.getLockContext() + if (lockContextResult.failure) { + return { failure: lockContextResult.failure } + } + + const { ctsToken, context } = lockContextResult.data + const op = (this.operation as any) + + return await withResult( + async () => { + if (!op.client) throw noClientError() + + const { metadata } = this.getAuditData() + + const queries: QueryPayload[] = op.terms.map((term: SearchTerm) => ({ + plaintext: term.value, + column: term.column.getName(), + table: term.table.tableName, + indexType: inferIndexType(term.column), + lockContext: context, + })) + + const encryptedTerms = await encryptQueryBulk(op.client, { + queries, + serviceToken: ctsToken, + unverifiedContext: metadata, + }) + + return op.terms.map((term: SearchTerm, index: number) => { + if (term.returnType === 'composite-literal') { + return `(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})` + } + if (term.returnType === 'escaped-composite-literal') { + return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})`)}` + } + return encryptedTerms[index] + }) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + }), + ) + } +} diff --git a/packages/protect/src/ffi/operations/encrypt-query.ts b/packages/protect/src/ffi/operations/encrypt-query.ts new file mode 100644 index 00000000..9b27a360 --- /dev/null +++ b/packages/protect/src/ffi/operations/encrypt-query.ts @@ -0,0 +1,145 @@ +import { type Result, withResult } from '@byteslice/result' +import { + type JsPlaintext, + encryptQuery as ffiEncryptQuery, +} from '@cipherstash/protect-ffi' +import { type ProtectError, ProtectErrorTypes } from '../..' +import { logger } from '../../../../utils/logger' +import type { LockContext } from '../../identify' +import type { Client, Encrypted, EncryptQueryOptions } from '../../types' +import { noClientError } from '../index' +import { ProtectOperation } from './base-operation' +import { resolveIndexType } from '../helpers/infer-index-type' +import { validateNumericValue, assertValueIndexCompatibility } from '../helpers/validation' + +/** + * @internal Use {@link ProtectClient.encryptQuery} instead. + */ +export class EncryptQueryOperation extends ProtectOperation { + constructor( + private client: Client, + private plaintext: JsPlaintext | null, + private opts: EncryptQueryOptions, + ) { + super() + } + + public withLockContext(lockContext: LockContext): EncryptQueryOperationWithLockContext { + return new EncryptQueryOperationWithLockContext(this.client, this.plaintext, this.opts, lockContext, this.auditMetadata) + } + + public async execute(): Promise> { + logger.debug('Encrypting query', { + column: this.opts.column.getName(), + table: this.opts.table.tableName, + queryType: this.opts.queryType, + }) + + if (this.plaintext === null) { + return { data: null } + } + + const validationError = validateNumericValue(this.plaintext) + if (validationError?.failure) { + return { failure: validationError.failure } + } + + return await withResult( + async () => { + if (!this.client) throw noClientError() + + const { metadata } = this.getAuditData() + + const indexType = resolveIndexType(this.opts.column, this.opts.queryType) + + // Validate value/index compatibility + assertValueIndexCompatibility( + this.plaintext, + indexType, + this.opts.column.getName() + ) + + return await ffiEncryptQuery(this.client, { + plaintext: this.plaintext as JsPlaintext, + column: this.opts.column.getName(), + table: this.opts.table.tableName, + indexType, + unverifiedContext: metadata, + }) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + }), + ) + } + + public getOperation() { + return { client: this.client, plaintext: this.plaintext, ...this.opts } + } +} + +/** + * @internal Use {@link ProtectClient.encryptQuery} with `.withLockContext()` instead. + */ +export class EncryptQueryOperationWithLockContext extends ProtectOperation { + constructor( + private client: Client, + private plaintext: JsPlaintext | null, + private opts: EncryptQueryOptions, + private lockContext: LockContext, + auditMetadata?: Record, + ) { + super() + this.auditMetadata = auditMetadata + } + + public async execute(): Promise> { + if (this.plaintext === null) { + return { data: null } + } + + const validationError = validateNumericValue(this.plaintext) + if (validationError?.failure) { + return { failure: validationError.failure } + } + + const lockContextResult = await this.lockContext.getLockContext() + if (lockContextResult.failure) { + return { failure: lockContextResult.failure } + } + + const { ctsToken, context } = lockContextResult.data + + return await withResult( + async () => { + if (!this.client) throw noClientError() + + const { metadata } = this.getAuditData() + + const indexType = resolveIndexType(this.opts.column, this.opts.queryType) + + // Validate value/index compatibility + assertValueIndexCompatibility( + this.plaintext, + indexType, + this.opts.column.getName() + ) + + return await ffiEncryptQuery(this.client, { + plaintext: this.plaintext as JsPlaintext, + column: this.opts.column.getName(), + table: this.opts.table.tableName, + indexType, + lockContext: context, + serviceToken: ctsToken, + unverifiedContext: metadata, + }) + }, + (error) => ({ + type: ProtectErrorTypes.EncryptionError, + message: error.message, + }), + ) + } +} diff --git a/packages/protect/src/helpers/index.ts b/packages/protect/src/helpers/index.ts index 037d27df..6af77746 100644 --- a/packages/protect/src/helpers/index.ts +++ b/packages/protect/src/helpers/index.ts @@ -1,4 +1,4 @@ -import type { KeysetIdentifier as KeysetIdentifierFfi } from '@cipherstash/protect-ffi' +import type { Encrypted as CipherStashEncrypted, KeysetIdentifier as KeysetIdentifierFfi } from '@cipherstash/protect-ffi' import type { Encrypted, KeysetIdentifier } from '../types' export type EncryptedPgComposite = { @@ -6,7 +6,8 @@ export type EncryptedPgComposite = { } /** - * Helper function to transform an encrypted payload into a PostgreSQL composite type + * Helper function to transform an encrypted payload into a PostgreSQL composite type. + * Use this when inserting data via Supabase or similar clients. */ export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite { return { @@ -14,6 +15,61 @@ export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite { } } +/** + * Helper function to transform an encrypted payload into a PostgreSQL composite literal string. + * Use this when querying with `.eq()` or similar equality operations in Supabase. + * + * @deprecated Use `encryptQuery()` with `returnType: 'composite-literal'` instead. + * @example + * ```typescript + * // Before (deprecated): + * const [encrypted] = await protectClient.encryptQuery([ + * { value: searchValue, column, table, queryType: 'equality' } + * ]) + * const literal = encryptedToCompositeLiteral(encrypted) + * await supabase.from('table').select().eq('column', literal) + * + * // After (recommended): + * const [searchTerm] = await protectClient.encryptQuery([ + * { value: searchValue, column, table, queryType: 'equality', returnType: 'composite-literal' } + * ]) + * await supabase.from('table').select().eq('column', searchTerm) + * ``` + */ +export function encryptedToCompositeLiteral(obj: CipherStashEncrypted): string { + if (obj === null) { + throw new Error('encryptedToCompositeLiteral: obj cannot be null') + } + return `(${JSON.stringify(JSON.stringify(obj))})` +} + +/** + * Helper function to transform an encrypted payload into an escaped PostgreSQL composite literal string. + * Use this when you need the composite literal format to be escaped as a string value. + * + * @deprecated Use `encryptQuery()` with `returnType: 'escaped-composite-literal'` instead. + * See also: `encryptedToCompositeLiteral` for parallel deprecation guidance. + * @example + * ```typescript + * // Before (deprecated): + * const [encrypted] = await protectClient.encryptQuery([ + * { value: searchValue, column, table, queryType: 'equality' } + * ]) + * const escapedLiteral = encryptedToEscapedCompositeLiteral(encrypted) + * + * // After (recommended): + * const [searchTerm] = await protectClient.encryptQuery([ + * { value: searchValue, column, table, queryType: 'equality', returnType: 'escaped-composite-literal' } + * ]) + * ``` + */ +export function encryptedToEscapedCompositeLiteral(obj: CipherStashEncrypted): string { + if (obj === null) { + throw new Error('encryptedToEscapedCompositeLiteral: obj cannot be null') + } + return JSON.stringify(encryptedToCompositeLiteral(obj)) +} + /** * Helper function to transform a model's encrypted fields into PostgreSQL composite types */ diff --git a/packages/protect/src/identify/index.ts b/packages/protect/src/identify/index.ts index a3c34ef7..d6bb8103 100644 --- a/packages/protect/src/identify/index.ts +++ b/packages/protect/src/identify/index.ts @@ -57,13 +57,13 @@ export class LockContext { async identify(jwtToken: string): Promise> { const workspaceId = this.workspaceId - const ctsEndoint = + const ctsEndpoint = process.env.CS_CTS_ENDPOINT || 'https://ap-southeast-2.aws.auth.viturhosted.net' const ctsFetchResult = await withResult( () => - fetch(`${ctsEndoint}/api/authorize`, { + fetch(`${ctsEndpoint}/api/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/protect/src/index.ts b/packages/protect/src/index.ts index 54d4a8d9..4f1f3ef0 100644 --- a/packages/protect/src/index.ts +++ b/packages/protect/src/index.ts @@ -99,6 +99,23 @@ export type { DecryptModelOperation } from './ffi/operations/decrypt-model' export type { EncryptModelOperation } from './ffi/operations/encrypt-model' export type { EncryptOperation } from './ffi/operations/encrypt' +// Operations +export { EncryptQueryOperation, EncryptQueryOperationWithLockContext } from './ffi/operations/encrypt-query' +export { BatchEncryptQueryOperation, BatchEncryptQueryOperationWithLockContext } from './ffi/operations/batch-encrypt-query' + +// Helpers +export { inferIndexType, validateIndexType } from './ffi/helpers/infer-index-type' + +// Types +export type { + QueryTypeName, + FfiIndexTypeName, + EncryptQueryOptions, + ScalarQueryTerm, +} from './types' + +export { queryTypes, queryTypeToFfi } from './types' + export { csTable, csColumn, csValue } from '@cipherstash/schema' export type { ProtectColumn, @@ -106,8 +123,11 @@ export type { ProtectTableColumn, ProtectValue, } from '@cipherstash/schema' +// LockContext class export (value export for instantiation) +export { LockContext } from './identify' + +// LockContext related type exports export type { - LockContext, CtsRegions, IdentifyOptions, CtsToken, diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts index 7dc15705..d7d174f4 100644 --- a/packages/protect/src/types.ts +++ b/packages/protect/src/types.ts @@ -58,6 +58,12 @@ export type KeysetIdentifier = */ export type EncryptedSearchTerm = Encrypted | string +/** + * Result type for encryptQuery batch operations. + * Can be Encrypted (default), string (for composite-literal formats), or null. + */ +export type EncryptedQueryResult = Encrypted | string | null + /** * Represents a payload to be encrypted using the `encrypt` function */ @@ -122,3 +128,66 @@ type DecryptionError = { } export type DecryptionResult = DecryptionSuccess | DecryptionError + +/** + * User-facing query type names for encrypting query values. + * + * - `'equality'`: For exact match queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/exact | Exact Queries} + * - `'freeTextSearch'`: For text search queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/match | Match Queries} + * - `'orderAndRange'`: For comparison and range queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/range | Range Queries} + */ +export type QueryTypeName = 'orderAndRange' | 'freeTextSearch' | 'equality' + +/** + * Internal FFI index type names. + * @internal + */ +export type FfiIndexTypeName = 'ore' | 'match' | 'unique' + +/** + * Query type constants for use with encryptQuery(). + */ +export const queryTypes = { + orderAndRange: 'orderAndRange', + freeTextSearch: 'freeTextSearch', + equality: 'equality', +} as const satisfies Record + +/** + * Maps user-friendly query type names to FFI index type names. + * @internal + */ +export const queryTypeToFfi: Record = { + orderAndRange: 'ore', + freeTextSearch: 'match', + equality: 'unique', +} + +/** + * Base type for query term options shared between single and bulk operations. + * @internal + */ +export type QueryTermBase = { + column: ProtectColumn + table: ProtectTable + queryType?: QueryTypeName // Optional - auto-infers if omitted + /** + * The format for the returned encrypted value: + * - `'eql'` (default) - Returns raw Encrypted object + * - `'composite-literal'` - Returns PostgreSQL composite literal format `("json")` + * - `'escaped-composite-literal'` - Returns escaped format `"(\"json\")"` + */ + returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' +} + +/** + * Options for encrypting a single query term. + */ +export type EncryptQueryOptions = QueryTermBase + +/** + * Individual query term for bulk operations. + */ +export type ScalarQueryTerm = QueryTermBase & { + value: JsPlaintext | null +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 904f13a4..7998546d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,8 +512,8 @@ importers: specifier: ^0.2.0 version: 0.2.2 '@cipherstash/protect-ffi': - specifier: 0.19.0 - version: 0.19.0 + specifier: 0.20.1 + version: 0.20.1 '@cipherstash/schema': specifier: workspace:* version: link:../schema @@ -1061,38 +1061,38 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/protect-ffi-darwin-arm64@0.19.0': - resolution: {integrity: sha512-0U/paHskpD2SCiy4T4s5Ery112n5MfT3cXudCZ0m82x03SiK5sU9SNtD2tI0tJhcUlDP9TsFUKnYEEZPFR8pUA==} + '@cipherstash/protect-ffi-darwin-arm64@0.20.1': + resolution: {integrity: sha512-2a24tijXFCbalkPqWNoIa6yjGAFvvyZJl17IcJpMU2HYICQbuKvDjA8oqOlj3JuGHlikJRjDLnLo/AWEmBeoBA==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.19.0': - resolution: {integrity: sha512-gbPomTjvBCO7eZsMLGzMVv0Al/TZQ3SOfLWCRzRdWzff3BIC+wPrqJJBbpxIb/WRG7Ak8ceRSdMkrnhQnlsYHA==} + '@cipherstash/protect-ffi-darwin-x64@0.20.1': + resolution: {integrity: sha512-BKtb+aev4x/UwiIs+cgRHj7sONGdE/GJBdoQD2s5e2ImGA4a4Q6+Bt/2ba839/wmyatTZcCiZqknjVXhvD1rYA==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.19.0': - resolution: {integrity: sha512-z4ZFJGrmxlsZM5arFyLeeiod8z5SONPYLYnQnO+HG9CH+ra2jRhCvA5qvPjF1+/7vL/zpuV+9MhVJGTt7Vo38A==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.20.1': + resolution: {integrity: sha512-AATWV+AebX2vt5TC4BujjJRbsEQsu9eMA2bXxymH3wJvvI0b1xv0GZjpdnkjxRnzAMjzZwiYxMxL7gdttb0rPA==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.19.0': - resolution: {integrity: sha512-ZD3YSzGdgtN7Elsp4rKGBREvbhsYNIt5ywnme8JEgVID7UFENQK5WmsLr20ZbMT1C37TaMRS5ZuIS+loSZvE5Q==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.20.1': + resolution: {integrity: sha512-O13Hq4bcb/arorfO60ohHR+5zX/aXEtGteynb8z0Gop7dXpAdbOLm49QaGrCGwvuAZ4TWVnjp0DyzM+XFcvkPQ==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.19.0': - resolution: {integrity: sha512-dngMn6EP2016fwJMg8yeZiJJ/lDOiZ5lkA8fMrVxkr/pv6t7x8m1pdbh4TuLA4OSozm2MLXFu/SZInPwdWZu/w==} + '@cipherstash/protect-ffi-linux-x64-musl@0.20.1': + resolution: {integrity: sha512-tTa2fPToDseikYCf1FRuDj1fHVtpjeRFUioP8LYmFRA2g4r4OaHqNcQpx8NMFuTtnbCIllxTyEaTMZ09YLbHxQ==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.19.0': - resolution: {integrity: sha512-A0WaKj+8WtO+synaMUbOy4a34/s7urJemXj5nC/8EKS8ppGcAJR5pZqV4+RV57j0pQSSR52BAvAenuQEGyKZPA==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.20.1': + resolution: {integrity: sha512-+EmjUzUr9AcFUWaAFdxwv2LCdG7X079Pwotx+D+kIFHfWPtHoVQfKpPHjSnLATEdcgVnGkNAgkpci0rgerf1ng==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.19.0': - resolution: {integrity: sha512-UfPwO2axmi4O18Wwv87wDg1aGU1RHIEZoWtb/nEYWQgXDOhYtKmWcKQic0MMednBeHAF972pNsrw9Dxhs0ZxXw==} + '@cipherstash/protect-ffi@0.20.1': + resolution: {integrity: sha512-bq+e6XRCSB9km8KTLwGAZaP2N12J6WeHTrb0kfUdlIeYeJR/Lexmb9ho4LNUUiEsJ/tCRFOWgjeC44arFYmaUA==} '@clerk/backend@2.28.0': resolution: {integrity: sha512-rd0hWrU7VES/CEYwnyaXDDHzDXYIaSzI5G03KLUfxLyOQSChU0ZUeViDYyXEsjZgAQqiUP1TFykh9JU2YlaNYg==} @@ -7436,34 +7436,34 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@cipherstash/protect-ffi-darwin-arm64@0.19.0': + '@cipherstash/protect-ffi-darwin-arm64@0.20.1': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.19.0': + '@cipherstash/protect-ffi-darwin-x64@0.20.1': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.19.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.20.1': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.19.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.20.1': optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.19.0': + '@cipherstash/protect-ffi-linux-x64-musl@0.20.1': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.19.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.20.1': optional: true - '@cipherstash/protect-ffi@0.19.0': + '@cipherstash/protect-ffi@0.20.1': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.19.0 - '@cipherstash/protect-ffi-darwin-x64': 0.19.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.19.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.19.0 - '@cipherstash/protect-ffi-linux-x64-musl': 0.19.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.19.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.20.1 + '@cipherstash/protect-ffi-darwin-x64': 0.20.1 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.20.1 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.20.1 + '@cipherstash/protect-ffi-linux-x64-musl': 0.20.1 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.20.1 '@clerk/backend@2.28.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: