diff --git a/.changeset/eighty-items-smile.md b/.changeset/eighty-items-smile.md new file mode 100644 index 00000000..19bad9dc --- /dev/null +++ b/.changeset/eighty-items-smile.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/protect-dynamodb": minor +--- + +Support nested protect schema in dynamodb helper functions. diff --git a/.changeset/kind-symbols-hug.md b/.changeset/kind-symbols-hug.md new file mode 100644 index 00000000..c6044510 --- /dev/null +++ b/.changeset/kind-symbols-hug.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/protect-dynamodb": minor +--- + +Fixed bug when handling schema definitions without an equality flag. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 62a2194b..259ff1ab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,14 @@ jobs: echo "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" >> ./packages/protect/.env echo "SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}" >> ./packages/protect/.env + - name: Create .env file in ./packages/protect-dynamodb/ + run: | + touch ./packages/protect-dynamodb/.env + echo "CS_WORKSPACE_CRN=${{ secrets.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env + echo "CS_CLIENT_ID=${{ secrets.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env + echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env + echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env + # Run TurboRepo tests - name: Run tests run: pnpm run test diff --git a/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts index 832d886a..c111c1e6 100644 --- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts +++ b/packages/protect-dynamodb/__tests__/dynamodb.test.ts @@ -1,7 +1,296 @@ -import { describe, expect, it } from 'vitest' +import 'dotenv/config' +import { describe, expect, it, beforeAll } from 'vitest' +import { protectDynamoDB } from '../src' +import { protect, csColumn, csTable, csValue } from '@cipherstash/protect' + +const schema = csTable('dynamo_cipherstash_test', { + email: csColumn('email').equality(), + firstName: csColumn('firstName').equality(), + lastName: csColumn('lastName').equality(), + phoneNumber: csColumn('phoneNumber'), + example: { + protected: csValue('example.protected'), + deep: { + protected: csValue('example.deep.protected'), + }, + }, +}) describe('protect dynamodb helpers', () => { - it('should say hello', () => { - expect(true).toBe(true) + let protectClient: Awaited> + let protectDynamo: ReturnType + + beforeAll(async () => { + protectClient = await protect({ + schemas: [schema], + }) + + protectDynamo = protectDynamoDB({ + protectClient, + }) + }) + + it('should encrypt columns', async () => { + const testData = { + id: '01ABCDEFGHIJKLMNOPQRSTUVWX', + email: 'test.user@example.com', + address: '123 Main Street', + createdAt: '2024-08-15T22:14:49.948Z', + firstName: 'John', + lastName: 'Smith', + phoneNumber: '555-555-5555', + companyName: 'Acme Corp', + batteryBrands: ['Brand1', 'Brand2'], + metadata: { role: 'admin' }, + example: { + protected: 'hello world', + notProtected: 'I am not protected', + deep: { + protected: 'deep protected', + notProtected: 'deep not protected', + }, + }, + } + + const result = await protectDynamo.encryptModel(testData, schema) + if (result.failure) { + throw new Error(`Encryption failed: ${result.failure.message}`) + } + + const encryptedData = result.data + + // Verify equality columns are encrypted + expect(encryptedData).toHaveProperty('email__source') + expect(encryptedData).toHaveProperty('email__hmac') + expect(encryptedData).toHaveProperty('firstName__source') + expect(encryptedData).toHaveProperty('firstName__hmac') + expect(encryptedData).toHaveProperty('lastName__source') + expect(encryptedData).toHaveProperty('lastName__hmac') + expect(encryptedData).toHaveProperty('phoneNumber__source') + expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') + expect(encryptedData.example).toHaveProperty('protected__source') + expect(encryptedData.example.deep).toHaveProperty('protected__source') + + // Verify other fields remain unchanged + expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') + expect(encryptedData.address).toBe('123 Main Street') + expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z') + expect(encryptedData.companyName).toBe('Acme Corp') + expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2']) + expect(encryptedData.example.notProtected).toBe('I am not protected') + expect(encryptedData.example.deep.notProtected).toBe('deep not protected') + expect(encryptedData.metadata).toEqual({ role: 'admin' }) + }) + + it('should handle null and undefined values', async () => { + const testData = { + id: '01ABCDEFGHIJKLMNOPQRSTUVWX', + email: null, + firstName: undefined, + lastName: 'Smith', + phoneNumber: null, + metadata: { role: null }, + example: { + protected: null, + notProtected: 'I am not protected', + deep: { + protected: undefined, + notProtected: 'deep not protected', + }, + }, + } + + const result = await protectDynamo.encryptModel(testData, schema) + if (result.failure) { + throw new Error(`Encryption failed: ${result.failure.message}`) + } + + const encryptedData = result.data + + // Verify null/undefined equality columns are handled + expect(encryptedData).toHaveProperty('lastName__source') + expect(encryptedData).toHaveProperty('lastName__hmac') + + // Verify other fields remain unchanged + expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') + expect(encryptedData.phoneNumber).toBeNull() + expect(encryptedData.email).toBeNull() + expect(encryptedData.firstName).toBeUndefined() + expect(encryptedData.metadata).toEqual({ role: null }) + expect(encryptedData.example.protected).toBeNull() + expect(encryptedData.example.deep.protected).toBeUndefined() + expect(encryptedData.example.deep.notProtected).toBe('deep not protected') + }) + + it('should handle empty strings and special characters', async () => { + const testData = { + id: '01ABCDEFGHIJKLMNOPQRSTUVWX', + email: '', + firstName: 'John!@#$%^&*()', + lastName: 'Smith ', + phoneNumber: '', + metadata: { role: 'admin!@#$%^&*()' }, + } + + const result = await protectDynamo.encryptModel(testData, schema) + if (result.failure) { + throw new Error(`Encryption failed: ${result.failure.message}`) + } + + const encryptedData = result.data + + // Verify equality columns are encrypted + expect(encryptedData).toHaveProperty('email__source') + expect(encryptedData).toHaveProperty('email__hmac') + expect(encryptedData).toHaveProperty('firstName__source') + expect(encryptedData).toHaveProperty('firstName__hmac') + expect(encryptedData).toHaveProperty('lastName__source') + expect(encryptedData).toHaveProperty('lastName__hmac') + expect(encryptedData).toHaveProperty('phoneNumber__source') + expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') + + // Verify other fields remain unchanged + expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') + expect(encryptedData.metadata).toEqual({ role: 'admin!@#$%^&*()' }) + }) + + it('should handle bulk encryption', async () => { + const testData = [ + { + id: '01ABCDEFGHIJKLMNOPQRSTUVWX', + email: 'test1@example.com', + firstName: 'John', + lastName: 'Smith', + phoneNumber: '555-555-5555', + }, + { + id: '02ABCDEFGHIJKLMNOPQRSTUVWX', + email: 'test2@example.com', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: '555-555-5556', + }, + ] + + const result = await protectDynamo.bulkEncryptModels(testData, schema) + if (result.failure) { + throw new Error(`Bulk encryption failed: ${result.failure.message}`) + } + + const encryptedData = result.data + + // Verify both items are encrypted + expect(encryptedData).toHaveLength(2) + + // Verify first item + expect(encryptedData[0]).toHaveProperty('email__source') + expect(encryptedData[0]).toHaveProperty('email__hmac') + expect(encryptedData[0]).toHaveProperty('firstName__source') + expect(encryptedData[0]).toHaveProperty('firstName__hmac') + expect(encryptedData[0]).toHaveProperty('lastName__source') + expect(encryptedData[0]).toHaveProperty('lastName__hmac') + expect(encryptedData[0]).toHaveProperty('phoneNumber__source') + + // Verify second item + expect(encryptedData[1]).toHaveProperty('email__source') + expect(encryptedData[1]).toHaveProperty('email__hmac') + expect(encryptedData[1]).toHaveProperty('firstName__source') + expect(encryptedData[1]).toHaveProperty('firstName__hmac') + expect(encryptedData[1]).toHaveProperty('lastName__source') + expect(encryptedData[1]).toHaveProperty('lastName__hmac') + expect(encryptedData[1]).toHaveProperty('phoneNumber__source') + }) + + it('should handle decryption of encrypted data', async () => { + const originalData = { + id: '01ABCDEFGHIJKLMNOPQRSTUVWX', + email: 'test.user@example.com', + firstName: 'John', + lastName: 'Smith', + phoneNumber: '555-555-5555', + example: { + protected: 'hello world', + notProtected: 'I am not protected', + deep: { + protected: 'deep protected', + notProtected: 'deep not protected', + }, + }, + } + + // First encrypt + const encryptResult = await protectDynamo.encryptModel(originalData, schema) + + if (encryptResult.failure) { + throw new Error(`Encryption failed: ${encryptResult.failure.message}`) + } + + // Then decrypt + const decryptResult = await protectDynamo.decryptModel( + encryptResult.data, + schema, + ) + if (decryptResult.failure) { + throw new Error(`Decryption failed: ${decryptResult.failure.message}`) + } + + const decryptedData = decryptResult.data + + // Verify all fields match original data + expect(decryptedData).toEqual(originalData) + }) + + it('should handle decryption of bulk encrypted data', async () => { + const originalData = [ + { + id: '01ABCDEFGHIJKLMNOPQRSTUVWX', + email: 'test1@example.com', + firstName: 'John', + lastName: 'Smith', + phoneNumber: '555-555-5555', + }, + { + id: '02ABCDEFGHIJKLMNOPQRSTUVWX', + email: 'test2@example.com', + firstName: 'Jane', + lastName: 'Doe', + phoneNumber: '555-555-5556', + example: { + protected: 'hello world', + notProtected: 'I am not protected', + deep: { + protected: 'deep protected', + notProtected: 'deep not protected', + }, + }, + }, + ] + + // First encrypt + const encryptResult = await protectDynamo.bulkEncryptModels( + originalData, + schema, + ) + if (encryptResult.failure) { + throw new Error( + `Bulk encryption failed: ${encryptResult.failure.message}`, + ) + } + + // Then decrypt + const decryptResult = await protectDynamo.bulkDecryptModels( + encryptResult.data, + schema, + ) + if (decryptResult.failure) { + throw new Error( + `Bulk decryption failed: ${decryptResult.failure.message}`, + ) + } + + const decryptedData = decryptResult.data + + // Verify all items match original data + expect(decryptedData).toEqual(originalData) }) }) diff --git a/packages/protect-dynamodb/package.json b/packages/protect-dynamodb/package.json index f08243cd..096c03dd 100644 --- a/packages/protect-dynamodb/package.json +++ b/packages/protect-dynamodb/package.json @@ -41,6 +41,9 @@ "typescript": "catalog:repo", "vitest": "catalog:repo" }, + "peerDependencies": { + "@cipherstash/protect": "workspace:*" + }, "publishConfig": { "access": "public" }, diff --git a/packages/protect-dynamodb/src/index.ts b/packages/protect-dynamodb/src/index.ts index f7dbdfc4..4ed3772e 100644 --- a/packages/protect-dynamodb/src/index.ts +++ b/packages/protect-dynamodb/src/index.ts @@ -21,22 +21,78 @@ class ProtectDynamoDBErrorImpl extends Error implements ProtectDynamoDBError { } } +function deepClone(obj: T): T { + if (obj === null || typeof obj !== 'object') { + return obj + } + + if (Array.isArray(obj)) { + return obj.map((item) => deepClone(item)) as unknown as T + } + + return Object.entries(obj as Record).reduce( + (acc, [key, value]) => ({ + // biome-ignore lint/performance/noAccumulatingSpread: TODO later + ...acc, + [key]: deepClone(value), + }), + {} as T, + ) +} + function toEncryptedDynamoItem( encrypted: Record, encryptedAttrs: string[], ): Record { - return Object.entries(encrypted).reduce( - (putItem, [attrName, attrValue]) => { - if (encryptedAttrs.includes(attrName)) { - const encryptPayload = attrValue as EncryptedPayload - if (encryptPayload?.hm && encryptPayload?.c) { - putItem[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm - putItem[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c + function processValue( + attrName: string, + attrValue: unknown, + isNested: boolean, + ): Record { + if (attrValue === null || attrValue === undefined) { + return { [attrName]: attrValue } + } + + // Handle encrypted payload + if ( + encryptedAttrs.includes(attrName) || + (isNested && + typeof attrValue === 'object' && + 'c' in (attrValue as object)) + ) { + const encryptPayload = attrValue as EncryptedPayload + if (encryptPayload?.c) { + const result: Record = {} + if (encryptPayload.hm) { + result[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm } - } else { - putItem[attrName] = attrValue + result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c + return result } - return putItem + } + + // Handle nested objects recursively + if (typeof attrValue === 'object' && !Array.isArray(attrValue)) { + const nestedResult = Object.entries( + attrValue as Record, + ).reduce( + (acc, [key, val]) => { + const processed = processValue(key, val, true) + return Object.assign({}, acc, processed) + }, + {} as Record, + ) + return { [attrName]: nestedResult } + } + + // Handle non-encrypted values + return { [attrName]: attrValue } + } + + return Object.entries(encrypted).reduce( + (putItem, [attrName, attrValue]) => { + const processed = processValue(attrName, attrValue, false) + return Object.assign({}, putItem, processed) }, {} as Record, ) @@ -46,13 +102,31 @@ function toItemWithEqlPayloads( decrypted: Record, encryptedAttrs: string[], ): Record { - return Object.entries(decrypted).reduce( - (formattedItem, [attrName, attrValue]) => { - if ( - attrName.endsWith(ciphertextAttrSuffix) && - encryptedAttrs.includes(attrName.slice(0, -ciphertextAttrSuffix.length)) - ) { - formattedItem[attrName.slice(0, -ciphertextAttrSuffix.length)] = { + function processValue( + attrName: string, + attrValue: unknown, + isNested: boolean, + ): Record { + if (attrValue === null || attrValue === undefined) { + return { [attrName]: attrValue } + } + + // Skip HMAC fields + if (attrName.endsWith(searchTermAttrSuffix)) { + return {} + } + + // Handle encrypted payload + if ( + attrName.endsWith(ciphertextAttrSuffix) && + (encryptedAttrs.includes( + attrName.slice(0, -ciphertextAttrSuffix.length), + ) || + isNested) + ) { + const baseName = attrName.slice(0, -ciphertextAttrSuffix.length) + return { + [baseName]: { c: attrValue, bf: null, hm: null, @@ -60,13 +134,32 @@ function toItemWithEqlPayloads( k: 'notUsed', ob: null, v: 2, - } - } else if (attrName.endsWith(searchTermAttrSuffix)) { - // skip HMAC attrs since we don't need those for decryption - } else { - formattedItem[attrName] = attrValue + }, } - return formattedItem + } + + // Handle nested objects recursively + if (typeof attrValue === 'object' && !Array.isArray(attrValue)) { + const nestedResult = Object.entries( + attrValue as Record, + ).reduce( + (acc, [key, val]) => { + const processed = processValue(key, val, true) + return Object.assign({}, acc, processed) + }, + {} as Record, + ) + return { [attrName]: nestedResult } + } + + // Handle non-encrypted values + return { [attrName]: attrValue } + } + + return Object.entries(decrypted).reduce( + (formattedItem, [attrName, attrValue]) => { + const processed = processValue(attrName, attrValue, false) + return Object.assign({}, formattedItem, processed) }, {} as Record, ) @@ -104,7 +197,7 @@ export function protectDynamoDB( return await withResult( async () => { const encryptResult = await protectClient.encryptModel( - item, + deepClone(item), protectTable, ) @@ -114,10 +207,10 @@ export function protectDynamoDB( ) } - const data = encryptResult.data + const data = deepClone(encryptResult.data) const encryptedAttrs = Object.keys(protectTable.build().columns) - return toEncryptedDynamoItem(data, encryptedAttrs) + return toEncryptedDynamoItem(data, encryptedAttrs) as T }, (error) => handleError(error, 'encryptModel'), ) @@ -130,7 +223,7 @@ export function protectDynamoDB( return await withResult( async () => { const encryptResult = await protectClient.bulkEncryptModels( - items, + items.map((item) => deepClone(item)), protectTable, ) @@ -140,11 +233,12 @@ export function protectDynamoDB( ) } - const data = encryptResult.data + const data = encryptResult.data.map((item) => deepClone(item)) const encryptedAttrs = Object.keys(protectTable.build().columns) - return data.map((encrypted) => - toEncryptedDynamoItem(encrypted, encryptedAttrs), + return data.map( + (encrypted) => + toEncryptedDynamoItem(encrypted, encryptedAttrs) as T, ) }, (error) => handleError(error, 'bulkEncryptModels'), diff --git a/packages/protect-dynamodb/src/types.ts b/packages/protect-dynamodb/src/types.ts index f07a42b2..ba782666 100644 --- a/packages/protect-dynamodb/src/types.ts +++ b/packages/protect-dynamodb/src/types.ts @@ -27,20 +27,20 @@ export interface ProtectDynamoDBInstance { encryptModel>( item: T, protectTable: ProtectTable, - ): Promise, ProtectDynamoDBError>> + ): Promise> bulkEncryptModels>( items: T[], protectTable: ProtectTable, - ): Promise[], ProtectDynamoDBError>> + ): Promise> decryptModel>( - item: Record, + item: T, protectTable: ProtectTable, ): Promise, ProtectDynamoDBError>> bulkDecryptModels>( - items: Record[], + items: T[], protectTable: ProtectTable, ): Promise[], ProtectDynamoDBError>>