diff --git a/CHANGELOG.md b/CHANGELOG.md index 77082b2..61b5d75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## 0.3.0 — Unreleased +### Added +- Safari cookie extraction (`Cookies.binarycookies`) + `allowSafari` config toggle. + ### Changed - Removed the Sweetistics engine + fallback. `bird` is GraphQL-only. +- Browser cookie fallback order: Safari → Chrome → Firefox. ## 0.2.0 — 2025-12-26 diff --git a/README.md b/README.md index 4ac3136..3df3bcd 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,12 @@ X endpoints and authenticates via cookies (`auth_token`, `ct0`). 1. CLI flags: `--auth-token`, `--ct0` 2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`) -3. Browser cookies (macOS): Firefox or Chrome profiles +3. Browser cookies (macOS): Safari, Chrome, Firefox Browser cookie sources: -- Firefox (default): `~/Library/Application Support/Firefox/Profiles//cookies.sqlite` +- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`) - Chrome: `~/Library/Application Support/Google/Chrome//Cookies` +- Firefox: `~/Library/Application Support/Firefox/Profiles//cookies.sqlite` ## Config (JSON5) @@ -84,6 +85,7 @@ Example `~/.config/bird/config.json5`: ```json5 { firefoxProfile: "default-release", + allowSafari: true, allowFirefox: true, allowChrome: false, timeoutMs: 20000 diff --git a/docs/releasing.md b/docs/releasing.md index b2ce949..f365ee2 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -53,4 +53,4 @@ If you want a single-file binary for Homebrew/GitHub assets: ## Notes - Scoped npm name (`@steipete/bird`) requires `--access public` on first publish. - Homebrew formula assumes macOS universal binary; adjust URL/name if you ship per-arch. -- Config defaults (JSON5) and Firefox/Chrome cookie selection are documented in `README.md` — keep that in sync for each release. +- Config defaults (JSON5) and Safari/Chrome/Firefox cookie selection are documented in `README.md` — keep that in sync for each release. diff --git a/src/index.ts b/src/index.ts index 4a603d2..7201716 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,7 @@ const colors = { type BirdConfig = { chromeProfile?: string; firefoxProfile?: string; + allowSafari?: boolean; allowChrome?: boolean; allowFirefox?: boolean; timeoutMs?: number; @@ -322,6 +323,7 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); @@ -388,6 +390,7 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); @@ -450,6 +453,7 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); @@ -499,6 +503,9 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, + allowChrome: config.allowChrome ?? true, + allowFirefox: config.allowFirefox ?? true, }); for (const warning of warnings) { @@ -537,6 +544,9 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, + allowChrome: config.allowChrome ?? true, + allowFirefox: config.allowFirefox ?? true, }); for (const warning of warnings) { @@ -576,6 +586,9 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, + allowChrome: config.allowChrome ?? true, + allowFirefox: config.allowFirefox ?? true, }); for (const warning of warnings) { @@ -623,6 +636,7 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); @@ -674,6 +688,7 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); @@ -718,6 +733,7 @@ program ct0: opts.ct0, chromeProfile: opts.chromeProfile || config.chromeProfile, firefoxProfile: opts.firefoxProfile || config.firefoxProfile, + allowSafari: config.allowSafari ?? true, allowChrome: config.allowChrome ?? true, allowFirefox: config.allowFirefox ?? true, }); @@ -752,7 +768,7 @@ program console.log(`\n${p('ok')}Ready to tweet!`); } else { console.log(`\n${p('err')}Missing credentials. Options:`); - console.log(' 1. Login to x.com in Chrome'); + console.log(' 1. Login to x.com in Safari/Chrome/Firefox'); console.log(' 2. Set AUTH_TOKEN and CT0 environment variables'); console.log(' 3. Use --auth-token and --ct0 flags'); process.exit(1); diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index 83a9c2a..34bd7a8 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -1,11 +1,11 @@ /** - * Chrome cookie extraction for Twitter authentication - * Uses macOS sqlite3 CLI and keychain for decryption - no native dependencies! + * Browser cookie extraction for Twitter authentication + * Uses sqlite3 CLI where possible and binarycookies parsing for Safari. */ import { execSync } from 'node:child_process'; import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; -import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -71,6 +71,158 @@ function getFirefoxCookiesPath(profile?: string): string | null { return pickFirefoxProfile(profilesRoot, profile); } +function getSafariCookiesPath(): string | null { + if (process.platform !== 'darwin') return null; + const home = process.env.HOME || ''; + const candidates = [ + join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), + join(home, 'Library', 'Containers', 'com.apple.Safari', 'Data', 'Library', 'Cookies', 'Cookies.binarycookies'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +const SAFARI_COOKIE_NAMES = new Set(['auth_token', 'ct0']); +const SAFARI_COOKIE_DOMAINS = ['x.com', 'twitter.com']; +const SAFARI_PAGE_SIGNATURE = Buffer.from([0x00, 0x00, 0x01, 0x00]); + +function matchesSafariDomain(domain: string | null): boolean { + if (!domain) return false; + const normalized = (domain.startsWith('.') ? domain.slice(1) : domain).toLowerCase(); + return SAFARI_COOKIE_DOMAINS.some((target) => normalized === target || normalized.endsWith(`.${target}`)); +} + +function readSafariCString(buffer: Buffer, start: number, end: number): string | null { + if (start < 0 || start >= end) return null; + let cursor = start; + while (cursor < end && buffer[cursor] !== 0) cursor += 1; + if (cursor >= end) return null; + return buffer.toString('utf8', start, cursor); +} + +function parseSafariCookieRecord(page: Buffer, offset: number, cookies: TwitterCookies): void { + if (offset < 0 || offset + 4 > page.length) return; + const recordSize = page.readUInt32LE(offset); + const recordEnd = offset + recordSize; + if (recordSize <= 0 || recordEnd > page.length) return; + + const headerStart = offset + 4; + const headerSize = 4 + 4 + 4 + 4 + 4 + 4 + 4; // unknown1 + flags + unknown2 + domain + name + path + value + if (headerStart + headerSize > recordEnd) return; + + const domainOffset = page.readUInt32LE(headerStart + 12); + const nameOffset = page.readUInt32LE(headerStart + 16); + const valueOffset = page.readUInt32LE(headerStart + 24); + + const domain = readSafariCString(page, offset + domainOffset, recordEnd); + const name = readSafariCString(page, offset + nameOffset, recordEnd); + const value = readSafariCString(page, offset + valueOffset, recordEnd); + + if (!name || !value || !matchesSafariDomain(domain)) return; + if (!SAFARI_COOKIE_NAMES.has(name)) return; + + const normalizedValue = normalizeValue(value); + if (!normalizedValue) return; + + if (name === 'auth_token' && !cookies.authToken) { + cookies.authToken = normalizedValue; + } else if (name === 'ct0' && !cookies.ct0) { + cookies.ct0 = normalizedValue; + } +} + +function parseSafariCookiePage(page: Buffer, cookies: TwitterCookies): void { + if (page.length < 12) return; + if (!page.subarray(0, 4).equals(SAFARI_PAGE_SIGNATURE)) return; + const cookieCount = page.readUInt32LE(4); + if (!cookieCount) return; + + const offsets: number[] = []; + let cursor = 8; + for (let i = 0; i < cookieCount; i += 1) { + if (cursor + 4 > page.length) return; + offsets.push(page.readUInt32LE(cursor)); + cursor += 4; + } + + for (const offset of offsets) { + parseSafariCookieRecord(page, offset, cookies); + if (cookies.authToken && cookies.ct0) return; + } +} + +function parseSafariCookies(data: Buffer, cookies: TwitterCookies): void { + if (data.length < 8) return; + if (data.subarray(0, 4).toString('utf8') !== 'cook') return; + const pageCount = data.readUInt32BE(4); + let cursor = 8; + const pageSizes: number[] = []; + for (let i = 0; i < pageCount; i += 1) { + if (cursor + 4 > data.length) return; + pageSizes.push(data.readUInt32BE(cursor)); + cursor += 4; + } + + for (const pageSize of pageSizes) { + if (cursor + pageSize > data.length) return; + const page = data.subarray(cursor, cursor + pageSize); + parseSafariCookiePage(page, cookies); + if (cookies.authToken && cookies.ct0) return; + cursor += pageSize; + } +} + +/** + * Extract Twitter cookies from Safari browser using Cookies.binarycookies + */ +export async function extractCookiesFromSafari(): Promise { + const warnings: string[] = []; + const cookies: TwitterCookies = { + authToken: null, + ct0: null, + source: null, + }; + + const cookiesPath = getSafariCookiesPath(); + if (!cookiesPath) { + warnings.push('Safari cookies database not found.'); + return { cookies, warnings }; + } + + let tempDir: string | null = null; + + try { + tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); + const tempCookiesPath = join(tempDir, 'Cookies.binarycookies'); + copyFileSync(cookiesPath, tempCookiesPath); + const data = readFileSync(tempCookiesPath); + parseSafariCookies(data, cookies); + + if (cookies.authToken || cookies.ct0) { + cookies.source = 'Safari'; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warnings.push(`Failed to read Safari cookies: ${message}`); + } finally { + if (tempDir) { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } + } + + if (!cookies.authToken && !cookies.ct0) { + warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.'); + } + + return { cookies, warnings }; +} + /** * Decrypt Chrome cookie value using macOS keychain * Chrome encrypts cookies with a key stored in the keychain @@ -287,11 +439,12 @@ export async function extractCookiesFromFirefox(profile?: string): Promise environment variables > Chrome cookies + * Priority: CLI args > environment variables > Safari > Chrome > Firefox */ export async function resolveCredentials(options: { authToken?: string; ct0?: string; + allowSafari?: boolean; chromeProfile?: string; firefoxProfile?: string; allowChrome?: boolean; @@ -340,21 +493,22 @@ export async function resolveCredentials(options: { } } - const allowFirefox = options.allowFirefox ?? true; + const allowSafari = options.allowSafari ?? true; const allowChrome = options.allowChrome ?? true; + const allowFirefox = options.allowFirefox ?? true; - // 3. Firefox cookies (preferred browser fallback) - if (allowFirefox && (!cookies.authToken || !cookies.ct0)) { - const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile); - warnings.push(...firefoxResult.warnings); + // 3. Safari cookies (preferred browser fallback) + if (allowSafari && (!cookies.authToken || !cookies.ct0)) { + const safariResult = await extractCookiesFromSafari(); + warnings.push(...safariResult.warnings); - if (!cookies.authToken && firefoxResult.cookies.authToken) { - cookies.authToken = firefoxResult.cookies.authToken; - cookies.source = firefoxResult.cookies.source; + if (!cookies.authToken && safariResult.cookies.authToken) { + cookies.authToken = safariResult.cookies.authToken; + cookies.source = safariResult.cookies.source; } - if (!cookies.ct0 && firefoxResult.cookies.ct0) { - cookies.ct0 = firefoxResult.cookies.ct0; - if (!cookies.source) cookies.source = firefoxResult.cookies.source; + if (!cookies.ct0 && safariResult.cookies.ct0) { + cookies.ct0 = safariResult.cookies.ct0; + if (!cookies.source) cookies.source = safariResult.cookies.source; } } @@ -373,14 +527,29 @@ export async function resolveCredentials(options: { } } + // 5. Firefox cookies (tertiary browser fallback) + if (allowFirefox && (!cookies.authToken || !cookies.ct0)) { + const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile); + warnings.push(...firefoxResult.warnings); + + if (!cookies.authToken && firefoxResult.cookies.authToken) { + cookies.authToken = firefoxResult.cookies.authToken; + cookies.source = firefoxResult.cookies.source; + } + if (!cookies.ct0 && firefoxResult.cookies.ct0) { + cookies.ct0 = firefoxResult.cookies.ct0; + if (!cookies.source) cookies.source = firefoxResult.cookies.source; + } + } + // Validation if (!cookies.authToken) { warnings.push( - 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox', + 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox', ); } if (!cookies.ct0) { - warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox'); + warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox'); } return { cookies, warnings }; diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index 342e469..533aaae 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -14,10 +14,78 @@ vi.mock('node:fs', () => { copyFileSync: vi.fn(), mkdtempSync: vi.fn(() => '/tmp/test-dir'), readdirSync: vi.fn(() => []), + readFileSync: vi.fn(() => Buffer.alloc(0)), rmSync: vi.fn(), }; }); +const itIfDarwin = process.platform === 'darwin' ? it : it.skip; + +function buildSafariCookieRecord(input: { domain: string; name: string; value: string; path?: string }): Buffer { + const domain = Buffer.from(input.domain, 'utf8'); + const name = Buffer.from(input.name, 'utf8'); + const path = Buffer.from(input.path ?? '/', 'utf8'); + const value = Buffer.from(input.value, 'utf8'); + + const headerSize = 56; + const domainOffset = headerSize; + const nameOffset = domainOffset + domain.length + 1; + const pathOffset = nameOffset + name.length + 1; + const valueOffset = pathOffset + path.length + 1; + const recordSize = valueOffset + value.length + 1; + + const record = Buffer.alloc(recordSize); + record.writeUInt32LE(recordSize, 0); + record.writeUInt32LE(0, 4); + record.writeUInt32LE(0, 8); + record.writeUInt32LE(0, 12); + record.writeUInt32LE(domainOffset, 16); + record.writeUInt32LE(nameOffset, 20); + record.writeUInt32LE(pathOffset, 24); + record.writeUInt32LE(valueOffset, 28); + + domain.copy(record, domainOffset); + record[domainOffset + domain.length] = 0; + name.copy(record, nameOffset); + record[nameOffset + name.length] = 0; + path.copy(record, pathOffset); + record[pathOffset + path.length] = 0; + value.copy(record, valueOffset); + record[valueOffset + value.length] = 0; + + return record; +} + +function buildSafariCookiesFile(records: Buffer[]): Buffer { + const cookieCount = records.length; + const headerSize = 4 + 4 + 4 * cookieCount + 4; + const offsets: number[] = []; + let cursor = headerSize; + for (const record of records) { + offsets.push(cursor); + cursor += record.length; + } + + const pageSize = cursor; + const page = Buffer.alloc(pageSize); + page.writeUInt32BE(0x00000100, 0); + page.writeUInt32LE(cookieCount, 4); + offsets.forEach((offset, index) => { + page.writeUInt32LE(offset, 8 + index * 4); + }); + page.writeUInt32LE(0, 8 + cookieCount * 4); + offsets.forEach((offset, index) => { + records[index].copy(page, offset); + }); + + const header = Buffer.alloc(12); + header.write('cook', 0, 'ascii'); + header.writeUInt32BE(1, 4); + header.writeUInt32BE(pageSize, 8); + + return Buffer.concat([header, page]); +} + describe('cookies', () => { const originalEnv = process.env; @@ -135,10 +203,10 @@ describe('cookies', () => { const result = await resolveCredentials({ allowFirefox: false, allowChrome: false }); expect(result.warnings).toContain( - 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Chrome/Firefox', + 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox', ); expect(result.warnings).toContain( - 'Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Chrome/Firefox', + 'Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox', ); }); @@ -155,7 +223,12 @@ describe('cookies', () => { }); const { resolveCredentials } = await import('../src/lib/cookies.js'); - const result = await resolveCredentials({ allowFirefox: false, allowChrome: true, chromeProfile: 'Default' }); + const result = await resolveCredentials({ + allowSafari: false, + allowFirefox: false, + allowChrome: true, + chromeProfile: 'Default', + }); expect(result.cookies.authToken).toBe('test_auth'); expect(result.cookies.ct0).toBe('test_ct0'); @@ -163,6 +236,55 @@ describe('cookies', () => { }); }); + describe('extractCookiesFromSafari', () => { + itIfDarwin('returns cookies from Safari binarycookies', async () => { + const fs = await import('node:fs'); + (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => + path.toLowerCase().endsWith('cookies.binarycookies'), + ); + (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); + (fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); + (fs.readFileSync as unknown as vi.Mock).mockReturnValue( + buildSafariCookiesFile([ + buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), + buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), + ]), + ); + + const { extractCookiesFromSafari } = await import('../src/lib/cookies.js'); + const result = await extractCookiesFromSafari(); + + expect(result.cookies.authToken).toBe('safari_auth'); + expect(result.cookies.ct0).toBe('safari_ct0'); + expect(result.cookies.source).toBe('Safari'); + }); + + itIfDarwin('prefers Safari over Chrome when both are available', async () => { + const fs = await import('node:fs'); + + (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { + const lower = path.toLowerCase(); + if (lower.endsWith('cookies.binarycookies')) return true; + if (lower.includes('chrome') && lower.endsWith('cookies')) return true; + return false; + }); + (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); + (fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); + (fs.readFileSync as unknown as vi.Mock).mockReturnValue( + buildSafariCookiesFile([ + buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), + buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), + ]), + ); + + const { resolveCredentials } = await import('../src/lib/cookies.js'); + const result = await resolveCredentials({ allowSafari: true, allowChrome: true, allowFirefox: false }); + + expect(result.cookies.authToken).toBe('safari_auth'); + expect(result.cookies.ct0).toBe('safari_ct0'); + }); + }); + describe('extractCookiesFromChrome', () => { it('returns cookies when sqlite yields hex values', async () => { const fs = await import('node:fs');