use std::fmt::Write as _;
use std::path::Path;
use sha2::{Digest, Sha256};
use crate::error::{AppError, Result};
const FALLBACK_SECRET: &str = "peanuts";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrokbotCredentials {
pub access_token: String,
pub refresh_token: String,
pub fingerprint: String,
}
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(target_os = "linux")]
pub fn oscrypt_key() -> [u8; 16] {
key_for(lookup_secret().as_deref())
}
#[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)
}
}
pub fn read_at(path: &Path, key: &[u8; 16]) -> Result<GrokbotCredentials> {
let raw = std::fs::read(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
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)
))
} else {
AppError::io_at(path, e)
}
})?;
parse(&raw, key)
}
fn parse(raw: &[u8], key: &[u8; 16]) -> 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 = root.get("cursor-accounts").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 decrypt_field(key: &[u8; 16], 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 = crate::safe_storage::decrypt(key, 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, &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, &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, &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, &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, &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()));
}
}