Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
edfcbe1
feat(protect): add initial searchable encryption functionality
calvinbrewer Mar 3, 2025
be55d3f
feat(app-drizzle): add new encrypt config
calvinbrewer Mar 3, 2025
0574b0c
Add compose file with service for PG
CDThomas Mar 3, 2025
b2c271b
Add example of EQL setup with mise
CDThomas Mar 3, 2025
68f13a7
Add EQL sql scripts to .gitignore
CDThomas Mar 3, 2025
1582e1c
Start example of search on encrypted column
CDThomas Mar 3, 2025
932e06b
Working search example with equality on email
CDThomas Mar 3, 2025
c70f9d2
Add examples of matching and sorting to apps/drizzle
CDThomas Mar 4, 2025
bd4c6b9
Fix up error message in select.ts in Drizzle example
CDThomas Mar 4, 2025
86ad204
Merge pull request #95 from cipherstash/feat/add-search-support
calvinbrewer Mar 4, 2025
825cc8f
Merge branch 'main' of https://github.com/cipherstash/protectjs into …
calvinbrewer Mar 4, 2025
ad827cf
feat(protect): implement a protect schema strategy
calvinbrewer Mar 4, 2025
c2d2f9b
fix(protect): bulk encryption with schema
calvinbrewer Mar 4, 2025
9133861
Enter prerelease mode and version packages
calvinbrewer Mar 4, 2025
43e1acb
Exit prerelease
calvinbrewer Mar 4, 2025
ca7e8d3
fix: hot fix for prerelease
calvinbrewer Mar 4, 2025
dde8203
feat(protect): add eql data types
calvinbrewer Mar 5, 2025
2ecd0a3
chore: update lock file
calvinbrewer Mar 5, 2025
b75cf97
docs(branding): improve heading in the main README
calvinbrewer Mar 5, 2025
ee78a1f
build(protect): bump ffi to 0.12.0
calvinbrewer Mar 5, 2025
b2d4163
docs(schema): add initial schema docs
calvinbrewer Mar 5, 2025
f456d1e
docs: add schema to root docs readme
calvinbrewer Mar 5, 2025
54e54d7
docs: add coming soon for searchable encryption
calvinbrewer Mar 5, 2025
c2b1871
docs(configuration): correct file name
calvinbrewer Mar 5, 2025
393f311
docs: clean up supported data types
calvinbrewer Mar 5, 2025
32ca245
docs: update header image
calvinbrewer Mar 5, 2025
414a080
docs: add structure and searchable encryption concepts
calvinbrewer Mar 6, 2025
26691d3
docs(getting-started): add note about clerk support for lock contexts
calvinbrewer Mar 6, 2025
b980fbf
docs(reference): add searchable encryption reference
calvinbrewer Mar 6, 2025
7b7f308
feat(protect): rename orderAndSort to orderAndRange
calvinbrewer Mar 6, 2025
5ae2d9c
docs(searchable-encryption): improve readability of concept
calvinbrewer Mar 6, 2025
fea3a67
docs: update table of contents
calvinbrewer Mar 6, 2025
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
7 changes: 7 additions & 0 deletions .changeset/young-waves-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cipherstash/protect": minor
---

