diff --git a/changelog.d/fixes/11500-decrypt-log-dedup.md b/changelog.d/fixes/11500-decrypt-log-dedup.md new file mode 100644 index 000000000000..d8d6cebbcb46 --- /dev/null +++ b/changelog.d/fixes/11500-decrypt-log-dedup.md @@ -0,0 +1 @@ +- **fix(db):** dedupe the raw `[Encryption] Decryption failed...` log line emitted by the lazy-decrypt views (`createLazyRowProxy`/`createLazyConnectionView`), which power `getProviderConnections()` and were re-triggering that line on every CredentialHealth/model-sync cycle for the same corrupt or stale-key credential — a fresh Proxy over a fresh row on every cycle meant the per-proxy memoization never suppressed it, unlike the dedup `decryptConnectionFields()` already had since [#9927](https://github.com/diegosouzapw/OmniRoute/issues/9927). Now shares that dedupe tracking so the line logs at most once per credential ([#11500](https://github.com/diegosouzapw/OmniRoute/issues/11500)). diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index 7b519f948003..389ffbef6b4f 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -288,6 +288,36 @@ export function decrypt( } } +/** + * #11500 — decrypt() wrapper for callers outside decryptConnectionFields() + * (the lazy-decrypt views in providers/lazyConnectionView.ts, which call + * decrypt() directly on every fresh getProviderConnections() cycle). A + * fresh Proxy wraps a fresh row object each cycle, so per-proxy memoization + * never survives across cycles — without this wrapper the raw + * "[Encryption] Decryption failed..." line re-fires every single cycle for + * the same corrupt/stale-key credential. Shares the loggedDecryptFailures + * Set with decryptConnectionFields() so a credential already flagged via one + * path does not re-log via the other, and logs the SAME raw message + * decrypt() would emit (unlike decryptConnectionFields()'s enriched + * message) — just deduped to once per (provider + connection + field + + * ciphertext) instead of once per cycle. + */ +export function decryptQuiet( + ciphertext: string | null | undefined, + meta: { connectionId: string; provider: string; field: string } +): string | null | undefined { + if (!looksEncrypted(ciphertext)) { + return decrypt(ciphertext); + } + const signature = `${meta.provider}::${meta.connectionId}::${meta.field}:${ciphertext}`; + const alreadyLogged = loggedDecryptFailures.has(signature); + const result = decrypt(ciphertext, { quiet: alreadyLogged }); + if (result === null && !alreadyLogged) { + loggedDecryptFailures.add(signature); + } + return result; +} + /** * Encrypt sensitive fields in a connection object (mutates in-place). * After decryption that required legacy key, re-encrypt with static key diff --git a/src/lib/db/providers/lazyConnectionView.ts b/src/lib/db/providers/lazyConnectionView.ts index db7f215ee704..23c4e68b6b8b 100644 --- a/src/lib/db/providers/lazyConnectionView.ts +++ b/src/lib/db/providers/lazyConnectionView.ts @@ -9,7 +9,7 @@ * admin, and catalog callers during Phase 2/3 of the lazy-decrypt rollout. */ -import { decrypt } from "../encryption"; +import { decryptQuiet } from "../encryption"; type JsonRecord = Record; @@ -119,10 +119,18 @@ export function createLazyConnectionView(row: Record): Provider const ensureDecrypted = () => { if (!decrypted) { + const connectionId = base.id; + const provider = base.provider; decrypted = { - apiKey: toStringOrNull(decrypt(base.apiKey)), - accessToken: toStringOrNull(decrypt(base.accessToken)), - refreshToken: toStringOrNull(decrypt(base.refreshToken)), + apiKey: toStringOrNull( + decryptQuiet(base.apiKey, { connectionId, provider, field: "apiKey" }) + ), + accessToken: toStringOrNull( + decryptQuiet(base.accessToken, { connectionId, provider, field: "accessToken" }) + ), + refreshToken: toStringOrNull( + decryptQuiet(base.refreshToken, { connectionId, provider, field: "refreshToken" }) + ), }; } return decrypted; @@ -154,11 +162,13 @@ export function createLazyRowProxy(row: Record): Record { if (!decrypted) { + const connectionId = typeof row.id === "string" ? row.id : ""; + const provider = typeof row.provider === "string" ? row.provider : "unknown"; decrypted = { - apiKey: lazyDecrypt(row.apiKey), - accessToken: lazyDecrypt(row.accessToken), - refreshToken: lazyDecrypt(row.refreshToken), - idToken: lazyDecrypt(row.idToken), + apiKey: lazyDecrypt(row.apiKey, { connectionId, provider, field: "apiKey" }), + accessToken: lazyDecrypt(row.accessToken, { connectionId, provider, field: "accessToken" }), + refreshToken: lazyDecrypt(row.refreshToken, { connectionId, provider, field: "refreshToken" }), + idToken: lazyDecrypt(row.idToken, { connectionId, provider, field: "idToken" }), }; } return decrypted; @@ -189,7 +199,10 @@ export function createLazyRowProxy(row: Record): Record { + if (ORIGINAL_STORAGE_KEY === undefined) { + delete process.env.STORAGE_ENCRYPTION_KEY; + } else { + process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_KEY; + } +}); + +function captureConsoleError(fn: () => void): string[] { + const original = console.error; + const logs: string[] = []; + console.error = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }; + try { + fn(); + } finally { + console.error = original; + } + return logs; +} + +test("#11500 — createLazyRowProxy dedupes decrypt-failure logging across sync cycles", async () => { + process.env.STORAGE_ENCRYPTION_KEY = "probe-11500-current-key"; + + const { createLazyRowProxy } = await importFresh("src/lib/db/providers/lazyConnectionView.ts"); + + // Credential encrypted with a DIFFERENT key than the one currently + // configured (stale STORAGE_ENCRYPTION_KEY / corrupted row) — produces + // exactly "Auth tag validation likely failed." + const staleCiphertext = encryptWithStaticSalt( + "some-other-key-that-was-rotated-away", + "omniroute-field-encryption-v1", + "sk-super-secret-api-key" + ); + + const rawRow = { + id: "conn-zai-1", + provider: "zai", + apiKey: staleCiphertext, + accessToken: null, + refreshToken: null, + idToken: null, + }; + + const capturedLines = captureConsoleError(() => { + // Simulate 3 separate CredentialHealth / model-sync cycles, each of + // which calls getProviderConnections() fresh and gets a brand-new + // createLazyRowProxy() over a brand-new row object for the SAME + // underlying corrupt DB row. + for (let cycle = 0; cycle < 3; cycle++) { + const view = createLazyRowProxy({ ...rawRow }); + void view.apiKey; + } + }); + + const rawDecryptFailureLines = capturedLines.filter((line) => + line.includes("[Encryption] Decryption failed. Ciphertext prefix:") + ); + + assert.equal( + rawDecryptFailureLines.length, + 1, + `expected the raw decrypt-failure line to be logged at most once across 3 sync cycles for the ` + + `same corrupt credential, but it was logged ${rawDecryptFailureLines.length} times: ` + + JSON.stringify(rawDecryptFailureLines, null, 2) + ); +}); + +test("#11500 — createLazyConnectionView dedupes decrypt-failure logging across sync cycles", async () => { + process.env.STORAGE_ENCRYPTION_KEY = "probe-11500-current-key-view"; + + const { createLazyConnectionView } = await importFresh( + "src/lib/db/providers/lazyConnectionView.ts" + ); + + const staleCiphertext = encryptWithStaticSalt( + "some-other-key-that-was-rotated-away-view", + "omniroute-field-encryption-v1", + "sk-super-secret-api-key-view" + ); + + const rawRow = { + id: "conn-glm-1", + provider: "glm", + apiKey: staleCiphertext, + accessToken: null, + refreshToken: null, + }; + + const capturedLines = captureConsoleError(() => { + for (let cycle = 0; cycle < 3; cycle++) { + const view = createLazyConnectionView({ ...rawRow }); + void view.apiKey; + } + }); + + const rawDecryptFailureLines = capturedLines.filter((line) => + line.includes("[Encryption] Decryption failed. Ciphertext prefix:") + ); + + assert.equal( + rawDecryptFailureLines.length, + 1, + `expected the raw decrypt-failure line to be logged at most once across 3 sync cycles for the ` + + `same corrupt credential, but it was logged ${rawDecryptFailureLines.length} times: ` + + JSON.stringify(rawDecryptFailureLines, null, 2) + ); +});