use std::fmt::Write as _;
use std::path::Path;
use sha2::{Digest, Sha256};
use crate::error::{AppError, Result};
const FALLBACK_SECRET: &str = "peanuts";
#[cfg(target_os = "macos")]
const MACOS_SERVICE: &str = "Grok Bot Safe Storage";
#[cfg(target_os = "macos")]
const MACOS_ACCOUNT: &str = "Grok Bot Key";
#[derive(Clone, PartialEq, Eq)]
pub enum OsCryptKey {
Cbc([u8; 16]),
Gcm([u8; crate::safe_storage::WINDOWS_KEY_LEN]),
}
impl std::fmt::Debug for OsCryptKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Cbc(_) => "OsCryptKey::Cbc(..)",
Self::Gcm(_) => "OsCryptKey::Gcm(..)",
})
}
}
impl OsCryptKey {
fn decrypt(&self, blob: &str) -> Result<Vec<u8>> {
match self {
Self::Cbc(key) => crate::safe_storage::decrypt(key, blob),
Self::Gcm(key) => crate::safe_storage::decrypt_windows(key, blob),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct GrokbotCredentials {
pub access_token: String,
pub refresh_token: String,
pub fingerprint: String,
}
impl std::fmt::Debug for GrokbotCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GrokbotCredentials")
.field("access_token", &"<redacted>")
.field("refresh_token", &"<redacted>")
.field("fingerprint", &self.fingerprint)
.finish()
}
}
pub fn fingerprint_of(secret: &str) -> String {
let digest = Sha256::digest(secret.as_bytes());
let mut hex = String::with_capacity(16);
for byte in digest.iter().take(8) {
let _ = write!(hex, "{byte:02x}");
}
hex
}
pub fn key_for(secret: Option<&str>) -> [u8; 16] {
crate::safe_storage::derive_key_linux(secret.unwrap_or(FALLBACK_SECRET).as_bytes())
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn oscrypt_key() -> Result<OsCryptKey> {
#[cfg(target_os = "linux")]
{
Ok(OsCryptKey::Cbc(key_for(lookup_secret().as_deref())))
}
#[cfg(target_os = "macos")]
{
macos_oscrypt_key().map(OsCryptKey::Cbc)
}
}
pub const LOCAL_STATE_FILE_NAME: &str = "Local State";
#[cfg(windows)]
pub fn windows_oscrypt_key(secrets_path: &Path) -> Result<OsCryptKey> {
let local_state = secrets_path.with_file_name(LOCAL_STATE_FILE_NAME);
if !local_state.is_file() {
return Err(AppError::Credentials(format!(
"Grok Bot: no Local State at {} — install the Grok Bot desktop app and sign in to it",
crate::display::sanitize_untrusted_path(&local_state)
)));
}
crate::safe_storage::windows_key(&local_state)
.map(OsCryptKey::Gcm)
.map_err(|_| {
AppError::Credentials(
"Grok Bot: the desktop app's encryption key could not be read for this Windows user; sign in to the Grok Bot desktop app again"
.into(),
)
})
}
#[cfg(target_os = "linux")]
fn lookup_secret() -> Option<String> {
let mut command = std::process::Command::new("secret-tool");
command
.args(["lookup", "application", "Grok Bot"])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::piped());
for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
command.env_remove(var);
}
let out = command.output().ok()?;
if !out.status.success() {
return None;
}
let secret = String::from_utf8_lossy(&out.stdout).trim().to_string();
if secret.is_empty() {
None
} else {
Some(secret)
}
}
#[cfg(target_os = "macos")]
fn macos_oscrypt_key() -> Result<[u8; 16]> {
let secret = lookup_macos_secret().ok_or_else(|| {
AppError::Credentials(
"Grok Bot: no `Grok Bot Safe Storage` item in the login Keychain — install the Grok Bot desktop app and sign in to it"
.into(),
)
})?;
Ok(crate::safe_storage::derive_key(secret.as_bytes()))
}
#[cfg(target_os = "macos")]
fn lookup_macos_secret() -> Option<String> {
let mut command = std::process::Command::new("/usr/bin/security");
command
.args([
"find-generic-password",
"-s",
MACOS_SERVICE,
"-a",
MACOS_ACCOUNT,
"-w",
])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.stdout(std::process::Stdio::piped());
for var in crate::vendor::vendor_secret_env_vars_to_remove(&[]) {
command.env_remove(var);
}
let out = command.output().ok()?;
if !out.status.success() {
return None;
}
let secret = String::from_utf8_lossy(&out.stdout).trim().to_string();
if secret.is_empty() {
None
} else {
Some(secret)
}
}
pub fn read_at(path: &Path, key: &OsCryptKey) -> Result<GrokbotCredentials> {
let raw = std::fs::read(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
missing_file_error(path)
} else {
AppError::io_at(path, e)
}
})?;
parse(&raw, key)
}
pub fn missing_file_error(path: &Path) -> AppError {
AppError::Credentials(format!(
"Grok Bot: no credential file at {} — install the Grok Bot desktop app and sign in to it",
crate::display::sanitize_untrusted_path(path)
))
}
fn parse(raw: &[u8], key: &OsCryptKey) -> Result<GrokbotCredentials> {
let malformed = || {
AppError::Credentials(
"Grok Bot: sand-secrets.json does not hold an active signed-in account; sign in to the Grok Bot desktop app again"
.into(),
)
};
let root: serde_json::Value = serde_json::from_slice(raw).map_err(|_| malformed())?;
let accounts = cursor_accounts_object(&root).ok_or_else(malformed)?;
let active = accounts
.get("active")
.and_then(serde_json::Value::as_str)
.filter(|id| !id.trim().is_empty())
.ok_or_else(malformed)?;
let entry = accounts
.get("accounts")
.and_then(|accounts| accounts.get(active))
.ok_or_else(malformed)?;
let access_token = decrypt_field(key, entry.get("cursor-access-token"))?;
let refresh_token = decrypt_field(key, entry.get("cursor-refresh-token"))?;
let fingerprint = fingerprint_of(&refresh_token);
Ok(GrokbotCredentials {
access_token,
refresh_token,
fingerprint,
})
}
fn cursor_accounts_object(root: &serde_json::Value) -> Option<serde_json::Value> {
match root.get("cursor-accounts") {
Some(serde_json::Value::Object(_)) => root.get("cursor-accounts").cloned(),
Some(serde_json::Value::String(s)) => serde_json::from_str(s).ok(),
_ => None,
}
}
fn decrypt_field(key: &OsCryptKey, field: Option<&serde_json::Value>) -> Result<String> {
let blob = field
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
AppError::Credentials(
"Grok Bot: sand-secrets.json is missing a token field; sign in to the Grok Bot desktop app again"
.into(),
)
})?;
let bytes = key.decrypt(blob).map_err(|_| {
AppError::Credentials(
"Grok Bot: a stored token could not be decrypted; sign in to the Grok Bot desktop app again"
.into(),
)
})?;
String::from_utf8(bytes).map_err(|_| {
AppError::Credentials(
"Grok Bot: a stored token is not UTF-8; sign in to the Grok Bot desktop app again"
.into(),
)
})
}
pub fn secrets_present_at(path: &Path) -> bool {
std::fs::metadata(path).is_ok_and(|meta| meta.is_file() && meta.len() > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_key() -> [u8; 16] {
key_for(None)
}
fn seed_secrets(dir: &TempDir, access: &str, refresh: &str) -> std::path::PathBuf {
let key = test_key();
let path = dir.path().join("sand-secrets.json");
let doc = serde_json::json!({
"cursor-accounts": {
"active": "acct-1",
"accounts": {
"acct-1": {
"cursor-access-token": crate::safe_storage::encrypt(&key, access.as_bytes()),
"cursor-refresh-token": crate::safe_storage::encrypt(&key, refresh.as_bytes()),
}
}
}
});
std::fs::write(&path, doc.to_string()).unwrap();
path
}
#[test]
fn a_seeded_secrets_file_yields_decrypted_tokens() {
let td = TempDir::new().unwrap();
let path = seed_secrets(&td, "at-test", "rt-test");
let creds = read_at(&path, &OsCryptKey::Cbc(test_key())).unwrap();
assert_eq!(creds.access_token, "at-test");
assert_eq!(creds.refresh_token, "rt-test");
assert_eq!(creds.fingerprint, fingerprint_of("rt-test"));
assert_eq!(creds.fingerprint.len(), 16);
assert!(creds.fingerprint.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn the_active_entry_is_selected_among_several_accounts() {
let td = TempDir::new().unwrap();
let key = test_key();
let path = td.path().join("sand-secrets.json");
let doc = serde_json::json!({
"cursor-accounts": {
"active": "acct-2",
"accounts": {
"acct-1": {
"cursor-access-token": crate::safe_storage::encrypt(&key, b"at-one"),
"cursor-refresh-token": crate::safe_storage::encrypt(&key, b"rt-one"),
},
"acct-2": {
"cursor-access-token": crate::safe_storage::encrypt(&key, b"at-two"),
"cursor-refresh-token": crate::safe_storage::encrypt(&key, b"rt-two"),
}
}
}
});
std::fs::write(&path, doc.to_string()).unwrap();
let creds = read_at(&path, &OsCryptKey::Cbc(key)).unwrap();
assert_eq!(creds.access_token, "at-two");
assert_eq!(creds.refresh_token, "rt-two");
}
#[test]
fn a_missing_file_names_the_fix_and_the_path_checked() {
let td = TempDir::new().unwrap();
let missing = td.path().join("absent").join("sand-secrets.json");
let err = read_at(&missing, &OsCryptKey::Cbc(test_key())).unwrap_err();
let message = err.to_string();
assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
assert!(
message.contains("install the Grok Bot desktop app"),
"{message}"
);
assert!(message.contains("sand-secrets.json"), "{message}");
}
#[test]
fn malformed_files_and_missing_fields_are_credential_errors() {
let td = TempDir::new().unwrap();
for (name, contents) in [
("not-json", "not json".to_string()),
("no-root", r#"{"other": {}}"#.into()),
(
"no-active",
r#"{"cursor-accounts": {"accounts": {}}}"#.into(),
),
(
"no-entry",
r#"{"cursor-accounts": {"active": "a", "accounts": {}}}"#.into(),
),
(
"no-tokens",
r#"{"cursor-accounts": {"active": "a", "accounts": {"a": {}}}}"#.into(),
),
] {
let path = td.path().join(format!("{name}.json"));
std::fs::write(&path, contents).unwrap();
let err = read_at(&path, &OsCryptKey::Cbc(test_key())).unwrap_err();
assert!(matches!(err, AppError::Credentials(_)), "{name}: {err:?}");
assert!(!err.to_string().contains("\"other\""), "{name}: {err}");
}
}
#[test]
fn an_undecryptable_blob_is_a_credential_error_without_the_blob() {
let td = TempDir::new().unwrap();
let path = seed_secrets(&td, "at-test", "rt-test");
let wrong_key = crate::safe_storage::derive_key_linux(b"somebody-elses-secret");
let err = read_at(&path, &OsCryptKey::Cbc(wrong_key)).unwrap_err();
assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
let message = err.to_string();
assert!(message.contains("could not be decrypted"), "{message}");
assert!(!message.contains("at-test"), "{message}");
}
#[test]
fn the_fallback_secret_is_chromiums_documented_default() {
assert_eq!(key_for(None), key_for(Some("peanuts")));
}
#[test]
fn the_probe_wants_a_non_empty_file() {
let td = TempDir::new().unwrap();
let path = td.path().join("sand-secrets.json");
assert!(!secrets_present_at(&path));
std::fs::write(&path, "").unwrap();
assert!(!secrets_present_at(&path));
std::fs::write(&path, "{}").unwrap();
assert!(secrets_present_at(&path));
assert!(!secrets_present_at(td.path()));
}
#[test]
fn a_string_wrapped_cursor_accounts_object_parses() {
let td = TempDir::new().unwrap();
let key = test_key();
let inner = serde_json::json!({
"active": "acct-1",
"accounts": {
"acct-1": {
"cursor-access-token": crate::safe_storage::encrypt(&key, b"at-mac"),
"cursor-refresh-token": crate::safe_storage::encrypt(&key, b"rt-mac"),
}
}
});
let path = td.path().join("sand-secrets.json");
let doc = serde_json::json!({ "cursor-accounts": inner.to_string() });
std::fs::write(&path, doc.to_string()).unwrap();
let creds = read_at(&path, &OsCryptKey::Cbc(key)).unwrap();
assert_eq!(creds.access_token, "at-mac");
assert_eq!(creds.refresh_token, "rt-mac");
}
#[test]
fn macos_round_blobs_decrypt_with_the_macos_derivation() {
let td = TempDir::new().unwrap();
let key = crate::safe_storage::derive_key(b"not-a-real-secret");
let path = td.path().join("sand-secrets.json");
let doc = serde_json::json!({
"cursor-accounts": {
"active": "acct-1",
"accounts": {
"acct-1": {
"cursor-access-token": crate::safe_storage::encrypt(&key, b"at-macos"),
"cursor-refresh-token": crate::safe_storage::encrypt(&key, b"rt-macos"),
}
}
}
});
std::fs::write(&path, doc.to_string()).unwrap();
let creds = read_at(&path, &OsCryptKey::Cbc(key)).unwrap();
assert_eq!(creds.access_token, "at-macos");
assert_eq!(creds.refresh_token, "rt-macos");
}
const WINDOWS_KEY: [u8; 32] = [9; 32];
fn seed_windows_secrets(dir: &TempDir) -> std::path::PathBuf {
let seal = |nonce: u8, text: &str| {
crate::safe_storage::encrypt_windows(&WINDOWS_KEY, [nonce; 12], text.as_bytes())
};
let inner = serde_json::json!({
"active": "acct-1",
"accounts": {
"acct-1": {
"cursor-access-token": seal(1, "at-windows"),
"cursor-account-profile": {},
"cursor-refresh-token": seal(2, "rt-windows"),
}
}
});
let path = dir.path().join("sand-secrets.json");
let doc = serde_json::json!({
"cursor-machine-id": "machine",
"cursor-accounts": inner.to_string(),
});
std::fs::write(&path, doc.to_string()).unwrap();
path
}
#[test]
fn windows_gcm_blobs_decrypt_with_the_windows_key() {
let td = TempDir::new().unwrap();
let path = seed_windows_secrets(&td);
let creds = read_at(&path, &OsCryptKey::Gcm(WINDOWS_KEY)).unwrap();
assert_eq!(creds.access_token, "at-windows");
assert_eq!(creds.refresh_token, "rt-windows");
assert_eq!(creds.fingerprint, fingerprint_of("rt-windows"));
}
#[test]
fn a_windows_store_under_the_wrong_key_is_a_credential_error_without_the_blob() {
let td = TempDir::new().unwrap();
let path = seed_windows_secrets(&td);
for key in [OsCryptKey::Gcm([1; 32]), OsCryptKey::Cbc(test_key())] {
let err = read_at(&path, &key).unwrap_err();
assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
let message = err.to_string();
assert!(message.contains("could not be decrypted"), "{message}");
assert!(!message.contains("at-windows"), "{message}");
}
}
#[test]
fn debug_output_never_shows_a_key_or_a_token() {
let td = TempDir::new().unwrap();
let creds = read_at(&seed_windows_secrets(&td), &OsCryptKey::Gcm(WINDOWS_KEY)).unwrap();
let shown = format!("{creds:?} {:?}", OsCryptKey::Gcm(WINDOWS_KEY));
assert!(!shown.contains("at-windows"), "{shown}");
assert!(!shown.contains("rt-windows"), "{shown}");
assert!(!shown.contains("[9, 9"), "{shown}");
assert!(shown.contains(&creds.fingerprint), "{shown}");
}
#[cfg(windows)]
#[test]
fn a_missing_local_state_names_the_fix_and_the_path_checked() {
let td = TempDir::new().unwrap();
let path = seed_windows_secrets(&td);
let err = windows_oscrypt_key(&path).unwrap_err();
assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
let message = err.to_string();
assert!(message.contains("Local State"), "{message}");
assert!(
message.contains("install the Grok Bot desktop app"),
"{message}"
);
}
}