diff --git a/.changeset/windows-app-session.md b/.changeset/windows-app-session.md new file mode 100644 index 0000000..d3ab5f8 --- /dev/null +++ b/.changeset/windows-app-session.md @@ -0,0 +1,5 @@ +--- +"grok-bot-cli": minor +--- + +Use the signed-in Grok Bot app session on Windows. diff --git a/README.md b/README.md index cb0df33..b9d29fd 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 app on macOS or Windows. Open Grok Bot and sign in once; `gbot` automatically uses the app's encrypted session and routing credentials. No token copying is required. ## Use diff --git a/src/app-session.js b/src/app-session.js index eb6baf0..1b1ab9b 100644 --- a/src/app-session.js +++ b/src/app-session.js @@ -67,18 +67,55 @@ export function decryptSafeStorageString(encryptedBase64, password) { ]).toString("utf8"); } -export function grokBotGatewayDescriptorPath(home = homedir()) { +// Windows Chromium Safe Storage: "v10" + 12-byte nonce + AES-256-GCM ciphertext + 16-byte tag. +export function decryptWindowsSafeStorageString(encryptedBase64, key) { + const encrypted = Buffer.from(encryptedBase64, "base64"); + if (!encrypted.subarray(0, 3).equals(SAFE_STORAGE_PREFIX)) { + throw new Error("Unsupported Grok Bot Safe Storage format."); + } + + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + key, + encrypted.subarray(3, 15), + ); + decipher.setAuthTag(encrypted.subarray(-16)); + return Buffer.concat([ + decipher.update(encrypted.subarray(15, -16)), + decipher.final(), + ]).toString("utf8"); +} + +function grokBotAppDataPath(home, platform, appData) { + return platform === "win32" + ? join(appData || join(home, "AppData/Roaming"), "Grok Bot") + : join(home, "Library/Application Support/Grok Bot"); +} + +function isSupportedPlatform(platform) { + return platform === "darwin" || platform === "win32"; +} + +export function grokBotGatewayDescriptorPath( + home = homedir(), + platform = process.platform, + appData = process.env.APPDATA, +) { return join( - home, - "Library/Application Support/Grok Bot/gateway-descriptor.json", + grokBotAppDataPath(home, platform, appData), + "gateway-descriptor.json", ); } export function hasGrokBotGatewaySession({ platform = process.platform, home = homedir(), + appData = process.env.APPDATA, } = {}) { - return platform === "darwin" && existsSync(grokBotGatewayDescriptorPath(home)); + return ( + isSupportedPlatform(platform) && + existsSync(grokBotGatewayDescriptorPath(home, platform, appData)) + ); } function readKeychainPassword() { @@ -89,22 +126,61 @@ function readKeychainPassword() { ).trimEnd(); } +function unprotectWithDpapi(blob) { + const script = + "Add-Type -AssemblyName System.Security; " + + "$blob = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); " + + "[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Unprotect($blob, $null, 'CurrentUser'))"; + const out = execFileSync( + join( + process.env.SystemRoot ?? "C:\\Windows", + "System32/WindowsPowerShell/v1.0/powershell.exe", + ), + ["-NoProfile", "-NonInteractive", "-Command", script], + { input: blob.toString("base64"), encoding: "utf8" }, + ); + return Buffer.from(out.trim(), "base64"); +} + +function readWindowsSafeStorageKey(home, appData, unprotectData) { + const path = join(grokBotAppDataPath(home, "win32", appData), "Local State"); + const encryptedKey = existsSync(path) + ? JSON.parse(readFileSync(path, "utf8")).os_crypt?.encrypted_key + : null; + const blob = Buffer.from( + typeof encryptedKey === "string" ? encryptedKey : "", + "base64", + ); + if (blob.subarray(0, 5).toString("latin1") !== "DPAPI") { + throw new GrokBotGatewaySessionError( + "MISSING_SAFE_STORAGE_KEY", + "Grok Bot Local State has no Safe Storage key.", + ); + } + return unprotectData(blob.subarray(5)); +} + export function loadGrokBotGatewaySession({ platform = process.platform, home = homedir(), + appData = process.env.APPDATA, getKeychainPassword = readKeychainPassword, + unprotectData = unprotectWithDpapi, } = {}) { - if (platform !== "darwin") return null; + if (!isSupportedPlatform(platform)) return null; - const path = grokBotGatewayDescriptorPath(home); + const path = grokBotGatewayDescriptorPath(home, platform, appData); if (!existsSync(path)) return null; const wrapped = JSON.parse(readFileSync(path, "utf8")); const encrypted = encryptedPayload(wrapped); - const clear = decryptSafeStorageString( - encrypted, - getKeychainPassword(), - ); + const clear = + platform === "win32" + ? decryptWindowsSafeStorageString( + encrypted, + readWindowsSafeStorageKey(home, appData, unprotectData), + ) + : decryptSafeStorageString(encrypted, getKeychainPassword()); const descriptor = JSON.parse(clear); if (!descriptor.baseUrl || !descriptor.token) { throw new GrokBotGatewaySessionError( diff --git a/test/app-session.test.js b/test/app-session.test.js index 6c5c2b9..d6919fc 100644 --- a/test/app-session.test.js +++ b/test/app-session.test.js @@ -6,6 +6,7 @@ import test from "node:test"; import { decryptSafeStorageString, + decryptWindowsSafeStorageString, inspectGrokBotGatewaySession, loadGrokBotGatewaySession, } from "../src/app-session.js"; @@ -14,6 +15,12 @@ const ENCRYPTED_DESCRIPTOR = "djEwddBm+U69UF2IJtIUtedNqMB3bQt7HsRw7MLWRkw/IfnMK+c4czCXq82JKPNsdsP3Bp2fX8HoGPZFsa7k+JOmbIkBanQwl4yiy9v7iOA+mE4rtGqYbYD9jJc+/9YnhcGjvSxCxD8fKbJLbHifTwroGQ=="; const ENCRYPTED_INCOMPLETE_DESCRIPTOR = "djEwddBm+U69UF2IJtIUtedNqMB3bQt7HsRw7MLWRkw/IfnWb2TrPAofoKysOC2KnKLJ"; +const WINDOWS_KEY = Buffer.from("demo-safe-storage-key-32-bytes!!"); +const WINDOWS_ENCRYPTED_DESCRIPTOR = + "djEwZGVtby1ub25jZTEyUN+Gpc6/+RfJzyx52epPxU9Qlzo9TQNAJD6QHtsCaTCTBxssiBtRPJzcylXxQTjOJdPlxMjVIjlxWhW7WfcfxPjJiVIr8J3dZZA7J1dF9uSYG6iDLXHAotC+0gDB7k81WdJmrbloEC+sslxz+AeU6VW3B5E5yYepIfkEdw=="; +const WINDOWS_LOCAL_STATE = { + os_crypt: { encrypted_key: Buffer.from("DPAPIdemo-dpapi-blob").toString("base64") }, +}; function writeWrappedDescriptor(wrapped) { const home = mkdtempSync(join(tmpdir(), "gbot-home-")); @@ -26,6 +33,25 @@ function writeWrappedDescriptor(wrapped) { return home; } +function writeWindowsAppData( + localState, + appData = mkdtempSync(join(tmpdir(), "gbot-appdata-")), +) { + const dir = join(appData, "Grok Bot"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "gateway-descriptor.json"), + JSON.stringify({ version: 2, entries: { primary: { encrypted: WINDOWS_ENCRYPTED_DESCRIPTOR } } }), + ); + writeFileSync(join(dir, "Local State"), JSON.stringify(localState)); + return appData; +} + +function unprotectDemoKey(blob) { + assert.equal(blob.toString("utf8"), "demo-dpapi-blob"); + return WINDOWS_KEY; +} + test("decrypts an Electron Safe Storage v10 string", () => { const clear = decryptSafeStorageString( ENCRYPTED_DESCRIPTOR, @@ -225,3 +251,65 @@ test("does not probe macOS credentials on other platforms", () => { assert.equal(session, null); assert.equal(keychainRead, false); }); + +test("decrypts a Windows Electron Safe Storage v10 string", () => { + const clear = decryptWindowsSafeStorageString( + WINDOWS_ENCRYPTED_DESCRIPTOR, + WINDOWS_KEY, + ); + + assert.deepEqual(JSON.parse(clear), { + baseUrl: "https://box.example", + token: "gateway-token", + headers: { "x-anyrun-network-token": "route-token" }, + }); +}); + +test("loads the signed-in Grok Bot gateway on Windows", () => { + const appData = writeWindowsAppData(WINDOWS_LOCAL_STATE); + + const session = loadGrokBotGatewaySession({ + platform: "win32", + home: "/tmp/unused", + appData, + unprotectData: unprotectDemoKey, + }); + + assert.deepEqual(session, { + gatewayUrl: "https://box.example", + gatewayToken: "gateway-token", + headers: { "x-anyrun-network-token": "route-token" }, + }); +}); + +test("falls back to AppData/Roaming under home when APPDATA is unset", () => { + const home = mkdtempSync(join(tmpdir(), "gbot-home-")); + writeWindowsAppData(WINDOWS_LOCAL_STATE, join(home, "AppData/Roaming")); + + const session = loadGrokBotGatewaySession({ + platform: "win32", + home, + appData: "", + unprotectData: unprotectDemoKey, + }); + + assert.equal(session.gatewayUrl, "https://box.example"); +}); + +test("reports a Windows app session without a Safe Storage key", () => { + const appData = writeWindowsAppData({ os_crypt: {} }); + + const status = inspectGrokBotGatewaySession({ + platform: "win32", + home: "/tmp/unused", + appData, + unprotectData: unprotectDemoKey, + }); + + assert.deepEqual(status, { + present: true, + usable: false, + code: "MISSING_SAFE_STORAGE_KEY", + error: "Grok Bot Local State has no Safe Storage key.", + }); +});