Skip to content

Commit e8eef57

Browse files
committed
fix(encryption): remove aad parameter from decrypt methods
decryptText/decryptObject now read the aad off the stored blob instead of requiring the caller to reconstruct it, since only 1 of the 22 production decrypt call sites derived one and duplicating that string between writer and reader was a correctness footgun with no real payoff. encryptText/encryptObject keep their aad parameter to stamp the value at write time. The one place that read .aad directly (the appointment-token purpose check) keeps its own parameter and comparison, since that guards token purpose separation rather than crypto authentication and now does so alone. Also reverts platform-credential's required-aad schema and its now-inaccurate rationale, since decrypt no longer needs a caller- derived aad to bind against.
1 parent 305897f commit e8eef57

7 files changed

Lines changed: 293 additions & 54 deletions

File tree

packages/business/__tests__/platform-credential-service.test.ts

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,24 @@ vi.mock("@chatbotx.io/database/client", () => ({
99
eq: vi.fn(),
1010
isNull: vi.fn(),
1111
}))
12+
const credentialSchemas = {
13+
instagram: { name: "instagram-schema" },
14+
messenger: { name: "messenger-schema" },
15+
}
16+
const credentialPublicSchemas = {
17+
messenger: { parse: vi.fn((value: unknown) => value) },
18+
}
19+
const credentialEncryptedSchema = {
20+
parse: vi.fn((value: unknown) => value),
21+
}
1222
vi.mock("@chatbotx.io/database/partials", () => ({
13-
credentialEncryptedSchema: {},
14-
credentialPublicSchemas: {},
15-
credentialSchemas: {},
23+
credentialEncryptedSchema,
24+
credentialPublicSchemas,
25+
credentialSchemas,
1626
}))
1727
vi.mock("@chatbotx.io/database/schema", () => ({ platformCredentialModel: {} }))
18-
vi.mock("@chatbotx.io/encryption", () => ({ encryptUtils: {} }))
28+
const encryptUtils = { decryptObject: vi.fn(), encryptObject: vi.fn() }
29+
vi.mock("@chatbotx.io/encryption", () => ({ encryptUtils }))
1930
vi.mock("@chatbotx.io/redis", () => ({
2031
invalidateCacheByTags: vi.fn(async () => undefined),
2132
withCache: vi.fn(async (_key: string, fn: () => unknown) => fn()),
@@ -39,6 +50,9 @@ beforeEach(() => {
3950

4051
afterEach(() => {
4152
vi.restoreAllMocks()
53+
encryptUtils.decryptObject.mockReset()
54+
encryptUtils.encryptObject.mockReset()
55+
credentialEncryptedSchema.parse.mockClear()
4256
})
4357

4458
describe("resolveForOwner", () => {
@@ -229,3 +243,140 @@ describe("resolvePlatformAppAccessToken", () => {
229243
).resolves.toBeUndefined()
230244
})
231245
})
246+
247+
describe("_decrypt", () => {
248+
// decryptObject takes no aad argument: it reads the aad off the blob
249+
// itself (see packages/encryption/src/encryption.ts decryptText), so
250+
// `_decrypt` doesn't need to re-derive the row-scoped aad the writers
251+
// stamped — it just passes the blob and schema through.
252+
test("decrypts a platform-scoped row", async () => {
253+
encryptUtils.decryptObject.mockResolvedValue({ clientId: "client-1" })
254+
255+
await expect(
256+
(platformCredentialService as never)._decrypt({
257+
id: "platform-1",
258+
userId: null,
259+
type: "instagram",
260+
livemode: false,
261+
value: { encrypted: true },
262+
publicConfig: { clientId: "client-1" },
263+
createdAt: new Date("2026-08-13T00:00:00.000Z"),
264+
updatedAt: new Date("2026-08-13T00:00:00.000Z"),
265+
}),
266+
).resolves.toEqual(
267+
expect.objectContaining({
268+
id: "platform-1",
269+
userId: null,
270+
type: "instagram",
271+
config: { clientId: "client-1" },
272+
}),
273+
)
274+
275+
expect(encryptUtils.decryptObject).toHaveBeenCalledTimes(1)
276+
expect(encryptUtils.decryptObject).toHaveBeenCalledWith(
277+
{ encrypted: true },
278+
credentialSchemas.instagram,
279+
)
280+
})
281+
282+
test("decrypts a user-scoped row", async () => {
283+
encryptUtils.decryptObject.mockResolvedValue({ clientId: "client-2" })
284+
285+
await expect(
286+
(platformCredentialService as never)._decrypt({
287+
id: "user-1",
288+
userId: "owner-1",
289+
type: "messenger",
290+
livemode: true,
291+
value: { encrypted: true },
292+
publicConfig: { clientId: "client-2" },
293+
createdAt: new Date("2026-08-13T00:00:00.000Z"),
294+
updatedAt: new Date("2026-08-13T00:00:00.000Z"),
295+
}),
296+
).resolves.toEqual(
297+
expect.objectContaining({
298+
id: "user-1",
299+
userId: "owner-1",
300+
type: "messenger",
301+
config: { clientId: "client-2" },
302+
}),
303+
)
304+
305+
expect(encryptUtils.decryptObject).toHaveBeenCalledTimes(1)
306+
expect(encryptUtils.decryptObject).toHaveBeenCalledWith(
307+
{ encrypted: true },
308+
credentialSchemas.messenger,
309+
)
310+
})
311+
})
312+
313+
// Fakes a minimal chainable drizzle-style tx sufficient for
314+
// upsertForUser/upsertPlatform's `.insert().values().onConflictDoUpdate()`
315+
// call shape, without needing a real DB client.
316+
const fakeTx = () => {
317+
const tx = {
318+
insert: vi.fn(() => tx),
319+
values: vi.fn(() => tx),
320+
onConflictDoUpdate: vi.fn(() => Promise.resolve()),
321+
}
322+
return tx as unknown as Parameters<
323+
typeof platformCredentialService.upsertForUser
324+
>[0]["tx"]
325+
}
326+
327+
describe("upsertForUser / upsertPlatform write path", () => {
328+
// Writers still stamp a row-derived aad at encrypt time — only decrypt
329+
// stopped taking one. These assertions lock in that the derivation
330+
// (user:<id>:<type>:<livemode> / platform:<type>:<livemode>) still reaches
331+
// encryptObject unchanged.
332+
test("upsertForUser encrypts with a row-derived aad", async () => {
333+
encryptUtils.encryptObject.mockResolvedValue({
334+
v: 1,
335+
iv: "iv",
336+
text: "text",
337+
tag: "tag",
338+
aad: "user:owner-1:messenger:false",
339+
})
340+
vi.spyOn(
341+
platformCredentialService,
342+
"invalidateCacheTags",
343+
).mockResolvedValue(undefined)
344+
345+
await platformCredentialService.upsertForUser({
346+
userId: "owner-1",
347+
type: "messenger",
348+
config: { clientId: "c", clientSecret: "s" } as never,
349+
tx: fakeTx(),
350+
})
351+
352+
expect(encryptUtils.encryptObject).toHaveBeenCalledWith(
353+
{ clientId: "c", clientSecret: "s" },
354+
"user:owner-1:messenger:false",
355+
)
356+
})
357+
358+
test("upsertPlatform encrypts with a platform-scoped aad", async () => {
359+
encryptUtils.encryptObject.mockResolvedValue({
360+
v: 1,
361+
iv: "iv",
362+
text: "text",
363+
tag: "tag",
364+
aad: "platform:messenger:false",
365+
})
366+
vi.spyOn(
367+
platformCredentialService,
368+
"invalidateCacheTags",
369+
).mockResolvedValue(undefined)
370+
371+
await platformCredentialService.upsertPlatform({
372+
type: "messenger",
373+
config: { clientId: "c", clientSecret: "s" } as never,
374+
tx: fakeTx(),
375+
})
376+
377+
expect(encryptUtils.encryptObject).toHaveBeenCalledWith(
378+
{ clientId: "c", clientSecret: "s" },
379+
"platform:messenger:false",
380+
)
381+
})
382+
})

packages/database/__tests__/credential.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from "vitest"
22
import {
3+
credentialEncryptedSchema,
34
giphyCredentialUpdateSchema,
45
googleCredentialUpdateSchema,
56
instagramCredentialUpdateSchema,
@@ -141,3 +142,64 @@ describe("credential update schemas", () => {
141142
})
142143
})
143144
})
145+
146+
describe("credentialEncryptedSchema", () => {
147+
const validBlob = {
148+
v: 1 as const,
149+
iv: "a".repeat(24),
150+
text: "ciphertext-hex",
151+
tag: "b".repeat(32),
152+
aad: "user:1:messenger:false",
153+
}
154+
155+
test("accepts a blob with a non-empty aad", () => {
156+
const result = credentialEncryptedSchema.safeParse(validBlob)
157+
expect(result.success).toBe(true)
158+
})
159+
160+
// aad is optional here, matching the transport schema: the writers stamp
161+
// it and decrypt reads it back off the blob, so no reader needs to
162+
// reconstruct or pass one, and a blob with no aad is a legitimate case
163+
// rather than an error.
164+
test("accepts a blob missing aad entirely", () => {
165+
const { aad: _aad, ...blobWithoutAad } = validBlob
166+
const result = credentialEncryptedSchema.safeParse(blobWithoutAad)
167+
expect(result.success).toBe(true)
168+
})
169+
170+
test("kid remains optional for legacy blobs predating key versioning", () => {
171+
const { kid: _kid, ...blobWithoutKid } = { ...validBlob, kid: "k1" }
172+
const result = credentialEncryptedSchema.safeParse(blobWithoutKid)
173+
expect(result.success).toBe(true)
174+
})
175+
176+
// These mirror the length/non-empty checks encryptedDataSchema enforces on
177+
// the same fields (packages/encryption/src/encryption.ts). credentialEncryptedSchema
178+
// is intentionally standalone rather than derived from that schema, so
179+
// without these checks a malformed iv/text/tag would pass this parse and
180+
// only fail later inside hexToBytes/WebCrypto with an opaque error instead
181+
// of a clear validation failure at the storage boundary.
182+
test("rejects a blob with a wrong-length iv", () => {
183+
const result = credentialEncryptedSchema.safeParse({
184+
...validBlob,
185+
iv: "a".repeat(10),
186+
})
187+
expect(result.success).toBe(false)
188+
})
189+
190+
test("rejects a blob with an empty-string text", () => {
191+
const result = credentialEncryptedSchema.safeParse({
192+
...validBlob,
193+
text: "",
194+
})
195+
expect(result.success).toBe(false)
196+
})
197+
198+
test("rejects a blob with a wrong-length tag", () => {
199+
const result = credentialEncryptedSchema.safeParse({
200+
...validBlob,
201+
tag: "b".repeat(10),
202+
})
203+
expect(result.success).toBe(false)
204+
})
205+
})

packages/database/scripts/rotate-encryption-key.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,40 @@ const main = async (): Promise<void> => {
4141

4242
const rows = await db.select().from(platformCredentialModel)
4343

44+
const unparseable: (typeof rows)[number][] = []
4445
const toRotate = rows.filter((row) => {
4546
const result = credentialEncryptedSchema.safeParse(row.value)
46-
return result.success && result.data.kid !== activeKid
47+
if (!result.success) {
48+
unparseable.push(row)
49+
return false
50+
}
51+
return result.data.kid !== activeKid
4752
})
4853

4954
console.log(
5055
`Found ${toRotate.length} of ${rows.length} rows to rotate → kid="${activeKid}".`,
5156
)
5257

58+
if (unparseable.length > 0) {
59+
// These rows fail schema validation (e.g. an unexpected `v`, or a
60+
// malformed iv/text/tag) and are silently excluded from `toRotate`
61+
// above. Left unrotated, they will become
62+
// permanently undecryptable once ENCRYPTION_KEY_PREV is removed, with no
63+
// other signal that they were ever a problem. Surface them loudly here
64+
// instead of letting the exclusion pass unnoticed.
65+
console.error(
66+
`Warning: ${unparseable.length} row(s) failed schema validation and ` +
67+
"will NOT be rotated. They will become undecryptable once " +
68+
"ENCRYPTION_KEY_PREV is removed. Investigate before completing " +
69+
"rotation:",
70+
)
71+
for (const row of unparseable) {
72+
console.error(
73+
` [id:${row.id} userId:${row.userId ?? "platform"} type:${row.type}]`,
74+
)
75+
}
76+
}
77+
5378
if (isDryRun) {
5479
console.log("Dry run — no changes written.")
5580
return
@@ -69,7 +94,7 @@ const main = async (): Promise<void> => {
6994
CredentialByType[CredentialType]
7095
>
7196

72-
const config = await encryptUtils.decryptObject(blob, schema, aad)
97+
const config = await encryptUtils.decryptObject(blob, schema)
7398
const newValue = await encryptUtils.encryptObject(config, aad)
7499

75100
await db

packages/database/src/partials/credential.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,24 @@ export type MakeCredentialUpdate = z.infer<typeof makeCredentialUpdateSchema>
351351

352352
// ─── Encrypted blob shape stored in Credential.value ─────────────────────────
353353

354+
// Storage guard for Credential.value. Deliberately standalone rather than
355+
// derived from @chatbotx.io/encryption's encryptedDataSchema (even though
356+
// packages/database already depends on that package for other things), so
357+
// the field constraints below are mirrored by hand and must be kept in sync
358+
// with encryptedDataSchema if that shape ever changes.
359+
//
360+
// `iv`/`text`/`tag` mirror the length/non-empty checks encryptedDataSchema
361+
// already enforces, so a malformed row fails loudly here instead of only
362+
// surfacing later as an opaque hexToBytes/WebCrypto error. `aad` stays
363+
// optional, matching the transport schema: it is stamped by the writers
364+
// (upsertForUser, upsertPlatform, rotate-encryption-key.ts) and read back
365+
// off the blob by decrypt — no caller needs to reconstruct or pass it.
354366
export const credentialEncryptedSchema = z.object({
355367
v: z.literal(1),
356368
kid: z.string().optional(),
357-
iv: z.string(),
358-
text: z.string(),
359-
tag: z.string(),
369+
iv: z.string().length(24),
370+
text: z.string().min(1),
371+
tag: z.string().length(32),
360372
aad: z.string().optional(),
361373
})
362374
export type CredentialEncrypted = z.infer<typeof credentialEncryptedSchema>

0 commit comments

Comments
 (0)