fix(browser): report cookies an import could not decrypt (#14683)

* 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 <manuaudio@users.noreply.github.com>

* 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 <manuaudio@users.noreply.github.com>
This commit is contained in:
Brennan Benson
2026-08-16 18:43:11 -07:00
committed by GitHub
co-authored by manuaudio
parent 5e189d6081
commit 886dec1d2a
7 changed files with 485 additions and 37 deletions
@@ -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)
})
})
@@ -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<typeof NodeCrypto>()
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<typeof NodeFs>()) }))
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<unknown>
remove: (url: string, name: string) => Promise<void>
}
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; 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<typeof vi.fn>
let platformSpy: ReturnType<typeof vi.spyOn>
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'
})
})
})
+124 -31
View File
@@ -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<Record<'v10' | 'v11', Buffer>>
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,
+6 -1
View File
@@ -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."
}
}
}
@@ -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 },
@@ -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 }
)
}
}
}
+15 -5
View File
@@ -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 =