@cipherstash/jseql
Version:
Encrypted Query Language JavaScript Library
670 lines (555 loc) • 17.8 kB
text/typescript
import {
newClient,
encrypt as ffiEncrypt,
decrypt as ffiDecrypt,
encryptBulk as ffiEncryptBulk,
decryptBulk as ffiDecryptBulk,
} from '@cipherstash/jseql-ffi'
import { logger } from '../../../utils/logger'
import { checkEnvironmentVariables } from './env-check'
import {
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<ReturnType<typeof newClient>> | undefined
// ------------------------
// Reusable functions
// ------------------------
const noClientError = () =>
new Error(
'The EQL client has not been initialized. Please call init() before using the client.',
)
// ------------------------
// Encrhyption operation implementations
// ------------------------
class EncryptOperation implements PromiseLike<EncryptedPayload> {
private client: Client
private plaintext: EncryptPayload
private column: string
private table: string
constructor(client: Client, plaintext: EncryptPayload, opts: EncryptOptions) {
this.client = client
this.plaintext = plaintext
this.column = opts.column
this.table = opts.table
}
public withLockContext(
lockContext: LockContext,
): EncryptOperationWithLockContext {
return new EncryptOperationWithLockContext(this, lockContext)
}
/** Implement the PromiseLike interface so `await` works. */
public then<TResult1 = EncryptedPayload, TResult2 = never>(
onfulfilled?:
| ((value: EncryptedPayload) => TResult1 | PromiseLike<TResult1>)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected)
}
/** Actual encryption logic, deferred until `then()` is called. */
private async execute(): Promise<EncryptedPayload> {
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,
})
logger.debug('SHOULD NEVER BE LOGGED: Plaintext for encryption')
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<EncryptedPayload> {
private operation: EncryptOperation
private lockContext: LockContext
constructor(operation: EncryptOperation, lockContext: LockContext) {
this.operation = operation
this.lockContext = lockContext
}
public then<TResult1 = EncryptedPayload, TResult2 = never>(
onfulfilled?:
| ((value: EncryptedPayload) => TResult1 | PromiseLike<TResult1>)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected)
}
private async execute(): Promise<EncryptedPayload> {
const { client, plaintext, column, table } = this.operation.getOperation()
if (!client) {
throw noClientError()
}
if (plaintext === null) {
return null
}
logger.debug('Encrypting data WITH a lock context')
const context = this.lockContext?.getLockContext()
if (!context?.success) {
throw new Error(`[jseql]: ${context?.error}`)
}
logger.debug('SENSITIVE TO BE DELETED: Lock context for encryption', {
context: context.context,
ctsToken: context.ctsToken,
accessToken: context.ctsToken?.accessToken,
})
const val = await ffiEncrypt(
client,
plaintext,
column,
context.context,
context.ctsToken,
)
return { c: val }
}
}
// ------------------------
// Decryption operation implementations
// ------------------------
class DecryptOperation implements PromiseLike<string | null> {
private client: Client
private encryptedPayload: EncryptedPayload
constructor(client: Client, encryptedPayload: EncryptedPayload) {
this.client = client
this.encryptedPayload = encryptedPayload
}
public withLockContext(
lockContext: LockContext,
): DecryptOperationWithLockContext {
return new DecryptOperationWithLockContext(this, lockContext)
}
public then<TResult1 = string | null, TResult2 = never>(
onfulfilled?:
| ((value: string | null) => TResult1 | PromiseLike<TResult1>)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected)
}
private async execute(): Promise<string | null> {
if (!this.client) {
throw noClientError()
}
if (this.encryptedPayload === null) {
return null
}
logger.debug('Decrypting data WITHOUT a lock context')
logger.debug('SHOULD NEVER BE LOGGED: Encrypted payload for decryption')
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<string | null> {
private operation: DecryptOperation
private lockContext: LockContext
constructor(operation: DecryptOperation, lockContext: LockContext) {
this.operation = operation
this.lockContext = lockContext
}
public then<TResult1 = string | null, TResult2 = never>(
onfulfilled?:
| ((value: string | null) => TResult1 | PromiseLike<TResult1>)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected)
}
private async execute(): Promise<string | null> {
const { client, encryptedPayload } = this.operation.getOperation()
if (!client) {
throw noClientError()
}
if (encryptedPayload === null) {
return null
}
logger.debug('Decrypting data WITH a lock context')
const context = this.lockContext?.getLockContext()
if (!context?.success) {
throw new Error(`[jseql]: ${context?.error}`)
}
logger.debug('SENSITIVE TO BE DELETED: Lock context for decryption', {
context: context.context,
ctsToken: context.ctsToken,
accessToken: context.ctsToken?.accessToken,
})
return await ffiDecrypt(
client,
encryptedPayload.c,
context.context,
context.ctsToken,
)
}
}
// ------------------------
// Bulk Encryption operation implementations
// ------------------------
class BulkEncryptOperation implements PromiseLike<BulkEncryptedData> {
private client: Client
private plaintexts: BulkEncryptPayload
private column: string
private table: string
constructor(
client: Client,
plaintexts: BulkEncryptPayload,
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<TResult1 = BulkEncryptedData, TResult2 = never>(
onfulfilled?:
| ((value: BulkEncryptedData) => TResult1 | PromiseLike<TResult1>)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected)
}
private async execute(): Promise<BulkEncryptedData> {
if (!this.client) {
throw noClientError()
}
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,
})
logger.debug('SHOULD NEVER BE LOGGED: Plaintexts for bulk encryption')
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<BulkEncryptedData>
{
private operation: BulkEncryptOperation
private lockContext: LockContext
constructor(operation: BulkEncryptOperation, lockContext: LockContext) {
this.operation = operation
this.lockContext = lockContext
}
public then<TResult1 = BulkEncryptedData, TResult2 = never>(
onfulfilled?:
| ((value: BulkEncryptedData) => TResult1 | PromiseLike<TResult1>)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected)
}
private async execute(): Promise<BulkEncryptedData> {
const { client, plaintexts, column, table } = this.operation.getOperation()
if (!client) {
throw noClientError()
}
if (!plaintexts || plaintexts.length === 0) {
return null
}
const encryptPayloads = normalizeBulkEncryptPayloadsWithLockContext(
plaintexts,
column,
this.lockContext,
)
logger.debug('Bulk encrypting data WITH a lock context', {
column,
table,
})
const context = this.lockContext.getLockContext()
if (!context.success) {
throw new Error(`[jseql]: ${context?.error}`)
}
logger.debug('SENSITIVE TO BE DELETED: Lock context for bulk encryption', {
context: context.context,
ctsToken: context.ctsToken,
accessToken: context.ctsToken?.accessToken,
})
const encryptedData = await ffiEncryptBulk(
client,
encryptPayloads,
context.ctsToken,
)
return encryptedData.map((enc, index) => ({
c: enc,
id: plaintexts[index].id,
}))
}
}
// ------------------------
// Bulk Decryption operation implementations
// ------------------------
class BulkDecryptOperation implements PromiseLike<BulkDecryptedData> {
private client: Client
private encryptedPayloads: BulkEncryptedData
constructor(client: Client, encryptedPayloads: BulkEncryptedData) {
this.client = client
this.encryptedPayloads = encryptedPayloads
}
public withLockContext(
lockContext: LockContext,
): BulkDecryptOperationWithLockContext {
return new BulkDecryptOperationWithLockContext(this, lockContext)
}
public then<TResult1 = BulkDecryptedData, TResult2 = never>(
onfulfilled?:
| ((value: BulkDecryptedData) => TResult1 | PromiseLike<TResult1>)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected)
}
private async execute(): Promise<BulkDecryptedData> {
if (!this.client) {
throw noClientError()
}
if (!this.encryptedPayloads) {
return null
}
const decryptPayloads = normalizeBulkDecryptPayloads(this.encryptedPayloads)
if (!decryptPayloads) {
return null
}
logger.debug('Bulk decrypting data WITHOUT a lock context')
logger.debug(
'SHOULD NEVER BE LOGGED: Encrypted payloads for bulk decryption',
)
const decryptedData = await ffiDecryptBulk(this.client, decryptPayloads)
return decryptedData.map((dec, index) => {
if (!this.encryptedPayloads) return null
return {
plaintext: dec,
id: this.encryptedPayloads[index].id,
}
})
}
public getOperation(): {
client: Client
encryptedPayloads: BulkEncryptedData
} {
return {
client: this.client,
encryptedPayloads: this.encryptedPayloads,
}
}
}
class BulkDecryptOperationWithLockContext
implements PromiseLike<BulkDecryptedData>
{
private operation: BulkDecryptOperation
private lockContext: LockContext
constructor(operation: BulkDecryptOperation, lockContext: LockContext) {
this.operation = operation
this.lockContext = lockContext
}
public then<TResult1 = BulkDecryptedData, TResult2 = never>(
onfulfilled?:
| ((value: BulkDecryptedData) => TResult1 | PromiseLike<TResult1>)
| null,
// biome-ignore lint/suspicious/noExplicitAny: Rejections require an any type
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected)
}
private async execute(): Promise<BulkDecryptedData> {
const { client, encryptedPayloads } = this.operation.getOperation()
if (!client) {
throw noClientError()
}
if (!encryptedPayloads) {
return null
}
const decryptPayloads = normalizeBulkDecryptPayloadsWithLockContext(
encryptedPayloads,
this.lockContext,
)
if (!decryptPayloads) {
return null
}
logger.debug('Bulk decrypting data WITH a lock context')
const context = this.lockContext.getLockContext()
if (!context.success) {
throw new Error(`[jseql]: ${context?.error}`)
}
logger.debug('SENSITIVE TO BE DELETED: Lock context for bulk decryption', {
context: context.context,
ctsToken: context.ctsToken,
accessToken: context.ctsToken?.accessToken,
})
const decryptedData = await ffiDecryptBulk(
client,
decryptPayloads,
context.ctsToken,
)
return decryptedData.map((dec, index) => {
if (!encryptedPayloads) return null
return {
plaintext: dec,
id: encryptedPayloads[index].id,
}
})
}
}
// ------------------------
// Main EQL Client
// ------------------------
export class EqlClient {
private client: Client
private workspaceId: string | undefined
constructor() {
checkEnvironmentVariables()
logger.info(
'Successfully initialized the EQL client with your defined environment variables.',
)
this.workspaceId = process.env.CS_WORKSPACE_ID
}
async init(): Promise<EqlClient> {
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)
}
/**
* 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()
}
return new DecryptOperation(this.client, encryptedPayload)
}
/**
* 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()
}
return new BulkEncryptOperation(this.client, plaintexts, opts)
}
/**
* 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()
}
return new BulkDecryptOperation(this.client, encryptedPayloads)
}
/** e.g., debugging or environment info */
clientInfo() {
return {
workspaceId: this.workspaceId,
}
}
}