diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7a6f3e6..fd3b274 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -506,7 +506,7 @@ dependencies = [ "bitflags 2.11.0", "block", "cocoa-foundation", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "foreign-types", "libc", @@ -521,7 +521,7 @@ checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ "bitflags 2.11.0", "block", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "objc", ] @@ -587,6 +587,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -610,7 +620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -623,7 +633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -2178,6 +2188,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2293,6 +2318,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3815,7 +3850,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3833,7 +3868,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3842,7 +3877,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3952,6 +3987,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3959,7 +4007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4636,6 +4684,7 @@ dependencies = [ "chrono", "cocoa", "dirs", + "keyring", "log", "objc", "rusqlite", @@ -4742,7 +4791,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c78392..ab84f39 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ tokio = { version = "1", features = ["process", "sync", "time", "fs"] } log = "0.4" walkdir = "2" rusqlite = { version = "0.31", features = ["bundled"] } +keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.26" diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index 79d11ab..ae45a3a 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -1,7 +1,39 @@ // We use the SQL plugin from the frontend for most DB operations, // but this command provides UUID generation for auth records. +use keyring::Entry; + +const KEYCHAIN_SERVICE: &str = "app.sustn.desktop"; +const KEYCHAIN_USER: &str = "github_access_token"; + +fn get_entry() -> Result { + Entry::new(KEYCHAIN_SERVICE, KEYCHAIN_USER).map_err(|e| format!("keychain init error: {e}")) +} + #[tauri::command] pub fn generate_auth_id() -> String { uuid::Uuid::new_v4().to_string() } + +#[tauri::command] +pub fn keychain_set_token(token: String) -> Result<(), String> { + get_entry()?.set_password(&token).map_err(|e| format!("keychain write error: {e}")) +} + +#[tauri::command] +pub fn keychain_get_token() -> Result, String> { + match get_entry()?.get_password() { + Ok(password) => Ok(Some(password)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keychain read error: {e}")), + } +} + +#[tauri::command] +pub fn keychain_delete_token() -> Result<(), String> { + match get_entry()?.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(format!("keychain delete error: {e}")), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2afac0d..9ceb759 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,6 +73,9 @@ pub fn run() { command::greet, command::open_in_app, auth::generate_auth_id, + auth::keychain_set_token, + auth::keychain_get_token, + auth::keychain_delete_token, preflight::check_git_installed, preflight::check_claude_installed, preflight::check_claude_authenticated, diff --git a/src/core/db/auth.ts b/src/core/db/auth.ts index 126d25f..0d34fbb 100644 --- a/src/core/db/auth.ts +++ b/src/core/db/auth.ts @@ -26,24 +26,37 @@ async function getDb() { return await Database.load(config.dbUrl); } -function rowToRecord(row: AuthRow): AuthRecord { +export async function getAuth(): Promise { + const db = await getDb(); + const rows = await db.select("SELECT * FROM auth LIMIT 1"); + if (rows.length === 0) return undefined; + + const row = rows[0]; + + // Migrate plaintext token from SQLite to OS keychain on first read + if (row.github_access_token) { + await invoke("keychain_set_token", { + token: row.github_access_token, + }); + await db.execute( + "UPDATE auth SET github_access_token = '' WHERE id = $1", + [row.id], + ); + } + + const token = await invoke("keychain_get_token"); + if (!token) return undefined; + return { id: row.id, githubId: row.github_id, username: row.github_username, avatarUrl: row.github_avatar_url ?? undefined, email: row.github_email ?? undefined, - accessToken: row.github_access_token, + accessToken: token, }; } -export async function getAuth(): Promise { - const db = await getDb(); - const rows = await db.select("SELECT * FROM auth LIMIT 1"); - if (rows.length === 0) return undefined; - return rowToRecord(rows[0]); -} - export async function saveAuth(params: { githubId: number; username: string; @@ -57,6 +70,9 @@ export async function saveAuth(params: { // Delete any existing auth record (single-user app) await db.execute("DELETE FROM auth"); + // Store token in OS keychain, not in SQLite + await invoke("keychain_set_token", { token: params.accessToken }); + await db.execute( `INSERT INTO auth (id, github_id, github_username, github_avatar_url, github_email, github_access_token) VALUES ($1, $2, $3, $4, $5, $6)`, @@ -66,7 +82,7 @@ export async function saveAuth(params: { params.username, params.avatarUrl ?? null, params.email ?? null, - params.accessToken, + "", // empty — token is in OS keychain ], ); } @@ -74,4 +90,5 @@ export async function saveAuth(params: { export async function clearAuth(): Promise { const db = await getDb(); await db.execute("DELETE FROM auth"); + await invoke("keychain_delete_token"); }