diff --git a/.changeset/fresh-windows-call.md b/.changeset/fresh-windows-call.md new file mode 100644 index 00000000..160cc7b0 --- /dev/null +++ b/.changeset/fresh-windows-call.md @@ -0,0 +1,8 @@ +--- +"@cipherstash/jseql": major +--- + +Enforced lock context to be called as a proto function rather than an optional argument for crypto functions. +There was a bug that caused the lock context to be interpreted as undefined when the users intention was to use it causing the encryption/decryption to fail. +This is a breaking change for users who were using the lock context as an optional argument. +To use the lock context, call the `withLockContext` method on the encrypt, decrypt, and bulk encrypt/decrypt functions, passing the lock context as a parameter rather than as an optional argument. diff --git a/.changeset/strange-bees-hope.md b/.changeset/strange-bees-hope.md new file mode 100644 index 00000000..c7abb0b1 --- /dev/null +++ b/.changeset/strange-bees-hope.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/nextjs": major +--- + +Implemented better error handling for fetching CTS tokens and accessing them in the Next.js application. diff --git a/README.md b/README.md index 77e7a505..474e3bcb 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,10 @@ The `decrypt` function returns a string with the plaintext data. ### Lock context +> [!CAUTION] +> If you use a lock context to encrypt data, you must also use the same lock context to decrypt the data. +> Otherwise, you will receive a `400` error from ZeroKMS indicating that the request was unable to generate a data key, and you will be unable to decrypt the data. + `jseql` supports lock contexts to ensure that only the intended users can access sensitive data. To use a lock context, initialize a `LockContext` object with the identity claims. @@ -162,7 +166,34 @@ const lc = new LockContext() ``` > [!NOTE] -> At the time of this writing, the default LockContext is set to use the `sub` Identity Claim, as this is the only Identity Claim that is currently supported. +> When initializing a `LockContext` the default context is set to use the `sub` Identity Claim. + +**Custom context** + +If you want to override the default context, you can pass a custom context to the `LockContext` constructor. + +```typescript +import { LockContext } from '@cipherstash/jseql/identify' + +// eqlClient from the previous steps +const lc = new LockContext({ + context: { + identityClaim: ['sub'], // this is the default context + }, +}) +``` + +**Context and identity claim options** + +The context object contains an `identityClaim` property. +The `identityClaim` property must be an array of strings that correspond to the Identity Claim(s) you want to lock the encryption operation to. + +Currently supported Identity Claims are: + +| Identity Claim | Description | +| -------------- | ----------- | +| `sub` | The user's subject identifier. | +| `scopes` | The user's scopes set by your IDP policy. | #### Identifying the user @@ -200,6 +231,8 @@ export default clerkMiddleware(async (auth, req: NextRequest) => { }) ``` +#### Retrieving the CTS token in Next.js + You can then use the `getCtsToken` function to retrieve the CTS token for the current user session. ```typescript @@ -240,24 +273,21 @@ export default async function Page() { ### Encrypting data with a lock context -To encrypt data with a lock context, pass the lock context object as a parameter to the `encrypt` function. +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 const ciphertext = await eqlClient.encrypt('plaintext', { table: 'users', column: 'email', - lockContext, -}) +}).withLockContext(lockContext) ``` ### Decrypting data with a lock context -To decrypt data with a lock context, pass the lock context object as a parameter to the `decrypt` function. +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 -const plaintext = await eqlClient.decrypt(ciphertext, { - lockContext, -}) +const plaintext = await eqlClient.decrypt(ciphertext).withLockContext(lockContext) ``` ### Storing encrypted data in a database @@ -283,8 +313,14 @@ If you have a large list of items to encrypt or decrypt, you can use the **`bulk const encryptedResults = await eqlClient.bulkEncrypt(plaintextsToEncrypt, { column: 'email', table: 'Users', - // lockContext: someLockContext, // if you have one }) + +// or with lock context + +const encryptedResults = await eqlClient.bulkEncrypt(plaintextsToEncrypt, { + column: 'email', + table: 'Users', +}).withLockContext(lockContext) ``` **Parameters** @@ -306,11 +342,6 @@ const encryptedResults = await eqlClient.bulkEncrypt(plaintextsToEncrypt, { - **Description**: The name of the table you’re encrypting data in (e.g., "Users"). -4. **`lockContext`** (optional) - - **Type**: `LockContext` - - **Description**: - Additional metadata and tokens for secure encryption/decryption. If not provided, encryption proceeds without a lock context. - ### Return Value - **Type**: `Promise | null>` @@ -338,7 +369,6 @@ const plaintextsToEncrypt = users.map((user) => ({ const encryptedResults = await bulkEncrypt(plaintextsToEncrypt, { column: 'email', table: 'Users', - // lockContext: someLockContext, // if you have one }) // encryptedResults might look like: @@ -362,9 +392,11 @@ if (encryptedResults) { #### bulkDecrypt ```ts -const decryptedResults = await eqlClient.bulkDecrypt(encryptedPayloads, { - // lockContext: someLockContext, // if needed -}) +const decryptedResults = await eqlClient.bulkDecrypt(encryptedPayloads) + +// or with lock context + +const decryptedResults = await eqlClient.bulkDecrypt(encryptedPayloads).withLockContext(lockContext) ``` **Parameters** @@ -374,11 +406,6 @@ const decryptedResults = await eqlClient.bulkDecrypt(encryptedPayloads, { - **Description**: An array of objects containing the **ciphertext** (`c`) and the **id**. If this array is empty or `null`, the function returns `null`. -2. **`lockContext`** (optional) - - **Type**: `LockContext` - - **Description**: - Additional metadata used to securely unlock ciphertext. If not provided, decryption proceeds without it. - ### Return Value - **Type**: `Promise | null>` @@ -403,9 +430,7 @@ const encryptedPayloads = users.map((user) => ({ })) // 2) Call bulkDecrypt -const decryptedResults = await bulkDecrypt(encryptedPayloads, { - // lockContext: someLockContext, // if needed -}) +const decryptedResults = await bulkDecrypt(encryptedPayloads) // decryptedResults might look like: // [ diff --git a/biome.json b/biome.json index 1f3812bf..ef15aeb7 100644 --- a/biome.json +++ b/biome.json @@ -14,5 +14,12 @@ "quoteStyle": "single", "semicolons": "asNeeded" } + }, + "linter": { + "rules": { + "suspicious": { + "noThenProperty": "off" + } + } } } diff --git a/packages/jseql/__tests__/jseql.test.ts b/packages/jseql/__tests__/jseql.test.ts index 24f6cd76..304cd8df 100644 --- a/packages/jseql/__tests__/jseql.test.ts +++ b/packages/jseql/__tests__/jseql.test.ts @@ -111,7 +111,7 @@ describe('getPlaintext', () => { }) }) -describe('jseql-ffi', () => { +describe('encryption and decryption', () => { it('should have all required environment variables defined', () => { expect(process.env.CS_CLIENT_ID).toBeDefined() expect(process.env.CS_CLIENT_KEY).toBeDefined() @@ -144,48 +144,6 @@ describe('jseql-ffi', () => { expect(plaintext).toEqual(null) }, 30000) - - it('should encrypt and decrypt a payload with lock context', async () => { - const eqlClient = await eql() - - const lc = new LockContext() - - // TODO: implement lockContext when CTS v2 is deployed - // const lockContext = await lc.identify('users_1_jwt') - - const ciphertext = await eqlClient.encrypt('plaintext', { - column: 'column_name', - table: 'users', - }) - - const plaintext = await eqlClient.decrypt(ciphertext) - - expect(plaintext).toEqual('plaintext') - }, 30000) - - it('should encrypt with context and be unable to decrypt without correct context', async () => { - const eqlClient = await eql() - - const lc = new LockContext() - - // const lockContext = await lc.identify('users_1_jwt') - - const ciphertext = await eqlClient.encrypt('plaintext', { - column: 'column_name', - table: 'users', - }) - - const incorrectLc = new LockContext() - - // const badLockContext = await incorrectLc.identify('users_2_jwt') - - try { - await eqlClient.decrypt(ciphertext) - } catch (error) { - const e = error as Error - expect(e.message.startsWith('Failed to retrieve key')).toEqual(true) - } - }, 30000) }) describe('bulk encryption', () => { @@ -238,3 +196,93 @@ describe('bulk encryption', () => { expect(plaintexts).toEqual(null) }, 30000) }) + +// ------------------------ +// TODO get bulk Encryption/Decryption working in CI. +// These tests pass locally, given you provide a valid JWT. +// To manually test locally, uncomment the following lines and provide a valid JWT in the userJwt variable. +// ------------------------ +// const userJwt = '' +// describe('encryption and decryption with lock context', () => { +// it('should encrypt and decrypt a payload with lock context', async () => { +// const eqlClient = await eql() + +// const lc = new LockContext() +// const lockContext = await lc.identify(userJwt) + +// const ciphertext = await eqlClient +// .encrypt('plaintext', { +// column: 'column_name', +// table: 'users', +// }) +// .withLockContext(lockContext) + +// const plaintext = await eqlClient +// .decrypt(ciphertext) +// .withLockContext(lockContext) + +// expect(plaintext).toEqual('plaintext') +// }, 30000) + +// it('should encrypt with context and be unable to decrypt without context', async () => { +// const eqlClient = await eql() + +// const lc = new LockContext() +// const lockContext = await lc.identify(userJwt) + +// const ciphertext = await eqlClient +// .encrypt('plaintext', { +// column: 'column_name', +// table: 'users', +// }) +// .withLockContext(lockContext) + +// try { +// await eqlClient.decrypt(ciphertext) +// } catch (error) { +// const e = error as Error +// expect(e.message.startsWith('Failed to retrieve key')).toEqual(true) +// } +// }, 30000) + +// it('should bulk encrypt and decrypt a payload with lock context', async () => { +// const eqlClient = await eql() + +// const lc = new LockContext() +// const lockContext = await lc.identify(userJwt) + +// const ciphertexts = await eqlClient +// .bulkEncrypt( +// [ +// { +// plaintext: 'test', +// id: '1', +// }, +// { +// plaintext: 'test2', +// id: '2', +// }, +// ], +// { +// table: 'users', +// column: 'column_name', +// }, +// ) +// .withLockContext(lockContext) + +// const plaintexts = await eqlClient +// .bulkDecrypt(ciphertexts) +// .withLockContext(lockContext) + +// expect(plaintexts).toEqual([ +// { +// plaintext: 'test', +// id: '1', +// }, +// { +// plaintext: 'test2', +// id: '2', +// }, +// ]) +// }, 30000) +// }) diff --git a/packages/jseql/src/ffi/index.ts b/packages/jseql/src/ffi/index.ts index 3c9dd3a2..5447f4d7 100644 --- a/packages/jseql/src/ffi/index.ts +++ b/packages/jseql/src/ffi/index.ts @@ -1,267 +1,634 @@ import { newClient, - decrypt, - encrypt, - encryptBulk, - decryptBulk, + encrypt as ffiEncrypt, + decrypt as ffiDecrypt, + encryptBulk as ffiEncryptBulk, + decryptBulk as ffiDecryptBulk, } from '@cipherstash/jseql-ffi' import { logger } from '../../../utils/logger' -import type { LockContext } from '../identify' import { checkEnvironmentVariables } from './env-check' import { - normalizeBulkDecryptPayloads, normalizeBulkEncryptPayloads, + normalizeBulkDecryptPayloads, + normalizeBulkEncryptPayloadsWithLockContext, + normalizeBulkDecryptPayloadsWithLockContext, } from './payload-helpers' +import type { LockContext } from '../identify' + +// ------------------------ +// Type Definitions +// ------------------------ +export type EncryptPayload = string | null + +export type EncryptedPayload = { c: string } | null + +export type BulkEncryptPayload = { + plaintext: string + id: string +}[] + +export type BulkEncryptedData = + | { + c: string + id: string + }[] + | null + +export type BulkDecryptedData = + | ({ + plaintext: string + id: string + } | null)[] + | null + +export type EncryptOptions = { + column: string + table: string +} type Client = Awaited> | undefined +// ------------------------ +// Reusable functions +// ------------------------ const noClientError = () => new Error( - 'The EQL client has not been initialized. Please call the init() method before using the client.', + 'The EQL client has not been initialized. Please call init() before using the client.', ) -export class EqlClient { +// ------------------------ +// Encrhyption operation implementations +// ------------------------ +class EncryptOperation implements PromiseLike { private client: Client - private workspaceId - - constructor() { - checkEnvironmentVariables() + private plaintext: EncryptPayload + private column: string + private table: string - logger.info( - 'Successfully initialized the EQL client with your defined environment variables.', - ) + constructor(client: Client, plaintext: EncryptPayload, opts: EncryptOptions) { + this.client = client + this.plaintext = plaintext + this.column = opts.column + this.table = opts.table + } - this.workspaceId = process.env.CS_WORKSPACE_ID + public withLockContext( + lockContext: LockContext, + ): EncryptOperationWithLockContext { + return new EncryptOperationWithLockContext(this, lockContext) } - async init(): Promise { - const client = await newClient() - this.client = client - return this + /** Implement the PromiseLike interface so `await` works. */ + public then( + onfulfilled?: + | ((value: EncryptedPayload) => TResult1 | PromiseLike) + | null, + // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.execute().then(onfulfilled, onrejected) } - async encrypt( - plaintext: EncryptPayload, - { column, table, lockContext }: EncryptOptions, - ): Promise { + /** Actual encryption logic, deferred until `then()` is called. */ + private async execute(): Promise { if (!this.client) { throw noClientError() } + if (this.plaintext === null) { + return null + } + + logger.debug('Encrypting data WITHOUT a lock context', { + column: this.column, + table: this.table, + }) + + const val = await ffiEncrypt(this.client, this.plaintext, this.column) + return { c: val } + } + + public getOperation(): { + client: Client + plaintext: EncryptPayload + column: string + table: string + } { + return { + client: this.client, + plaintext: this.plaintext, + column: this.column, + table: this.table, + } + } +} + +class EncryptOperationWithLockContext implements PromiseLike { + private operation: EncryptOperation + private lockContext: LockContext + + constructor(operation: EncryptOperation, lockContext: LockContext) { + this.operation = operation + this.lockContext = lockContext + } + + public then( + onfulfilled?: + | ((value: EncryptedPayload) => TResult1 | PromiseLike) + | null, + // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.execute().then(onfulfilled, onrejected) + } + + private async execute(): Promise { + const { client, plaintext, column, table } = this.operation.getOperation() + + if (!client) { + throw noClientError() + } + if (plaintext === null) { return null } - if (lockContext) { - const { ctsToken, context } = lockContext.getLockContext() - - logger.debug('Encrypting data with lock context', { - context, - column, - table, - }) - - return await encrypt( - this.client, - plaintext, - column, - { - identityClaim: context.identityClaim, - }, - ctsToken, - ).then((val: string) => { - return { c: val } - }) + logger.debug('Encrypting data WITH a lock context') + + const context = this.lockContext?.getLockContext() + + if (!context?.success) { + throw new Error(`[jseql]: ${context?.error}`) } - logger.debug('Encrypting data without a lock context', { + const val = await ffiEncrypt( + client, + plaintext, column, - table, - }) + context.context, + context.ctsToken, + ) + return { c: val } + } +} - return await encrypt(this.client, plaintext, column).then((val: string) => { - return { c: val } - }) +// ------------------------ +// Decryption operation implementations +// ------------------------ +class DecryptOperation implements PromiseLike { + private client: Client + private encryptedPayload: EncryptedPayload + + constructor(client: Client, encryptedPayload: EncryptedPayload) { + this.client = client + this.encryptedPayload = encryptedPayload } - // make decrypt options optional - async decrypt( - encryptedPayload: EncryptedPayload, - { lockContext }: DecryptOptions = {}, - ): Promise { + public withLockContext( + lockContext: LockContext, + ): DecryptOperationWithLockContext { + return new DecryptOperationWithLockContext(this, lockContext) + } + + public then( + onfulfilled?: + | ((value: string | null) => TResult1 | PromiseLike) + | null, + // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.execute().then(onfulfilled, onrejected) + } + + private async execute(): Promise { if (!this.client) { throw noClientError() } + if (this.encryptedPayload === null) { + return null + } + + logger.debug('Decrypting data WITHOUT a lock context') + return await ffiDecrypt(this.client, this.encryptedPayload.c) + } + + public getOperation(): { + client: Client + encryptedPayload: EncryptedPayload + } { + return { + client: this.client, + encryptedPayload: this.encryptedPayload, + } + } +} + +class DecryptOperationWithLockContext implements PromiseLike { + private operation: DecryptOperation + private lockContext: LockContext + + constructor(operation: DecryptOperation, lockContext: LockContext) { + this.operation = operation + this.lockContext = lockContext + } + + public then( + onfulfilled?: + | ((value: string | null) => TResult1 | PromiseLike) + | null, + // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.execute().then(onfulfilled, onrejected) + } + + private async execute(): Promise { + const { client, encryptedPayload } = this.operation.getOperation() + + if (!client) { + throw noClientError() + } + if (encryptedPayload === null) { return null } - try { - if (lockContext) { - const { ctsToken, context } = lockContext.getLockContext() - - logger.debug('Decrypting data with lock context', { - context, - }) - - return await decrypt( - this.client, - encryptedPayload.c, - { - identityClaim: context.identityClaim, - }, - ctsToken, - ) - } + logger.debug('Decrypting data WITH a lock context') + + const context = this.lockContext?.getLockContext() - logger.debug('Decrypting data without a lock context') - return await decrypt(this.client, encryptedPayload.c) - } catch (error) { - logger.debug((error as Error).message) - return encryptedPayload.c + if (!context?.success) { + throw new Error(`[jseql]: ${context?.error}`) } + + return await ffiDecrypt( + client, + encryptedPayload.c, + context.context, + context.ctsToken, + ) } +} + +// ------------------------ +// Bulk Encryption operation implementations +// ------------------------ +class BulkEncryptOperation implements PromiseLike { + private client: Client + private plaintexts: BulkEncryptPayload + private column: string + private table: string - async bulkEncrypt( + constructor( + client: Client, plaintexts: BulkEncryptPayload, - { column, table, lockContext }: EncryptOptions, - ): Promise { + opts: EncryptOptions, + ) { + this.client = client + this.plaintexts = plaintexts + this.column = opts.column + this.table = opts.table + } + + public withLockContext( + lockContext: LockContext, + ): BulkEncryptOperationWithLockContext { + return new BulkEncryptOperationWithLockContext(this, lockContext) + } + + public then( + onfulfilled?: + | ((value: BulkEncryptedData) => TResult1 | PromiseLike) + | null, + // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.execute().then(onfulfilled, onrejected) + } + + private async execute(): Promise { if (!this.client) { throw noClientError() } - if (plaintexts.length === 0 || plaintexts === null) { + if (!this.plaintexts || this.plaintexts.length === 0) { return null } const encryptPayloads = normalizeBulkEncryptPayloads( + this.plaintexts, + this.column, + ) + + logger.debug('Bulk encrypting data WITHOUT a lock context', { + column: this.column, + table: this.table, + }) + + const encryptedData = await ffiEncryptBulk(this.client, encryptPayloads) + return encryptedData.map((enc, index) => ({ + c: enc, + id: this.plaintexts[index].id, + })) + } + + public getOperation(): { + client: Client + plaintexts: BulkEncryptPayload + column: string + table: string + } { + return { + client: this.client, + plaintexts: this.plaintexts, + column: this.column, + table: this.table, + } + } +} + +class BulkEncryptOperationWithLockContext + implements PromiseLike +{ + private operation: BulkEncryptOperation + private lockContext: LockContext + + constructor(operation: BulkEncryptOperation, lockContext: LockContext) { + this.operation = operation + this.lockContext = lockContext + } + + public then( + onfulfilled?: + | ((value: BulkEncryptedData) => TResult1 | PromiseLike) + | null, + // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.execute().then(onfulfilled, onrejected) + } + + private async execute(): Promise { + const { client, plaintexts, column, table } = this.operation.getOperation() + + if (!client) { + throw noClientError() + } + + if (!plaintexts || plaintexts.length === 0) { + return null + } + + const encryptPayloads = normalizeBulkEncryptPayloadsWithLockContext( plaintexts, column, - lockContext, + this.lockContext, ) - logger.debug('Bulk encrypting data...', { + logger.debug('Bulk encrypting data WITH a lock context', { column, table, }) - let encryptedData: string[] + const context = this.lockContext.getLockContext() - if (lockContext) { - const { ctsToken, context } = lockContext.getLockContext() + if (!context.success) { + throw new Error(`[jseql]: ${context?.error}`) + } - logger.debug('Bulk encrypting data with lock context', { - context, - column, - table, - }) + const encryptedData = await ffiEncryptBulk( + client, + encryptPayloads, + context.ctsToken, + ) + + return encryptedData.map((enc, index) => ({ + c: enc, + id: plaintexts[index].id, + })) + } +} - encryptedData = await encryptBulk(this.client, encryptPayloads, ctsToken) - } else { - logger.debug('Bulk encrypting data without a lock context', { - column, - table, - }) +// ------------------------ +// Bulk Decryption operation implementations +// ------------------------ +class BulkDecryptOperation implements PromiseLike { + private client: Client + private encryptedPayloads: BulkEncryptedData + + constructor(client: Client, encryptedPayloads: BulkEncryptedData) { + this.client = client + this.encryptedPayloads = encryptedPayloads + } - encryptedData = await encryptBulk(this.client, encryptPayloads) + public withLockContext( + lockContext: LockContext, + ): BulkDecryptOperationWithLockContext { + return new BulkDecryptOperationWithLockContext(this, lockContext) + } + + public then( + onfulfilled?: + | ((value: BulkDecryptedData) => TResult1 | PromiseLike) + | null, + // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.execute().then(onfulfilled, onrejected) + } + + private async execute(): Promise { + if (!this.client) { + throw noClientError() } - const response = encryptedData?.map((encryptedData, index) => { + if (!this.encryptedPayloads) { + return null + } + + const decryptPayloads = normalizeBulkDecryptPayloads(this.encryptedPayloads) + + if (!decryptPayloads) { + return null + } + + logger.debug('Bulk decrypting data WITHOUT a lock context') + + const decryptedData = await ffiDecryptBulk(this.client, decryptPayloads) + return decryptedData.map((dec, index) => { + if (!this.encryptedPayloads) return null return { - c: encryptedData, - id: plaintexts[index].id, + plaintext: dec, + id: this.encryptedPayloads[index].id, } }) + } - return response + public getOperation(): { + client: Client + encryptedPayloads: BulkEncryptedData + } { + return { + client: this.client, + encryptedPayloads: this.encryptedPayloads, + } } +} - async bulkDecrypt( - encryptedPayloads: BulkEncryptedData, - { lockContext }: DecryptOptions = {}, - ): Promise { - if (!this.client) { +class BulkDecryptOperationWithLockContext + implements PromiseLike +{ + private operation: BulkDecryptOperation + private lockContext: LockContext + + constructor(operation: BulkDecryptOperation, lockContext: LockContext) { + this.operation = operation + this.lockContext = lockContext + } + + public then( + onfulfilled?: + | ((value: BulkDecryptedData) => TResult1 | PromiseLike) + | null, + // biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return this.execute().then(onfulfilled, onrejected) + } + + private async execute(): Promise { + const { client, encryptedPayloads } = this.operation.getOperation() + + if (!client) { throw noClientError() } - const decryptPayloads = normalizeBulkDecryptPayloads( + if (!encryptedPayloads) { + return null + } + + const decryptPayloads = normalizeBulkDecryptPayloadsWithLockContext( encryptedPayloads, - lockContext, + this.lockContext, ) if (!decryptPayloads) { return null } - let decryptedData: string[] - - if (lockContext) { - const { ctsToken, context } = lockContext.getLockContext() + logger.debug('Bulk decrypting data WITH a lock context') - logger.debug('Decrypting data with lock context', { - context, - }) + const context = this.lockContext.getLockContext() - decryptedData = await decryptBulk(this.client, decryptPayloads, ctsToken) - } else { - logger.debug('Decrypting data without a lock context') - decryptedData = await decryptBulk(this.client, decryptPayloads) + if (!context.success) { + throw new Error(`[jseql]: ${context?.error}`) } - const response = decryptedData?.map((decryptedData, index) => { - if (!encryptedPayloads) { - return null - } + const decryptedData = await ffiDecryptBulk( + client, + decryptPayloads, + context.ctsToken, + ) + return decryptedData.map((dec, index) => { + if (!encryptedPayloads) return null return { - plaintext: decryptedData, + plaintext: dec, id: encryptedPayloads[index].id, } }) + } +} + +// ------------------------ +// Main EQL Client +// ------------------------ +export class EqlClient { + private client: Client + private workspaceId: string | undefined - return response + constructor() { + checkEnvironmentVariables() + + logger.info( + 'Successfully initialized the EQL client with your defined environment variables.', + ) + + this.workspaceId = process.env.CS_WORKSPACE_ID } - clientInfo() { - return { - workspaceId: this.workspaceId, + async init(): Promise { + const c = await newClient() + this.client = c + return this + } + + /** + * Encryption - returns a thenable object. + * Usage: + * await eqlClient.encrypt(plaintext, { column, table }) + * await eqlClient.encrypt(plaintext, { column, table }).withLockContext(lockContext) + */ + encrypt(plaintext: EncryptPayload, opts: EncryptOptions): EncryptOperation { + if (!this.client) { + throw noClientError() } + + return new EncryptOperation(this.client, plaintext, opts) } -} -export type EncryptPayload = string | null + /** + * Decryption - returns a thenable object. + * Usage: + * await eqlClient.decrypt(encryptedPayload) + * await eqlClient.decrypt(encryptedPayload).withLockContext(lockContext) + */ + decrypt(encryptedPayload: EncryptedPayload): DecryptOperation { + if (!this.client) { + throw noClientError() + } -export type BulkEncryptPayload = { - plaintext: string - id: string -}[] + return new DecryptOperation(this.client, encryptedPayload) + } -export type EncryptOptions = { - column: string - table: string - lockContext?: LockContext -} + /** + * Bulk Encrypt - returns a thenable object. + * Usage: + * await eqlClient.bulkEncrypt([{ plaintext, id }, ...], { column, table }) + * await eqlClient + * .bulkEncrypt([{ plaintext, id }, ...], { column, table }) + * .withLockContext(lockContext) + */ + bulkEncrypt( + plaintexts: BulkEncryptPayload, + opts: EncryptOptions, + ): BulkEncryptOperation { + if (!this.client) { + throw noClientError() + } -// make decrypt options optional -export type DecryptOptions = { - lockContext?: LockContext -} + return new BulkEncryptOperation(this.client, plaintexts, opts) + } -export type EncryptedPayload = { - c: string -} | null + /** + * Bulk Decrypt - returns a thenable object. + * Usage: + * await eqlClient.bulkDecrypt(encryptedPayloads) + * await eqlClient.bulkDecrypt(encryptedPayloads).withLockContext(lockContext) + */ + bulkDecrypt(encryptedPayloads: BulkEncryptedData): BulkDecryptOperation { + if (!this.client) { + throw noClientError() + } -export type BulkEncryptedData = - | { - c: string - id: string - }[] - | null + return new BulkDecryptOperation(this.client, encryptedPayloads) + } -export type BulkDecryptedData = - | ({ - plaintext: string - id: string - } | null)[] - | null + /** e.g., debugging or environment info */ + clientInfo() { + return { + workspaceId: this.workspaceId, + } + } +} diff --git a/packages/jseql/src/ffi/payload-helpers.ts b/packages/jseql/src/ffi/payload-helpers.ts index 9804a731..8be761c3 100644 --- a/packages/jseql/src/ffi/payload-helpers.ts +++ b/packages/jseql/src/ffi/payload-helpers.ts @@ -6,24 +6,26 @@ import type { import type { BulkEncryptPayload, BulkEncryptedData } from './index' import type { LockContext } from '../identify' -const getLockContextPayload = (lockContext?: LockContext) => { - if (!lockContext) { - return {} +const getLockContextPayload = (lockContext: LockContext) => { + const context = lockContext.getLockContext() + + if (!context.ctsToken?.accessToken) { + throw new Error( + '[jseql]: LockContext must be initialized with a valid CTS token before using it.', + ) } return { - lockContext: lockContext.getLockContext().context, + lockContext: context.context, } } export const normalizeBulkDecryptPayloads = ( encryptedPayloads: BulkEncryptedData, - lockContext?: LockContext, ) => encryptedPayloads?.reduce((acc, encryptedPayload) => { const payload = { ciphertext: encryptedPayload.c, - ...getLockContextPayload(lockContext), } acc.push(payload) @@ -33,7 +35,35 @@ export const normalizeBulkDecryptPayloads = ( export const normalizeBulkEncryptPayloads = ( plaintexts: BulkEncryptPayload, column: string, - lockContext?: LockContext, +) => + plaintexts.reduce((acc, plaintext) => { + const payload = { + plaintext: plaintext.plaintext, + column, + } + + acc.push(payload) + return acc + }, [] as InternalBulkEncryptPayload[]) + +export const normalizeBulkDecryptPayloadsWithLockContext = ( + encryptedPayloads: BulkEncryptedData, + lockContext: LockContext, +) => + encryptedPayloads?.reduce((acc, encryptedPayload) => { + const payload = { + ciphertext: encryptedPayload.c, + ...getLockContextPayload(lockContext), + } + + acc.push(payload) + return acc + }, [] as InternalBulkDecryptPayload[]) + +export const normalizeBulkEncryptPayloadsWithLockContext = ( + plaintexts: BulkEncryptPayload, + column: string, + lockContext: LockContext, ) => plaintexts.reduce((acc, plaintext) => { const payload = { diff --git a/packages/jseql/src/identify/index.ts b/packages/jseql/src/identify/index.ts index 86d34a76..a0981947 100644 --- a/packages/jseql/src/identify/index.ts +++ b/packages/jseql/src/identify/index.ts @@ -20,6 +20,20 @@ export type LockContextOptions = { ctsToken?: CtsToken } +export type GetLockContextResponse = + | { + success: boolean + error: string + ctsToken?: never + context?: never + } + | { + success: boolean + error?: never + ctsToken: CtsToken + context: Context + } + export class LockContext { private ctsToken: CtsToken | undefined private workspaceId: string @@ -82,18 +96,17 @@ export class LockContext { return this } - getLockContext(): { - context: Context - ctsToken: CtsToken - } { - if (!this.ctsToken) { - const errorMessage = - 'Please call identify() with a users JWT token, or pass an existing CTS token to the LockContext constructor before calling getLockContext().' - logger.error(errorMessage) - throw new Error(errorMessage) + getLockContext(): GetLockContextResponse { + if (!this.ctsToken?.accessToken && !this.ctsToken?.expiry) { + return { + success: false, + error: + 'The CTS token is not set. Please call identify() with a users JWT token, or pass an existing CTS token to the LockContext constructor before calling getLockContext().', + } } return { + success: true, context: this.context, ctsToken: this.ctsToken, } diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 8b7a6810..109848f6 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -11,17 +11,36 @@ export type CtsToken = { expiry: number } -export const getCtsToken = async () => { +type GetCtsTokenResponse = Promise< + | { + success: boolean + error: string + ctsToken?: never + } + | { + success: boolean + error?: never + ctsToken: CtsToken + } +> + +export const getCtsToken = async (): GetCtsTokenResponse => { const cookieStore = await cookies() const cookieData = cookieStore.get(CS_COOKIE_NAME)?.value if (!cookieData) { logger.debug('No CipherStash session cookie found in the request.') - return null + return { + success: false, + error: 'No CipherStash session cookie found in the request.', + } } const cts_token = JSON.parse(cookieData) as CtsToken - return cts_token + return { + success: true, + ctsToken: cts_token, + } } export const resetCtsToken = (res?: NextResponse) => { @@ -51,7 +70,7 @@ export const jseqlMiddleware = async ( 'The JWT token was undefined, so the CipherStash session was reset.', ) - return resetCtsToken() + return resetCtsToken(res) } logger.debug(