fix(browser): node:sqlite 的 BLOB 是裸 Uint8Array,Cookie 解密前必须转 Buffer

This commit is contained in:
Rick Ye
2026-09-13 01:13:46 +08:00
parent d8c20022d8
commit 2efcd8e346
@@ -206,13 +206,16 @@ function dpapiUnprotectKey(encodedKey: string): Promise<Buffer | null> {
})
}
/** 解密单条 Chrome Cookie(v10/v11 均为 AES-256-GCM:3 字节版本 + 12 字节 nonce + 密文 + 16 字节 tag) */
function decryptChromeBlob(encrypted: Buffer, key: Buffer): string {
const version = encrypted.subarray(0, 3).toString('utf8')
/** 解密单条 Chrome Cookie(v10/v11 均为 AES-256-GCM:3 字节版本 + 12 字节 nonce + 密文 + 16 字节 tag)。
* 注意 node:sqlite 返回的 BLOB 是裸 Uint8Array 而非 Buffer——其 toString() 会忽略
* 编码参数、返回逗号拼接的数字,必须先转成 Buffer 才能按 utf8 读版本头。 */
function decryptChromeBlob(encrypted: Uint8Array, key: Buffer): string {
const blob = Buffer.isBuffer(encrypted) ? encrypted : Buffer.from(encrypted)
const version = blob.subarray(0, 3).toString('utf8')
if (version !== 'v10' && version !== 'v11') throw new Error(`不支持的 Cookie 加密版本: ${version}`)
const nonce = encrypted.subarray(3, 15)
const tag = encrypted.subarray(encrypted.length - 16)
const data = encrypted.subarray(15, encrypted.length - 16)
const nonce = blob.subarray(3, 15)
const tag = blob.subarray(blob.length - 16)
const data = blob.subarray(15, blob.length - 16)
const decipher = createDecipheriv('aes-256-gcm', key, nonce)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
@@ -284,7 +287,7 @@ function applyStagedLocalStorage(staging: string, targetPartitionDir: string): v
}
type SourceCookieRead =
| { kind: 'ok'; cookies: BrowserCookieSeed[]; skipped: number }
| { kind: 'ok'; cookies: BrowserCookieSeed[]; v20: number; undecryptable: number }
| { kind: 'unsupported'; note: string }
/**
@@ -317,14 +320,16 @@ async function readSourceCookies(root: string, staging: string): Promise<SourceC
)
.all()
const cookies: BrowserCookieSeed[] = []
let skipped = 0
let v20 = 0
let undecryptable = 0
for (const row of rows) {
try {
const r = row as {
host_key: string
name: string
value: string
encrypted_value: Buffer | null
/** node:sqlite 的 BLOB 是裸 Uint8Array(非 Buffer),用前必须转换 */
encrypted_value: Uint8Array | null
path: string
is_secure: number
is_httponly: number
@@ -333,10 +338,16 @@ async function readSourceCookies(root: string, staging: string): Promise<SourceC
}
let value = r.value
if (!value && r.encrypted_value && r.encrypted_value.length > 3) {
value = decryptChromeBlob(r.encrypted_value, key)
const encrypted = Buffer.from(r.encrypted_value)
// v20 = 应用绑定加密(Chrome 127+ 逐步启用),第三方无法解密,单独计数便于如实提示
if (encrypted.subarray(0, 3).toString('utf8') === 'v20') {
v20++
continue
}
value = decryptChromeBlob(encrypted, key)
}
if (!value) {
skipped++
undecryptable++
continue
}
cookies.push({
@@ -351,10 +362,10 @@ async function readSourceCookies(root: string, staging: string): Promise<SourceC
...(mapSameSite(r.samesite) ? { sameSite: mapSameSite(r.samesite) } : {})
})
} catch {
skipped++
undecryptable++
}
}
return { kind: 'ok', cookies, skipped }
return { kind: 'ok', cookies, v20, undecryptable }
} finally {
db.close()
}
@@ -427,8 +438,11 @@ async function importBrowserProfileInner(sourceId: string, profileDir: string):
if (sourceRead.kind === 'ok') {
const result = await browser.importCookies(sourceRead.cookies)
injected = result.imported
const skipped = result.failed + sourceRead.skipped
if (skipped > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipped} 条无法解密已跳过。`
const skipNotes: string[] = []
if (sourceRead.v20 > 0) skipNotes.push(`${sourceRead.v20} 条为应用绑定加密(v20)无法迁移`)
const failedCount = result.failed + sourceRead.undecryptable
if (failedCount > 0) skipNotes.push(`${failedCount} 条解密失败已跳过`)
if (skipNotes.length > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipNotes.join(',')}。`
} else {
cookieNote = sourceRead.note
}