From 886dec1d2aaa0cab0b2714415fea93e992c0cb90 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:43:11 -0700 Subject: [PATCH] fix(browser): report cookies an import could not decrypt (#14683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): report cookies an import could not decrypt Supersedes #13193, which reported only the Windows v20 case. Nothing distinguished "decryption failed" from "no cookies present". A row that would not decrypt was folded into the generic `skipped` counter, and a profile whose rows all failed returned ok:true with importedCookies:0 and no warning — a green "Imported 0 cookies from Google Chrome." The two situations produce opposite result shapes and the worse one reported success. Attribute the cause at the point of failure, while the version prefix is still in hand, and surface it as one `cookies-undecryptable` warning carrying the reason. Covers all three known causes rather than one prefix: - app-bound-encryption: Chrome/Edge 140+ on Windows write `v20`, which only the writing browser can unwrap. The version gate is a FORMAT check (`/^v\d\d$/`), so v20 passed it and failed inside AES like corruption. - linux-keyring-unavailable: getLinuxEncryptionKey derived the v11 key from an empty password when both secret-tool lookups failed, so it never returned null and the "Could not access encryption key" guard was unreachable on Linux. - unknown: any other cause still warns instead of reporting success. Deliberately not a hard failure on Linux: Chrome falls back to the "peanuts" v10 key precisely when no keyring exists, so those profiles still import. Pinned by a regression test. Refs #13192, #14181 * fix(browser): attribute decrypt failures exactly and gate CBC by version Review-loop findings on the initial commit, all fixed here. - CORRECTNESS: v11 rows were attempted with the v10 key when the keyring was unavailable. AES-128-CBC is unauthenticated, so a wrong key that yields valid PKCS#7 padding was accepted — roughly 1 in 256 per row. Garbage values were written into the jar as real cookies, and because those rows counted as successes the warning this PR adds could never fire. Key eligibility is now explicit per version rather than implicit in key ordering. - CORRECTNESS: the CBC path returned an empty Buffer for a prefix-only value BEFORE checking eligibility. An empty Buffer is truthy, so an ineligible row counted as imported and reached the live-jar clear. Eligibility now precedes that branch and empty CBC ciphertext is rejected as malformed. - ACCURACY: a named cause reported the TOTAL failure count, so one v20 row plus one corrupt row claimed both failed to app-bound encryption. Counts are now exact per cause, with the remainder reported separately and a tie falling back to 'unknown'. Exact-count approach carried over from #13193. - The app-bound copy no longer dead-ends. It names the existing in-app file import without describing how to produce the file — Chrome has no native decrypted-cookie export, so concrete guidance would send users to an extension that can read their whole session jar. - Direct prefix edge tests carried forward from #13193. Repo-wide search found no second multi-key unauthenticated-CBC first-success site, so this pattern was one occurrence rather than a class. Co-authored-by: manuaudio * fix(pr): preserve split worktree slice Remove the unrelated rollback of the worktree-slice split and its forbidden max-lines baseline addition from this cookie-import PR. * fix(browser): match the unknown decrypt reason explicitly CI's type-aware code-quality gate flagged the reason switch as non-exhaustive: the 'unknown' member was handled by `default:` rather than matched. Matching it explicitly keeps the behaviour identical today and makes the gate enforce the thing that matters — adding a new reason to the union now fails the switch instead of falling silently into a generic message that would not describe it. This gate is separate from `oxlint` and is not covered by running oxlint on the changed files, which is why it only surfaced in CI. --------- Co-authored-by: manuaudio --- ...ser-cookie-import-app-bound-prefix.test.ts | 27 ++ ...rowser-cookie-import-undecryptable.test.ts | 259 ++++++++++++++++++ src/main/browser/browser-cookie-import.ts | 155 ++++++++--- src/renderer/src/i18n/locales/en.json | 7 +- .../lib/browser-cookie-import-toast.test.ts | 21 ++ .../src/lib/browser-cookie-import-toast.ts | 33 +++ src/shared/browser-workspace-types.ts | 20 +- 7 files changed, 485 insertions(+), 37 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-app-bound-prefix.test.ts create mode 100644 src/main/browser/browser-cookie-import-undecryptable.test.ts diff --git a/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts b/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts new file mode 100644 index 00000000000..0109269248b --- /dev/null +++ b/src/main/browser/browser-cookie-import-app-bound-prefix.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { isAppBoundEncryptedCookie } from './browser-cookie-import' + +function encryptedCookie(versionPrefix: string): Buffer { + return Buffer.concat([Buffer.from(versionPrefix), Buffer.from([0xde, 0xad, 0xbe, 0xef])]) +} + +describe('isAppBoundEncryptedCookie', () => { + it('detects the v20 app-bound prefix', () => { + expect(isAppBoundEncryptedCookie(encryptedCookie('v20'))).toBe(true) + }) + + it.each(['v10', 'v11'])('leaves the decryptable %s prefix alone', (prefix) => { + expect(isAppBoundEncryptedCookie(encryptedCookie(prefix))).toBe(false) + }) + + it('does not treat a plaintext value as app-bound', () => { + expect(isAppBoundEncryptedCookie(Buffer.from('session=abc123'))).toBe(false) + }) + + it.each([ + ['empty', Buffer.alloc(0)], + ['shorter than the prefix', Buffer.from('v2')] + ])('handles a buffer %s without throwing', (_label, buffer) => { + expect(isAppBoundEncryptedCookie(buffer)).toBe(false) + }) +}) diff --git a/src/main/browser/browser-cookie-import-undecryptable.test.ts b/src/main/browser/browser-cookie-import-undecryptable.test.ts new file mode 100644 index 00000000000..e79485bef20 --- /dev/null +++ b/src/main/browser/browser-cookie-import-undecryptable.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeCrypto from 'node:crypto' +import type * as NodeFs from 'node:fs' + +const { + appGetPathMock, + createDecipherivMock, + execFileSyncMock, + sessionFromPartitionMock, + dialogShowOpenDialogMock, + setPendingCookieImportMock, + clearPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + createDecipherivMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn() +})) + +vi.mock('node:crypto', async (importOriginal) => { + const original = await importOriginal() + createDecipherivMock.mockImplementation(original.createDecipheriv) + return { ...original, createDecipheriv: createDecipherivMock } +}) +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + BrowserWindow: { fromWebContents: vi.fn() }, + dialog: { showOpenDialog: dialogShowOpenDialogMock }, + session: { fromPartition: sessionFromPartitionMock } +})) +vi.mock('./browser-cookie-clear-store', () => ({ + openCookieClearStore: (targetSession: { + cookies: { + get: (filter: object) => Promise + remove: (url: string, name: string) => Promise + } + }) => ({ + get: (filter: object) => targetSession.cookies.get(filter), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie, url }) => ({ url, ...cookie })), + restoreClearIdentities: async () => undefined, + dispose: () => undefined + }) +})) + +import { importCookiesFromBrowser, type DetectedBrowser } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createCipheriv, pbkdf2Sync } from 'node:crypto' + +// Why: Linux derives its cookie key with a single PBKDF2 round, unlike the 1003 macOS uses, so +// the shared macOS helper cannot produce a row this code path will decrypt. +function encryptLinuxChromiumCookie(value: string, password: string, prefix: string): Buffer { + const key = pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1') + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, ' ')) + return Buffer.concat([ + Buffer.from(prefix), + cipher.update(Buffer.from(value, 'latin1')), + cipher.final() + ]) +} + +function chromeBrowser(cookiesPath: string): DetectedBrowser { + return { + family: 'chrome', + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} + +describe('importCookiesFromBrowser — undecryptable cookies', () => { + let tmpDir: string + let cookiesSetMock: ReturnType + let platformSpy: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-linux-keyring-test-')) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + sessionFromPartitionMock.mockReturnValue({ + cookies: { + get: vi.fn().mockResolvedValue([]), + set: cookiesSetMock, + remove: vi.fn().mockResolvedValue(undefined), + flushStore: vi.fn().mockResolvedValue(undefined) + }, + clearData: vi.fn().mockResolvedValue(undefined), + setUserAgent: vi.fn() + }) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + // Why: both secret-tool lookups failing is exactly the keyring-unavailable case. + execFileSyncMock.mockImplementation(() => { + throw new Error('secret-tool: no such service') + }) + }) + + afterEach(() => { + platformSpy.mockRestore() + vi.clearAllMocks() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function seedProfile(...encryptedValues: Buffer[]): string { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase( + sourceCookiesPath, + encryptedValues.map((encryptedValue, index) => ({ + name: `sid-${index}`, + value: '', + encryptedValue + })) + ).close() + createChromiumCookieTestDatabase( + join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'), + [] + ).close() + return sourceCookiesPath + } + + it('does not attempt v11 CBC decryption without a keyring, even with valid wrong-key padding', async () => { + const sourceCookiesPath = seedProfile( + encryptLinuxChromiumCookie('wrong-key-garbage', 'peanuts', 'v11') + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(createDecipherivMock).not.toHaveBeenCalled() + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'linux-keyring-unavailable' + }) + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it.each(['v10', 'v99'])( + 'rejects a prefix-only %s CBC row before importing it', + async (prefix) => { + const sourceCookiesPath = seedProfile(Buffer.from(prefix)) + + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(createDecipherivMock).not.toHaveBeenCalled() + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'unknown' + }) + expect(cookiesSetMock).not.toHaveBeenCalled() + } + ) + + it('reports Windows app-bound (v20) cookies rather than an empty success', async () => { + // Why: Chrome/Edge 140+ v20 rows can only be unwrapped by the writing browser. + const sourceCookiesPath = seedProfile( + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])]) + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'app-bound-encryption' + }) + }) + + it('still imports v10 cookies, which is what a keyring-less profile actually holds', async () => { + const sourceCookiesPath = seedProfile(encryptLinuxChromiumCookie('v10-value', 'peanuts', 'v10')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + // Mutation guard: replacing the keyring-unavailable branch with `return null` must break v10. + expect(result.summary.importedCookies).toBe(1) + expect(result.summary.warning).toBeUndefined() + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid-0', value: 'v10-value' }) + ) + }) + + it('uses an unknown warning when app-bound and corrupt failures are tied', async () => { + const sourceCookiesPath = seedProfile( + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4])]), + Buffer.from('v99-corrupt') + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 2, + reason: 'unknown' + }) + }) + + it('reports a dominant app-bound cause with its exact count and the remainder', async () => { + const appBoundValues = Array.from({ length: 200 }, () => + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4])]) + ) + const sourceCookiesPath = seedProfile(...appBoundValues, Buffer.from('v99-corrupt')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 200, + otherFailedCookies: 1, + reason: 'app-bound-encryption' + }) + }) +}) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 2e2f50dbdd1..581b2b2222b 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -823,12 +823,13 @@ function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { // Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. -type EncryptionKeyResult = { - key: Buffer - mode: 'aes-128-cbc' | 'aes-256-gcm' - // Why: Linux v10 cookies use "peanuts" and v11 the keyring password; both keys are needed to decrypt the full set. - fallbackKey?: Buffer -} +type EncryptionKeyResult = + | { + mode: 'aes-128-cbc' + keysByVersion: Partial> + keyringUnavailable?: boolean + } + | { mode: 'aes-256-gcm'; key: Buffer } export type ChromiumCookieColumnInfo = { name: string @@ -971,8 +972,10 @@ function getMacEncryptionKey( { encoding: 'utf-8', timeout: 30_000 } ).trim() return { - key: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1'), - mode: 'aes-128-cbc' + mode: 'aes-128-cbc', + keysByVersion: { + v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') + } } } catch { return null @@ -983,7 +986,8 @@ function getLinuxEncryptionKey( keychainService: string, keychainAccount: string ): EncryptionKeyResult | null { - // Why: v10 cookies use hardcoded "peanuts", v11 the keyring password; derive both so decrypt can pick by version prefix. + // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded + // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') let keyringPassword = '' @@ -1003,12 +1007,20 @@ function getLinuxEncryptionKey( timeout: 5_000 }).trim() } catch { - diag(' Linux keyring unavailable — v11 cookies may fail to decrypt') + diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') + } + } + + if (!keyringPassword) { + return { + mode: 'aes-128-cbc', + keysByVersion: { v10: v10Key }, + keyringUnavailable: true } } const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - return { key: v11Key, mode: 'aes-128-cbc', fallbackKey: v10Key } + return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } } function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { @@ -1084,6 +1096,53 @@ function stripHmac(buf: Buffer): Buffer { return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf } +// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and +// share it between the decrypt path and the failure attribution. +function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { + if (encryptedBuffer.length < 3) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + return /^v\d\d$/.test(version) ? version : null +} + +// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which +// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. +export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { + return cookieEncryptionVersion(encryptedBuffer) === 'v20' +} + +// Why: a named cause must carry only its exact count; tied causes fall back to unknown. +function buildUndecryptableWarning(counts: { + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number +}): BrowserCookieImportSummary['warning'] { + if (counts.decryptFailed === 0) { + return undefined + } + const unknownFailed = + counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed + const rankedCauses = [ + { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, + { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, + { reason: 'unknown' as const, count: unknownFailed } + ].sort((left, right) => right.count - left.count) + const [dominant, runnerUp] = rankedCauses + + if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { + return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } + } + + const otherFailedCookies = counts.decryptFailed - dominant.count + return { + code: 'cookies-undecryptable', + failedCookies: dominant.count, + reason: dominant.reason, + ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) + } +} + function decryptCookieValueRaw( encryptedBuffer: Buffer, keyResult: EncryptionKeyResult @@ -1101,29 +1160,25 @@ function decryptCookieValueRaw( } // AES-128-CBC (macOS and Linux) + const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined + if (!key) { + return null + } + const ciphertext = encryptedBuffer.subarray(3) if (!ciphertext.length) { - return Buffer.alloc(0) + return null } - // Why: Linux v10 uses the "peanuts" key, v11 the keyring key; try primary then fallback (macOS uses one key). - const keysToTry = - version === 'v10' && keyResult.fallbackKey - ? [keyResult.fallbackKey, keyResult.key] - : [keyResult.key, ...(keyResult.fallbackKey ? [keyResult.fallbackKey] : [])] - - for (const key of keysToTry) { - try { - const iv = Buffer.alloc(16, ' ') - const decipher = createDecipheriv('aes-128-cbc', key, iv) - decipher.setAutoPadding(true) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - continue - } + try { + const iv = Buffer.alloc(16, ' ') + const decipher = createDecipheriv('aes-128-cbc', key, iv) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null } - return null } function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { @@ -1630,6 +1685,9 @@ export async function importCookiesFromBrowser( let imported = 0 let skipped = 0 + let decryptFailed = 0 + let appBoundFailed = 0 + let keyringUnavailableFailed = 0 let integritySkipped = 0 let nonTransplantableSkipped = 0 let memoryLoaded = 0 @@ -1697,8 +1755,26 @@ export async function importCookiesFromBrowser( let decryptedValue: Buffer if (encBuf && encBuf.length > 0) { - const raw = sourceKey ? decryptCookieValueRaw(encBuf, sourceKey) : null + const version = cookieEncryptionVersion(encBuf) + const appBoundIneligible = version === 'v20' + const keyringIneligible = + version === 'v11' && + sourceKey?.mode === 'aes-128-cbc' && + sourceKey.keyringUnavailable === true + const raw = + sourceKey && !appBoundIneligible && !keyringIneligible + ? decryptCookieValueRaw(encBuf, sourceKey) + : null if (!raw) { + // Why: once decrypt returns null every failure looks identical, so attribute the cause + // here while the version prefix is still in hand. Without this an undecryptable profile + // is indistinguishable from an empty one and reports success. + decryptFailed++ + if (appBoundIneligible) { + appBoundFailed++ + } else if (keyringIneligible) { + keyringUnavailableFailed++ + } skipped++ continue } @@ -1765,7 +1841,14 @@ export async function importCookiesFromBrowser( ) const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + const undecryptableWarning = buildUndecryptableWarning({ + decryptFailed, + appBoundFailed, + keyringUnavailableFailed + }) + if (decryptedCookies.length === 0) { + const zeroPathWarning = undecryptableWarning closeStagingDb() discardStagingFile() return { @@ -1776,7 +1859,10 @@ export async function importCookiesFromBrowser( importedCookies: 0, skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - domains: [] + domains: [], + // Why: a profile whose rows cannot be decrypted returns here, and without this it is + // reported as a successful empty import. + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) } } } @@ -1874,6 +1960,13 @@ export async function importCookiesFromBrowser( // Google-bound integrity cookies are already excluded by // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. + // Why: a partial import still drops every undecryptable row, so silence here would report it + // as an unqualified success. The restart-fallback warning describes a lossier outcome and + // keeps precedence. + if (!warning && undecryptableWarning) { + warning = undecryptableWarning + } + const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3477f7aef38..ab43728ae84 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -783,7 +783,12 @@ "toast": { "restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.", "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.", - "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google." + "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google.", + "undecryptableAppBound": "Orca cannot decrypt {{value0}} of this browser's cookies because they use app-bound encryption. You can import cookies from a file using “From File…”.", + "undecryptableAppBoundMixed": "Orca cannot decrypt {{value0}} of this browser's cookies because they use app-bound encryption; {{value1}} more could not be decrypted for another reason. You can import cookies from a file using “From File…”.", + "undecryptableKeyring": "{{value0}} cookies could not be decrypted because the system keyring was unavailable. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.", + "undecryptableKeyringMixed": "{{value0}} cookies could not be decrypted because the system keyring was unavailable; {{value1}} more could not be decrypted for another reason. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.", + "undecryptableUnknown": "{{value0}} cookies could not be decrypted and were skipped. Close the source browser completely and try the import again." } } } diff --git a/src/renderer/src/lib/browser-cookie-import-toast.test.ts b/src/renderer/src/lib/browser-cookie-import-toast.test.ts index 3479887b419..23e04c53034 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.test.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.test.ts @@ -52,6 +52,27 @@ describe('emitBrowserCookieImportToast', () => { expect(warningToastMock).not.toHaveBeenCalled() }) + it('offers the in-app file import without recommending an exporter', () => { + emitBrowserCookieImportToast( + { + ...summary, + warning: { + code: 'cookies-undecryptable', + failedCookies: 3, + reason: 'app-bound-encryption' + } + }, + 'Imported 0 cookies.', + 'Local Windows' + ) + + const message = warningToastMock.mock.calls[0]?.[0] + expect(message).toBe( + "Orca cannot decrypt 3 of this browser's cookies because they use app-bound encryption. You can import cookies from a file using “From File…”." + ) + expect(message).not.toContain('export') + }) + it('shows separate host-specific Google guidance after success', () => { emitBrowserCookieImportToast( { ...summary, importedCookies: 2, skippedCookies: 1, googleCookiesSkipped: 1 }, diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts index 9e18e3c96c8..a8fa4aa6bae 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -21,6 +21,39 @@ function formatCookieImportWarning(warning: CookieImportWarning): string { value1: warning.loadedCookies + warning.failedCookies } ) + case 'cookies-undecryptable': + switch (warning.reason) { + case 'app-bound-encryption': + return warning.otherFailedCookies + ? translate( + 'auto.lib.browser.cookie.import.toast.undecryptableAppBoundMixed', + "Orca cannot decrypt {{value0}} of this browser's cookies because they use app-bound encryption; {{value1}} more could not be decrypted for another reason. You can import cookies from a file using “From File…”.", + { value0: warning.failedCookies, value1: warning.otherFailedCookies } + ) + : translate( + 'auto.lib.browser.cookie.import.toast.undecryptableAppBound', + "Orca cannot decrypt {{value0}} of this browser's cookies because they use app-bound encryption. You can import cookies from a file using “From File…”.", + { value0: warning.failedCookies } + ) + case 'linux-keyring-unavailable': + return warning.otherFailedCookies + ? translate( + 'auto.lib.browser.cookie.import.toast.undecryptableKeyringMixed', + '{{value0}} cookies could not be decrypted because the system keyring was unavailable; {{value1}} more could not be decrypted for another reason. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.', + { value0: warning.failedCookies, value1: warning.otherFailedCookies } + ) + : translate( + 'auto.lib.browser.cookie.import.toast.undecryptableKeyring', + '{{value0}} cookies could not be decrypted because the system keyring was unavailable. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.', + { value0: warning.failedCookies } + ) + case 'unknown': + return translate( + 'auto.lib.browser.cookie.import.toast.undecryptableUnknown', + '{{value0}} cookies could not be decrypted and were skipped. Close the source browser completely and try the import again.', + { value0: warning.failedCookies } + ) + } } } diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts index 67619decd58..dbd796833b3 100644 --- a/src/shared/browser-workspace-types.ts +++ b/src/shared/browser-workspace-types.ts @@ -143,11 +143,21 @@ export type BrowserCookieImportSummary = { skippedCookies: number googleCookiesSkipped?: number domains: string[] - warning?: { - code: 'restart-fallback-unavailable' - loadedCookies: number - failedCookies: number - } + warning?: + | { + code: 'restart-fallback-unavailable' + loadedCookies: number + failedCookies: number + } + | { + // Why: a row that will not decrypt is indistinguishable from a corrupt one by the time + // decrypt returns null, so the cause is captured at the point of failure and reported + // here. Without this an undecryptable profile is reported as a successful empty import. + code: 'cookies-undecryptable' + failedCookies: number + otherFailedCookies?: number + reason: 'app-bound-encryption' | 'linux-keyring-unavailable' | 'unknown' + } } export type BrowserCookieImportResult =