From 6dd9237bf4b8696ab89b99877d1f991874490c62 Mon Sep 17 00:00:00 2001 From: Native Muttaqien Date: Wed, 19 Aug 2026 15:32:00 +0700 Subject: [PATCH] feat: add a Firefox cookie backend, the first that works off macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-fetch was macOS-only because both backends lean on the login Keychain. Firefox needs no keychain at all: cookies.sqlite stores values in plaintext on every OS, so one backend covers Windows, macOS and Linux and adds no dependency — node:sqlite was already imported for the Chromium DB. This is also the only route to auto-fetch on Windows. Chromium there is closed, not pending: since Chrome 127 App-Bound Encryption wraps the cookie key in SYSTEM-DPAPI, and every published bypass is COM hijacking or injection into the elevation service. Two things the backend has to get right: - Profiles are ranked by cookies.sqlite mtime, newest first, instead of parsing profiles.ini. Someone with several profiles is logged in on the one they last wrote to, which is what an mtime sort answers directly. - Firefox holds the DB open in WAL mode and on Windows that lock is enforced, so the DB is copied to a temp dir with its -wal sidecar and the copy is opened writable — replaying a WAL needs write access, and the sidecar holds cookies set this session that are not checkpointed yet. macOS behaviour is unchanged: the keychain-backed backends still run first and in their original order, with Firefox filling only what they left empty. Tested against real cookies.sqlite files built by node:sqlite — host ranking, profile ordering, and the failure paths. Firefox was not installed on the development machine, so this has not run against a genuine logged-in profile. Co-Authored-By: Claude Opus 5 --- README.md | 25 ++++++++----- docs/CONTINUATION.md | 56 +++++++++++++++++------------ src/browser.ts | 75 ++++++++++++++++++++++++++++++++++---- tests/browser.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 201 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 3770282..e5a0ed3 100644 --- a/README.md +++ b/README.md @@ -66,17 +66,24 @@ dozen-odd cookies, so copy the entire `cookie:` value: on [music.youtube.com](ht DevTools → **Network** → any request → **Request Headers** → `cookie:`. Google sessions also expire faster than the other two, so expect to redo this occasionally. -### Auto-fetch from your browser (macOS) +### Auto-fetch from your browser -Rather than copying cookies by hand, plx can read them straight from a logged-in browser — you only -accept the macOS Keychain prompt. Pick **"Auto-fetch credentials (from browser)"** from the menu, or -let it run automatically when a credential is missing. Works with the Chromium family (Brave, Chrome, -Edge, Chromium, Vivaldi, Opera) and Safari (needs Full Disk Access). Manual paste stays the fallback -everywhere else. +Rather than copying cookies by hand, plx can read them straight from a logged-in browser. Pick +**"Auto-fetch credentials (from browser)"** from the menu, or let it run automatically when a +credential is missing. Manual paste stays the fallback everywhere. -**Windows and Linux:** plx itself runs fine — every conversion works the same. Only this auto-fetch -shortcut is macOS-only, so paste the cookies once as described above and they're stored like any -other credential. +| Browser | macOS | Windows | Linux | +|---|---|---|---| +| Firefox | ✅ | ✅ | ✅ | +| Chromium family (Brave, Chrome, Edge, Chromium, Vivaldi, Opera) | ✅ | — | — | +| Safari | ✅ — needs Full Disk Access | — | — | + +On macOS the Chromium and Safari backends ask you to accept the Keychain prompt once. Firefox needs +no prompt on any OS, since it stores cookie values unencrypted. + +Chromium on Windows isn't a gap waiting to be filled: since Chrome 127 the cookie key is sealed +behind App-Bound Encryption that only a SYSTEM-level process can unwrap, and every published +workaround is the sort of thing malware does. On Windows, use Firefox or paste manually. Set credentials in `.env`, export them in your shell, or paste when prompted. Full setup: [`docs/CONFIG.md`](docs/CONFIG.md). diff --git a/docs/CONTINUATION.md b/docs/CONTINUATION.md index fc860df..47a18b5 100644 --- a/docs/CONTINUATION.md +++ b/docs/CONTINUATION.md @@ -182,7 +182,7 @@ Verified live this session: - Dedupe on write: existing-track skip + **in-batch duplicate removal** (`[...new Set(ids)]`). Deezer rejects any chunk containing a repeated id (`PlaylistAddTracksError { isNotAllowed }`). - Existing-playlist target: `listPlaylists` (GetUserPlaylists) + paste link (`resolveDeezerPlaylistId` follows `link.deezer.com/s/…` shortlinks). -## Semi-auto credential fill (macOS: Chromium family + Safari) — DONE (2026-08-13) +## Semi-auto credential fill (Chromium family + Safari + Firefox) — DONE (2026-08-13, Firefox 2026-08-19) Instead of fully manual copy-paste, the ARL / `sp_dc` inputs now try to **read the cookie from a logged-in browser session** first — the user only has to accept the @@ -200,29 +200,41 @@ OS permission dialog — and fall back to manual paste if nothing is found. Parser mirrors `browser_cookie3` (`cook` magic + big-endian page table, then little-endian cookie records with null-terminated strings at record-relative offsets). +- **Firefox** — the only backend that is not macOS-only, and the cheapest of the + three. `cookies.sqlite` stores values in **plaintext** on every OS: no keychain, + no DPAPI, no permission dialog. (`key4.db` is widely assumed to encrypt these — + it does not, it protects saved *logins*.) Two things it does need: + - Profile discovery sorts candidate `cookies.sqlite` files by **mtime**, newest + first, rather than parsing `profiles.ini`. Someone with several profiles is + logged in on the one they last wrote to, which is the question `profiles.ini` + answers less directly. + - The DB is WAL-mode and held open by a running Firefox — on Windows that lock + is enforced and reading in place fails. It is copied to a temp dir with its + `-wal` sidecar (a cookie set this session may not be checkpointed yet) and the + copy is opened **writable**, because replaying a WAL requires write access. - Wired into `ensureDeezer`/`ensureSpotify` + the `arl`/`sp_dc` settings entries via `tryAutoFillCredentials(cfg)` — additive: env vars and stored credentials win, - manual paste stays the fallback. First use pops the macOS Keychain "Allow" dialog. - -Out of scope, with the reasons — these are not all equally hard: - -- **Windows, Chromium family — treat as closed, not merely unimplemented.** Plain - DPAPI only ever covered Chrome < 127. Since Chrome 127 (July 2024) App-Bound - Encryption stores cookies as `v20` blobs whose key is wrapped by User-DPAPI and - then SYSTEM-DPAPI, so only a SYSTEM process or `elevation_service.exe` can - unwrap it — the feature exists specifically to stop what this module does. Every - published bypass is COM hijacking or injection into the elevation service, which - is malware behaviour and would get plx flagged as an infostealer. Do not ship it. -- **Linux, Chromium family** — libsecret/kwallet. Unimplemented, not blocked. -- **Firefox — the actually cheap one, and the note it replaces was wrong.** - `key4.db` protects saved *logins*, not cookies; `cookies.sqlite` stores cookie - values in **plaintext** on every OS. No DPAPI, no App-Bound Encryption, and it - reuses the `node:sqlite` import already here. One backend would cover Windows, - macOS and Linux at once. Gotchas: profile discovery via `profiles.ini`, and the - DB is WAL-mode and held open by the browser, so copy it (plus `-wal`) to a temp - file rather than relying on `readOnly: true`. - -If auto-fetch on Windows is ever asked for, Firefox is the answer; Chromium is not. + manual paste stays the fallback. On macOS the two keychain-backed backends run + first and keep their original order, so nothing changed for existing users; + Firefox only fills what they left empty. + +**Not verified live.** Firefox was not installed on the development machine, so the +backend is covered by tests against a real `cookies.sqlite` built by `node:sqlite` +— schema, host ranking, ordering and the failure paths — but has never been run +against a genuine logged-in profile. First real Firefox user is the actual test. + +Still out of scope, and the two reasons are not equivalent: + +- **Windows, Chromium family — closed, not merely unimplemented.** Plain DPAPI only + ever covered Chrome < 127. Since Chrome 127 (July 2024) App-Bound Encryption + stores cookies as `v20` blobs whose key is wrapped by User-DPAPI and then + SYSTEM-DPAPI, so only a SYSTEM process or `elevation_service.exe` can unwrap it — + the feature exists specifically to stop what this module does. Every published + bypass is COM hijacking or injection into the elevation service, which is malware + behaviour and would get plx flagged as an infostealer. Do not ship it. Firefox is + the answer on Windows. +- **Linux, Chromium family** — libsecret/kwallet. Unimplemented, not blocked; worth + doing only if someone actually asks, since Firefox already covers Linux. ## Deezer → Spotify (WORKING — verified live) diff --git a/src/browser.ts b/src/browser.ts index 8aaf52d..1836d34 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -1,18 +1,21 @@ import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; -import { homedir, platform } from 'node:os'; +import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { homedir, platform, tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { YTMUSIC_COOKIES, cookieHeader } from './ytmusic.js'; // Read the Deezer `arl` / Spotify `sp_dc` cookies from an already-logged-in // browser session so the user only has to accept the OS keychain permission. -// macOS only for now. Two backends: +// Three backends, two of them macOS-only: // - Chromium family (Brave/Chrome/Edge/Chromium/…) — SQLite cookie DB, values -// AES-128-CBC encrypted under a key held in the login Keychain. +// AES-128-CBC encrypted under a key held in the login Keychain. macOS only, +// and deliberately so: see the Windows note on `readFirefox` below. // - Safari — `Cookies.binarycookies`, plaintext (no encryption) but TCC-gated -// (requires Full Disk Access, not just a keychain "Allow"). +// (requires Full Disk Access, not just a keychain "Allow"). macOS only. +// - Firefox — `cookies.sqlite`, plaintext on every OS. The only backend that +// works on Windows and Linux. // Cookie name → value, for every cookie plx cares about that a browser happened to have. Kept in // cookie terms; the mapping to one credential per provider happens in fetchBrowserCredentials. @@ -171,16 +174,74 @@ function readSafari(): BrowserCredentials { return {}; } +// Firefox — the one backend that is not macOS-only. `cookies.sqlite` stores values in **plaintext**: +// no keychain, no DPAPI, and none of the App-Bound Encryption that closed the Chromium family off on +// Windows (Chrome 127+ wraps the cookie key in SYSTEM-DPAPI, so only a SYSTEM process can unwrap it, +// and every published bypass is malware behaviour). `key4.db` is commonly assumed to encrypt these +// too — it does not; it protects saved *logins*. +const FIREFOX_ROOTS: Record = { + win32: join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'Mozilla', 'Firefox'), + darwin: join(homedir(), 'Library', 'Application Support', 'Firefox'), + linux: join(homedir(), '.mozilla', 'firefox'), +}; + +// Every profile holding a cookie DB, most-recently-written first. `profiles.ini` names the default +// one, but reading it needs an INI parser to answer worse than an mtime sort already does: someone +// with several profiles is logged in on the one they actually use, which is the one just written to. +export function firefoxProfiles(root: string = FIREFOX_ROOTS[platform()] ?? FIREFOX_ROOTS.linux): string[] { + const nested = join(root, 'Profiles'); + const base = existsSync(nested) ? nested : root; // some Linux packagings drop the Profiles/ level + try { + return readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(base, entry.name, 'cookies.sqlite')) + .filter(existsSync) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + } catch { + return []; // no Firefox installed, or an unreadable profile root + } +} + +// Firefox keeps the DB in WAL mode and holds it open while running — on Windows that lock is +// enforced, so reading the file in place fails outright. Copy it to a temp dir and read the copy. +// The `-wal` sidecar comes along because a cookie set this session may not be checkpointed into the +// main file yet, and the copy is opened writable precisely so SQLite is allowed to replay it. +export function readFirefox(dbPath: string): BrowserCredentials { + let dir: string | null = null; + try { + dir = mkdtempSync(join(tmpdir(), 'plx-ff-')); + const copy = join(dir, 'cookies.sqlite'); + copyFileSync(dbPath, copy); + if (existsSync(`${dbPath}-wal`)) copyFileSync(`${dbPath}-wal`, `${copy}-wal`); + const db = new DatabaseSync(copy); + try { + const query = `SELECT name, host, value FROM moz_cookies WHERE name IN (${COOKIE_NAMES.map(() => '?').join(',')})`; + // `host` is stored dot-prefixed for domain cookies (`.youtube.com`); hostRank's endsWith arm + // already accepts that, so it is passed through as-is. + return collect(db.prepare(query).all(...COOKIE_NAMES) as Array<{ name: string; host: string; value: string }>); + } finally { + db.close(); + } + } catch { + return {}; // unreadable profile / changed schema — skip it + } finally { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +} + // Auto-retrieve credentials from the first logged-in browser that has them, keyed by provider — // the parsers above speak in cookie names, and this is where that becomes one opaque string per // provider for the config layer to store. Never throws: a missing browser, locked DB, denied // keychain, or TCC block just yields {}. Manual paste stays the fallback. export function fetchBrowserCredentials(): Record { - if (platform() !== 'darwin') return {}; + // The keychain-backed backends stay gated on macOS; Firefox runs everywhere and is all that + // Windows and Linux get. macOS keeps its original order, so nothing changes for anyone who + // already relied on this — Firefox only fills what the others left empty. + const keychainBacked = platform() === 'darwin' ? [...CHROMIUM_BROWSERS.map(readChromium), readSafari()] : []; const credentials: Record = {}; // Every backend is read rather than stopping at the first: a user may be logged into Deezer in one // browser and YouTube Music in another. - for (const found of [...CHROMIUM_BROWSERS.map(readChromium), readSafari()]) { + for (const found of [...keychainBacked, ...firefoxProfiles().map(readFirefox)]) { if (!credentials.deezer && found.arl) credentials.deezer = found.arl; if (!credentials.spotify && found.sp_dc) credentials.spotify = found.sp_dc; // A cookie bundle is only a session as a *set* from one profile, so it is built per browser and diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 4f3c2e2..b837ebd 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -1,6 +1,10 @@ import { createCipheriv, pbkdf2Sync } from 'node:crypto'; +import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { describe, expect, it } from 'vitest'; -import { decryptCookie, parseSafariCookies } from '../src/browser.js'; +import { decryptCookie, firefoxProfiles, parseSafariCookies, readFirefox } from '../src/browser.js'; import { cookieHeader } from '../src/ytmusic.js'; const key = pbkdf2Sync('test-password', 'saltysalt', 1003, 16, 'sha1'); @@ -96,3 +100,81 @@ describe('parseSafariCookies', () => { expect(cookieHeader(found)).toBe('SAPISID=sap; SID=sid; HSID=hs'); }); }); + +// Firefox needs no encryption fixture at all — that is the whole point of the backend. The schema plx +// touches is three columns of `moz_cookies`, and node:sqlite writes a DB as readily as it reads one, +// so these run against a real database file rather than a stand-in for one. +function firefoxDb(cookies: Array<{ host: string; name: string; value: string }>): string { + const path = join(mkdtempSync(join(tmpdir(), 'plx-ff-test-')), 'cookies.sqlite'); + const db = new DatabaseSync(path); + db.exec('CREATE TABLE moz_cookies (id INTEGER PRIMARY KEY, name TEXT, value TEXT, host TEXT)'); + const insert = db.prepare('INSERT INTO moz_cookies (name, value, host) VALUES (?, ?, ?)'); + for (const { host, name, value } of cookies) insert.run(name, value, host); + db.close(); + return path; +} + +describe('readFirefox', () => { + it('reads plaintext cookies straight out of a profile DB', () => { + expect(readFirefox(firefoxDb([{ host: '.deezer.com', name: 'arl', value: 'abc123arl' }]))).toEqual({ arl: 'abc123arl' }); + }); + + it('ignores a wanted cookie name served from the wrong host', () => { + expect(readFirefox(firefoxDb([{ host: '.evil.example', name: 'arl', value: 'nope' }]))).toEqual({}); + }); + + it('feeds the same joiner the other two backends use', () => { + const found = readFirefox(firefoxDb([ + { host: '.youtube.com', name: 'SAPISID', value: 'sap' }, + { host: '.youtube.com', name: 'SID', value: 'sid' }, + { host: '.google.com', name: 'HSID', value: 'hs' }, + ])); + expect(cookieHeader(found)).toBe('SAPISID=sap; SID=sid; HSID=hs'); + }); + + // Google sets SID on both .google.com and .youtube.com with *different* values, and a bundle + // mixing the two authenticates as neither. Listed google-first so row order cannot mask a + // ranking that silently took whichever came back first. + it('prefers the .youtube.com copy of a cookie Google also set on .google.com', () => { + const found = readFirefox(firefoxDb([ + { host: '.google.com', name: 'SID', value: 'google-copy' }, + { host: '.youtube.com', name: 'SID', value: 'youtube-copy' }, + ])); + expect(found.SID).toBe('youtube-copy'); + }); + + it('returns nothing rather than throwing when the path is not a database', () => { + expect(readFirefox(join(tmpdir(), 'plx-nonexistent-profile.sqlite'))).toEqual({}); + }); +}); + +describe('firefoxProfiles', () => { + const profileDir = (base: string, name: string, mtime: number): string => { + mkdirSync(join(base, name), { recursive: true }); + const db = join(base, name, 'cookies.sqlite'); + writeFileSync(db, ''); + utimesSync(db, mtime, mtime); + return db; + }; + + // Named so that alphabetical order is the *opposite* of mtime order: readdir yielding the right + // answer by luck would still fail this. + it('lists profiles holding a cookie DB, most recently written first', () => { + const root = mkdtempSync(join(tmpdir(), 'plx-ff-root-')); + const profiles = join(root, 'Profiles'); + const stale = profileDir(profiles, 'aaa.default', 1_000); + const current = profileDir(profiles, 'zzz.default-release', 9_000); + mkdirSync(join(profiles, 'never-used'), { recursive: true }); // a profile that stored no cookies + expect(firefoxProfiles(root)).toEqual([current, stale]); + }); + + it('falls back to the root when a packaging omits the Profiles/ level', () => { + const root = mkdtempSync(join(tmpdir(), 'plx-ff-flat-')); + const db = profileDir(root, 'abcd1234.default', 5_000); + expect(firefoxProfiles(root)).toEqual([db]); + }); + + it('returns nothing when Firefox is not installed', () => { + expect(firefoxProfiles(join(tmpdir(), 'plx-no-firefox-here'))).toEqual([]); + }); +});