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) */ /** 解密单条 Chrome Cookie(v10/v11 均为 AES-256-GCM:3 字节版本 + 12 字节 nonce + 密文 + 16 字节 tag)。
function decryptChromeBlob(encrypted: Buffer, key: Buffer): string { * 注意 node:sqlite 返回的 BLOB 是裸 Uint8Array 而非 Buffer——其 toString() 会忽略
const version = encrypted.subarray(0, 3).toString('utf8') * 编码参数、返回逗号拼接的数字,必须先转成 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}`) if (version !== 'v10' && version !== 'v11') throw new Error(`不支持的 Cookie 加密版本: ${version}`)
const nonce = encrypted.subarray(3, 15) const nonce = blob.subarray(3, 15)
const tag = encrypted.subarray(encrypted.length - 16) const tag = blob.subarray(blob.length - 16)
const data = encrypted.subarray(15, encrypted.length - 16) const data = blob.subarray(15, blob.length - 16)
const decipher = createDecipheriv('aes-256-gcm', key, nonce) const decipher = createDecipheriv('aes-256-gcm', key, nonce)
decipher.setAuthTag(tag) decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8') return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
@@ -284,7 +287,7 @@ function applyStagedLocalStorage(staging: string, targetPartitionDir: string): v
} }
type SourceCookieRead = type SourceCookieRead =
| { kind: 'ok'; cookies: BrowserCookieSeed[]; skipped: number } | { kind: 'ok'; cookies: BrowserCookieSeed[]; v20: number; undecryptable: number }
| { kind: 'unsupported'; note: string } | { kind: 'unsupported'; note: string }
/** /**
@@ -317,14 +320,16 @@ async function readSourceCookies(root: string, staging: string): Promise<SourceC
) )
.all() .all()
const cookies: BrowserCookieSeed[] = [] const cookies: BrowserCookieSeed[] = []
let skipped = 0 let v20 = 0
let undecryptable = 0
for (const row of rows) { for (const row of rows) {
try { try {
const r = row as { const r = row as {
host_key: string host_key: string
name: string name: string
value: string value: string
encrypted_value: Buffer | null /** node:sqlite 的 BLOB 是裸 Uint8Array(非 Buffer),用前必须转换 */
encrypted_value: Uint8Array | null
path: string path: string
is_secure: number is_secure: number
is_httponly: number is_httponly: number
@@ -333,10 +338,16 @@ async function readSourceCookies(root: string, staging: string): Promise<SourceC
} }
let value = r.value let value = r.value
if (!value && r.encrypted_value && r.encrypted_value.length > 3) { 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) { if (!value) {
skipped++ undecryptable++
continue continue
} }
cookies.push({ cookies.push({
@@ -351,10 +362,10 @@ async function readSourceCookies(root: string, staging: string): Promise<SourceC
...(mapSameSite(r.samesite) ? { sameSite: mapSameSite(r.samesite) } : {}) ...(mapSameSite(r.samesite) ? { sameSite: mapSameSite(r.samesite) } : {})
}) })
} catch { } catch {
skipped++ undecryptable++
} }
} }
return { kind: 'ok', cookies, skipped } return { kind: 'ok', cookies, v20, undecryptable }
} finally { } finally {
db.close() db.close()
} }
@@ -427,8 +438,11 @@ async function importBrowserProfileInner(sourceId: string, profileDir: string):
if (sourceRead.kind === 'ok') { if (sourceRead.kind === 'ok') {
const result = await browser.importCookies(sourceRead.cookies) const result = await browser.importCookies(sourceRead.cookies)
injected = result.imported injected = result.imported
const skipped = result.failed + sourceRead.skipped const skipNotes: string[] = []
if (skipped > 0) cookieNote = `源 Cookie ${injected} 条已注入,${skipped} 条无法解密已跳过。` 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 { } else {
cookieNote = sourceRead.note cookieNote = sourceRead.note
} }