From ade3e8a78ed75746a3ae478195ef510a11de1c49 Mon Sep 17 00:00:00 2001 From: alansyue Date: Thu, 13 Aug 2026 23:49:37 +0800 Subject: [PATCH] fix(security): bind platform credential decryption to aad --- .../platform-credential-service.test.ts | 74 ++++++++++++++++++- .../src/platform-credential/service.ts | 6 +- packages/encryption/src/encryption.ts | 13 +++- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/business/__tests__/platform-credential-service.test.ts b/packages/business/__tests__/platform-credential-service.test.ts index 4969cb0f0..35beca249 100644 --- a/packages/business/__tests__/platform-credential-service.test.ts +++ b/packages/business/__tests__/platform-credential-service.test.ts @@ -9,13 +9,18 @@ vi.mock("@chatbotx.io/database/client", () => ({ eq: vi.fn(), isNull: vi.fn(), })) +const credentialSchemas = { + instagram: { name: "instagram-schema" }, + messenger: { name: "messenger-schema" }, +} vi.mock("@chatbotx.io/database/partials", () => ({ - credentialEncryptedSchema: {}, + credentialEncryptedSchema: { parse: vi.fn((value: unknown) => value) }, credentialPublicSchemas: {}, - credentialSchemas: {}, + credentialSchemas, })) vi.mock("@chatbotx.io/database/schema", () => ({ platformCredentialModel: {} })) -vi.mock("@chatbotx.io/encryption", () => ({ encryptUtils: {} })) +const encryptUtils = { decryptObject: vi.fn() } +vi.mock("@chatbotx.io/encryption", () => ({ encryptUtils })) vi.mock("@chatbotx.io/redis", () => ({ invalidateCacheByTags: vi.fn(async () => undefined), withCache: vi.fn(async (_key: string, fn: () => unknown) => fn()), @@ -39,6 +44,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks() + encryptUtils.decryptObject.mockReset() }) describe("resolveForOwner", () => { @@ -196,3 +202,65 @@ describe("resolvePlatformAppAccessToken", () => { ).resolves.toBeUndefined() }) }) + +describe("_decrypt", () => { + test("passes platform aad when decrypting a platform-scoped row", async () => { + encryptUtils.decryptObject.mockResolvedValue({ clientId: "client-1" }) + + await expect( + (platformCredentialService as never)._decrypt({ + id: "platform-1", + userId: null, + type: "instagram", + livemode: false, + value: { encrypted: true }, + publicConfig: { clientId: "client-1" }, + createdAt: new Date("2026-08-13T00:00:00.000Z"), + updatedAt: new Date("2026-08-13T00:00:00.000Z"), + }), + ).resolves.toEqual( + expect.objectContaining({ + id: "platform-1", + userId: null, + type: "instagram", + config: { clientId: "client-1" }, + }), + ) + + expect(encryptUtils.decryptObject).toHaveBeenCalledWith( + { encrypted: true }, + credentialSchemas.instagram, + "platform:instagram:false", + ) + }) + + test("passes user aad when decrypting a user-scoped row", async () => { + encryptUtils.decryptObject.mockResolvedValue({ clientId: "client-2" }) + + await expect( + (platformCredentialService as never)._decrypt({ + id: "user-1", + userId: "owner-1", + type: "messenger", + livemode: true, + value: { encrypted: true }, + publicConfig: { clientId: "client-2" }, + createdAt: new Date("2026-08-13T00:00:00.000Z"), + updatedAt: new Date("2026-08-13T00:00:00.000Z"), + }), + ).resolves.toEqual( + expect.objectContaining({ + id: "user-1", + userId: "owner-1", + type: "messenger", + config: { clientId: "client-2" }, + }), + ) + + expect(encryptUtils.decryptObject).toHaveBeenCalledWith( + { encrypted: true }, + credentialSchemas.messenger, + "user:owner-1:messenger:true", + ) + }) +}) diff --git a/packages/business/src/platform-credential/service.ts b/packages/business/src/platform-credential/service.ts index 380a11b28..de37ad59b 100644 --- a/packages/business/src/platform-credential/service.ts +++ b/packages/business/src/platform-credential/service.ts @@ -433,7 +433,11 @@ class PlatformCredentialService extends BaseService { const schema = credentialSchemas[row.type] as unknown as z.ZodType< CredentialByType[T] > - const config = await encryptUtils.decryptObject(blob, schema) + const aad = + row.userId === null + ? `platform:${row.type}:${row.livemode}` + : `user:${row.userId}:${row.type}:${row.livemode}` + const config = await encryptUtils.decryptObject(blob, schema, aad) return { id: row.id, userId: row.userId ?? null, diff --git a/packages/encryption/src/encryption.ts b/packages/encryption/src/encryption.ts index 9c4cc58a8..a601e44b4 100644 --- a/packages/encryption/src/encryption.ts +++ b/packages/encryption/src/encryption.ts @@ -90,6 +90,15 @@ const encode = (text: string): Uint8Array => const decode = (bytes: Uint8Array): string => new TextDecoder().decode(bytes) +const assertMatchingAad = ( + encryptedAad: string | undefined, + providedAad: string | undefined, +): void => { + if (encryptedAad !== providedAad) { + throw new Error("AAD mismatch.") + } +} + const buildAlgorithm = ( iv: Uint8Array, aad?: string, @@ -128,6 +137,7 @@ export const encryptUtils = { aad?: string, ): Promise => { assertCurrentVersion(encryptedData.v) + assertMatchingAad(encryptedData.aad, aad) const key = await getKey(encryptedData.kid) const iv = hexToBytes(encryptedData.iv) // Web Crypto expects ciphertext + tag concatenated as a single buffer @@ -135,9 +145,8 @@ export const encryptUtils = { hexToBytes(encryptedData.text), hexToBytes(encryptedData.tag), ) - const resolvedAad = encryptedData.aad ?? aad const raw = await crypto.subtle.decrypt( - buildAlgorithm(iv, resolvedAad), + buildAlgorithm(iv, aad), key, combined, )