feat: add Safari cookie extraction

This commit is contained in:
Peter Steinberger
2025-12-26 17:51:41 +01:00
parent 02d12b4e87
commit a4686f90ee
6 changed files with 337 additions and 24 deletions
+4
View File
@@ -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
+4 -2
View File
@@ -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/<profile>/cookies.sqlite`
- Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`)
- Chrome: `~/Library/Application Support/Google/Chrome/<Profile>/Cookies`
- Firefox: `~/Library/Application Support/Firefox/Profiles/<profile>/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
+1 -1
View File
@@ -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.
+17 -1
View File
@@ -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);
+186 -17
View File
@@ -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<CookieExtractionResult> {
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<Cooki
/**
* Resolve Twitter credentials from multiple sources
* Priority: CLI args > 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 };
+125 -3
View File
@@ -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');