mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
127 lines
4.3 KiB
TypeScript
127 lines
4.3 KiB
TypeScript
import { createDecipheriv } from 'node:crypto'
|
|
import type { BrowserCookieImportSummary } from '../../shared/browser-workspace-types'
|
|
import type { EncryptionKeyResult } from './browser-cookie-sqlite'
|
|
|
|
// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix.
|
|
const CHROMIUM_COOKIE_HMAC_LEN = 32
|
|
|
|
function hasHmacPrefix(buf: Buffer): boolean {
|
|
if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) {
|
|
return false
|
|
}
|
|
let nonPrintable = 0
|
|
for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) {
|
|
if (buf[i] < 0x20 || buf[i] > 0x7e) {
|
|
nonPrintable++
|
|
}
|
|
}
|
|
return nonPrintable >= 8
|
|
}
|
|
|
|
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.
|
|
export 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.
|
|
export 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 } : {})
|
|
}
|
|
}
|
|
|
|
export function decryptCookieValueRaw(
|
|
encryptedBuffer: Buffer,
|
|
keyResult: EncryptionKeyResult
|
|
): Buffer | null {
|
|
if (!encryptedBuffer || encryptedBuffer.length === 0) {
|
|
return null
|
|
}
|
|
const version = encryptedBuffer.subarray(0, 3).toString('utf-8')
|
|
if (!/^v\d\d$/.test(version)) {
|
|
return null
|
|
}
|
|
|
|
if (keyResult.mode === 'aes-256-gcm') {
|
|
return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key)
|
|
}
|
|
|
|
// 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 null
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null {
|
|
// Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag]
|
|
if (payload.length < 12 + 16) {
|
|
return null
|
|
}
|
|
const nonce = payload.subarray(0, 12)
|
|
const authTag = payload.subarray(-16)
|
|
const ciphertext = payload.subarray(12, -16)
|
|
try {
|
|
const decipher = createDecipheriv('aes-256-gcm', key, nonce)
|
|
decipher.setAuthTag(authTag)
|
|
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
return stripHmac(decrypted)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|