import { afterEach, describe, expect, spyOn, it } from 'bun:test' import { createCipheriv, randomBytes } from 'node:crypto' import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { ChannelTokenExtractor } from './token-extractor' describe('ChannelTokenExtractor', () => { const tempDirs: string[] = [] afterEach(() => { for (const tempDir of tempDirs.splice(0)) { if (existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }) } } }) describe('getAppDataDir', () => { const originalAppData = process.env.APPDATA afterEach(() => { if (originalAppData === undefined) delete process.env.APPDATA else process.env.APPDATA = originalAppData }) it('returns null for unsupported platform', () => { const extractor = new ChannelTokenExtractor('freebsd' as NodeJS.Platform) expect(extractor.getAppDataDir()).toBeNull() }) it('resolves the rebranded Channel Works directory', () => { // given const tempDir = mkdtempSync(join(tmpdir(), 'channel-appdata-')) tempDirs.push(tempDir) mkdirSync(join(tempDir, 'Channel Works'), { recursive: true }) process.env.APPDATA = tempDir // when const extractor = new ChannelTokenExtractor('win32') // then expect(extractor.getAppDataDir()).toBe(join(tempDir, 'Channel Works')) }) it('falls back to the legacy Channel Talk directory', () => { // given const tempDir = mkdtempSync(join(tmpdir(), 'channel-appdata-')) tempDirs.push(tempDir) mkdirSync(join(tempDir, 'Channel Talk'), { recursive: true }) process.env.APPDATA = tempDir // when const extractor = new ChannelTokenExtractor('win32') // then expect(extractor.getAppDataDir()).toBe(join(tempDir, 'Channel Talk')) }) it('prefers Channel Works when an upgraded install keeps both directories', () => { // given const tempDir = mkdtempSync(join(tmpdir(), 'channel-appdata-')) tempDirs.push(tempDir) mkdirSync(join(tempDir, 'Channel Talk'), { recursive: true }) mkdirSync(join(tempDir, 'Channel Works'), { recursive: true }) process.env.APPDATA = tempDir // when const extractor = new ChannelTokenExtractor('win32') // then expect(extractor.getAppDataDir()).toBe(join(tempDir, 'Channel Works')) }) }) describe('getCookiesPath', () => { it('returns null for unsupported platform', () => { const extractor = new ChannelTokenExtractor('freebsd' as NodeJS.Platform) expect(extractor.getCookiesPath()).toBeNull() }) it('returns win32 path under AppData/Roaming/Channel Talk/Network', () => { // given const tempDir = mkdtempSync(join(tmpdir(), 'channel-win-')) tempDirs.push(tempDir) const networkDir = join(tempDir, 'Channel Talk', 'Network') mkdirSync(networkDir, { recursive: true }) writeFileSync(join(networkDir, 'Cookies'), '') class WinExtractor extends ChannelTokenExtractor { constructor() { super('win32') } override getAppDataDir(): string | null { return join(tempDir, 'Channel Talk') } } // when const extractor = new WinExtractor() const path = extractor.getCookiesPath() // then expect(path).toBe(join(networkDir, 'Cookies')) }) }) describe('getBrowserCookiesPaths', () => { it('returns browser cookie paths on macOS including Default profile', () => { const extractor = new ChannelTokenExtractor('darwin') const paths = extractor.getBrowserCookiesPaths() const chromeBase = join(process.env.HOME || '/tmp', 'Library', 'Application Support', 'Google', 'Chrome') expect(paths).toContain(join(chromeBase, 'Default', 'Cookies')) expect(paths).toContain(join(chromeBase, 'Default', 'Network', 'Cookies')) }) it('returns browser cookie paths on Linux', () => { const extractor = new ChannelTokenExtractor('linux') const paths = extractor.getBrowserCookiesPaths() const chromeBase = join(process.env.HOME || '/tmp', '.config', 'google-chrome') expect(paths).toContain(join(chromeBase, 'Default', 'Cookies')) }) it('returns browser cookie paths on Windows', () => { const extractor = new ChannelTokenExtractor('win32') const paths = extractor.getBrowserCookiesPaths() const localAppData = process.env.LOCALAPPDATA || join(process.env.HOME || '/tmp', 'AppData', 'Local') const chromeBase = join(localAppData, 'Google', 'Chrome', 'User Data') expect(paths).toContain(join(chromeBase, 'Default', 'Cookies')) }) it('returns empty array for unsupported platform', () => { const extractor = new ChannelTokenExtractor('freebsd' as NodeJS.Platform) expect(extractor.getBrowserCookiesPaths()).toEqual([]) }) }) describe('extract', () => { it('returns empty array when desktop cookies path does not exist', async () => { class MissingPathExtractor extends ChannelTokenExtractor { override getCookiesPath(): string | null { return null } } const extractor = new MissingPathExtractor('darwin') expect(await extractor.extract()).toEqual([]) }) it('tries desktop app before browser profiles and collects both', async () => { const extractor = new ChannelTokenExtractor('darwin') const desktopSpy = spyOn(extractor as any, 'extractFromDesktopApp').mockResolvedValue({ accountCookie: 'desktop-account', sessionCookie: 'desktop-session', }) const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([]) const result = await extractor.extract() expect(desktopSpy).toHaveBeenCalled() expect(browserSpy).toHaveBeenCalled() expect(result[0]?.accountCookie).toBe('desktop-account') desktopSpy.mockRestore() browserSpy.mockRestore() }) it('includes browser profiles even when desktop extraction returns null', async () => { const extractor = new ChannelTokenExtractor('darwin') const desktopSpy = spyOn(extractor as any, 'extractFromDesktopApp').mockResolvedValue(null) const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([ { accountCookie: 'browser-account', sessionCookie: undefined, }, ]) const result = await extractor.extract() expect(desktopSpy).toHaveBeenCalled() expect(browserSpy).toHaveBeenCalled() expect(result[0]?.accountCookie).toBe('browser-account') desktopSpy.mockRestore() browserSpy.mockRestore() }) it('extracts plaintext cookies from a real sqlite database', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') await createCookieDatabase(dbPath, [ { name: 'x-account', value: 'account-jwt', encrypted_value: Buffer.alloc(0), host_key: '.desk.channel.io' }, { name: 'ch-session-1', value: 'session-jwt', encrypted_value: Buffer.alloc(0), host_key: '.desk.channel.io' }, { name: 'other', value: 'ignore-me', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' }, ]) class TestExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('darwin') } override getCookiesPath(): string | null { return this.dbPath } } const extractor = new TestExtractor(dbPath) expect(await extractor.extract()).toEqual([ { accountCookie: 'account-jwt', sessionCookie: 'session-jwt', }, ]) }) it('ignores look-alike hosts that merely contain a channel domain', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') // These hosts all contain a channel domain as a substring but are not the domain nor a // subdomain of it, so nobody who controls one may speak for Channel. They are inserted // first so a substring match would let them shadow the real cookies below. await createCookieDatabase(dbPath, [ { name: 'x-account', value: 'imposter-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.works.example.com', }, { name: 'ch-session-1', value: 'imposter-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.works.example.com', }, { name: 'x-account', value: 'imposter-io', encrypted_value: Buffer.alloc(0), host_key: '.channel.io.evil.net' }, { name: 'x-account', value: 'real-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' }, { name: 'ch-session-1', value: 'real-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' }, ]) class TestExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('darwin') } override getCookiesPath(): string | null { return this.dbPath } } const extractor = new TestExtractor(dbPath) const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([]) expect(await extractor.extract()).toEqual([ { accountCookie: 'real-account', sessionCookie: 'real-session', }, ]) browserSpy.mockRestore() }) it('extracts cookies from a subdomain of a channel domain', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') // The boundary check must not be so strict that it drops legitimate subdomains. await createCookieDatabase(dbPath, [ { name: 'x-account', value: 'account-jwt', encrypted_value: Buffer.alloc(0), host_key: '.desk.channel.works' }, { name: 'ch-session-1', value: 'session-jwt', encrypted_value: Buffer.alloc(0), host_key: 'channel.works' }, ]) class TestExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('darwin') } override getCookiesPath(): string | null { return this.dbPath } } const extractor = new TestExtractor(dbPath) const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([]) expect(await extractor.extract()).toEqual([ { accountCookie: 'account-jwt', sessionCookie: 'session-jwt', }, ]) browserSpy.mockRestore() }) it('prefers channel.works cookies over stale legacy channel.io cookies', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') // Legacy rows are inserted first so they win a naive find() that ignores the domain. await createCookieDatabase(dbPath, [ { name: 'x-account', value: 'stale-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' }, { name: 'ch-session-1', value: 'stale-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' }, { name: 'x-account', value: 'live-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' }, { name: 'ch-session-1', value: 'live-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' }, ]) class TestExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('darwin') } override getCookiesPath(): string | null { return this.dbPath } } const extractor = new TestExtractor(dbPath) const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([]) expect(await extractor.extract()).toEqual([ { accountCookie: 'live-account', sessionCookie: 'live-session', }, ]) browserSpy.mockRestore() }) it('never pairs an x-account with a session cookie from the other domain', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') // channel.works holds an identity but no session; the only session belongs to channel.io. // The live identity must be returned session-less rather than borrowing the legacy session, // which belongs to a different domain and would not authenticate it. await createCookieDatabase(dbPath, [ { name: 'x-account', value: 'works-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' }, { name: 'x-account', value: 'io-account', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' }, { name: 'ch-session-1', value: 'io-session', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' }, ]) class TestExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('darwin') } override getCookiesPath(): string | null { return this.dbPath } } const extractor = new TestExtractor(dbPath) const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([]) expect(await extractor.extract()).toEqual([ { accountCookie: 'works-account', sessionCookie: undefined, }, ]) browserSpy.mockRestore() }) it('extracts cookies stored on the rebranded channel.works domain', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') await createCookieDatabase(dbPath, [ { name: 'x-account', value: 'account-jwt', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' }, { name: 'ch-session-1', value: 'session-jwt', encrypted_value: Buffer.alloc(0), host_key: '.channel.works' }, ]) class TestExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('darwin') } override getCookiesPath(): string | null { return this.dbPath } } const extractor = new TestExtractor(dbPath) // Browser profiles are stubbed out so a real logged-in browser on the host cannot leak in. const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([]) expect(await extractor.extract()).toEqual([ { accountCookie: 'account-jwt', sessionCookie: 'session-jwt', }, ]) browserSpy.mockRestore() }) it('returns token with undefined sessionCookie when only x-account is present', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') await createCookieDatabase(dbPath, [ { name: 'x-account', value: 'account-jwt', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' }, ]) class TestExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('darwin') } override getCookiesPath(): string | null { return this.dbPath } } const extractor = new TestExtractor(dbPath) const result = await extractor.extract() expect(result).not.toEqual([]) expect(result[0]?.accountCookie).toBe('account-jwt') expect(result[0]?.sessionCookie).toBeUndefined() }) it('returns empty array when x-account is missing', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') await createCookieDatabase(dbPath, [ { name: 'ch-session-1', value: 'session-jwt', encrypted_value: Buffer.alloc(0), host_key: '.channel.io' }, ]) class TestExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('darwin') } override getCookiesPath(): string | null { return this.dbPath } } const extractor = new TestExtractor(dbPath) expect(await extractor.extract()).toEqual([]) }) it('decrypts AES-256-GCM encrypted cookies using master key', async () => { // given — known master key and AES-256-GCM encrypted cookie const masterKey = randomBytes(32) const accountValue = 'encrypted-account-jwt' const sessionValue = 'encrypted-session-jwt' const encryptAccount = encryptAESGCM(accountValue, masterKey) const encryptSession = encryptAESGCM(sessionValue, masterKey) const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') await createCookieDatabase(dbPath, [ { name: 'x-account', value: '', encrypted_value: encryptAccount, host_key: '.channel.io' }, { name: 'ch-session-1', value: '', encrypted_value: encryptSession, host_key: '.channel.io' }, ]) // when — extractor uses the master key (bypassing DPAPI) class TestWinExtractor extends ChannelTokenExtractor { constructor( private dbPath: string, private masterKey: Buffer, ) { super('win32') } override getCookiesPath(): string | null { return this.dbPath } override decryptDPAPI(_encryptedBlob: Buffer): Buffer | null { return this.masterKey } override getAppDataDir(): string | null { return tempDir } } const localStatePath = join(tempDir, 'Local State') const fakeEncryptedKey = Buffer.concat([Buffer.from('DPAPI'), randomBytes(32)]) writeFileSync( localStatePath, JSON.stringify({ os_crypt: { encrypted_key: fakeEncryptedKey.toString('base64') } }), ) const extractor = new TestWinExtractor(dbPath, masterKey) const result = await extractor.extract() // then expect(result).not.toEqual([]) expect(result[0]?.accountCookie).toBe('encrypted-account-jwt') expect(result[0]?.sessionCookie).toBe('encrypted-session-jwt') }) it('deduplicates entries with the same accountCookie from desktop and browser', async () => { const extractor = new ChannelTokenExtractor('darwin') const desktopSpy = spyOn(extractor as any, 'extractFromDesktopApp').mockResolvedValue({ accountCookie: 'same-account-cookie', sessionCookie: 'desktop-session', }) const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([ { accountCookie: 'same-account-cookie', sessionCookie: 'browser-session', }, ]) const result = await extractor.extract() expect(result).toHaveLength(1) expect(result[0]?.accountCookie).toBe('same-account-cookie') desktopSpy.mockRestore() browserSpy.mockRestore() }) it('returns multiple distinct accounts from desktop and browser sources', async () => { const extractor = new ChannelTokenExtractor('darwin') const desktopSpy = spyOn(extractor as any, 'extractFromDesktopApp').mockResolvedValue({ accountCookie: 'desktop-account-cookie', sessionCookie: 'desktop-session', }) const browserSpy = spyOn(extractor as any, 'extractAllFromBrowserPaths').mockResolvedValue([ { accountCookie: 'browser-account-cookie', sessionCookie: 'browser-session', }, ]) const result = await extractor.extract() expect(result).toHaveLength(2) expect(result.map((r) => r.accountCookie)).toContain('desktop-account-cookie') expect(result.map((r) => r.accountCookie)).toContain('browser-account-cookie') desktopSpy.mockRestore() browserSpy.mockRestore() }) it('returns empty array when DPAPI decryption fails', async () => { // given const masterKey = randomBytes(32) const encryptAccount = encryptAESGCM('account-jwt', masterKey) const tempDir = mkdtempSync(join(tmpdir(), 'channel-cookie-db-')) tempDirs.push(tempDir) const dbPath = join(tempDir, 'Cookies') await createCookieDatabase(dbPath, [ { name: 'x-account', value: '', encrypted_value: encryptAccount, host_key: '.channel.io' }, ]) // when — DPAPI fails class FailingDPAPIExtractor extends ChannelTokenExtractor { constructor(private dbPath: string) { super('win32') } override getCookiesPath(): string | null { return this.dbPath } override decryptDPAPI(_encryptedBlob: Buffer): Buffer | null { return null } override getAppDataDir(): string | null { return tempDir } } const localStatePath = join(tempDir, 'Local State') const fakeEncryptedKey = Buffer.concat([Buffer.from('DPAPI'), randomBytes(32)]) writeFileSync( localStatePath, JSON.stringify({ os_crypt: { encrypted_key: fakeEncryptedKey.toString('base64') } }), ) const extractor = new FailingDPAPIExtractor(dbPath) const result = await extractor.extract() // then expect(result).toEqual([]) }) }) describe('decryptDPAPI', () => { it('returns null on non-win32 platform', () => { const extractor = new ChannelTokenExtractor('darwin') expect(extractor.decryptDPAPI(Buffer.from('test'))).toBeNull() }) }) describe('decryptBrowserCookie', () => { it('decrypts v10-prefixed browser cookie using macOS keychain password (AES-128-CBC)', () => { // given — AES-128-CBC encrypted cookie with macOS keychain-derived key const { createCipheriv, pbkdf2Sync } = require('node:crypto') const password = 'test-keychain-password' const key = pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1') const iv = Buffer.alloc(16, 0x20) const plainValue = 'test-channel-account-value' const cipher = createCipheriv('aes-128-cbc', key, iv) const ciphertext = Buffer.concat([cipher.update(plainValue, 'utf8'), cipher.final()]) const encrypted = Buffer.concat([Buffer.from('v10'), ciphertext]) const darwinExtractor = new ChannelTokenExtractor('darwin') const execSecuritySpy = spyOn(darwinExtractor as any, 'execSecurityCommand').mockReturnValue(password) // when const result = (darwinExtractor as any).decryptBrowserCookie(encrypted, '/fake/path/Cookies') // then expect(result).toBe(plainValue) execSecuritySpy.mockRestore() }) it('decrypts v10-prefixed browser cookie using Linux peanuts key (AES-128-CBC)', () => { // given — AES-128-CBC encrypted cookie with Linux Chromium peanuts key const { createCipheriv, pbkdf2Sync } = require('node:crypto') const key = pbkdf2Sync('peanuts', 'saltysalt', 1, 16, 'sha1') const iv = Buffer.alloc(16, 0x20) const plainValue = 'test-channel-account-linux' const cipher = createCipheriv('aes-128-cbc', key, iv) const ciphertext = Buffer.concat([cipher.update(plainValue, 'utf8'), cipher.final()]) const encrypted = Buffer.concat([Buffer.from('v10'), ciphertext]) const linuxExtractor = new ChannelTokenExtractor('linux') // when const result = (linuxExtractor as any).decryptBrowserCookie( encrypted, '/home/user/.config/google-chrome/Default/Cookies', ) // then expect(result).toBe(plainValue) }) }) }) function encryptAESGCM(plaintext: string, key: Buffer): Buffer { const nonce = randomBytes(12) const cipher = createCipheriv('aes-256-gcm', key, nonce) const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]) const authTag = cipher.getAuthTag() // v10 prefix (3 bytes) + IV (12 bytes) + ciphertext + auth tag (16 bytes) return Buffer.concat([Buffer.from('v10'), nonce, encrypted, authTag]) } async function createCookieDatabase( dbPath: string, rows: Array<{ name: string; value: string; encrypted_value: Buffer; host_key: string }>, ): Promise { if (typeof globalThis.Bun !== 'undefined') { const { Database } = await import('bun:sqlite') const db = new Database(dbPath) db.run('PRAGMA journal_mode = DELETE') db.run('CREATE TABLE cookies (name TEXT, value TEXT, encrypted_value BLOB, host_key TEXT)') for (const row of rows) { db.run('INSERT INTO cookies (name, value, encrypted_value, host_key) VALUES (?, ?, ?, ?)', [ row.name, row.value, row.encrypted_value, row.host_key, ]) } db.close() return } const { createRequire } = await import('node:module') const req = createRequire(import.meta.url) const { DatabaseSync } = req('node:sqlite') const db = new DatabaseSync(dbPath) db.exec('CREATE TABLE cookies (name TEXT, value TEXT, encrypted_value BLOB, host_key TEXT)') const statement = db.prepare('INSERT INTO cookies (name, value, encrypted_value, host_key) VALUES (?, ?, ?, ?)') for (const row of rows) { statement.run(row.name, row.value, row.encrypted_value, row.host_key) } db.close() }