From 7ccb9e5cdd156f0680d6c7ed0ce97c89787f0b3b Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Thu, 2 Oct 2025 19:01:10 -0600 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20searchable=20JS?= =?UTF-8?q?ON=20encryption=20and=20update=20FFI=20to=20v0.17.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add searchableJson() method to schema for JSON field indexing - Update @cipherstash/protect-ffi from 0.16.0 to 0.17.0 - Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext - Add comprehensive test suites for JSON, integer, and basic encryption - Update encryption format to use 'k' property for searchable JSON - Remove deprecated search terms tests for JSON fields - Simplify schema data types to text, int, jsonb only - Update model helpers to handle new encryption format - Fix type safety issues in bulk operations and model encryption --- .../protect/__tests__/basic-protect.test.ts | 44 + .../protect/__tests__/bulk-protect.test.ts | 47 - packages/protect/__tests__/debug.test.ts | 39 + .../protect/__tests__/int-protect.test.ts | 1050 ++++++++++++++ .../protect/__tests__/json-protect.test.ts | 1217 +++++++++++++++++ .../{protect.test.ts => protect-ops.test.ts} | 24 +- packages/protect/__tests__/supabase.test.ts | 4 +- packages/protect/package.json | 2 +- packages/protect/src/ffi/index.ts | 12 +- packages/protect/src/ffi/model-helpers.ts | 45 +- .../src/ffi/operations/bulk-decrypt.ts | 5 +- .../src/ffi/operations/bulk-encrypt.ts | 8 +- .../protect/src/ffi/operations/decrypt.ts | 27 +- .../protect/src/ffi/operations/encrypt.ts | 30 +- packages/protect/src/helpers/index.ts | 15 +- packages/protect/src/types.ts | 37 +- packages/schema/src/index.ts | 16 +- pnpm-lock.yaml | 50 +- 18 files changed, 2486 insertions(+), 186 deletions(-) create mode 100644 packages/protect/__tests__/basic-protect.test.ts create mode 100644 packages/protect/__tests__/debug.test.ts create mode 100644 packages/protect/__tests__/int-protect.test.ts create mode 100644 packages/protect/__tests__/json-protect.test.ts rename packages/protect/__tests__/{protect.test.ts => protect-ops.test.ts} (97%) diff --git a/packages/protect/__tests__/basic-protect.test.ts b/packages/protect/__tests__/basic-protect.test.ts new file mode 100644 index 0000000..a6ae0ab --- /dev/null +++ b/packages/protect/__tests__/basic-protect.test.ts @@ -0,0 +1,44 @@ +import 'dotenv/config' +import { csColumn, csTable } from '@cipherstash/schema' +import { beforeAll, describe, expect, it } from 'vitest' +import { protect } from '../src' + +const users = csTable('users', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + address: csColumn('address').freeTextSearch(), + json: csColumn('json').dataType('jsonb'), +}) + +let protectClient: Awaited> + +beforeAll(async () => { + protectClient = await protect({ + schemas: [users], + }) +}) + +describe('encryption and decryption', () => { + it('should encrypt and decrypt a payload', async () => { + const email = 'hello@example.com' + + const ciphertext = await protectClient.encrypt(email, { + column: users.email, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const a = ciphertext.data + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: email, + }) + }, 30000) +}) diff --git a/packages/protect/__tests__/bulk-protect.test.ts b/packages/protect/__tests__/bulk-protect.test.ts index d021731..893bea8 100644 --- a/packages/protect/__tests__/bulk-protect.test.ts +++ b/packages/protect/__tests__/bulk-protect.test.ts @@ -326,53 +326,6 @@ describe('bulk encryption and decryption', () => { expect(decryptedData.data).toHaveLength(0) }, 30000) - - it('should handle encrypted payloads with only "c" key', async () => { - // First encrypt some data - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Remove all keys except "c" from each encrypted payload - const modifiedEncryptedData = encryptedData.data.map((item) => ({ - id: item.id, - data: { - c: item.data?.c, - } as EncryptedPayload, - })) - - // Now decrypt the modified data - const decryptedData = await protectClient.bulkDecrypt( - modifiedEncryptedData, - ) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) }) describe('bulk operations with lock context', () => { diff --git a/packages/protect/__tests__/debug.test.ts b/packages/protect/__tests__/debug.test.ts new file mode 100644 index 0000000..95f0aad --- /dev/null +++ b/packages/protect/__tests__/debug.test.ts @@ -0,0 +1,39 @@ +import 'dotenv/config' +import { csColumn, csTable, csValue } from '@cipherstash/schema' +import { beforeAll, describe, expect, it } from 'vitest' +import { protect } from '../src' + +const users = csTable('users', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + json: csColumn('json').dataType('jsonb'), + jsonSearchable: csColumn('jsonSearchable') + .dataType('jsonb') + .searchableJson('users/jsonSearchable'), +}) + +type User = { + id: string + email?: string | null + createdAt?: Date + updatedAt?: Date + address?: string | null + json?: Record | null + metadata?: { + profile?: Record | null + settings?: { + preferences?: Record | null + } + } +} + +let protectClient: Awaited> + +const test = false + +beforeAll(async () => { + protectClient = await protect({ + schemas: [users], + }) +}) + +describe('debugging', () => {}) diff --git a/packages/protect/__tests__/int-protect.test.ts b/packages/protect/__tests__/int-protect.test.ts new file mode 100644 index 0000000..2b4e611 --- /dev/null +++ b/packages/protect/__tests__/int-protect.test.ts @@ -0,0 +1,1050 @@ +import 'dotenv/config' +import { csColumn, csTable, csValue } from '@cipherstash/schema' +import { beforeAll, describe, expect, it } from 'vitest' +import { LockContext, protect } from '../src' + +const users = csTable('users', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + address: csColumn('address').freeTextSearch(), + age: csColumn('age').dataType('int').equality().orderAndRange(), + score: csColumn('score').dataType('int').equality().orderAndRange(), + metadata: { + count: csValue('metadata.count').dataType('int'), + level: csValue('metadata.level').dataType('int'), + }, +}) + +type User = { + id: string + email?: string | null + createdAt?: Date + updatedAt?: Date + address?: string | null + age?: number | null + score?: number | null + metadata?: { + count?: number | null + level?: number | null + } +} + +let protectClient: Awaited> + +beforeAll(async () => { + protectClient = await protect({ + schemas: [users], + }) +}) + +describe('Integer encryption and decryption', () => { + it('should encrypt and decrypt a simple integer', async () => { + const age = 25 + + const ciphertext = await protectClient.encrypt(age, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: age, + }) + }, 30000) + + it('should encrypt and decrypt zero', async () => { + const score = 0 + + const ciphertext = await protectClient.encrypt(score, { + column: users.score, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: score, + }) + }, 30000) + + it('should encrypt and decrypt negative integers', async () => { + const temperature = -42 + + const ciphertext = await protectClient.encrypt(temperature, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: temperature, + }) + }, 30000) + + it('should encrypt and decrypt large integers', async () => { + const largeNumber = 2147483647 // Max 32-bit signed integer + + const ciphertext = await protectClient.encrypt(largeNumber, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: largeNumber, + }) + }, 30000) + + it('should encrypt and decrypt very large integers', async () => { + const veryLargeNumber = 9007199254740991 // Max safe integer in JavaScript + + const ciphertext = await protectClient.encrypt(veryLargeNumber, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: veryLargeNumber, + }) + }, 30000) + + it('should handle null integer', async () => { + const ciphertext = await protectClient.encrypt(null, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify null is preserved + expect(ciphertext.data).toBeNull() + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: null, + }) + }, 30000) +}) + +describe('Integer model encryption and decryption', () => { + it('should encrypt and decrypt a model with integer fields', async () => { + const decryptedModel = { + id: '1', + email: 'test@example.com', + address: '123 Main St', + age: 30, + score: 95, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.address).toHaveProperty('c') + expect(encryptedModel.data.age).toHaveProperty('c') + expect(encryptedModel.data.score).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle null integers in model', async () => { + const decryptedModel = { + id: '2', + email: 'test2@example.com', + address: '456 Oak St', + age: null, + score: null, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.address).toHaveProperty('c') + expect(encryptedModel.data.age).toBeNull() + expect(encryptedModel.data.score).toBeNull() + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle undefined integers in model', async () => { + const decryptedModel = { + id: '3', + email: 'test3@example.com', + address: '789 Pine St', + age: undefined, + score: undefined, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.address).toHaveProperty('c') + expect(encryptedModel.data.age).toBeUndefined() + expect(encryptedModel.data.score).toBeUndefined() + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) +}) + +describe('Integer bulk encryption and decryption', () => { + it('should bulk encrypt and decrypt integer payloads', async () => { + const intPayloads = [ + { id: 'user1', plaintext: 25 }, + { id: 'user2', plaintext: 30 }, + { id: 'user3', plaintext: 35 }, + ] + + const encryptedData = await protectClient.bulkEncrypt(intPayloads, { + column: users.age, + table: users, + }) + + if (encryptedData.failure) { + throw new Error(`[protect]: ${encryptedData.failure.message}`) + } + + // Verify structure + expect(encryptedData.data).toHaveLength(3) + expect(encryptedData.data[0]).toHaveProperty('id', 'user1') + expect(encryptedData.data[0]).toHaveProperty('data') + expect(encryptedData.data[0].data).toHaveProperty('c') + expect(encryptedData.data[1]).toHaveProperty('id', 'user2') + expect(encryptedData.data[1]).toHaveProperty('data') + expect(encryptedData.data[1].data).toHaveProperty('c') + expect(encryptedData.data[2]).toHaveProperty('id', 'user3') + expect(encryptedData.data[2]).toHaveProperty('data') + expect(encryptedData.data[2].data).toHaveProperty('c') + + // Verify all encrypted values are different + expect(encryptedData.data[0].data?.c).not.toBe( + encryptedData.data[1].data?.c, + ) + expect(encryptedData.data[1].data?.c).not.toBe( + encryptedData.data[2].data?.c, + ) + expect(encryptedData.data[0].data?.c).not.toBe( + encryptedData.data[2].data?.c, + ) + + // Now decrypt the data + const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) + + if (decryptedData.failure) { + throw new Error(`[protect]: ${decryptedData.failure.message}`) + } + + // Verify decrypted data + expect(decryptedData.data).toHaveLength(3) + expect(decryptedData.data[0]).toHaveProperty('id', 'user1') + expect(decryptedData.data[0]).toHaveProperty('data', 25) + expect(decryptedData.data[1]).toHaveProperty('id', 'user2') + expect(decryptedData.data[1]).toHaveProperty('data', 30) + expect(decryptedData.data[2]).toHaveProperty('id', 'user3') + expect(decryptedData.data[2]).toHaveProperty('data', 35) + }, 30000) + + it('should handle mixed null and non-null integers in bulk operations', async () => { + const intPayloads = [ + { id: 'user1', plaintext: 25 }, + { id: 'user2', plaintext: null }, + { id: 'user3', plaintext: 35 }, + ] + + const encryptedData = await protectClient.bulkEncrypt(intPayloads, { + column: users.age, + table: users, + }) + + if (encryptedData.failure) { + throw new Error(`[protect]: ${encryptedData.failure.message}`) + } + + // Verify structure + expect(encryptedData.data).toHaveLength(3) + expect(encryptedData.data[0]).toHaveProperty('id', 'user1') + expect(encryptedData.data[0]).toHaveProperty('data') + expect(encryptedData.data[0].data).toHaveProperty('c') + expect(encryptedData.data[1]).toHaveProperty('id', 'user2') + expect(encryptedData.data[1]).toHaveProperty('data') + expect(encryptedData.data[1].data).toBeNull() + expect(encryptedData.data[2]).toHaveProperty('id', 'user3') + expect(encryptedData.data[2]).toHaveProperty('data') + expect(encryptedData.data[2].data).toHaveProperty('c') + + // Now decrypt the data + const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) + + if (decryptedData.failure) { + throw new Error(`[protect]: ${decryptedData.failure.message}`) + } + + // Verify decrypted data + expect(decryptedData.data).toHaveLength(3) + expect(decryptedData.data[0]).toHaveProperty('id', 'user1') + expect(decryptedData.data[0]).toHaveProperty('data', 25) + expect(decryptedData.data[1]).toHaveProperty('id', 'user2') + expect(decryptedData.data[1]).toHaveProperty('data', null) + expect(decryptedData.data[2]).toHaveProperty('id', 'user3') + expect(decryptedData.data[2]).toHaveProperty('data', 35) + }, 30000) + + it('should bulk encrypt and decrypt models with integer fields', async () => { + const decryptedModels = [ + { + id: '1', + email: 'test1@example.com', + address: '123 Main St', + age: 25, + score: 85, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + }, + { + id: '2', + email: 'test2@example.com', + address: '456 Oak St', + age: 30, + score: 90, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + // Verify encrypted fields for each model + expect(encryptedModels.data[0].email).toHaveProperty('c') + expect(encryptedModels.data[0].address).toHaveProperty('c') + expect(encryptedModels.data[0].age).toHaveProperty('c') + expect(encryptedModels.data[0].score).toHaveProperty('c') + expect(encryptedModels.data[1].email).toHaveProperty('c') + expect(encryptedModels.data[1].address).toHaveProperty('c') + expect(encryptedModels.data[1].age).toHaveProperty('c') + expect(encryptedModels.data[1].score).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) + + const decryptedResult = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModels) + }, 30000) +}) + +describe('Integer encryption with lock context', () => { + it('should encrypt and decrypt integer with lock context', async () => { + const userJwt = process.env.USER_JWT + + if (!userJwt) { + console.log('Skipping lock context test - no USER_JWT provided') + return + } + + const lc = new LockContext() + const lockContext = await lc.identify(userJwt) + + if (lockContext.failure) { + throw new Error(`[protect]: ${lockContext.failure.message}`) + } + + const age = 42 + + const ciphertext = await protectClient + .encrypt(age, { + column: users.age, + table: users, + }) + .withLockContext(lockContext.data) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient + .decrypt(ciphertext.data) + .withLockContext(lockContext.data) + + if (plaintext.failure) { + throw new Error(`[protect]: ${plaintext.failure.message}`) + } + + expect(plaintext.data).toEqual(age) + }, 30000) + + it('should encrypt integer model with lock context', async () => { + const userJwt = process.env.USER_JWT + + if (!userJwt) { + console.log('Skipping lock context test - no USER_JWT provided') + return + } + + const lc = new LockContext() + const lockContext = await lc.identify(userJwt) + + if (lockContext.failure) { + throw new Error(`[protect]: ${lockContext.failure.message}`) + } + + const decryptedModel = { + id: '1', + email: 'test@example.com', + age: 30, + score: 95, + } + + const encryptedModel = await protectClient + .encryptModel(decryptedModel, users) + .withLockContext(lockContext.data) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.age).toHaveProperty('c') + expect(encryptedModel.data.score).toHaveProperty('c') + + const decryptedResult = await protectClient + .decryptModel(encryptedModel.data) + .withLockContext(lockContext.data) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should bulk encrypt integers with lock context', async () => { + const userJwt = process.env.USER_JWT + + if (!userJwt) { + console.log('Skipping lock context test - no USER_JWT provided') + return + } + + const lc = new LockContext() + const lockContext = await lc.identify(userJwt) + + if (lockContext.failure) { + throw new Error(`[protect]: ${lockContext.failure.message}`) + } + + const intPayloads = [ + { id: 'user1', plaintext: 25 }, + { id: 'user2', plaintext: 30 }, + ] + + const encryptedData = await protectClient + .bulkEncrypt(intPayloads, { + column: users.age, + table: users, + }) + .withLockContext(lockContext.data) + + if (encryptedData.failure) { + throw new Error(`[protect]: ${encryptedData.failure.message}`) + } + + // Verify structure + expect(encryptedData.data).toHaveLength(2) + expect(encryptedData.data[0]).toHaveProperty('id', 'user1') + expect(encryptedData.data[0]).toHaveProperty('data') + expect(encryptedData.data[0].data).toHaveProperty('c') + expect(encryptedData.data[1]).toHaveProperty('id', 'user2') + expect(encryptedData.data[1]).toHaveProperty('data') + expect(encryptedData.data[1].data).toHaveProperty('c') + + // Decrypt with lock context + const decryptedData = await protectClient + .bulkDecrypt(encryptedData.data) + .withLockContext(lockContext.data) + + if (decryptedData.failure) { + throw new Error(`[protect]: ${decryptedData.failure.message}`) + } + + // Verify decrypted data + expect(decryptedData.data).toHaveLength(2) + expect(decryptedData.data[0]).toHaveProperty('id', 'user1') + expect(decryptedData.data[0]).toHaveProperty('data', 25) + expect(decryptedData.data[1]).toHaveProperty('id', 'user2') + expect(decryptedData.data[1]).toHaveProperty('data', 30) + }, 30000) +}) + +describe('Integer nested object encryption', () => { + it('should encrypt and decrypt nested integer objects', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '1', + email: 'test@example.com', + metadata: { + count: 100, + level: 5, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.metadata?.count).toHaveProperty('c') + expect(encryptedModel.data.metadata?.level).toHaveProperty('c') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle null values in nested integer objects', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '2', + email: 'test2@example.com', + metadata: { + count: null, + level: null, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify null fields are preserved + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.metadata?.count).toBeNull() + expect(encryptedModel.data.metadata?.level).toBeNull() + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle undefined values in nested integer objects', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '3', + email: 'test3@example.com', + metadata: { + count: undefined, + level: undefined, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify undefined fields are preserved + expect(encryptedModel.data.email).toHaveProperty('c') + expect(encryptedModel.data.metadata?.count).toBeUndefined() + expect(encryptedModel.data.metadata?.level).toBeUndefined() + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) +}) + +describe('Integer search terms', () => { + it('should create search terms for integer 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 integers', 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 integers', async () => { + 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) +}) + +describe('Integer performance tests', () => { + it('should handle large numbers of integers efficiently', async () => { + const largeIntArray = Array.from({ length: 100 }, (_, i) => ({ + id: i, + data: { + age: i + 18, // Ages 18-117 + score: (i % 100) + 1, // Scores 1-100 + }, + })) + + const intPayloads = largeIntArray.map((item, index) => ({ + id: `user${index}`, + plaintext: item.data.age, + })) + + const encryptedData = await protectClient.bulkEncrypt(intPayloads, { + column: users.age, + table: users, + }) + + if (encryptedData.failure) { + throw new Error(`[protect]: ${encryptedData.failure.message}`) + } + + // Verify structure + expect(encryptedData.data).toHaveLength(100) + + // Decrypt the data + const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) + + if (decryptedData.failure) { + throw new Error(`[protect]: ${decryptedData.failure.message}`) + } + + // Verify all data is preserved + expect(decryptedData.data).toHaveLength(100) + + for (let i = 0; i < 100; i++) { + expect(decryptedData.data[i].id).toBe(`user${i}`) + expect(decryptedData.data[i].data).toEqual(largeIntArray[i].data.age) + } + }, 60000) +}) + +describe('Integer advanced scenarios', () => { + it('should handle boundary values', async () => { + const boundaryValues = [ + Number.MIN_SAFE_INTEGER, + -2147483648, // Min 32-bit signed integer + -1, + 0, + 1, + 2147483647, // Max 32-bit signed integer + Number.MAX_SAFE_INTEGER, + ] + + for (const value of boundaryValues) { + const ciphertext = await protectClient.encrypt(value, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: value, + }) + } + }, 30000) + + it('should handle consecutive integers', async () => { + const consecutiveInts = Array.from({ length: 10 }, (_, i) => i + 1) + + for (const value of consecutiveInts) { + const ciphertext = await protectClient.encrypt(value, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: value, + }) + } + }, 30000) + + it('should handle random integers', async () => { + const randomInts = Array.from( + { length: 20 }, + () => Math.floor(Math.random() * 10000) - 5000, + ) + + for (const value of randomInts) { + const ciphertext = await protectClient.encrypt(value, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: value, + }) + } + }, 30000) + + it('should handle mixed positive and negative integers', async () => { + const mixedInts = [-100, -50, -1, 0, 1, 50, 100] + + for (const value of mixedInts) { + const ciphertext = await protectClient.encrypt(value, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: value, + }) + } + }, 30000) +}) + +describe('Integer error handling and edge cases', () => { + it('should handle floating point numbers (should be truncated)', async () => { + const floatValue = 42.7 + + const ciphertext = await protectClient.encrypt(floatValue, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + // Floating point numbers are preserved as-is (not truncated) + expect(plaintext).toEqual({ + data: floatValue, + }) + }, 30000) + + it('should handle very large numbers (should be handled appropriately)', async () => { + const veryLargeNumber = 1e15 + + const ciphertext = await protectClient.encrypt(veryLargeNumber, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: veryLargeNumber, + }) + }, 30000) + + it('should handle string numbers (should be converted)', async () => { + // Note: This test might fail if the library doesn't handle string conversion + // Remove this test if string conversion is not supported + const stringNumber = '42' + + try { + const ciphertext = await protectClient.encrypt(stringNumber, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + // String should be converted to number + expect(plaintext).toEqual({ + data: Number(stringNumber), + }) + } catch (error) { + // If string conversion is not supported, that's also acceptable + expect(error).toBeDefined() + } + }, 30000) + + it('should handle all integer edge cases', async () => { + const edgeCases = [ + Number.MIN_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + 0, + 1, + -1, + Number.MAX_VALUE, + Number.MIN_VALUE, + ] + + for (const value of edgeCases) { + const ciphertext = await protectClient.encrypt(value, { + column: users.age, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('c') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: value, + }) + } + }, 30000) +}) diff --git a/packages/protect/__tests__/json-protect.test.ts b/packages/protect/__tests__/json-protect.test.ts new file mode 100644 index 0000000..c5b2795 --- /dev/null +++ b/packages/protect/__tests__/json-protect.test.ts @@ -0,0 +1,1217 @@ +import 'dotenv/config' +import { csColumn, csTable, csValue } from '@cipherstash/schema' +import { beforeAll, describe, expect, it } from 'vitest' +import { LockContext, protect } from '../src' + +const users = csTable('users', { + email: csColumn('email').freeTextSearch().equality().orderAndRange(), + address: csColumn('address').freeTextSearch(), + json: csColumn('json').dataType('jsonb').searchableJson('users/json'), + metadata: { + profile: csValue('metadata.profile'), + settings: { + preferences: csValue('metadata.settings.preferences'), + }, + }, +}) + +type User = { + id: string + email?: string | null + createdAt?: Date + updatedAt?: Date + address?: string | null + json?: Record | null + metadata?: { + profile?: Record | null + settings?: { + preferences?: Record | null + } + } +} + +let protectClient: Awaited> + +beforeAll(async () => { + protectClient = await protect({ + schemas: [users], + }) +}) + +describe('JSON encryption and decryption', () => { + it('should encrypt and decrypt a simple JSON payload', async () => { + const json = { + name: 'John Doe', + age: 30, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should encrypt and decrypt a complex JSON payload', async () => { + const json = { + user: { + id: 123, + name: 'Jane Smith', + email: 'jane@example.com', + preferences: { + theme: 'dark', + notifications: true, + language: 'en-US', + }, + tags: ['premium', 'verified'], + metadata: { + created: '2023-01-01T00:00:00Z', + lastLogin: '2023-12-01T10:30:00Z', + }, + }, + settings: { + privacy: { + public: false, + shareData: true, + }, + features: { + beta: true, + experimental: false, + }, + }, + array: [1, 2, 3, { nested: 'value' }], + nullValue: null, + booleanValue: true, + numberValue: 42.5, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should handle null JSON payload', async () => { + const ciphertext = await protectClient.encrypt(null, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify null is preserved + expect(ciphertext.data).toBeNull() + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: null, + }) + }, 30000) + + it('should handle empty JSON object', async () => { + const json = {} + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should handle JSON with special characters', async () => { + const json = { + message: 'Hello "world" with \'quotes\' and \\backslashes\\', + unicode: '🚀 emoji and ñ special chars', + symbols: '!@#$%^&*()_+-=[]{}|;:,.<>?/~`', + multiline: 'Line 1\nLine 2\tTabbed', + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) +}) + +describe('JSON model encryption and decryption', () => { + it('should encrypt and decrypt a model with JSON field', async () => { + const decryptedModel = { + id: '1', + email: 'test@example.com', + address: '123 Main St', + json: { + name: 'John Doe', + age: 30, + preferences: { + theme: 'dark', + notifications: true, + }, + }, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('k') + expect(encryptedModel.data.address).toHaveProperty('k') + expect(encryptedModel.data.json).toHaveProperty('k') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle null JSON in model', async () => { + const decryptedModel = { + id: '2', + email: 'test2@example.com', + address: '456 Oak St', + json: null, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('k') + expect(encryptedModel.data.address).toHaveProperty('k') + expect(encryptedModel.data.json).toBeNull() + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle undefined JSON in model', async () => { + const decryptedModel = { + id: '3', + email: 'test3@example.com', + address: '789 Pine St', + json: undefined, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('k') + expect(encryptedModel.data.address).toHaveProperty('k') + expect(encryptedModel.data.json).toBeUndefined() + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) +}) + +describe('JSON bulk encryption and decryption', () => { + it('should bulk encrypt and decrypt JSON payloads', async () => { + const jsonPayloads = [ + { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, + { id: 'user2', plaintext: { name: 'Bob', age: 30 } }, + { id: 'user3', plaintext: { name: 'Charlie', age: 35 } }, + ] + + const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { + column: users.json, + table: users, + }) + + if (encryptedData.failure) { + throw new Error(`[protect]: ${encryptedData.failure.message}`) + } + + // Verify structure + expect(encryptedData.data).toHaveLength(3) + expect(encryptedData.data[0]).toHaveProperty('id', 'user1') + expect(encryptedData.data[0]).toHaveProperty('data') + expect(encryptedData.data[0].data).toHaveProperty('k') + expect(encryptedData.data[1]).toHaveProperty('id', 'user2') + expect(encryptedData.data[1]).toHaveProperty('data') + expect(encryptedData.data[1].data).toHaveProperty('k') + expect(encryptedData.data[2]).toHaveProperty('id', 'user3') + expect(encryptedData.data[2]).toHaveProperty('data') + expect(encryptedData.data[2].data).toHaveProperty('k') + + // Now decrypt the data + const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) + + if (decryptedData.failure) { + throw new Error(`[protect]: ${decryptedData.failure.message}`) + } + + // Verify decrypted data + expect(decryptedData.data).toHaveLength(3) + expect(decryptedData.data[0]).toHaveProperty('id', 'user1') + expect(decryptedData.data[0]).toHaveProperty('data', { + name: 'Alice', + age: 25, + }) + expect(decryptedData.data[1]).toHaveProperty('id', 'user2') + expect(decryptedData.data[1]).toHaveProperty('data', { + name: 'Bob', + age: 30, + }) + expect(decryptedData.data[2]).toHaveProperty('id', 'user3') + expect(decryptedData.data[2]).toHaveProperty('data', { + name: 'Charlie', + age: 35, + }) + }, 30000) + + it('should handle mixed null and non-null JSON in bulk operations', async () => { + const jsonPayloads = [ + { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, + { id: 'user2', plaintext: null }, + { id: 'user3', plaintext: { name: 'Charlie', age: 35 } }, + ] + + const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { + column: users.json, + table: users, + }) + + if (encryptedData.failure) { + throw new Error(`[protect]: ${encryptedData.failure.message}`) + } + + // Verify structure + expect(encryptedData.data).toHaveLength(3) + expect(encryptedData.data[0]).toHaveProperty('id', 'user1') + expect(encryptedData.data[0]).toHaveProperty('data') + expect(encryptedData.data[0].data).toHaveProperty('k') + expect(encryptedData.data[1]).toHaveProperty('id', 'user2') + expect(encryptedData.data[1]).toHaveProperty('data') + expect(encryptedData.data[1].data).toBeNull() + expect(encryptedData.data[2]).toHaveProperty('id', 'user3') + expect(encryptedData.data[2]).toHaveProperty('data') + expect(encryptedData.data[2].data).toHaveProperty('k') + + // Now decrypt the data + const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) + + if (decryptedData.failure) { + throw new Error(`[protect]: ${decryptedData.failure.message}`) + } + + // Verify decrypted data + expect(decryptedData.data).toHaveLength(3) + expect(decryptedData.data[0]).toHaveProperty('id', 'user1') + expect(decryptedData.data[0]).toHaveProperty('data', { + name: 'Alice', + age: 25, + }) + expect(decryptedData.data[1]).toHaveProperty('id', 'user2') + expect(decryptedData.data[1]).toHaveProperty('data', null) + expect(decryptedData.data[2]).toHaveProperty('id', 'user3') + expect(decryptedData.data[2]).toHaveProperty('data', { + name: 'Charlie', + age: 35, + }) + }, 30000) + + it('should bulk encrypt and decrypt models with JSON fields', async () => { + const decryptedModels = [ + { + id: '1', + email: 'test1@example.com', + address: '123 Main St', + json: { + name: 'Alice', + preferences: { theme: 'dark' }, + }, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + }, + { + id: '2', + email: 'test2@example.com', + address: '456 Oak St', + json: { + name: 'Bob', + preferences: { theme: 'light' }, + }, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels( + decryptedModels, + users, + ) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + // Verify encrypted fields for each model + expect(encryptedModels.data[0].email).toHaveProperty('k') + expect(encryptedModels.data[0].address).toHaveProperty('k') + expect(encryptedModels.data[0].json).toHaveProperty('k') + expect(encryptedModels.data[1].email).toHaveProperty('k') + expect(encryptedModels.data[1].address).toHaveProperty('k') + expect(encryptedModels.data[1].json).toHaveProperty('k') + + // Verify non-encrypted fields remain unchanged + expect(encryptedModels.data[0].id).toBe('1') + expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].id).toBe('2') + expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) + expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) + + const decryptedResult = await protectClient.bulkDecryptModels( + encryptedModels.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModels) + }, 30000) +}) + +describe('JSON encryption with lock context', () => { + it('should encrypt and decrypt JSON with lock context', async () => { + const userJwt = process.env.USER_JWT + + if (!userJwt) { + console.log('Skipping lock context test - no USER_JWT provided') + return + } + + const lc = new LockContext() + const lockContext = await lc.identify(userJwt) + + if (lockContext.failure) { + throw new Error(`[protect]: ${lockContext.failure.message}`) + } + + const json = { + name: 'John Doe', + age: 30, + preferences: { + theme: 'dark', + notifications: true, + }, + } + + const ciphertext = await protectClient + .encrypt(json, { + column: users.json, + table: users, + }) + .withLockContext(lockContext.data) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient + .decrypt(ciphertext.data) + .withLockContext(lockContext.data) + + if (plaintext.failure) { + throw new Error(`[protect]: ${plaintext.failure.message}`) + } + + expect(plaintext.data).toEqual(json) + }, 30000) + + it('should encrypt JSON model with lock context', async () => { + const userJwt = process.env.USER_JWT + + if (!userJwt) { + console.log('Skipping lock context test - no USER_JWT provided') + return + } + + const lc = new LockContext() + const lockContext = await lc.identify(userJwt) + + if (lockContext.failure) { + throw new Error(`[protect]: ${lockContext.failure.message}`) + } + + const decryptedModel = { + id: '1', + email: 'test@example.com', + json: { + name: 'John Doe', + preferences: { theme: 'dark' }, + }, + } + + const encryptedModel = await protectClient + .encryptModel(decryptedModel, users) + .withLockContext(lockContext.data) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('k') + expect(encryptedModel.data.json).toHaveProperty('k') + + const decryptedResult = await protectClient + .decryptModel(encryptedModel.data) + .withLockContext(lockContext.data) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should bulk encrypt JSON with lock context', async () => { + const userJwt = process.env.USER_JWT + + if (!userJwt) { + console.log('Skipping lock context test - no USER_JWT provided') + return + } + + const lc = new LockContext() + const lockContext = await lc.identify(userJwt) + + if (lockContext.failure) { + throw new Error(`[protect]: ${lockContext.failure.message}`) + } + + const jsonPayloads = [ + { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, + { id: 'user2', plaintext: { name: 'Bob', age: 30 } }, + ] + + const encryptedData = await protectClient + .bulkEncrypt(jsonPayloads, { + column: users.json, + table: users, + }) + .withLockContext(lockContext.data) + + if (encryptedData.failure) { + throw new Error(`[protect]: ${encryptedData.failure.message}`) + } + + // Verify structure + expect(encryptedData.data).toHaveLength(2) + expect(encryptedData.data[0]).toHaveProperty('id', 'user1') + expect(encryptedData.data[0]).toHaveProperty('data') + expect(encryptedData.data[0].data).toHaveProperty('k') + expect(encryptedData.data[1]).toHaveProperty('id', 'user2') + expect(encryptedData.data[1]).toHaveProperty('data') + expect(encryptedData.data[1].data).toHaveProperty('k') + + // Decrypt with lock context + const decryptedData = await protectClient + .bulkDecrypt(encryptedData.data) + .withLockContext(lockContext.data) + + if (decryptedData.failure) { + throw new Error(`[protect]: ${decryptedData.failure.message}`) + } + + // Verify decrypted data + expect(decryptedData.data).toHaveLength(2) + expect(decryptedData.data[0]).toHaveProperty('id', 'user1') + expect(decryptedData.data[0]).toHaveProperty('data', { + name: 'Alice', + age: 25, + }) + expect(decryptedData.data[1]).toHaveProperty('id', 'user2') + expect(decryptedData.data[1]).toHaveProperty('data', { + name: 'Bob', + age: 30, + }) + }, 30000) +}) + +describe('JSON nested object encryption', () => { + it('should encrypt and decrypt nested JSON objects', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '1', + email: 'test@example.com', + metadata: { + profile: { + name: 'John Doe', + age: 30, + preferences: { + theme: 'dark', + notifications: true, + }, + }, + settings: { + preferences: { + language: 'en-US', + timezone: 'UTC', + }, + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify encrypted fields + expect(encryptedModel.data.email).toHaveProperty('k') + expect(encryptedModel.data.metadata?.profile).toHaveProperty('k') + expect(encryptedModel.data.metadata?.settings?.preferences).toHaveProperty( + 'c', + ) + + // Verify non-encrypted fields remain unchanged + expect(encryptedModel.data.id).toBe('1') + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle null values in nested JSON objects', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '2', + email: 'test2@example.com', + metadata: { + profile: null, + settings: { + preferences: null, + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify null fields are preserved + expect(encryptedModel.data.email).toHaveProperty('k') + expect(encryptedModel.data.metadata?.profile).toBeNull() + expect(encryptedModel.data.metadata?.settings?.preferences).toBeNull() + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) + + it('should handle undefined values in nested JSON objects', async () => { + const protectClient = await protect({ schemas: [users] }) + + const decryptedModel = { + id: '3', + email: 'test3@example.com', + metadata: { + profile: undefined, + settings: { + preferences: undefined, + }, + }, + } + + const encryptedModel = await protectClient.encryptModel( + decryptedModel, + users, + ) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + // Verify undefined fields are preserved + expect(encryptedModel.data.email).toHaveProperty('k') + expect(encryptedModel.data.metadata?.profile).toBeUndefined() + expect(encryptedModel.data.metadata?.settings?.preferences).toBeUndefined() + + const decryptedResult = await protectClient.decryptModel( + encryptedModel.data, + ) + + if (decryptedResult.failure) { + throw new Error(`[protect]: ${decryptedResult.failure.message}`) + } + + expect(decryptedResult.data).toEqual(decryptedModel) + }, 30000) +}) + +describe('JSON edge cases and error handling', () => { + it('should handle very large JSON objects', async () => { + const largeJson = { + data: Array.from({ length: 1000 }, (_, i) => ({ + id: i, + name: `User ${i}`, + email: `user${i}@example.com`, + metadata: { + preferences: { + theme: i % 2 === 0 ? 'dark' : 'light', + notifications: i % 3 === 0, + }, + }, + })), + metadata: { + total: 1000, + created: new Date().toISOString(), + }, + } + + const ciphertext = await protectClient.encrypt(largeJson, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: largeJson, + }) + }, 30000) + + it('should handle JSON with circular references (should fail gracefully)', async () => { + const circularObj: Record = { name: 'test' } + circularObj.self = circularObj + + try { + await protectClient.encrypt(circularObj, { + column: users.json, + table: users, + }) + // This should not reach here as JSON.stringify should fail + expect(true).toBe(false) + } catch (error) { + // Expected to fail due to circular reference + expect(error).toBeDefined() + } + }, 30000) + + it('should handle JSON with special data types', async () => { + const json = { + string: 'hello', + number: 42, + boolean: true, + null: null, + array: [1, 2, 3], + object: { nested: 'value' }, + date: new Date('2023-01-01T00:00:00Z'), + // Note: Functions and undefined are not JSON serializable + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + // Date objects get serialized to strings in JSON + const expectedJson = { + ...json, + date: '2023-01-01T00:00:00.000Z', + } + + expect(plaintext).toEqual({ + data: expectedJson, + }) + }, 30000) +}) + +describe('JSON performance tests', () => { + it('should handle large numbers of JSON objects efficiently', async () => { + const largeJsonArray = Array.from({ length: 100 }, (_, i) => ({ + id: i, + data: { + name: `User ${i}`, + preferences: { + theme: i % 2 === 0 ? 'dark' : 'light', + notifications: i % 3 === 0, + }, + metadata: { + created: new Date().toISOString(), + version: i, + }, + }, + })) + + const jsonPayloads = largeJsonArray.map((item, index) => ({ + id: `user${index}`, + plaintext: item, + })) + + const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { + column: users.json, + table: users, + }) + + if (encryptedData.failure) { + throw new Error(`[protect]: ${encryptedData.failure.message}`) + } + + // Verify structure + expect(encryptedData.data).toHaveLength(100) + + // Decrypt the data + const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) + + if (decryptedData.failure) { + throw new Error(`[protect]: ${decryptedData.failure.message}`) + } + + // Verify all data is preserved + expect(decryptedData.data).toHaveLength(100) + + for (let i = 0; i < 100; i++) { + expect(decryptedData.data[i].id).toBe(`user${i}`) + expect(decryptedData.data[i].data).toEqual(largeJsonArray[i]) + } + }, 60000) +}) + +describe('JSON advanced scenarios', () => { + it('should handle JSON with deeply nested arrays', async () => { + const json = { + users: [ + { + id: 1, + name: 'Alice', + roles: [ + { name: 'admin', permissions: ['read', 'write', 'delete'] }, + { name: 'user', permissions: ['read'] }, + ], + }, + { + id: 2, + name: 'Bob', + roles: [{ name: 'user', permissions: ['read'] }], + }, + ], + metadata: { + total: 2, + lastUpdated: new Date().toISOString(), + }, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should handle JSON with mixed data types in arrays', async () => { + const json = { + mixedArray: ['string', 42, true, null, { nested: 'object' }, [1, 2, 3]], + metadata: { + types: ['string', 'number', 'boolean', 'null', 'object', 'array'], + }, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should handle JSON with Unicode and international characters', async () => { + const json = { + international: { + chinese: '你好世界', + japanese: 'こんにちは世界', + korean: '안녕하세요 세계', + arabic: 'مرحبا بالعالم', + russian: 'Привет мир', + emoji: '🚀 🌍 💻 🎉', + }, + metadata: { + languages: ['Chinese', 'Japanese', 'Korean', 'Arabic', 'Russian'], + encoding: 'UTF-8', + }, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should handle JSON with scientific notation and large numbers', async () => { + const json = { + numbers: { + integer: 1234567890, + float: Math.PI, + scientific: 1.23e10, + negative: -9876543210, + zero: 0, + verySmall: 1.23e-10, + }, + metadata: { + precision: 'high', + format: 'scientific', + }, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should handle JSON with boolean edge cases', async () => { + const json = { + booleans: { + true: true, + false: false, + stringTrue: 'true', + stringFalse: 'false', + numberOne: 1, + numberZero: 0, + emptyString: '', + nullValue: null, + }, + metadata: { + type: 'boolean_edge_cases', + }, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) +}) + +describe('JSON error handling and edge cases', () => { + it('should handle malformed JSON gracefully', async () => { + // This test ensures the library handles JSON serialization errors + const invalidJson = { + valid: 'data', + // This will cause JSON.stringify to fail + circular: null as unknown, + } + + // Create a circular reference + invalidJson.circular = invalidJson + + try { + await protectClient.encrypt(invalidJson, { + column: users.json, + table: users, + }) + expect(true).toBe(false) // Should not reach here + } catch (error) { + expect(error).toBeDefined() + expect(error).toBeInstanceOf(Error) + } + }, 30000) + + it('should handle empty arrays and objects', async () => { + const json = { + emptyArray: [], + emptyObject: {}, + nestedEmpty: { + array: [], + object: {}, + }, + mixedEmpty: { + data: 'present', + empty: [], + null: null, + }, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should handle JSON with very long strings', async () => { + const longString = 'A'.repeat(10000) // 10KB string + const json = { + longString, + metadata: { + length: longString.length, + type: 'long_string', + }, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) + + it('should handle JSON with all primitive types', async () => { + const json = { + string: 'hello world', + number: 42, + float: 3.14, + boolean: true, + null: null, + array: [1, 2, 3], + object: { key: 'value' }, + nested: { + level1: { + level2: { + level3: 'deep value', + }, + }, + }, + } + + const ciphertext = await protectClient.encrypt(json, { + column: users.json, + table: users, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + // Verify encrypted field + expect(ciphertext.data).toHaveProperty('k') + + const plaintext = await protectClient.decrypt(ciphertext.data) + + expect(plaintext).toEqual({ + data: json, + }) + }, 30000) +}) diff --git a/packages/protect/__tests__/protect.test.ts b/packages/protect/__tests__/protect-ops.test.ts similarity index 97% rename from packages/protect/__tests__/protect.test.ts rename to packages/protect/__tests__/protect-ops.test.ts index 8a7abfa..c7a2e27 100644 --- a/packages/protect/__tests__/protect.test.ts +++ b/packages/protect/__tests__/protect-ops.test.ts @@ -25,29 +25,7 @@ beforeAll(async () => { }) }) -describe('encryption and decryption', () => { - it('should encrypt and decrypt a payload', async () => { - const email = 'hello@example.com' - - const ciphertext = await protectClient.encrypt(email, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) - +describe('encryption and decryption edge cases', () => { it('should return null if plaintext is null', async () => { const ciphertext = await protectClient.encrypt(null, { column: users.email, diff --git a/packages/protect/__tests__/supabase.test.ts b/packages/protect/__tests__/supabase.test.ts index 45a1526..7468308 100644 --- a/packages/protect/__tests__/supabase.test.ts +++ b/packages/protect/__tests__/supabase.test.ts @@ -2,7 +2,7 @@ import 'dotenv/config' import { csColumn, csTable } from '@cipherstash/schema' import { describe, expect, it } from 'vitest' import { - type EncryptedPayload, + type Encrypted, bulkModelsToEncryptedPgComposites, encryptedToPgComposite, isEncryptedPayload, @@ -61,7 +61,7 @@ describe('supabase', () => { throw new Error(`[protect]: ${error.message}`) } - const dataToDecrypt = data[0].encrypted as EncryptedPayload + const dataToDecrypt = data[0].encrypted as Encrypted const plaintext = await protectClient.decrypt(dataToDecrypt) await supabase.from('protect-ci').delete().eq('id', insertedData[0].id) diff --git a/packages/protect/package.json b/packages/protect/package.json index 3af089d..d1b5288 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.16.0", + "@cipherstash/protect-ffi": "0.17.0", "@cipherstash/schema": "workspace:*", "zod": "^3.24.2" }, diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index c5ea50d..bacd41c 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -1,5 +1,5 @@ import { type Result, withResult } from '@byteslice/result' -import { newClient } from '@cipherstash/protect-ffi' +import { type JsPlaintext, newClient } from '@cipherstash/protect-ffi' import { type EncryptConfig, type ProtectTable, @@ -15,8 +15,7 @@ import type { Client, Decrypted, EncryptOptions, - EncryptPayload, - EncryptedPayload, + Encrypted, SearchTerm, } from '../types' import { BulkDecryptOperation } from './operations/bulk-decrypt' @@ -92,7 +91,10 @@ export class ProtectClient { * await eqlClient.encrypt(plaintext, { column, table }) * await eqlClient.encrypt(plaintext, { column, table }).withLockContext(lockContext) */ - encrypt(plaintext: EncryptPayload, opts: EncryptOptions): EncryptOperation { + encrypt( + plaintext: JsPlaintext | null, + opts: EncryptOptions, + ): EncryptOperation { return new EncryptOperation(this.client, plaintext, opts) } @@ -102,7 +104,7 @@ export class ProtectClient { * await eqlClient.decrypt(encryptedData) * await eqlClient.decrypt(encryptedData).withLockContext(lockContext) */ - decrypt(encryptedData: EncryptedPayload): DecryptOperation { + decrypt(encryptedData: Encrypted): DecryptOperation { return new DecryptOperation(this.client, encryptedData) } diff --git a/packages/protect/src/ffi/model-helpers.ts b/packages/protect/src/ffi/model-helpers.ts index 3003a84..7cde0dd 100644 --- a/packages/protect/src/ffi/model-helpers.ts +++ b/packages/protect/src/ffi/model-helpers.ts @@ -1,12 +1,14 @@ import { - type Encrypted, + type Encrypted as CipherStashEncrypted, + type DecryptBulkOptions, + type JsPlaintext, decryptBulk, encryptBulk, } from '@cipherstash/protect-ffi' import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' import { isEncryptedPayload } from '../helpers' import type { GetLockContextResponse } from '../identify' -import type { Client, Decrypted, EncryptedPayload } from '../types' +import type { Client, Decrypted, Encrypted } from '../types' import type { AuditData } from './operations/base-operation' /** @@ -14,8 +16,8 @@ import type { AuditData } from './operations/base-operation' */ export function extractEncryptedFields>( model: T, -): Record { - const result: Record = {} +): Record { + const result: Record = {} for (const [key, value] of Object.entries(model)) { if (isEncryptedPayload(value)) { @@ -48,7 +50,7 @@ export function extractOtherFields>( */ export function mergeFields( otherFields: Record, - encryptedFields: Record, + encryptedFields: Record, ): T { return { ...otherFields, ...encryptedFields } as T } @@ -201,7 +203,11 @@ function prepareFieldsForEncryption>( continue } - if (typeof value === 'object' && !isEncryptedPayload(value)) { + if ( + typeof value === 'object' && + !isEncryptedPayload(value) && + !columnPaths.includes(fullKey) + ) { // Only process nested objects if they're in the schema if (columnPaths.some((path) => path.startsWith(fullKey))) { processNestedFields( @@ -253,7 +259,7 @@ export async function decryptModelFields>( const bulkDecryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, - ciphertext: (value as EncryptedPayload)?.c ?? '', + ciphertext: value as CipherStashEncrypted, }), ) @@ -397,7 +403,7 @@ export async function decryptModelFieldsWithLockContext< const bulkDecryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, - ciphertext: (value as EncryptedPayload)?.c ?? '', + ciphertext: value as CipherStashEncrypted, lockContext: lockContext.context, }), ) @@ -564,7 +570,11 @@ function prepareBulkModelsForOperation>( continue } - if (typeof value === 'object' && !isEncryptedPayload(value)) { + if ( + typeof value === 'object' && + !isEncryptedPayload(value) && + !columnPaths.includes(fullKey) + ) { // Only process nested objects if they're in the schema if (columnPaths.some((path) => path.startsWith(fullKey))) { processNestedFields( @@ -600,6 +610,7 @@ function prepareBulkModelsForOperation>( const processEncryptedFields = ( obj: Record, prefix = '', + columnPaths: string[] = [], ) => { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key @@ -609,9 +620,17 @@ function prepareBulkModelsForOperation>( continue } - if (typeof value === 'object' && !isEncryptedPayload(value)) { + if ( + typeof value === 'object' && + !isEncryptedPayload(value) && + !columnPaths.includes(fullKey) + ) { // Recursively process nested objects - processEncryptedFields(value as Record, fullKey) + processEncryptedFields( + value as Record, + fullKey, + columnPaths, + ) } else if (isEncryptedPayload(value)) { // This is an encrypted field const id = index.toString() @@ -749,7 +768,7 @@ export async function bulkDecryptModels>( const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, - ciphertext: (value as EncryptedPayload)?.c ?? '', + ciphertext: value as CipherStashEncrypted, })), ) @@ -836,7 +855,7 @@ export async function bulkDecryptModelsWithLockContext< const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, - ciphertext: (value as EncryptedPayload)?.c ?? '', + ciphertext: value as CipherStashEncrypted, lockContext: lockContext.context, })), ) diff --git a/packages/protect/src/ffi/operations/bulk-decrypt.ts b/packages/protect/src/ffi/operations/bulk-decrypt.ts index 2480266..2ddc2b0 100644 --- a/packages/protect/src/ffi/operations/bulk-decrypt.ts +++ b/packages/protect/src/ffi/operations/bulk-decrypt.ts @@ -1,5 +1,6 @@ import { type Result, withResult } from '@byteslice/result' import { + type Encrypted as CipherStashEncrypted, type DecryptResult, decryptBulkFallible, } from '@cipherstash/protect-ffi' @@ -20,9 +21,7 @@ const createDecryptPayloads = ( .filter(({ data }) => data !== null) .map(({ id, data, originalIndex }) => ({ id, - ciphertext: (typeof data === 'object' && data !== null - ? data.c - : data) as string, + ciphertext: data as CipherStashEncrypted, originalIndex, ...(lockContext && { lockContext }), })) diff --git a/packages/protect/src/ffi/operations/bulk-encrypt.ts b/packages/protect/src/ffi/operations/bulk-encrypt.ts index 5e22396..e89f827 100644 --- a/packages/protect/src/ffi/operations/bulk-encrypt.ts +++ b/packages/protect/src/ffi/operations/bulk-encrypt.ts @@ -1,5 +1,5 @@ import { type Result, withResult } from '@byteslice/result' -import { encryptBulk } from '@cipherstash/protect-ffi' +import { type JsPlaintext, encryptBulk } from '@cipherstash/protect-ffi' import type { ProtectColumn, ProtectTable, @@ -14,7 +14,7 @@ import type { BulkEncryptedData, Client, EncryptOptions, - EncryptedPayload, + Encrypted, } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' @@ -31,7 +31,7 @@ const createEncryptPayloads = ( .filter(({ plaintext }) => plaintext !== null) .map(({ id, plaintext, originalIndex }) => ({ id, - plaintext: plaintext as string, + plaintext: plaintext as JsPlaintext, column: column.getName(), table: table.tableName, originalIndex, @@ -47,7 +47,7 @@ const createNullResult = ( const mapEncryptedDataToResult = ( plaintexts: BulkEncryptPayload, - encryptedData: EncryptedPayload[], + encryptedData: Encrypted[], ): BulkEncryptedData => { const result: BulkEncryptedData = new Array(plaintexts.length) let encryptedIndex = 0 diff --git a/packages/protect/src/ffi/operations/decrypt.ts b/packages/protect/src/ffi/operations/decrypt.ts index eb6dae6..23fdde0 100644 --- a/packages/protect/src/ffi/operations/decrypt.ts +++ b/packages/protect/src/ffi/operations/decrypt.ts @@ -1,17 +1,20 @@ import { type Result, withResult } from '@byteslice/result' -import { decrypt as ffiDecrypt } from '@cipherstash/protect-ffi' +import { + type JsPlaintext, + decrypt as ffiDecrypt, +} from '@cipherstash/protect-ffi' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' -import type { Client, EncryptedPayload } from '../../types' +import type { Client, Encrypted } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' -export class DecryptOperation extends ProtectOperation { +export class DecryptOperation extends ProtectOperation { private client: Client - private encryptedData: EncryptedPayload + private encryptedData: Encrypted - constructor(client: Client, encryptedData: EncryptedPayload) { + constructor(client: Client, encryptedData: Encrypted) { super() this.client = client this.encryptedData = encryptedData @@ -23,7 +26,7 @@ export class DecryptOperation extends ProtectOperation { return new DecryptOperationWithLockContext(this, lockContext) } - public async execute(): Promise> { + public async execute(): Promise> { return await withResult( async () => { if (!this.client) { @@ -41,7 +44,7 @@ export class DecryptOperation extends ProtectOperation { }) return await ffiDecrypt(this.client, { - ciphertext: this.encryptedData.c, + ciphertext: this.encryptedData, unverifiedContext: metadata, }) }, @@ -54,7 +57,7 @@ export class DecryptOperation extends ProtectOperation { public getOperation(): { client: Client - encryptedData: EncryptedPayload + encryptedData: Encrypted auditData?: Record } { return { @@ -65,9 +68,7 @@ export class DecryptOperation extends ProtectOperation { } } -export class DecryptOperationWithLockContext extends ProtectOperation< - string | null -> { +export class DecryptOperationWithLockContext extends ProtectOperation { private operation: DecryptOperation private lockContext: LockContext @@ -81,7 +82,7 @@ export class DecryptOperationWithLockContext extends ProtectOperation< } } - public async execute(): Promise> { + public async execute(): Promise> { return await withResult( async () => { const { client, encryptedData } = this.operation.getOperation() @@ -107,7 +108,7 @@ export class DecryptOperationWithLockContext extends ProtectOperation< } return await ffiDecrypt(client, { - ciphertext: encryptedData.c, + ciphertext: encryptedData, unverifiedContext: metadata, lockContext: context.data.context, serviceToken: context.data.ctsToken, diff --git a/packages/protect/src/ffi/operations/encrypt.ts b/packages/protect/src/ffi/operations/encrypt.ts index 13bb48a..6d479b3 100644 --- a/packages/protect/src/ffi/operations/encrypt.ts +++ b/packages/protect/src/ffi/operations/encrypt.ts @@ -1,5 +1,8 @@ import { type Result, withResult } from '@byteslice/result' -import { encrypt as ffiEncrypt } from '@cipherstash/protect-ffi' +import { + type JsPlaintext, + encrypt as ffiEncrypt, +} from '@cipherstash/protect-ffi' import type { ProtectColumn, ProtectTable, @@ -9,22 +12,21 @@ import type { import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' -import type { - Client, - EncryptOptions, - EncryptPayload, - EncryptedPayload, -} from '../../types' +import type { Client, EncryptOptions, Encrypted } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' -export class EncryptOperation extends ProtectOperation { +export class EncryptOperation extends ProtectOperation { private client: Client - private plaintext: EncryptPayload + private plaintext: JsPlaintext | null private column: ProtectColumn | ProtectValue private table: ProtectTable - constructor(client: Client, plaintext: EncryptPayload, opts: EncryptOptions) { + constructor( + client: Client, + plaintext: JsPlaintext | null, + opts: EncryptOptions, + ) { super() this.client = client this.plaintext = plaintext @@ -38,7 +40,7 @@ export class EncryptOperation extends ProtectOperation { return new EncryptOperationWithLockContext(this, lockContext) } - public async execute(): Promise> { + public async execute(): Promise> { logger.debug('Encrypting data WITHOUT a lock context', { column: this.column.getName(), table: this.table.tableName, @@ -72,7 +74,7 @@ export class EncryptOperation extends ProtectOperation { public getOperation(): { client: Client - plaintext: EncryptPayload + plaintext: JsPlaintext | null column: ProtectColumn | ProtectValue table: ProtectTable } { @@ -85,7 +87,7 @@ export class EncryptOperation extends ProtectOperation { } } -export class EncryptOperationWithLockContext extends ProtectOperation { +export class EncryptOperationWithLockContext extends ProtectOperation { private operation: EncryptOperation private lockContext: LockContext @@ -95,7 +97,7 @@ export class EncryptOperationWithLockContext extends ProtectOperation> { + public async execute(): Promise> { return await withResult( async () => { const { client, plaintext, column, table } = diff --git a/packages/protect/src/helpers/index.ts b/packages/protect/src/helpers/index.ts index c168730..0074427 100644 --- a/packages/protect/src/helpers/index.ts +++ b/packages/protect/src/helpers/index.ts @@ -1,16 +1,13 @@ -import type { Encrypted } from '@cipherstash/protect-ffi' -import type { EncryptedPayload } from '../types' +import type { Encrypted } from '../types' export type EncryptedPgComposite = { - data: EncryptedPayload + data: Encrypted } /** * Helper function to transform an encrypted payload into a PostgreSQL composite type */ -export function encryptedToPgComposite( - obj: EncryptedPayload, -): EncryptedPgComposite { +export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite { return { data: obj, } @@ -47,13 +44,15 @@ export function bulkModelsToEncryptedPgComposites< /** * Helper function to check if a value is an encrypted payload */ -export function isEncryptedPayload(value: unknown): value is EncryptedPayload { +export function isEncryptedPayload(value: unknown): value is Encrypted { if (value === null) return false // TODO: this can definitely be improved if (typeof value === 'object') { const obj = value as Encrypted - return 'v' in obj && 'c' in obj && 'i' in obj + return ( + obj !== null && 'v' in obj && ('c' in obj || 'sv' in obj) && 'i' in obj + ) } return false diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts index 9a3a9b5..a491ed9 100644 --- a/packages/protect/src/types.ts +++ b/packages/protect/src/types.ts @@ -1,4 +1,8 @@ -import type { Encrypted, newClient } from '@cipherstash/protect-ffi' +import type { + Encrypted as CipherStashEncrypted, + JsPlaintext, + newClient, +} from '@cipherstash/protect-ffi' import type { ProtectColumn, ProtectTable, @@ -11,14 +15,20 @@ import type { */ export type Client = Awaited> | undefined +/** + * Type to represent an encrypted payload + */ +export type Encrypted = CipherStashEncrypted | null + /** * Represents an encrypted payload in the database + * @deprecated Use `Encrypted` instead */ export type EncryptedPayload = Encrypted | null /** * Represents an encrypted data object in the database - * @deprecated Use `EncryptedPayload` instead + * @deprecated Use `Encrypted` instead */ export type EncryptedData = Encrypted | null @@ -26,7 +36,7 @@ export type EncryptedData = Encrypted | null * Represents a value that will be encrypted and used in a search */ export type SearchTerm = { - value: string + value: JsPlaintext column: ProtectColumn table: ProtectTable returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' @@ -34,17 +44,16 @@ export type SearchTerm = { /** * The return type of the search term based on the return type specified in the `SearchTerm` type - * If the return type is `eql`, the return type is `EncryptedPayload` + * If the return type is `eql`, the return type is `Encrypted` * If the return type is `composite-literal`, the return type is `string` where the value is a composite literal * If the return type is `escaped-composite-literal`, the return type is `string` where the value is an escaped composite literal */ -export type EncryptedSearchTerm = EncryptedPayload | string +export type EncryptedSearchTerm = Encrypted | string /** * Represents a payload to be encrypted using the `encrypt` function - * We currently only support the encryption of strings */ -export type EncryptPayload = string | null +export type EncryptPayload = JsPlaintext | null /** * Represents the options for encrypting a payload using the `encrypt` function @@ -58,21 +67,21 @@ export type EncryptOptions = { * Type to identify encrypted fields in a model */ export type EncryptedFields = { - [K in keyof T as T[K] extends EncryptedPayload ? K : never]: T[K] + [K in keyof T as T[K] extends Encrypted ? K : never]: T[K] } /** * Type to identify non-encrypted fields in a model */ export type OtherFields = { - [K in keyof T as T[K] extends EncryptedPayload | null ? never : K]: T[K] + [K in keyof T as T[K] extends Encrypted ? never : K]: T[K] } /** * Type to represent decrypted fields in a model */ export type DecryptedFields = { - [K in keyof T as T[K] extends EncryptedPayload | null ? K : never]: string + [K in keyof T as T[K] extends Encrypted ? K : never]: string } /** @@ -85,12 +94,12 @@ export type Decrypted = OtherFields & DecryptedFields */ export type BulkEncryptPayload = Array<{ id?: string - plaintext: string | null + plaintext: JsPlaintext | null }> -export type BulkEncryptedData = Array<{ id?: string; data: EncryptedPayload }> -export type BulkDecryptPayload = Array<{ id?: string; data: EncryptedPayload }> -export type BulkDecryptedData = Array> +export type BulkEncryptedData = Array<{ id?: string; data: Encrypted }> +export type BulkDecryptPayload = Array<{ id?: string; data: Encrypted }> +export type BulkDecryptedData = Array> type DecryptionSuccess = { error?: never diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 1431403..2780861 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -3,19 +3,7 @@ import { z } from 'zod' // ------------------------ // Zod schemas // ------------------------ -const castAsEnum = z - .enum([ - 'big_int', - 'boolean', - 'date', - 'real', - 'double', - 'int', - 'small_int', - 'text', - 'jsonb', - ]) - .default('text') +const castAsEnum = z.enum(['text', 'int', 'jsonb']).default('text') const tokenFilterSchema = z.object({ kind: z.literal('downcase'), @@ -200,7 +188,7 @@ export class ProtectColumn { /** * Enable a STE Vec index, requires a prefix. */ - josn(prefix: string) { + searchableJson(prefix: string) { this.indexesValue.ste_vec = { prefix } return this } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 284f33d..6b9b16b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,8 +456,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@cipherstash/protect-ffi': - specifier: 0.16.0 - version: 0.16.0 + specifier: 0.17.0 + version: 0.17.0 '@cipherstash/schema': specifier: workspace:* version: link:../schema @@ -975,33 +975,33 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/protect-ffi-darwin-arm64@0.16.0': - resolution: {integrity: sha512-RXxhgGemIbFMPH9NcpIncI9uICwEwJxsYekIaZ/DEsT9wUTGmPVkAyscGlellHx5xUkDtrvxIQY086GlcNON8w==} + '@cipherstash/protect-ffi-darwin-arm64@0.17.0': + resolution: {integrity: sha512-/QsbCzpDs9orsBWf7RhPosyspsES7WjD2xr98NeJeN5IR/KbVmr+KuEchl18eqeVH1EzXNT5FZLZ+AUfEjO9rw==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.16.0': - resolution: {integrity: sha512-H5LG2mzeQ6Fy3sPqBw1D/IZ5QVe+pZNYRR1u2wiSc6UR7fdlRZPzYNPWcTuHDJvI4vEY3DZjYCUaNNeeGtX0IA==} + '@cipherstash/protect-ffi-darwin-x64@0.17.0': + resolution: {integrity: sha512-EOq6ZNAPlWU0SJTCmfQQkLPX7UDUv7O/fuTzVGksPjM6vC1iYAGRXa37TdxGHXzZDmOl9En70KblPtHI0opT7g==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.16.0': - resolution: {integrity: sha512-iHsdJKX6IAe8EoPMdH/zQycH8WXNC7IKoYVI6CdvelJjhScIMfKxeuQolDgEYSQe5ujLfCA6Ujk0bqyKPwiS7Q==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.0': + resolution: {integrity: sha512-jLA+tNKNE4sluH6Clnn2TUoOPcvyuSaAj6h4FluswQahiHdILPDaM5WbePnbwOIi0eumff3zo9HuAWRJqhB7Pw==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.16.0': - resolution: {integrity: sha512-desQSbnHAB8i8GfQ/kYMojX92McVsejnCf2uCfW9ilkJ+HVtFVfrYp1R2Y2zCtQtvdfuYel07UiFpogIWGp/2Q==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.17.0': + resolution: {integrity: sha512-QjAyb7K2X/ccnIsKByTLG3iD5qQy4GQ23EWWJcTUkz6u1smE6C1lXjs9y43jlFcQc9WaGlIFn4SeC4q4fnuMDA==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.16.0': - resolution: {integrity: sha512-9aZAepWdPUnBeNM8tWL7D0VCqrylwySZbw0qChVDzEfDMrxOnIjrTht54kI14FVKfRHWugm7FFbzjjfLwS4M5A==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.17.0': + resolution: {integrity: sha512-bXU2Tv516qJEp47Yc7lizNdRCrXehubc87CdzU1Jl/bReWtmRK2Nu6lH9FsLtPSPdo8SClb81ZqtryxBW19n+w==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.16.0': - resolution: {integrity: sha512-sG5yo0Q277oQGXHd4T4hA9Jdf19AI+0sLzEOvcjjvb/T9AZplsUZJd1ZU0oQUzSrJ4JGzOahyQ6CeMrYvdmdeg==} + '@cipherstash/protect-ffi@0.17.0': + resolution: {integrity: sha512-TJv/qhuSzid42XXgdmeExf8G/XLZ51VB0wHsg0hJoR9t6yNjWOuOY3NZ37X5+7rWJnnIjjVFsQIr3sQ+aam7sw==} '@clerk/backend@1.25.5': resolution: {integrity: sha512-nnBpr7oSq5iATWRExuljEfp7xa90KE1OUgaGCSmtZYF0T9TWHGkZHYqkQhD4XjiqlR2XsrsQ/UzPfmHM1Km7+Q==} @@ -7813,30 +7813,30 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@cipherstash/protect-ffi-darwin-arm64@0.16.0': + '@cipherstash/protect-ffi-darwin-arm64@0.17.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.16.0': + '@cipherstash/protect-ffi-darwin-x64@0.17.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.16.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.16.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.17.0': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.16.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.17.0': optional: true - '@cipherstash/protect-ffi@0.16.0': + '@cipherstash/protect-ffi@0.17.0': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.16.0 - '@cipherstash/protect-ffi-darwin-x64': 0.16.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.16.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.16.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.16.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.17.0 + '@cipherstash/protect-ffi-darwin-x64': 0.17.0 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.17.0 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.17.0 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.17.0 '@clerk/backend@1.25.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: From a6b94b11fceb44032f95324b8f715c0221f738a7 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 6 Oct 2025 18:35:22 -0600 Subject: [PATCH 02/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20update?= =?UTF-8?q?=20DynamoDB=20package=20to=20use=20Encrypted=20type=20instead?= =?UTF-8?q?=20of=20EncryptedPayload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace EncryptedPayload imports with Encrypted type across all DynamoDB operations - Update EQL payload structure in toItemWithEqlPayloads helper function - Simplify payload structure by removing unused fields (bf, hm, ob) - Update type annotations in decrypt operations and bulk operations - Add TODO comment for future ste_vec EQL type support --- packages/protect-dynamodb/src/helpers.ts | 13 ++++++------- packages/protect-dynamodb/src/index.ts | 6 +++--- .../src/operations/bulk-decrypt-models.ts | 6 +++--- .../src/operations/decrypt-model.ts | 6 +++--- packages/protect-dynamodb/src/types.ts | 8 +++----- 5 files changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/protect-dynamodb/src/helpers.ts b/packages/protect-dynamodb/src/helpers.ts index 8566dcf..060bb1a 100644 --- a/packages/protect-dynamodb/src/helpers.ts +++ b/packages/protect-dynamodb/src/helpers.ts @@ -1,4 +1,4 @@ -import type { EncryptedPayload } from '@cipherstash/protect' +import type { Encrypted } from '@cipherstash/protect' import type { ProtectDynamoDBError } from './types' export const ciphertextAttrSuffix = '__source' export const searchTermAttrSuffix = '__hmac' @@ -83,7 +83,7 @@ export function toEncryptedDynamoItem( typeof attrValue === 'object' && 'c' in (attrValue as object)) ) { - const encryptPayload = attrValue as EncryptedPayload + const encryptPayload = attrValue as Encrypted if (encryptPayload?.c) { const result: Record = {} if (encryptPayload.hm) { @@ -122,7 +122,7 @@ export function toEncryptedDynamoItem( } export function toItemWithEqlPayloads( - decrypted: Record, + decrypted: Record, encryptedAttrs: string[], ): Record { function processValue( @@ -148,14 +148,13 @@ export function toItemWithEqlPayloads( isNested) ) { const baseName = attrName.slice(0, -ciphertextAttrSuffix.length) + + // TODO: in order to support the ste_vec eql type, this needs to be updated return { [baseName]: { c: attrValue, - bf: null, - hm: null, i: { c: 'notUsed', t: 'notUsed' }, - k: 'notUsed', - ob: null, + k: 'ct', v: 2, }, } diff --git a/packages/protect-dynamodb/src/index.ts b/packages/protect-dynamodb/src/index.ts index 6cd02f0..433cda6 100644 --- a/packages/protect-dynamodb/src/index.ts +++ b/packages/protect-dynamodb/src/index.ts @@ -1,5 +1,5 @@ import type { - EncryptedPayload, + Encrypted, ProtectTable, ProtectTableColumn, SearchTerm, @@ -42,7 +42,7 @@ export function protectDynamoDB( }, decryptModel>( - item: Record, + item: Record, protectTable: ProtectTable, ) { return new DecryptModelOperation( @@ -54,7 +54,7 @@ export function protectDynamoDB( }, bulkDecryptModels>( - items: Record[], + items: Record[], protectTable: ProtectTable, ) { return new BulkDecryptModelsOperation( diff --git a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts index 7bab00c..ba085c9 100644 --- a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts +++ b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts @@ -1,7 +1,7 @@ import { type Result, withResult } from '@byteslice/result' import type { Decrypted, - EncryptedPayload, + Encrypted, ProtectClient, ProtectTable, ProtectTableColumn, @@ -17,12 +17,12 @@ export class BulkDecryptModelsOperation< T extends Record, > extends DynamoDBOperation[]> { private protectClient: ProtectClient - private items: Record[] + private items: Record[] private protectTable: ProtectTable constructor( protectClient: ProtectClient, - items: Record[], + items: Record[], protectTable: ProtectTable, options?: DynamoDBOperationOptions, ) { diff --git a/packages/protect-dynamodb/src/operations/decrypt-model.ts b/packages/protect-dynamodb/src/operations/decrypt-model.ts index 56ccf15..6aa5aaf 100644 --- a/packages/protect-dynamodb/src/operations/decrypt-model.ts +++ b/packages/protect-dynamodb/src/operations/decrypt-model.ts @@ -1,7 +1,7 @@ import { type Result, withResult } from '@byteslice/result' import type { Decrypted, - EncryptedPayload, + Encrypted, ProtectClient, ProtectTable, ProtectTableColumn, @@ -17,12 +17,12 @@ export class DecryptModelOperation< T extends Record, > extends DynamoDBOperation> { private protectClient: ProtectClient - private item: Record + private item: Record private protectTable: ProtectTable constructor( protectClient: ProtectClient, - item: Record, + item: Record, protectTable: ProtectTable, options?: DynamoDBOperationOptions, ) { diff --git a/packages/protect-dynamodb/src/types.ts b/packages/protect-dynamodb/src/types.ts index a00ea1b..594d640 100644 --- a/packages/protect-dynamodb/src/types.ts +++ b/packages/protect-dynamodb/src/types.ts @@ -1,7 +1,5 @@ -import type { Result } from '@byteslice/result' import type { - Decrypted, - EncryptedPayload, + Encrypted, ProtectClient, ProtectTable, ProtectTableColumn, @@ -40,12 +38,12 @@ export interface ProtectDynamoDBInstance { ): BulkEncryptModelsOperation decryptModel>( - item: Record, + item: Record, protectTable: ProtectTable, ): DecryptModelOperation bulkDecryptModels>( - items: Record[], + items: Record[], protectTable: ProtectTable, ): BulkDecryptModelsOperation From 5b87d7d563b26ae83310a2c503563a04026d91f5 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 6 Oct 2025 18:40:41 -0600 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=A7=AA=20test:=20clean=20up=20debug?= =?UTF-8?q?=20test=20file=20boilerplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove empty test suite that was causing 'No test found' error - Remove unused test variable - Add comment placeholder for future tests - Keep boilerplate setup for when tests are needed --- packages/protect/__tests__/debug.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/protect/__tests__/debug.test.ts b/packages/protect/__tests__/debug.test.ts index 95f0aad..a9b0941 100644 --- a/packages/protect/__tests__/debug.test.ts +++ b/packages/protect/__tests__/debug.test.ts @@ -28,12 +28,10 @@ type User = { let protectClient: Awaited> -const test = false - beforeAll(async () => { protectClient = await protect({ schemas: [users], }) }) -describe('debugging', () => {}) +// Add your tests here when ready From 1495a732d513fc75c296f5035b65bd546eecaa4e Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 6 Oct 2025 18:41:36 -0600 Subject: [PATCH 04/19] chore: remove console log --- packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts index ba085c9..4445885 100644 --- a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts +++ b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts @@ -38,6 +38,7 @@ export class BulkDecryptModelsOperation< return await withResult( async () => { const encryptedAttrs = Object.keys(this.protectTable.build().columns) + const itemsWithEqlPayloads = this.items.map((item) => toItemWithEqlPayloads(item, encryptedAttrs), ) From dd466a823186334f57daa1adc24265816c55045c Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 7 Oct 2025 09:01:53 -0600 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20JSONB=20support?= =?UTF-8?q?=20for=20DynamoDB=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add searchable JSON column support with ste_vec indexes - Update toItemWithEqlPayloads to construct proper EQL payloads for JSON types - Handle both standard ciphertext (ct) and searchable vector (sv) payloads - Pass ProtectTable schema to helpers for column metadata access - Add comprehensive test coverage for JSON and nested JSON encryption - Support nested protectNestedJson values in test data --- .../__tests__/dynamodb.test.ts | 38 ++++++++++++- packages/protect-dynamodb/src/helpers.ts | 53 ++++++++++++++----- .../src/operations/bulk-decrypt-models.ts | 4 +- .../src/operations/decrypt-model.ts | 6 ++- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts index 1cd036a..eb4320d 100644 --- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts +++ b/packages/protect-dynamodb/__tests__/dynamodb.test.ts @@ -8,10 +8,17 @@ const schema = csTable('dynamo_cipherstash_test', { firstName: csColumn('firstName').equality(), lastName: csColumn('lastName').equality(), phoneNumber: csColumn('phoneNumber'), + json: csColumn('json').dataType('jsonb'), + jsonSearchable: csColumn('jsonSearchable') + .dataType('jsonb') + .searchableJson('users/jsonSearchable'), example: { protected: csValue('example.protected'), deep: { protected: csValue('example.deep.protected'), + protectNestedJson: csValue('example.deep.protectNestedJson').dataType( + 'jsonb', + ), }, }, }) @@ -30,7 +37,7 @@ describe('protect dynamodb helpers', () => { }) }) - it('should encrypt columns', async () => { + it('should encrypt and decrypt a model', async () => { const testData = { id: '01ABCDEFGHIJKLMNOPQRSTUVWX', email: 'test.user@example.com', @@ -39,6 +46,22 @@ describe('protect dynamodb helpers', () => { firstName: 'John', lastName: 'Smith', phoneNumber: '555-555-5555', + json: { + name: 'John Doe', + age: 30, + preferences: { + theme: 'dark', + notifications: true, + }, + }, + jsonSearchable: { + name: 'John Doe', + age: 30, + preferences: { + theme: 'dark', + notifications: true, + }, + }, companyName: 'Acme Corp', batteryBrands: ['Brand1', 'Brand2'], metadata: { role: 'admin' }, @@ -48,6 +71,9 @@ describe('protect dynamodb helpers', () => { deep: { protected: 'deep protected', notProtected: 'deep not protected', + protectNestedJson: { + hello: 'world', + }, }, }, } @@ -80,6 +106,16 @@ describe('protect dynamodb helpers', () => { expect(encryptedData.example.notProtected).toBe('I am not protected') expect(encryptedData.example.deep.notProtected).toBe('deep not protected') expect(encryptedData.metadata).toEqual({ role: 'admin' }) + + const decryptResult = await protectDynamo.decryptModel( + encryptedData, + schema, + ) + if (decryptResult.failure) { + throw new Error(`Decryption failed: ${decryptResult.failure.message}`) + } + + expect(decryptResult.data).toEqual(testData) }) it('should handle null and undefined values', async () => { diff --git a/packages/protect-dynamodb/src/helpers.ts b/packages/protect-dynamodb/src/helpers.ts index 060bb1a..b1ef31e 100644 --- a/packages/protect-dynamodb/src/helpers.ts +++ b/packages/protect-dynamodb/src/helpers.ts @@ -1,4 +1,8 @@ -import type { Encrypted } from '@cipherstash/protect' +import type { + Encrypted, + ProtectTable, + ProtectTableColumn, +} from '@cipherstash/protect' import type { ProtectDynamoDBError } from './types' export const ciphertextAttrSuffix = '__source' export const searchTermAttrSuffix = '__hmac' @@ -92,6 +96,12 @@ export function toEncryptedDynamoItem( result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c return result } + + if (encryptPayload?.sv) { + const result: Record = {} + result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.sv + return result + } } // Handle nested objects recursively @@ -123,7 +133,7 @@ export function toEncryptedDynamoItem( export function toItemWithEqlPayloads( decrypted: Record, - encryptedAttrs: string[], + encryptSchemas: ProtectTable, ): Record { function processValue( attrName: string, @@ -139,23 +149,42 @@ export function toItemWithEqlPayloads( return {} } + const encryptConfig = encryptSchemas.build() + const encryptedAttrs = Object.keys(encryptConfig.columns) + const columnName = attrName.slice(0, -ciphertextAttrSuffix.length) + // Handle encrypted payload if ( attrName.endsWith(ciphertextAttrSuffix) && - (encryptedAttrs.includes( - attrName.slice(0, -ciphertextAttrSuffix.length), - ) || - isNested) + (encryptedAttrs.includes(columnName) || isNested) ) { - const baseName = attrName.slice(0, -ciphertextAttrSuffix.length) + // TODO: Implement the standardized typing for Encrypted Payloads through the FFI interface + const i = { c: columnName, t: encryptConfig.tableName } + const v = 2 + + // Nested values are not searchable, so we can just return the standard EQL payload. + // Worth noting, that encryptConfig.columns[columnName] will be undefined if isNested is true. + if ( + !isNested && + encryptConfig.columns[columnName].cast_as === 'jsonb' && + encryptConfig.columns[columnName].indexes.ste_vec + ) { + return { + [columnName]: { + i, + v, + k: 'sv', + sv: attrValue, + }, + } + } - // TODO: in order to support the ste_vec eql type, this needs to be updated return { - [baseName]: { - c: attrValue, - i: { c: 'notUsed', t: 'notUsed' }, + [columnName]: { + i, + v, k: 'ct', - v: 2, + c: attrValue, }, } } diff --git a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts index 4445885..e72e231 100644 --- a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts +++ b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts @@ -37,10 +37,8 @@ export class BulkDecryptModelsOperation< > { return await withResult( async () => { - const encryptedAttrs = Object.keys(this.protectTable.build().columns) - const itemsWithEqlPayloads = this.items.map((item) => - toItemWithEqlPayloads(item, encryptedAttrs), + toItemWithEqlPayloads(item, this.protectTable), ) const decryptResult = await this.protectClient diff --git a/packages/protect-dynamodb/src/operations/decrypt-model.ts b/packages/protect-dynamodb/src/operations/decrypt-model.ts index 6aa5aaf..862e434 100644 --- a/packages/protect-dynamodb/src/operations/decrypt-model.ts +++ b/packages/protect-dynamodb/src/operations/decrypt-model.ts @@ -35,8 +35,10 @@ export class DecryptModelOperation< public async execute(): Promise, ProtectDynamoDBError>> { return await withResult( async () => { - const encryptedAttrs = Object.keys(this.protectTable.build().columns) - const withEqlPayloads = toItemWithEqlPayloads(this.item, encryptedAttrs) + const withEqlPayloads = toItemWithEqlPayloads( + this.item, + this.protectTable, + ) const decryptResult = await this.protectClient .decryptModel(withEqlPayloads as T) From 503aaf3f89c5e67247bc5a9e7ed76de3b2337a50 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 7 Oct 2025 09:05:19 -0600 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=92=A1=20docs:=20add=20TODO=20for?= =?UTF-8?q?=20FFI=20encrypt=20payload=20type=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comment noting need to implement new Encrypt payload type - Reminder to update when FFI interface is updated for sv payloads --- packages/protect-dynamodb/src/helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/protect-dynamodb/src/helpers.ts b/packages/protect-dynamodb/src/helpers.ts index b1ef31e..c52d0a1 100644 --- a/packages/protect-dynamodb/src/helpers.ts +++ b/packages/protect-dynamodb/src/helpers.ts @@ -97,6 +97,7 @@ export function toEncryptedDynamoItem( return result } + // TODO: Need to implement the new Encrypt payload type when FFI is updated if (encryptPayload?.sv) { const result: Record = {} result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.sv From 89488ea0df371b45bfc36d5484aaa9f398b580e0 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 7 Oct 2025 09:07:21 -0600 Subject: [PATCH 07/19] chore: remove debug test --- packages/protect/__tests__/debug.test.ts | 37 ------------------------ 1 file changed, 37 deletions(-) delete mode 100644 packages/protect/__tests__/debug.test.ts diff --git a/packages/protect/__tests__/debug.test.ts b/packages/protect/__tests__/debug.test.ts deleted file mode 100644 index a9b0941..0000000 --- a/packages/protect/__tests__/debug.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - json: csColumn('json').dataType('jsonb'), - jsonSearchable: csColumn('jsonSearchable') - .dataType('jsonb') - .searchableJson('users/jsonSearchable'), -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - json?: Record | null - metadata?: { - profile?: Record | null - settings?: { - preferences?: Record | null - } - } -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -// Add your tests here when ready From 52cec2fbf005d12172aecc7c62ebfb26b2a0bd0c Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 20 Oct 2025 18:17:34 -0600 Subject: [PATCH 08/19] feat(protect, schema): remove prefix requirments for searchable json --- .../protect/__tests__/json-protect.test.ts | 2 +- packages/schema/README.md | 12 +++------- packages/schema/__tests__/schema.test.ts | 13 ++++++++++ packages/schema/src/index.ts | 24 ++++++++++++++++--- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/protect/__tests__/json-protect.test.ts b/packages/protect/__tests__/json-protect.test.ts index c5b2795..35eafe5 100644 --- a/packages/protect/__tests__/json-protect.test.ts +++ b/packages/protect/__tests__/json-protect.test.ts @@ -6,7 +6,7 @@ import { LockContext, protect } from '../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), address: csColumn('address').freeTextSearch(), - json: csColumn('json').dataType('jsonb').searchableJson('users/json'), + json: csColumn('json').dataType('jsonb').searchableJson(), metadata: { profile: csValue('metadata.profile'), settings: { diff --git a/packages/schema/README.md b/packages/schema/README.md index 8304e48..7e4c1e2 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -136,12 +136,6 @@ Set the data type for a column using `.dataType()`: const column = csColumn('field') .dataType('text') // text (default) .dataType('int') // integer - .dataType('big_int') // big integer - .dataType('small_int') // small integer - .dataType('real') // real number - .dataType('double') // double precision - .dataType('boolean') // boolean - .dataType('date') // date .dataType('jsonb') // JSON binary ``` @@ -189,10 +183,10 @@ const column = csColumn('field').freeTextSearch({ }) ``` -### JSON Field with Prefix +### Searchable JSON ```typescript -const column = csColumn('metadata').josn('meta') +const column = csColumn('metadata').searchableJson() ``` ## Type Safety @@ -288,7 +282,7 @@ Creates a column definition. - `.equality(tokenFilters?: TokenFilter[])`: Enable equality index - `.freeTextSearch(opts?: MatchIndexOpts)`: Enable text search - `.orderAndRange()`: Enable order and range index -- `.josn(prefix: string)`: Enable JSON field with prefix +- `.searchableJson()`: Enable searchable JSON index ### `csValue(valueName: string)` diff --git a/packages/schema/__tests__/schema.test.ts b/packages/schema/__tests__/schema.test.ts index 7dcae85..293f740 100644 --- a/packages/schema/__tests__/schema.test.ts +++ b/packages/schema/__tests__/schema.test.ts @@ -129,4 +129,17 @@ describe('Schema with nested columns', () => { indexes: {}, }) }) + + it('should handle ste_vec index for JSON columns', () => { + const users = csTable('users', { + json: csColumn('json').dataType('jsonb').searchableJson(), + } as const) + + const config = buildEncryptConfig(users) + + expect(config.tables.users.json.indexes).toHaveProperty('ste_vec') + expect(config.tables.users.json.indexes.ste_vec?.prefix).toEqual( + 'users/json', + ) + }) }) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 2780861..470b09e 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -188,8 +188,8 @@ export class ProtectColumn { /** * Enable a STE Vec index, requires a prefix. */ - searchableJson(prefix: string) { - this.indexesValue.ste_vec = { prefix } + searchableJson() { + this.indexesValue.ste_vec = { prefix: 'enabled' } return this } @@ -237,7 +237,25 @@ export class ProtectTable { colName: string, ) => { if (builder instanceof ProtectColumn) { - builtColumns[colName] = builder.build() + const builtColumn = builder.build() + + // Hanlde building the ste_vec index for JSON columns so users don't have to pass the prefix. + if ( + builtColumn.cast_as === 'jsonb' && + builtColumn.indexes.ste_vec?.prefix === 'enabled' + ) { + builtColumns[colName] = { + ...builtColumn, + indexes: { + ...builtColumn.indexes, + ste_vec: { + prefix: `${this.tableName}/${colName}`, + }, + }, + } + } else { + builtColumns[colName] = builtColumn + } } else { for (const [key, value] of Object.entries(builder)) { if (value instanceof ProtectValue) { From 17195f35ef652f66f3aa794fff0b912f11ce5035 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 21 Oct 2025 17:47:32 -0600 Subject: [PATCH 09/19] feat(protect): init ffi 0.18.0 pre release --- README.md | 1000 +---------------- packages/protect-dynamodb/README.md | 349 +----- packages/protect/README.md | 1000 +---------------- .../protect/__tests__/bulk-protect.test.ts | 14 +- packages/protect/package.json | 2 +- packages/protect/src/ffi/index.ts | 54 +- .../src/ffi/operations/bulk-decrypt-models.ts | 19 +- .../src/ffi/operations/bulk-decrypt.ts | 62 +- .../src/ffi/operations/bulk-encrypt-models.ts | 18 +- .../src/ffi/operations/bulk-encrypt.ts | 55 +- .../src/ffi/operations/decrypt-model.ts | 19 +- .../protect/src/ffi/operations/decrypt.ts | 26 +- .../src/ffi/operations/encrypt-model.ts | 18 +- .../protect/src/ffi/operations/encrypt.ts | 24 +- .../src/ffi/operations/search-terms.ts | 11 +- packages/protect/src/helpers/index.ts | 15 +- packages/protect/src/types.ts | 38 +- pnpm-lock.yaml | 50 +- 18 files changed, 287 insertions(+), 2487 deletions(-) diff --git a/README.md b/README.md index 87c8dfe..03a3a7c 100644 --- a/README.md +++ b/README.md @@ -50,1006 +50,32 @@ -Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database. - -Per‑value unique keys are powered by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) bulk key operations, backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html). - -Encrypted data is structured as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload and can be stored in any database that supports JSONB. - -> [!IMPORTANT] -> Searching, sorting, and filtering on encrypted data is currently only supported when storing encrypted data in PostgreSQL. -> Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md). - -Looking for DynamoDB support? Check out the [Protect.js for DynamoDB helper library](https://www.npmjs.com/package/@cipherstash/protect-dynamodb). - -## Quick start (60 seconds) - -Create an account and workspace in the [CipherStash dashboard](https://cipherstash.com/signup), then follow the onboarding guide to generate your client credentials and store them in your `.env` file. - -Install the package: - -```bash -npm install @cipherstash/protect -``` - -Start encrypting data: - -```ts -import { protect } from "@cipherstash/protect"; -import { csTable, csColumn } from "@cipherstash/protect"; - -// 1) Define a schema -const users = csTable("users", { email: csColumn("email") }); - -// 2) Create a client (requires CS_* env vars) -const client = await protect({ schemas: [users] }); - -// 3) Encrypt → store JSONB payload -const encrypted = await client.encrypt("alice@example.com", { - table: users, - column: users.email, -}); - -if (encrypted.failure) { - // You decide how to handle the failure and the user experience -} - -// 4) Decrypt later -const decrypted = await client.decrypt(encrypted.data); -``` - -## Architecture (high level) - -![Protect.js Architecture Diagram](https://github.com/cipherstash/protectjs/blob/main/docs/images/protectjs-architecture.png) - -## Table of contents - -- [Quick start (60 seconds)](#quick-start-60-seconds) -- [Architecture (high level)](#architecture-high-level) -- [Features](#features) -- [Installing Protect.js](#installing-protectjs) -- [Getting started](#getting-started) -- [Identity-aware encryption](#identity-aware-encryption) -- [Supported data types](#supported-data-types) -- [Searchable encryption](#searchable-encryption) -- [Logging](#logging) -- [CipherStash Client](#cipherstash-client) -- [Example applications](#example-applications) -- [Builds and bundling](#builds-and-bundling) -- [Contributing](#contributing) -- [License](#license) - -For more specific documentation, refer to the [docs](https://github.com/cipherstash/protectjs/tree/main/docs). - -## Features - -Protect.js protects data in using industry-standard AES encryption. -Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations. -This enables every encrypted value, in every column, in every row in your database to have a unique key — without sacrificing performance. - -**Features:** - -- **Bulk encryption and decryption**: Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for encrypting and decrypting thousands of records at once, while using a unique key for every value. -- **Single item encryption and decryption**: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered. -- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js. -- **Identity-aware encryption**: Lock down access to sensitive data by requiring a valid JWT to perform a decryption. -- **Audit trail**: Every decryption event will be logged in ZeroKMS to help you prove compliance. -- **Searchable encryption**: Protect.js supports searching encrypted data in PostgreSQL. -- **TypeScript support**: Strongly typed with TypeScript interfaces and types. - -**Use cases:** - -- **Trusted data access**: make sure only your end-users can access their sensitive data stored in your product. -- **Meet compliance requirements faster:** meet and exceed the data encryption requirements of SOC2 and ISO27001. -- **Reduce the blast radius of data breaches:** limit the impact of exploited vulnerabilities to only the data your end-users can decrypt. - -## Installing Protect.js - -Install the [`@cipherstash/protect` package](https://www.npmjs.com/package/@cipherstash/protect) with your package manager of choice: - -```bash -npm install @cipherstash/protect -# or -yarn add @cipherstash/protect -# or -pnpm add @cipherstash/protect -``` - -> [!TIP] -> [Bun](https://bun.sh/) is not currently supported due to a lack of [Node-API compatibility](https://github.com/oven-sh/bun/issues/158). Under the hood, Protect.js uses [CipherStash Client](#cipherstash-client) which is written in Rust and embedded using [Neon](https://github.com/neon-bindings/neon). - -### Opt-out of bundling - -> [!IMPORTANT] -> **You need to opt-out of bundling when using Protect.js.** - -Protect.js uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid). - -When using Protect.js, you need to opt-out of bundling for tools like [Webpack](https://webpack.js.org/configuration/externals/), [esbuild](https://webpack.js.org/configuration/externals/), or [Next.js](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages). - -Read more about [building and bundling with Protect.js](#builds-and-bundling). - -## Getting started - -- 🆕 **Existing app?** Skip to [the next step](#configuration). -- 🌱 **Clean slate?** Check out the [getting started tutorial](./docs/getting-started.md). - -### Configuration - -If you haven't already, sign up for a [CipherStash account](https://cipherstash.com/signup). -Once you have an account, you will create a Workspace which is scoped to your application environment. - -Follow the onboarding steps to get your first set of credentials required to use Protect.js. -By the end of the onboarding, you will have the following environment variables: - -```bash -CS_WORKSPACE_CRN= # The workspace identifier -CS_CLIENT_ID= # The client identifier -CS_CLIENT_KEY= # The client key which is used as key material in combination with ZeroKMS -CS_CLIENT_ACCESS_KEY= # The API key used for authenticating with the CipherStash API -``` - -Save these environment variables to a `.env` file in your project. - -### Basic file structure - -The following is the basic file structure of the project. -In the `src/protect/` directory, we have the table definition in `schema.ts` and the protect client in `index.ts`. - -``` -📦 - ├ 📂 src - │ ├ 📂 protect - │ │ ├ 📜 index.ts - │ │ └ 📜 schema.ts - │ └ 📜 index.ts - ├ 📜 .env - ├ 📜 cipherstash.toml - ├ 📜 cipherstash.secret.toml - ├ 📜 package.json - └ 📜 tsconfig.json -``` - -### Define your schema - -Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt. - -Define your tables and columns by adding this to `src/protect/schema.ts`: - -```ts -import { csTable, csColumn } from "@cipherstash/protect"; - -export const users = csTable("users", { - email: csColumn("email"), -}); - -export const orders = csTable("orders", { - address: csColumn("address"), -}); -``` - -**Searchable encryption:** - -If you want to search encrypted data in your PostgreSQL database, you must declare the indexes in schema in `src/protect/schema.ts`: - -```ts -import { csTable, csColumn } from "@cipherstash/protect"; - -export const users = csTable("users", { - email: csColumn("email").freeTextSearch().equality().orderAndRange(), -}); - -export const orders = csTable("orders", { - address: csColumn("address"), -}); -``` - -Read more about [defining your schema](./docs/reference/schema.md). - -### Initialize the Protect client - -To import the `protect` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`: - -```ts -import { protect, type ProtectClientConfig } from "@cipherstash/protect"; -import { users, orders } from "./schema"; - -const config: ProtectClientConfig = { - schemas: [users, orders], -} - -// Pass all your tables to the protect function to initialize the client -export const protectClient = await protect(config); -``` - -The `protect` function requires at least one `csTable` be provided in the `schemas` array. - -### Encrypt data - -Protect.js provides the `encrypt` function on `protectClient` to encrypt data. -`encrypt` takes a plaintext string, and an object with the table and column as parameters. - -To start encrypting data, add the following to `src/index.ts`: - -```typescript -import { users } from "./protect/schema"; -import { protectClient } from "./protect"; - -const encryptResult = await protectClient.encrypt("secret@squirrel.example", { - column: users.email, - table: users, -}); - -if (encryptResult.failure) { - // Handle the failure - console.log( - "error when encrypting:", - encryptResult.failure.type, - encryptResult.failure.message - ); -} - -console.log("EQL Payload containing ciphertexts:", encryptResult.data); -``` - -The `encrypt` function will return a `Result` object with either a `data` key, or a `failure` key. -The `encryptResult` will return one of the following: - -```typescript -// Success -{ - data: EncryptedPayload -} - -// Failure -{ - failure: { - type: 'EncryptionError', - message: 'A message about the error' - } -} -``` - -### Decrypt data - -Protect.js provides the `decrypt` function on `protectClient` to decrypt data. -`decrypt` takes an encrypted data object as a parameter. - -To start decrypting data, add the following to `src/index.ts`: - -```typescript -import { protectClient } from "./protect"; - -// encryptResult is the EQL payload from the previous step -const decryptResult = await protectClient.decrypt(encryptResult.data); - -if (decryptResult.failure) { - // Handle the failure - console.log( - "error when decrypting:", - decryptResult.failure.type, - decryptResult.failure.message - ); -} - -const plaintext = decryptResult.data; -console.log("plaintext:", plaintext); -``` - -The `decrypt` function returns a `Result` object with either a `data` key, or a `failure` key. -The `decryptResult` will return one of the following: - -```typescript -// Success -{ - data: 'secret@squirrel.example' -} - -// Failure -{ - failure: { - type: 'DecryptionError', - message: 'A message about the error' - } -} -``` - -### Working with models and objects - -Protect.js provides model-level encryption methods that make it easy to encrypt and decrypt entire objects. -These methods automatically handle the encryption of fields defined in your schema. - -If you are working with a large data set, the model operations are significantly faster than encrypting and decrypting individual objects as they are able to perform bulk operations. - -> [!TIP] -> CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) is optimized for bulk operations. -> -> All the model operations are able to take advantage of this performance for real-world use cases by only making a single call to ZeroKMS regardless of the number of objects you are encrypting or decrypting while still using a unique key for each record. - -#### Encrypting a model - -Use the `encryptModel` method to encrypt a model's fields that are defined in your schema: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Your model with plaintext values -const user = { - id: "1", - email: "user@example.com", - address: "123 Main St", - createdAt: new Date("2024-01-01"), -}; - -const encryptedResult = await protectClient.encryptModel(user, users); - -if (encryptedResult.failure) { - // Handle the failure - console.log( - "error when encrypting:", - encryptedResult.failure.type, - encryptedResult.failure.message - ); -} - -const encryptedUser = encryptedResult.data; -console.log("encrypted user:", encryptedUser); -``` - -The `encryptModel` function will only encrypt fields that are defined in your schema. -Other fields (like `id` and `createdAt` in the example above) will remain unchanged. - -#### Type safety with models - -Protect.js provides strong TypeScript support for model operations. -You can specify your model's type to ensure end-to-end type safety: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Define your model type -type User = { - id: string; - email: string | null; - address: string | null; - createdAt: Date; - updatedAt: Date; - metadata?: { - preferences?: { - notifications: boolean; - theme: string; - }; - }; -}; - -// The encryptModel method will ensure type safety -const encryptedResult = await protectClient.encryptModel(user, users); - -if (encryptedResult.failure) { - // Handle the failure -} - -const encryptedUser = encryptedResult.data; -// TypeScript knows that encryptedUser matches the User type structure -// but with encrypted fields for those defined in the schema - -// Decryption maintains type safety -const decryptedResult = await protectClient.decryptModel(encryptedUser); - -if (decryptedResult.failure) { - // Handle the failure -} - -const decryptedUser = decryptedResult.data; -// decryptedUser is fully typed as User - -// Bulk operations also support type safety -const bulkEncryptedResult = await protectClient.bulkEncryptModels( - userModels, - users -); - -const bulkDecryptedResult = await protectClient.bulkDecryptModels( - bulkEncryptedResult.data -); -``` - -The type system ensures that: - -- Input models match your defined type structure -- Only fields defined in your schema are encrypted -- Encrypted and decrypted results maintain the correct type structure -- Optional and nullable fields are properly handled -- Nested object structures are preserved -- Additional properties not defined in the schema remain unchanged - -This type safety helps catch potential issues at compile time and provides better IDE support with autocompletion and type hints. - -> [!TIP] -> When using TypeScript with an ORM, you can reuse your ORM's model types directly with Protect.js's model operations. - -Example with Drizzle infered types: - -```typescript -import { protectClient } from "./protect"; -import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core"; -import { csTable, csColumn } from "@cipherstash/protect"; - -const protectUsers = csTable("users", { - email: csColumn("email"), -}); - -const users = pgTable("users", { - id: serial("id").primaryKey(), - email: jsonb("email").notNull(), -}); - -type User = InferSelectModel; - -const user = { - id: "1", - email: "user@example.com", -}; - -// Drizzle User type works directly with model operations -const encryptedResult = await protectClient.encryptModel( - user, - protectUsers -); -``` - -#### Decrypting a model - -Use the `decryptModel` method to decrypt a model's encrypted fields: - -```typescript -import { protectClient } from "./protect"; - -const decryptedResult = await protectClient.decryptModel(encryptedUser); - -if (decryptedResult.failure) { - // Handle the failure - console.log( - "error when decrypting:", - decryptedResult.failure.type, - decryptedResult.failure.message - ); -} - -const decryptedUser = decryptedResult.data; -console.log("decrypted user:", decryptedUser); -``` - -#### Bulk model operations - -For better performance when working with multiple models, use the `bulkEncryptModels` and `bulkDecryptModels` methods: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Array of models with plaintext values -const userModels = [ - { - id: "1", - email: "user1@example.com", - address: "123 Main St", - }, - { - id: "2", - email: "user2@example.com", - address: "456 Oak Ave", - }, -]; - -// Encrypt multiple models at once -const encryptedResult = await protectClient.bulkEncryptModels( - userModels, - users -); - -if (encryptedResult.failure) { - // Handle the failure -} - -const encryptedUsers = encryptedResult.data; +## Getting Started -// Decrypt multiple models at once -const decryptedResult = await protectClient.bulkDecryptModels(encryptedUsers); - -if (decryptedResult.failure) { - // Handle the failure -} - -const decryptedUsers = decryptedResult.data; -``` - -The model encryption methods provide a higher-level interface that's particularly useful when working with ORMs or when you need to encrypt multiple fields in an object. -They automatically handle the mapping between your model's structure and the encrypted fields defined in your schema. - -### Bulk operations - -Protect.js provides direct access to ZeroKMS bulk operations through the `bulkEncrypt` and `bulkDecrypt` methods. These methods are ideal when you need maximum performance and want to handle the correlation between encrypted/decrypted values and your application data manually. - -> [!TIP] -> The bulk operations provide the most direct interface to ZeroKMS's blazing fast bulk encryption and decryption capabilities. Each value gets a unique key while maintaining optimal performance through a single call to ZeroKMS. - -#### Bulk encryption - -Use the `bulkEncrypt` method to encrypt multiple plaintext values at once: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Array of plaintext values with optional IDs for correlation -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: "bob@example.com" }, - { id: "user3", plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -if (encryptedResult.failure) { - // Handle the failure - console.log( - "error when bulk encrypting:", - encryptedResult.failure.type, - encryptedResult.failure.message - ); -} - -const encryptedData = encryptedResult.data; -console.log("encrypted data:", encryptedData); -``` - -The `bulkEncrypt` method returns an array of objects with the following structure: - -```typescript -[ - { id: "user1", data: EncryptedPayload }, - { id: "user2", data: EncryptedPayload }, - { id: "user3", data: EncryptedPayload }, -] -``` - -You can also encrypt without IDs if you don't need correlation: - -```typescript -const plaintexts = [ - { plaintext: "alice@example.com" }, - { plaintext: "bob@example.com" }, - { plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); -``` - -#### Bulk decryption - -Use the `bulkDecrypt` method to decrypt multiple encrypted values at once: - -```typescript -import { protectClient } from "./protect"; - -// encryptedData is the result from bulkEncrypt -const decryptedResult = await protectClient.bulkDecrypt(encryptedData); - -if (decryptedResult.failure) { - // Handle the failure - console.log( - "error when bulk decrypting:", - decryptedResult.failure.type, - decryptedResult.failure.message - ); -} - -const decryptedData = decryptedResult.data; -console.log("decrypted data:", decryptedData); -``` - -The `bulkDecrypt` method returns an array of objects with the following structure: - -```typescript -[ - { id: "user1", data: "alice@example.com" }, - { id: "user2", data: "bob@example.com" }, - { id: "user3", data: "charlie@example.com" }, -] -``` - -#### Response structure - -The `bulkDecrypt` method returns a `Result` object that represents the overall operation status. When successful from an HTTP and execution perspective, the `data` field contains an array where each item can have one of two outcomes: - -- **Success**: The item has a `data` field containing the decrypted plaintext -- **Failure**: The item has an `error` field containing a specific error message explaining why that particular decryption failed - -```typescript -// Example response structure -{ - data: [ - { id: "user1", data: "alice@example.com" }, // Success - { id: "user2", error: "Invalid ciphertext format" }, // Failure - { id: "user3", data: "charlie@example.com" }, // Success - ] -} -``` - -> [!NOTE] -> The underlying ZeroKMS response uses HTTP status code 207 (Multi-Status) to indicate that the bulk operation completed, but individual items within the batch may have succeeded or failed. This allows you to handle partial failures gracefully while still processing the successful decryptions. - -You can handle mixed results by checking each item: - -```typescript -const decryptedResult = await protectClient.bulkDecrypt(encryptedData); - -if (decryptedResult.failure) { - // Handle overall operation failure - console.log("Bulk decryption failed:", decryptedResult.failure.message); - return; -} - -// Process individual results -decryptedResult.data.forEach((item) => { - if ('data' in item) { - // Success - item.data contains the decrypted plaintext - console.log(`Decrypted ${item.id}:`, item.data); - } else if ('error' in item) { - // Failure - item.error contains the specific error message - console.log(`Failed to decrypt ${item.id}:`, item.error); - } -}); -``` - -#### Handling null values - -Bulk operations properly handle null values in both encryption and decryption: - -```typescript -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: null }, - { id: "user3", plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -// Null values are preserved in the encrypted result -// encryptedResult.data[1].data will be null - -const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); - -// Null values are preserved in the decrypted result -// decryptedResult.data[1].data will be null -``` - -#### Using bulk operations with lock contexts - -Bulk operations support identity-aware encryption through lock contexts: - -```typescript -import { LockContext } from "@cipherstash/protect/identify"; - -const lc = new LockContext(); -const lockContext = await lc.identify(userJwt); - -if (lockContext.failure) { - // Handle the failure -} - -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: "bob@example.com" }, -]; - -// Encrypt with lock context -const encryptedResult = await protectClient - .bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data); - -// Decrypt with lock context -const decryptedResult = await protectClient - .bulkDecrypt(encryptedResult.data) - .withLockContext(lockContext.data); -``` - -#### Performance considerations - -Bulk operations are optimized for performance and can handle thousands of values efficiently: - -```typescript -// Create a large array of values -const plaintexts = Array.from({ length: 1000 }, (_, i) => ({ - id: `user${i}`, - plaintext: `user${i}@example.com`, -})); - -// Single call to ZeroKMS for all 1000 values -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -// Single call to ZeroKMS for all 1000 values -const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); -``` - -The bulk operations maintain the same security guarantees as individual operations - each value gets a unique key - while providing optimal performance through ZeroKMS's bulk processing capabilities. - -### Store encrypted data in a database - -Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment. - -To store the encrypted data, specify the column type as `jsonb`. - -```sql -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - email jsonb NOT NULL, -); -``` - -#### Searchable encryption in PostgreSQL - -To enable searchable encryption in PostgreSQL, [install the EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation). - -1. Download the latest EQL install script: - - ```sh - curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql - ``` - - Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase. - Download the latest EQL install script: - - ```sh - curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt-supabase.sql - ``` - -2. Run this command to install the custom types and functions: - - ```sh - psql -f cipherstash-encrypt.sql - ``` - - or with Supabase: - - ```sh - psql -f cipherstash-encrypt-supabase.sql - ``` - -EQL is now installed in your database and you can enable searchable encryption by adding the `eql_v2_encrypted` type to a column. - -```sql -CREATE TABLE users ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - email eql_v2_encrypted -); -``` - -> [!WARNING] -> The `eql_v2_encrypted` type is a [composite type](https://www.postgresql.org/docs/current/rowtypes.html) and each ORM/client has a different way of handling inserts and selects. -> We've documented how to handle inserts and selects for the different ORMs/clients in the [docs](./docs/reference/working-with-composite-types.md). - -Read more about [how to search encrypted data](./docs/reference/searchable-encryption-postgres.md) in the docs. - -## Identity-aware encryption - -> [!IMPORTANT] -> Right now identity-aware encryption is only supported if you are using [Clerk](https://clerk.com/) as your identity provider. -> Read more about [lock contexts with Clerk and Next.js](./docs/how-to/lock-contexts-with-clerk.md). - -Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption. - -This ensures that only the user who encrypted data is able to decrypt it. - -Protect.js does this through a mechanism called a _lock context_. - -### Lock context - -Lock contexts ensure that only specific users can access sensitive data. - -> [!CAUTION] -> You must use the same lock context to encrypt and decrypt data. -> If you use different lock contexts, you will be unable to decrypt the data. - -To use a lock context, initialize a `LockContext` object with the identity claims. - -```typescript -import { LockContext } from "@cipherstash/protect/identify"; - -// protectClient from the previous steps -const lc = new LockContext(); -``` - -> [!NOTE] -> When initializing a `LockContext`, the default context is set to use the `sub` Identity Claim. - -### Identifying a user for a lock context - -A lock context needs to be locked to a user. -To identify the user, call the `identify` method on the lock context object, and pass a valid JWT from a user's session: - -```typescript -const identifyResult = await lc.identify(jwt); - -// The identify method returns the same Result pattern as the encrypt and decrypt methods. -if (identifyResult.failure) { - // Hanlde the failure -} - -const lockContext = identifyResult.data; -``` - -### Encrypting data with a lock context - -To encrypt data with a lock context, call the optional `withLockContext` method on the `encrypt` function and pass the lock context object as a parameter: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -const encryptResult = await protectClient - .encrypt("plaintext", { - table: users, - column: users.email, - }) - .withLockContext(lockContext); - -if (encryptResult.failure) { - // Handle the failure -} - -console.log("EQL Payload containing ciphertexts:", encryptResult.data); -``` - -### Decrypting data with a lock context - -To decrypt data with a lock context, call the optional `withLockContext` method on the `decrypt` function and pass the lock context object as a parameter: - -```typescript -import { protectClient } from "./protect"; - -const decryptResult = await protectClient - .decrypt(encryptResult.data) - .withLockContext(lockContext); - -if (decryptResult.failure) { - // Handle the failure -} - -const plaintext = decryptResult.data; -``` - -### Model encryption with lock context - -All model operations support lock contexts for identity-aware encryption: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -const myUsers = [ - { - id: "1", - email: "user@example.com", - address: "123 Main St", - createdAt: new Date("2024-01-01"), - }, - { - id: "2", - email: "user2@example.com", - address: "456 Oak Ave", - }, -]; - -// Encrypt a model with lock context -const encryptedResult = await protectClient - .encryptModel(myUsers[0], users) - .withLockContext(lockContext); - -if (encryptedResult.failure) { - // Handle the failure -} - -// Decrypt a model with lock context -const decryptedResult = await protectClient - .decryptModel(encryptedResult.data) - .withLockContext(lockContext); - -// Bulk operations also support lock contexts -const bulkEncryptedResult = await protectClient - .bulkEncryptModels(myUsers, users) - .withLockContext(lockContext); - -const bulkDecryptedResult = await protectClient - .bulkDecryptModels(bulkEncryptedResult.data) - .withLockContext(lockContext); -``` - -## Supported data types - -Protect.js currently supports encrypting and decrypting text. -Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon. - -Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48). - -## Searchable encryption - -Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs. - -## Logging - -> [!IMPORTANT] -> `@cipherstash/protect` will NEVER log plaintext data. -> This is by design to prevent sensitive data from leaking into logs. - -`@cipherstash/protect` and `@cipherstash/nextjs` will log to the console with a log level of `info` by default. -To enable the logger, configure the following environment variable: - -```bash -PROTECT_LOG_LEVEL=debug # Enable debug logging -PROTECT_LOG_LEVEL=info # Enable info logging -PROTECT_LOG_LEVEL=error # Enable error logging -``` - -## CipherStash Client - -Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the `@cipherstash/protect-ffi` package. -The `@cipherstash/protect-ffi` source code is available on [GitHub](https://github.com/cipherstash/protectjs-ffi). - -Read more about configuring the CipherStash Client in the [configuration docs](./docs/reference/configuration.md). - -## Example applications - -Looking for examples of how to use Protect.js? -Check out the [example applications](./examples): +Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database. -- [Basic example](/examples/basic) demonstrates how to perform encryption operations -- [Drizzle example](/examples/drizzle) demonstrates how to use Protect.js with an ORM -- [Next.js and lock contexts example using Clerk](/examples/nextjs-clerk) demonstrates how to protect data with identity-aware encryption +Create an account on [CipherStash](https://dashboard.cipherstash.com/sign-up) and create a workspace to start using Protect.js. -`@cipherstash/protect` can be used with most ORMs. -If you're interested in using `@cipherstash/protect` with a specific ORM, please [create an issue](https://github.com/cipherstash/protectjs/issues/new). +## Documentation -## Builds and bundling +Visit [https://cipherstash.com/docs](https://cipherstash.com/docs/sdk/protect/js) to view the full documentation. -`@cipherstash/protect` is a native Node.js module, and relies on native Node.js `require` to load the package. +## Contributing -Here are a few resources to help based on your tool set: +We welcome contributions to Protect.js. Please read the [contribution guide](https://github.com/cipherstash/protectjs/blob/main/CONTRIBUTE.md). -- [Required Next.js configuration](./docs/how-to/nextjs-external-packages.md). -- [SST and AWS serverless functions](./docs/how-to/sst-external-packages.md). - -> [!TIP] -> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [`docs/how-to/npm-lockfile-v3`](./docs/how-to/npm-lockfile-v3-linux-deployments.md). +## License -## Contributing +Protect.js is [MIT licensed](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) with copyright by CipherStash. -Please read the [contribution guide](CONTRIBUTE.md). +## Security -## License +If you believe you have found a security vulnerability in Protect.js, we encourage you to **_responsibly disclose this and NOT open a public issue_**. -Protect.js is [MIT licensed](./LICENSE.md). +Please email [security@cipherstash.com](mailto:security@cipherstash.com). We will provide further instructions for submitting your report. --- -### Didn't find what you wanted? +## Didn't find what you wanted? [Click here to let us know what was missing from our docs.](https://github.com/cipherstash/protectjs/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md) diff --git a/packages/protect-dynamodb/README.md b/packages/protect-dynamodb/README.md index e52ffe6..258dffd 100644 --- a/packages/protect-dynamodb/README.md +++ b/packages/protect-dynamodb/README.md @@ -6,350 +6,35 @@ Helpers for using CipherStash [Protect.js](https://github.com/cipherstash/protec [![NPM version](https://img.shields.io/npm/v/@cipherstash/protect-dynamodb.svg?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/@cipherstash/protect-dynamodb) [![License](https://img.shields.io/npm/l/@cipherstash/protect.svg?style=for-the-badge&labelColor=000000)](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) -## Installation +## Getting Started -```bash -npm install @cipherstash/protect-dynamodb -# or -yarn add @cipherstash/protect-dynamodb -# or -pnpm add @cipherstash/protect-dynamodb -``` +In order to use Protect.js with DynamoDB, you need to be familiar with the following: -## Quick Start +- [Protect.js](https://cipherstash.com/docs/sdk/protect/js) +- [DynamoDB](https://aws.amazon.com/dynamodb/) -```typescript -import { protectDynamoDB } from '@cipherstash/protect-dynamodb' -import { protect, csColumn, csTable } from '@cipherstash/protect' -import { PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb' +Create an account on [CipherStash](https://dashboard.cipherstash.com/sign-up) and create a workspace to start using Protect.js. -// Define your protected table schema -const users = csTable('users', { - email: csColumn('email').equality(), -}) +## Documentation -// Initialize the Protect client -const protectClient = await protect({ - schemas: [users], -}) +Visit [https://cipherstash.com/docs](https://cipherstash.com/docs/guides/protectjs/dynamodb) to view the full documentation. -// Create the DynamoDB helper instance -const protectDynamo = protectDynamoDB({ - protectClient, -}) +## Contributing -// Encrypt and store a user -const user = { - email: 'user@example.com', -} +We welcome contributions to Protect.js. Please read the [contribution guide](https://github.com/cipherstash/protectjs/blob/main/CONTRIBUTE.md). -const encryptResult = await protectDynamo.encryptModel(user, users) -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} +## License -// Store in DynamoDB -await docClient.send(new PutCommand({ - TableName: 'Users', - Item: encryptResult.data, -})) +Protect.js is [MIT licensed](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) with copyright by CipherStash. -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) +## Security -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} +If you believe you have found a security vulnerability in Protect.js, we encourage you to **_responsibly disclose this and NOT open a public issue_**. -// Query using the search term -const [emailHmac] = searchTermsResult.data -const result = await docClient.send(new GetCommand({ - TableName: 'Users', - Key: { email__hmac: emailHmac }, -})) +Please email [security@cipherstash.com](mailto:security@cipherstash.com). We will provide further instructions for submitting your report. -if (!result.Item) { - throw new Error('Item not found') -} +--- -// Decrypt the result -const decryptResult = await protectDynamo.decryptModel( - result.Item, - users, -) +## Didn't find what you wanted? -if (decryptResult.failure) { - throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`) -} - -const decryptedUser = decryptResult.data -``` - -## Features - -### Encryption and Decryption - -The package provides methods to encrypt and decrypt data for DynamoDB: - -- `encryptModel`: Encrypts a single model -- `bulkEncryptModels`: Encrypts multiple models in bulk -- `decryptModel`: Decrypts a single model -- `bulkDecryptModels`: Decrypts multiple models in bulk - -All methods return a `Result` type that must be checked for failures: - -```typescript -const result = await protectDynamo.encryptModel(user, users) -if (result.failure) { - // Handle error - console.error(result.failure.message) -} else { - // Use encrypted data - const encryptedData = result.data -} -``` - -### Search Terms - -Create search terms for querying encrypted data: - -- `createSearchTerms`: Creates search terms for one or more columns - -```typescript -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### DynamoDB Integration - -The package automatically handles: -- Converting encrypted data to DynamoDB's format -- Adding HMAC attributes for searchable fields -- Preserving unencrypted fields -- Converting DynamoDB items back to encrypted format for decryption - -## Usage Patterns - -### Simple Table with Encrypted Fields - -```typescript -const users = csTable('users', { - email: csColumn('email').equality(), -}) - -// Encrypt and store -const encryptResult = await protectDynamo.encryptModel({ - pk: 'user#1', - email: 'user@example.com', -}, users) - -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} - -// Query using search terms -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} -``` - -### Encrypted Partition Key - -```typescript -// Table with encrypted partition key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'email__hmac', - KeyType: 'HASH', - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### Encrypted Sort Key - -```typescript -// Table with encrypted sort key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'pk', - AttributeType: 'S', - }, - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'pk', - KeyType: 'HASH', - }, - { - AttributeName: 'email__hmac', - KeyType: 'RANGE', - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### Global Secondary Index with Encrypted Key - -```typescript -// Table with GSI using encrypted key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'pk', - AttributeType: 'S', - }, - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'pk', - KeyType: 'HASH', - }, - ], - GlobalSecondaryIndexes: [ - { - IndexName: 'EmailIndex', - KeySchema: [ - { - AttributeName: 'email__hmac', - KeyType: 'HASH', - }, - ], - Projection: { - ProjectionType: 'INCLUDE', - NonKeyAttributes: ['email__source'], - }, - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -## Error Handling - -All methods return a `Result` type from `@byteslice/result` that must be checked for failures: - -```typescript -const result = await protectDynamo.encryptModel(user, users) - -if (result.failure) { - // Handle error - console.error(result.failure.message) -} else { - // Use encrypted data - const encryptedData = result.data -} -``` - -## Type Safety - -The package is fully typed and supports TypeScript: - -```typescript -type User = { - pk: string - email: string -} - -// Type-safe encryption -const encryptResult = await protectDynamo.encryptModel(user, users) -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} -const encryptedUser = encryptResult.data - -// Type-safe decryption -const decryptResult = await protectDynamo.decryptModel(item, users) -if (decryptResult.failure) { - throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`) -} -const decryptedUser = decryptResult.data -``` +[Click here to let us know what was missing from our docs.](https://github.com/cipherstash/protectjs/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md) diff --git a/packages/protect/README.md b/packages/protect/README.md index 87c8dfe..03a3a7c 100644 --- a/packages/protect/README.md +++ b/packages/protect/README.md @@ -50,1006 +50,32 @@ -Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database. - -Per‑value unique keys are powered by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) bulk key operations, backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html). - -Encrypted data is structured as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload and can be stored in any database that supports JSONB. - -> [!IMPORTANT] -> Searching, sorting, and filtering on encrypted data is currently only supported when storing encrypted data in PostgreSQL. -> Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md). - -Looking for DynamoDB support? Check out the [Protect.js for DynamoDB helper library](https://www.npmjs.com/package/@cipherstash/protect-dynamodb). - -## Quick start (60 seconds) - -Create an account and workspace in the [CipherStash dashboard](https://cipherstash.com/signup), then follow the onboarding guide to generate your client credentials and store them in your `.env` file. - -Install the package: - -```bash -npm install @cipherstash/protect -``` - -Start encrypting data: - -```ts -import { protect } from "@cipherstash/protect"; -import { csTable, csColumn } from "@cipherstash/protect"; - -// 1) Define a schema -const users = csTable("users", { email: csColumn("email") }); - -// 2) Create a client (requires CS_* env vars) -const client = await protect({ schemas: [users] }); - -// 3) Encrypt → store JSONB payload -const encrypted = await client.encrypt("alice@example.com", { - table: users, - column: users.email, -}); - -if (encrypted.failure) { - // You decide how to handle the failure and the user experience -} - -// 4) Decrypt later -const decrypted = await client.decrypt(encrypted.data); -``` - -## Architecture (high level) - -![Protect.js Architecture Diagram](https://github.com/cipherstash/protectjs/blob/main/docs/images/protectjs-architecture.png) - -## Table of contents - -- [Quick start (60 seconds)](#quick-start-60-seconds) -- [Architecture (high level)](#architecture-high-level) -- [Features](#features) -- [Installing Protect.js](#installing-protectjs) -- [Getting started](#getting-started) -- [Identity-aware encryption](#identity-aware-encryption) -- [Supported data types](#supported-data-types) -- [Searchable encryption](#searchable-encryption) -- [Logging](#logging) -- [CipherStash Client](#cipherstash-client) -- [Example applications](#example-applications) -- [Builds and bundling](#builds-and-bundling) -- [Contributing](#contributing) -- [License](#license) - -For more specific documentation, refer to the [docs](https://github.com/cipherstash/protectjs/tree/main/docs). - -## Features - -Protect.js protects data in using industry-standard AES encryption. -Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations. -This enables every encrypted value, in every column, in every row in your database to have a unique key — without sacrificing performance. - -**Features:** - -- **Bulk encryption and decryption**: Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for encrypting and decrypting thousands of records at once, while using a unique key for every value. -- **Single item encryption and decryption**: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered. -- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js. -- **Identity-aware encryption**: Lock down access to sensitive data by requiring a valid JWT to perform a decryption. -- **Audit trail**: Every decryption event will be logged in ZeroKMS to help you prove compliance. -- **Searchable encryption**: Protect.js supports searching encrypted data in PostgreSQL. -- **TypeScript support**: Strongly typed with TypeScript interfaces and types. - -**Use cases:** - -- **Trusted data access**: make sure only your end-users can access their sensitive data stored in your product. -- **Meet compliance requirements faster:** meet and exceed the data encryption requirements of SOC2 and ISO27001. -- **Reduce the blast radius of data breaches:** limit the impact of exploited vulnerabilities to only the data your end-users can decrypt. - -## Installing Protect.js - -Install the [`@cipherstash/protect` package](https://www.npmjs.com/package/@cipherstash/protect) with your package manager of choice: - -```bash -npm install @cipherstash/protect -# or -yarn add @cipherstash/protect -# or -pnpm add @cipherstash/protect -``` - -> [!TIP] -> [Bun](https://bun.sh/) is not currently supported due to a lack of [Node-API compatibility](https://github.com/oven-sh/bun/issues/158). Under the hood, Protect.js uses [CipherStash Client](#cipherstash-client) which is written in Rust and embedded using [Neon](https://github.com/neon-bindings/neon). - -### Opt-out of bundling - -> [!IMPORTANT] -> **You need to opt-out of bundling when using Protect.js.** - -Protect.js uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid). - -When using Protect.js, you need to opt-out of bundling for tools like [Webpack](https://webpack.js.org/configuration/externals/), [esbuild](https://webpack.js.org/configuration/externals/), or [Next.js](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages). - -Read more about [building and bundling with Protect.js](#builds-and-bundling). - -## Getting started - -- 🆕 **Existing app?** Skip to [the next step](#configuration). -- 🌱 **Clean slate?** Check out the [getting started tutorial](./docs/getting-started.md). - -### Configuration - -If you haven't already, sign up for a [CipherStash account](https://cipherstash.com/signup). -Once you have an account, you will create a Workspace which is scoped to your application environment. - -Follow the onboarding steps to get your first set of credentials required to use Protect.js. -By the end of the onboarding, you will have the following environment variables: - -```bash -CS_WORKSPACE_CRN= # The workspace identifier -CS_CLIENT_ID= # The client identifier -CS_CLIENT_KEY= # The client key which is used as key material in combination with ZeroKMS -CS_CLIENT_ACCESS_KEY= # The API key used for authenticating with the CipherStash API -``` - -Save these environment variables to a `.env` file in your project. - -### Basic file structure - -The following is the basic file structure of the project. -In the `src/protect/` directory, we have the table definition in `schema.ts` and the protect client in `index.ts`. - -``` -📦 - ├ 📂 src - │ ├ 📂 protect - │ │ ├ 📜 index.ts - │ │ └ 📜 schema.ts - │ └ 📜 index.ts - ├ 📜 .env - ├ 📜 cipherstash.toml - ├ 📜 cipherstash.secret.toml - ├ 📜 package.json - └ 📜 tsconfig.json -``` - -### Define your schema - -Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt. - -Define your tables and columns by adding this to `src/protect/schema.ts`: - -```ts -import { csTable, csColumn } from "@cipherstash/protect"; - -export const users = csTable("users", { - email: csColumn("email"), -}); - -export const orders = csTable("orders", { - address: csColumn("address"), -}); -``` - -**Searchable encryption:** - -If you want to search encrypted data in your PostgreSQL database, you must declare the indexes in schema in `src/protect/schema.ts`: - -```ts -import { csTable, csColumn } from "@cipherstash/protect"; - -export const users = csTable("users", { - email: csColumn("email").freeTextSearch().equality().orderAndRange(), -}); - -export const orders = csTable("orders", { - address: csColumn("address"), -}); -``` - -Read more about [defining your schema](./docs/reference/schema.md). - -### Initialize the Protect client - -To import the `protect` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`: - -```ts -import { protect, type ProtectClientConfig } from "@cipherstash/protect"; -import { users, orders } from "./schema"; - -const config: ProtectClientConfig = { - schemas: [users, orders], -} - -// Pass all your tables to the protect function to initialize the client -export const protectClient = await protect(config); -``` - -The `protect` function requires at least one `csTable` be provided in the `schemas` array. - -### Encrypt data - -Protect.js provides the `encrypt` function on `protectClient` to encrypt data. -`encrypt` takes a plaintext string, and an object with the table and column as parameters. - -To start encrypting data, add the following to `src/index.ts`: - -```typescript -import { users } from "./protect/schema"; -import { protectClient } from "./protect"; - -const encryptResult = await protectClient.encrypt("secret@squirrel.example", { - column: users.email, - table: users, -}); - -if (encryptResult.failure) { - // Handle the failure - console.log( - "error when encrypting:", - encryptResult.failure.type, - encryptResult.failure.message - ); -} - -console.log("EQL Payload containing ciphertexts:", encryptResult.data); -``` - -The `encrypt` function will return a `Result` object with either a `data` key, or a `failure` key. -The `encryptResult` will return one of the following: - -```typescript -// Success -{ - data: EncryptedPayload -} - -// Failure -{ - failure: { - type: 'EncryptionError', - message: 'A message about the error' - } -} -``` - -### Decrypt data - -Protect.js provides the `decrypt` function on `protectClient` to decrypt data. -`decrypt` takes an encrypted data object as a parameter. - -To start decrypting data, add the following to `src/index.ts`: - -```typescript -import { protectClient } from "./protect"; - -// encryptResult is the EQL payload from the previous step -const decryptResult = await protectClient.decrypt(encryptResult.data); - -if (decryptResult.failure) { - // Handle the failure - console.log( - "error when decrypting:", - decryptResult.failure.type, - decryptResult.failure.message - ); -} - -const plaintext = decryptResult.data; -console.log("plaintext:", plaintext); -``` - -The `decrypt` function returns a `Result` object with either a `data` key, or a `failure` key. -The `decryptResult` will return one of the following: - -```typescript -// Success -{ - data: 'secret@squirrel.example' -} - -// Failure -{ - failure: { - type: 'DecryptionError', - message: 'A message about the error' - } -} -``` - -### Working with models and objects - -Protect.js provides model-level encryption methods that make it easy to encrypt and decrypt entire objects. -These methods automatically handle the encryption of fields defined in your schema. - -If you are working with a large data set, the model operations are significantly faster than encrypting and decrypting individual objects as they are able to perform bulk operations. - -> [!TIP] -> CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) is optimized for bulk operations. -> -> All the model operations are able to take advantage of this performance for real-world use cases by only making a single call to ZeroKMS regardless of the number of objects you are encrypting or decrypting while still using a unique key for each record. - -#### Encrypting a model - -Use the `encryptModel` method to encrypt a model's fields that are defined in your schema: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Your model with plaintext values -const user = { - id: "1", - email: "user@example.com", - address: "123 Main St", - createdAt: new Date("2024-01-01"), -}; - -const encryptedResult = await protectClient.encryptModel(user, users); - -if (encryptedResult.failure) { - // Handle the failure - console.log( - "error when encrypting:", - encryptedResult.failure.type, - encryptedResult.failure.message - ); -} - -const encryptedUser = encryptedResult.data; -console.log("encrypted user:", encryptedUser); -``` - -The `encryptModel` function will only encrypt fields that are defined in your schema. -Other fields (like `id` and `createdAt` in the example above) will remain unchanged. - -#### Type safety with models - -Protect.js provides strong TypeScript support for model operations. -You can specify your model's type to ensure end-to-end type safety: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Define your model type -type User = { - id: string; - email: string | null; - address: string | null; - createdAt: Date; - updatedAt: Date; - metadata?: { - preferences?: { - notifications: boolean; - theme: string; - }; - }; -}; - -// The encryptModel method will ensure type safety -const encryptedResult = await protectClient.encryptModel(user, users); - -if (encryptedResult.failure) { - // Handle the failure -} - -const encryptedUser = encryptedResult.data; -// TypeScript knows that encryptedUser matches the User type structure -// but with encrypted fields for those defined in the schema - -// Decryption maintains type safety -const decryptedResult = await protectClient.decryptModel(encryptedUser); - -if (decryptedResult.failure) { - // Handle the failure -} - -const decryptedUser = decryptedResult.data; -// decryptedUser is fully typed as User - -// Bulk operations also support type safety -const bulkEncryptedResult = await protectClient.bulkEncryptModels( - userModels, - users -); - -const bulkDecryptedResult = await protectClient.bulkDecryptModels( - bulkEncryptedResult.data -); -``` - -The type system ensures that: - -- Input models match your defined type structure -- Only fields defined in your schema are encrypted -- Encrypted and decrypted results maintain the correct type structure -- Optional and nullable fields are properly handled -- Nested object structures are preserved -- Additional properties not defined in the schema remain unchanged - -This type safety helps catch potential issues at compile time and provides better IDE support with autocompletion and type hints. - -> [!TIP] -> When using TypeScript with an ORM, you can reuse your ORM's model types directly with Protect.js's model operations. - -Example with Drizzle infered types: - -```typescript -import { protectClient } from "./protect"; -import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core"; -import { csTable, csColumn } from "@cipherstash/protect"; - -const protectUsers = csTable("users", { - email: csColumn("email"), -}); - -const users = pgTable("users", { - id: serial("id").primaryKey(), - email: jsonb("email").notNull(), -}); - -type User = InferSelectModel; - -const user = { - id: "1", - email: "user@example.com", -}; - -// Drizzle User type works directly with model operations -const encryptedResult = await protectClient.encryptModel( - user, - protectUsers -); -``` - -#### Decrypting a model - -Use the `decryptModel` method to decrypt a model's encrypted fields: - -```typescript -import { protectClient } from "./protect"; - -const decryptedResult = await protectClient.decryptModel(encryptedUser); - -if (decryptedResult.failure) { - // Handle the failure - console.log( - "error when decrypting:", - decryptedResult.failure.type, - decryptedResult.failure.message - ); -} - -const decryptedUser = decryptedResult.data; -console.log("decrypted user:", decryptedUser); -``` - -#### Bulk model operations - -For better performance when working with multiple models, use the `bulkEncryptModels` and `bulkDecryptModels` methods: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Array of models with plaintext values -const userModels = [ - { - id: "1", - email: "user1@example.com", - address: "123 Main St", - }, - { - id: "2", - email: "user2@example.com", - address: "456 Oak Ave", - }, -]; - -// Encrypt multiple models at once -const encryptedResult = await protectClient.bulkEncryptModels( - userModels, - users -); - -if (encryptedResult.failure) { - // Handle the failure -} - -const encryptedUsers = encryptedResult.data; +## Getting Started -// Decrypt multiple models at once -const decryptedResult = await protectClient.bulkDecryptModels(encryptedUsers); - -if (decryptedResult.failure) { - // Handle the failure -} - -const decryptedUsers = decryptedResult.data; -``` - -The model encryption methods provide a higher-level interface that's particularly useful when working with ORMs or when you need to encrypt multiple fields in an object. -They automatically handle the mapping between your model's structure and the encrypted fields defined in your schema. - -### Bulk operations - -Protect.js provides direct access to ZeroKMS bulk operations through the `bulkEncrypt` and `bulkDecrypt` methods. These methods are ideal when you need maximum performance and want to handle the correlation between encrypted/decrypted values and your application data manually. - -> [!TIP] -> The bulk operations provide the most direct interface to ZeroKMS's blazing fast bulk encryption and decryption capabilities. Each value gets a unique key while maintaining optimal performance through a single call to ZeroKMS. - -#### Bulk encryption - -Use the `bulkEncrypt` method to encrypt multiple plaintext values at once: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Array of plaintext values with optional IDs for correlation -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: "bob@example.com" }, - { id: "user3", plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -if (encryptedResult.failure) { - // Handle the failure - console.log( - "error when bulk encrypting:", - encryptedResult.failure.type, - encryptedResult.failure.message - ); -} - -const encryptedData = encryptedResult.data; -console.log("encrypted data:", encryptedData); -``` - -The `bulkEncrypt` method returns an array of objects with the following structure: - -```typescript -[ - { id: "user1", data: EncryptedPayload }, - { id: "user2", data: EncryptedPayload }, - { id: "user3", data: EncryptedPayload }, -] -``` - -You can also encrypt without IDs if you don't need correlation: - -```typescript -const plaintexts = [ - { plaintext: "alice@example.com" }, - { plaintext: "bob@example.com" }, - { plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); -``` - -#### Bulk decryption - -Use the `bulkDecrypt` method to decrypt multiple encrypted values at once: - -```typescript -import { protectClient } from "./protect"; - -// encryptedData is the result from bulkEncrypt -const decryptedResult = await protectClient.bulkDecrypt(encryptedData); - -if (decryptedResult.failure) { - // Handle the failure - console.log( - "error when bulk decrypting:", - decryptedResult.failure.type, - decryptedResult.failure.message - ); -} - -const decryptedData = decryptedResult.data; -console.log("decrypted data:", decryptedData); -``` - -The `bulkDecrypt` method returns an array of objects with the following structure: - -```typescript -[ - { id: "user1", data: "alice@example.com" }, - { id: "user2", data: "bob@example.com" }, - { id: "user3", data: "charlie@example.com" }, -] -``` - -#### Response structure - -The `bulkDecrypt` method returns a `Result` object that represents the overall operation status. When successful from an HTTP and execution perspective, the `data` field contains an array where each item can have one of two outcomes: - -- **Success**: The item has a `data` field containing the decrypted plaintext -- **Failure**: The item has an `error` field containing a specific error message explaining why that particular decryption failed - -```typescript -// Example response structure -{ - data: [ - { id: "user1", data: "alice@example.com" }, // Success - { id: "user2", error: "Invalid ciphertext format" }, // Failure - { id: "user3", data: "charlie@example.com" }, // Success - ] -} -``` - -> [!NOTE] -> The underlying ZeroKMS response uses HTTP status code 207 (Multi-Status) to indicate that the bulk operation completed, but individual items within the batch may have succeeded or failed. This allows you to handle partial failures gracefully while still processing the successful decryptions. - -You can handle mixed results by checking each item: - -```typescript -const decryptedResult = await protectClient.bulkDecrypt(encryptedData); - -if (decryptedResult.failure) { - // Handle overall operation failure - console.log("Bulk decryption failed:", decryptedResult.failure.message); - return; -} - -// Process individual results -decryptedResult.data.forEach((item) => { - if ('data' in item) { - // Success - item.data contains the decrypted plaintext - console.log(`Decrypted ${item.id}:`, item.data); - } else if ('error' in item) { - // Failure - item.error contains the specific error message - console.log(`Failed to decrypt ${item.id}:`, item.error); - } -}); -``` - -#### Handling null values - -Bulk operations properly handle null values in both encryption and decryption: - -```typescript -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: null }, - { id: "user3", plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -// Null values are preserved in the encrypted result -// encryptedResult.data[1].data will be null - -const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); - -// Null values are preserved in the decrypted result -// decryptedResult.data[1].data will be null -``` - -#### Using bulk operations with lock contexts - -Bulk operations support identity-aware encryption through lock contexts: - -```typescript -import { LockContext } from "@cipherstash/protect/identify"; - -const lc = new LockContext(); -const lockContext = await lc.identify(userJwt); - -if (lockContext.failure) { - // Handle the failure -} - -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: "bob@example.com" }, -]; - -// Encrypt with lock context -const encryptedResult = await protectClient - .bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data); - -// Decrypt with lock context -const decryptedResult = await protectClient - .bulkDecrypt(encryptedResult.data) - .withLockContext(lockContext.data); -``` - -#### Performance considerations - -Bulk operations are optimized for performance and can handle thousands of values efficiently: - -```typescript -// Create a large array of values -const plaintexts = Array.from({ length: 1000 }, (_, i) => ({ - id: `user${i}`, - plaintext: `user${i}@example.com`, -})); - -// Single call to ZeroKMS for all 1000 values -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -// Single call to ZeroKMS for all 1000 values -const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); -``` - -The bulk operations maintain the same security guarantees as individual operations - each value gets a unique key - while providing optimal performance through ZeroKMS's bulk processing capabilities. - -### Store encrypted data in a database - -Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment. - -To store the encrypted data, specify the column type as `jsonb`. - -```sql -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - email jsonb NOT NULL, -); -``` - -#### Searchable encryption in PostgreSQL - -To enable searchable encryption in PostgreSQL, [install the EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation). - -1. Download the latest EQL install script: - - ```sh - curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql - ``` - - Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase. - Download the latest EQL install script: - - ```sh - curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt-supabase.sql - ``` - -2. Run this command to install the custom types and functions: - - ```sh - psql -f cipherstash-encrypt.sql - ``` - - or with Supabase: - - ```sh - psql -f cipherstash-encrypt-supabase.sql - ``` - -EQL is now installed in your database and you can enable searchable encryption by adding the `eql_v2_encrypted` type to a column. - -```sql -CREATE TABLE users ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - email eql_v2_encrypted -); -``` - -> [!WARNING] -> The `eql_v2_encrypted` type is a [composite type](https://www.postgresql.org/docs/current/rowtypes.html) and each ORM/client has a different way of handling inserts and selects. -> We've documented how to handle inserts and selects for the different ORMs/clients in the [docs](./docs/reference/working-with-composite-types.md). - -Read more about [how to search encrypted data](./docs/reference/searchable-encryption-postgres.md) in the docs. - -## Identity-aware encryption - -> [!IMPORTANT] -> Right now identity-aware encryption is only supported if you are using [Clerk](https://clerk.com/) as your identity provider. -> Read more about [lock contexts with Clerk and Next.js](./docs/how-to/lock-contexts-with-clerk.md). - -Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption. - -This ensures that only the user who encrypted data is able to decrypt it. - -Protect.js does this through a mechanism called a _lock context_. - -### Lock context - -Lock contexts ensure that only specific users can access sensitive data. - -> [!CAUTION] -> You must use the same lock context to encrypt and decrypt data. -> If you use different lock contexts, you will be unable to decrypt the data. - -To use a lock context, initialize a `LockContext` object with the identity claims. - -```typescript -import { LockContext } from "@cipherstash/protect/identify"; - -// protectClient from the previous steps -const lc = new LockContext(); -``` - -> [!NOTE] -> When initializing a `LockContext`, the default context is set to use the `sub` Identity Claim. - -### Identifying a user for a lock context - -A lock context needs to be locked to a user. -To identify the user, call the `identify` method on the lock context object, and pass a valid JWT from a user's session: - -```typescript -const identifyResult = await lc.identify(jwt); - -// The identify method returns the same Result pattern as the encrypt and decrypt methods. -if (identifyResult.failure) { - // Hanlde the failure -} - -const lockContext = identifyResult.data; -``` - -### Encrypting data with a lock context - -To encrypt data with a lock context, call the optional `withLockContext` method on the `encrypt` function and pass the lock context object as a parameter: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -const encryptResult = await protectClient - .encrypt("plaintext", { - table: users, - column: users.email, - }) - .withLockContext(lockContext); - -if (encryptResult.failure) { - // Handle the failure -} - -console.log("EQL Payload containing ciphertexts:", encryptResult.data); -``` - -### Decrypting data with a lock context - -To decrypt data with a lock context, call the optional `withLockContext` method on the `decrypt` function and pass the lock context object as a parameter: - -```typescript -import { protectClient } from "./protect"; - -const decryptResult = await protectClient - .decrypt(encryptResult.data) - .withLockContext(lockContext); - -if (decryptResult.failure) { - // Handle the failure -} - -const plaintext = decryptResult.data; -``` - -### Model encryption with lock context - -All model operations support lock contexts for identity-aware encryption: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -const myUsers = [ - { - id: "1", - email: "user@example.com", - address: "123 Main St", - createdAt: new Date("2024-01-01"), - }, - { - id: "2", - email: "user2@example.com", - address: "456 Oak Ave", - }, -]; - -// Encrypt a model with lock context -const encryptedResult = await protectClient - .encryptModel(myUsers[0], users) - .withLockContext(lockContext); - -if (encryptedResult.failure) { - // Handle the failure -} - -// Decrypt a model with lock context -const decryptedResult = await protectClient - .decryptModel(encryptedResult.data) - .withLockContext(lockContext); - -// Bulk operations also support lock contexts -const bulkEncryptedResult = await protectClient - .bulkEncryptModels(myUsers, users) - .withLockContext(lockContext); - -const bulkDecryptedResult = await protectClient - .bulkDecryptModels(bulkEncryptedResult.data) - .withLockContext(lockContext); -``` - -## Supported data types - -Protect.js currently supports encrypting and decrypting text. -Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon. - -Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48). - -## Searchable encryption - -Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs. - -## Logging - -> [!IMPORTANT] -> `@cipherstash/protect` will NEVER log plaintext data. -> This is by design to prevent sensitive data from leaking into logs. - -`@cipherstash/protect` and `@cipherstash/nextjs` will log to the console with a log level of `info` by default. -To enable the logger, configure the following environment variable: - -```bash -PROTECT_LOG_LEVEL=debug # Enable debug logging -PROTECT_LOG_LEVEL=info # Enable info logging -PROTECT_LOG_LEVEL=error # Enable error logging -``` - -## CipherStash Client - -Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the `@cipherstash/protect-ffi` package. -The `@cipherstash/protect-ffi` source code is available on [GitHub](https://github.com/cipherstash/protectjs-ffi). - -Read more about configuring the CipherStash Client in the [configuration docs](./docs/reference/configuration.md). - -## Example applications - -Looking for examples of how to use Protect.js? -Check out the [example applications](./examples): +Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database. -- [Basic example](/examples/basic) demonstrates how to perform encryption operations -- [Drizzle example](/examples/drizzle) demonstrates how to use Protect.js with an ORM -- [Next.js and lock contexts example using Clerk](/examples/nextjs-clerk) demonstrates how to protect data with identity-aware encryption +Create an account on [CipherStash](https://dashboard.cipherstash.com/sign-up) and create a workspace to start using Protect.js. -`@cipherstash/protect` can be used with most ORMs. -If you're interested in using `@cipherstash/protect` with a specific ORM, please [create an issue](https://github.com/cipherstash/protectjs/issues/new). +## Documentation -## Builds and bundling +Visit [https://cipherstash.com/docs](https://cipherstash.com/docs/sdk/protect/js) to view the full documentation. -`@cipherstash/protect` is a native Node.js module, and relies on native Node.js `require` to load the package. +## Contributing -Here are a few resources to help based on your tool set: +We welcome contributions to Protect.js. Please read the [contribution guide](https://github.com/cipherstash/protectjs/blob/main/CONTRIBUTE.md). -- [Required Next.js configuration](./docs/how-to/nextjs-external-packages.md). -- [SST and AWS serverless functions](./docs/how-to/sst-external-packages.md). - -> [!TIP] -> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [`docs/how-to/npm-lockfile-v3`](./docs/how-to/npm-lockfile-v3-linux-deployments.md). +## License -## Contributing +Protect.js is [MIT licensed](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) with copyright by CipherStash. -Please read the [contribution guide](CONTRIBUTE.md). +## Security -## License +If you believe you have found a security vulnerability in Protect.js, we encourage you to **_responsibly disclose this and NOT open a public issue_**. -Protect.js is [MIT licensed](./LICENSE.md). +Please email [security@cipherstash.com](mailto:security@cipherstash.com). We will provide further instructions for submitting your report. --- -### Didn't find what you wanted? +## Didn't find what you wanted? [Click here to let us know what was missing from our docs.](https://github.com/cipherstash/protectjs/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md) diff --git a/packages/protect/__tests__/bulk-protect.test.ts b/packages/protect/__tests__/bulk-protect.test.ts index 893bea8..6b55a2c 100644 --- a/packages/protect/__tests__/bulk-protect.test.ts +++ b/packages/protect/__tests__/bulk-protect.test.ts @@ -1,7 +1,12 @@ import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' +import { csColumn, csTable, type EncryptConfig } from '@cipherstash/schema' import { beforeAll, describe, expect, it } from 'vitest' -import { type EncryptedPayload, LockContext, protect } from '../src' +import { + type BulkDecryptPayload, + type Encrypted, + LockContext, + protect, +} from '../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), @@ -315,10 +320,9 @@ describe('bulk encryption and decryption', () => { }, 30000) it('should handle empty array in bulk decrypt', async () => { - const encryptedPayloads: Array<{ id?: string; data: EncryptedPayload }> = - [] + const encrypteds: BulkDecryptPayload[] = [] - const decryptedData = await protectClient.bulkDecrypt(encryptedPayloads) + const decryptedData = await protectClient.bulkDecrypt(encrypteds) if (decryptedData.failure) { throw new Error(`[protect]: ${decryptedData.failure.message}`) diff --git a/packages/protect/package.json b/packages/protect/package.json index d1b5288..2e866e5 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.17.0", + "@cipherstash/protect-ffi": "0.18.0-9", "@cipherstash/schema": "workspace:*", "zod": "^3.24.2" }, diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index bacd41c..d1df5a7 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -33,9 +33,9 @@ export const noClientError = () => 'The EQL client has not been initialized. Please call init() before using the client.', ) -export class ProtectClient { +export class ProtectClient { private client: Client - private encryptConfig: EncryptConfig | undefined + private encryptConfig: C | undefined private workspaceId: string | undefined constructor(workspaceCrn?: string) { @@ -44,17 +44,17 @@ export class ProtectClient { } async init(config: { - encryptConfig: EncryptConfig + encryptConfig: C workspaceCrn?: string accessKey?: string clientId?: string clientKey?: string - }): Promise> { + }): Promise, ProtectError>> { return await withResult( async () => { - const validated: EncryptConfig = encryptConfigSchema.parse( + const validated: C = encryptConfigSchema.parse( config.encryptConfig, - ) + ) as C logger.debug( 'Initializing the Protect.js client with the following encrypt config:', @@ -94,8 +94,8 @@ export class ProtectClient { encrypt( plaintext: JsPlaintext | null, opts: EncryptOptions, - ): EncryptOperation { - return new EncryptOperation(this.client, plaintext, opts) + ): EncryptOperation { + return new EncryptOperation(this.client, plaintext, opts) } /** @@ -104,8 +104,8 @@ export class ProtectClient { * await eqlClient.decrypt(encryptedData) * await eqlClient.decrypt(encryptedData).withLockContext(lockContext) */ - decrypt(encryptedData: Encrypted): DecryptOperation { - return new DecryptOperation(this.client, encryptedData) + decrypt(encryptedData: Encrypted): DecryptOperation { + return new DecryptOperation(this.client, encryptedData) } /** @@ -115,10 +115,10 @@ export class ProtectClient { * await eqlClient.encryptModel(decryptedModel, table).withLockContext(lockContext) */ encryptModel>( - input: Decrypted, + input: Decrypted, table: ProtectTable, - ): EncryptModelOperation { - return new EncryptModelOperation(this.client, input, table) + ): EncryptModelOperation { + return new EncryptModelOperation(this.client, input, table) } /** @@ -129,8 +129,8 @@ export class ProtectClient { */ decryptModel>( input: T, - ): DecryptModelOperation { - return new DecryptModelOperation(this.client, input) + ): DecryptModelOperation { + return new DecryptModelOperation(this.client, input) } /** @@ -140,10 +140,10 @@ export class ProtectClient { * await eqlClient.bulkEncryptModels(decryptedModels, table).withLockContext(lockContext) */ bulkEncryptModels>( - input: Array>, + input: Array>, table: ProtectTable, - ): BulkEncryptModelsOperation { - return new BulkEncryptModelsOperation(this.client, input, table) + ): BulkEncryptModelsOperation { + return new BulkEncryptModelsOperation(this.client, input, table) } /** @@ -154,8 +154,8 @@ export class ProtectClient { */ bulkDecryptModels>( input: Array, - ): BulkDecryptModelsOperation { - return new BulkDecryptModelsOperation(this.client, input) + ): BulkDecryptModelsOperation { + return new BulkDecryptModelsOperation(this.client, input) } /** @@ -167,8 +167,8 @@ export class ProtectClient { bulkEncrypt( plaintexts: BulkEncryptPayload, opts: EncryptOptions, - ): BulkEncryptOperation { - return new BulkEncryptOperation(this.client, plaintexts, opts) + ): BulkEncryptOperation { + return new BulkEncryptOperation(this.client, plaintexts, opts) } /** @@ -177,8 +177,10 @@ export class ProtectClient { * await eqlClient.bulkDecrypt(encryptedPayloads) * await eqlClient.bulkDecrypt(encryptedPayloads).withLockContext(lockContext) */ - bulkDecrypt(encryptedPayloads: BulkDecryptPayload): BulkDecryptOperation { - return new BulkDecryptOperation(this.client, encryptedPayloads) + bulkDecrypt( + encryptedPayloads: BulkDecryptPayload, + ): BulkDecryptOperation { + return new BulkDecryptOperation(this.client, encryptedPayloads) } /** @@ -187,8 +189,8 @@ export class ProtectClient { * await eqlClient.createSearchTerms(searchTerms) * await eqlClient.createSearchTerms(searchTerms).withLockContext(lockContext) */ - createSearchTerms(terms: SearchTerm[]): SearchTermsOperation { - return new SearchTermsOperation(this.client, terms) + createSearchTerms(terms: SearchTerm[]): SearchTermsOperation { + return new SearchTermsOperation(this.client, terms) } /** e.g., debugging or environment info */ diff --git a/packages/protect/src/ffi/operations/bulk-decrypt-models.ts b/packages/protect/src/ffi/operations/bulk-decrypt-models.ts index 7f87365..ae59f1a 100644 --- a/packages/protect/src/ffi/operations/bulk-decrypt-models.ts +++ b/packages/protect/src/ffi/operations/bulk-decrypt-models.ts @@ -1,4 +1,5 @@ import { type Result, withResult } from '@byteslice/result' +import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -12,7 +13,8 @@ import { ProtectOperation } from './base-operation' export class BulkDecryptModelsOperation< T extends Record, -> extends ProtectOperation[]> { + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation[]> { private client: Client private models: T[] @@ -24,11 +26,11 @@ export class BulkDecryptModelsOperation< public withLockContext( lockContext: LockContext, - ): BulkDecryptModelsOperationWithLockContext { - return new BulkDecryptModelsOperationWithLockContext(this, lockContext) + ): BulkDecryptModelsOperationWithLockContext { + return new BulkDecryptModelsOperationWithLockContext(this, lockContext) } - public async execute(): Promise[], ProtectError>> { + public async execute(): Promise[], ProtectError>> { logger.debug('Bulk decrypting models WITHOUT a lock context') return await withResult( @@ -61,12 +63,13 @@ export class BulkDecryptModelsOperation< export class BulkDecryptModelsOperationWithLockContext< T extends Record, -> extends ProtectOperation[]> { - private operation: BulkDecryptModelsOperation + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation[]> { + private operation: BulkDecryptModelsOperation private lockContext: LockContext constructor( - operation: BulkDecryptModelsOperation, + operation: BulkDecryptModelsOperation, lockContext: LockContext, ) { super() @@ -74,7 +77,7 @@ export class BulkDecryptModelsOperationWithLockContext< this.lockContext = lockContext } - public async execute(): Promise[], ProtectError>> { + public async execute(): Promise[], ProtectError>> { return await withResult( async () => { const { client, models } = this.operation.getOperation() diff --git a/packages/protect/src/ffi/operations/bulk-decrypt.ts b/packages/protect/src/ffi/operations/bulk-decrypt.ts index 2ddc2b0..664f406 100644 --- a/packages/protect/src/ffi/operations/bulk-decrypt.ts +++ b/packages/protect/src/ffi/operations/bulk-decrypt.ts @@ -1,9 +1,10 @@ import { type Result, withResult } from '@byteslice/result' import { - type Encrypted as CipherStashEncrypted, + type AnyEncrypted as CipherStashEncrypted, type DecryptResult, decryptBulkFallible, } from '@cipherstash/protect-ffi' +import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { Context, LockContext } from '../../identify' @@ -12,8 +13,8 @@ import { noClientError } from '../index' import { ProtectOperation } from './base-operation' // Helper functions for better composability -const createDecryptPayloads = ( - encryptedPayloads: BulkDecryptPayload, +const createDecryptPayloads = ( + encryptedPayloads: BulkDecryptPayload, lockContext?: Context, ) => { return encryptedPayloads @@ -21,14 +22,14 @@ const createDecryptPayloads = ( .filter(({ data }) => data !== null) .map(({ id, data, originalIndex }) => ({ id, - ciphertext: data as CipherStashEncrypted, + ciphertext: data as CipherStashEncrypted, originalIndex, - ...(lockContext && { lockContext }), + ...(lockContext && { lockContext: [lockContext] }), })) } -const createNullResult = ( - encryptedPayloads: BulkDecryptPayload, +const createNullResult = ( + encryptedPayloads: BulkDecryptPayload, ): BulkDecryptedData => { return encryptedPayloads.map(({ id }) => ({ id, @@ -36,8 +37,8 @@ const createNullResult = ( })) } -const mapDecryptedDataToResult = ( - encryptedPayloads: BulkDecryptPayload, +const mapDecryptedDataToResult = ( + encryptedPayloads: BulkDecryptPayload, decryptedData: DecryptResult[], ): BulkDecryptedData => { const result: BulkDecryptedData = new Array(encryptedPayloads.length) @@ -66,11 +67,13 @@ const mapDecryptedDataToResult = ( return result } -export class BulkDecryptOperation extends ProtectOperation { +export class BulkDecryptOperation< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation { private client: Client - private encryptedPayloads: BulkDecryptPayload + private encryptedPayloads: BulkDecryptPayload - constructor(client: Client, encryptedPayloads: BulkDecryptPayload) { + constructor(client: Client, encryptedPayloads: BulkDecryptPayload) { super() this.client = client this.encryptedPayloads = encryptedPayloads @@ -78,8 +81,8 @@ export class BulkDecryptOperation extends ProtectOperation { public withLockContext( lockContext: LockContext, - ): BulkDecryptOperationWithLockContext { - return new BulkDecryptOperationWithLockContext(this, lockContext) + ): BulkDecryptOperationWithLockContext { + return new BulkDecryptOperationWithLockContext(this, lockContext) } public async execute(): Promise> { @@ -90,20 +93,24 @@ export class BulkDecryptOperation extends ProtectOperation { if (!this.encryptedPayloads || this.encryptedPayloads.length === 0) return [] - const nonNullPayloads = createDecryptPayloads(this.encryptedPayloads) + const nonNullPayloads = createDecryptPayloads(this.encryptedPayloads) if (nonNullPayloads.length === 0) { - return createNullResult(this.encryptedPayloads) + return createNullResult(this.encryptedPayloads) } const { metadata } = this.getAuditData() const decryptedData = await decryptBulkFallible(this.client, { - ciphertexts: nonNullPayloads, + // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types + ciphertexts: nonNullPayloads as any, unverifiedContext: metadata, }) - return mapDecryptedDataToResult(this.encryptedPayloads, decryptedData) + return mapDecryptedDataToResult( + this.encryptedPayloads, + decryptedData, + ) }, (error: unknown) => ({ type: ProtectErrorTypes.DecryptionError, @@ -114,7 +121,7 @@ export class BulkDecryptOperation extends ProtectOperation { public getOperation(): { client: Client - encryptedPayloads: BulkDecryptPayload + encryptedPayloads: BulkDecryptPayload } { return { client: this.client, @@ -123,11 +130,13 @@ export class BulkDecryptOperation extends ProtectOperation { } } -export class BulkDecryptOperationWithLockContext extends ProtectOperation { - private operation: BulkDecryptOperation +export class BulkDecryptOperationWithLockContext< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation { + private operation: BulkDecryptOperation private lockContext: LockContext - constructor(operation: BulkDecryptOperation, lockContext: LockContext) { + constructor(operation: BulkDecryptOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext @@ -147,24 +156,25 @@ export class BulkDecryptOperationWithLockContext extends ProtectOperation( encryptedPayloads, context.data.context, ) if (nonNullPayloads.length === 0) { - return createNullResult(encryptedPayloads) + return createNullResult(encryptedPayloads) } const { metadata } = this.getAuditData() const decryptedData = await decryptBulkFallible(client, { - ciphertexts: nonNullPayloads, + // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types + ciphertexts: nonNullPayloads as any, serviceToken: context.data.ctsToken, unverifiedContext: metadata, }) - return mapDecryptedDataToResult(encryptedPayloads, decryptedData) + return mapDecryptedDataToResult(encryptedPayloads, decryptedData) }, (error: unknown) => ({ type: ProtectErrorTypes.DecryptionError, diff --git a/packages/protect/src/ffi/operations/bulk-encrypt-models.ts b/packages/protect/src/ffi/operations/bulk-encrypt-models.ts index f3d9a8c..505e22f 100644 --- a/packages/protect/src/ffi/operations/bulk-encrypt-models.ts +++ b/packages/protect/src/ffi/operations/bulk-encrypt-models.ts @@ -1,5 +1,5 @@ import { type Result, withResult } from '@byteslice/result' -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' +import type { EncryptConfig, ProtectTable, ProtectTableColumn } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -13,14 +13,15 @@ import { ProtectOperation } from './base-operation' export class BulkEncryptModelsOperation< T extends Record, + C extends EncryptConfig = EncryptConfig, > extends ProtectOperation { private client: Client - private models: Decrypted[] + private models: Decrypted[] private table: ProtectTable constructor( client: Client, - models: Decrypted[], + models: Decrypted[], table: ProtectTable, ) { super() @@ -31,8 +32,8 @@ export class BulkEncryptModelsOperation< public withLockContext( lockContext: LockContext, - ): BulkEncryptModelsOperationWithLockContext { - return new BulkEncryptModelsOperationWithLockContext(this, lockContext) + ): BulkEncryptModelsOperationWithLockContext { + return new BulkEncryptModelsOperationWithLockContext(this, lockContext) } public async execute(): Promise> { @@ -64,7 +65,7 @@ export class BulkEncryptModelsOperation< public getOperation(): { client: Client - models: Decrypted[] + models: Decrypted[] table: ProtectTable } { return { @@ -77,12 +78,13 @@ export class BulkEncryptModelsOperation< export class BulkEncryptModelsOperationWithLockContext< T extends Record, + C extends EncryptConfig = EncryptConfig, > extends ProtectOperation { - private operation: BulkEncryptModelsOperation + private operation: BulkEncryptModelsOperation private lockContext: LockContext constructor( - operation: BulkEncryptModelsOperation, + operation: BulkEncryptModelsOperation, lockContext: LockContext, ) { super() diff --git a/packages/protect/src/ffi/operations/bulk-encrypt.ts b/packages/protect/src/ffi/operations/bulk-encrypt.ts index e89f827..9ea1ce9 100644 --- a/packages/protect/src/ffi/operations/bulk-encrypt.ts +++ b/packages/protect/src/ffi/operations/bulk-encrypt.ts @@ -1,6 +1,7 @@ import { type Result, withResult } from '@byteslice/result' import { type JsPlaintext, encryptBulk } from '@cipherstash/protect-ffi' import type { + EncryptConfig, ProtectColumn, ProtectTable, ProtectTableColumn, @@ -20,7 +21,7 @@ import { noClientError } from '../index' import { ProtectOperation } from './base-operation' // Helper functions for better composability -const createEncryptPayloads = ( +const createEncryptPayloads = ( plaintexts: BulkEncryptPayload, column: ProtectColumn | ProtectValue, table: ProtectTable, @@ -35,21 +36,21 @@ const createEncryptPayloads = ( column: column.getName(), table: table.tableName, originalIndex, - ...(lockContext && { lockContext }), + ...(lockContext && { lockContext: [lockContext] }), })) } -const createNullResult = ( +const createNullResult = ( plaintexts: BulkEncryptPayload, -): BulkEncryptedData => { +): BulkEncryptedData => { return plaintexts.map(({ id }) => ({ id, data: null })) } -const mapEncryptedDataToResult = ( +const mapEncryptedDataToResult = ( plaintexts: BulkEncryptPayload, - encryptedData: Encrypted[], -): BulkEncryptedData => { - const result: BulkEncryptedData = new Array(plaintexts.length) + encryptedData: Encrypted[], +): BulkEncryptedData => { + const result: BulkEncryptedData = new Array(plaintexts.length) let encryptedIndex = 0 for (let i = 0; i < plaintexts.length; i++) { @@ -67,7 +68,9 @@ const mapEncryptedDataToResult = ( return result } -export class BulkEncryptOperation extends ProtectOperation { +export class BulkEncryptOperation< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation> { private client: Client private plaintexts: BulkEncryptPayload private column: ProtectColumn | ProtectValue @@ -87,11 +90,11 @@ export class BulkEncryptOperation extends ProtectOperation { public withLockContext( lockContext: LockContext, - ): BulkEncryptOperationWithLockContext { - return new BulkEncryptOperationWithLockContext(this, lockContext) + ): BulkEncryptOperationWithLockContext { + return new BulkEncryptOperationWithLockContext(this, lockContext) } - public async execute(): Promise> { + public async execute(): Promise, ProtectError>> { logger.debug('Bulk encrypting data WITHOUT a lock context', { column: this.column.getName(), table: this.table.tableName, @@ -106,24 +109,25 @@ export class BulkEncryptOperation extends ProtectOperation { return [] } - const nonNullPayloads = createEncryptPayloads( + const nonNullPayloads = createEncryptPayloads( this.plaintexts, this.column, this.table, ) if (nonNullPayloads.length === 0) { - return createNullResult(this.plaintexts) + return createNullResult(this.plaintexts) } const { metadata } = this.getAuditData() const encryptedData = await encryptBulk(this.client, { - plaintexts: nonNullPayloads, + // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types + plaintexts: nonNullPayloads as any, unverifiedContext: metadata, }) - return mapEncryptedDataToResult(this.plaintexts, encryptedData) + return mapEncryptedDataToResult(this.plaintexts, encryptedData) }, (error: unknown) => ({ type: ProtectErrorTypes.EncryptionError, @@ -147,17 +151,19 @@ export class BulkEncryptOperation extends ProtectOperation { } } -export class BulkEncryptOperationWithLockContext extends ProtectOperation { - private operation: BulkEncryptOperation +export class BulkEncryptOperationWithLockContext< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation> { + private operation: BulkEncryptOperation private lockContext: LockContext - constructor(operation: BulkEncryptOperation, lockContext: LockContext) { + constructor(operation: BulkEncryptOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext } - public async execute(): Promise> { + public async execute(): Promise, ProtectError>> { return await withResult( async () => { const { client, plaintexts, column, table } = @@ -180,7 +186,7 @@ export class BulkEncryptOperationWithLockContext extends ProtectOperation( plaintexts, column, table, @@ -188,18 +194,19 @@ export class BulkEncryptOperationWithLockContext extends ProtectOperation(plaintexts) } const { metadata } = this.getAuditData() const encryptedData = await encryptBulk(client, { - plaintexts: nonNullPayloads, + // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types + plaintexts: nonNullPayloads as any, serviceToken: context.data.ctsToken, unverifiedContext: metadata, }) - return mapEncryptedDataToResult(plaintexts, encryptedData) + return mapEncryptedDataToResult(plaintexts, encryptedData) }, (error: unknown) => ({ type: ProtectErrorTypes.EncryptionError, diff --git a/packages/protect/src/ffi/operations/decrypt-model.ts b/packages/protect/src/ffi/operations/decrypt-model.ts index d2aba28..7ac8de1 100644 --- a/packages/protect/src/ffi/operations/decrypt-model.ts +++ b/packages/protect/src/ffi/operations/decrypt-model.ts @@ -1,4 +1,5 @@ import { type Result, withResult } from '@byteslice/result' +import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -12,7 +13,8 @@ import { ProtectOperation } from './base-operation' export class DecryptModelOperation< T extends Record, -> extends ProtectOperation> { + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation> { private client: Client private model: T @@ -24,11 +26,11 @@ export class DecryptModelOperation< public withLockContext( lockContext: LockContext, - ): DecryptModelOperationWithLockContext { - return new DecryptModelOperationWithLockContext(this, lockContext) + ): DecryptModelOperationWithLockContext { + return new DecryptModelOperationWithLockContext(this, lockContext) } - public async execute(): Promise, ProtectError>> { + public async execute(): Promise, ProtectError>> { logger.debug('Decrypting model WITHOUT a lock context') return await withResult( @@ -61,17 +63,18 @@ export class DecryptModelOperation< export class DecryptModelOperationWithLockContext< T extends Record, -> extends ProtectOperation> { - private operation: DecryptModelOperation + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation> { + private operation: DecryptModelOperation private lockContext: LockContext - constructor(operation: DecryptModelOperation, lockContext: LockContext) { + constructor(operation: DecryptModelOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext } - public async execute(): Promise, ProtectError>> { + public async execute(): Promise, ProtectError>> { return await withResult( async () => { const { client, model } = this.operation.getOperation() diff --git a/packages/protect/src/ffi/operations/decrypt.ts b/packages/protect/src/ffi/operations/decrypt.ts index 23fdde0..e9fdb99 100644 --- a/packages/protect/src/ffi/operations/decrypt.ts +++ b/packages/protect/src/ffi/operations/decrypt.ts @@ -3,6 +3,7 @@ import { type JsPlaintext, decrypt as ffiDecrypt, } from '@cipherstash/protect-ffi' +import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -10,11 +11,13 @@ import type { Client, Encrypted } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' -export class DecryptOperation extends ProtectOperation { +export class DecryptOperation< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation { private client: Client - private encryptedData: Encrypted + private encryptedData: Encrypted - constructor(client: Client, encryptedData: Encrypted) { + constructor(client: Client, encryptedData: Encrypted) { super() this.client = client this.encryptedData = encryptedData @@ -22,8 +25,8 @@ export class DecryptOperation extends ProtectOperation { public withLockContext( lockContext: LockContext, - ): DecryptOperationWithLockContext { - return new DecryptOperationWithLockContext(this, lockContext) + ): DecryptOperationWithLockContext { + return new DecryptOperationWithLockContext(this, lockContext) } public async execute(): Promise> { @@ -57,7 +60,7 @@ export class DecryptOperation extends ProtectOperation { public getOperation(): { client: Client - encryptedData: Encrypted + encryptedData: Encrypted auditData?: Record } { return { @@ -68,11 +71,13 @@ export class DecryptOperation extends ProtectOperation { } } -export class DecryptOperationWithLockContext extends ProtectOperation { - private operation: DecryptOperation +export class DecryptOperationWithLockContext< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation { + private operation: DecryptOperation private lockContext: LockContext - constructor(operation: DecryptOperation, lockContext: LockContext) { + constructor(operation: DecryptOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext @@ -110,7 +115,8 @@ export class DecryptOperationWithLockContext extends ProtectOperation, + C extends EncryptConfig = EncryptConfig, > extends ProtectOperation { private client: Client - private model: Decrypted + private model: Decrypted private table: ProtectTable constructor( client: Client, - model: Decrypted, + model: Decrypted, table: ProtectTable, ) { super() @@ -31,8 +32,8 @@ export class EncryptModelOperation< public withLockContext( lockContext: LockContext, - ): EncryptModelOperationWithLockContext { - return new EncryptModelOperationWithLockContext(this, lockContext) + ): EncryptModelOperationWithLockContext { + return new EncryptModelOperationWithLockContext(this, lockContext) } public async execute(): Promise> { @@ -64,7 +65,7 @@ export class EncryptModelOperation< public getOperation(): { client: Client - model: Decrypted + model: Decrypted table: ProtectTable } { return { @@ -77,11 +78,12 @@ export class EncryptModelOperation< export class EncryptModelOperationWithLockContext< T extends Record, + C extends EncryptConfig = EncryptConfig, > extends ProtectOperation { - private operation: EncryptModelOperation + private operation: EncryptModelOperation private lockContext: LockContext - constructor(operation: EncryptModelOperation, lockContext: LockContext) { + constructor(operation: EncryptModelOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext diff --git a/packages/protect/src/ffi/operations/encrypt.ts b/packages/protect/src/ffi/operations/encrypt.ts index 6d479b3..3f9303f 100644 --- a/packages/protect/src/ffi/operations/encrypt.ts +++ b/packages/protect/src/ffi/operations/encrypt.ts @@ -4,6 +4,7 @@ import { encrypt as ffiEncrypt, } from '@cipherstash/protect-ffi' import type { + EncryptConfig, ProtectColumn, ProtectTable, ProtectTableColumn, @@ -16,7 +17,9 @@ import type { Client, EncryptOptions, Encrypted } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' -export class EncryptOperation extends ProtectOperation { +export class EncryptOperation< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation> { private client: Client private plaintext: JsPlaintext | null private column: ProtectColumn | ProtectValue @@ -36,11 +39,11 @@ export class EncryptOperation extends ProtectOperation { public withLockContext( lockContext: LockContext, - ): EncryptOperationWithLockContext { - return new EncryptOperationWithLockContext(this, lockContext) + ): EncryptOperationWithLockContext { + return new EncryptOperationWithLockContext(this, lockContext) } - public async execute(): Promise> { + public async execute(): Promise, ProtectError>> { logger.debug('Encrypting data WITHOUT a lock context', { column: this.column.getName(), table: this.table.tableName, @@ -87,17 +90,19 @@ export class EncryptOperation extends ProtectOperation { } } -export class EncryptOperationWithLockContext extends ProtectOperation { - private operation: EncryptOperation +export class EncryptOperationWithLockContext< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation> { + private operation: EncryptOperation private lockContext: LockContext - constructor(operation: EncryptOperation, lockContext: LockContext) { + constructor(operation: EncryptOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext } - public async execute(): Promise> { + public async execute(): Promise, ProtectError>> { return await withResult( async () => { const { client, plaintext, column, table } = @@ -127,7 +132,8 @@ export class EncryptOperationWithLockContext extends ProtectOperation plaintext, column: column.getName(), table: table.tableName, - lockContext: context.data.context, + // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types + lockContext: [context.data.context] as any, serviceToken: context.data.ctsToken, unverifiedContext: metadata, }) diff --git a/packages/protect/src/ffi/operations/search-terms.ts b/packages/protect/src/ffi/operations/search-terms.ts index 3949ee2..5e7de3b 100644 --- a/packages/protect/src/ffi/operations/search-terms.ts +++ b/packages/protect/src/ffi/operations/search-terms.ts @@ -1,14 +1,15 @@ import { type Result, withResult } from '@byteslice/result' import { encryptBulk } from '@cipherstash/protect-ffi' +import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { Client, EncryptedSearchTerm, SearchTerm } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' -export class SearchTermsOperation extends ProtectOperation< - EncryptedSearchTerm[] -> { +export class SearchTermsOperation< + C extends EncryptConfig = EncryptConfig, +> extends ProtectOperation[]> { private client: Client private terms: SearchTerm[] @@ -18,7 +19,9 @@ export class SearchTermsOperation extends ProtectOperation< this.terms = terms } - public async execute(): Promise> { + public async execute(): Promise< + Result[], ProtectError> + > { logger.debug('Creating search terms', { terms: this.terms, }) diff --git a/packages/protect/src/helpers/index.ts b/packages/protect/src/helpers/index.ts index 0074427..9fe9a76 100644 --- a/packages/protect/src/helpers/index.ts +++ b/packages/protect/src/helpers/index.ts @@ -1,13 +1,16 @@ +import type { EncryptConfig } from '@cipherstash/schema' import type { Encrypted } from '../types' -export type EncryptedPgComposite = { - data: Encrypted +export type EncryptedPgComposite = { + data: Encrypted } /** * Helper function to transform an encrypted payload into a PostgreSQL composite type */ -export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite { +export function encryptedToPgComposite( + obj: Encrypted, +): EncryptedPgComposite { return { data: obj, } @@ -44,12 +47,14 @@ export function bulkModelsToEncryptedPgComposites< /** * Helper function to check if a value is an encrypted payload */ -export function isEncryptedPayload(value: unknown): value is Encrypted { +export function isEncryptedPayload( + value: unknown, +): value is Encrypted { if (value === null) return false // TODO: this can definitely be improved if (typeof value === 'object') { - const obj = value as Encrypted + const obj = value as Encrypted return ( obj !== null && 'v' in obj && ('c' in obj || 'sv' in obj) && 'i' in obj ) diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts index a491ed9..ca554c3 100644 --- a/packages/protect/src/types.ts +++ b/packages/protect/src/types.ts @@ -1,13 +1,16 @@ import type { - Encrypted as CipherStashEncrypted, + EncryptedCell, + EncryptedSV, JsPlaintext, newClient, + AnyEncrypted, } from '@cipherstash/protect-ffi' import type { ProtectColumn, ProtectTable, ProtectTableColumn, ProtectValue, + EncryptConfig, } from '@cipherstash/schema' /** @@ -18,19 +21,19 @@ export type Client = Awaited> | undefined /** * Type to represent an encrypted payload */ -export type Encrypted = CipherStashEncrypted | null +export type Encrypted = AnyEncrypted | null /** * Represents an encrypted payload in the database * @deprecated Use `Encrypted` instead */ -export type EncryptedPayload = Encrypted | null +export type EncryptedPayload = Encrypted /** * Represents an encrypted data object in the database * @deprecated Use `Encrypted` instead */ -export type EncryptedData = Encrypted | null +export type EncryptedData = Encrypted /** * Represents a value that will be encrypted and used in a search @@ -48,7 +51,7 @@ export type SearchTerm = { * If the return type is `composite-literal`, the return type is `string` where the value is a composite literal * If the return type is `escaped-composite-literal`, the return type is `string` where the value is an escaped composite literal */ -export type EncryptedSearchTerm = Encrypted | string +export type EncryptedSearchTerm = Encrypted | string /** * Represents a payload to be encrypted using the `encrypt` function @@ -66,28 +69,29 @@ export type EncryptOptions = { /** * Type to identify encrypted fields in a model */ -export type EncryptedFields = { - [K in keyof T as T[K] extends Encrypted ? K : never]: T[K] +export type EncryptedFields = { + [K in keyof T as T[K] extends Encrypted ? K : never]: T[K] } /** * Type to identify non-encrypted fields in a model */ -export type OtherFields = { - [K in keyof T as T[K] extends Encrypted ? never : K]: T[K] +export type OtherFields = { + [K in keyof T as T[K] extends Encrypted ? never : K]: T[K] } /** * Type to represent decrypted fields in a model */ -export type DecryptedFields = { - [K in keyof T as T[K] extends Encrypted ? K : never]: string +export type DecryptedFields = { + [K in keyof T as T[K] extends Encrypted ? K : never]: string } /** * Represents a model with plaintext (decrypted) values instead of the EQL/JSONB types */ -export type Decrypted = OtherFields & DecryptedFields +export type Decrypted = OtherFields & + DecryptedFields /** * Types for bulk encryption and decryption operations. @@ -97,8 +101,14 @@ export type BulkEncryptPayload = Array<{ plaintext: JsPlaintext | null }> -export type BulkEncryptedData = Array<{ id?: string; data: Encrypted }> -export type BulkDecryptPayload = Array<{ id?: string; data: Encrypted }> +export type BulkEncryptedData = Array<{ + id?: string + data: Encrypted +}> +export type BulkDecryptPayload = Array<{ + id?: string + data: Encrypted +}> export type BulkDecryptedData = Array> type DecryptionSuccess = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b9b16b..4a4231b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,8 +456,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@cipherstash/protect-ffi': - specifier: 0.17.0 - version: 0.17.0 + specifier: 0.18.0-9 + version: 0.18.0-9 '@cipherstash/schema': specifier: workspace:* version: link:../schema @@ -975,33 +975,33 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/protect-ffi-darwin-arm64@0.17.0': - resolution: {integrity: sha512-/QsbCzpDs9orsBWf7RhPosyspsES7WjD2xr98NeJeN5IR/KbVmr+KuEchl18eqeVH1EzXNT5FZLZ+AUfEjO9rw==} + '@cipherstash/protect-ffi-darwin-arm64@0.18.0-9': + resolution: {integrity: sha512-gpgrEA0YQepUeoF1y5I8/MrWiPezwwSL1edE08iObU2G5GwWUsHbIvpjk86aXcqex+WbjWtqx+IgOSkLyXb8cQ==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.17.0': - resolution: {integrity: sha512-EOq6ZNAPlWU0SJTCmfQQkLPX7UDUv7O/fuTzVGksPjM6vC1iYAGRXa37TdxGHXzZDmOl9En70KblPtHI0opT7g==} + '@cipherstash/protect-ffi-darwin-x64@0.18.0-9': + resolution: {integrity: sha512-D4S24b6HvJDSjE6/YPwvCk7wTXY7GJ+RI8whuxU5IfRhwo0SEfZoGO3ZDToU89Yi4TaEqm/gv/53K9YWzdfLgA==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.0': - resolution: {integrity: sha512-jLA+tNKNE4sluH6Clnn2TUoOPcvyuSaAj6h4FluswQahiHdILPDaM5WbePnbwOIi0eumff3zo9HuAWRJqhB7Pw==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.18.0-9': + resolution: {integrity: sha512-psjFYCCKgcb7upy8yq6SRuk3b9TSn6mE/uHrrNhvDVkXGlOTTJB+UyCkKxJywlLy3PD+iJ204dH0FewV8LVWyg==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.17.0': - resolution: {integrity: sha512-QjAyb7K2X/ccnIsKByTLG3iD5qQy4GQ23EWWJcTUkz6u1smE6C1lXjs9y43jlFcQc9WaGlIFn4SeC4q4fnuMDA==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.18.0-9': + resolution: {integrity: sha512-1FgGBxtrG0rN/5APqY8nCPYrEUTFskB1nXnB9qxrdH9pmXtdk5OR5YG1DpZv4bZOp7RRwrlwolHq8dA20AbK/w==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.17.0': - resolution: {integrity: sha512-bXU2Tv516qJEp47Yc7lizNdRCrXehubc87CdzU1Jl/bReWtmRK2Nu6lH9FsLtPSPdo8SClb81ZqtryxBW19n+w==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.18.0-9': + resolution: {integrity: sha512-XRjLRXhmdvQLPs9IncbBYw52byK+T6uqwV4VTaCQChWIScbQ/As2nBOzjTOtxTurEn5HbeJjJh7U6tchmWGxLg==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.17.0': - resolution: {integrity: sha512-TJv/qhuSzid42XXgdmeExf8G/XLZ51VB0wHsg0hJoR9t6yNjWOuOY3NZ37X5+7rWJnnIjjVFsQIr3sQ+aam7sw==} + '@cipherstash/protect-ffi@0.18.0-9': + resolution: {integrity: sha512-1FrRf0qDC78IsBG57HhFEmGY3qFyP4gMEkvz0A/5xU3/CeedvO7ZazCf5QdmRUVTVyzn2zsVBDDlsiBloqoMSg==} '@clerk/backend@1.25.5': resolution: {integrity: sha512-nnBpr7oSq5iATWRExuljEfp7xa90KE1OUgaGCSmtZYF0T9TWHGkZHYqkQhD4XjiqlR2XsrsQ/UzPfmHM1Km7+Q==} @@ -7813,30 +7813,30 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@cipherstash/protect-ffi-darwin-arm64@0.17.0': + '@cipherstash/protect-ffi-darwin-arm64@0.18.0-9': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.17.0': + '@cipherstash/protect-ffi-darwin-x64@0.18.0-9': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.18.0-9': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.17.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.18.0-9': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.17.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.18.0-9': optional: true - '@cipherstash/protect-ffi@0.17.0': + '@cipherstash/protect-ffi@0.18.0-9': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.17.0 - '@cipherstash/protect-ffi-darwin-x64': 0.17.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.17.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.17.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.17.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.18.0-9 + '@cipherstash/protect-ffi-darwin-x64': 0.18.0-9 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.18.0-9 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.18.0-9 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.18.0-9 '@clerk/backend@1.25.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: From e8fe444cd5d788d35bbee99e165e3d36a10a823e Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 22 Oct 2025 10:24:48 -0600 Subject: [PATCH 10/19] Revert "feat(protect): init ffi 18 pre release" --- README.md | 1000 ++++++++++++++++- packages/protect-dynamodb/README.md | 349 +++++- packages/protect/README.md | 1000 ++++++++++++++++- .../protect/__tests__/bulk-protect.test.ts | 14 +- packages/protect/package.json | 2 +- packages/protect/src/ffi/index.ts | 54 +- .../src/ffi/operations/bulk-decrypt-models.ts | 19 +- .../src/ffi/operations/bulk-decrypt.ts | 62 +- .../src/ffi/operations/bulk-encrypt-models.ts | 18 +- .../src/ffi/operations/bulk-encrypt.ts | 55 +- .../src/ffi/operations/decrypt-model.ts | 19 +- .../protect/src/ffi/operations/decrypt.ts | 26 +- .../src/ffi/operations/encrypt-model.ts | 18 +- .../protect/src/ffi/operations/encrypt.ts | 24 +- .../src/ffi/operations/search-terms.ts | 11 +- packages/protect/src/helpers/index.ts | 15 +- packages/protect/src/types.ts | 38 +- pnpm-lock.yaml | 50 +- 18 files changed, 2487 insertions(+), 287 deletions(-) diff --git a/README.md b/README.md index 03a3a7c..87c8dfe 100644 --- a/README.md +++ b/README.md @@ -50,32 +50,1006 @@ -## Getting Started - Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database. -Create an account on [CipherStash](https://dashboard.cipherstash.com/sign-up) and create a workspace to start using Protect.js. +Per‑value unique keys are powered by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) bulk key operations, backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html). -## Documentation +Encrypted data is structured as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload and can be stored in any database that supports JSONB. -Visit [https://cipherstash.com/docs](https://cipherstash.com/docs/sdk/protect/js) to view the full documentation. +> [!IMPORTANT] +> Searching, sorting, and filtering on encrypted data is currently only supported when storing encrypted data in PostgreSQL. +> Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md). -## Contributing +Looking for DynamoDB support? Check out the [Protect.js for DynamoDB helper library](https://www.npmjs.com/package/@cipherstash/protect-dynamodb). -We welcome contributions to Protect.js. Please read the [contribution guide](https://github.com/cipherstash/protectjs/blob/main/CONTRIBUTE.md). +## Quick start (60 seconds) -## License +Create an account and workspace in the [CipherStash dashboard](https://cipherstash.com/signup), then follow the onboarding guide to generate your client credentials and store them in your `.env` file. + +Install the package: + +```bash +npm install @cipherstash/protect +``` + +Start encrypting data: + +```ts +import { protect } from "@cipherstash/protect"; +import { csTable, csColumn } from "@cipherstash/protect"; + +// 1) Define a schema +const users = csTable("users", { email: csColumn("email") }); + +// 2) Create a client (requires CS_* env vars) +const client = await protect({ schemas: [users] }); + +// 3) Encrypt → store JSONB payload +const encrypted = await client.encrypt("alice@example.com", { + table: users, + column: users.email, +}); + +if (encrypted.failure) { + // You decide how to handle the failure and the user experience +} + +// 4) Decrypt later +const decrypted = await client.decrypt(encrypted.data); +``` + +## Architecture (high level) + +![Protect.js Architecture Diagram](https://github.com/cipherstash/protectjs/blob/main/docs/images/protectjs-architecture.png) + +## Table of contents + +- [Quick start (60 seconds)](#quick-start-60-seconds) +- [Architecture (high level)](#architecture-high-level) +- [Features](#features) +- [Installing Protect.js](#installing-protectjs) +- [Getting started](#getting-started) +- [Identity-aware encryption](#identity-aware-encryption) +- [Supported data types](#supported-data-types) +- [Searchable encryption](#searchable-encryption) +- [Logging](#logging) +- [CipherStash Client](#cipherstash-client) +- [Example applications](#example-applications) +- [Builds and bundling](#builds-and-bundling) +- [Contributing](#contributing) +- [License](#license) + +For more specific documentation, refer to the [docs](https://github.com/cipherstash/protectjs/tree/main/docs). + +## Features + +Protect.js protects data in using industry-standard AES encryption. +Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations. +This enables every encrypted value, in every column, in every row in your database to have a unique key — without sacrificing performance. + +**Features:** + +- **Bulk encryption and decryption**: Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for encrypting and decrypting thousands of records at once, while using a unique key for every value. +- **Single item encryption and decryption**: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered. +- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js. +- **Identity-aware encryption**: Lock down access to sensitive data by requiring a valid JWT to perform a decryption. +- **Audit trail**: Every decryption event will be logged in ZeroKMS to help you prove compliance. +- **Searchable encryption**: Protect.js supports searching encrypted data in PostgreSQL. +- **TypeScript support**: Strongly typed with TypeScript interfaces and types. + +**Use cases:** + +- **Trusted data access**: make sure only your end-users can access their sensitive data stored in your product. +- **Meet compliance requirements faster:** meet and exceed the data encryption requirements of SOC2 and ISO27001. +- **Reduce the blast radius of data breaches:** limit the impact of exploited vulnerabilities to only the data your end-users can decrypt. + +## Installing Protect.js + +Install the [`@cipherstash/protect` package](https://www.npmjs.com/package/@cipherstash/protect) with your package manager of choice: + +```bash +npm install @cipherstash/protect +# or +yarn add @cipherstash/protect +# or +pnpm add @cipherstash/protect +``` + +> [!TIP] +> [Bun](https://bun.sh/) is not currently supported due to a lack of [Node-API compatibility](https://github.com/oven-sh/bun/issues/158). Under the hood, Protect.js uses [CipherStash Client](#cipherstash-client) which is written in Rust and embedded using [Neon](https://github.com/neon-bindings/neon). + +### Opt-out of bundling + +> [!IMPORTANT] +> **You need to opt-out of bundling when using Protect.js.** + +Protect.js uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid). + +When using Protect.js, you need to opt-out of bundling for tools like [Webpack](https://webpack.js.org/configuration/externals/), [esbuild](https://webpack.js.org/configuration/externals/), or [Next.js](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages). + +Read more about [building and bundling with Protect.js](#builds-and-bundling). + +## Getting started + +- 🆕 **Existing app?** Skip to [the next step](#configuration). +- 🌱 **Clean slate?** Check out the [getting started tutorial](./docs/getting-started.md). + +### Configuration + +If you haven't already, sign up for a [CipherStash account](https://cipherstash.com/signup). +Once you have an account, you will create a Workspace which is scoped to your application environment. + +Follow the onboarding steps to get your first set of credentials required to use Protect.js. +By the end of the onboarding, you will have the following environment variables: + +```bash +CS_WORKSPACE_CRN= # The workspace identifier +CS_CLIENT_ID= # The client identifier +CS_CLIENT_KEY= # The client key which is used as key material in combination with ZeroKMS +CS_CLIENT_ACCESS_KEY= # The API key used for authenticating with the CipherStash API +``` + +Save these environment variables to a `.env` file in your project. + +### Basic file structure + +The following is the basic file structure of the project. +In the `src/protect/` directory, we have the table definition in `schema.ts` and the protect client in `index.ts`. + +``` +📦 + ├ 📂 src + │ ├ 📂 protect + │ │ ├ 📜 index.ts + │ │ └ 📜 schema.ts + │ └ 📜 index.ts + ├ 📜 .env + ├ 📜 cipherstash.toml + ├ 📜 cipherstash.secret.toml + ├ 📜 package.json + └ 📜 tsconfig.json +``` + +### Define your schema + +Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt. + +Define your tables and columns by adding this to `src/protect/schema.ts`: + +```ts +import { csTable, csColumn } from "@cipherstash/protect"; + +export const users = csTable("users", { + email: csColumn("email"), +}); + +export const orders = csTable("orders", { + address: csColumn("address"), +}); +``` + +**Searchable encryption:** + +If you want to search encrypted data in your PostgreSQL database, you must declare the indexes in schema in `src/protect/schema.ts`: + +```ts +import { csTable, csColumn } from "@cipherstash/protect"; + +export const users = csTable("users", { + email: csColumn("email").freeTextSearch().equality().orderAndRange(), +}); + +export const orders = csTable("orders", { + address: csColumn("address"), +}); +``` + +Read more about [defining your schema](./docs/reference/schema.md). + +### Initialize the Protect client + +To import the `protect` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`: + +```ts +import { protect, type ProtectClientConfig } from "@cipherstash/protect"; +import { users, orders } from "./schema"; + +const config: ProtectClientConfig = { + schemas: [users, orders], +} + +// Pass all your tables to the protect function to initialize the client +export const protectClient = await protect(config); +``` + +The `protect` function requires at least one `csTable` be provided in the `schemas` array. + +### Encrypt data + +Protect.js provides the `encrypt` function on `protectClient` to encrypt data. +`encrypt` takes a plaintext string, and an object with the table and column as parameters. + +To start encrypting data, add the following to `src/index.ts`: + +```typescript +import { users } from "./protect/schema"; +import { protectClient } from "./protect"; + +const encryptResult = await protectClient.encrypt("secret@squirrel.example", { + column: users.email, + table: users, +}); + +if (encryptResult.failure) { + // Handle the failure + console.log( + "error when encrypting:", + encryptResult.failure.type, + encryptResult.failure.message + ); +} + +console.log("EQL Payload containing ciphertexts:", encryptResult.data); +``` + +The `encrypt` function will return a `Result` object with either a `data` key, or a `failure` key. +The `encryptResult` will return one of the following: + +```typescript +// Success +{ + data: EncryptedPayload +} + +// Failure +{ + failure: { + type: 'EncryptionError', + message: 'A message about the error' + } +} +``` + +### Decrypt data + +Protect.js provides the `decrypt` function on `protectClient` to decrypt data. +`decrypt` takes an encrypted data object as a parameter. + +To start decrypting data, add the following to `src/index.ts`: + +```typescript +import { protectClient } from "./protect"; + +// encryptResult is the EQL payload from the previous step +const decryptResult = await protectClient.decrypt(encryptResult.data); + +if (decryptResult.failure) { + // Handle the failure + console.log( + "error when decrypting:", + decryptResult.failure.type, + decryptResult.failure.message + ); +} + +const plaintext = decryptResult.data; +console.log("plaintext:", plaintext); +``` + +The `decrypt` function returns a `Result` object with either a `data` key, or a `failure` key. +The `decryptResult` will return one of the following: + +```typescript +// Success +{ + data: 'secret@squirrel.example' +} + +// Failure +{ + failure: { + type: 'DecryptionError', + message: 'A message about the error' + } +} +``` + +### Working with models and objects + +Protect.js provides model-level encryption methods that make it easy to encrypt and decrypt entire objects. +These methods automatically handle the encryption of fields defined in your schema. + +If you are working with a large data set, the model operations are significantly faster than encrypting and decrypting individual objects as they are able to perform bulk operations. + +> [!TIP] +> CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) is optimized for bulk operations. +> +> All the model operations are able to take advantage of this performance for real-world use cases by only making a single call to ZeroKMS regardless of the number of objects you are encrypting or decrypting while still using a unique key for each record. + +#### Encrypting a model + +Use the `encryptModel` method to encrypt a model's fields that are defined in your schema: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +// Your model with plaintext values +const user = { + id: "1", + email: "user@example.com", + address: "123 Main St", + createdAt: new Date("2024-01-01"), +}; + +const encryptedResult = await protectClient.encryptModel(user, users); + +if (encryptedResult.failure) { + // Handle the failure + console.log( + "error when encrypting:", + encryptedResult.failure.type, + encryptedResult.failure.message + ); +} + +const encryptedUser = encryptedResult.data; +console.log("encrypted user:", encryptedUser); +``` + +The `encryptModel` function will only encrypt fields that are defined in your schema. +Other fields (like `id` and `createdAt` in the example above) will remain unchanged. + +#### Type safety with models + +Protect.js provides strong TypeScript support for model operations. +You can specify your model's type to ensure end-to-end type safety: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +// Define your model type +type User = { + id: string; + email: string | null; + address: string | null; + createdAt: Date; + updatedAt: Date; + metadata?: { + preferences?: { + notifications: boolean; + theme: string; + }; + }; +}; + +// The encryptModel method will ensure type safety +const encryptedResult = await protectClient.encryptModel(user, users); + +if (encryptedResult.failure) { + // Handle the failure +} + +const encryptedUser = encryptedResult.data; +// TypeScript knows that encryptedUser matches the User type structure +// but with encrypted fields for those defined in the schema + +// Decryption maintains type safety +const decryptedResult = await protectClient.decryptModel(encryptedUser); + +if (decryptedResult.failure) { + // Handle the failure +} + +const decryptedUser = decryptedResult.data; +// decryptedUser is fully typed as User + +// Bulk operations also support type safety +const bulkEncryptedResult = await protectClient.bulkEncryptModels( + userModels, + users +); + +const bulkDecryptedResult = await protectClient.bulkDecryptModels( + bulkEncryptedResult.data +); +``` + +The type system ensures that: + +- Input models match your defined type structure +- Only fields defined in your schema are encrypted +- Encrypted and decrypted results maintain the correct type structure +- Optional and nullable fields are properly handled +- Nested object structures are preserved +- Additional properties not defined in the schema remain unchanged + +This type safety helps catch potential issues at compile time and provides better IDE support with autocompletion and type hints. + +> [!TIP] +> When using TypeScript with an ORM, you can reuse your ORM's model types directly with Protect.js's model operations. + +Example with Drizzle infered types: + +```typescript +import { protectClient } from "./protect"; +import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core"; +import { csTable, csColumn } from "@cipherstash/protect"; + +const protectUsers = csTable("users", { + email: csColumn("email"), +}); + +const users = pgTable("users", { + id: serial("id").primaryKey(), + email: jsonb("email").notNull(), +}); + +type User = InferSelectModel; + +const user = { + id: "1", + email: "user@example.com", +}; + +// Drizzle User type works directly with model operations +const encryptedResult = await protectClient.encryptModel( + user, + protectUsers +); +``` + +#### Decrypting a model + +Use the `decryptModel` method to decrypt a model's encrypted fields: + +```typescript +import { protectClient } from "./protect"; + +const decryptedResult = await protectClient.decryptModel(encryptedUser); + +if (decryptedResult.failure) { + // Handle the failure + console.log( + "error when decrypting:", + decryptedResult.failure.type, + decryptedResult.failure.message + ); +} + +const decryptedUser = decryptedResult.data; +console.log("decrypted user:", decryptedUser); +``` + +#### Bulk model operations + +For better performance when working with multiple models, use the `bulkEncryptModels` and `bulkDecryptModels` methods: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +// Array of models with plaintext values +const userModels = [ + { + id: "1", + email: "user1@example.com", + address: "123 Main St", + }, + { + id: "2", + email: "user2@example.com", + address: "456 Oak Ave", + }, +]; + +// Encrypt multiple models at once +const encryptedResult = await protectClient.bulkEncryptModels( + userModels, + users +); + +if (encryptedResult.failure) { + // Handle the failure +} + +const encryptedUsers = encryptedResult.data; + +// Decrypt multiple models at once +const decryptedResult = await protectClient.bulkDecryptModels(encryptedUsers); + +if (decryptedResult.failure) { + // Handle the failure +} + +const decryptedUsers = decryptedResult.data; +``` + +The model encryption methods provide a higher-level interface that's particularly useful when working with ORMs or when you need to encrypt multiple fields in an object. +They automatically handle the mapping between your model's structure and the encrypted fields defined in your schema. -Protect.js is [MIT licensed](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) with copyright by CipherStash. +### Bulk operations -## Security +Protect.js provides direct access to ZeroKMS bulk operations through the `bulkEncrypt` and `bulkDecrypt` methods. These methods are ideal when you need maximum performance and want to handle the correlation between encrypted/decrypted values and your application data manually. -If you believe you have found a security vulnerability in Protect.js, we encourage you to **_responsibly disclose this and NOT open a public issue_**. +> [!TIP] +> The bulk operations provide the most direct interface to ZeroKMS's blazing fast bulk encryption and decryption capabilities. Each value gets a unique key while maintaining optimal performance through a single call to ZeroKMS. + +#### Bulk encryption + +Use the `bulkEncrypt` method to encrypt multiple plaintext values at once: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +// Array of plaintext values with optional IDs for correlation +const plaintexts = [ + { id: "user1", plaintext: "alice@example.com" }, + { id: "user2", plaintext: "bob@example.com" }, + { id: "user3", plaintext: "charlie@example.com" }, +]; + +const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { + column: users.email, + table: users, +}); + +if (encryptedResult.failure) { + // Handle the failure + console.log( + "error when bulk encrypting:", + encryptedResult.failure.type, + encryptedResult.failure.message + ); +} + +const encryptedData = encryptedResult.data; +console.log("encrypted data:", encryptedData); +``` + +The `bulkEncrypt` method returns an array of objects with the following structure: + +```typescript +[ + { id: "user1", data: EncryptedPayload }, + { id: "user2", data: EncryptedPayload }, + { id: "user3", data: EncryptedPayload }, +] +``` + +You can also encrypt without IDs if you don't need correlation: + +```typescript +const plaintexts = [ + { plaintext: "alice@example.com" }, + { plaintext: "bob@example.com" }, + { plaintext: "charlie@example.com" }, +]; + +const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { + column: users.email, + table: users, +}); +``` + +#### Bulk decryption + +Use the `bulkDecrypt` method to decrypt multiple encrypted values at once: + +```typescript +import { protectClient } from "./protect"; + +// encryptedData is the result from bulkEncrypt +const decryptedResult = await protectClient.bulkDecrypt(encryptedData); + +if (decryptedResult.failure) { + // Handle the failure + console.log( + "error when bulk decrypting:", + decryptedResult.failure.type, + decryptedResult.failure.message + ); +} + +const decryptedData = decryptedResult.data; +console.log("decrypted data:", decryptedData); +``` + +The `bulkDecrypt` method returns an array of objects with the following structure: + +```typescript +[ + { id: "user1", data: "alice@example.com" }, + { id: "user2", data: "bob@example.com" }, + { id: "user3", data: "charlie@example.com" }, +] +``` + +#### Response structure + +The `bulkDecrypt` method returns a `Result` object that represents the overall operation status. When successful from an HTTP and execution perspective, the `data` field contains an array where each item can have one of two outcomes: + +- **Success**: The item has a `data` field containing the decrypted plaintext +- **Failure**: The item has an `error` field containing a specific error message explaining why that particular decryption failed + +```typescript +// Example response structure +{ + data: [ + { id: "user1", data: "alice@example.com" }, // Success + { id: "user2", error: "Invalid ciphertext format" }, // Failure + { id: "user3", data: "charlie@example.com" }, // Success + ] +} +``` + +> [!NOTE] +> The underlying ZeroKMS response uses HTTP status code 207 (Multi-Status) to indicate that the bulk operation completed, but individual items within the batch may have succeeded or failed. This allows you to handle partial failures gracefully while still processing the successful decryptions. + +You can handle mixed results by checking each item: + +```typescript +const decryptedResult = await protectClient.bulkDecrypt(encryptedData); + +if (decryptedResult.failure) { + // Handle overall operation failure + console.log("Bulk decryption failed:", decryptedResult.failure.message); + return; +} + +// Process individual results +decryptedResult.data.forEach((item) => { + if ('data' in item) { + // Success - item.data contains the decrypted plaintext + console.log(`Decrypted ${item.id}:`, item.data); + } else if ('error' in item) { + // Failure - item.error contains the specific error message + console.log(`Failed to decrypt ${item.id}:`, item.error); + } +}); +``` + +#### Handling null values + +Bulk operations properly handle null values in both encryption and decryption: + +```typescript +const plaintexts = [ + { id: "user1", plaintext: "alice@example.com" }, + { id: "user2", plaintext: null }, + { id: "user3", plaintext: "charlie@example.com" }, +]; + +const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { + column: users.email, + table: users, +}); + +// Null values are preserved in the encrypted result +// encryptedResult.data[1].data will be null + +const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); + +// Null values are preserved in the decrypted result +// decryptedResult.data[1].data will be null +``` + +#### Using bulk operations with lock contexts + +Bulk operations support identity-aware encryption through lock contexts: + +```typescript +import { LockContext } from "@cipherstash/protect/identify"; + +const lc = new LockContext(); +const lockContext = await lc.identify(userJwt); + +if (lockContext.failure) { + // Handle the failure +} + +const plaintexts = [ + { id: "user1", plaintext: "alice@example.com" }, + { id: "user2", plaintext: "bob@example.com" }, +]; + +// Encrypt with lock context +const encryptedResult = await protectClient + .bulkEncrypt(plaintexts, { + column: users.email, + table: users, + }) + .withLockContext(lockContext.data); + +// Decrypt with lock context +const decryptedResult = await protectClient + .bulkDecrypt(encryptedResult.data) + .withLockContext(lockContext.data); +``` + +#### Performance considerations + +Bulk operations are optimized for performance and can handle thousands of values efficiently: + +```typescript +// Create a large array of values +const plaintexts = Array.from({ length: 1000 }, (_, i) => ({ + id: `user${i}`, + plaintext: `user${i}@example.com`, +})); + +// Single call to ZeroKMS for all 1000 values +const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { + column: users.email, + table: users, +}); + +// Single call to ZeroKMS for all 1000 values +const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); +``` + +The bulk operations maintain the same security guarantees as individual operations - each value gets a unique key - while providing optimal performance through ZeroKMS's bulk processing capabilities. + +### Store encrypted data in a database + +Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment. + +To store the encrypted data, specify the column type as `jsonb`. + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email jsonb NOT NULL, +); +``` + +#### Searchable encryption in PostgreSQL + +To enable searchable encryption in PostgreSQL, [install the EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation). + +1. Download the latest EQL install script: + + ```sh + curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql + ``` + + Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase. + Download the latest EQL install script: + + ```sh + curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt-supabase.sql + ``` + +2. Run this command to install the custom types and functions: + + ```sh + psql -f cipherstash-encrypt.sql + ``` + + or with Supabase: + + ```sh + psql -f cipherstash-encrypt-supabase.sql + ``` + +EQL is now installed in your database and you can enable searchable encryption by adding the `eql_v2_encrypted` type to a column. + +```sql +CREATE TABLE users ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email eql_v2_encrypted +); +``` + +> [!WARNING] +> The `eql_v2_encrypted` type is a [composite type](https://www.postgresql.org/docs/current/rowtypes.html) and each ORM/client has a different way of handling inserts and selects. +> We've documented how to handle inserts and selects for the different ORMs/clients in the [docs](./docs/reference/working-with-composite-types.md). + +Read more about [how to search encrypted data](./docs/reference/searchable-encryption-postgres.md) in the docs. + +## Identity-aware encryption + +> [!IMPORTANT] +> Right now identity-aware encryption is only supported if you are using [Clerk](https://clerk.com/) as your identity provider. +> Read more about [lock contexts with Clerk and Next.js](./docs/how-to/lock-contexts-with-clerk.md). + +Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption. + +This ensures that only the user who encrypted data is able to decrypt it. + +Protect.js does this through a mechanism called a _lock context_. + +### Lock context + +Lock contexts ensure that only specific users can access sensitive data. + +> [!CAUTION] +> You must use the same lock context to encrypt and decrypt data. +> If you use different lock contexts, you will be unable to decrypt the data. + +To use a lock context, initialize a `LockContext` object with the identity claims. + +```typescript +import { LockContext } from "@cipherstash/protect/identify"; + +// protectClient from the previous steps +const lc = new LockContext(); +``` + +> [!NOTE] +> When initializing a `LockContext`, the default context is set to use the `sub` Identity Claim. + +### Identifying a user for a lock context + +A lock context needs to be locked to a user. +To identify the user, call the `identify` method on the lock context object, and pass a valid JWT from a user's session: + +```typescript +const identifyResult = await lc.identify(jwt); + +// The identify method returns the same Result pattern as the encrypt and decrypt methods. +if (identifyResult.failure) { + // Hanlde the failure +} + +const lockContext = identifyResult.data; +``` + +### Encrypting data with a lock context + +To encrypt data with a lock context, call the optional `withLockContext` method on the `encrypt` function and pass the lock context object as a parameter: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +const encryptResult = await protectClient + .encrypt("plaintext", { + table: users, + column: users.email, + }) + .withLockContext(lockContext); + +if (encryptResult.failure) { + // Handle the failure +} + +console.log("EQL Payload containing ciphertexts:", encryptResult.data); +``` + +### Decrypting data with a lock context + +To decrypt data with a lock context, call the optional `withLockContext` method on the `decrypt` function and pass the lock context object as a parameter: + +```typescript +import { protectClient } from "./protect"; + +const decryptResult = await protectClient + .decrypt(encryptResult.data) + .withLockContext(lockContext); + +if (decryptResult.failure) { + // Handle the failure +} + +const plaintext = decryptResult.data; +``` + +### Model encryption with lock context + +All model operations support lock contexts for identity-aware encryption: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +const myUsers = [ + { + id: "1", + email: "user@example.com", + address: "123 Main St", + createdAt: new Date("2024-01-01"), + }, + { + id: "2", + email: "user2@example.com", + address: "456 Oak Ave", + }, +]; + +// Encrypt a model with lock context +const encryptedResult = await protectClient + .encryptModel(myUsers[0], users) + .withLockContext(lockContext); + +if (encryptedResult.failure) { + // Handle the failure +} + +// Decrypt a model with lock context +const decryptedResult = await protectClient + .decryptModel(encryptedResult.data) + .withLockContext(lockContext); + +// Bulk operations also support lock contexts +const bulkEncryptedResult = await protectClient + .bulkEncryptModels(myUsers, users) + .withLockContext(lockContext); + +const bulkDecryptedResult = await protectClient + .bulkDecryptModels(bulkEncryptedResult.data) + .withLockContext(lockContext); +``` + +## Supported data types + +Protect.js currently supports encrypting and decrypting text. +Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon. + +Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48). + +## Searchable encryption + +Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs. + +## Logging + +> [!IMPORTANT] +> `@cipherstash/protect` will NEVER log plaintext data. +> This is by design to prevent sensitive data from leaking into logs. + +`@cipherstash/protect` and `@cipherstash/nextjs` will log to the console with a log level of `info` by default. +To enable the logger, configure the following environment variable: + +```bash +PROTECT_LOG_LEVEL=debug # Enable debug logging +PROTECT_LOG_LEVEL=info # Enable info logging +PROTECT_LOG_LEVEL=error # Enable error logging +``` + +## CipherStash Client + +Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the `@cipherstash/protect-ffi` package. +The `@cipherstash/protect-ffi` source code is available on [GitHub](https://github.com/cipherstash/protectjs-ffi). + +Read more about configuring the CipherStash Client in the [configuration docs](./docs/reference/configuration.md). + +## Example applications + +Looking for examples of how to use Protect.js? +Check out the [example applications](./examples): + +- [Basic example](/examples/basic) demonstrates how to perform encryption operations +- [Drizzle example](/examples/drizzle) demonstrates how to use Protect.js with an ORM +- [Next.js and lock contexts example using Clerk](/examples/nextjs-clerk) demonstrates how to protect data with identity-aware encryption + +`@cipherstash/protect` can be used with most ORMs. +If you're interested in using `@cipherstash/protect` with a specific ORM, please [create an issue](https://github.com/cipherstash/protectjs/issues/new). + +## Builds and bundling + +`@cipherstash/protect` is a native Node.js module, and relies on native Node.js `require` to load the package. + +Here are a few resources to help based on your tool set: + +- [Required Next.js configuration](./docs/how-to/nextjs-external-packages.md). +- [SST and AWS serverless functions](./docs/how-to/sst-external-packages.md). + +> [!TIP] +> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [`docs/how-to/npm-lockfile-v3`](./docs/how-to/npm-lockfile-v3-linux-deployments.md). + +## Contributing + +Please read the [contribution guide](CONTRIBUTE.md). + +## License -Please email [security@cipherstash.com](mailto:security@cipherstash.com). We will provide further instructions for submitting your report. +Protect.js is [MIT licensed](./LICENSE.md). --- -## Didn't find what you wanted? +### Didn't find what you wanted? [Click here to let us know what was missing from our docs.](https://github.com/cipherstash/protectjs/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md) diff --git a/packages/protect-dynamodb/README.md b/packages/protect-dynamodb/README.md index 258dffd..e52ffe6 100644 --- a/packages/protect-dynamodb/README.md +++ b/packages/protect-dynamodb/README.md @@ -6,35 +6,350 @@ Helpers for using CipherStash [Protect.js](https://github.com/cipherstash/protec [![NPM version](https://img.shields.io/npm/v/@cipherstash/protect-dynamodb.svg?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/@cipherstash/protect-dynamodb) [![License](https://img.shields.io/npm/l/@cipherstash/protect.svg?style=for-the-badge&labelColor=000000)](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) -## Getting Started +## Installation -In order to use Protect.js with DynamoDB, you need to be familiar with the following: +```bash +npm install @cipherstash/protect-dynamodb +# or +yarn add @cipherstash/protect-dynamodb +# or +pnpm add @cipherstash/protect-dynamodb +``` -- [Protect.js](https://cipherstash.com/docs/sdk/protect/js) -- [DynamoDB](https://aws.amazon.com/dynamodb/) +## Quick Start -Create an account on [CipherStash](https://dashboard.cipherstash.com/sign-up) and create a workspace to start using Protect.js. +```typescript +import { protectDynamoDB } from '@cipherstash/protect-dynamodb' +import { protect, csColumn, csTable } from '@cipherstash/protect' +import { PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb' -## Documentation +// Define your protected table schema +const users = csTable('users', { + email: csColumn('email').equality(), +}) -Visit [https://cipherstash.com/docs](https://cipherstash.com/docs/guides/protectjs/dynamodb) to view the full documentation. +// Initialize the Protect client +const protectClient = await protect({ + schemas: [users], +}) -## Contributing +// Create the DynamoDB helper instance +const protectDynamo = protectDynamoDB({ + protectClient, +}) -We welcome contributions to Protect.js. Please read the [contribution guide](https://github.com/cipherstash/protectjs/blob/main/CONTRIBUTE.md). +// Encrypt and store a user +const user = { + email: 'user@example.com', +} -## License +const encryptResult = await protectDynamo.encryptModel(user, users) +if (encryptResult.failure) { + throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) +} -Protect.js is [MIT licensed](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) with copyright by CipherStash. +// Store in DynamoDB +await docClient.send(new PutCommand({ + TableName: 'Users', + Item: encryptResult.data, +})) -## Security +// Create search terms for querying +const searchTermsResult = await protectDynamo.createSearchTerms([ + { + value: 'user@example.com', + column: users.email, + table: users, + }, +]) -If you believe you have found a security vulnerability in Protect.js, we encourage you to **_responsibly disclose this and NOT open a public issue_**. +if (searchTermsResult.failure) { + throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) +} -Please email [security@cipherstash.com](mailto:security@cipherstash.com). We will provide further instructions for submitting your report. +// Query using the search term +const [emailHmac] = searchTermsResult.data +const result = await docClient.send(new GetCommand({ + TableName: 'Users', + Key: { email__hmac: emailHmac }, +})) ---- +if (!result.Item) { + throw new Error('Item not found') +} -## Didn't find what you wanted? +// Decrypt the result +const decryptResult = await protectDynamo.decryptModel( + result.Item, + users, +) -[Click here to let us know what was missing from our docs.](https://github.com/cipherstash/protectjs/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md) +if (decryptResult.failure) { + throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`) +} + +const decryptedUser = decryptResult.data +``` + +## Features + +### Encryption and Decryption + +The package provides methods to encrypt and decrypt data for DynamoDB: + +- `encryptModel`: Encrypts a single model +- `bulkEncryptModels`: Encrypts multiple models in bulk +- `decryptModel`: Decrypts a single model +- `bulkDecryptModels`: Decrypts multiple models in bulk + +All methods return a `Result` type that must be checked for failures: + +```typescript +const result = await protectDynamo.encryptModel(user, users) +if (result.failure) { + // Handle error + console.error(result.failure.message) +} else { + // Use encrypted data + const encryptedData = result.data +} +``` + +### Search Terms + +Create search terms for querying encrypted data: + +- `createSearchTerms`: Creates search terms for one or more columns + +```typescript +const searchTermsResult = await protectDynamo.createSearchTerms([ + { + value: 'user@example.com', + column: users.email, + table: users, + }, +]) + +if (searchTermsResult.failure) { + throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) +} + +const [emailHmac] = searchTermsResult.data +``` + +### DynamoDB Integration + +The package automatically handles: +- Converting encrypted data to DynamoDB's format +- Adding HMAC attributes for searchable fields +- Preserving unencrypted fields +- Converting DynamoDB items back to encrypted format for decryption + +## Usage Patterns + +### Simple Table with Encrypted Fields + +```typescript +const users = csTable('users', { + email: csColumn('email').equality(), +}) + +// Encrypt and store +const encryptResult = await protectDynamo.encryptModel({ + pk: 'user#1', + email: 'user@example.com', +}, users) + +if (encryptResult.failure) { + throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) +} + +// Query using search terms +const searchTermsResult = await protectDynamo.createSearchTerms([ + { + value: 'user@example.com', + column: users.email, + table: users, + }, +]) + +if (searchTermsResult.failure) { + throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) +} +``` + +### Encrypted Partition Key + +```typescript +// Table with encrypted partition key +const table = { + TableName: 'Users', + AttributeDefinitions: [ + { + AttributeName: 'email__hmac', + AttributeType: 'S', + }, + ], + KeySchema: [ + { + AttributeName: 'email__hmac', + KeyType: 'HASH', + }, + ], +} + +// Create search terms for querying +const searchTermsResult = await protectDynamo.createSearchTerms([ + { + value: 'user@example.com', + column: users.email, + table: users, + }, +]) + +if (searchTermsResult.failure) { + throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) +} + +const [emailHmac] = searchTermsResult.data +``` + +### Encrypted Sort Key + +```typescript +// Table with encrypted sort key +const table = { + TableName: 'Users', + AttributeDefinitions: [ + { + AttributeName: 'pk', + AttributeType: 'S', + }, + { + AttributeName: 'email__hmac', + AttributeType: 'S', + }, + ], + KeySchema: [ + { + AttributeName: 'pk', + KeyType: 'HASH', + }, + { + AttributeName: 'email__hmac', + KeyType: 'RANGE', + }, + ], +} + +// Create search terms for querying +const searchTermsResult = await protectDynamo.createSearchTerms([ + { + value: 'user@example.com', + column: users.email, + table: users, + }, +]) + +if (searchTermsResult.failure) { + throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) +} + +const [emailHmac] = searchTermsResult.data +``` + +### Global Secondary Index with Encrypted Key + +```typescript +// Table with GSI using encrypted key +const table = { + TableName: 'Users', + AttributeDefinitions: [ + { + AttributeName: 'pk', + AttributeType: 'S', + }, + { + AttributeName: 'email__hmac', + AttributeType: 'S', + }, + ], + KeySchema: [ + { + AttributeName: 'pk', + KeyType: 'HASH', + }, + ], + GlobalSecondaryIndexes: [ + { + IndexName: 'EmailIndex', + KeySchema: [ + { + AttributeName: 'email__hmac', + KeyType: 'HASH', + }, + ], + Projection: { + ProjectionType: 'INCLUDE', + NonKeyAttributes: ['email__source'], + }, + }, + ], +} + +// Create search terms for querying +const searchTermsResult = await protectDynamo.createSearchTerms([ + { + value: 'user@example.com', + column: users.email, + table: users, + }, +]) + +if (searchTermsResult.failure) { + throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) +} + +const [emailHmac] = searchTermsResult.data +``` + +## Error Handling + +All methods return a `Result` type from `@byteslice/result` that must be checked for failures: + +```typescript +const result = await protectDynamo.encryptModel(user, users) + +if (result.failure) { + // Handle error + console.error(result.failure.message) +} else { + // Use encrypted data + const encryptedData = result.data +} +``` + +## Type Safety + +The package is fully typed and supports TypeScript: + +```typescript +type User = { + pk: string + email: string +} + +// Type-safe encryption +const encryptResult = await protectDynamo.encryptModel(user, users) +if (encryptResult.failure) { + throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) +} +const encryptedUser = encryptResult.data + +// Type-safe decryption +const decryptResult = await protectDynamo.decryptModel(item, users) +if (decryptResult.failure) { + throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`) +} +const decryptedUser = decryptResult.data +``` diff --git a/packages/protect/README.md b/packages/protect/README.md index 03a3a7c..87c8dfe 100644 --- a/packages/protect/README.md +++ b/packages/protect/README.md @@ -50,32 +50,1006 @@ -## Getting Started - Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database. -Create an account on [CipherStash](https://dashboard.cipherstash.com/sign-up) and create a workspace to start using Protect.js. +Per‑value unique keys are powered by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) bulk key operations, backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html). -## Documentation +Encrypted data is structured as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload and can be stored in any database that supports JSONB. -Visit [https://cipherstash.com/docs](https://cipherstash.com/docs/sdk/protect/js) to view the full documentation. +> [!IMPORTANT] +> Searching, sorting, and filtering on encrypted data is currently only supported when storing encrypted data in PostgreSQL. +> Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md). -## Contributing +Looking for DynamoDB support? Check out the [Protect.js for DynamoDB helper library](https://www.npmjs.com/package/@cipherstash/protect-dynamodb). -We welcome contributions to Protect.js. Please read the [contribution guide](https://github.com/cipherstash/protectjs/blob/main/CONTRIBUTE.md). +## Quick start (60 seconds) -## License +Create an account and workspace in the [CipherStash dashboard](https://cipherstash.com/signup), then follow the onboarding guide to generate your client credentials and store them in your `.env` file. + +Install the package: + +```bash +npm install @cipherstash/protect +``` + +Start encrypting data: + +```ts +import { protect } from "@cipherstash/protect"; +import { csTable, csColumn } from "@cipherstash/protect"; + +// 1) Define a schema +const users = csTable("users", { email: csColumn("email") }); + +// 2) Create a client (requires CS_* env vars) +const client = await protect({ schemas: [users] }); + +// 3) Encrypt → store JSONB payload +const encrypted = await client.encrypt("alice@example.com", { + table: users, + column: users.email, +}); + +if (encrypted.failure) { + // You decide how to handle the failure and the user experience +} + +// 4) Decrypt later +const decrypted = await client.decrypt(encrypted.data); +``` + +## Architecture (high level) + +![Protect.js Architecture Diagram](https://github.com/cipherstash/protectjs/blob/main/docs/images/protectjs-architecture.png) + +## Table of contents + +- [Quick start (60 seconds)](#quick-start-60-seconds) +- [Architecture (high level)](#architecture-high-level) +- [Features](#features) +- [Installing Protect.js](#installing-protectjs) +- [Getting started](#getting-started) +- [Identity-aware encryption](#identity-aware-encryption) +- [Supported data types](#supported-data-types) +- [Searchable encryption](#searchable-encryption) +- [Logging](#logging) +- [CipherStash Client](#cipherstash-client) +- [Example applications](#example-applications) +- [Builds and bundling](#builds-and-bundling) +- [Contributing](#contributing) +- [License](#license) + +For more specific documentation, refer to the [docs](https://github.com/cipherstash/protectjs/tree/main/docs). + +## Features + +Protect.js protects data in using industry-standard AES encryption. +Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations. +This enables every encrypted value, in every column, in every row in your database to have a unique key — without sacrificing performance. + +**Features:** + +- **Bulk encryption and decryption**: Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for encrypting and decrypting thousands of records at once, while using a unique key for every value. +- **Single item encryption and decryption**: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered. +- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js. +- **Identity-aware encryption**: Lock down access to sensitive data by requiring a valid JWT to perform a decryption. +- **Audit trail**: Every decryption event will be logged in ZeroKMS to help you prove compliance. +- **Searchable encryption**: Protect.js supports searching encrypted data in PostgreSQL. +- **TypeScript support**: Strongly typed with TypeScript interfaces and types. + +**Use cases:** + +- **Trusted data access**: make sure only your end-users can access their sensitive data stored in your product. +- **Meet compliance requirements faster:** meet and exceed the data encryption requirements of SOC2 and ISO27001. +- **Reduce the blast radius of data breaches:** limit the impact of exploited vulnerabilities to only the data your end-users can decrypt. + +## Installing Protect.js + +Install the [`@cipherstash/protect` package](https://www.npmjs.com/package/@cipherstash/protect) with your package manager of choice: + +```bash +npm install @cipherstash/protect +# or +yarn add @cipherstash/protect +# or +pnpm add @cipherstash/protect +``` + +> [!TIP] +> [Bun](https://bun.sh/) is not currently supported due to a lack of [Node-API compatibility](https://github.com/oven-sh/bun/issues/158). Under the hood, Protect.js uses [CipherStash Client](#cipherstash-client) which is written in Rust and embedded using [Neon](https://github.com/neon-bindings/neon). + +### Opt-out of bundling + +> [!IMPORTANT] +> **You need to opt-out of bundling when using Protect.js.** + +Protect.js uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid). + +When using Protect.js, you need to opt-out of bundling for tools like [Webpack](https://webpack.js.org/configuration/externals/), [esbuild](https://webpack.js.org/configuration/externals/), or [Next.js](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages). + +Read more about [building and bundling with Protect.js](#builds-and-bundling). + +## Getting started + +- 🆕 **Existing app?** Skip to [the next step](#configuration). +- 🌱 **Clean slate?** Check out the [getting started tutorial](./docs/getting-started.md). + +### Configuration + +If you haven't already, sign up for a [CipherStash account](https://cipherstash.com/signup). +Once you have an account, you will create a Workspace which is scoped to your application environment. + +Follow the onboarding steps to get your first set of credentials required to use Protect.js. +By the end of the onboarding, you will have the following environment variables: + +```bash +CS_WORKSPACE_CRN= # The workspace identifier +CS_CLIENT_ID= # The client identifier +CS_CLIENT_KEY= # The client key which is used as key material in combination with ZeroKMS +CS_CLIENT_ACCESS_KEY= # The API key used for authenticating with the CipherStash API +``` + +Save these environment variables to a `.env` file in your project. + +### Basic file structure + +The following is the basic file structure of the project. +In the `src/protect/` directory, we have the table definition in `schema.ts` and the protect client in `index.ts`. + +``` +📦 + ├ 📂 src + │ ├ 📂 protect + │ │ ├ 📜 index.ts + │ │ └ 📜 schema.ts + │ └ 📜 index.ts + ├ 📜 .env + ├ 📜 cipherstash.toml + ├ 📜 cipherstash.secret.toml + ├ 📜 package.json + └ 📜 tsconfig.json +``` + +### Define your schema + +Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt. + +Define your tables and columns by adding this to `src/protect/schema.ts`: + +```ts +import { csTable, csColumn } from "@cipherstash/protect"; + +export const users = csTable("users", { + email: csColumn("email"), +}); + +export const orders = csTable("orders", { + address: csColumn("address"), +}); +``` + +**Searchable encryption:** + +If you want to search encrypted data in your PostgreSQL database, you must declare the indexes in schema in `src/protect/schema.ts`: + +```ts +import { csTable, csColumn } from "@cipherstash/protect"; + +export const users = csTable("users", { + email: csColumn("email").freeTextSearch().equality().orderAndRange(), +}); + +export const orders = csTable("orders", { + address: csColumn("address"), +}); +``` + +Read more about [defining your schema](./docs/reference/schema.md). + +### Initialize the Protect client + +To import the `protect` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`: + +```ts +import { protect, type ProtectClientConfig } from "@cipherstash/protect"; +import { users, orders } from "./schema"; + +const config: ProtectClientConfig = { + schemas: [users, orders], +} + +// Pass all your tables to the protect function to initialize the client +export const protectClient = await protect(config); +``` + +The `protect` function requires at least one `csTable` be provided in the `schemas` array. + +### Encrypt data + +Protect.js provides the `encrypt` function on `protectClient` to encrypt data. +`encrypt` takes a plaintext string, and an object with the table and column as parameters. + +To start encrypting data, add the following to `src/index.ts`: + +```typescript +import { users } from "./protect/schema"; +import { protectClient } from "./protect"; + +const encryptResult = await protectClient.encrypt("secret@squirrel.example", { + column: users.email, + table: users, +}); + +if (encryptResult.failure) { + // Handle the failure + console.log( + "error when encrypting:", + encryptResult.failure.type, + encryptResult.failure.message + ); +} + +console.log("EQL Payload containing ciphertexts:", encryptResult.data); +``` + +The `encrypt` function will return a `Result` object with either a `data` key, or a `failure` key. +The `encryptResult` will return one of the following: + +```typescript +// Success +{ + data: EncryptedPayload +} + +// Failure +{ + failure: { + type: 'EncryptionError', + message: 'A message about the error' + } +} +``` + +### Decrypt data + +Protect.js provides the `decrypt` function on `protectClient` to decrypt data. +`decrypt` takes an encrypted data object as a parameter. + +To start decrypting data, add the following to `src/index.ts`: + +```typescript +import { protectClient } from "./protect"; + +// encryptResult is the EQL payload from the previous step +const decryptResult = await protectClient.decrypt(encryptResult.data); + +if (decryptResult.failure) { + // Handle the failure + console.log( + "error when decrypting:", + decryptResult.failure.type, + decryptResult.failure.message + ); +} + +const plaintext = decryptResult.data; +console.log("plaintext:", plaintext); +``` + +The `decrypt` function returns a `Result` object with either a `data` key, or a `failure` key. +The `decryptResult` will return one of the following: + +```typescript +// Success +{ + data: 'secret@squirrel.example' +} + +// Failure +{ + failure: { + type: 'DecryptionError', + message: 'A message about the error' + } +} +``` + +### Working with models and objects + +Protect.js provides model-level encryption methods that make it easy to encrypt and decrypt entire objects. +These methods automatically handle the encryption of fields defined in your schema. + +If you are working with a large data set, the model operations are significantly faster than encrypting and decrypting individual objects as they are able to perform bulk operations. + +> [!TIP] +> CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) is optimized for bulk operations. +> +> All the model operations are able to take advantage of this performance for real-world use cases by only making a single call to ZeroKMS regardless of the number of objects you are encrypting or decrypting while still using a unique key for each record. + +#### Encrypting a model + +Use the `encryptModel` method to encrypt a model's fields that are defined in your schema: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +// Your model with plaintext values +const user = { + id: "1", + email: "user@example.com", + address: "123 Main St", + createdAt: new Date("2024-01-01"), +}; + +const encryptedResult = await protectClient.encryptModel(user, users); + +if (encryptedResult.failure) { + // Handle the failure + console.log( + "error when encrypting:", + encryptedResult.failure.type, + encryptedResult.failure.message + ); +} + +const encryptedUser = encryptedResult.data; +console.log("encrypted user:", encryptedUser); +``` + +The `encryptModel` function will only encrypt fields that are defined in your schema. +Other fields (like `id` and `createdAt` in the example above) will remain unchanged. + +#### Type safety with models + +Protect.js provides strong TypeScript support for model operations. +You can specify your model's type to ensure end-to-end type safety: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +// Define your model type +type User = { + id: string; + email: string | null; + address: string | null; + createdAt: Date; + updatedAt: Date; + metadata?: { + preferences?: { + notifications: boolean; + theme: string; + }; + }; +}; + +// The encryptModel method will ensure type safety +const encryptedResult = await protectClient.encryptModel(user, users); + +if (encryptedResult.failure) { + // Handle the failure +} + +const encryptedUser = encryptedResult.data; +// TypeScript knows that encryptedUser matches the User type structure +// but with encrypted fields for those defined in the schema + +// Decryption maintains type safety +const decryptedResult = await protectClient.decryptModel(encryptedUser); + +if (decryptedResult.failure) { + // Handle the failure +} + +const decryptedUser = decryptedResult.data; +// decryptedUser is fully typed as User + +// Bulk operations also support type safety +const bulkEncryptedResult = await protectClient.bulkEncryptModels( + userModels, + users +); + +const bulkDecryptedResult = await protectClient.bulkDecryptModels( + bulkEncryptedResult.data +); +``` + +The type system ensures that: + +- Input models match your defined type structure +- Only fields defined in your schema are encrypted +- Encrypted and decrypted results maintain the correct type structure +- Optional and nullable fields are properly handled +- Nested object structures are preserved +- Additional properties not defined in the schema remain unchanged + +This type safety helps catch potential issues at compile time and provides better IDE support with autocompletion and type hints. + +> [!TIP] +> When using TypeScript with an ORM, you can reuse your ORM's model types directly with Protect.js's model operations. + +Example with Drizzle infered types: + +```typescript +import { protectClient } from "./protect"; +import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core"; +import { csTable, csColumn } from "@cipherstash/protect"; + +const protectUsers = csTable("users", { + email: csColumn("email"), +}); + +const users = pgTable("users", { + id: serial("id").primaryKey(), + email: jsonb("email").notNull(), +}); + +type User = InferSelectModel; + +const user = { + id: "1", + email: "user@example.com", +}; + +// Drizzle User type works directly with model operations +const encryptedResult = await protectClient.encryptModel( + user, + protectUsers +); +``` + +#### Decrypting a model + +Use the `decryptModel` method to decrypt a model's encrypted fields: + +```typescript +import { protectClient } from "./protect"; + +const decryptedResult = await protectClient.decryptModel(encryptedUser); + +if (decryptedResult.failure) { + // Handle the failure + console.log( + "error when decrypting:", + decryptedResult.failure.type, + decryptedResult.failure.message + ); +} + +const decryptedUser = decryptedResult.data; +console.log("decrypted user:", decryptedUser); +``` + +#### Bulk model operations + +For better performance when working with multiple models, use the `bulkEncryptModels` and `bulkDecryptModels` methods: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +// Array of models with plaintext values +const userModels = [ + { + id: "1", + email: "user1@example.com", + address: "123 Main St", + }, + { + id: "2", + email: "user2@example.com", + address: "456 Oak Ave", + }, +]; + +// Encrypt multiple models at once +const encryptedResult = await protectClient.bulkEncryptModels( + userModels, + users +); + +if (encryptedResult.failure) { + // Handle the failure +} + +const encryptedUsers = encryptedResult.data; + +// Decrypt multiple models at once +const decryptedResult = await protectClient.bulkDecryptModels(encryptedUsers); + +if (decryptedResult.failure) { + // Handle the failure +} + +const decryptedUsers = decryptedResult.data; +``` + +The model encryption methods provide a higher-level interface that's particularly useful when working with ORMs or when you need to encrypt multiple fields in an object. +They automatically handle the mapping between your model's structure and the encrypted fields defined in your schema. -Protect.js is [MIT licensed](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md) with copyright by CipherStash. +### Bulk operations -## Security +Protect.js provides direct access to ZeroKMS bulk operations through the `bulkEncrypt` and `bulkDecrypt` methods. These methods are ideal when you need maximum performance and want to handle the correlation between encrypted/decrypted values and your application data manually. -If you believe you have found a security vulnerability in Protect.js, we encourage you to **_responsibly disclose this and NOT open a public issue_**. +> [!TIP] +> The bulk operations provide the most direct interface to ZeroKMS's blazing fast bulk encryption and decryption capabilities. Each value gets a unique key while maintaining optimal performance through a single call to ZeroKMS. + +#### Bulk encryption + +Use the `bulkEncrypt` method to encrypt multiple plaintext values at once: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +// Array of plaintext values with optional IDs for correlation +const plaintexts = [ + { id: "user1", plaintext: "alice@example.com" }, + { id: "user2", plaintext: "bob@example.com" }, + { id: "user3", plaintext: "charlie@example.com" }, +]; + +const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { + column: users.email, + table: users, +}); + +if (encryptedResult.failure) { + // Handle the failure + console.log( + "error when bulk encrypting:", + encryptedResult.failure.type, + encryptedResult.failure.message + ); +} + +const encryptedData = encryptedResult.data; +console.log("encrypted data:", encryptedData); +``` + +The `bulkEncrypt` method returns an array of objects with the following structure: + +```typescript +[ + { id: "user1", data: EncryptedPayload }, + { id: "user2", data: EncryptedPayload }, + { id: "user3", data: EncryptedPayload }, +] +``` + +You can also encrypt without IDs if you don't need correlation: + +```typescript +const plaintexts = [ + { plaintext: "alice@example.com" }, + { plaintext: "bob@example.com" }, + { plaintext: "charlie@example.com" }, +]; + +const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { + column: users.email, + table: users, +}); +``` + +#### Bulk decryption + +Use the `bulkDecrypt` method to decrypt multiple encrypted values at once: + +```typescript +import { protectClient } from "./protect"; + +// encryptedData is the result from bulkEncrypt +const decryptedResult = await protectClient.bulkDecrypt(encryptedData); + +if (decryptedResult.failure) { + // Handle the failure + console.log( + "error when bulk decrypting:", + decryptedResult.failure.type, + decryptedResult.failure.message + ); +} + +const decryptedData = decryptedResult.data; +console.log("decrypted data:", decryptedData); +``` + +The `bulkDecrypt` method returns an array of objects with the following structure: + +```typescript +[ + { id: "user1", data: "alice@example.com" }, + { id: "user2", data: "bob@example.com" }, + { id: "user3", data: "charlie@example.com" }, +] +``` + +#### Response structure + +The `bulkDecrypt` method returns a `Result` object that represents the overall operation status. When successful from an HTTP and execution perspective, the `data` field contains an array where each item can have one of two outcomes: + +- **Success**: The item has a `data` field containing the decrypted plaintext +- **Failure**: The item has an `error` field containing a specific error message explaining why that particular decryption failed + +```typescript +// Example response structure +{ + data: [ + { id: "user1", data: "alice@example.com" }, // Success + { id: "user2", error: "Invalid ciphertext format" }, // Failure + { id: "user3", data: "charlie@example.com" }, // Success + ] +} +``` + +> [!NOTE] +> The underlying ZeroKMS response uses HTTP status code 207 (Multi-Status) to indicate that the bulk operation completed, but individual items within the batch may have succeeded or failed. This allows you to handle partial failures gracefully while still processing the successful decryptions. + +You can handle mixed results by checking each item: + +```typescript +const decryptedResult = await protectClient.bulkDecrypt(encryptedData); + +if (decryptedResult.failure) { + // Handle overall operation failure + console.log("Bulk decryption failed:", decryptedResult.failure.message); + return; +} + +// Process individual results +decryptedResult.data.forEach((item) => { + if ('data' in item) { + // Success - item.data contains the decrypted plaintext + console.log(`Decrypted ${item.id}:`, item.data); + } else if ('error' in item) { + // Failure - item.error contains the specific error message + console.log(`Failed to decrypt ${item.id}:`, item.error); + } +}); +``` + +#### Handling null values + +Bulk operations properly handle null values in both encryption and decryption: + +```typescript +const plaintexts = [ + { id: "user1", plaintext: "alice@example.com" }, + { id: "user2", plaintext: null }, + { id: "user3", plaintext: "charlie@example.com" }, +]; + +const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { + column: users.email, + table: users, +}); + +// Null values are preserved in the encrypted result +// encryptedResult.data[1].data will be null + +const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); + +// Null values are preserved in the decrypted result +// decryptedResult.data[1].data will be null +``` + +#### Using bulk operations with lock contexts + +Bulk operations support identity-aware encryption through lock contexts: + +```typescript +import { LockContext } from "@cipherstash/protect/identify"; + +const lc = new LockContext(); +const lockContext = await lc.identify(userJwt); + +if (lockContext.failure) { + // Handle the failure +} + +const plaintexts = [ + { id: "user1", plaintext: "alice@example.com" }, + { id: "user2", plaintext: "bob@example.com" }, +]; + +// Encrypt with lock context +const encryptedResult = await protectClient + .bulkEncrypt(plaintexts, { + column: users.email, + table: users, + }) + .withLockContext(lockContext.data); + +// Decrypt with lock context +const decryptedResult = await protectClient + .bulkDecrypt(encryptedResult.data) + .withLockContext(lockContext.data); +``` + +#### Performance considerations + +Bulk operations are optimized for performance and can handle thousands of values efficiently: + +```typescript +// Create a large array of values +const plaintexts = Array.from({ length: 1000 }, (_, i) => ({ + id: `user${i}`, + plaintext: `user${i}@example.com`, +})); + +// Single call to ZeroKMS for all 1000 values +const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { + column: users.email, + table: users, +}); + +// Single call to ZeroKMS for all 1000 values +const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); +``` + +The bulk operations maintain the same security guarantees as individual operations - each value gets a unique key - while providing optimal performance through ZeroKMS's bulk processing capabilities. + +### Store encrypted data in a database + +Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment. + +To store the encrypted data, specify the column type as `jsonb`. + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email jsonb NOT NULL, +); +``` + +#### Searchable encryption in PostgreSQL + +To enable searchable encryption in PostgreSQL, [install the EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation). + +1. Download the latest EQL install script: + + ```sh + curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql + ``` + + Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase. + Download the latest EQL install script: + + ```sh + curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt-supabase.sql + ``` + +2. Run this command to install the custom types and functions: + + ```sh + psql -f cipherstash-encrypt.sql + ``` + + or with Supabase: + + ```sh + psql -f cipherstash-encrypt-supabase.sql + ``` + +EQL is now installed in your database and you can enable searchable encryption by adding the `eql_v2_encrypted` type to a column. + +```sql +CREATE TABLE users ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email eql_v2_encrypted +); +``` + +> [!WARNING] +> The `eql_v2_encrypted` type is a [composite type](https://www.postgresql.org/docs/current/rowtypes.html) and each ORM/client has a different way of handling inserts and selects. +> We've documented how to handle inserts and selects for the different ORMs/clients in the [docs](./docs/reference/working-with-composite-types.md). + +Read more about [how to search encrypted data](./docs/reference/searchable-encryption-postgres.md) in the docs. + +## Identity-aware encryption + +> [!IMPORTANT] +> Right now identity-aware encryption is only supported if you are using [Clerk](https://clerk.com/) as your identity provider. +> Read more about [lock contexts with Clerk and Next.js](./docs/how-to/lock-contexts-with-clerk.md). + +Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption. + +This ensures that only the user who encrypted data is able to decrypt it. + +Protect.js does this through a mechanism called a _lock context_. + +### Lock context + +Lock contexts ensure that only specific users can access sensitive data. + +> [!CAUTION] +> You must use the same lock context to encrypt and decrypt data. +> If you use different lock contexts, you will be unable to decrypt the data. + +To use a lock context, initialize a `LockContext` object with the identity claims. + +```typescript +import { LockContext } from "@cipherstash/protect/identify"; + +// protectClient from the previous steps +const lc = new LockContext(); +``` + +> [!NOTE] +> When initializing a `LockContext`, the default context is set to use the `sub` Identity Claim. + +### Identifying a user for a lock context + +A lock context needs to be locked to a user. +To identify the user, call the `identify` method on the lock context object, and pass a valid JWT from a user's session: + +```typescript +const identifyResult = await lc.identify(jwt); + +// The identify method returns the same Result pattern as the encrypt and decrypt methods. +if (identifyResult.failure) { + // Hanlde the failure +} + +const lockContext = identifyResult.data; +``` + +### Encrypting data with a lock context + +To encrypt data with a lock context, call the optional `withLockContext` method on the `encrypt` function and pass the lock context object as a parameter: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +const encryptResult = await protectClient + .encrypt("plaintext", { + table: users, + column: users.email, + }) + .withLockContext(lockContext); + +if (encryptResult.failure) { + // Handle the failure +} + +console.log("EQL Payload containing ciphertexts:", encryptResult.data); +``` + +### Decrypting data with a lock context + +To decrypt data with a lock context, call the optional `withLockContext` method on the `decrypt` function and pass the lock context object as a parameter: + +```typescript +import { protectClient } from "./protect"; + +const decryptResult = await protectClient + .decrypt(encryptResult.data) + .withLockContext(lockContext); + +if (decryptResult.failure) { + // Handle the failure +} + +const plaintext = decryptResult.data; +``` + +### Model encryption with lock context + +All model operations support lock contexts for identity-aware encryption: + +```typescript +import { protectClient } from "./protect"; +import { users } from "./protect/schema"; + +const myUsers = [ + { + id: "1", + email: "user@example.com", + address: "123 Main St", + createdAt: new Date("2024-01-01"), + }, + { + id: "2", + email: "user2@example.com", + address: "456 Oak Ave", + }, +]; + +// Encrypt a model with lock context +const encryptedResult = await protectClient + .encryptModel(myUsers[0], users) + .withLockContext(lockContext); + +if (encryptedResult.failure) { + // Handle the failure +} + +// Decrypt a model with lock context +const decryptedResult = await protectClient + .decryptModel(encryptedResult.data) + .withLockContext(lockContext); + +// Bulk operations also support lock contexts +const bulkEncryptedResult = await protectClient + .bulkEncryptModels(myUsers, users) + .withLockContext(lockContext); + +const bulkDecryptedResult = await protectClient + .bulkDecryptModels(bulkEncryptedResult.data) + .withLockContext(lockContext); +``` + +## Supported data types + +Protect.js currently supports encrypting and decrypting text. +Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon. + +Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48). + +## Searchable encryption + +Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs. + +## Logging + +> [!IMPORTANT] +> `@cipherstash/protect` will NEVER log plaintext data. +> This is by design to prevent sensitive data from leaking into logs. + +`@cipherstash/protect` and `@cipherstash/nextjs` will log to the console with a log level of `info` by default. +To enable the logger, configure the following environment variable: + +```bash +PROTECT_LOG_LEVEL=debug # Enable debug logging +PROTECT_LOG_LEVEL=info # Enable info logging +PROTECT_LOG_LEVEL=error # Enable error logging +``` + +## CipherStash Client + +Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the `@cipherstash/protect-ffi` package. +The `@cipherstash/protect-ffi` source code is available on [GitHub](https://github.com/cipherstash/protectjs-ffi). + +Read more about configuring the CipherStash Client in the [configuration docs](./docs/reference/configuration.md). + +## Example applications + +Looking for examples of how to use Protect.js? +Check out the [example applications](./examples): + +- [Basic example](/examples/basic) demonstrates how to perform encryption operations +- [Drizzle example](/examples/drizzle) demonstrates how to use Protect.js with an ORM +- [Next.js and lock contexts example using Clerk](/examples/nextjs-clerk) demonstrates how to protect data with identity-aware encryption + +`@cipherstash/protect` can be used with most ORMs. +If you're interested in using `@cipherstash/protect` with a specific ORM, please [create an issue](https://github.com/cipherstash/protectjs/issues/new). + +## Builds and bundling + +`@cipherstash/protect` is a native Node.js module, and relies on native Node.js `require` to load the package. + +Here are a few resources to help based on your tool set: + +- [Required Next.js configuration](./docs/how-to/nextjs-external-packages.md). +- [SST and AWS serverless functions](./docs/how-to/sst-external-packages.md). + +> [!TIP] +> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [`docs/how-to/npm-lockfile-v3`](./docs/how-to/npm-lockfile-v3-linux-deployments.md). + +## Contributing + +Please read the [contribution guide](CONTRIBUTE.md). + +## License -Please email [security@cipherstash.com](mailto:security@cipherstash.com). We will provide further instructions for submitting your report. +Protect.js is [MIT licensed](./LICENSE.md). --- -## Didn't find what you wanted? +### Didn't find what you wanted? [Click here to let us know what was missing from our docs.](https://github.com/cipherstash/protectjs/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md) diff --git a/packages/protect/__tests__/bulk-protect.test.ts b/packages/protect/__tests__/bulk-protect.test.ts index 6b55a2c..893bea8 100644 --- a/packages/protect/__tests__/bulk-protect.test.ts +++ b/packages/protect/__tests__/bulk-protect.test.ts @@ -1,12 +1,7 @@ import 'dotenv/config' -import { csColumn, csTable, type EncryptConfig } from '@cipherstash/schema' +import { csColumn, csTable } from '@cipherstash/schema' import { beforeAll, describe, expect, it } from 'vitest' -import { - type BulkDecryptPayload, - type Encrypted, - LockContext, - protect, -} from '../src' +import { type EncryptedPayload, LockContext, protect } from '../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), @@ -320,9 +315,10 @@ describe('bulk encryption and decryption', () => { }, 30000) it('should handle empty array in bulk decrypt', async () => { - const encrypteds: BulkDecryptPayload[] = [] + const encryptedPayloads: Array<{ id?: string; data: EncryptedPayload }> = + [] - const decryptedData = await protectClient.bulkDecrypt(encrypteds) + const decryptedData = await protectClient.bulkDecrypt(encryptedPayloads) if (decryptedData.failure) { throw new Error(`[protect]: ${decryptedData.failure.message}`) diff --git a/packages/protect/package.json b/packages/protect/package.json index 2e866e5..d1b5288 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.18.0-9", + "@cipherstash/protect-ffi": "0.17.0", "@cipherstash/schema": "workspace:*", "zod": "^3.24.2" }, diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index d1df5a7..bacd41c 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -33,9 +33,9 @@ export const noClientError = () => 'The EQL client has not been initialized. Please call init() before using the client.', ) -export class ProtectClient { +export class ProtectClient { private client: Client - private encryptConfig: C | undefined + private encryptConfig: EncryptConfig | undefined private workspaceId: string | undefined constructor(workspaceCrn?: string) { @@ -44,17 +44,17 @@ export class ProtectClient { } async init(config: { - encryptConfig: C + encryptConfig: EncryptConfig workspaceCrn?: string accessKey?: string clientId?: string clientKey?: string - }): Promise, ProtectError>> { + }): Promise> { return await withResult( async () => { - const validated: C = encryptConfigSchema.parse( + const validated: EncryptConfig = encryptConfigSchema.parse( config.encryptConfig, - ) as C + ) logger.debug( 'Initializing the Protect.js client with the following encrypt config:', @@ -94,8 +94,8 @@ export class ProtectClient { encrypt( plaintext: JsPlaintext | null, opts: EncryptOptions, - ): EncryptOperation { - return new EncryptOperation(this.client, plaintext, opts) + ): EncryptOperation { + return new EncryptOperation(this.client, plaintext, opts) } /** @@ -104,8 +104,8 @@ export class ProtectClient { * await eqlClient.decrypt(encryptedData) * await eqlClient.decrypt(encryptedData).withLockContext(lockContext) */ - decrypt(encryptedData: Encrypted): DecryptOperation { - return new DecryptOperation(this.client, encryptedData) + decrypt(encryptedData: Encrypted): DecryptOperation { + return new DecryptOperation(this.client, encryptedData) } /** @@ -115,10 +115,10 @@ export class ProtectClient { * await eqlClient.encryptModel(decryptedModel, table).withLockContext(lockContext) */ encryptModel>( - input: Decrypted, + input: Decrypted, table: ProtectTable, - ): EncryptModelOperation { - return new EncryptModelOperation(this.client, input, table) + ): EncryptModelOperation { + return new EncryptModelOperation(this.client, input, table) } /** @@ -129,8 +129,8 @@ export class ProtectClient { */ decryptModel>( input: T, - ): DecryptModelOperation { - return new DecryptModelOperation(this.client, input) + ): DecryptModelOperation { + return new DecryptModelOperation(this.client, input) } /** @@ -140,10 +140,10 @@ export class ProtectClient { * await eqlClient.bulkEncryptModels(decryptedModels, table).withLockContext(lockContext) */ bulkEncryptModels>( - input: Array>, + input: Array>, table: ProtectTable, - ): BulkEncryptModelsOperation { - return new BulkEncryptModelsOperation(this.client, input, table) + ): BulkEncryptModelsOperation { + return new BulkEncryptModelsOperation(this.client, input, table) } /** @@ -154,8 +154,8 @@ export class ProtectClient { */ bulkDecryptModels>( input: Array, - ): BulkDecryptModelsOperation { - return new BulkDecryptModelsOperation(this.client, input) + ): BulkDecryptModelsOperation { + return new BulkDecryptModelsOperation(this.client, input) } /** @@ -167,8 +167,8 @@ export class ProtectClient { bulkEncrypt( plaintexts: BulkEncryptPayload, opts: EncryptOptions, - ): BulkEncryptOperation { - return new BulkEncryptOperation(this.client, plaintexts, opts) + ): BulkEncryptOperation { + return new BulkEncryptOperation(this.client, plaintexts, opts) } /** @@ -177,10 +177,8 @@ export class ProtectClient { * await eqlClient.bulkDecrypt(encryptedPayloads) * await eqlClient.bulkDecrypt(encryptedPayloads).withLockContext(lockContext) */ - bulkDecrypt( - encryptedPayloads: BulkDecryptPayload, - ): BulkDecryptOperation { - return new BulkDecryptOperation(this.client, encryptedPayloads) + bulkDecrypt(encryptedPayloads: BulkDecryptPayload): BulkDecryptOperation { + return new BulkDecryptOperation(this.client, encryptedPayloads) } /** @@ -189,8 +187,8 @@ export class ProtectClient { * await eqlClient.createSearchTerms(searchTerms) * await eqlClient.createSearchTerms(searchTerms).withLockContext(lockContext) */ - createSearchTerms(terms: SearchTerm[]): SearchTermsOperation { - return new SearchTermsOperation(this.client, terms) + createSearchTerms(terms: SearchTerm[]): SearchTermsOperation { + return new SearchTermsOperation(this.client, terms) } /** e.g., debugging or environment info */ diff --git a/packages/protect/src/ffi/operations/bulk-decrypt-models.ts b/packages/protect/src/ffi/operations/bulk-decrypt-models.ts index ae59f1a..7f87365 100644 --- a/packages/protect/src/ffi/operations/bulk-decrypt-models.ts +++ b/packages/protect/src/ffi/operations/bulk-decrypt-models.ts @@ -1,5 +1,4 @@ import { type Result, withResult } from '@byteslice/result' -import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -13,8 +12,7 @@ import { ProtectOperation } from './base-operation' export class BulkDecryptModelsOperation< T extends Record, - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation[]> { +> extends ProtectOperation[]> { private client: Client private models: T[] @@ -26,11 +24,11 @@ export class BulkDecryptModelsOperation< public withLockContext( lockContext: LockContext, - ): BulkDecryptModelsOperationWithLockContext { - return new BulkDecryptModelsOperationWithLockContext(this, lockContext) + ): BulkDecryptModelsOperationWithLockContext { + return new BulkDecryptModelsOperationWithLockContext(this, lockContext) } - public async execute(): Promise[], ProtectError>> { + public async execute(): Promise[], ProtectError>> { logger.debug('Bulk decrypting models WITHOUT a lock context') return await withResult( @@ -63,13 +61,12 @@ export class BulkDecryptModelsOperation< export class BulkDecryptModelsOperationWithLockContext< T extends Record, - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation[]> { - private operation: BulkDecryptModelsOperation +> extends ProtectOperation[]> { + private operation: BulkDecryptModelsOperation private lockContext: LockContext constructor( - operation: BulkDecryptModelsOperation, + operation: BulkDecryptModelsOperation, lockContext: LockContext, ) { super() @@ -77,7 +74,7 @@ export class BulkDecryptModelsOperationWithLockContext< this.lockContext = lockContext } - public async execute(): Promise[], ProtectError>> { + public async execute(): Promise[], ProtectError>> { return await withResult( async () => { const { client, models } = this.operation.getOperation() diff --git a/packages/protect/src/ffi/operations/bulk-decrypt.ts b/packages/protect/src/ffi/operations/bulk-decrypt.ts index 664f406..2ddc2b0 100644 --- a/packages/protect/src/ffi/operations/bulk-decrypt.ts +++ b/packages/protect/src/ffi/operations/bulk-decrypt.ts @@ -1,10 +1,9 @@ import { type Result, withResult } from '@byteslice/result' import { - type AnyEncrypted as CipherStashEncrypted, + type Encrypted as CipherStashEncrypted, type DecryptResult, decryptBulkFallible, } from '@cipherstash/protect-ffi' -import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { Context, LockContext } from '../../identify' @@ -13,8 +12,8 @@ import { noClientError } from '../index' import { ProtectOperation } from './base-operation' // Helper functions for better composability -const createDecryptPayloads = ( - encryptedPayloads: BulkDecryptPayload, +const createDecryptPayloads = ( + encryptedPayloads: BulkDecryptPayload, lockContext?: Context, ) => { return encryptedPayloads @@ -22,14 +21,14 @@ const createDecryptPayloads = ( .filter(({ data }) => data !== null) .map(({ id, data, originalIndex }) => ({ id, - ciphertext: data as CipherStashEncrypted, + ciphertext: data as CipherStashEncrypted, originalIndex, - ...(lockContext && { lockContext: [lockContext] }), + ...(lockContext && { lockContext }), })) } -const createNullResult = ( - encryptedPayloads: BulkDecryptPayload, +const createNullResult = ( + encryptedPayloads: BulkDecryptPayload, ): BulkDecryptedData => { return encryptedPayloads.map(({ id }) => ({ id, @@ -37,8 +36,8 @@ const createNullResult = ( })) } -const mapDecryptedDataToResult = ( - encryptedPayloads: BulkDecryptPayload, +const mapDecryptedDataToResult = ( + encryptedPayloads: BulkDecryptPayload, decryptedData: DecryptResult[], ): BulkDecryptedData => { const result: BulkDecryptedData = new Array(encryptedPayloads.length) @@ -67,13 +66,11 @@ const mapDecryptedDataToResult = ( return result } -export class BulkDecryptOperation< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation { +export class BulkDecryptOperation extends ProtectOperation { private client: Client - private encryptedPayloads: BulkDecryptPayload + private encryptedPayloads: BulkDecryptPayload - constructor(client: Client, encryptedPayloads: BulkDecryptPayload) { + constructor(client: Client, encryptedPayloads: BulkDecryptPayload) { super() this.client = client this.encryptedPayloads = encryptedPayloads @@ -81,8 +78,8 @@ export class BulkDecryptOperation< public withLockContext( lockContext: LockContext, - ): BulkDecryptOperationWithLockContext { - return new BulkDecryptOperationWithLockContext(this, lockContext) + ): BulkDecryptOperationWithLockContext { + return new BulkDecryptOperationWithLockContext(this, lockContext) } public async execute(): Promise> { @@ -93,24 +90,20 @@ export class BulkDecryptOperation< if (!this.encryptedPayloads || this.encryptedPayloads.length === 0) return [] - const nonNullPayloads = createDecryptPayloads(this.encryptedPayloads) + const nonNullPayloads = createDecryptPayloads(this.encryptedPayloads) if (nonNullPayloads.length === 0) { - return createNullResult(this.encryptedPayloads) + return createNullResult(this.encryptedPayloads) } const { metadata } = this.getAuditData() const decryptedData = await decryptBulkFallible(this.client, { - // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types - ciphertexts: nonNullPayloads as any, + ciphertexts: nonNullPayloads, unverifiedContext: metadata, }) - return mapDecryptedDataToResult( - this.encryptedPayloads, - decryptedData, - ) + return mapDecryptedDataToResult(this.encryptedPayloads, decryptedData) }, (error: unknown) => ({ type: ProtectErrorTypes.DecryptionError, @@ -121,7 +114,7 @@ export class BulkDecryptOperation< public getOperation(): { client: Client - encryptedPayloads: BulkDecryptPayload + encryptedPayloads: BulkDecryptPayload } { return { client: this.client, @@ -130,13 +123,11 @@ export class BulkDecryptOperation< } } -export class BulkDecryptOperationWithLockContext< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation { - private operation: BulkDecryptOperation +export class BulkDecryptOperationWithLockContext extends ProtectOperation { + private operation: BulkDecryptOperation private lockContext: LockContext - constructor(operation: BulkDecryptOperation, lockContext: LockContext) { + constructor(operation: BulkDecryptOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext @@ -156,25 +147,24 @@ export class BulkDecryptOperationWithLockContext< throw new Error(`[protect]: ${context.failure.message}`) } - const nonNullPayloads = createDecryptPayloads( + const nonNullPayloads = createDecryptPayloads( encryptedPayloads, context.data.context, ) if (nonNullPayloads.length === 0) { - return createNullResult(encryptedPayloads) + return createNullResult(encryptedPayloads) } const { metadata } = this.getAuditData() const decryptedData = await decryptBulkFallible(client, { - // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types - ciphertexts: nonNullPayloads as any, + ciphertexts: nonNullPayloads, serviceToken: context.data.ctsToken, unverifiedContext: metadata, }) - return mapDecryptedDataToResult(encryptedPayloads, decryptedData) + return mapDecryptedDataToResult(encryptedPayloads, decryptedData) }, (error: unknown) => ({ type: ProtectErrorTypes.DecryptionError, diff --git a/packages/protect/src/ffi/operations/bulk-encrypt-models.ts b/packages/protect/src/ffi/operations/bulk-encrypt-models.ts index 505e22f..f3d9a8c 100644 --- a/packages/protect/src/ffi/operations/bulk-encrypt-models.ts +++ b/packages/protect/src/ffi/operations/bulk-encrypt-models.ts @@ -1,5 +1,5 @@ import { type Result, withResult } from '@byteslice/result' -import type { EncryptConfig, ProtectTable, ProtectTableColumn } from '@cipherstash/schema' +import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -13,15 +13,14 @@ import { ProtectOperation } from './base-operation' export class BulkEncryptModelsOperation< T extends Record, - C extends EncryptConfig = EncryptConfig, > extends ProtectOperation { private client: Client - private models: Decrypted[] + private models: Decrypted[] private table: ProtectTable constructor( client: Client, - models: Decrypted[], + models: Decrypted[], table: ProtectTable, ) { super() @@ -32,8 +31,8 @@ export class BulkEncryptModelsOperation< public withLockContext( lockContext: LockContext, - ): BulkEncryptModelsOperationWithLockContext { - return new BulkEncryptModelsOperationWithLockContext(this, lockContext) + ): BulkEncryptModelsOperationWithLockContext { + return new BulkEncryptModelsOperationWithLockContext(this, lockContext) } public async execute(): Promise> { @@ -65,7 +64,7 @@ export class BulkEncryptModelsOperation< public getOperation(): { client: Client - models: Decrypted[] + models: Decrypted[] table: ProtectTable } { return { @@ -78,13 +77,12 @@ export class BulkEncryptModelsOperation< export class BulkEncryptModelsOperationWithLockContext< T extends Record, - C extends EncryptConfig = EncryptConfig, > extends ProtectOperation { - private operation: BulkEncryptModelsOperation + private operation: BulkEncryptModelsOperation private lockContext: LockContext constructor( - operation: BulkEncryptModelsOperation, + operation: BulkEncryptModelsOperation, lockContext: LockContext, ) { super() diff --git a/packages/protect/src/ffi/operations/bulk-encrypt.ts b/packages/protect/src/ffi/operations/bulk-encrypt.ts index 9ea1ce9..e89f827 100644 --- a/packages/protect/src/ffi/operations/bulk-encrypt.ts +++ b/packages/protect/src/ffi/operations/bulk-encrypt.ts @@ -1,7 +1,6 @@ import { type Result, withResult } from '@byteslice/result' import { type JsPlaintext, encryptBulk } from '@cipherstash/protect-ffi' import type { - EncryptConfig, ProtectColumn, ProtectTable, ProtectTableColumn, @@ -21,7 +20,7 @@ import { noClientError } from '../index' import { ProtectOperation } from './base-operation' // Helper functions for better composability -const createEncryptPayloads = ( +const createEncryptPayloads = ( plaintexts: BulkEncryptPayload, column: ProtectColumn | ProtectValue, table: ProtectTable, @@ -36,21 +35,21 @@ const createEncryptPayloads = ( column: column.getName(), table: table.tableName, originalIndex, - ...(lockContext && { lockContext: [lockContext] }), + ...(lockContext && { lockContext }), })) } -const createNullResult = ( +const createNullResult = ( plaintexts: BulkEncryptPayload, -): BulkEncryptedData => { +): BulkEncryptedData => { return plaintexts.map(({ id }) => ({ id, data: null })) } -const mapEncryptedDataToResult = ( +const mapEncryptedDataToResult = ( plaintexts: BulkEncryptPayload, - encryptedData: Encrypted[], -): BulkEncryptedData => { - const result: BulkEncryptedData = new Array(plaintexts.length) + encryptedData: Encrypted[], +): BulkEncryptedData => { + const result: BulkEncryptedData = new Array(plaintexts.length) let encryptedIndex = 0 for (let i = 0; i < plaintexts.length; i++) { @@ -68,9 +67,7 @@ const mapEncryptedDataToResult = ( return result } -export class BulkEncryptOperation< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation> { +export class BulkEncryptOperation extends ProtectOperation { private client: Client private plaintexts: BulkEncryptPayload private column: ProtectColumn | ProtectValue @@ -90,11 +87,11 @@ export class BulkEncryptOperation< public withLockContext( lockContext: LockContext, - ): BulkEncryptOperationWithLockContext { - return new BulkEncryptOperationWithLockContext(this, lockContext) + ): BulkEncryptOperationWithLockContext { + return new BulkEncryptOperationWithLockContext(this, lockContext) } - public async execute(): Promise, ProtectError>> { + public async execute(): Promise> { logger.debug('Bulk encrypting data WITHOUT a lock context', { column: this.column.getName(), table: this.table.tableName, @@ -109,25 +106,24 @@ export class BulkEncryptOperation< return [] } - const nonNullPayloads = createEncryptPayloads( + const nonNullPayloads = createEncryptPayloads( this.plaintexts, this.column, this.table, ) if (nonNullPayloads.length === 0) { - return createNullResult(this.plaintexts) + return createNullResult(this.plaintexts) } const { metadata } = this.getAuditData() const encryptedData = await encryptBulk(this.client, { - // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types - plaintexts: nonNullPayloads as any, + plaintexts: nonNullPayloads, unverifiedContext: metadata, }) - return mapEncryptedDataToResult(this.plaintexts, encryptedData) + return mapEncryptedDataToResult(this.plaintexts, encryptedData) }, (error: unknown) => ({ type: ProtectErrorTypes.EncryptionError, @@ -151,19 +147,17 @@ export class BulkEncryptOperation< } } -export class BulkEncryptOperationWithLockContext< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation> { - private operation: BulkEncryptOperation +export class BulkEncryptOperationWithLockContext extends ProtectOperation { + private operation: BulkEncryptOperation private lockContext: LockContext - constructor(operation: BulkEncryptOperation, lockContext: LockContext) { + constructor(operation: BulkEncryptOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext } - public async execute(): Promise, ProtectError>> { + public async execute(): Promise> { return await withResult( async () => { const { client, plaintexts, column, table } = @@ -186,7 +180,7 @@ export class BulkEncryptOperationWithLockContext< throw new Error(`[protect]: ${context.failure.message}`) } - const nonNullPayloads = createEncryptPayloads( + const nonNullPayloads = createEncryptPayloads( plaintexts, column, table, @@ -194,19 +188,18 @@ export class BulkEncryptOperationWithLockContext< ) if (nonNullPayloads.length === 0) { - return createNullResult(plaintexts) + return createNullResult(plaintexts) } const { metadata } = this.getAuditData() const encryptedData = await encryptBulk(client, { - // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types - plaintexts: nonNullPayloads as any, + plaintexts: nonNullPayloads, serviceToken: context.data.ctsToken, unverifiedContext: metadata, }) - return mapEncryptedDataToResult(plaintexts, encryptedData) + return mapEncryptedDataToResult(plaintexts, encryptedData) }, (error: unknown) => ({ type: ProtectErrorTypes.EncryptionError, diff --git a/packages/protect/src/ffi/operations/decrypt-model.ts b/packages/protect/src/ffi/operations/decrypt-model.ts index 7ac8de1..d2aba28 100644 --- a/packages/protect/src/ffi/operations/decrypt-model.ts +++ b/packages/protect/src/ffi/operations/decrypt-model.ts @@ -1,5 +1,4 @@ import { type Result, withResult } from '@byteslice/result' -import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -13,8 +12,7 @@ import { ProtectOperation } from './base-operation' export class DecryptModelOperation< T extends Record, - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation> { +> extends ProtectOperation> { private client: Client private model: T @@ -26,11 +24,11 @@ export class DecryptModelOperation< public withLockContext( lockContext: LockContext, - ): DecryptModelOperationWithLockContext { - return new DecryptModelOperationWithLockContext(this, lockContext) + ): DecryptModelOperationWithLockContext { + return new DecryptModelOperationWithLockContext(this, lockContext) } - public async execute(): Promise, ProtectError>> { + public async execute(): Promise, ProtectError>> { logger.debug('Decrypting model WITHOUT a lock context') return await withResult( @@ -63,18 +61,17 @@ export class DecryptModelOperation< export class DecryptModelOperationWithLockContext< T extends Record, - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation> { - private operation: DecryptModelOperation +> extends ProtectOperation> { + private operation: DecryptModelOperation private lockContext: LockContext - constructor(operation: DecryptModelOperation, lockContext: LockContext) { + constructor(operation: DecryptModelOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext } - public async execute(): Promise, ProtectError>> { + public async execute(): Promise, ProtectError>> { return await withResult( async () => { const { client, model } = this.operation.getOperation() diff --git a/packages/protect/src/ffi/operations/decrypt.ts b/packages/protect/src/ffi/operations/decrypt.ts index e9fdb99..23fdde0 100644 --- a/packages/protect/src/ffi/operations/decrypt.ts +++ b/packages/protect/src/ffi/operations/decrypt.ts @@ -3,7 +3,6 @@ import { type JsPlaintext, decrypt as ffiDecrypt, } from '@cipherstash/protect-ffi' -import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -11,13 +10,11 @@ import type { Client, Encrypted } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' -export class DecryptOperation< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation { +export class DecryptOperation extends ProtectOperation { private client: Client - private encryptedData: Encrypted + private encryptedData: Encrypted - constructor(client: Client, encryptedData: Encrypted) { + constructor(client: Client, encryptedData: Encrypted) { super() this.client = client this.encryptedData = encryptedData @@ -25,8 +22,8 @@ export class DecryptOperation< public withLockContext( lockContext: LockContext, - ): DecryptOperationWithLockContext { - return new DecryptOperationWithLockContext(this, lockContext) + ): DecryptOperationWithLockContext { + return new DecryptOperationWithLockContext(this, lockContext) } public async execute(): Promise> { @@ -60,7 +57,7 @@ export class DecryptOperation< public getOperation(): { client: Client - encryptedData: Encrypted + encryptedData: Encrypted auditData?: Record } { return { @@ -71,13 +68,11 @@ export class DecryptOperation< } } -export class DecryptOperationWithLockContext< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation { - private operation: DecryptOperation +export class DecryptOperationWithLockContext extends ProtectOperation { + private operation: DecryptOperation private lockContext: LockContext - constructor(operation: DecryptOperation, lockContext: LockContext) { + constructor(operation: DecryptOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext @@ -115,8 +110,7 @@ export class DecryptOperationWithLockContext< return await ffiDecrypt(client, { ciphertext: encryptedData, unverifiedContext: metadata, - // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types - lockContext: [context.data.context] as any, + lockContext: context.data.context, serviceToken: context.data.ctsToken, }) }, diff --git a/packages/protect/src/ffi/operations/encrypt-model.ts b/packages/protect/src/ffi/operations/encrypt-model.ts index d0c946f..cb06e4f 100644 --- a/packages/protect/src/ffi/operations/encrypt-model.ts +++ b/packages/protect/src/ffi/operations/encrypt-model.ts @@ -1,5 +1,5 @@ import { type Result, withResult } from '@byteslice/result' -import type { EncryptConfig, ProtectTable, ProtectTableColumn } from '@cipherstash/schema' +import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { LockContext } from '../../identify' @@ -13,15 +13,14 @@ import { ProtectOperation } from './base-operation' export class EncryptModelOperation< T extends Record, - C extends EncryptConfig = EncryptConfig, > extends ProtectOperation { private client: Client - private model: Decrypted + private model: Decrypted private table: ProtectTable constructor( client: Client, - model: Decrypted, + model: Decrypted, table: ProtectTable, ) { super() @@ -32,8 +31,8 @@ export class EncryptModelOperation< public withLockContext( lockContext: LockContext, - ): EncryptModelOperationWithLockContext { - return new EncryptModelOperationWithLockContext(this, lockContext) + ): EncryptModelOperationWithLockContext { + return new EncryptModelOperationWithLockContext(this, lockContext) } public async execute(): Promise> { @@ -65,7 +64,7 @@ export class EncryptModelOperation< public getOperation(): { client: Client - model: Decrypted + model: Decrypted table: ProtectTable } { return { @@ -78,12 +77,11 @@ export class EncryptModelOperation< export class EncryptModelOperationWithLockContext< T extends Record, - C extends EncryptConfig = EncryptConfig, > extends ProtectOperation { - private operation: EncryptModelOperation + private operation: EncryptModelOperation private lockContext: LockContext - constructor(operation: EncryptModelOperation, lockContext: LockContext) { + constructor(operation: EncryptModelOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext diff --git a/packages/protect/src/ffi/operations/encrypt.ts b/packages/protect/src/ffi/operations/encrypt.ts index 3f9303f..6d479b3 100644 --- a/packages/protect/src/ffi/operations/encrypt.ts +++ b/packages/protect/src/ffi/operations/encrypt.ts @@ -4,7 +4,6 @@ import { encrypt as ffiEncrypt, } from '@cipherstash/protect-ffi' import type { - EncryptConfig, ProtectColumn, ProtectTable, ProtectTableColumn, @@ -17,9 +16,7 @@ import type { Client, EncryptOptions, Encrypted } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' -export class EncryptOperation< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation> { +export class EncryptOperation extends ProtectOperation { private client: Client private plaintext: JsPlaintext | null private column: ProtectColumn | ProtectValue @@ -39,11 +36,11 @@ export class EncryptOperation< public withLockContext( lockContext: LockContext, - ): EncryptOperationWithLockContext { - return new EncryptOperationWithLockContext(this, lockContext) + ): EncryptOperationWithLockContext { + return new EncryptOperationWithLockContext(this, lockContext) } - public async execute(): Promise, ProtectError>> { + public async execute(): Promise> { logger.debug('Encrypting data WITHOUT a lock context', { column: this.column.getName(), table: this.table.tableName, @@ -90,19 +87,17 @@ export class EncryptOperation< } } -export class EncryptOperationWithLockContext< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation> { - private operation: EncryptOperation +export class EncryptOperationWithLockContext extends ProtectOperation { + private operation: EncryptOperation private lockContext: LockContext - constructor(operation: EncryptOperation, lockContext: LockContext) { + constructor(operation: EncryptOperation, lockContext: LockContext) { super() this.operation = operation this.lockContext = lockContext } - public async execute(): Promise, ProtectError>> { + public async execute(): Promise> { return await withResult( async () => { const { client, plaintext, column, table } = @@ -132,8 +127,7 @@ export class EncryptOperationWithLockContext< plaintext, column: column.getName(), table: table.tableName, - // biome-ignore lint/suspicious/noExplicitAny: Context type mismatch between local and FFI types - lockContext: [context.data.context] as any, + lockContext: context.data.context, serviceToken: context.data.ctsToken, unverifiedContext: metadata, }) diff --git a/packages/protect/src/ffi/operations/search-terms.ts b/packages/protect/src/ffi/operations/search-terms.ts index 5e7de3b..3949ee2 100644 --- a/packages/protect/src/ffi/operations/search-terms.ts +++ b/packages/protect/src/ffi/operations/search-terms.ts @@ -1,15 +1,14 @@ import { type Result, withResult } from '@byteslice/result' import { encryptBulk } from '@cipherstash/protect-ffi' -import type { EncryptConfig } from '@cipherstash/schema' import { type ProtectError, ProtectErrorTypes } from '../..' import { logger } from '../../../../utils/logger' import type { Client, EncryptedSearchTerm, SearchTerm } from '../../types' import { noClientError } from '../index' import { ProtectOperation } from './base-operation' -export class SearchTermsOperation< - C extends EncryptConfig = EncryptConfig, -> extends ProtectOperation[]> { +export class SearchTermsOperation extends ProtectOperation< + EncryptedSearchTerm[] +> { private client: Client private terms: SearchTerm[] @@ -19,9 +18,7 @@ export class SearchTermsOperation< this.terms = terms } - public async execute(): Promise< - Result[], ProtectError> - > { + public async execute(): Promise> { logger.debug('Creating search terms', { terms: this.terms, }) diff --git a/packages/protect/src/helpers/index.ts b/packages/protect/src/helpers/index.ts index 9fe9a76..0074427 100644 --- a/packages/protect/src/helpers/index.ts +++ b/packages/protect/src/helpers/index.ts @@ -1,16 +1,13 @@ -import type { EncryptConfig } from '@cipherstash/schema' import type { Encrypted } from '../types' -export type EncryptedPgComposite = { - data: Encrypted +export type EncryptedPgComposite = { + data: Encrypted } /** * Helper function to transform an encrypted payload into a PostgreSQL composite type */ -export function encryptedToPgComposite( - obj: Encrypted, -): EncryptedPgComposite { +export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite { return { data: obj, } @@ -47,14 +44,12 @@ export function bulkModelsToEncryptedPgComposites< /** * Helper function to check if a value is an encrypted payload */ -export function isEncryptedPayload( - value: unknown, -): value is Encrypted { +export function isEncryptedPayload(value: unknown): value is Encrypted { if (value === null) return false // TODO: this can definitely be improved if (typeof value === 'object') { - const obj = value as Encrypted + const obj = value as Encrypted return ( obj !== null && 'v' in obj && ('c' in obj || 'sv' in obj) && 'i' in obj ) diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts index ca554c3..a491ed9 100644 --- a/packages/protect/src/types.ts +++ b/packages/protect/src/types.ts @@ -1,16 +1,13 @@ import type { - EncryptedCell, - EncryptedSV, + Encrypted as CipherStashEncrypted, JsPlaintext, newClient, - AnyEncrypted, } from '@cipherstash/protect-ffi' import type { ProtectColumn, ProtectTable, ProtectTableColumn, ProtectValue, - EncryptConfig, } from '@cipherstash/schema' /** @@ -21,19 +18,19 @@ export type Client = Awaited> | undefined /** * Type to represent an encrypted payload */ -export type Encrypted = AnyEncrypted | null +export type Encrypted = CipherStashEncrypted | null /** * Represents an encrypted payload in the database * @deprecated Use `Encrypted` instead */ -export type EncryptedPayload = Encrypted +export type EncryptedPayload = Encrypted | null /** * Represents an encrypted data object in the database * @deprecated Use `Encrypted` instead */ -export type EncryptedData = Encrypted +export type EncryptedData = Encrypted | null /** * Represents a value that will be encrypted and used in a search @@ -51,7 +48,7 @@ export type SearchTerm = { * If the return type is `composite-literal`, the return type is `string` where the value is a composite literal * If the return type is `escaped-composite-literal`, the return type is `string` where the value is an escaped composite literal */ -export type EncryptedSearchTerm = Encrypted | string +export type EncryptedSearchTerm = Encrypted | string /** * Represents a payload to be encrypted using the `encrypt` function @@ -69,29 +66,28 @@ export type EncryptOptions = { /** * Type to identify encrypted fields in a model */ -export type EncryptedFields = { - [K in keyof T as T[K] extends Encrypted ? K : never]: T[K] +export type EncryptedFields = { + [K in keyof T as T[K] extends Encrypted ? K : never]: T[K] } /** * Type to identify non-encrypted fields in a model */ -export type OtherFields = { - [K in keyof T as T[K] extends Encrypted ? never : K]: T[K] +export type OtherFields = { + [K in keyof T as T[K] extends Encrypted ? never : K]: T[K] } /** * Type to represent decrypted fields in a model */ -export type DecryptedFields = { - [K in keyof T as T[K] extends Encrypted ? K : never]: string +export type DecryptedFields = { + [K in keyof T as T[K] extends Encrypted ? K : never]: string } /** * Represents a model with plaintext (decrypted) values instead of the EQL/JSONB types */ -export type Decrypted = OtherFields & - DecryptedFields +export type Decrypted = OtherFields & DecryptedFields /** * Types for bulk encryption and decryption operations. @@ -101,14 +97,8 @@ export type BulkEncryptPayload = Array<{ plaintext: JsPlaintext | null }> -export type BulkEncryptedData = Array<{ - id?: string - data: Encrypted -}> -export type BulkDecryptPayload = Array<{ - id?: string - data: Encrypted -}> +export type BulkEncryptedData = Array<{ id?: string; data: Encrypted }> +export type BulkDecryptPayload = Array<{ id?: string; data: Encrypted }> export type BulkDecryptedData = Array> type DecryptionSuccess = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a4231b..6b9b16b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,8 +456,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@cipherstash/protect-ffi': - specifier: 0.18.0-9 - version: 0.18.0-9 + specifier: 0.17.0 + version: 0.17.0 '@cipherstash/schema': specifier: workspace:* version: link:../schema @@ -975,33 +975,33 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/protect-ffi-darwin-arm64@0.18.0-9': - resolution: {integrity: sha512-gpgrEA0YQepUeoF1y5I8/MrWiPezwwSL1edE08iObU2G5GwWUsHbIvpjk86aXcqex+WbjWtqx+IgOSkLyXb8cQ==} + '@cipherstash/protect-ffi-darwin-arm64@0.17.0': + resolution: {integrity: sha512-/QsbCzpDs9orsBWf7RhPosyspsES7WjD2xr98NeJeN5IR/KbVmr+KuEchl18eqeVH1EzXNT5FZLZ+AUfEjO9rw==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.18.0-9': - resolution: {integrity: sha512-D4S24b6HvJDSjE6/YPwvCk7wTXY7GJ+RI8whuxU5IfRhwo0SEfZoGO3ZDToU89Yi4TaEqm/gv/53K9YWzdfLgA==} + '@cipherstash/protect-ffi-darwin-x64@0.17.0': + resolution: {integrity: sha512-EOq6ZNAPlWU0SJTCmfQQkLPX7UDUv7O/fuTzVGksPjM6vC1iYAGRXa37TdxGHXzZDmOl9En70KblPtHI0opT7g==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.18.0-9': - resolution: {integrity: sha512-psjFYCCKgcb7upy8yq6SRuk3b9TSn6mE/uHrrNhvDVkXGlOTTJB+UyCkKxJywlLy3PD+iJ204dH0FewV8LVWyg==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.0': + resolution: {integrity: sha512-jLA+tNKNE4sluH6Clnn2TUoOPcvyuSaAj6h4FluswQahiHdILPDaM5WbePnbwOIi0eumff3zo9HuAWRJqhB7Pw==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.18.0-9': - resolution: {integrity: sha512-1FgGBxtrG0rN/5APqY8nCPYrEUTFskB1nXnB9qxrdH9pmXtdk5OR5YG1DpZv4bZOp7RRwrlwolHq8dA20AbK/w==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.17.0': + resolution: {integrity: sha512-QjAyb7K2X/ccnIsKByTLG3iD5qQy4GQ23EWWJcTUkz6u1smE6C1lXjs9y43jlFcQc9WaGlIFn4SeC4q4fnuMDA==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.18.0-9': - resolution: {integrity: sha512-XRjLRXhmdvQLPs9IncbBYw52byK+T6uqwV4VTaCQChWIScbQ/As2nBOzjTOtxTurEn5HbeJjJh7U6tchmWGxLg==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.17.0': + resolution: {integrity: sha512-bXU2Tv516qJEp47Yc7lizNdRCrXehubc87CdzU1Jl/bReWtmRK2Nu6lH9FsLtPSPdo8SClb81ZqtryxBW19n+w==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.18.0-9': - resolution: {integrity: sha512-1FrRf0qDC78IsBG57HhFEmGY3qFyP4gMEkvz0A/5xU3/CeedvO7ZazCf5QdmRUVTVyzn2zsVBDDlsiBloqoMSg==} + '@cipherstash/protect-ffi@0.17.0': + resolution: {integrity: sha512-TJv/qhuSzid42XXgdmeExf8G/XLZ51VB0wHsg0hJoR9t6yNjWOuOY3NZ37X5+7rWJnnIjjVFsQIr3sQ+aam7sw==} '@clerk/backend@1.25.5': resolution: {integrity: sha512-nnBpr7oSq5iATWRExuljEfp7xa90KE1OUgaGCSmtZYF0T9TWHGkZHYqkQhD4XjiqlR2XsrsQ/UzPfmHM1Km7+Q==} @@ -7813,30 +7813,30 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@cipherstash/protect-ffi-darwin-arm64@0.18.0-9': + '@cipherstash/protect-ffi-darwin-arm64@0.17.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.18.0-9': + '@cipherstash/protect-ffi-darwin-x64@0.17.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.18.0-9': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.18.0-9': + '@cipherstash/protect-ffi-linux-x64-gnu@0.17.0': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.18.0-9': + '@cipherstash/protect-ffi-win32-x64-msvc@0.17.0': optional: true - '@cipherstash/protect-ffi@0.18.0-9': + '@cipherstash/protect-ffi@0.17.0': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.18.0-9 - '@cipherstash/protect-ffi-darwin-x64': 0.18.0-9 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.18.0-9 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.18.0-9 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.18.0-9 + '@cipherstash/protect-ffi-darwin-arm64': 0.17.0 + '@cipherstash/protect-ffi-darwin-x64': 0.17.0 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.17.0 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.17.0 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.17.0 '@clerk/backend@1.25.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: From c7cdf973183a89b13a37fb202440ac124cd58430 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 28 Oct 2025 16:03:50 +1100 Subject: [PATCH 11/19] test: reduce timeout for JSON performance test to 5 seconds --- packages/protect/__tests__/json-protect.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/protect/__tests__/json-protect.test.ts b/packages/protect/__tests__/json-protect.test.ts index 35eafe5..3f19804 100644 --- a/packages/protect/__tests__/json-protect.test.ts +++ b/packages/protect/__tests__/json-protect.test.ts @@ -911,7 +911,7 @@ describe('JSON performance tests', () => { expect(decryptedData.data[i].id).toBe(`user${i}`) expect(decryptedData.data[i].data).toEqual(largeJsonArray[i]) } - }, 60000) + }, 5000) }) describe('JSON advanced scenarios', () => { From fe4bf4ed76bae83b3e552fc0905e7cf60154f569 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 28 Oct 2025 16:11:23 +1100 Subject: [PATCH 12/19] refactor: comment out tests and methods related to ste_vec indexing for JSON until support is available --- packages/schema/__tests__/schema.test.ts | 5 +++-- packages/schema/src/index.ts | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/schema/__tests__/schema.test.ts b/packages/schema/__tests__/schema.test.ts index 293f740..686d391 100644 --- a/packages/schema/__tests__/schema.test.ts +++ b/packages/schema/__tests__/schema.test.ts @@ -130,7 +130,8 @@ describe('Schema with nested columns', () => { }) }) - it('should handle ste_vec index for JSON columns', () => { + // NOTE: Leaving this test commented out until stevec indexing for JSON is supported. + /*it('should handle ste_vec index for JSON columns', () => { const users = csTable('users', { json: csColumn('json').dataType('jsonb').searchableJson(), } as const) @@ -141,5 +142,5 @@ describe('Schema with nested columns', () => { expect(config.tables.users.json.indexes.ste_vec?.prefix).toEqual( 'users/json', ) - }) + })*/ }) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 470b09e..456fa7e 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -186,12 +186,13 @@ export class ProtectColumn { } /** - * Enable a STE Vec index, requires a prefix. + * Enable a STE Vec index, uses the column name for the index. */ - searchableJson() { - this.indexesValue.ste_vec = { prefix: 'enabled' } + // NOTE: Leaving this commented out until stevec indexing for JSON is supported. + /*searchableJson() { + this.indexesValue.ste_vec = { prefix: this.columnName } return this - } + }*/ build() { return { From 33760e5c64af0b0b3c5147f02793831226ff743c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 28 Oct 2025 16:16:15 +1100 Subject: [PATCH 13/19] refactor: comment out searchableJson method for jsonSearchable column in schema --- packages/protect-dynamodb/__tests__/dynamodb.test.ts | 4 ++-- packages/protect/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts index eb4320d..b96fc3e 100644 --- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts +++ b/packages/protect-dynamodb/__tests__/dynamodb.test.ts @@ -10,8 +10,8 @@ const schema = csTable('dynamo_cipherstash_test', { phoneNumber: csColumn('phoneNumber'), json: csColumn('json').dataType('jsonb'), jsonSearchable: csColumn('jsonSearchable') - .dataType('jsonb') - .searchableJson('users/jsonSearchable'), + .dataType('jsonb'), + //.searchableJson('users/jsonSearchable'), example: { protected: csValue('example.protected'), deep: { diff --git a/packages/protect/package.json b/packages/protect/package.json index d1b5288..e462754 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -62,4 +62,4 @@ "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.24.0" } -} +} \ No newline at end of file From 5da45a522222c8f15b55b8aa5b1a0c3ecc0995ad Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 28 Oct 2025 16:23:19 +1100 Subject: [PATCH 14/19] refactor: comment out searchableJson method for json column in tests and documentation --- packages/protect/__tests__/json-protect.test.ts | 2 +- packages/schema/README.md | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/protect/__tests__/json-protect.test.ts b/packages/protect/__tests__/json-protect.test.ts index 3f19804..8de97b6 100644 --- a/packages/protect/__tests__/json-protect.test.ts +++ b/packages/protect/__tests__/json-protect.test.ts @@ -6,7 +6,7 @@ import { LockContext, protect } from '../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), address: csColumn('address').freeTextSearch(), - json: csColumn('json').dataType('jsonb').searchableJson(), + json: csColumn('json').dataType('jsonb'),//.searchableJson() metadata: { profile: csValue('metadata.profile'), settings: { diff --git a/packages/schema/README.md b/packages/schema/README.md index 7e4c1e2..71b889b 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -183,12 +183,6 @@ const column = csColumn('field').freeTextSearch({ }) ``` -### Searchable JSON - -```typescript -const column = csColumn('metadata').searchableJson() -``` - ## Type Safety The schema builder provides full TypeScript support: From 8e832325005354deab40728117669e0bfb219129 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 28 Oct 2025 14:52:59 -0700 Subject: [PATCH 15/19] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20standard?= =?UTF-8?q?ize=20data=20type=20names=20from=20PostgreSQL-specific=20to=20g?= =?UTF-8?q?eneric?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename 'text' to 'string' for text data types - Rename 'int' to 'number' for numeric data types - Rename 'jsonb' to 'json' for JSON data types - Update all schema definitions and tests to use generic type names - Add validation in search-terms to check for valid encrypted payloads - Set default type to 'string' instead of 'text' This makes the API more framework-agnostic and easier to use across different databases and contexts. --- .../protect-dynamodb/__tests__/dynamodb.test.ts | 9 ++++----- .../src/operations/search-terms.ts | 6 ++++++ .../protect/__tests__/basic-protect.test.ts | 2 +- packages/protect/__tests__/int-protect.test.ts | 16 ++++++++-------- packages/protect/__tests__/json-protect.test.ts | 6 +++--- packages/schema/__tests__/schema.test.ts | 12 ++++++------ packages/schema/src/index.ts | 17 +++++++++++++---- 7 files changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts index b96fc3e..0b8ee27 100644 --- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts +++ b/packages/protect-dynamodb/__tests__/dynamodb.test.ts @@ -8,16 +8,15 @@ const schema = csTable('dynamo_cipherstash_test', { firstName: csColumn('firstName').equality(), lastName: csColumn('lastName').equality(), phoneNumber: csColumn('phoneNumber'), - json: csColumn('json').dataType('jsonb'), - jsonSearchable: csColumn('jsonSearchable') - .dataType('jsonb'), - //.searchableJson('users/jsonSearchable'), + json: csColumn('json').dataType('json'), + jsonSearchable: csColumn('jsonSearchable').dataType('json'), + //.searchableJson('users/jsonSearchable'), example: { protected: csValue('example.protected'), deep: { protected: csValue('example.deep.protected'), protectNestedJson: csValue('example.deep.protectNestedJson').dataType( - 'jsonb', + 'json', ), }, }, diff --git a/packages/protect-dynamodb/src/operations/search-terms.ts b/packages/protect-dynamodb/src/operations/search-terms.ts index 74b9032..2bd6c24 100644 --- a/packages/protect-dynamodb/src/operations/search-terms.ts +++ b/packages/protect-dynamodb/src/operations/search-terms.ts @@ -39,6 +39,12 @@ export class SearchTermsOperation extends DynamoDBOperation { ) } + if (term?.k !== 'ct') { + throw new Error( + 'Tried to create search term with an invalid encrypted payload', + ) + } + if (!term?.hm) { throw new Error('expected encrypted search term to have an HMAC') } diff --git a/packages/protect/__tests__/basic-protect.test.ts b/packages/protect/__tests__/basic-protect.test.ts index a6ae0ab..6277a84 100644 --- a/packages/protect/__tests__/basic-protect.test.ts +++ b/packages/protect/__tests__/basic-protect.test.ts @@ -6,7 +6,7 @@ import { protect } from '../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), address: csColumn('address').freeTextSearch(), - json: csColumn('json').dataType('jsonb'), + json: csColumn('json').dataType('json'), }) let protectClient: Awaited> diff --git a/packages/protect/__tests__/int-protect.test.ts b/packages/protect/__tests__/int-protect.test.ts index 2b4e611..5315d8e 100644 --- a/packages/protect/__tests__/int-protect.test.ts +++ b/packages/protect/__tests__/int-protect.test.ts @@ -6,11 +6,11 @@ import { LockContext, protect } from '../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), address: csColumn('address').freeTextSearch(), - age: csColumn('age').dataType('int').equality().orderAndRange(), - score: csColumn('score').dataType('int').equality().orderAndRange(), + age: csColumn('age').dataType('number').equality().orderAndRange(), + score: csColumn('score').dataType('number').equality().orderAndRange(), metadata: { - count: csValue('metadata.count').dataType('int'), - level: csValue('metadata.level').dataType('int'), + count: csValue('metadata.count').dataType('number'), + level: csValue('metadata.level').dataType('number'), }, }) @@ -716,12 +716,12 @@ describe('Integer search terms', () => { it('should create search terms for integer fields', async () => { const searchTerms = [ { - value: '25', + value: 25, column: users.age, table: users, }, { - value: '100', + value: 100, column: users.score, table: users, }, @@ -745,7 +745,7 @@ describe('Integer search terms', () => { it('should create search terms with composite-literal return type for integers', async () => { const searchTerms = [ { - value: '42', + value: 42, column: users.age, table: users, returnType: 'composite-literal' as const, @@ -766,7 +766,7 @@ describe('Integer search terms', () => { it('should create search terms with escaped-composite-literal return type for integers', async () => { const searchTerms = [ { - value: '99', + value: 99, column: users.score, table: users, returnType: 'escaped-composite-literal' as const, diff --git a/packages/protect/__tests__/json-protect.test.ts b/packages/protect/__tests__/json-protect.test.ts index 8de97b6..24841d2 100644 --- a/packages/protect/__tests__/json-protect.test.ts +++ b/packages/protect/__tests__/json-protect.test.ts @@ -6,11 +6,11 @@ import { LockContext, protect } from '../src' const users = csTable('users', { email: csColumn('email').freeTextSearch().equality().orderAndRange(), address: csColumn('address').freeTextSearch(), - json: csColumn('json').dataType('jsonb'),//.searchableJson() + json: csColumn('json').dataType('json'), metadata: { - profile: csValue('metadata.profile'), + profile: csValue('metadata.profile').dataType('json'), settings: { - preferences: csValue('metadata.settings.preferences'), + preferences: csValue('metadata.settings.preferences').dataType('json'), }, }, }) diff --git a/packages/schema/__tests__/schema.test.ts b/packages/schema/__tests__/schema.test.ts index 686d391..d1d99a5 100644 --- a/packages/schema/__tests__/schema.test.ts +++ b/packages/schema/__tests__/schema.test.ts @@ -35,7 +35,7 @@ describe('Schema with nested columns', () => { // Verify email column configuration expect(columns.email).toEqual({ - cast_as: 'text', + cast_as: 'string', indexes: { match: expect.any(Object), unique: expect.any(Object), @@ -45,7 +45,7 @@ describe('Schema with nested columns', () => { // Verify address column configuration expect(columns.address).toEqual({ - cast_as: 'text', + cast_as: 'string', indexes: { match: expect.any(Object), }, @@ -53,13 +53,13 @@ describe('Schema with nested columns', () => { // Verify nested field configuration expect(columns['example.field']).toEqual({ - cast_as: 'text', + cast_as: 'string', indexes: {}, }) // Verify deeply nested field configuration expect(columns['example.nested.deep']).toEqual({ - cast_as: 'text', + cast_as: 'string', indexes: {}, }) }) @@ -119,13 +119,13 @@ describe('Schema with nested columns', () => { // Verify complex nested column with multiple indexes expect(config.tables.complex['content.metadata.tags']).toEqual({ - cast_as: 'text', + cast_as: 'string', indexes: {}, }) // Verify deeply nested column with order and range expect(config.tables.complex['content.metadata.stats.views']).toEqual({ - cast_as: 'text', + cast_as: 'string', indexes: {}, }) }) diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 456fa7e..a47434c 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -3,7 +3,16 @@ import { z } from 'zod' // ------------------------ // Zod schemas // ------------------------ -const castAsEnum = z.enum(['text', 'int', 'jsonb']).default('text') +// export type CastAs = +// | 'bigint' +// | 'boolean' +// | 'date' +// | 'number' +// | 'string' +// | 'json' +const castAsEnum = z + .enum(['bigint', 'boolean', 'date', 'number', 'string', 'json']) + .default('string') const tokenFilterSchema = z.object({ kind: z.literal('downcase'), @@ -102,7 +111,7 @@ export class ProtectValue { constructor(valueName: string) { this.valueName = valueName - this.castAsValue = 'text' + this.castAsValue = 'string' } /** @@ -137,7 +146,7 @@ export class ProtectColumn { constructor(columnName: string) { this.columnName = columnName - this.castAsValue = 'text' + this.castAsValue = 'string' } /** @@ -242,7 +251,7 @@ export class ProtectTable { // Hanlde building the ste_vec index for JSON columns so users don't have to pass the prefix. if ( - builtColumn.cast_as === 'jsonb' && + builtColumn.cast_as === 'json' && builtColumn.indexes.ste_vec?.prefix === 'enabled' ) { builtColumns[colName] = { From 37aeaaaf19554fd44b24fd7f61c798f3fbdd9962 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 28 Oct 2025 14:53:04 -0700 Subject: [PATCH 16/19] =?UTF-8?q?chore:=20=F0=9F=91=B7=20update=20dependen?= =?UTF-8?q?cies=20and=20add=20Protect.js=20initialization=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update @cipherstash/protect-ffi from 0.17.0 to 0.17.1 - Add init-protect.md prompt guide for implementing Protect.js into Node.js applications - Update pnpm-lock.yaml with new dependency versions --- docs/prompts/init-protect.md | 111 ++++++++++++++++++++++++++++++++++ packages/protect/package.json | 2 +- pnpm-lock.yaml | 59 ++++++++++-------- 3 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 docs/prompts/init-protect.md diff --git a/docs/prompts/init-protect.md b/docs/prompts/init-protect.md new file mode 100644 index 0000000..df45ea3 --- /dev/null +++ b/docs/prompts/init-protect.md @@ -0,0 +1,111 @@ +# Implementing Protect.js into a Node.js application + +Your task is to introduce Protect.js into a Node.js application. Protect.js requires the Node.js runtime so it will still work with framweworks like Next.js and Tanstack Start, since they support running code that only executes on the server. + +--- + +## Installing Protect.js + +Determine what package manager the application is using. This will either be `pnpm`, `npm`, or `bun` and then use the appropriate package manager to add Protect.js to the application: + +```bash +npm install @cipehrstash/protect +# or +pnpm add @cipehrstash/protect +# or +bun add @cipehrstash/protect +``` + +If you detect a mono repo, you need to ask the user which application they want Protect.js installed in. + +--- + +## Adding scafolding for the Protect.js client, schemas, and example code + +In the rool of the application (if the application is configred to use something like `src` then this is where these operations will occur), you need to add a `protect` directory with the following files/content. If the application uses TypeScript use the `.ts` extension, else use the `.js` extension. + +`protect/schemas.(ts/js)` +```js +import { csTable, csColumn } from '@cipherstash/protect` + +export const protectedExample = csTable('example_table', { + sensitiveData: csColumn('sensitiveData'), +} +``` + +`protect/index.(ts/js)` +```js +import { protect } from '@cipehrstash/protect' +import { * as protectSchemas } from './schemas' + +export const protectClient = protect({ + schemas: [...protectSchemas] +}) +``` + +`protect/example.(ts/js)` +```js +import { protectClient } from './index' +import { * as protectSchemas } from './schemas' + +const sensitiveData = "Let's encrypt some data." + +/** + * There is no need to wrap any protectClient method in a try/catch as it will always return a Result pattern. + * --- + * The Result will either contain a `failure` OR a `data` key. You should ALWAYS check for the `failure` key first. + * If the `failure` key is present, you should handle the error accordingly. + * If the `data` key is present, the operation was successful. + */ +// +const encryptResult = protectClient.encrypt(sensitiveData, { + table: protectSchemas.protectedExample + column: protectSchemas.protectedExample.sensitiveData +}) + +if (encryptResult.failure) { + // You as the developer can determine exactly how you want to handle the failure scenario. +} + +const encryptedData = encryptResult.data + +/** + * The encryptedData is a JSON object which is unique to CipherStash. + * You can store this data in any data store that supports JSON, however, only PostgreSQL supports searchable encryption operations. + * --- + * When storing in PostgreSQL, the database instance needs to be configured to Encrypt Query Language (EQL). + * More information can be found in the [Encrypt Query Language (EQL) documentation](https://github.com/cipherstash/encrypt-query-language). + * After EQL is installed, you can use the `eql_v2_encrypted` data type to store the encrypted data. + * ```sql + * CREATE TABLE protected_example ( + * id SERIAL PRIMARY KEY, + * sensitive_data eql_v2_encrypted + * ) + * ``` + **/ +console.log('encryptedData:', encryptedData) + +const decryptResult = protectClient.decrypt(encryptedData) + +if (decryptResult.failure) { + // Again, you as the developer can determine exactly how you want to handle the failure scenario. +} + +const decryptedData = decryptResult.data + +/** + * The decryptedData is the plaintext data. + */ +console.log('decryptedData:', decryptedData) +``` + +--- + +After the initialization process, you need to also analyze the application code base to determine the following: + +1. What database and general data store (if any) does the application connect to and store data? +2. Does the application use a specific ORM (like Drizzle ORM, Prisma, or Sequalize)? +3. What types of data inputs does this application ingest that could be considered sensitive data? +4. If the application is ingesting sensitive data, where does the application store the data? + +Use these answer in combonation with the answers provided a CIPHERSTASH_GETTING_STARTED.md file which will be used to help the user determine the next steps based on the application specific details. \ No newline at end of file diff --git a/packages/protect/package.json b/packages/protect/package.json index e462754..4480b03 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.17.0", + "@cipherstash/protect-ffi": "0.17.1", "@cipherstash/schema": "workspace:*", "zod": "^3.24.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b9b16b..b36b44a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,8 +456,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@cipherstash/protect-ffi': - specifier: 0.17.0 - version: 0.17.0 + specifier: 0.17.1 + version: 0.17.1 '@cipherstash/schema': specifier: workspace:* version: link:../schema @@ -975,33 +975,38 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/protect-ffi-darwin-arm64@0.17.0': - resolution: {integrity: sha512-/QsbCzpDs9orsBWf7RhPosyspsES7WjD2xr98NeJeN5IR/KbVmr+KuEchl18eqeVH1EzXNT5FZLZ+AUfEjO9rw==} + '@cipherstash/protect-ffi-darwin-arm64@0.17.1': + resolution: {integrity: sha512-hVQJxDDtdXCSX5+r5PCk1Tl2fz/jbaKEtOph/MDz5gSLsxoA9cpgH01TQqmVQdQLV5kKHDBXalvKk6+2b5EgMw==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.17.0': - resolution: {integrity: sha512-EOq6ZNAPlWU0SJTCmfQQkLPX7UDUv7O/fuTzVGksPjM6vC1iYAGRXa37TdxGHXzZDmOl9En70KblPtHI0opT7g==} + '@cipherstash/protect-ffi-darwin-x64@0.17.1': + resolution: {integrity: sha512-+eJnIxXz7VPT+l0I2pyhzo2lPNjxZzu6qUHhejWJsdvh5yYgA0yAa/MZKXOJ1EyuTgVqBDYsE0YSE8KpNgByEw==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.0': - resolution: {integrity: sha512-jLA+tNKNE4sluH6Clnn2TUoOPcvyuSaAj6h4FluswQahiHdILPDaM5WbePnbwOIi0eumff3zo9HuAWRJqhB7Pw==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.1': + resolution: {integrity: sha512-qXWvDZmRYiBM/SCPPk7sRxw3UB7Nxb/yAbY7R/FimWFaKH7H086sYAsmXXT/iTe0OSYAdomLj/c1d4gBDvN4bA==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.17.0': - resolution: {integrity: sha512-QjAyb7K2X/ccnIsKByTLG3iD5qQy4GQ23EWWJcTUkz6u1smE6C1lXjs9y43jlFcQc9WaGlIFn4SeC4q4fnuMDA==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.17.1': + resolution: {integrity: sha512-uEwqmRIovx+h+gv0v3uS6xd5AD21eFFvWq7DjWcdeIsHiBK9nQrsdMCIxhkSYGK37kzc+fRX0wwd8R2cNV6Jzw==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.17.0': - resolution: {integrity: sha512-bXU2Tv516qJEp47Yc7lizNdRCrXehubc87CdzU1Jl/bReWtmRK2Nu6lH9FsLtPSPdo8SClb81ZqtryxBW19n+w==} + '@cipherstash/protect-ffi-linux-x64-musl@0.17.1': + resolution: {integrity: sha512-0BrhKuL7QTJQbmpioQF6A0wVUA+exrpjUENosUWCOjRf6Pn3fdkNexh2KfwtVaxEnv5E8ro824kH28ud8k7Cvg==} + cpu: [x64] + os: [linux] + + '@cipherstash/protect-ffi-win32-x64-msvc@0.17.1': + resolution: {integrity: sha512-3nl9xcjbd2UMMhhldwZWU+W9rPFQgUty/6JGsphVZtRlP2g034ZHo6hyXWxMcj3tsDluzB37L6Kv/dEGYAILlA==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.17.0': - resolution: {integrity: sha512-TJv/qhuSzid42XXgdmeExf8G/XLZ51VB0wHsg0hJoR9t6yNjWOuOY3NZ37X5+7rWJnnIjjVFsQIr3sQ+aam7sw==} + '@cipherstash/protect-ffi@0.17.1': + resolution: {integrity: sha512-FGf/GMC+6Qx9KnwaWCdo3a94YaCz2SbqtucHjw2V64h/FXxZf+yNJuVXgfqQz8pNWuz+LgUKojUuXJ2nUmM7KQ==} '@clerk/backend@1.25.5': resolution: {integrity: sha512-nnBpr7oSq5iATWRExuljEfp7xa90KE1OUgaGCSmtZYF0T9TWHGkZHYqkQhD4XjiqlR2XsrsQ/UzPfmHM1Km7+Q==} @@ -7813,30 +7818,34 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@cipherstash/protect-ffi-darwin-arm64@0.17.0': + '@cipherstash/protect-ffi-darwin-arm64@0.17.1': + optional: true + + '@cipherstash/protect-ffi-darwin-x64@0.17.1': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.17.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.1': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.17.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.17.1': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.17.0': + '@cipherstash/protect-ffi-linux-x64-musl@0.17.1': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.17.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.17.1': optional: true - '@cipherstash/protect-ffi@0.17.0': + '@cipherstash/protect-ffi@0.17.1': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.17.0 - '@cipherstash/protect-ffi-darwin-x64': 0.17.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.17.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.17.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.17.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.17.1 + '@cipherstash/protect-ffi-darwin-x64': 0.17.1 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.17.1 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.17.1 + '@cipherstash/protect-ffi-linux-x64-musl': 0.17.1 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.17.1 '@clerk/backend@1.25.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: From 788dbfcfa803a1d18c45afa6b985389a230dc7b4 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 28 Oct 2025 15:43:42 -0700 Subject: [PATCH 17/19] chore: changeset --- .changeset/great-experts-repair.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/great-experts-repair.md diff --git a/.changeset/great-experts-repair.md b/.changeset/great-experts-repair.md new file mode 100644 index 0000000..f605e0a --- /dev/null +++ b/.changeset/great-experts-repair.md @@ -0,0 +1,17 @@ +--- +"@cipherstash/protect-dynamodb": major +"@cipherstash/protect": major +"@cipherstash/schema": major +--- + +Added JSON and INT data type support and update FFI to v0.17.1 with x86_64 musl environment platform support. + +* Update @cipherstash/protect-ffi from 0.16.0 to 0.17.1 with support for x86_64 musl platforms. +* Add searchableJson() method to schema for JSON field indexing (the search operations still don't work but this interface exists) +* Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext +* Add comprehensive test suites for JSON, integer, and basic encryption +* Update encryption format to use 'k' property for searchable JSON +* Remove deprecated search terms tests for JSON fields +* Simplify schema data types to text, int, json only +* Update model helpers to handle new encryption format +* Fix type safety issues in bulk operations and model encryption \ No newline at end of file From fd649cc19af3b422e85aa28e176e8bcc135125b0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 29 Oct 2025 12:12:21 +1100 Subject: [PATCH 18/19] fix: handle NaN and Infinity values in encryption process, update number tests --- README.md | 4 + ...protect.test.ts => number-protect.test.ts} | 417 +++++------------- .../protect/src/ffi/operations/encrypt.ts | 8 + 3 files changed, 134 insertions(+), 295 deletions(-) rename packages/protect/__tests__/{int-protect.test.ts => number-protect.test.ts} (69%) diff --git a/README.md b/README.md index 87c8dfe..80f331a 100644 --- a/README.md +++ b/README.md @@ -990,6 +990,10 @@ Other data types like booleans, dates, ints, floats, and JSON are well-supported Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48). +| Type | Support operations | Available | +|--|--|--| +| String | `=`, `LIKE`, `ORDER BY` | ✅ | + ## Searchable encryption Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs. diff --git a/packages/protect/__tests__/int-protect.test.ts b/packages/protect/__tests__/number-protect.test.ts similarity index 69% rename from packages/protect/__tests__/int-protect.test.ts rename to packages/protect/__tests__/number-protect.test.ts index 5315d8e..8a417c8 100644 --- a/packages/protect/__tests__/int-protect.test.ts +++ b/packages/protect/__tests__/number-protect.test.ts @@ -1,6 +1,6 @@ import 'dotenv/config' import { csColumn, csTable, csValue } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it, test } from 'vitest' import { LockContext, protect } from '../src' const users = csTable('users', { @@ -16,15 +16,15 @@ const users = csTable('users', { type User = { id: string - email?: string | null + email?: string createdAt?: Date updatedAt?: Date - address?: string | null - age?: number | null - score?: number | null + address?: string + age?: number + score?: number metadata?: { - count?: number | null - level?: number | null + count?: number + level?: number } } @@ -36,10 +36,15 @@ beforeAll(async () => { }) }) -describe('Integer encryption and decryption', () => { - it('should encrypt and decrypt a simple integer', async () => { - const age = 25 +const cases = [ + 25, 0, -42, 2147483647, + 77.9, 0.0, -117.123456, + 1e15, -1e15, // Very large floats + 9007199254740991 // Max safe integer in JavaScript +] +describe('Number encryption and decryption', () => { + test.each(cases)('should encrypt and decrypt a number: %d', async (age) => { const ciphertext = await protectClient.encrypt(age, { column: users.age, table: users, @@ -59,11 +64,9 @@ describe('Integer encryption and decryption', () => { }) }, 30000) - it('should encrypt and decrypt zero', async () => { - const score = 0 - - const ciphertext = await protectClient.encrypt(score, { - column: users.score, + it('should handle null integer', async () => { + const ciphertext = await protectClient.encrypt(null, { + column: users.age, table: users, }) @@ -71,21 +74,22 @@ describe('Integer encryption and decryption', () => { throw new Error(`[protect]: ${ciphertext.failure.message}`) } - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') + // Verify null is preserved + expect(ciphertext.data).toBeNull() const plaintext = await protectClient.decrypt(ciphertext.data) expect(plaintext).toEqual({ - data: score, + data: null, }) }, 30000) - it('should encrypt and decrypt negative integers', async () => { - const temperature = -42 + // Special case + it('should treat a negative zero valued float as 0.0', async () => { + const score = -0.0 - const ciphertext = await protectClient.encrypt(temperature, { - column: users.age, + const ciphertext = await protectClient.encrypt(score, { + column: users.score, table: users, }) @@ -99,77 +103,52 @@ describe('Integer encryption and decryption', () => { const plaintext = await protectClient.decrypt(ciphertext.data) expect(plaintext).toEqual({ - data: temperature, + data: 0.0, }) }, 30000) - it('should encrypt and decrypt large integers', async () => { - const largeNumber = 2147483647 // Max 32-bit signed integer + // Special case + it('should error for a NaN float', async () => { + const score = NaN - const ciphertext = await protectClient.encrypt(largeNumber, { - column: users.age, + const result = await protectClient.encrypt(score, { + column: users.score, table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } + }); - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: largeNumber, - }) + expect(result.failure).toBeDefined() + expect(result.failure?.message).toContain('Cannot encrypt NaN value') }, 30000) - it('should encrypt and decrypt very large integers', async () => { - const veryLargeNumber = 9007199254740991 // Max safe integer in JavaScript + // Special case + it('should error for Infinity', async () => { + const score = Infinity - const ciphertext = await protectClient.encrypt(veryLargeNumber, { - column: users.age, + const result = await protectClient.encrypt(score, { + column: users.score, table: users, }) - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: veryLargeNumber, - }) + expect(result.failure).toBeDefined() + expect(result.failure?.message).toContain('Cannot encrypt Infinity value') }, 30000) - it('should handle null integer', async () => { - const ciphertext = await protectClient.encrypt(null, { - column: users.age, + // Special case + it('should error for -Infinity', async () => { + const score = -Infinity + + const result = await protectClient.encrypt(score, { + column: users.score, table: users, }) - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify null is preserved - expect(ciphertext.data).toBeNull() - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: null, - }) + expect(result.failure).toBeDefined() + expect(result.failure?.message).toContain('Cannot encrypt Infinity value') }, 30000) }) -describe('Integer model encryption and decryption', () => { - it('should encrypt and decrypt a model with integer fields', async () => { +describe('Model encryption and decryption', () => { + it('should encrypt and decrypt a model with number fields', async () => { const decryptedModel = { id: '1', email: 'test@example.com', @@ -211,13 +190,13 @@ describe('Integer model encryption and decryption', () => { expect(decryptedResult.data).toEqual(decryptedModel) }, 30000) - it('should handle null integers in model', async () => { - const decryptedModel = { + it('should handle null numbers in model', async () => { + const decryptedModel: User = { id: '2', email: 'test2@example.com', address: '456 Oak St', - age: null, - score: null, + age: undefined, + score: undefined, createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'), } @@ -234,8 +213,8 @@ describe('Integer model encryption and decryption', () => { // Verify encrypted fields expect(encryptedModel.data.email).toHaveProperty('c') expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.age).toBeNull() - expect(encryptedModel.data.score).toBeNull() + expect(encryptedModel.data.age).toBeUndefined() + expect(encryptedModel.data.score).toBeUndefined() const decryptedResult = await protectClient.decryptModel( encryptedModel.data, @@ -248,7 +227,7 @@ describe('Integer model encryption and decryption', () => { expect(decryptedResult.data).toEqual(decryptedModel) }, 30000) - it('should handle undefined integers in model', async () => { + it('should handle undefined numbers in model', async () => { const decryptedModel = { id: '3', email: 'test3@example.com', @@ -286,12 +265,12 @@ describe('Integer model encryption and decryption', () => { }, 30000) }) -describe('Integer bulk encryption and decryption', () => { - it('should bulk encrypt and decrypt integer payloads', async () => { +describe('Bulk encryption and decryption', () => { + it('should bulk encrypt and decrypt number payloads', async () => { const intPayloads = [ { id: 'user1', plaintext: 25 }, - { id: 'user2', plaintext: 30 }, - { id: 'user3', plaintext: 35 }, + { id: 'user2', plaintext: 30.7 }, + { id: 'user3', plaintext: -35.123 }, ] const encryptedData = await protectClient.bulkEncrypt(intPayloads, { @@ -315,15 +294,24 @@ describe('Integer bulk encryption and decryption', () => { expect(encryptedData.data[2]).toHaveProperty('data') expect(encryptedData.data[2].data).toHaveProperty('c') + expect(encryptedData.data[0].data?.k).toBe('ct') + expect(encryptedData.data[1].data?.k).toBe('ct') + expect(encryptedData.data[2].data?.k).toBe('ct') + // Verify all encrypted values are different - expect(encryptedData.data[0].data?.c).not.toBe( - encryptedData.data[1].data?.c, + const getCiphertext = (data: any) => { + if (data?.k === 'ct') return data.c + return data?.c + } + + expect(getCiphertext(encryptedData.data[0].data)).not.toBe( + getCiphertext(encryptedData.data[1].data), ) - expect(encryptedData.data[1].data?.c).not.toBe( - encryptedData.data[2].data?.c, + expect(getCiphertext(encryptedData.data[1].data)).not.toBe( + getCiphertext(encryptedData.data[2].data), ) - expect(encryptedData.data[0].data?.c).not.toBe( - encryptedData.data[2].data?.c, + expect(getCiphertext(encryptedData.data[0].data)).not.toBe( + getCiphertext(encryptedData.data[2].data), ) // Now decrypt the data @@ -338,12 +326,12 @@ describe('Integer bulk encryption and decryption', () => { expect(decryptedData.data[0]).toHaveProperty('id', 'user1') expect(decryptedData.data[0]).toHaveProperty('data', 25) expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 30) + expect(decryptedData.data[1]).toHaveProperty('data', 30.7) expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', 35) + expect(decryptedData.data[2]).toHaveProperty('data', -35.123) }, 30000) - it('should handle mixed null and non-null integers in bulk operations', async () => { + it('should handle mixed null and non-null numbers in bulk operations', async () => { const intPayloads = [ { id: 'user1', plaintext: 25 }, { id: 'user2', plaintext: null }, @@ -388,7 +376,7 @@ describe('Integer bulk encryption and decryption', () => { expect(decryptedData.data[2]).toHaveProperty('data', 35) }, 30000) - it('should bulk encrypt and decrypt models with integer fields', async () => { + it('should bulk encrypt and decrypt models with number fields', async () => { const decryptedModels = [ { id: '1', @@ -449,8 +437,8 @@ describe('Integer bulk encryption and decryption', () => { }, 30000) }) -describe('Integer encryption with lock context', () => { - it('should encrypt and decrypt integer with lock context', async () => { +describe('Encryption with lock context', () => { + it('should encrypt and decrypt number with lock context', async () => { const userJwt = process.env.USER_JWT if (!userJwt) { @@ -492,7 +480,7 @@ describe('Integer encryption with lock context', () => { expect(plaintext.data).toEqual(age) }, 30000) - it('should encrypt integer model with lock context', async () => { + it('should encrypt model with lock context', async () => { const userJwt = process.env.USER_JWT if (!userJwt) { @@ -538,7 +526,7 @@ describe('Integer encryption with lock context', () => { expect(decryptedResult.data).toEqual(decryptedModel) }, 30000) - it('should bulk encrypt integers with lock context', async () => { + it('should bulk encrypt numbers with lock context', async () => { const userJwt = process.env.USER_JWT if (!userJwt) { @@ -596,8 +584,8 @@ describe('Integer encryption with lock context', () => { }, 30000) }) -describe('Integer nested object encryption', () => { - it('should encrypt and decrypt nested integer objects', async () => { +describe('Nested object encryption', () => { + it('should encrypt and decrypt nested number objects', async () => { const protectClient = await protect({ schemas: [users] }) const decryptedModel = { @@ -637,15 +625,15 @@ describe('Integer nested object encryption', () => { expect(decryptedResult.data).toEqual(decryptedModel) }, 30000) - it('should handle null values in nested integer objects', async () => { + it('should handle null values in nested objects with number fields', async () => { const protectClient = await protect({ schemas: [users] }) - const decryptedModel = { + const decryptedModel: User = { id: '2', email: 'test2@example.com', metadata: { - count: null, - level: null, + count: undefined, + level: undefined, }, } @@ -660,8 +648,8 @@ describe('Integer nested object encryption', () => { // Verify null fields are preserved expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.metadata?.count).toBeNull() - expect(encryptedModel.data.metadata?.level).toBeNull() + expect(encryptedModel.data.metadata?.count).toBeUndefined() + expect(encryptedModel.data.metadata?.level).toBeUndefined() const decryptedResult = await protectClient.decryptModel( encryptedModel.data, @@ -674,7 +662,7 @@ describe('Integer nested object encryption', () => { expect(decryptedResult.data).toEqual(decryptedModel) }, 30000) - it('should handle undefined values in nested integer objects', async () => { + it('should handle undefined values in nested objects with number fields', async () => { const protectClient = await protect({ schemas: [users] }) const decryptedModel = { @@ -712,8 +700,8 @@ describe('Integer nested object encryption', () => { }, 30000) }) -describe('Integer search terms', () => { - it('should create search terms for integer fields', async () => { +describe('Search terms', () => { + it('should create search terms for number fields', async () => { const searchTerms = [ { value: 25, @@ -742,7 +730,7 @@ describe('Integer search terms', () => { ) }, 30000) - it('should create search terms with composite-literal return type for integers', async () => { + it('should create search terms with composite-literal return type for numbers', async () => { const searchTerms = [ { value: 42, @@ -763,7 +751,7 @@ describe('Integer search terms', () => { expect(() => JSON.parse(result.slice(1, -1))).not.toThrow() }, 30000) - it('should create search terms with escaped-composite-literal return type for integers', async () => { + it('should create search terms with escaped-composite-literal return type for numbers', async () => { const searchTerms = [ { value: 99, @@ -787,9 +775,9 @@ describe('Integer search terms', () => { }, 30000) }) -describe('Integer performance tests', () => { - it('should handle large numbers of integers efficiently', async () => { - const largeIntArray = Array.from({ length: 100 }, (_, i) => ({ +describe('Performance tests', () => { + it('should handle large numbers of numbers efficiently', async () => { + const largeNumArray = Array.from({ length: 100 }, (_, i) => ({ id: i, data: { age: i + 18, // Ages 18-117 @@ -797,12 +785,12 @@ describe('Integer performance tests', () => { }, })) - const intPayloads = largeIntArray.map((item, index) => ({ + const numPayloads = largeNumArray.map((item, index) => ({ id: `user${index}`, plaintext: item.data.age, })) - const encryptedData = await protectClient.bulkEncrypt(intPayloads, { + const encryptedData = await protectClient.bulkEncrypt(numPayloads, { column: users.age, table: users, }) @@ -826,12 +814,12 @@ describe('Integer performance tests', () => { for (let i = 0; i < 100; i++) { expect(decryptedData.data[i].id).toBe(`user${i}`) - expect(decryptedData.data[i].data).toEqual(largeIntArray[i].data.age) + expect(decryptedData.data[i].data).toEqual(largeNumArray[i].data.age) } }, 60000) }) -describe('Integer advanced scenarios', () => { +describe('Advanced scenarios', () => { it('should handle boundary values', async () => { const boundaryValues = [ Number.MIN_SAFE_INTEGER, @@ -863,188 +851,27 @@ describe('Integer advanced scenarios', () => { }) } }, 30000) - - it('should handle consecutive integers', async () => { - const consecutiveInts = Array.from({ length: 10 }, (_, i) => i + 1) - - for (const value of consecutiveInts) { - const ciphertext = await protectClient.encrypt(value, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: value, - }) - } - }, 30000) - - it('should handle random integers', async () => { - const randomInts = Array.from( - { length: 20 }, - () => Math.floor(Math.random() * 10000) - 5000, - ) - - for (const value of randomInts) { - const ciphertext = await protectClient.encrypt(value, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: value, - }) - } - }, 30000) - - it('should handle mixed positive and negative integers', async () => { - const mixedInts = [-100, -50, -1, 0, 1, 50, 100] - - for (const value of mixedInts) { - const ciphertext = await protectClient.encrypt(value, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: value, - }) - } - }, 30000) }) -describe('Integer error handling and edge cases', () => { - it('should handle floating point numbers (should be truncated)', async () => { - const floatValue = 42.7 - - const ciphertext = await protectClient.encrypt(floatValue, { +const invalidPlaintexts = [ + '400', + 'aaa', + '100a', + '73.51', + {}, + [], + [123], + { num: 123 }, +]; + +describe('Invalid or uncoercable values', () => { + test.each(invalidPlaintexts)('should fail to encrypt', async (input) => { + const result = await protectClient.encrypt(input, { column: users.age, table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - // Floating point numbers are preserved as-is (not truncated) - expect(plaintext).toEqual({ - data: floatValue, - }) - }, 30000) - - it('should handle very large numbers (should be handled appropriately)', async () => { - const veryLargeNumber = 1e15 - - const ciphertext = await protectClient.encrypt(veryLargeNumber, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: veryLargeNumber, - }) - }, 30000) - - it('should handle string numbers (should be converted)', async () => { - // Note: This test might fail if the library doesn't handle string conversion - // Remove this test if string conversion is not supported - const stringNumber = '42' - - try { - const ciphertext = await protectClient.encrypt(stringNumber, { - column: users.age, - table: users, }) - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - // String should be converted to number - expect(plaintext).toEqual({ - data: Number(stringNumber), - }) - } catch (error) { - // If string conversion is not supported, that's also acceptable - expect(error).toBeDefined() - } - }, 30000) - - it('should handle all integer edge cases', async () => { - const edgeCases = [ - Number.MIN_SAFE_INTEGER, - Number.MAX_SAFE_INTEGER, - 0, - 1, - -1, - Number.MAX_VALUE, - Number.MIN_VALUE, - ] - - for (const value of edgeCases) { - const ciphertext = await protectClient.encrypt(value, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: value, - }) - } + expect(result.failure).toBeDefined() + expect(result.failure?.message).toContain('Unsupported conversion') }, 30000) }) diff --git a/packages/protect/src/ffi/operations/encrypt.ts b/packages/protect/src/ffi/operations/encrypt.ts index 6d479b3..2d15175 100644 --- a/packages/protect/src/ffi/operations/encrypt.ts +++ b/packages/protect/src/ffi/operations/encrypt.ts @@ -56,6 +56,14 @@ export class EncryptOperation extends ProtectOperation { return null } + if (typeof this.plaintext === 'number' && isNaN(this.plaintext)) { + throw new Error('[protect]: Cannot encrypt NaN value') + } + + if (typeof this.plaintext === 'number' && !Number.isFinite(this.plaintext)) { + throw new Error('[protect]: Cannot encrypt Infinity value') + } + const { metadata } = this.getAuditData() return await ffiEncrypt(this.client, { From 87c8b267b5b74b82a2cbcb4eb1374c9936ee5a4b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 29 Oct 2025 14:05:26 +1100 Subject: [PATCH 19/19] docs: update README to reflect supported data types and handling of special values in Protect.js --- README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 80f331a..5f90f02 100644 --- a/README.md +++ b/README.md @@ -985,14 +985,59 @@ const bulkDecryptedResult = await protectClient ## Supported data types -Protect.js currently supports encrypting and decrypting text. -Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon. +Protect.js supports a number of different data types with support for additional types on the roadmap. -Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48). +| JS/TS Type | Available | Notes | +|--|--|--| +| `string` | ✅ | +| `number` | ✅ | +| `json` (opaque) | ✅ | | +| `json` (searchable) | ⚙️ | Coming soon | +| `bigint` | ⚙️ | Coming soon | +| `boolean`| ⚙️ | Coming soon | +| `date` | ⚙️ | Coming soon | + +If you need support for ther data types please [raise an issue](https://github.com/cipherstash/protectjs/issues) and we'll do our best to add it to Protect.js. + +### Type casting + +When encrypting types other than `string`, Protect requires the data type to be specified explicitly using the `dataType` function on the column definition. + +For example, to handle encryption of a `number` field called `score`: + +```ts +const users = csTable('users', { + score: csColumn('score').dataType('number') +}) +``` + +This means that any JavaScript/TypeScript `number` will encrypt correctly but if an attempt to encrypt a value of a different type is made the operation will fail with an error. +This is particularly important for searchable index schemes that require data types (and their encodings) to be consistent. -| Type | Support operations | Available | +In an unencrypted setup, this type checking is usually handled by the database (the column type in a table) but when the data is encrypted, the database can't determine what type the plaintext value should be so we must specify it in the Protect schema instead. + +> [!IMPORTANT] +> If the data type of a column is set to `bigint`, floating point numbers will be converted to integers (via truncation). + +### Handling of null and special values + +There are some important special cases to be aware of when encrypting values with Protect.js. +For example, encrypting `null` or `undefined` will just return a `null`/`undefined` value. + +When `dataType` is `number`, attempting to encrypt `NaN`, `Infinity` or `-Infinity` will fail with an error. +Encrypting `-0.0` will coerce the value into `0.0`. + +The table below summarizes these cases. + +| Data type | Plaintext | Encryption | |--|--|--| -| String | `=`, `LIKE`, `ORDER BY` | ✅ | +|`any`| `null` | `null` | +| `any` | `undefined` | `undefined` | +| `number` | `-0.0` | Encryption of `0.0` | +| `number` | `NaN` | _Error_ | +| `number` | `Infinity` | _Error_| +| `number` | `-Infinity` | _Error_| + ## Searchable encryption