* Added support for searching encrypted data
* Added a schema strategy for defining your schema
* Required schema to initialize the protect client
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ mise.local.toml
# cipherstash
cipherstash.toml
cipherstash.secret.toml
sql/cipherstash-*.sql
21 changes: 21 additions & 0 deletions CONTRIBUTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,27 @@ We use [**Changesets**](https://github.com/changesets/changesets) to manage vers
- Follow the prompts to indicate the type of version bump (patch, minor, major).
- The [GitHub Actions](./.github/workflows/) (or other CI pipeline) will handle the **publish** step to npm once your PR is merged and the changeset is committed to `main`.

## Pre release process
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thanks for documenting this (and for wiring up pre releases)


We currently use [changesets to manage pre-releasing](https://github.com/changesets/changesets/blob/main/docs/prereleases.md) the `next` version of the package, and the process is executed manually.

To do so, you need to:

1. Check out the `next` branch
2. Run `pnpm changeset pre enter next`
3. Run `pnpm changeset version`
4. Run `git add .`
5. Run `git commit -m "Enter prerelease mode and version packages"`
6. Run `pnpm changeset publish --tag next`
7. Run `git push --follow-tags`

When you are ready to release, you can run `pnpm changeset pre exit` to exit prerelease mode and commit the changes.
When you merge the PR, the `next` branch will be merged into `main`, and the package will be published to npm without the prerelease tag.

> [!IMPORTANT]
> This process can be dangerous, so please be careful when using it as it's difficult to undo mistakes.
> If you are unfamiliar with the process, please reach out to the maintainers for help.

## Additional Resources

- [Turborepo Documentation](https://turbo.build/repo/docs)
Expand Down
174 changes: 136 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
# Protect.js
<h1 align="center">
<img alt="CipherStash Logo" loading="lazy" width="200" height="60" decoding="async" data-nimg="1" style="color:transparent" src="https://cipherstash.com/assets/cs-github.png">
</br>

[![Tests](https://github.com/cipherstash/protectjs/actions/workflows/tests.yml/badge.svg)](https://github.com/cipherstash/protectjs/actions/workflows/tests.yml)
[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why remove this? I've been putting this on all our repos. Looks better than the non-transparent logo IMHO.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added it above the fold!

Protect.js</h1>
<p align="center">
Implement robust data security without sacrificing performance or usability
<br/>
<a href="https://cipherstash.com">Built by CipherStash</a>
</p>
<br/>

Protect.js is a JavaScript/TypeScript package for encrypting and decrypting data in PostgreSQL databases.
Encryption operations happen directly in your app, and the ciphertext is stored in your PostgreSQL database.
<!-- start -->

Every value you encrypt with Protect.js has a unique key, made possible by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms)'s blazing fast bulk key operations.
Under the hood Protect.js uses CipherStash [Encrypt Query Language (EQL)](https://github.com/cipherstash/encrypt-query-language), and all ZeroKMS data keys are backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html).
## What's Protect.js?

Protect.js is a TypeScript package for encrypting and decrypting data.
Encryption operations happen directly in your app, and the ciphertext is stored in your database.

Every value you encrypt with Protect.js has a unique key, made possible by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms)'s blazing fast bulk key operations, and backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html).

The 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 only supported in PostgreSQL at the moment.
> Read more about [searching encrypted data](./docs/searchable-encryption.md).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that changing the phrasing around Postgres support makes it less clear which databases we do actually support. Previously it sounded like Postgres was the only database supported, but now it's ambiguous. Not blocking for this PR, but I think we'll want to clarify which databases we support/plan on supporting and for which features.


## Table of contents

Expand All @@ -29,7 +45,7 @@ For more specific documentation, please refer to the [docs](https://github.com/c

## Features

Protect.js protects data in PostgreSQL databases using industry-standard AES encryption.
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.

Expand All @@ -38,6 +54,8 @@ This enables every encrypted value, in every column, in every row in your databa
- **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:**
Expand All @@ -54,7 +72,7 @@ Check out the example applications:
- [Drizzle example](/apps/drizzle) demonstrates how to use Protect.js with an ORM
- [Next.js and lock contexts example using Clerk](/apps/nextjs-clerk) demonstrates how to protect data with identity-aware encryption

`@cipherstash/protect` can be used with most ORMs that support PostgreSQL.
`@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).

## Installing Protect.js
Expand Down Expand Up @@ -117,31 +135,84 @@ At the end of `stash setup`, you will have two files in your project:

You can read more about [configuration via toml file or environment variables here](./docs/configuration.md).

### Initializing the EQL client
### Basic file structure

In your application, import the `protect` function from the `@cipherstash/protect` package, and initialize a client with your CipherStash credentials.
This is the basic file structure of the project. In the `src/protect` directory, we have table definition in `schema.ts` and the protect client in `index.ts`.

```typescript
const { protect } = require('@cipherstash/protect')
const protectClient = await protect()
```
📦 <project root>
├ 📂 src
│ ├ 📂 protect
│ │ ├ 📜 index.ts
│ │ └ 📜 schema.ts
│ └ 📜 index.ts
├ 📜 .env
├ 📜 cipherstash.toml
├ 📜 cipherstash.secret.toml
├ 📜 package.json
└ 📜 tsconfig.json
```

If you are using ES6:
### Defining your schema

```typescript
Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt.

In the `src/protect/schema.ts` file, you can define your tables and columns.

```ts
import { csTable, csColumn } from '@cipherstash/protect'

export const users = csTable('users', {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I mentioned in our call, I think the term csTable is confusing because (to me at least) it feels like this is defining the table. But what it's actually doing is protecting it.
Given the name of the library is Protect.js, why don't we call this protect?

export const users = protect.table('users', {
  email: protect.column('email'),
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although we've used protect to load the protect client. So how about this:

import { protected } from '@cipherstash/protect'

export const users = protected.table('users', {
  email: protected.column('email'),
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point! Here are my thoughts:

  • Using csTable and csColumn bring some CipherStash branding into the picture to tie Protect.js and CipherStash together.
  • Replicates building our your database schema, which in a way is kinda what we are doing. I actually really love how it replicates the Drizzle experience.
  • csTable is a much more JS way of doing things in this specific manner compared to protected.table

Copy link
Copy Markdown
Contributor Author

@calvinbrewer calvinbrewer Mar 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another reason I really like the csTable and csColumn approach is because it is definitely more tied to the database than the core of Protect.js as it's bridging the gap between EQL, CipherStash, and the interface.

Another point on that is when we build out ORM specific examples. E.g.

This is how you do equality with Drizzle today

import { eq } from "drizzle-orm";
import { integer, pgTable, varchar } from "drizzle-orm/pg-core";

export const users = pgTable('users', {
  id: integer(),
  email: varchar('email')
})

db.select().from(table).where(eq(users.email, "test@example.com"));

To maintain consistency with Drizzle, TypeORM, and any other ORM we interface with it'd look like this and be an awesome dev ex

import { csEq } from "@cipherstash/drizzle-orm"
import { csTable, csColumn } from "@cipherstash/protect"

export cost protectedUsers = csTable('users', {
  email: csColumn('email').equality()
})

const searchTerm = protectClient.encrypt("test@example.com", {
  table: protectedUsers,
  column: protectedUsers.email
})

db.select().from(table).where(csEq(protectedUsers.email, searchTerm.data));

email: csColumn('email'),
})

export const orders = csTable('orders', {
address: csColumn('address'),
})
```

**Searchable encryption**

If you are looking to enable searchable encryption in a PostgreSQL database, you must declaratively enable the indexes in your schema.

```ts
import { csTable, csColumn } from '@cipherstash/protect'

export const users = csTable('users', {
email: csColumn('email').freeTextSearch().equality().orderAndSort(),
})
```

Read more about [defining your schema here](./docs/schema.md).

### Initializing the EQL client

To initialize the protect client, import the `protect` function and initialize a client with your defined schema.

In the `src/protect/index.ts` file:

```ts
import { protect } from '@cipherstash/protect'
const protectClient = await protect()
import { users } from './schema'

// Pass all your tables to the protect function to initialize the client
export const protectClient = await protect(users, orders)
```

The `protect` function requires at least one `csTable` to be passed in.

### Encrypting data

Use the `encrypt` function to encrypt data.
`encrypt` takes a plaintext string, and an object with the table and column name as parameters.
`encrypt` takes a plaintext string, and an object with the table and column as parameters.

```typescript
import { users } from './protect/schema'
import { protectClient } from './protect'

const encryptResult = await protectClient.encrypt('secret@squirrel.example', {
column: 'email',
table: 'users',
column: users.email,
table: users,
Comment on lines +214 to +215
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the schema already knows what column and table are being protected, can we set the EQL identity (ie. column and table) automatically for the user? Its a bit of a pain having to provide these values every time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think being declarative in this instance is a good idea

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There could definitely be some room for improvement here but without major refactoring it'd be difficult to do that

})

if (encryptResult.failure) {
Expand Down Expand Up @@ -177,9 +248,11 @@ The `encryptResult` will return one of the following:
### Decrypting data

Use the `decrypt` function to decrypt data.
`decrypt` takes an encrypted data object, and an object with the lock context as parameters.
`decrypt` takes an encrypted data object as a parameter.

```typescript
import { protectClient } from './protect'

const decryptResult = await protectClient.decrypt(ciphertext)

if (decryptResult.failure) {
Expand Down Expand Up @@ -212,7 +285,9 @@ The `decryptResult` will return one of the following:

### Storing encrypted data in a database

To store the encrypted data in PostgreSQL, you will need to specify the column type as `jsonb`.
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, you will need to specify the column type as `jsonb`.

```sql
CREATE TABLE users (
Expand All @@ -221,6 +296,31 @@ CREATE TABLE users (
);
```

**Searchable encryption**

To enable searchable encryption in PostgreSQL, you need to [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
```

2. Run this command to install the custom types and functions:

```sh
psql -f cipherstash-encrypt.sql
```

EQL is now installed in your database and you can enable searchable encryption by adding the `cs_encrypted_v1` type to a column.

```sql
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email cs_encrypted_v1
);
```

## Identity-aware encryption

Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption.
Expand Down Expand Up @@ -270,9 +370,12 @@ const lockContext = identifyResult.data
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: 'email',
table: users,
column: users.email,
}).withLockContext(lockContext)

if (encryptResult.failure) {
Expand All @@ -287,6 +390,8 @@ const ciphertext = encryptResult.data
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(ciphertext).withLockContext(lockContext)

if (decryptResult.failure) {
Expand Down Expand Up @@ -338,8 +443,8 @@ const encryptedValues = encryptedResults.data

// encryptedValues might look like:
// [
// { c: 'ENCRYPTED_VALUE_1', id: '1' },
// { c: 'ENCRYPTED_VALUE_2', id: '2' },
// { encryptedData: { c: 'ENCRYPTED_VALUE_1', k: 'ct' }, id: '1' },
// { encryptedData: { c: 'ENCRYPTED_VALUE_2', k: 'ct' }, id: '2' },
// ]
```

Expand All @@ -350,7 +455,7 @@ encryptedValues.forEach((result) => {
// Find the corresponding user
const user = users.find((u) => u.id === result.id)
if (user) {
user.email = result.c // Store ciphertext back into the user object
user.email = result.encryptedData // Store the encrypted data back into the user object
}
})
```
Expand Down Expand Up @@ -410,21 +515,14 @@ Learn more about [bulk decryption](./docs/bulk-encryption-decryption.md#bulk-dec

## Supported data types

`@cipherstash/protect` currently supports encrypting and decrypting text.
Other data types like booleans, dates, ints, floats, and JSON are extremely well supported in other CipherStash products, and will be coming to `@cipherstash/protect`.
Until support for other data types are available in `@cipherstash/protect`, you can:
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.

- Read [about how these data types work in EQL](https://github.com/cipherstash/encrypt-query-language/blob/main/docs/reference/INDEX.md)
- Express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/48).
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

`@cipherstash/protect` does not currently support searching encrypted data.
Searchable encryption is an extremely well supported capability in other CipherStash products, and will be coming to `@cipherstash/protect`.
Until searchable encryption support is released in `@cipherstash/protect`, you can:

- Read [about how searchable encryption works in EQL](https://github.com/cipherstash/encrypt-query-language)
- Express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/protectjs/issues/46).
Read more about [searching encrypted data](./docs/searchable-encryption.md) in the docs.

## Logging

Expand Down
27 changes: 13 additions & 14 deletions apps/basic/index.mjs → apps/basic/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
import 'dotenv/config'
import { protect } from '@cipherstash/protect'
import readline from 'node:readline';
import { protectClient, users } from './protect'
import readline from 'node:readline'

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
})

const askQuestion = () => {
const askQuestion = (): Promise<string> => {
return new Promise((resolve) => {
rl.question("\n👋Hello\n\nWhat is your name? ", (answer) => {
resolve(answer);
});
});
};
rl.question('\n👋Hello\n\nWhat is your name? ', (answer) => {
resolve(answer)
})
})
}

async function main() {
const protectClient = await protect()
const input = await askQuestion();
const input = await askQuestion()

const encryptResult = await protectClient.encrypt(input, {
column: 'column_name',
table: 'users',
column: users.name,
table: users,
})

if (encryptResult.failure) {
Expand All @@ -44,7 +43,7 @@ async function main() {
console.log('Decrypting the ciphertext...')
console.log('The plaintext is:', plaintext)

rl.close();
rl.close()
}

main()
4 changes: 3 additions & 1 deletion apps/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
"version": "1.0.3",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs"
"start": "tsx index.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@cipherstash/protect": "workspace:*",
"typescript": "^5.0.0",
"tsx": "^4.19.2",
"dotenv": "^16.4.7"
}
}
Loading