From 76fcfd2928eddd7367b5a1c73de7ec48c739c5f2 Mon Sep 17 00:00:00 2001 From: joaofellows Date: Mon, 9 Nov 2026 23:05:24 -0300 Subject: [PATCH 1/6] Add Cursor v10 token decryption. Co-authored-by: Cursor --- .../src/plugin_engine/cursor_safe_storage.rs | 119 ++++++++++++++++++ src-tauri/src/plugin_engine/mod.rs | 1 + 2 files changed, 120 insertions(+) create mode 100644 src-tauri/src/plugin_engine/cursor_safe_storage.rs diff --git a/src-tauri/src/plugin_engine/cursor_safe_storage.rs b/src-tauri/src/plugin_engine/cursor_safe_storage.rs new file mode 100644 index 00000000..2a11bb38 --- /dev/null +++ b/src-tauri/src/plugin_engine/cursor_safe_storage.rs @@ -0,0 +1,119 @@ +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + +pub const SAFE_STORAGE_PREFIX: &str = "cursor-safe-storage:v1:"; + +pub fn decrypt_stored_value(raw: &str, key: &[u8]) -> Result { + let Some(encoded) = raw.strip_prefix(SAFE_STORAGE_PREFIX) else { + return Ok(raw.to_string()); + }; + let blob = BASE64_STANDARD + .decode(encoded.trim()) + .map_err(|_| "decryption failed".to_string())?; + decrypt_v10_blob(key, &blob) +} + +pub fn decrypt_v10_blob(key: &[u8], blob: &[u8]) -> Result { + if key.len() != 32 { + return Err("Windows DPAPI returned an unexpected key".to_string()); + } + if blob.len() < 3 || &blob[..3] != b"v10" { + return Err("unrecognized encryption version".to_string()); + } + if blob.len() < 3 + 12 + 16 { + return Err("encrypted value is truncated".to_string()); + } + let nonce = Nonce::from_slice(&blob[3..15]); + let tag_at = blob.len() - 16; + let mut sealed = Vec::with_capacity(tag_at - 15 + 16); + sealed.extend_from_slice(&blob[15..tag_at]); + sealed.extend_from_slice(&blob[tag_at..]); + let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| "decryption failed".to_string())?; + let plain = cipher + .decrypt(nonce, sealed.as_ref()) + .map_err(|_| "decryption failed".to_string())?; + let text = String::from_utf8(plain).map_err(|_| "decrypted value is not a token".to_string())?; + if text.is_empty() || !text.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) { + return Err("decrypted value is not a token".to_string()); + } + Ok(text) +} + +#[cfg(test)] +mod tests { + use super::*; + use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, + }; + use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + + fn seal(key: &[u8; 32], nonce: &[u8; 12], plain: &str) -> Vec { + let cipher = Aes256Gcm::new_from_slice(key).expect("cipher"); + let sealed = cipher + .encrypt(nonce.into(), Payload { msg: plain.as_bytes(), aad: b"" }) + .expect("seal"); + let mut blob = b"v10".to_vec(); + blob.extend_from_slice(nonce); + blob.extend_from_slice(&sealed); + blob + } + + #[test] + fn decrypts_v10_and_passes_plaintext_through() { + let key = [7_u8; 32]; + let nonce = [9_u8; 12]; + let token = "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OX0.sig"; + let blob = seal(&key, &nonce, token); + let stored = format!( + "{}{}", + SAFE_STORAGE_PREFIX, + BASE64_STANDARD.encode(&blob) + ); + + assert_eq!(decrypt_stored_value(&stored, &key).expect("decrypt"), token); + assert_eq!( + decrypt_stored_value(token, &key).expect("plaintext"), + token + ); + } + + #[test] + fn rejects_bad_tag_truncated_blob_and_wrong_version() { + let key = [7_u8; 32]; + let mut blob = seal(&key, &[9_u8; 12], "token.ok"); + let last = blob.len() - 1; + blob[last] ^= 0xff; + let flipped = format!("{}{}", SAFE_STORAGE_PREFIX, BASE64_STANDARD.encode(&blob)); + assert_eq!( + decrypt_stored_value(&flipped, &key).expect_err("tag"), + "decryption failed" + ); + + let short = format!("{}{}", SAFE_STORAGE_PREFIX, BASE64_STANDARD.encode(b"v10short")); + assert_eq!( + decrypt_stored_value(&short, &key).expect_err("truncated"), + "encrypted value is truncated" + ); + + let other = format!("{}{}", SAFE_STORAGE_PREFIX, BASE64_STANDARD.encode(b"v11abcdefghijklmnop")); + assert_eq!( + decrypt_stored_value(&other, &key).expect_err("version"), + "unrecognized encryption version" + ); + } + + #[test] + fn rejects_non_token_plaintext() { + let key = [7_u8; 32]; + let blob = seal(&key, &[9_u8; 12], "tem espaço"); + let stored = format!("{}{}", SAFE_STORAGE_PREFIX, BASE64_STANDARD.encode(&blob)); + assert_eq!( + decrypt_stored_value(&stored, &key).expect_err("charset"), + "decrypted value is not a token" + ); + } +} diff --git a/src-tauri/src/plugin_engine/mod.rs b/src-tauri/src/plugin_engine/mod.rs index 95073bde..97c6025a 100644 --- a/src-tauri/src/plugin_engine/mod.rs +++ b/src-tauri/src/plugin_engine/mod.rs @@ -1,5 +1,6 @@ pub mod host_api; mod ccusage_cache; +mod cursor_safe_storage; pub(crate) mod http_gate; pub mod manifest; pub mod runtime; From cdb41cb4f42a769359725732e693bbb82f8d00b5 Mon Sep 17 00:00:00 2001 From: joaofellows Date: Mon, 9 Nov 2026 23:08:01 -0300 Subject: [PATCH 2/6] Unwrap Cursor's DPAPI master key in-process. Co-authored-by: Cursor --- src-tauri/Cargo.toml | 2 +- .../src/plugin_engine/cursor_safe_storage.rs | 144 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e768edaf..c281bf8a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ winreg = "0.55" # Already pulled in by tauri; listed so we can read the applied backdrop result. window-vibrancy = "0.6" windows-version = "0.1" -windows-sys = { version = "0.59", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media_Audio", "Win32_Security_Credentials", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Threading", "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.59", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media_Audio", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Threading", "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging"] } # Already pulled in by the notification plugin; used to read Do Not Disturb. windows = { version = "0.62", features = ["UI_Notifications", "Win32_System_Com", "Win32_UI_Accessibility"] } diff --git a/src-tauri/src/plugin_engine/cursor_safe_storage.rs b/src-tauri/src/plugin_engine/cursor_safe_storage.rs index 2a11bb38..8f6153e1 100644 --- a/src-tauri/src/plugin_engine/cursor_safe_storage.rs +++ b/src-tauri/src/plugin_engine/cursor_safe_storage.rs @@ -42,6 +42,83 @@ pub fn decrypt_v10_blob(key: &[u8], blob: &[u8]) -> Result { Ok(text) } +pub fn read_wrapped_key(local_state_json: &str) -> Result, String> { + let json: serde_json::Value = serde_json::from_str(local_state_json) + .map_err(|_| "could not read Cursor's Local State".to_string())?; + let encoded = json + .pointer("/os_crypt/encrypted_key") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let wrapped = BASE64_STANDARD + .decode(encoded) + .map_err(|_| "Cursor's Local State has no DPAPI key".to_string())?; + if wrapped.len() < 5 || &wrapped[..5] != b"DPAPI" { + return Err("Cursor's Local State has no DPAPI key".to_string()); + } + Ok(wrapped[5..].to_vec()) +} + +#[cfg(windows)] +pub fn unwrap_dpapi(payload: &[u8]) -> Result, String> { + use std::ptr; + use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; + use windows_sys::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB}; + + const UI_FORBIDDEN: u32 = 0x1; + let input = CRYPT_INTEGER_BLOB { + cbData: u32::try_from(payload.len()).map_err(|_| "Windows DPAPI could not unwrap the key".to_string())?, + pbData: payload.as_ptr() as *mut u8, + }; + let mut output = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: ptr::null_mut(), + }; + let ok = unsafe { + CryptUnprotectData( + &input, + ptr::null_mut(), + ptr::null(), + ptr::null_mut(), + ptr::null(), + UI_FORBIDDEN, + &mut output, + ) + }; + if ok == 0 || output.pbData.is_null() { + return Err("Windows DPAPI could not unwrap the key".to_string()); + } + let key = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; + unsafe { + LocalFree(output.pbData as HLOCAL); + } + if key.len() != 32 { + return Err("Windows DPAPI returned an unexpected key".to_string()); + } + Ok(key) +} + +pub fn decrypt_cursor_value(raw: &str) -> Result { + if !raw.starts_with(SAFE_STORAGE_PREFIX) { + return Ok(raw.to_string()); + } + #[cfg(windows)] + { + let path = dirs::data_dir() + .ok_or_else(|| "could not read Cursor's Local State".to_string())? + .join("Cursor") + .join("Local State"); + let json = std::fs::read_to_string(&path) + .map_err(|_| "could not read Cursor's Local State".to_string())?; + let wrapped = read_wrapped_key(&json)?; + let key = unwrap_dpapi(&wrapped)?; + decrypt_stored_value(raw, &key) + } + #[cfg(not(windows))] + { + Err("not supported on this platform".to_string()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -62,6 +139,37 @@ mod tests { blob } + #[cfg(all(test, windows))] + fn protect_dpapi(plain: &[u8]) -> Vec { + use std::ptr; + use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; + use windows_sys::Win32::Security::Cryptography::{CryptProtectData, CRYPT_INTEGER_BLOB}; + + let input = CRYPT_INTEGER_BLOB { + cbData: plain.len() as u32, + pbData: plain.as_ptr() as *mut u8, + }; + let mut output = CRYPT_INTEGER_BLOB { + cbData: 0, + pbData: ptr::null_mut(), + }; + let ok = unsafe { + CryptProtectData( + &input, + ptr::null(), + ptr::null(), + ptr::null_mut(), + ptr::null(), + 0x1, + &mut output, + ) + }; + assert_eq!(ok, 1, "CryptProtectData"); + let protected = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; + unsafe { LocalFree(output.pbData as HLOCAL); } + protected + } + #[test] fn decrypts_v10_and_passes_plaintext_through() { let key = [7_u8; 32]; @@ -116,4 +224,40 @@ mod tests { "decrypted value is not a token" ); } + + #[test] + fn read_wrapped_key_strips_dpapi_prefix() { + let body = b"DPAPI0123456789abcdef0123456789ab"; + let json = format!( + r#"{{"os_crypt":{{"encrypted_key":"{}"}}}}"#, + BASE64_STANDARD.encode(body) + ); + let wrapped = read_wrapped_key(&json).expect("wrapped"); + assert_eq!(wrapped, b"0123456789abcdef0123456789ab"); + } + + #[test] + fn read_wrapped_key_rejects_other_prefixes() { + let json = format!( + r#"{{"os_crypt":{{"encrypted_key":"{}"}}}}"#, + BASE64_STANDARD.encode(b"APPBnot-dpapi") + ); + assert_eq!( + read_wrapped_key(&json).expect_err("appb"), + "Cursor's Local State has no DPAPI key" + ); + assert_eq!( + read_wrapped_key("{}").expect_err("missing"), + "Cursor's Local State has no DPAPI key" + ); + } + + #[cfg(windows)] + #[test] + fn dpapi_round_trip_returns_32_bytes() { + let key = [3_u8; 32]; + let protected = protect_dpapi(&key); + let opened = unwrap_dpapi(&protected).expect("unwrap"); + assert_eq!(opened, key); + } } From 52042cfe0f558ecc5cd0c6945502769e30973a3a Mon Sep 17 00:00:00 2001 From: joaofellows Date: Mon, 9 Nov 2026 23:09:11 -0300 Subject: [PATCH 3/6] Expose Cursor token decrypt only to the cursor plugin. Co-authored-by: Cursor --- src-tauri/src/plugin_engine/host_api.rs | 43 +++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 502c2c5b..8d98dfc1 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -699,6 +699,12 @@ pub(crate) fn inject_host_api_with_deadline<'js>( inject_http(ctx, &host, plugin_id, deadline)?; inject_keychain(ctx, &host, plugin_id)?; inject_sqlite(ctx, &host, home.clone())?; + let cursor_id = account + .map(|account| account.base_id.as_str()) + .unwrap_or(plugin_id); + if cursor_id == "cursor" { + inject_cursor_auth(ctx, &host)?; + } inject_ls(ctx, &host, plugin_id)?; // An account copy reads the same kind of local logs as its base plugin (from its own home). A // WSL copy reads none: its logs live in WSL, far too slow to scan over \\wsl.localhost. @@ -719,6 +725,19 @@ pub(crate) fn inject_host_api_with_deadline<'js>( Ok(()) } +fn inject_cursor_auth<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { + let cursor_auth = Object::new(ctx.clone())?; + cursor_auth.set( + "decrypt", + Function::new(ctx.clone(), |ctx_inner: Ctx<'_>, raw: String| -> rquickjs::Result { + super::cursor_safe_storage::decrypt_cursor_value(&raw) + .map_err(|message| Exception::throw_message(&ctx_inner, &message)) + })?, + )?; + host.set("cursorAuth", cursor_auth)?; + Ok(()) +} + fn inject_log<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { let log_obj = Object::new(ctx.clone())?; @@ -5703,4 +5722,28 @@ wait assert_eq!(status, "ok"); }); } + + #[test] + fn cursor_plugin_can_decrypt_plaintext_and_other_plugins_cannot() { + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "cursor", &app_data, "0.0.0").expect("inject"); + let plain: String = ctx + .eval(r#"__openusage_ctx.host.cursorAuth.decrypt("plain-token")"#) + .expect("plaintext decrypt"); + assert_eq!(plain, "plain-token"); + }); + + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "claude", &app_data, "0.0.0").expect("inject"); + let missing: rquickjs::Result = ctx.eval( + r#"__openusage_ctx.host.cursorAuth.decrypt("plain-token")"#, + ); + assert!(missing.is_err(), "claude must not receive cursorAuth"); + }); + } } From 3bc97fe1b855a4befc3c38d7fb124bfe20ceee5d Mon Sep 17 00:00:00 2001 From: joaofellows Date: Mon, 9 Nov 2026 23:14:52 -0300 Subject: [PATCH 4/6] Decrypt Cursor auth tokens without writing them back. Co-authored-by: Cursor --- plugins/cursor/plugin.js | 73 ++++++++++++++++++++++---- plugins/cursor/plugin.test.js | 80 +++++++++++++++++++++++++++++ src/lib/plugin-errors.test.ts | 2 + src/lib/plugin-errors.ts | 2 + src/locales/en.lock.json | 4 +- src/locales/plugin-errors/de-DE.ts | 2 + src/locales/plugin-errors/en-US.ts | 2 + src/locales/plugin-errors/es-419.ts | 2 + src/locales/plugin-errors/es-ES.ts | 2 + src/locales/plugin-errors/fr-FR.ts | 2 + src/locales/plugin-errors/it-IT.ts | 2 + src/locales/plugin-errors/ja-JP.ts | 2 + src/locales/plugin-errors/ko-KR.ts | 2 + src/locales/plugin-errors/pt-BR.ts | 2 + src/locales/plugin-errors/tr-TR.ts | 2 + src/locales/plugin-errors/zh-CN.ts | 2 + 16 files changed, 173 insertions(+), 10 deletions(-) diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index db425425..7ee7cbb2 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -17,6 +17,8 @@ const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration const LOGIN_HINT = "Sign in via Cursor app or run `agent login`." + const SAFE_STORAGE_PREFIX = "cursor-safe-storage:v1:" + let cachedAccessToken = null function readStateValue(ctx, key) { try { @@ -83,9 +85,46 @@ } } + function isSafeStorageValue(value) { + return typeof value === "string" && value.indexOf(SAFE_STORAGE_PREFIX) === 0 + } + + function decodeStoredToken(ctx, raw) { + if (typeof raw !== "string" || raw.length === 0) return null + if (!isSafeStorageValue(raw)) return raw + if (!ctx.host.cursorAuth || typeof ctx.host.cursorAuth.decrypt !== "function") { + throw "Cursor secure storage is unavailable." + } + try { + const plain = ctx.host.cursorAuth.decrypt(raw) + if (typeof plain !== "string" || plain.length === 0) { + throw "Cursor secure storage could not be read." + } + return plain + } catch (e) { + if (typeof e === "string") throw e + ctx.host.log.error("cursor secure storage decrypt failed") + throw "Cursor secure storage could not be read." + } + } + + function takeCachedAccessToken(ctx, loadedAccessToken) { + if (!cachedAccessToken) return null + const cachedSubject = getTokenSubject(ctx, cachedAccessToken) + const loadedSubject = getTokenSubject(ctx, loadedAccessToken) + if (cachedSubject && loadedSubject && cachedSubject !== loadedSubject) { + cachedAccessToken = null + return null + } + return cachedAccessToken + } + function loadAuthState(ctx) { - const sqliteAccessToken = readStateValue(ctx, "cursorAuth/accessToken") - const sqliteRefreshToken = readStateValue(ctx, "cursorAuth/refreshToken") + const sqliteAccessRaw = readStateValue(ctx, "cursorAuth/accessToken") + const sqliteRefreshRaw = readStateValue(ctx, "cursorAuth/refreshToken") + const sqliteAccessToken = decodeStoredToken(ctx, sqliteAccessRaw) + const sqliteRefreshToken = decodeStoredToken(ctx, sqliteRefreshRaw) + const writeSqlite = !isSafeStorageValue(sqliteAccessRaw) const sqliteMembershipTypeRaw = readStateValue(ctx, "cursorAuth/stripeMembershipType") const sqliteMembershipType = typeof sqliteMembershipTypeRaw === "string" ? sqliteMembershipTypeRaw.trim().toLowerCase() @@ -107,6 +146,7 @@ refreshToken: keychainRefreshToken, membershipType: null, source: "keychain", + writeSqlite: false, } } @@ -115,6 +155,7 @@ refreshToken: sqliteRefreshToken, membershipType: sqliteMembershipType || null, source: "sqlite", + writeSqlite: writeSqlite, } } @@ -125,6 +166,7 @@ refreshToken: keychainRefreshToken, membershipType: null, source: "keychain", + writeSqlite: false, } } @@ -132,6 +174,7 @@ accessToken: null, refreshToken: null, source: null, + writeSqlite: false, } } @@ -143,11 +186,18 @@ return subject || null } - function persistAccessToken(ctx, source, accessToken) { + function persistAccessToken(ctx, source, accessToken, writeSqlite) { + cachedAccessToken = accessToken if (source === "keychain") { return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken) } - return writeStateValue(ctx, "cursorAuth/accessToken", accessToken) + if (!writeSqlite) { + ctx.host.log.info("refresh succeeded, token kept in memory") + return true + } + const wrote = writeStateValue(ctx, "cursorAuth/accessToken", accessToken) + if (wrote) ctx.host.log.info("refresh succeeded, token persisted") + return wrote } function getTokenExpiration(ctx, token) { @@ -166,7 +216,7 @@ }) } - function refreshToken(ctx, refreshTokenValue, source) { + function refreshToken(ctx, refreshTokenValue, source, writeSqlite) { if (!refreshTokenValue) { ctx.host.log.warn("refresh skipped: no refresh token") return null @@ -192,6 +242,7 @@ const shouldLogout = errorInfo && errorInfo.shouldLogout === true ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout) if (shouldLogout) { + cachedAccessToken = null throw "Session expired. " + LOGIN_HINT } throw "Token expired. " + LOGIN_HINT @@ -211,6 +262,7 @@ // Check if server wants us to logout if (body.shouldLogout === true) { ctx.host.log.error("refresh response indicates shouldLogout=true") + cachedAccessToken = null throw "Session expired. " + LOGIN_HINT } @@ -221,8 +273,7 @@ } // Persist updated access token to source where auth was loaded from. - persistAccessToken(ctx, source, newAccessToken) - ctx.host.log.info("refresh succeeded, token persisted") + persistAccessToken(ctx, source, newAccessToken, writeSqlite) // Note: Cursor refresh returns access_token which is used as both // access and refresh token in some flows @@ -563,13 +614,17 @@ ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no")) const nowMs = Date.now() + const cached = takeCachedAccessToken(ctx, accessToken) + if (cached && !needsRefresh(ctx, cached, nowMs)) { + accessToken = cached + } // Proactively refresh if token is expired or about to expire if (needsRefresh(ctx, accessToken, nowMs)) { ctx.host.log.info("token needs refresh (expired or expiring soon)") let refreshed = null try { - refreshed = refreshToken(ctx, refreshTokenValue, authSource) + refreshed = refreshToken(ctx, refreshTokenValue, authSource, authState.writeSqlite) } catch (e) { // If refresh fails but we have an access token, try it anyway ctx.host.log.warn("refresh failed but have access token, will try: " + String(e)) @@ -601,7 +656,7 @@ refresh: () => { ctx.host.log.info("usage returned 401, attempting refresh") didRefresh = true - const refreshed = refreshToken(ctx, refreshTokenValue, authSource) + const refreshed = refreshToken(ctx, refreshTokenValue, authSource, authState.writeSqlite) if (refreshed) accessToken = refreshed return refreshed }, diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index 15952b7e..8e5a3b3c 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -2090,4 +2090,84 @@ describe("cursor plugin", () => { expect(result.lines.find((line) => line.label === "Grok Bot usage")).toBeUndefined() expect(failing.host.log.warn).toHaveBeenCalledWith("Grok Bot usage returned status=503") }) + + it("decrypts safe-storage tokens and does not write them back to sqlite", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const storedRefresh = "cursor-safe-storage:v1:refresh" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: storedAccess }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: storedRefresh }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = { + decrypt: vi.fn((raw) => { + if (raw === storedAccess) return expired + if (raw === storedRefresh) return "refresh-plain" + throw new Error("unexpected decrypt") + }), + } + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + const body = JSON.parse(opts.bodyText) + expect(body.refresh_token).toBe("refresh-plain") + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.lines.find((line) => line.label === "Total usage")).toBeTruthy() + expect(ctx.host.cursorAuth.decrypt).toHaveBeenCalledWith(storedAccess) + expect(ctx.host.cursorAuth.decrypt).toHaveBeenCalledWith(storedRefresh) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) + + it("reuses the in-memory access token on the next probe", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: storedAccess }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = { + decrypt: vi.fn((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")), + } + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + plugin.probe(ctx) + + expect(refreshCalls).toBe(1) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) }) diff --git a/src/lib/plugin-errors.test.ts b/src/lib/plugin-errors.test.ts index 5b528099..784943c9 100644 --- a/src/lib/plugin-errors.test.ts +++ b/src/lib/plugin-errors.test.ts @@ -54,6 +54,8 @@ const FIXTURES: [string, PluginErrorKey][] = [ ["Team request-based usage data unavailable. Try again later.", "teamRequestUsageUnavailable"], ["Cursor request-based usage data unavailable. Try again later.", "requestUsageUnavailable"], ["No active Cursor subscription.", "noActiveSubscription"], + ["Cursor secure storage is unavailable.", "cursorSecureStorageUnavailable"], + ["Cursor secure storage could not be read.", "cursorSecureStorageUnreadable"], ["Total usage limit missing from API response.", "totalLimitMissing"], // devin ["Run devin auth login or sign in to Devin and try again.", "runOrSignIn"], diff --git a/src/lib/plugin-errors.ts b/src/lib/plugin-errors.ts index 611b23d7..80245a9f 100644 --- a/src/lib/plugin-errors.ts +++ b/src/lib/plugin-errors.ts @@ -36,6 +36,8 @@ export const PLUGIN_ERROR_PATTERNS: readonly { pattern: RegExp; key: PluginError { pattern: /^Request failed \(HTTP (?\d+)\)$/, key: "requestFailedHttpShort" }, { pattern: /^Could not parse usage data\.$/, key: "parseFailed" }, { pattern: /^WSL isn't running\. Start it to update this card\.$/, key: "wslNotRunning" }, + { pattern: /^Cursor secure storage is unavailable\.$/, key: "cursorSecureStorageUnavailable" }, + { pattern: /^Cursor secure storage could not be read\.$/, key: "cursorSecureStorageUnreadable" }, { pattern: /^Usage request failed\. Check your connection\.$/, key: "usageRequestFailed" }, { pattern: /^Usage request failed \(HTTP (?\d+)\)\. Try again later\.$/, key: "usageRequestFailedHttp" }, diff --git a/src/locales/en.lock.json b/src/locales/en.lock.json index 651ee002..d9d3fabe 100644 --- a/src/locales/en.lock.json +++ b/src/locales/en.lock.json @@ -424,5 +424,7 @@ "plugin-errors.pluginFailed": "6252c16f47", "plugin-errors.probeStartFailed": "0af5da2403", "plugin-errors.updateFailed": "c10c914d19", - "plugin-errors.wslNotRunning": "26e5198224" + "plugin-errors.wslNotRunning": "26e5198224", + "plugin-errors.cursorSecureStorageUnavailable": "0b699ea09f", + "plugin-errors.cursorSecureStorageUnreadable": "bb3834cfa0" } diff --git a/src/locales/plugin-errors/de-DE.ts b/src/locales/plugin-errors/de-DE.ts index e9bba07d..bda23b19 100644 --- a/src/locales/plugin-errors/de-DE.ts +++ b/src/locales/plugin-errors/de-DE.ts @@ -72,6 +72,8 @@ const deDE: PluginErrorMessages = { probeStartFailed: "Abfrage konnte nicht gestartet werden", updateFailed: "Daten konnten nicht aktualisiert werden. Erneut versuchen?", wslNotRunning: "WSL läuft nicht. Starten Sie WSL, um diese Karte zu aktualisieren.", + cursorSecureStorageUnavailable: "Der sichere Speicher von Cursor ist nicht verfügbar.", + cursorSecureStorageUnreadable: "Der sichere Speicher von Cursor konnte nicht gelesen werden.", } export default deDE diff --git a/src/locales/plugin-errors/en-US.ts b/src/locales/plugin-errors/en-US.ts index db81f8dc..f609aa39 100644 --- a/src/locales/plugin-errors/en-US.ts +++ b/src/locales/plugin-errors/en-US.ts @@ -72,6 +72,8 @@ const enUS = { probeStartFailed: "Failed to start probe", updateFailed: "Couldn't update data. Try again?", wslNotRunning: "WSL isn't running. Start it to update this card.", + cursorSecureStorageUnavailable: "Cursor secure storage is unavailable.", + cursorSecureStorageUnreadable: "Cursor secure storage could not be read.", } export default enUS diff --git a/src/locales/plugin-errors/es-419.ts b/src/locales/plugin-errors/es-419.ts index 682f18ce..adcadaad 100644 --- a/src/locales/plugin-errors/es-419.ts +++ b/src/locales/plugin-errors/es-419.ts @@ -72,6 +72,8 @@ const es419: PluginErrorMessages = { probeStartFailed: "No se pudo iniciar la consulta de datos", updateFailed: "No se pudieron actualizar los datos. ¿Quieres volver a intentarlo?", wslNotRunning: "WSL no se está ejecutando. Inícialo para actualizar esta tarjeta.", + cursorSecureStorageUnavailable: "El almacenamiento seguro de Cursor no está disponible.", + cursorSecureStorageUnreadable: "No se pudo leer el almacenamiento seguro de Cursor.", } export default es419 diff --git a/src/locales/plugin-errors/es-ES.ts b/src/locales/plugin-errors/es-ES.ts index 76512c11..7e4b5a7f 100644 --- a/src/locales/plugin-errors/es-ES.ts +++ b/src/locales/plugin-errors/es-ES.ts @@ -72,6 +72,8 @@ const esES: PluginErrorMessages = { probeStartFailed: "No se pudo iniciar la consulta de datos", updateFailed: "No se pudieron actualizar los datos. ¿Quieres volver a intentarlo?", wslNotRunning: "WSL no se está ejecutando. Inícialo para actualizar esta tarjeta.", + cursorSecureStorageUnavailable: "El almacenamiento seguro de Cursor no está disponible.", + cursorSecureStorageUnreadable: "No se ha podido leer el almacenamiento seguro de Cursor.", } export default esES diff --git a/src/locales/plugin-errors/fr-FR.ts b/src/locales/plugin-errors/fr-FR.ts index 9dbda80f..0c0168f7 100644 --- a/src/locales/plugin-errors/fr-FR.ts +++ b/src/locales/plugin-errors/fr-FR.ts @@ -72,6 +72,8 @@ const frFR: PluginErrorMessages = { probeStartFailed: "Impossible de démarrer la collecte des données", updateFailed: "Impossible de mettre à jour les données. Réessayer ?", wslNotRunning: "WSL n'est pas lancé. Démarrez-le pour mettre à jour cette carte.", + cursorSecureStorageUnavailable: "Le stockage sécurisé de Cursor n'est pas disponible.", + cursorSecureStorageUnreadable: "Impossible de lire le stockage sécurisé de Cursor.", } export default frFR diff --git a/src/locales/plugin-errors/it-IT.ts b/src/locales/plugin-errors/it-IT.ts index 14f1af5d..a7946111 100644 --- a/src/locales/plugin-errors/it-IT.ts +++ b/src/locales/plugin-errors/it-IT.ts @@ -72,6 +72,8 @@ const itIT: PluginErrorMessages = { probeStartFailed: "Impossibile avviare la raccolta dati", updateFailed: "Impossibile aggiornare i dati. Riprovare?", wslNotRunning: "WSL non è in esecuzione. Avvialo per aggiornare questa scheda.", + cursorSecureStorageUnavailable: "L'archivio protetto di Cursor non è disponibile.", + cursorSecureStorageUnreadable: "Impossibile leggere l'archivio protetto di Cursor.", } export default itIT diff --git a/src/locales/plugin-errors/ja-JP.ts b/src/locales/plugin-errors/ja-JP.ts index b24096f2..ac2aea7e 100644 --- a/src/locales/plugin-errors/ja-JP.ts +++ b/src/locales/plugin-errors/ja-JP.ts @@ -72,6 +72,8 @@ const jaJP: PluginErrorMessages = { probeStartFailed: "データの取得を開始できませんでした", updateFailed: "データを更新できませんでした。もう一度やり直しますか?", wslNotRunning: "WSL が実行されていません。このカードを更新するには WSL を起動してください。", + cursorSecureStorageUnavailable: "Cursor の安全なストレージは利用できません。", + cursorSecureStorageUnreadable: "Cursor の安全なストレージを読み取れませんでした。", } export default jaJP diff --git a/src/locales/plugin-errors/ko-KR.ts b/src/locales/plugin-errors/ko-KR.ts index 555c0531..da261b9b 100644 --- a/src/locales/plugin-errors/ko-KR.ts +++ b/src/locales/plugin-errors/ko-KR.ts @@ -72,6 +72,8 @@ const koKR: PluginErrorMessages = { probeStartFailed: "데이터 조회를 시작하지 못했습니다", updateFailed: "데이터를 업데이트하지 못했습니다. 다시 시도하시겠습니까?", wslNotRunning: "WSL이 실행 중이 아닙니다. 이 카드를 업데이트하려면 WSL을 시작하세요.", + cursorSecureStorageUnavailable: "Cursor 보안 저장소를 사용할 수 없습니다.", + cursorSecureStorageUnreadable: "Cursor 보안 저장소를 읽을 수 없습니다.", } export default koKR diff --git a/src/locales/plugin-errors/pt-BR.ts b/src/locales/plugin-errors/pt-BR.ts index fed5de2c..cc274494 100644 --- a/src/locales/plugin-errors/pt-BR.ts +++ b/src/locales/plugin-errors/pt-BR.ts @@ -72,6 +72,8 @@ const ptBR: PluginErrorMessages = { probeStartFailed: "Falha ao iniciar a coleta de dados", updateFailed: "Não foi possível atualizar os dados. Tentar novamente?", wslNotRunning: "O WSL não está em execução. Inicie-o para atualizar este cartão.", + cursorSecureStorageUnavailable: "O armazenamento seguro do Cursor não está disponível.", + cursorSecureStorageUnreadable: "Não foi possível ler o armazenamento seguro do Cursor.", } export default ptBR diff --git a/src/locales/plugin-errors/tr-TR.ts b/src/locales/plugin-errors/tr-TR.ts index a14d9d0f..3bce41e0 100644 --- a/src/locales/plugin-errors/tr-TR.ts +++ b/src/locales/plugin-errors/tr-TR.ts @@ -72,6 +72,8 @@ const trTR: PluginErrorMessages = { probeStartFailed: "Veri sorgusu başlatılamadı", updateFailed: "Veriler güncelleştirilemedi. Yeniden denensin mi?", wslNotRunning: "WSL çalışmıyor. Bu kartı güncellemek için WSL'yi başlatın.", + cursorSecureStorageUnavailable: "Cursor güvenli depolaması kullanılamıyor.", + cursorSecureStorageUnreadable: "Cursor güvenli depolaması okunamadı.", } export default trTR diff --git a/src/locales/plugin-errors/zh-CN.ts b/src/locales/plugin-errors/zh-CN.ts index d94c352c..8e82319c 100644 --- a/src/locales/plugin-errors/zh-CN.ts +++ b/src/locales/plugin-errors/zh-CN.ts @@ -72,6 +72,8 @@ const zhCN: PluginErrorMessages = { probeStartFailed: "无法启动数据查询", updateFailed: "无法更新数据。要重试吗?", wslNotRunning: "WSL 未运行。启动 WSL 以更新此卡片。", + cursorSecureStorageUnavailable: "Cursor 安全存储不可用。", + cursorSecureStorageUnreadable: "无法读取 Cursor 安全存储。", } export default zhCN From 7fa5cbcae8c17b3ed4bdde50bc2bfb0fc7b9f528 Mon Sep 17 00:00:00 2001 From: joaofellows Date: Tue, 10 Nov 2026 22:29:45 -0300 Subject: [PATCH 5/6] Keep the refreshed Cursor token across probes. Co-authored-by: Cursor --- plugins/cursor/plugin.js | 54 ++++++++++++++--- plugins/cursor/plugin.test.js | 106 ++++++++++++++++++++++++++++++++-- 2 files changed, 149 insertions(+), 11 deletions(-) diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 7ee7cbb2..b256d0b0 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -18,7 +18,7 @@ const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration const LOGIN_HINT = "Sign in via Cursor app or run `agent login`." const SAFE_STORAGE_PREFIX = "cursor-safe-storage:v1:" - let cachedAccessToken = null + const CACHED_ACCESS_TOKEN_FILE = "access-token.json" function readStateValue(ctx, key) { try { @@ -108,12 +108,48 @@ } } + function cachedAccessTokenPath(ctx) { + return ctx.app.pluginDataDir + "/" + CACHED_ACCESS_TOKEN_FILE + } + + function readCachedAccessToken(ctx) { + const path = cachedAccessTokenPath(ctx) + try { + if (!ctx.host.fs.exists(path)) return null + const data = ctx.util.tryParseJson(ctx.host.fs.readText(path)) + if (!data || typeof data.accessToken !== "string" || data.accessToken.length === 0) return null + return data.accessToken + } catch (e) { + ctx.host.log.warn("failed to read cached access token: " + String(e)) + return null + } + } + + function writeCachedAccessToken(ctx, accessToken) { + try { + ctx.host.fs.writeText(cachedAccessTokenPath(ctx), JSON.stringify({ accessToken: accessToken })) + } catch (e) { + ctx.host.log.warn("failed to cache access token: " + String(e)) + } + } + + function discardCachedAccessToken(ctx) { + const path = cachedAccessTokenPath(ctx) + try { + if (!ctx.host.fs.exists(path)) return + ctx.host.fs.writeText(path, "{}") + } catch (e) { + ctx.host.log.warn("failed to discard cached access token: " + String(e)) + } + } + function takeCachedAccessToken(ctx, loadedAccessToken) { + const cachedAccessToken = readCachedAccessToken(ctx) if (!cachedAccessToken) return null const cachedSubject = getTokenSubject(ctx, cachedAccessToken) const loadedSubject = getTokenSubject(ctx, loadedAccessToken) if (cachedSubject && loadedSubject && cachedSubject !== loadedSubject) { - cachedAccessToken = null + discardCachedAccessToken(ctx) return null } return cachedAccessToken @@ -187,14 +223,16 @@ } function persistAccessToken(ctx, source, accessToken, writeSqlite) { - cachedAccessToken = accessToken if (source === "keychain") { + discardCachedAccessToken(ctx) return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken) } if (!writeSqlite) { - ctx.host.log.info("refresh succeeded, token kept in memory") + writeCachedAccessToken(ctx, accessToken) + ctx.host.log.info("refresh succeeded, token kept for the next probe") return true } + discardCachedAccessToken(ctx) const wrote = writeStateValue(ctx, "cursorAuth/accessToken", accessToken) if (wrote) ctx.host.log.info("refresh succeeded, token persisted") return wrote @@ -242,7 +280,7 @@ const shouldLogout = errorInfo && errorInfo.shouldLogout === true ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout) if (shouldLogout) { - cachedAccessToken = null + discardCachedAccessToken(ctx) throw "Session expired. " + LOGIN_HINT } throw "Token expired. " + LOGIN_HINT @@ -262,7 +300,7 @@ // Check if server wants us to logout if (body.shouldLogout === true) { ctx.host.log.error("refresh response indicates shouldLogout=true") - cachedAccessToken = null + discardCachedAccessToken(ctx) throw "Session expired. " + LOGIN_HINT } @@ -614,7 +652,9 @@ ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no")) const nowMs = Date.now() - const cached = takeCachedAccessToken(ctx, accessToken) + const cached = authSource === "sqlite" && !authState.writeSqlite + ? takeCachedAccessToken(ctx, accessToken) + : null if (cached && !needsRefresh(ctx, cached, nowMs)) { accessToken = cached } diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index 8e5a3b3c..d3059d47 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -2134,7 +2134,7 @@ describe("cursor plugin", () => { expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() }) - it("reuses the in-memory access token on the next probe", async () => { + it("reuses the safe-storage access token from pluginDataDir on a fresh probe", async () => { const ctx = makeCtx() const storedAccess = "cursor-safe-storage:v1:access" const expired = makeJwt({ exp: 1, sub: "user-1" }) @@ -2163,11 +2163,109 @@ describe("cursor plugin", () => { } }) - const plugin = await loadPlugin() - plugin.probe(ctx) - plugin.probe(ctx) + ;(await loadPlugin()).probe(ctx) + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) expect(refreshCalls).toBe(1) expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + const saved = JSON.parse(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")) + expect(saved.accessToken).toBe(fresh) + }) + + it("drops the cached Cursor token when the loaded account changes", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expiredOne = makeJwt({ exp: 1, sub: "user-1" }) + const freshOne = makeJwt({ exp: 9999999999, sub: "user-1" }) + const expiredTwo = makeJwt({ exp: 1, sub: "user-2" }) + const freshTwo = makeJwt({ exp: 9999999999, sub: "user-2" }) + let account = "user-1" + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: storedAccess }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = { + decrypt: vi.fn((raw) => { + if (String(raw).endsWith("access")) return account === "user-1" ? expiredOne : expiredTwo + return "refresh-plain" + }), + } + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + const access_token = account === "user-1" ? freshOne : freshTwo + return { status: 200, bodyText: JSON.stringify({ access_token }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + account = "user-2" + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(2) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + const saved = JSON.parse(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")) + expect(saved.accessToken).toBe(freshTwo) + }) + + it("forgets the cached Cursor token when refresh demands logout", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let usageCalls = 0 + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) { + return JSON.stringify([{ value: storedAccess }]) + } + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = { + decrypt: vi.fn((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")), + } + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("/oauth/token")) { + refreshCalls += 1 + if (refreshCalls > 1) return { status: 200, bodyText: JSON.stringify({ shouldLogout: true }) } + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + if (url.includes("GetCurrentPeriodUsage")) { + usageCalls += 1 + if (usageCalls === 1) { + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + } + return { status: 401, bodyText: "" } + } + return { status: 200, bodyText: "{}" } + }) + + ;(await loadPlugin()).probe(ctx) + vi.resetModules() + delete globalThis.__openusage_plugin + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired") + expect(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")).toBe("{}") }) }) From ba158fd9a51927c203a7e29ccc1d41380891d78f Mon Sep 17 00:00:00 2001 From: joaofellows Date: Thu, 12 Nov 2026 00:11:57 -0300 Subject: [PATCH 6/6] Protect the refreshed Cursor token and keep it out of the database. Co-authored-by: Cursor --- plugins/cursor/plugin.js | 80 ++++-- plugins/cursor/plugin.test.js | 238 ++++++++++++++++-- src-tauri/Cargo.toml | 2 +- .../src/plugin_engine/cursor_safe_storage.rs | 129 ++++++---- src-tauri/src/plugin_engine/host_api.rs | 37 +++ 5 files changed, 401 insertions(+), 85 deletions(-) diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index b256d0b0..e5459583 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -112,22 +112,60 @@ return ctx.app.pluginDataDir + "/" + CACHED_ACCESS_TOKEN_FILE } - function readCachedAccessToken(ctx) { + function refreshFingerprint(ctx, refreshTokenValue) { + if (typeof refreshTokenValue !== "string" || refreshTokenValue.length === 0) return null + if (!ctx.host.crypto || typeof ctx.host.crypto.sha256Hex !== "function") return null + return ctx.host.crypto.sha256Hex(refreshTokenValue) + } + + function readCacheRecord(ctx) { const path = cachedAccessTokenPath(ctx) try { if (!ctx.host.fs.exists(path)) return null const data = ctx.util.tryParseJson(ctx.host.fs.readText(path)) - if (!data || typeof data.accessToken !== "string" || data.accessToken.length === 0) return null - return data.accessToken + if (!data || typeof data.protected !== "string" || data.protected.length === 0) { + if (data && typeof data.accessToken === "string" && data.accessToken.length > 0) { + discardCachedAccessToken(ctx) + } + return null + } + if (!ctx.host.cursorAuth || typeof ctx.host.cursorAuth.unprotect !== "function") return null + const token = ctx.host.cursorAuth.unprotect(data.protected) + if (typeof token !== "string" || token.length === 0) { + discardCachedAccessToken(ctx) + return null + } + return { + token: token, + refreshFingerprint: typeof data.refreshFingerprint === "string" ? data.refreshFingerprint : "", + } } catch (e) { ctx.host.log.warn("failed to read cached access token: " + String(e)) + discardCachedAccessToken(ctx) return null } } - function writeCachedAccessToken(ctx, accessToken) { + function writeCacheRecord(ctx, accessToken, refreshTokenValue) { + const fingerprint = refreshFingerprint(ctx, refreshTokenValue) + if (!fingerprint) { + ctx.host.log.warn("cannot fingerprint the refresh token; not caching the access token") + return + } + if (!ctx.host.cursorAuth || typeof ctx.host.cursorAuth.protect !== "function") { + ctx.host.log.warn("cannot protect the access token; not caching it") + return + } try { - ctx.host.fs.writeText(cachedAccessTokenPath(ctx), JSON.stringify({ accessToken: accessToken })) + const protectedToken = ctx.host.cursorAuth.protect(accessToken) + if (typeof protectedToken !== "string" || protectedToken.length === 0 || protectedToken === accessToken) { + ctx.host.log.warn("cannot protect the access token; not caching it") + return + } + ctx.host.fs.writeText(cachedAccessTokenPath(ctx), JSON.stringify({ + protected: protectedToken, + refreshFingerprint: fingerprint, + })) } catch (e) { ctx.host.log.warn("failed to cache access token: " + String(e)) } @@ -143,16 +181,17 @@ } } - function takeCachedAccessToken(ctx, loadedAccessToken) { - const cachedAccessToken = readCachedAccessToken(ctx) - if (!cachedAccessToken) return null - const cachedSubject = getTokenSubject(ctx, cachedAccessToken) + function takeCachedAccessToken(ctx, loadedAccessToken, refreshTokenValue) { + const record = readCacheRecord(ctx) + if (!record) return null + const expected = refreshFingerprint(ctx, refreshTokenValue) + const cachedSubject = getTokenSubject(ctx, record.token) const loadedSubject = getTokenSubject(ctx, loadedAccessToken) - if (cachedSubject && loadedSubject && cachedSubject !== loadedSubject) { + if (!expected || record.refreshFingerprint !== expected || !cachedSubject || !loadedSubject || cachedSubject !== loadedSubject) { discardCachedAccessToken(ctx) return null } - return cachedAccessToken + return record.token } function loadAuthState(ctx) { @@ -160,7 +199,7 @@ const sqliteRefreshRaw = readStateValue(ctx, "cursorAuth/refreshToken") const sqliteAccessToken = decodeStoredToken(ctx, sqliteAccessRaw) const sqliteRefreshToken = decodeStoredToken(ctx, sqliteRefreshRaw) - const writeSqlite = !isSafeStorageValue(sqliteAccessRaw) + const writeSqlite = !isSafeStorageValue(sqliteAccessRaw) && !isSafeStorageValue(sqliteRefreshRaw) const sqliteMembershipTypeRaw = readStateValue(ctx, "cursorAuth/stripeMembershipType") const sqliteMembershipType = typeof sqliteMembershipTypeRaw === "string" ? sqliteMembershipTypeRaw.trim().toLowerCase() @@ -222,13 +261,13 @@ return subject || null } - function persistAccessToken(ctx, source, accessToken, writeSqlite) { + function persistAccessToken(ctx, source, accessToken, writeSqlite, refreshTokenValue) { if (source === "keychain") { discardCachedAccessToken(ctx) return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken) } if (!writeSqlite) { - writeCachedAccessToken(ctx, accessToken) + writeCacheRecord(ctx, accessToken, refreshTokenValue) ctx.host.log.info("refresh succeeded, token kept for the next probe") return true } @@ -311,7 +350,7 @@ } // Persist updated access token to source where auth was loaded from. - persistAccessToken(ctx, source, newAccessToken, writeSqlite) + persistAccessToken(ctx, source, newAccessToken, writeSqlite, refreshTokenValue) // Note: Cursor refresh returns access_token which is used as both // access and refresh token in some flows @@ -645,6 +684,7 @@ const authSource = authState.source if (!accessToken && !refreshTokenValue) { + discardCachedAccessToken(ctx) ctx.host.log.error("probe failed: no access or refresh token in sqlite/keychain") throw "Not logged in. " + LOGIN_HINT } @@ -652,8 +692,8 @@ ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no")) const nowMs = Date.now() - const cached = authSource === "sqlite" && !authState.writeSqlite - ? takeCachedAccessToken(ctx, accessToken) + const cached = authSource === "sqlite" && !authState.writeSqlite && needsRefresh(ctx, accessToken, nowMs) + ? takeCachedAccessToken(ctx, accessToken, refreshTokenValue) : null if (cached && !needsRefresh(ctx, cached, nowMs)) { accessToken = cached @@ -696,6 +736,12 @@ refresh: () => { ctx.host.log.info("usage returned 401, attempting refresh") didRefresh = true + const failed = accessToken + discardCachedAccessToken(ctx) + if (authState.accessToken && authState.accessToken !== failed && !needsRefresh(ctx, authState.accessToken, Date.now())) { + accessToken = authState.accessToken + return authState.accessToken + } const refreshed = refreshToken(ctx, refreshTokenValue, authSource, authState.writeSqlite) if (refreshed) accessToken = refreshed return refreshed diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index d3059d47..c5a08f53 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -13,6 +13,18 @@ function makeJwt(payload) { return `a.${jwtPayload}.c` } +function cursorAuthMock(decrypt) { + return { + decrypt: vi.fn(decrypt), + protect: (plain) => "protected:" + plain, + unprotect: (stored) => { + const text = String(stored) + if (text.indexOf("protected:") !== 0) throw new Error("bad cache") + return text.slice("protected:".length) + }, + } +} + describe("cursor plugin", () => { beforeEach(() => { delete globalThis.__openusage_plugin @@ -2106,13 +2118,11 @@ describe("cursor plugin", () => { } return JSON.stringify([]) }) - ctx.host.cursorAuth = { - decrypt: vi.fn((raw) => { - if (raw === storedAccess) return expired - if (raw === storedRefresh) return "refresh-plain" - throw new Error("unexpected decrypt") - }), - } + ctx.host.cursorAuth = cursorAuthMock((raw) => { + if (raw === storedAccess) return expired + if (raw === storedRefresh) return "refresh-plain" + throw new Error("unexpected decrypt") + }) ctx.host.http.request.mockImplementation((opts) => { if (String(opts.url).includes("/oauth/token")) { const body = JSON.parse(opts.bodyText) @@ -2149,9 +2159,7 @@ describe("cursor plugin", () => { } return JSON.stringify([]) }) - ctx.host.cursorAuth = { - decrypt: vi.fn((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")), - } + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")) ctx.host.http.request.mockImplementation((opts) => { if (String(opts.url).includes("/oauth/token")) { refreshCalls += 1 @@ -2171,7 +2179,9 @@ describe("cursor plugin", () => { expect(refreshCalls).toBe(1) expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() const saved = JSON.parse(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")) - expect(saved.accessToken).toBe(fresh) + expect(saved.accessToken).toBeUndefined() + expect(ctx.host.cursorAuth.unprotect(saved.protected)).toBe(fresh) + expect(saved.refreshFingerprint).toBe(ctx.host.crypto.sha256Hex("refresh-plain")) }) it("drops the cached Cursor token when the loaded account changes", async () => { @@ -2192,12 +2202,10 @@ describe("cursor plugin", () => { } return JSON.stringify([]) }) - ctx.host.cursorAuth = { - decrypt: vi.fn((raw) => { - if (String(raw).endsWith("access")) return account === "user-1" ? expiredOne : expiredTwo - return "refresh-plain" - }), - } + ctx.host.cursorAuth = cursorAuthMock((raw) => { + if (String(raw).endsWith("access")) return account === "user-1" ? expiredOne : expiredTwo + return "refresh-plain" + }) ctx.host.http.request.mockImplementation((opts) => { if (String(opts.url).includes("/oauth/token")) { refreshCalls += 1 @@ -2219,7 +2227,7 @@ describe("cursor plugin", () => { expect(refreshCalls).toBe(2) expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() const saved = JSON.parse(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")) - expect(saved.accessToken).toBe(freshTwo) + expect(ctx.host.cursorAuth.unprotect(saved.protected)).toBe(freshTwo) }) it("forgets the cached Cursor token when refresh demands logout", async () => { @@ -2238,9 +2246,7 @@ describe("cursor plugin", () => { } return JSON.stringify([]) }) - ctx.host.cursorAuth = { - decrypt: vi.fn((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")), - } + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")) ctx.host.http.request.mockImplementation((opts) => { const url = String(opts.url) if (url.includes("/oauth/token")) { @@ -2268,4 +2274,194 @@ describe("cursor plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Session expired") expect(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")).toBe("{}") }) + + it("does not write sqlite when only the refresh token is safe-storage", async () => { + const ctx = makeCtx() + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("refresh") ? "refresh-plain" : raw)) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + const plugin = await loadPlugin() + plugin.probe(ctx) + + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) + + it("drops the cached token when no login is found", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(ctx.app.pluginDataDir + "/access-token.json", JSON.stringify({ + protected: "protected:old", + refreshFingerprint: "abc", + })) + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([])) + ctx.host.cursorAuth = cursorAuthMock(() => null) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Not logged in") + expect(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")).toBe("{}") + }) + + it("refreshes again when the loaded token has no subject", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const noSubject = makeJwt({ exp: 1 }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let loaded = expired + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([{ value: storedAccess }]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? loaded : "refresh-plain")) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + loaded = noSubject + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(2) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) + + it("refreshes again when the refresh token changes", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let refreshPlain = "refresh-plain" + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([{ value: storedAccess }]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? expired : refreshPlain)) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + refreshPlain = "refresh-other" + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(2) + expect(ctx.host.sqlite.exec).not.toHaveBeenCalled() + }) + + it("keeps a fresh database token ahead of the cache", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const cachedFresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + const databaseFresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let loaded = expired + let refreshCalls = 0 + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([{ value: storedAccess }]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? loaded : "refresh-plain")) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("/oauth/token")) { + refreshCalls += 1 + return { status: 200, bodyText: JSON.stringify({ access_token: cachedFresh }) } + } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + loaded = databaseFresh + vi.resetModules() + delete globalThis.__openusage_plugin + ;(await loadPlugin()).probe(ctx) + + expect(refreshCalls).toBe(1) + const usageAuth = ctx.host.http.request.mock.calls + .filter((call) => String(call[0].url).includes("GetCurrentPeriodUsage")) + .map((call) => call[0].headers.Authorization) + expect(usageAuth[usageAuth.length - 1]).toBe("Bearer " + databaseFresh) + }) + + it("drops the cached token when usage returns 401 and refresh fails", async () => { + const ctx = makeCtx() + const storedAccess = "cursor-safe-storage:v1:access" + const expired = makeJwt({ exp: 1, sub: "user-1" }) + const fresh = makeJwt({ exp: 9999999999, sub: "user-1" }) + let phase = "first" + ctx.host.sqlite.query.mockImplementation((db, sql) => { + if (String(sql).includes("cursorAuth/accessToken")) return JSON.stringify([{ value: storedAccess }]) + if (String(sql).includes("cursorAuth/refreshToken")) { + return JSON.stringify([{ value: "cursor-safe-storage:v1:refresh" }]) + } + return JSON.stringify([]) + }) + ctx.host.cursorAuth = cursorAuthMock((raw) => (String(raw).endsWith("access") ? expired : "refresh-plain")) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("/oauth/token")) { + if (phase === "second") return { status: 500, bodyText: "" } + return { status: 200, bodyText: JSON.stringify({ access_token: fresh }) } + } + if (url.includes("GetCurrentPeriodUsage") && phase === "second") return { status: 401, bodyText: "" } + return { + status: 200, + bodyText: JSON.stringify({ enabled: true, planUsage: { totalSpend: 0, limit: 100 } }), + } + }) + + ;(await loadPlugin()).probe(ctx) + phase = "second" + vi.resetModules() + delete globalThis.__openusage_plugin + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Token expired") + expect(ctx.host.fs.readText(ctx.app.pluginDataDir + "/access-token.json")).toBe("{}") + }) }) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c281bf8a..47270a44 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -52,7 +52,7 @@ winreg = "0.55" # Already pulled in by tauri; listed so we can read the applied backdrop result. window-vibrancy = "0.6" windows-version = "0.1" -windows-sys = { version = "0.59", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media_Audio", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Threading", "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.59", features = ["Win32_Devices_Display", "Win32_Foundation", "Win32_Graphics_Dwm", "Win32_Graphics_Gdi", "Win32_Media_Audio", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_System_LibraryLoader", "Win32_System_Registry", "Win32_System_RemoteDesktop", "Win32_System_Threading", "Win32_UI_HiDpi", "Win32_UI_WindowsAndMessaging"] } # Already pulled in by the notification plugin; used to read Do Not Disturb. windows = { version = "0.62", features = ["UI_Notifications", "Win32_System_Com", "Win32_UI_Accessibility"] } diff --git a/src-tauri/src/plugin_engine/cursor_safe_storage.rs b/src-tauri/src/plugin_engine/cursor_safe_storage.rs index 8f6153e1..8844fe43 100644 --- a/src-tauri/src/plugin_engine/cursor_safe_storage.rs +++ b/src-tauri/src/plugin_engine/cursor_safe_storage.rs @@ -59,14 +59,14 @@ pub fn read_wrapped_key(local_state_json: &str) -> Result, String> { } #[cfg(windows)] -pub fn unwrap_dpapi(payload: &[u8]) -> Result, String> { +fn dpapi_call(protect: bool, payload: &[u8], failure: &str) -> Result, String> { use std::ptr; use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; - use windows_sys::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB}; + use windows_sys::Win32::Security::Cryptography::{CryptProtectData, CryptUnprotectData, CRYPT_INTEGER_BLOB}; const UI_FORBIDDEN: u32 = 0x1; let input = CRYPT_INTEGER_BLOB { - cbData: u32::try_from(payload.len()).map_err(|_| "Windows DPAPI could not unwrap the key".to_string())?, + cbData: u32::try_from(payload.len()).map_err(|_| failure.to_string())?, pbData: payload.as_ptr() as *mut u8, }; let mut output = CRYPT_INTEGER_BLOB { @@ -74,29 +74,94 @@ pub fn unwrap_dpapi(payload: &[u8]) -> Result, String> { pbData: ptr::null_mut(), }; let ok = unsafe { - CryptUnprotectData( - &input, - ptr::null_mut(), - ptr::null(), - ptr::null_mut(), - ptr::null(), - UI_FORBIDDEN, - &mut output, - ) + if protect { + CryptProtectData( + &input, + ptr::null(), + ptr::null(), + ptr::null_mut(), + ptr::null(), + UI_FORBIDDEN, + &mut output, + ) + } else { + CryptUnprotectData( + &input, + ptr::null_mut(), + ptr::null(), + ptr::null_mut(), + ptr::null(), + UI_FORBIDDEN, + &mut output, + ) + } }; if ok == 0 || output.pbData.is_null() { - return Err("Windows DPAPI could not unwrap the key".to_string()); + return Err(failure.to_string()); } - let key = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; + let bytes = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; unsafe { LocalFree(output.pbData as HLOCAL); } + Ok(bytes) +} + +#[cfg(windows)] +fn dpapi_protect(plain: &[u8]) -> Result, String> { + dpapi_call(true, plain, "Windows DPAPI could not protect the token") +} + +#[cfg(windows)] +fn dpapi_unprotect(payload: &[u8]) -> Result, String> { + dpapi_call(false, payload, "Windows DPAPI could not unwrap the key") +} + +#[cfg(windows)] +pub fn unwrap_dpapi(payload: &[u8]) -> Result, String> { + let key = dpapi_unprotect(payload)?; if key.len() != 32 { return Err("Windows DPAPI returned an unexpected key".to_string()); } Ok(key) } +pub fn protect_for_cache(plain: &str) -> Result { + if plain.is_empty() { + return Err("nothing to protect".to_string()); + } + #[cfg(windows)] + { + let protected = dpapi_protect(plain.as_bytes())?; + return Ok(BASE64_STANDARD.encode(protected)); + } + #[cfg(not(windows))] + { + Err("not supported on this platform".to_string()) + } +} + +pub fn unprotect_for_cache(stored: &str) -> Result { + if stored.is_empty() { + return Err("cached token could not be read".to_string()); + } + #[cfg(windows)] + { + let blob = BASE64_STANDARD + .decode(stored.trim()) + .map_err(|_| "cached token could not be read".to_string())?; + let plain = dpapi_unprotect(&blob).map_err(|_| "cached token could not be read".to_string())?; + let text = String::from_utf8(plain).map_err(|_| "cached token could not be read".to_string())?; + if text.is_empty() { + return Err("cached token could not be read".to_string()); + } + return Ok(text); + } + #[cfg(not(windows))] + { + Err("not supported on this platform".to_string()) + } +} + pub fn decrypt_cursor_value(raw: &str) -> Result { if !raw.starts_with(SAFE_STORAGE_PREFIX) { return Ok(raw.to_string()); @@ -139,37 +204,6 @@ mod tests { blob } - #[cfg(all(test, windows))] - fn protect_dpapi(plain: &[u8]) -> Vec { - use std::ptr; - use windows_sys::Win32::Foundation::{LocalFree, HLOCAL}; - use windows_sys::Win32::Security::Cryptography::{CryptProtectData, CRYPT_INTEGER_BLOB}; - - let input = CRYPT_INTEGER_BLOB { - cbData: plain.len() as u32, - pbData: plain.as_ptr() as *mut u8, - }; - let mut output = CRYPT_INTEGER_BLOB { - cbData: 0, - pbData: ptr::null_mut(), - }; - let ok = unsafe { - CryptProtectData( - &input, - ptr::null(), - ptr::null(), - ptr::null_mut(), - ptr::null(), - 0x1, - &mut output, - ) - }; - assert_eq!(ok, 1, "CryptProtectData"); - let protected = unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() }; - unsafe { LocalFree(output.pbData as HLOCAL); } - protected - } - #[test] fn decrypts_v10_and_passes_plaintext_through() { let key = [7_u8; 32]; @@ -256,8 +290,11 @@ mod tests { #[test] fn dpapi_round_trip_returns_32_bytes() { let key = [3_u8; 32]; - let protected = protect_dpapi(&key); + let protected = dpapi_protect(&key).expect("protect"); let opened = unwrap_dpapi(&protected).expect("unwrap"); assert_eq!(opened, key); + let cache = protect_for_cache("plain-token").expect("cache protect"); + assert_ne!(cache, "plain-token"); + assert_eq!(unprotect_for_cache(&cache).expect("cache unprotect"), "plain-token"); } } diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 8d98dfc1..c983a20f 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -734,6 +734,20 @@ fn inject_cursor_auth<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Resu .map_err(|message| Exception::throw_message(&ctx_inner, &message)) })?, )?; + cursor_auth.set( + "protect", + Function::new(ctx.clone(), |ctx_inner: Ctx<'_>, plain: String| -> rquickjs::Result { + super::cursor_safe_storage::protect_for_cache(&plain) + .map_err(|message| Exception::throw_message(&ctx_inner, &message)) + })?, + )?; + cursor_auth.set( + "unprotect", + Function::new(ctx.clone(), |ctx_inner: Ctx<'_>, stored: String| -> rquickjs::Result { + super::cursor_safe_storage::unprotect_for_cache(&stored) + .map_err(|message| Exception::throw_message(&ctx_inner, &message)) + })?, + )?; host.set("cursorAuth", cursor_auth)?; Ok(()) } @@ -5734,6 +5748,19 @@ wait .eval(r#"__openusage_ctx.host.cursorAuth.decrypt("plain-token")"#) .expect("plaintext decrypt"); assert_eq!(plain, "plain-token"); + #[cfg(windows)] + { + let protected: String = ctx + .eval(r#"__openusage_ctx.host.cursorAuth.protect("plain-token")"#) + .expect("protect"); + assert_ne!(protected, "plain-token"); + let opened: String = ctx + .eval(format!( + r#"__openusage_ctx.host.cursorAuth.unprotect("{protected}")"# + )) + .expect("unprotect"); + assert_eq!(opened, "plain-token"); + } }); let ctx = Context::full(&rt).expect("context"); @@ -5745,5 +5772,15 @@ wait ); assert!(missing.is_err(), "claude must not receive cursorAuth"); }); + + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "cursor-x", &app_data, "0.0.0").expect("inject"); + let missing: rquickjs::Result = ctx.eval( + r#"__openusage_ctx.host.cursorAuth.decrypt("plain-token")"#, + ); + assert!(missing.is_err(), "cursor-x must not receive cursorAuth"); + }); } }