Skip to content

Commit 231b5df

Browse files
authored
Merge pull request #212 from cipherstash/keyset-config
feat: support selecting keysets by identifier
2 parents 6590c66 + 394efce commit 231b5df

File tree

11 files changed

+284
-64
lines changed

11 files changed

+284
-64
lines changed

.changeset/large-masks-wink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cipherstash/protect": minor
3+
---
4+
5+
Added support for multi-tenant encryption with configurable keysets.

README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ const decrypted = await client.decrypt(encrypted.data);
112112
- [Identity-aware encryption](#identity-aware-encryption)
113113
- [Supported data types](#supported-data-types)
114114
- [Searchable encryption](#searchable-encryption)
115+
- [Multi-tenant encryption](#multi-tenant-encryption)
115116
- [Logging](#logging)
116117
- [CipherStash Client](#cipherstash-client)
117118
- [Example applications](#example-applications)
@@ -1043,9 +1044,43 @@ The table below summarizes these cases.
10431044

10441045
Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs.
10451046

1047+
## Multi-tenant encryption
1048+
1049+
Protect.js supports multi-tenant encryption by using keysets.
1050+
Each keyset is cryptographically isolated from other keysets which esentially means that each tenant has their own unique keyspace.
1051+
If you are using a multi-tenant application, you can use keysets to encrypt data for each tenant creating a strong security boundary.
1052+
1053+
In the [CipherStash Dashboard](https://dashboard.cipherstash.com/workspaces/_/encryption/keysets), you can create and manage keysets and then use the keyset identifier to encrypt data for each tenant when initializing the Protect.js client.
1054+
1055+
```typescript
1056+
import { protect } from "@cipherstash/protect";
1057+
import { users } from "./protect/schema";
1058+
1059+
const protectClient = await protect({
1060+
schemas: [users],
1061+
keyset: {
1062+
// Must be a valid UUID which can be found in the CipherStash Dashboard
1063+
id: '123e4567-e89b-12d3-a456-426614174000'
1064+
},
1065+
})
1066+
1067+
// or with a keyset name
1068+
1069+
const protectClient = await protect({
1070+
schemas: [users],
1071+
keyset: {
1072+
name: 'Company A'
1073+
},
1074+
})
1075+
```
1076+
1077+
> [!IMPORTANT]
1078+
> When creating a new keyset, make sure to grant your client access to the keyset or client initialization will fail.
1079+
> Read more about [managing keyset access](https://cipherstash.com/docs/platform/workspaces/key-sets).
1080+
10461081
## Logging
10471082

1048-
> [!IMPORTANT]
1083+
> [!TIP]
10491084
> `@cipherstash/protect` will NEVER log plaintext data.
10501085
> This is by design to prevent sensitive data from leaking into logs.
10511086

mise.toml

Lines changed: 0 additions & 50 deletions
This file was deleted.

packages/protect/README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ const decrypted = await client.decrypt(encrypted.data);
112112
- [Identity-aware encryption](#identity-aware-encryption)
113113
- [Supported data types](#supported-data-types)
114114
- [Searchable encryption](#searchable-encryption)
115+
- [Multi-tenant encryption](#multi-tenant-encryption)
115116
- [Logging](#logging)
116117
- [CipherStash Client](#cipherstash-client)
117118
- [Example applications](#example-applications)
@@ -994,9 +995,43 @@ Until support for other data types are available, you can express interest in th
994995

995996
Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs.
996997

998+
## Multi-tenant encryption
999+
1000+
Protect.js supports multi-tenant encryption by using keysets.
1001+
Each keyset is cryptographically isolated from other keysets which esentially means that each tenant has their own unique keyspace.
1002+
If you are using a multi-tenant application, you can use keysets to encrypt data for each tenant creating a strong security boundary.
1003+
1004+
In the [CipherStash Dashboard](https://dashboard.cipherstash.com/workspaces/_/encryption/keysets), you can create and manage keysets and then use the keyset identifier to encrypt data for each tenant when initializing the Protect.js client.
1005+
1006+
```typescript
1007+
import { protect } from "@cipherstash/protect";
1008+
import { users } from "./protect/schema";
1009+
1010+
const protectClient = await protect({
1011+
schemas: [users],
1012+
keyset: {
1013+
// Must be a valid UUID which can be found in the CipherStash Dashboard
1014+
id: '123e4567-e89b-12d3-a456-426614174000'
1015+
},
1016+
})
1017+
1018+
// or with a keyset name
1019+
1020+
const protectClient = await protect({
1021+
schemas: [users],
1022+
keyset: {
1023+
name: 'Company A'
1024+
},
1025+
})
1026+
```
1027+
1028+
> [!IMPORTANT]
1029+
> When creating a new keyset, make sure to grant your client access to the keyset or client initialization will fail.
1030+
> Read more about [managing keyset access](https://cipherstash.com/docs/platform/workspaces/key-sets).
1031+
9971032
## Logging
9981033

999-
> [!IMPORTANT]
1034+
> [!TIP]
10001035
> `@cipherstash/protect` will NEVER log plaintext data.
10011036
> This is by design to prevent sensitive data from leaking into logs.
10021037
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import 'dotenv/config'
2+
import { csColumn, csTable } from '@cipherstash/schema'
3+
import { describe, expect, it } from 'vitest'
4+
import { protect } from '../src'
5+
6+
const users = csTable('users', {
7+
email: csColumn('email'),
8+
})
9+
10+
describe('encryption and decryption with keyset id', () => {
11+
it('should encrypt and decrypt a payload', async () => {
12+
const protectClient = await protect({
13+
schemas: [users],
14+
keyset: {
15+
id: '4152449b-505a-4186-93b6-d3d87eba7a47',
16+
},
17+
})
18+
19+
const email = 'hello@example.com'
20+
21+
const ciphertext = await protectClient.encrypt(email, {
22+
column: users.email,
23+
table: users,
24+
})
25+
26+
if (ciphertext.failure) {
27+
throw new Error(`[protect]: ${ciphertext.failure.message}`)
28+
}
29+
30+
// Verify encrypted field
31+
expect(ciphertext.data).toHaveProperty('c')
32+
33+
const a = ciphertext.data
34+
35+
const plaintext = await protectClient.decrypt(ciphertext.data)
36+
37+
expect(plaintext).toEqual({
38+
data: email,
39+
})
40+
}, 30000)
41+
})
42+
43+
describe('encryption and decryption with keyset name', () => {
44+
it('should encrypt and decrypt a payload', async () => {
45+
const protectClient = await protect({
46+
schemas: [users],
47+
keyset: {
48+
name: 'Test',
49+
},
50+
})
51+
52+
const email = 'hello@example.com'
53+
54+
const ciphertext = await protectClient.encrypt(email, {
55+
column: users.email,
56+
table: users,
57+
})
58+
59+
if (ciphertext.failure) {
60+
throw new Error(`[protect]: ${ciphertext.failure.message}`)
61+
}
62+
63+
// Verify encrypted field
64+
expect(ciphertext.data).toHaveProperty('c')
65+
66+
const a = ciphertext.data
67+
68+
const plaintext = await protectClient.decrypt(ciphertext.data)
69+
70+
expect(plaintext).toEqual({
71+
data: email,
72+
})
73+
}, 30000)
74+
})
75+
76+
describe('encryption and decryption with invalid keyset id', () => {
77+
it('should throw an error', async () => {
78+
await expect(
79+
protect({
80+
schemas: [users],
81+
keyset: {
82+
id: 'invalid-uuid',
83+
},
84+
}),
85+
).rejects.toThrow(
86+
'[protect]: Invalid UUID provided for keyset id. Must be a valid UUID.',
87+
)
88+
})
89+
})

packages/protect/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
},
5656
"dependencies": {
5757
"@byteslice/result": "^0.2.0",
58-
"@cipherstash/protect-ffi": "0.17.1",
58+
"@cipherstash/protect-ffi": "0.18.1",
5959
"@cipherstash/schema": "workspace:*",
6060
"zod": "^3.24.2"
6161
},

packages/protect/src/ffi/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import {
99
import { type ProtectError, ProtectErrorTypes } from '..'
1010
import { loadWorkSpaceId } from '../../../utils/config'
1111
import { logger } from '../../../utils/logger'
12+
import { toFfiKeysetIdentifier } from '../helpers'
1213
import type {
1314
BulkDecryptPayload,
1415
BulkEncryptPayload,
1516
Client,
1617
Decrypted,
1718
EncryptOptions,
1819
Encrypted,
20+
KeysetIdentifier,
1921
SearchTerm,
2022
} from '../types'
2123
import { BulkDecryptOperation } from './operations/bulk-decrypt'
@@ -49,6 +51,7 @@ export class ProtectClient {
4951
accessKey?: string
5052
clientId?: string
5153
clientKey?: string
54+
keyset?: KeysetIdentifier
5255
}): Promise<Result<ProtectClient, ProtectError>> {
5356
return await withResult(
5457
async () => {
@@ -70,6 +73,7 @@ export class ProtectClient {
7073
accessKey: config.accessKey,
7174
clientId: config.clientId,
7275
clientKey: config.clientKey,
76+
keyset: toFfiKeysetIdentifier(config.keyset),
7377
},
7478
})
7579

packages/protect/src/helpers/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { Encrypted } from '../types'
1+
import type { KeysetIdentifier as KeysetIdentifierFfi } from '@cipherstash/protect-ffi'
2+
import type { Encrypted, KeysetIdentifier } from '../types'
23

34
export type EncryptedPgComposite = {
45
data: Encrypted
@@ -41,6 +42,18 @@ export function bulkModelsToEncryptedPgComposites<
4142
return models.map((model) => modelToEncryptedPgComposites(model))
4243
}
4344

45+
export function toFfiKeysetIdentifier(
46+
keyset: KeysetIdentifier | undefined,
47+
): KeysetIdentifierFfi | undefined {
48+
if (!keyset) return undefined
49+
50+
if ('name' in keyset) {
51+
return { Name: keyset.name }
52+
}
53+
54+
return { Uuid: keyset.id }
55+
}
56+
4457
/**
4558
* Helper function to check if a value is an encrypted payload
4659
*/

packages/protect/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema'
22
import { buildEncryptConfig } from '@cipherstash/schema'
33
import { ProtectClient } from './ffi'
4+
import type { KeysetIdentifier } from './types'
45

56
export const ProtectErrorTypes = {
67
ClientInitError: 'ClientInitError',
@@ -23,6 +24,13 @@ export type ProtectClientConfig = {
2324
accessKey?: string
2425
clientId?: string
2526
clientKey?: string
27+
keyset?: KeysetIdentifier
28+
}
29+
30+
function isValidUuid(uuid: string): boolean {
31+
const uuidRegex =
32+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
33+
return uuidRegex.test(uuid)
2634
}
2735

2836
export const protect = async (
@@ -36,11 +44,22 @@ export const protect = async (
3644
)
3745
}
3846

47+
if (
48+
config.keyset &&
49+
'id' in config.keyset &&
50+
!isValidUuid(config.keyset.id)
51+
) {
52+
throw new Error(
53+
'[protect]: Invalid UUID provided for keyset id. Must be a valid UUID.',
54+
)
55+
}
56+
3957
const clientConfig = {
4058
workspaceCrn: config.workspaceCrn,
4159
accessKey: config.accessKey,
4260
clientId: config.clientId,
4361
clientKey: config.clientKey,
62+
keyset: config.keyset,
4463
}
4564

4665
const client = new ProtectClient(clientConfig.workspaceCrn)

packages/protect/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ export type SearchTerm = {
4242
returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal'
4343
}
4444

45+
export type KeysetIdentifier =
46+
| {
47+
name: string
48+
}
49+
| {
50+
id: string
51+
}
52+
4553
/**
4654
* The return type of the search term based on the return type specified in the `SearchTerm` type
4755
* If the return type is `eql`, the return type is `Encrypted`

0 commit comments

Comments
 (0)