diff --git a/.changeset/fresh-icons-fly.md b/.changeset/fresh-icons-fly.md new file mode 100644 index 00000000..4e750bc5 --- /dev/null +++ b/.changeset/fresh-icons-fly.md @@ -0,0 +1,9 @@ +--- +"@cipherstash/nextjs": major +"@cipherstash/protect": minors +--- + +Implemented CipherStash CRN in favor of workspace ID. + +- Replaces the environment variable `CS_WORKSPACE_ID` with `CS_WORKSPACE_CRN` +- Replaces `workspace_id` with `workspace_crn` in the `cipherstash.toml` file diff --git a/.changeset/green-signs-shop.md b/.changeset/green-signs-shop.md new file mode 100644 index 00000000..32899157 --- /dev/null +++ b/.changeset/green-signs-shop.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/protect": minor +--- + +Fixed handling composite types for EQL v2. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c83a1f01..62a2194b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,10 +34,12 @@ jobs: - name: Create .env file in ./packages/protect/ run: | touch ./packages/protect/.env - echo "CS_WORKSPACE_ID=${{ secrets.CS_WORKSPACE_ID }}" >> ./packages/protect/.env + echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/protect/.env echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env + echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> ./packages/protect/.env + echo "SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" >> ./packages/protect/.env # Run TurboRepo tests - name: Run tests diff --git a/docs/reference/working-with-composite-types.md b/docs/reference/working-with-composite-types.md new file mode 100644 index 00000000..2e23255e --- /dev/null +++ b/docs/reference/working-with-composite-types.md @@ -0,0 +1,108 @@ +# Handling encrypted data with PostgreSQL's `eql_v2_encrypted` type + +> [!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 collected some examples for the most popular ORMs/clients below. + +## Supabase JS SDK + +If you are using [Supabase JS SDK](https://github.com/supabase/supabase-js) to interact with your database, you'll need to handle encrypted data in a specific way. +Here's how to work with it: + +### Inserting encrypted data + +When inserting encrypted data, you need to transform the encrypted payload into a PostgreSQL composite type using the `encryptedToPgComposite` helper: + +```typescript +import { protect, csTable, csColumn, encryptedToPgComposite } from '@cipherstash/protect' + +const table = csTable('your_table', { + encrypted: csColumn('encrypted').freeTextSearch().equality().orderAndRange(), +}) + +const protectClient = await protect(table) + +// Encrypt your data +const ciphertext = await protectClient.encrypt('sensitive data', { + column: table.encrypted, + table: table, +}) + +// Insert into Supabase +const { data, error } = await supabase + .from('your_table') + .insert({ encrypted: encryptedToPgComposite(ciphertext.data) }) +``` + +### Selecting encrypted data + +When selecting encrypted data, it's **highly recommended** to cast the encrypted column to JSONB using `::jsonb`. +Without this cast, the encrypted payload will be wrapped in an object with a `data` key, which requires additional handling before decryption. +This is especially important when working with models, as the decryption functions expect the raw encrypted payload: + +```typescript +// ✅ Recommended way - using ::jsonb cast +// This returns the raw encrypted payload, ready for decryption +const { data, error } = await supabase + .from('your_table') + .select('id, encrypted::jsonb') + .eq('id', someId) + +// ❌ Without ::jsonb cast +// This returns { data: encryptedPayload }, requiring extra handling +// before decryption, especially problematic with model decryption +const { data, error } = await supabase + .from('your_table') + .select('id, encrypted') +``` + +### Working with models + +For working with models that contain encrypted fields, use the `modelToEncryptedPgComposites` helper: + +```typescript +const model = { + encrypted: 'sensitive data', + otherField: 'not encrypted', +} + +const encryptedModel = await protectClient.encryptModel(model, table) + +const { data, error } = await supabase + .from('your_table') + .insert([modelToEncryptedPgComposites(encryptedModel.data)]) +``` + +For bulk operations with multiple models, use `bulkEncryptModels` and `bulkModelsToEncryptedPgComposites`: + +```typescript +const models = [ + { + encrypted: 'sensitive data 1', + otherField: 'not encrypted 1', + }, + { + encrypted: 'sensitive data 2', + otherField: 'not encrypted 2', + }, +] + +const encryptedModels = await protectClient.bulkEncryptModels(models, table) + +const { data, error } = await supabase + .from('your_table') + .insert(bulkModelsToEncryptedPgComposites(encryptedModels.data)) + .select('id') + +// When selecting multiple records, remember to use ::jsonb +const { data: selectedData, error: selectError } = await supabase + .from('your_table') + .select('id, encrypted::jsonb, otherField') + +// Decrypt all models at once +const decryptedModels = await protectClient.bulkDecryptModels(selectedData) +``` + +## Getting help + +Don't see your ORM/client here? [Open an issue](https://github.com/cipherstash/protectjs/issues/new?template=docs-feedback.yml) and we'll add it to the docs! \ No newline at end of file diff --git a/packages/protect/README.md b/packages/protect/README.md index e61d649d..bd08d935 100644 --- a/packages/protect/README.md +++ b/packages/protect/README.md @@ -574,6 +574,10 @@ CREATE TABLE users ( ); ``` +> [!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/how-to/searchable-encryption.md) in the docs. ## Identity-aware encryption diff --git a/packages/protect/__tests__/helpers.test.ts b/packages/protect/__tests__/helpers.test.ts new file mode 100644 index 00000000..9e14c91a --- /dev/null +++ b/packages/protect/__tests__/helpers.test.ts @@ -0,0 +1,124 @@ +import { + encryptedToPgComposite, + modelToEncryptedPgComposites, + bulkModelsToEncryptedPgComposites, + isEncryptedPayload, +} from '../src/helpers' +import { describe, expect, it } from 'vitest' + +describe('helpers', () => { + describe('encryptedToPgComposite', () => { + it('should convert encrypted payload to pg composite', () => { + const encrypted = { + v: 1, + c: 'ciphertext', + i: { + c: 'iv', + t: 't', + }, + k: 'k', + ob: ['a', 'b'], + bf: [1, 2, 3], + hm: 'hm', + } + + const pgComposite = encryptedToPgComposite(encrypted) + expect(pgComposite).toEqual({ + data: encrypted, + }) + }) + }) + + describe('isEncryptedPayload', () => { + it('should return true for valid encrypted payload', () => { + const encrypted = { + v: 1, + c: 'ciphertext', + i: { c: 'iv', t: 't' }, + } + expect(isEncryptedPayload(encrypted)).toBe(true) + }) + + it('should return false for null', () => { + expect(isEncryptedPayload(null)).toBe(false) + }) + + it('should return false for non-encrypted object', () => { + expect(isEncryptedPayload({ foo: 'bar' })).toBe(false) + }) + }) + + describe('modelToEncryptedPgComposites', () => { + it('should transform model with encrypted fields', () => { + const model = { + name: 'John', + email: { + v: 1, + c: 'encrypted_email', + i: { c: 'iv', t: 't' }, + }, + age: 30, + } + + const result = modelToEncryptedPgComposites(model) + expect(result).toEqual({ + name: 'John', + email: { + data: { + v: 1, + c: 'encrypted_email', + i: { c: 'iv', t: 't' }, + }, + }, + age: 30, + }) + }) + }) + + describe('bulkModelsToEncryptedPgComposites', () => { + it('should transform multiple models with encrypted fields', () => { + const models = [ + { + name: 'John', + email: { + v: 1, + c: 'encrypted_email1', + i: { c: 'iv', t: 't' }, + }, + }, + { + name: 'Jane', + email: { + v: 1, + c: 'encrypted_email2', + i: { c: 'iv', t: 't' }, + }, + }, + ] + + const result = bulkModelsToEncryptedPgComposites(models) + expect(result).toEqual([ + { + name: 'John', + email: { + data: { + v: 1, + c: 'encrypted_email1', + i: { c: 'iv', t: 't' }, + }, + }, + }, + { + name: 'Jane', + email: { + data: { + v: 1, + c: 'encrypted_email2', + i: { c: 'iv', t: 't' }, + }, + }, + }, + ]) + }) + }) +}) diff --git a/packages/protect/__tests__/protect.test.ts b/packages/protect/__tests__/protect.test.ts index 305d5b7f..e8e44ed0 100644 --- a/packages/protect/__tests__/protect.test.ts +++ b/packages/protect/__tests__/protect.test.ts @@ -468,7 +468,7 @@ describe('performance', () => { // ------------------------ // TODO get LockContext working in CI. // To manually test locally, uncomment the following lines and provide a valid JWT in the userJwt variable. -// Last successful local test was 2025-05-20 by cj@cipherstash.com +// Last successful local test was 2025-05-23 by cj@cipherstash.com // ------------------------ // const userJwt = '' // describe('encryption and decryption with lock context', () => { diff --git a/packages/protect/__tests__/supabase.test.ts b/packages/protect/__tests__/supabase.test.ts new file mode 100644 index 00000000..9b130785 --- /dev/null +++ b/packages/protect/__tests__/supabase.test.ts @@ -0,0 +1,190 @@ +import 'dotenv/config' +import { describe, expect, it } from 'vitest' + +import { + protect, + csTable, + csColumn, + type EncryptedPayload, + encryptedToPgComposite, + modelToEncryptedPgComposites, + isEncryptedPayload, + bulkModelsToEncryptedPgComposites, +} from '../src' + +import { createClient } from '@supabase/supabase-js' + +if (!process.env.SUPABASE_URL) { + throw new Error('Missing env.SUPABASE_URL') +} +if (!process.env.SUPABASE_ANON_KEY) { + throw new Error('Missing env.SUPABASE_ANON_KEY') +} + +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_ANON_KEY, +) + +const table = csTable('protect-ci', { + encrypted: csColumn('encrypted').freeTextSearch().equality().orderAndRange(), +}) + +describe('supabase', () => { + it('should insert and select encrypted data', async () => { + const protectClient = await protect(table) + + const e = 'hello world' + + const ciphertext = await protectClient.encrypt(e, { + column: table.encrypted, + table: table, + }) + + if (ciphertext.failure) { + throw new Error(`[protect]: ${ciphertext.failure.message}`) + } + + const { data: insertedData, error: insertError } = await supabase + .from('protect-ci') + .insert({ encrypted: encryptedToPgComposite(ciphertext.data) }) + .select('id') + + if (insertError) { + throw new Error(`[protect]: ${insertError.message}`) + } + + const { data, error } = await supabase + .from('protect-ci') + .select('id, encrypted::jsonb') + .eq('id', insertedData[0].id) + + if (error) { + throw new Error(`[protect]: ${error.message}`) + } + + const dataToDecrypt = data[0].encrypted as EncryptedPayload + const plaintext = await protectClient.decrypt(dataToDecrypt) + + await supabase.from('protect-ci').delete().eq('id', insertedData[0].id) + + expect(plaintext).toEqual({ + data: e, + }) + }, 30000) + + it('should insert and select encrypted model data', async () => { + const protectClient = await protect(table) + + const model = { + encrypted: 'hello world', + otherField: 'not encrypted', + } + + const encryptedModel = await protectClient.encryptModel(model, table) + + if (encryptedModel.failure) { + throw new Error(`[protect]: ${encryptedModel.failure.message}`) + } + + const { data: insertedData, error: insertError } = await supabase + .from('protect-ci') + .insert([modelToEncryptedPgComposites(encryptedModel.data)]) + .select('id') + + if (insertError) { + throw new Error(`[protect]: ${insertError.message}`) + } + + const { data, error } = await supabase + .from('protect-ci') + .select('id, encrypted::jsonb, otherField') + .eq('id', insertedData[0].id) + + if (error) { + throw new Error(`[protect]: ${error.message}`) + } + + if (!isEncryptedPayload(data[0].encrypted)) { + throw new Error('Expected encrypted payload') + } + + const decryptedModel = await protectClient.decryptModel(data[0]) + + if (decryptedModel.failure) { + throw new Error(`[protect]: ${decryptedModel.failure.message}`) + } + + await supabase.from('protect-ci').delete().eq('id', insertedData[0].id) + + expect({ + encrypted: decryptedModel.data.encrypted, + otherField: data[0].otherField, + }).toEqual(model) + }, 30000) + + it('should insert and select bulk encrypted model data', async () => { + const protectClient = await protect(table) + + const models = [ + { + encrypted: 'hello world 1', + otherField: 'not encrypted 1', + }, + { + encrypted: 'hello world 2', + otherField: 'not encrypted 2', + }, + ] + + const encryptedModels = await protectClient.bulkEncryptModels(models, table) + + if (encryptedModels.failure) { + throw new Error(`[protect]: ${encryptedModels.failure.message}`) + } + + const { data: insertedData, error: insertError } = await supabase + .from('protect-ci') + .insert(bulkModelsToEncryptedPgComposites(encryptedModels.data)) + .select('id') + + if (insertError) { + throw new Error(`[protect]: ${insertError.message}`) + } + + const { data, error } = await supabase + .from('protect-ci') + .select('id, encrypted::jsonb, otherField') + .in( + 'id', + insertedData.map((d) => d.id), + ) + + if (error) { + throw new Error(`[protect]: ${error.message}`) + } + + const decryptedModels = await protectClient.bulkDecryptModels(data) + + if (decryptedModels.failure) { + throw new Error(`[protect]: ${decryptedModels.failure.message}`) + } + + await supabase + .from('protect-ci') + .delete() + .in( + 'id', + insertedData.map((d) => d.id), + ) + + expect( + decryptedModels.data.map((d) => { + return { + encrypted: d.encrypted, + otherField: d.otherField, + } + }), + ).toEqual(models) + }, 30000) +}) diff --git a/packages/protect/eql.schema.json b/packages/protect/eql.schema.json deleted file mode 100644 index 1481c16b..00000000 --- a/packages/protect/eql.schema.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "The EQL encrypted JSON payload used for storage.", - "type": "object", - "properties": { - "v": { - "title": "Schema version", - "type": "integer" - }, - "k": { - "title": "kind", - "type": "string", - "enum": [ - "ct", - "sv" - ] - }, - "i": { - "title": "ident", - "type": "object", - "properties": { - "t": { - "title": "table", - "type": "string", - "pattern": "^[a-zA-Z_]{1}[0-9a-zA-Z_]*$" - }, - "c": { - "title": "column", - "type": "string", - "pattern": "^[a-zA-Z_]{1}[0-9a-zA-Z_]*$" - } - }, - "required": [ - "t", - "c" - ] - } - }, - "oneOf": [ - { - "properties": { - "k": { - "const": "ct" - }, - "c": { - "title": "ciphertext", - "type": "string" - }, - "u": { - "title": "unique index", - "type": "string" - }, - "o": { - "title": "ore index", - "type": "array", - "minItems": 1, - "items": { - "type": "string" - } - }, - "m": { - "title": "match index", - "type": "array", - "minItems": 1, - "items": { - "type": "number" - } - } - }, - "required": [ - "c" - ] - }, - { - "properties": { - "k": { - "const": "sv" - }, - "sv": { - "title": "Structured Encryption vector", - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string", - "minItems": 3, - "maxItems": 3 - } - } - } - }, - "required": [ - "sv" - ] - } - ], - "required": [ - "v", - "k", - "i" - ] -} \ No newline at end of file diff --git a/packages/protect/generateEqlSchema.ts b/packages/protect/generateEqlSchema.ts deleted file mode 100644 index 3e722206..00000000 --- a/packages/protect/generateEqlSchema.ts +++ /dev/null @@ -1,28 +0,0 @@ -import fs from 'node:fs/promises' -import { execa } from 'execa' - -async function main() { - const url = - 'https://raw.githubusercontent.com/cipherstash/encrypt-query-language/main/sql/schemas/cs_encrypted_storage_v2.schema.json' - - const response = await fetch(url) - - if (!response.ok) { - throw new Error(`Failed to fetch schema, status = ${response.status}`) - } - - const data = await response.json() - - await fs.writeFile('./eql.schema.json', JSON.stringify(data, null, 2)) - - await execa('pnpm', ['run', 'eql:generate'], { stdio: 'inherit' }) - - console.log( - 'The EQL schema has been updated from the source repo and the types have been generated. See the `eql.schema.json` file for the latest schema.', - ) -} - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/packages/protect/package.json b/packages/protect/package.json index 21ea82eb..34752a7f 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -35,18 +35,17 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "eql:update": "tsx ./generateEqlSchema.ts", - "eql:generate": "json2ts ./eql.schema.json --output ./src/eql.schema.ts", "test": "vitest run", "release": "tsup" }, "devDependencies": { + "@supabase/supabase-js": "^2.47.10", "dotenv": "^16.4.7", "execa": "^9.5.2", "json-schema-to-typescript": "^15.0.2", "tsup": "catalog:repo", - "typescript": "catalog:repo", "tsx": "catalog:repo", + "typescript": "catalog:repo", "vitest": "catalog:repo" }, "publishConfig": { @@ -54,7 +53,7 @@ }, "dependencies": { "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.12.0", + "@cipherstash/protect-ffi": "0.14.0", "zod": "^3.24.2" }, "optionalDependencies": { diff --git a/packages/protect/src/eql.schema.ts b/packages/protect/src/eql.schema.ts deleted file mode 100644 index f57c685b..00000000 --- a/packages/protect/src/eql.schema.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable */ -/** - * This file was automatically generated by json-schema-to-typescript. - * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, - * and run json-schema-to-typescript to regenerate this file. - */ - -/** - * The EQL encrypted JSON payload used for storage. - */ -export type EqlSchema = { - v: SchemaVersion - k: Kind - i: Ident - [k: string]: unknown -} & ( - | { - k?: 'ct' - c: Ciphertext - u?: UniqueIndex - o?: OreIndex - m?: MatchIndex - [k: string]: unknown - } - | { - k?: 'sv' - sv: StructuredEncryptionVector - [k: string]: unknown - } -) -export type SchemaVersion = number -export type Kind = 'ct' | 'sv' -export type Table = string -export type Column = string -export type Ciphertext = string -export type UniqueIndex = string -/** - * @minItems 1 - */ -export type OreIndex = [string, ...string[]] -/** - * @minItems 1 - */ -export type MatchIndex = [number, ...number[]] -export type StructuredEncryptionVector = string[][] - -export interface Ident { - t: Table - c: Column - [k: string]: unknown -} diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts index 1be3c1d7..736d31c9 100644 --- a/packages/protect/src/ffi/index.ts +++ b/packages/protect/src/ffi/index.ts @@ -43,31 +43,20 @@ export class ProtectClient { ): Promise> { return await withResult( async () => { - let c: Client + const validated: EncryptConfig = + encryptConfigSchema.parse(encryptConifg) - if (encryptConifg) { - const validated: EncryptConfig = - encryptConfigSchema.parse(encryptConifg) + logger.debug( + 'Initializing the Protect.js client with the following encrypt config:', + { + encryptConfig: validated, + }, + ) - logger.debug( - 'Initializing the Protect.js client with the following encrypt config:', - { - encryptConfig: validated, - }, - ) - - c = await newClient(JSON.stringify(validated)) - this.encryptConfig = validated - } else { - logger.debug( - 'Initializing the Protect.js client with default encrypt config.', - ) - - c = await newClient() - } + this.client = await newClient(JSON.stringify(validated)) + this.encryptConfig = validated logger.info('Successfully initialized the Protect.js client.') - this.client = c return this }, (error) => ({ diff --git a/packages/protect/src/ffi/model-helpers.ts b/packages/protect/src/ffi/model-helpers.ts index 4d98e982..54e789ec 100644 --- a/packages/protect/src/ffi/model-helpers.ts +++ b/packages/protect/src/ffi/model-helpers.ts @@ -1,7 +1,12 @@ -import { decryptBulk, encryptBulk } from '@cipherstash/protect-ffi' +import { + decryptBulk, + encryptBulk, + type Encrypted, +} from '@cipherstash/protect-ffi' import type { EncryptedPayload, Decrypted, Client } from '../types' import type { ProtectTable, ProtectTableColumn } from '../schema' import type { GetLockContextResponse } from '../identify' +import { isEncryptedPayload } from '../helpers' /** * Helper function to extract encrypted fields from a model @@ -37,27 +42,6 @@ export function extractOtherFields>( return result } -/** - * Helper function to check if a value is an encrypted payload - */ -export function isEncryptedPayload(value: unknown): value is EncryptedPayload { - if (value === null) return false - - if (typeof value === 'object') { - const obj = value as Record - return ( - 'data' in obj && - obj.data !== null && - typeof obj.data === 'object' && - 'v' in obj.data && - 'k' in obj.data && - 'i' in obj.data - ) - } - - return false -} - /** * Helper function to merge encrypted and non-encrypted fields into a model */ @@ -190,7 +174,7 @@ export async function decryptModelFields>( const bulkDecryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, - ciphertext: (value as EncryptedPayload).data?.c as string, + ciphertext: (value as EncryptedPayload)?.c ?? '', }), ) @@ -229,10 +213,7 @@ export async function encryptModelFields>( const encryptedData = await handleSingleModelBulkOperation( bulkEncryptPayload, - (items) => - encryptBulk(client, items).then((results) => - results.map((item) => ({ data: JSON.parse(item) }) as EncryptedPayload), - ), + (items) => encryptBulk(client, items), keyMap, ) @@ -263,7 +244,7 @@ export async function decryptModelFieldsWithLockContext< const bulkDecryptPayload = Object.entries(operationFields).map( ([key, value]) => ({ id: key, - ciphertext: (value as EncryptedPayload).data?.c as string, + ciphertext: (value as EncryptedPayload)?.c ?? '', lockContext: lockContext.context, }), ) @@ -311,10 +292,7 @@ export async function encryptModelFieldsWithLockContext< const encryptedData = await handleSingleModelBulkOperation( bulkEncryptPayload, - (items) => - encryptBulk(client, items, lockContext.ctsToken).then((results) => - results.map((item) => ({ data: JSON.parse(item) }) as EncryptedPayload), - ), + (items) => encryptBulk(client, items, lockContext.ctsToken), keyMap, ) @@ -403,10 +381,7 @@ export async function bulkEncryptModels>( // Make a single FFI call for all fields const encryptedData = await handleMultiModelBulkOperation( bulkEncryptPayload, - (items) => - encryptBulk(client, items).then((results) => - results.map((item) => ({ data: JSON.parse(item) }) as EncryptedPayload), - ), + (items) => encryptBulk(client, items), keyMap, ) @@ -450,7 +425,7 @@ export async function bulkDecryptModels>( const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, - ciphertext: (value as EncryptedPayload).data?.c as string, + ciphertext: (value as EncryptedPayload)?.c ?? '', })), ) @@ -503,7 +478,7 @@ export async function bulkDecryptModelsWithLockContext< const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => Object.entries(fields).map(([key, value]) => ({ id: `${modelIndex}-${key}`, - ciphertext: (value as EncryptedPayload).data?.c as string, + ciphertext: (value as EncryptedPayload)?.c ?? '', lockContext: lockContext.context, })), ) @@ -566,10 +541,7 @@ export async function bulkEncryptModelsWithLockContext< const encryptedData = await handleMultiModelBulkOperation( bulkEncryptPayload, - (items) => - encryptBulk(client, items, lockContext.ctsToken).then((results) => - results.map((item) => ({ data: JSON.parse(item) }) as EncryptedPayload), - ), + (items) => encryptBulk(client, items, lockContext.ctsToken), keyMap, ) diff --git a/packages/protect/src/ffi/operations/decrypt.ts b/packages/protect/src/ffi/operations/decrypt.ts index c0360359..615fadf2 100644 --- a/packages/protect/src/ffi/operations/decrypt.ts +++ b/packages/protect/src/ffi/operations/decrypt.ts @@ -42,23 +42,12 @@ export class DecryptOperation throw noClientError() } - if (this.encryptedData?.data === null) { - return null - } - - if (this.encryptedData.data.k !== 'ct') { - throw new Error( - 'The encrypted data is not compliant with the EQL schema', - ) - } - - // If ciphertext is empty, it represents a null value - if (this.encryptedData.data.c === '') { + if (this.encryptedData === null) { return null } logger.debug('Decrypting data WITHOUT a lock context') - return await ffiDecrypt(this.client, this.encryptedData.data.c) + return await ffiDecrypt(this.client, this.encryptedData.c) }, (error) => ({ type: ProtectErrorTypes.DecryptionError, @@ -110,7 +99,7 @@ export class DecryptOperationWithLockContext throw noClientError() } - if (encryptedData.data === null) { + if (encryptedData === null) { return null } @@ -122,20 +111,9 @@ export class DecryptOperationWithLockContext throw new Error(`[protect]: ${context.failure.message}`) } - if (encryptedData.data.k !== 'ct') { - throw new Error( - 'The encrypted data is not compliant with the EQL schema', - ) - } - - // If ciphertext is empty, it represents a null value - if (encryptedData.data.c === '') { - return null - } - return await ffiDecrypt( client, - encryptedData.data.c, + encryptedData.c, context.data.context, context.data.ctsToken, ) diff --git a/packages/protect/src/ffi/operations/encrypt.ts b/packages/protect/src/ffi/operations/encrypt.ts index fdac10e0..48f0f862 100644 --- a/packages/protect/src/ffi/operations/encrypt.ts +++ b/packages/protect/src/ffi/operations/encrypt.ts @@ -67,19 +67,14 @@ export class EncryptOperation } if (this.plaintext === null) { - return { - data: null, - } + return null } - const val = await ffiEncrypt( - this.client, - this.plaintext, - this.column.getName(), - this.table.tableName, - ) - - return { data: JSON.parse(val) } as EncryptedPayload + return await ffiEncrypt(this.client, { + plaintext: this.plaintext, + column: this.column.getName(), + table: this.table.tableName, + }) }, (error) => ({ type: ProtectErrorTypes.EncryptionError, @@ -145,9 +140,7 @@ export class EncryptOperationWithLockContext } if (plaintext === null) { - return { - data: null, - } + return null } const context = await this.lockContext.getLockContext() @@ -156,16 +149,16 @@ export class EncryptOperationWithLockContext throw new Error(`[protect]: ${context.failure.message}`) } - const val = await ffiEncrypt( + return await ffiEncrypt( client, - plaintext, - column.getName(), - table.tableName, - context.data.context, + { + plaintext, + column: column.getName(), + table: table.tableName, + lockContext: context.data.context, + }, context.data.ctsToken, ) - - return { data: JSON.parse(val) } as EncryptedPayload }, (error) => ({ type: ProtectErrorTypes.EncryptionError, diff --git a/packages/protect/src/helpers/index.ts b/packages/protect/src/helpers/index.ts new file mode 100644 index 00000000..c1687303 --- /dev/null +++ b/packages/protect/src/helpers/index.ts @@ -0,0 +1,60 @@ +import type { Encrypted } from '@cipherstash/protect-ffi' +import type { EncryptedPayload } from '../types' + +export type EncryptedPgComposite = { + data: EncryptedPayload +} + +/** + * Helper function to transform an encrypted payload into a PostgreSQL composite type + */ +export function encryptedToPgComposite( + obj: EncryptedPayload, +): EncryptedPgComposite { + return { + data: obj, + } +} + +/** + * Helper function to transform a model's encrypted fields into PostgreSQL composite types + */ +export function modelToEncryptedPgComposites>( + model: T, +): T { + const result: Record = {} + + for (const [key, value] of Object.entries(model)) { + if (isEncryptedPayload(value)) { + result[key] = encryptedToPgComposite(value) + } else { + result[key] = value + } + } + + return result as T +} + +/** + * Helper function to transform multiple models' encrypted fields into PostgreSQL composite types + */ +export function bulkModelsToEncryptedPgComposites< + T extends Record, +>(models: T[]): T[] { + return models.map((model) => modelToEncryptedPgComposites(model)) +} + +/** + * Helper function to check if a value is an encrypted payload + */ +export function isEncryptedPayload(value: unknown): value is EncryptedPayload { + 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 false +} diff --git a/packages/protect/src/index.ts b/packages/protect/src/index.ts index b50893e1..7b5dbbce 100644 --- a/packages/protect/src/index.ts +++ b/packages/protect/src/index.ts @@ -40,5 +40,6 @@ export const protect = async ( export type { Result } from '@byteslice/result' export type { ProtectClient } from './ffi' export { csTable, csColumn } from './schema' +export * from './helpers' export * from './identify' export * from './types' diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts index 8472a059..d621c4d1 100644 --- a/packages/protect/src/types.ts +++ b/packages/protect/src/types.ts @@ -1,4 +1,4 @@ -import type { newClient } from '@cipherstash/protect-ffi' +import type { newClient, Encrypted } from '@cipherstash/protect-ffi' import type { EqlSchema } from './eql.schema' import type { ProtectTableColumn } from './schema' import type { ProtectTable } from './schema' @@ -12,9 +12,7 @@ export type Client = Awaited> | undefined /** * Represents an encrypted payload in the database */ -export type EncryptedPayload = { - data: EqlSchema | null -} +export type EncryptedPayload = Encrypted | null /** * Represents a payload to be encrypted using the `encrypt` function diff --git a/packages/utils/config/index.ts b/packages/utils/config/index.ts index 2343b00e..ff53de60 100644 --- a/packages/utils/config/index.ts +++ b/packages/utils/config/index.ts @@ -3,76 +3,87 @@ import path from 'node:path' /** * A lightweight function that parses a TOML-like string - * and returns the `workspace_id` value found under `[auth]`. + * and returns the `workspace_crn` value found under `[auth]`. * * @param tomlString The contents of the TOML file as a string. - * @returns The workspace_id if found, otherwise undefined. + * @returns The workspace_crn if found, otherwise undefined. */ -function getWorkspaceId(tomlString: string): string | undefined { +function getWorkspaceCrn(tomlString: string): string | undefined { let currentSection = '' - let workspaceId: string | undefined + let workspaceCrn: string | undefined - // Split the file contents into individual lines const lines = tomlString.split(/\r?\n/) for (const line of lines) { const trimmedLine = line.trim() - // Skip empty or comment lines if (!trimmedLine || trimmedLine.startsWith('#')) { continue } - // Check if the line defines a section: e.g. [auth] const sectionMatch = trimmedLine.match(/^\[([^\]]+)\]$/) if (sectionMatch) { currentSection = sectionMatch[1] continue } - // Check if the line defines a key-value pair: e.g. workspace_id = "ABC123" const kvMatch = trimmedLine.match(/^(\w+)\s*=\s*"([^"]+)"$/) if (kvMatch) { const [_, key, value] = kvMatch - // We only care about `workspace_id` under `[auth]` - if (currentSection === 'auth' && key === 'workspace_id') { - workspaceId = value - // We can stop searching once we find it + if (currentSection === 'auth' && key === 'workspace_crn') { + workspaceCrn = value break } } } - return workspaceId + return workspaceCrn +} + +/** + * Extracts the workspace ID from a CRN string. + * CRN format: crn:region.aws:ID + * + * @param crn The CRN string to extract from + * @returns The workspace ID portion of the CRN + */ +function extractWorkspaceIdFromCrn(crn: string): string { + const match = crn.match(/crn:[^:]+:([^:]+)$/) + if (!match) { + throw new Error('Invalid CRN format') + } + return match[1] } export function loadWorkSpaceId(): string { const configPath = path.join(process.cwd(), 'cipherstash.toml') - if (!fs.existsSync(configPath) && !process.env.CS_WORKSPACE_ID) { + if (!fs.existsSync(configPath) && !process.env.CS_WORKSPACE_CRN) { throw new Error( - 'You have not defined a workspace ID in your config file, or the CS_WORKSPACE_ID environment variable.', + 'You have not defined a workspace CRN in your config file, or the CS_WORKSPACE_CRN environment variable.', ) } // Environment variables take precedence over config files - if (process.env.CS_WORKSPACE_ID) return process.env.CS_WORKSPACE_ID + if (process.env.CS_WORKSPACE_CRN) { + return extractWorkspaceIdFromCrn(process.env.CS_WORKSPACE_CRN) + } if (!fs.existsSync(configPath)) { throw new Error( - 'You have not defined a workspace ID in your config file, or the CS_WORKSPACE_ID environment variable.', + 'You have not defined a workspace CRN in your config file, or the CS_WORKSPACE_CRN environment variable.', ) } const tomlString = fs.readFileSync(configPath, 'utf8') - const workspaceId = getWorkspaceId(tomlString) + const workspaceCrn = getWorkspaceCrn(tomlString) - if (!workspaceId) { + if (!workspaceCrn) { throw new Error( - 'You have not defined a workspace ID in your config file, or the CS_WORKSPACE_ID environment variable.', + 'You have not defined a workspace CRN in your config file, or the CS_WORKSPACE_CRN environment variable.', ) } - return workspaceId + return extractWorkspaceIdFromCrn(workspaceCrn) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cf27caa..7865a9eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,8 +244,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@cipherstash/protect-ffi': - specifier: 0.12.0 - version: 0.12.0 + specifier: 0.14.0 + version: 0.14.0 zod: specifier: ^3.24.2 version: 3.24.2 @@ -254,6 +254,9 @@ importers: specifier: 4.24.0 version: 4.24.0 devDependencies: + '@supabase/supabase-js': + specifier: ^2.47.10 + version: 2.47.12 dotenv: specifier: ^16.4.7 version: 16.4.7 @@ -401,33 +404,33 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/protect-ffi-darwin-arm64@0.12.0': - resolution: {integrity: sha512-Oh7A9Mbn17QDHLLbugbT5bo/hZRrtgzcJZBTvXNTQpW9FtNmscCz1Wn79CJX+IhpdVrVxIQk7GOnG7JhTxJ/JA==} + '@cipherstash/protect-ffi-darwin-arm64@0.14.0': + resolution: {integrity: sha512-CmXaGc1OBKN6vEN55bvAsfnUkYrpfoR3pCASdExdvtc4nQC34JW95Tjl7nmrQUpvhbiK98b50aWYur3gD23pLA==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.12.0': - resolution: {integrity: sha512-1UkwOm5lUukIFPXYaI1KvlBy+WafMGX0qw5lYBq1ybFMOhQgAdCloRxON60yM1aEM6MCWgqw6J6eQbisLms/Ow==} + '@cipherstash/protect-ffi-darwin-x64@0.14.0': + resolution: {integrity: sha512-J96Uu3SSv7er8803muu1jvhiJ/YJMrVYyYizA8VEJ8JHne+fpbryk6mNF7fgRE5T/XoM1Y0M3UDlC3yUHB6F0A==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.12.0': - resolution: {integrity: sha512-zIOQoAgWXqPXE+wXQwiTu0+GNkLvXhN9US49nKezL2P+X97PuzZd0XaL+e5AXuDdrt5wC1WjUDvKvP1cQpNYPg==} + '@cipherstash/protect-ffi-linux-arm64-gnu@0.14.0': + resolution: {integrity: sha512-LMWYBYeLqkEGpGR8Ro85MXSHm1Snuz3TcMmrDchH4+6+3RTXQyCQt/Pdr3rp8yN7iZU8nnXdSQTx862knvKG8g==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.12.0': - resolution: {integrity: sha512-wSJPi+pNR9+7DgcLZz/6V1VZAyejeEByIh+URfuRDaqbv8oxhh957WPfZTrWZXP5paIhXEdzkH62g6rnAxKaiQ==} + '@cipherstash/protect-ffi-linux-x64-gnu@0.14.0': + resolution: {integrity: sha512-3RAxcl9DoebbGIaJEJfPJylYQEfERc0jOkKc3+FoYHYadM6btih+cSowGl6T8TLuAMyC/S4d8VZuPbs3B/i5eg==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.12.0': - resolution: {integrity: sha512-45icvAH+3QUaZVzCkpz4Xb8+EZ4jcQs0pd9Cvk6CMX4srkzdzGcN61NaEJmcWmYaq4XQqGSHYQi9afznpfvJow==} + '@cipherstash/protect-ffi-win32-x64-msvc@0.14.0': + resolution: {integrity: sha512-qk4YA7mEVfzTmg2wTV7jz3jPSsP/Uu7/yfEiAbteEV0XnEJ34KMA2oy5fphKCzBSOwrq+2V6J1mlJDZ7SBCLww==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.12.0': - resolution: {integrity: sha512-h1EdM+erAWBCy37jq4XExTQY4Pheg+wqLRyBGDRkS9vgxOQTt2g4TquqYVaS+aFZsYE1pMw76aSJNdca9sP2jg==} + '@cipherstash/protect-ffi@0.14.0': + resolution: {integrity: sha512-UVijp/c/kuCCbACN5hA5QhXzur6eWM/mh6L0JCYWaDpUmxdFFnD3/FsE51bWLx4Sqh+/bAUUGCkp36ypLkZfJQ==} '@clerk/backend@1.24.0': resolution: {integrity: sha512-DlOZ9pnCY77ngHKFZzC7ZImHBVjMf2whPLvnnBt4YXjkvuQ3m1v1tQHUXb8qqlwilptHU4/WzkOlXytez+iJ+A==} @@ -3557,30 +3560,30 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@cipherstash/protect-ffi-darwin-arm64@0.12.0': + '@cipherstash/protect-ffi-darwin-arm64@0.14.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.12.0': + '@cipherstash/protect-ffi-darwin-x64@0.14.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.12.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.14.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.12.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.14.0': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.12.0': + '@cipherstash/protect-ffi-win32-x64-msvc@0.14.0': optional: true - '@cipherstash/protect-ffi@0.12.0': + '@cipherstash/protect-ffi@0.14.0': dependencies: '@neon-rs/load': 0.1.82 optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.12.0 - '@cipherstash/protect-ffi-darwin-x64': 0.12.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.12.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.12.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.12.0 + '@cipherstash/protect-ffi-darwin-arm64': 0.14.0 + '@cipherstash/protect-ffi-darwin-x64': 0.14.0 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.14.0 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.14.0 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.14.0 '@clerk/backend@1.24.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: