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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/fresh-icons-fly.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/green-signs-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cipherstash/protect": minor
---

Fixed handling composite types for EQL v2.
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions docs/reference/working-with-composite-types.md
Original file line number Diff line number Diff line change
@@ -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!
4 changes: 4 additions & 0 deletions packages/protect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions packages/protect/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
},
},
},
])
})
})
})
2 changes: 1 addition & 1 deletion packages/protect/__tests__/protect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading