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([]); + }); +});