diff --git a/.changeset/linux-app-session.md b/.changeset/linux-app-session.md new file mode 100644 index 0000000..7a7b16e --- /dev/null +++ b/.changeset/linux-app-session.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": patch +--- + +Use the signed-in Grok Bot app session on Linux: read `~/.config/Grok Bot/gateway-descriptor.json` (honouring `XDG_CONFIG_HOME`), decrypt Chromium `v11` payloads with the Secret Service password via `secret-tool` and `v10` payloads with the basic-text key, and apply Linux's single PBKDF2 round instead of the macOS 1003. diff --git a/README.md b/README.md index cb0df33..e62f31c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Manage [Grok Bot](https://cursor.com/help/grok-bot/plans) agents, groups, and me npm install --global grok-bot-cli ``` -Requires Node.js 18+ and the Grok Bot macOS app. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required. +Requires Node.js 18+ and the Grok Bot desktop app on macOS or Linux. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required. On Linux the app keeps its session under `~/.config/Grok Bot` (or `$XDG_CONFIG_HOME`); when it is stored in the system keyring, `gbot` reads the key with `secret-tool` (package `libsecret-tools`). ## Use diff --git a/src/app-session.js b/src/app-session.js index eb6baf0..0f62695 100644 --- a/src/app-session.js +++ b/src/app-session.js @@ -4,7 +4,12 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -const SAFE_STORAGE_PREFIX = Buffer.from("v10"); +// Chromium OSCrypt: "v10" = fixed password (macOS: the Keychain password; Linux: "peanuts"), +// "v11" = Linux Secret Service password. macOS stretches with 1003 PBKDF2 rounds, Linux with 1. +const SAFE_STORAGE_PREFIX_V10 = "v10"; +const SAFE_STORAGE_PREFIX_V11 = "v11"; +const LINUX_BASIC_TEXT_PASSWORD = "peanuts"; +const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]); export class GrokBotGatewaySessionError extends Error { constructor(code, message) { @@ -49,13 +54,21 @@ function encryptedPayload(wrapped) { return encrypted; } -export function decryptSafeStorageString(encryptedBase64, password) { +function safeStorageKey(password, platform) { + const iterations = platform === "linux" ? 1 : 1003; + return crypto.pbkdf2Sync(password, "saltysalt", iterations, 16, "sha1"); +} + +export function decryptSafeStorageString(encryptedBase64, password, platform = "darwin") { const encrypted = Buffer.from(encryptedBase64, "base64"); - if (!encrypted.subarray(0, 3).equals(SAFE_STORAGE_PREFIX)) { + const prefix = encrypted.subarray(0, 3).toString("latin1"); + const linuxBasicText = platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10; + const keyring = prefix === SAFE_STORAGE_PREFIX_V10 || (platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V11); + if (!keyring) { throw new Error("Unsupported Grok Bot Safe Storage format."); } - const key = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1"); + const key = safeStorageKey(linuxBasicText ? LINUX_BASIC_TEXT_PASSWORD : password, platform); const decipher = crypto.createDecipheriv( "aes-128-cbc", key, @@ -67,7 +80,11 @@ export function decryptSafeStorageString(encryptedBase64, password) { ]).toString("utf8"); } -export function grokBotGatewayDescriptorPath(home = homedir()) { +export function grokBotGatewayDescriptorPath(home = homedir(), platform = process.platform, env = process.env) { + if (platform === "linux") { + const configHome = env.XDG_CONFIG_HOME || join(home, ".config"); + return join(configHome, "Grok Bot/gateway-descriptor.json"); + } return join( home, "Library/Application Support/Grok Bot/gateway-descriptor.json", @@ -77,11 +94,19 @@ export function grokBotGatewayDescriptorPath(home = homedir()) { export function hasGrokBotGatewaySession({ platform = process.platform, home = homedir(), + env = process.env, } = {}) { - return platform === "darwin" && existsSync(grokBotGatewayDescriptorPath(home)); + return SUPPORTED_PLATFORMS.has(platform) && existsSync(grokBotGatewayDescriptorPath(home, platform, env)); } -function readKeychainPassword() { +function readKeychainPassword(platform = process.platform) { + if (platform === "linux") { + return execFileSync( + "secret-tool", + ["lookup", "xdg:schema", "chrome_libsecret_os_crypt_password_v2", "application", "Grok Bot"], + { encoding: "utf8" }, + ).trimEnd(); + } return execFileSync( "/usr/bin/security", ["find-generic-password", "-w", "-s", "Grok Bot Safe Storage"], @@ -92,18 +117,23 @@ function readKeychainPassword() { export function loadGrokBotGatewaySession({ platform = process.platform, home = homedir(), + env = process.env, getKeychainPassword = readKeychainPassword, } = {}) { - if (platform !== "darwin") return null; + if (!SUPPORTED_PLATFORMS.has(platform)) return null; - const path = grokBotGatewayDescriptorPath(home); + const path = grokBotGatewayDescriptorPath(home, platform, env); if (!existsSync(path)) return null; const wrapped = JSON.parse(readFileSync(path, "utf8")); const encrypted = encryptedPayload(wrapped); + const prefix = Buffer.from(encrypted, "base64").subarray(0, 3).toString("latin1"); + // Linux v10 is the keyring-less basic_text backend; no secret store to ask. + const needsKeychain = !(platform === "linux" && prefix === SAFE_STORAGE_PREFIX_V10); const clear = decryptSafeStorageString( encrypted, - getKeychainPassword(), + needsKeychain ? getKeychainPassword(platform) : LINUX_BASIC_TEXT_PASSWORD, + platform, ); const descriptor = JSON.parse(clear); if (!descriptor.baseUrl || !descriptor.token) { diff --git a/test/app-session.test.js b/test/app-session.test.js index 6c5c2b9..191a101 100644 --- a/test/app-session.test.js +++ b/test/app-session.test.js @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import crypto from "node:crypto"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -6,6 +7,8 @@ import test from "node:test"; import { decryptSafeStorageString, + grokBotGatewayDescriptorPath, + hasGrokBotGatewaySession, inspectGrokBotGatewaySession, loadGrokBotGatewaySession, } from "../src/app-session.js"; @@ -210,11 +213,109 @@ test("reports a present but unusable Grok Bot app session", () => { }); }); -test("does not probe macOS credentials on other platforms", () => { +function encryptLinuxSafeStorage(clear, prefix, password) { + // Chromium OSCrypt on Linux: PBKDF2-SHA1, "saltysalt", ONE iteration, AES-128-CBC, IV = 16 spaces. + const key = crypto.pbkdf2Sync(password, "saltysalt", 1, 16, "sha1"); + const cipher = crypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 32)); + return Buffer.concat([ + Buffer.from(prefix, "latin1"), + cipher.update(clear, "utf8"), + cipher.final(), + ]).toString("base64"); +} + +const LINUX_DESCRIPTOR = JSON.stringify({ + baseUrl: "https://box.example/", + token: "gateway-token", + headers: { "x-anyrun-network-token": "route-token" }, +}); + +function writeLinuxDescriptor(encrypted, env = {}) { + const home = mkdtempSync(join(tmpdir(), "gbot-home-")); + const descriptorPath = grokBotGatewayDescriptorPath(home, "linux", env); + mkdirSync(dirname(descriptorPath), { recursive: true }); + writeFileSync(descriptorPath, JSON.stringify({ version: 2, entries: { primary: { encrypted } } })); + return home; +} + +test("loads a Linux v11 descriptor with the Secret Service password", () => { + const home = writeLinuxDescriptor( + encryptLinuxSafeStorage(LINUX_DESCRIPTOR, "v11", "keyring-password"), + ); + let platformAsked = null; + + const session = loadGrokBotGatewaySession({ + platform: "linux", + home, + env: {}, + getKeychainPassword: (platform) => { + platformAsked = platform; + return "keyring-password"; + }, + }); + + assert.equal(platformAsked, "linux"); + assert.deepEqual(session, { + gatewayUrl: "https://box.example", + gatewayToken: "gateway-token", + headers: { "x-anyrun-network-token": "route-token" }, + }); +}); + +test("loads a Linux v10 (basic_text) descriptor without touching the keyring", () => { + const home = writeLinuxDescriptor( + encryptLinuxSafeStorage(LINUX_DESCRIPTOR, "v10", "peanuts"), + ); let keychainRead = false; const session = loadGrokBotGatewaySession({ platform: "linux", + home, + env: {}, + getKeychainPassword: () => { + keychainRead = true; + return "unused"; + }, + }); + + assert.equal(keychainRead, false); + assert.equal(session.gatewayToken, "gateway-token"); +}); + +test("honours XDG_CONFIG_HOME for the Linux descriptor", () => { + const configHome = mkdtempSync(join(tmpdir(), "gbot-xdg-")); + const env = { XDG_CONFIG_HOME: configHome }; + writeLinuxDescriptor(encryptLinuxSafeStorage(LINUX_DESCRIPTOR, "v10", "peanuts"), env); + + assert.equal(hasGrokBotGatewaySession({ platform: "linux", home: "/tmp/unused", env }), true); + assert.equal( + grokBotGatewayDescriptorPath("/tmp/unused", "linux", env), + join(configHome, "Grok Bot/gateway-descriptor.json"), + ); +}); + +test("rejects a wrong Secret Service password on Linux as unusable", () => { + const home = writeLinuxDescriptor( + encryptLinuxSafeStorage(LINUX_DESCRIPTOR, "v11", "keyring-password"), + ); + + const status = inspectGrokBotGatewaySession({ + platform: "linux", + home, + env: {}, + getKeychainPassword: () => "wrong-password", + }); + + assert.equal(status.present, true); + assert.equal(status.usable, false); + assert.equal(status.code, "UNUSABLE_SESSION"); +}); + +test("does not probe app credentials on unsupported platforms", () => { + let keychainRead = false; + + const session = loadGrokBotGatewaySession({ + platform: "win32", home: "/tmp/unused", getKeychainPassword: () => { keychainRead = true; diff --git a/test/doctor.test.js b/test/doctor.test.js index 4ba3144..11d109c 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -6,20 +6,20 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { grokBotGatewayDescriptorPath } from "../src/app-session.js"; + const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); test("doctor reports a present but unusable Grok Bot app session", { - skip: process.platform !== "darwin" && "Grok Bot app sessions are macOS-only", + skip: !["darwin", "linux"].includes(process.platform) && "Grok Bot app sessions are macOS/Linux-only", }, () => { const home = mkdtempSync(join(tmpdir(), "gbot-doctor-home-")); - const descriptorPath = join( - home, - "Library/Application Support/Grok Bot/gateway-descriptor.json", - ); + const descriptorPath = grokBotGatewayDescriptorPath(home, process.platform, {}); mkdirSync(dirname(descriptorPath), { recursive: true }); writeFileSync(descriptorPath, JSON.stringify({ version: 2, entries: {} })); const env = { ...process.env, HOME: home }; for (const name of [ + "XDG_CONFIG_HOME", "CURSOR_ACCESS_TOKEN", "GROK_BOT_ACCESS_TOKEN", "GROK_BOT_GATEWAY_URL",