From e33fbaffa6099d8e0974277cf0fb15cbdf43c5dc Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Jun 2025 13:42:53 -0600 Subject: [PATCH 1/5] fix(protect-dynamodb): Fixed bug when handling schema definitions without an equality flag --- .changeset/kind-symbols-hug.md | 5 + .../__tests__/dynamodb.test.ts | 247 +++++++++++++++++- packages/protect-dynamodb/package.json | 4 +- packages/protect-dynamodb/src/index.ts | 14 +- pnpm-lock.yaml | 2 +- 5 files changed, 263 insertions(+), 9 deletions(-) create mode 100644 .changeset/kind-symbols-hug.md 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/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts index 832d886a..eb51d50c 100644 --- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts +++ b/packages/protect-dynamodb/__tests__/dynamodb.test.ts @@ -1,7 +1,248 @@ -import { describe, expect, it } from 'vitest' +import 'dotenv/config' +import { describe, expect, it, beforeAll } from 'vitest' +import { protectDynamoDB } from '../src' +import { protect, csColumn, csTable } from '@cipherstash/protect' + +const schema = csTable('dynamo_cipherstash_test', { + email: csColumn('email').equality(), + firstName: csColumn('firstName').equality(), + lastName: csColumn('lastName').equality(), + phoneNumber: csColumn('phoneNumber'), +}) 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' }, + } + + 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') + + // 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.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 }, + } + + 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 }) + }) + + 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') + + // 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', + } + + // 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', + }, + ] + + // 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..7b2862ca 100644 --- a/packages/protect-dynamodb/package.json +++ b/packages/protect-dynamodb/package.json @@ -34,13 +34,15 @@ "release": "tsup" }, "devDependencies": { - "@cipherstash/protect": "workspace:*", "dotenv": "^16.4.7", "tsup": "catalog:repo", "tsx": "catalog:repo", "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..61f74a06 100644 --- a/packages/protect-dynamodb/src/index.ts +++ b/packages/protect-dynamodb/src/index.ts @@ -28,10 +28,16 @@ function toEncryptedDynamoItem( 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 + if (attrValue === null || attrValue === undefined) { + putItem[attrName] = attrValue + } else { + const encryptPayload = attrValue as EncryptedPayload + if (encryptPayload?.c) { + if (encryptPayload.hm) { + putItem[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm + } + putItem[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c + } } } else { putItem[attrName] = attrValue diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8003ab94..6cb44d1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,10 +370,10 @@ importers: '@byteslice/result': specifier: ^0.2.0 version: 0.2.0 - devDependencies: '@cipherstash/protect': specifier: workspace:* version: link:../protect + devDependencies: dotenv: specifier: ^16.4.7 version: 16.4.7 From f75e6eb1b467166fb467864632888ac444fd29ac Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Jun 2025 13:51:59 -0600 Subject: [PATCH 2/5] ci(protect-dynamodb): add required dev dependency --- packages/protect-dynamodb/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/protect-dynamodb/package.json b/packages/protect-dynamodb/package.json index 7b2862ca..096c03dd 100644 --- a/packages/protect-dynamodb/package.json +++ b/packages/protect-dynamodb/package.json @@ -34,6 +34,7 @@ "release": "tsup" }, "devDependencies": { + "@cipherstash/protect": "workspace:*", "dotenv": "^16.4.7", "tsup": "catalog:repo", "tsx": "catalog:repo", From 6378ad44a2b48f13478f12bd0688f52c9d948948 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Jun 2025 13:54:22 -0600 Subject: [PATCH 3/5] chore: lock file --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cb44d1a..8003ab94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,10 +370,10 @@ importers: '@byteslice/result': specifier: ^0.2.0 version: 0.2.0 + devDependencies: '@cipherstash/protect': specifier: workspace:* version: link:../protect - devDependencies: dotenv: specifier: ^16.4.7 version: 16.4.7 From 3e2b70e4857fb4ad07ec8ae56d061eb81da363fe Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Jun 2025 13:57:15 -0600 Subject: [PATCH 4/5] ci(protect-dynamodb): add env for tests --- .github/workflows/tests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) 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 From 2b63ee183a334a98edfd39e1cb57007bb207f144 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Jun 2025 17:40:39 -0600 Subject: [PATCH 5/5] feat(protect-dynamodb): support protect nested schemas --- .changeset/eighty-items-smile.md | 5 + .../__tests__/dynamodb.test.ts | 50 +++++- packages/protect-dynamodb/src/index.ts | 160 ++++++++++++++---- packages/protect-dynamodb/src/types.ts | 8 +- 4 files changed, 182 insertions(+), 41 deletions(-) create mode 100644 .changeset/eighty-items-smile.md 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/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts index eb51d50c..c111c1e6 100644 --- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts +++ b/packages/protect-dynamodb/__tests__/dynamodb.test.ts @@ -1,13 +1,19 @@ import 'dotenv/config' import { describe, expect, it, beforeAll } from 'vitest' import { protectDynamoDB } from '../src' -import { protect, csColumn, csTable } from '@cipherstash/protect' +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', () => { @@ -36,6 +42,14 @@ describe('protect dynamodb helpers', () => { 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) @@ -53,6 +67,9 @@ describe('protect dynamodb helpers', () => { 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') @@ -60,6 +77,8 @@ describe('protect dynamodb helpers', () => { 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' }) }) @@ -71,6 +90,14 @@ describe('protect dynamodb helpers', () => { 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) @@ -90,6 +117,9 @@ describe('protect dynamodb helpers', () => { 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 () => { @@ -117,6 +147,7 @@ describe('protect dynamodb helpers', () => { 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') @@ -177,10 +208,19 @@ describe('protect dynamodb helpers', () => { 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}`) } @@ -215,6 +255,14 @@ describe('protect dynamodb helpers', () => { 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', + }, + }, }, ] diff --git a/packages/protect-dynamodb/src/index.ts b/packages/protect-dynamodb/src/index.ts index 61f74a06..4ed3772e 100644 --- a/packages/protect-dynamodb/src/index.ts +++ b/packages/protect-dynamodb/src/index.ts @@ -21,28 +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)) { - if (attrValue === null || attrValue === undefined) { - putItem[attrName] = attrValue - } else { - const encryptPayload = attrValue as EncryptedPayload - if (encryptPayload?.c) { - if (encryptPayload.hm) { - 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, ) @@ -52,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, @@ -66,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, ) @@ -110,7 +197,7 @@ export function protectDynamoDB( return await withResult( async () => { const encryptResult = await protectClient.encryptModel( - item, + deepClone(item), protectTable, ) @@ -120,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'), ) @@ -136,7 +223,7 @@ export function protectDynamoDB( return await withResult( async () => { const encryptResult = await protectClient.bulkEncryptModels( - items, + items.map((item) => deepClone(item)), protectTable, ) @@ -146,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>>