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 =