From 63a49901020c6f70eda65c0e0e24544b75f03480 Mon Sep 17 00:00:00 2001 From: Ghvst Date: Sat, 28 Mar 2026 19:14:18 +0100 Subject: [PATCH 1/3] feat(tauri,core): move GitHub access token from SQLite to OS credential store The GitHub access token was stored in plaintext in the local SQLite database, which is unencrypted and sits in a predictable app data directory. Anyone with filesystem access could read the token, which grants full repo read/write access to all connected repositories. This commit migrates token storage to the OS credential manager (macOS Keychain) via the `keyring` crate and exposes get/set/clear commands over Tauri IPC. The frontend auth module now persists everything except the token in SQLite and delegates token storage to the native credential store. - Add `keyring` v3 dependency to Cargo.toml - Add set_github_token, get_github_token, clear_github_token Tauri commands in auth.rs using keyring::Entry - Add migrate_token_to_keyring() pre-migration that moves any existing token from SQLite to keyring before the column is dropped - Add SQL migration 13 to drop github_access_token column from auth - Update frontend auth.ts: saveAuth stores token via IPC, getAuth retrieves it from keyring, clearAuth removes it from both stores - Register new commands in lib.rs invoke_handler SUSTN-Task: 21c49249-78ac-49b0-81fe-f1e573f12520 --- src-tauri/Cargo.lock | 11 +++++ src-tauri/Cargo.toml | 1 + src-tauri/src/auth.rs | 93 ++++++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 10 ++++ src-tauri/src/migrations.rs | 8 ++++ src/core/db/auth.ts | 20 +++++--- 6 files changed, 135 insertions(+), 8 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7a6f3e6..cff665f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2178,6 +2178,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -4636,6 +4646,7 @@ dependencies = [ "chrono", "cocoa", "dirs", + "keyring", "log", "objc", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c78392..3b7ad42 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 = "3" [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..fd1ecb8 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -1,7 +1,98 @@ // We use the SQL plugin from the frontend for most DB operations, -// but this command provides UUID generation for auth records. +// but this module also provides keyring-backed credential storage +// for the GitHub access token and UUID generation for auth records. + +use std::path::Path; + +use keyring::Entry; +use rusqlite::Connection; + +const KEYRING_SERVICE: &str = "dev.sustn.app"; +const KEYRING_USER: &str = "github_access_token"; + +fn token_entry() -> Result { + Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|e| format!("keyring error: {e}")) +} + +/// Migrate an existing GitHub access token from the SQLite `auth` table +/// into the OS credential store. Must run **before** migration 13 drops +/// the `github_access_token` column. +/// +/// This is intentionally lenient: if the DB doesn't exist, the column is +/// already gone, or the table is empty, it simply returns Ok(()). +pub fn migrate_token_to_keyring(db_path: &Path) { + if !db_path.exists() { + return; + } + + let conn = match Connection::open(db_path) { + Ok(c) => c, + Err(e) => { + eprintln!("[auth] migrate_token_to_keyring — failed to open DB: {e}"); + return; + } + }; + + // Check whether the column still exists (idempotent). + let has_column: bool = conn + .prepare("SELECT github_access_token FROM auth LIMIT 0") + .is_ok(); + + if !has_column { + return; // Column already dropped — nothing to migrate. + } + + let token: Option = conn + .query_row( + "SELECT github_access_token FROM auth LIMIT 1", + [], + |row| row.get(0), + ) + .ok(); + + if let Some(ref token) = token { + if !token.is_empty() { + match token_entry().and_then(|e| { + e.set_password(token) + .map_err(|e| format!("failed to store token: {e}")) + }) { + Ok(()) => { + println!("[auth] migrated GitHub token to OS credential store"); + } + Err(e) => { + eprintln!("[auth] migrate_token_to_keyring — keyring error: {e}"); + // Don't proceed — keep the token in the DB so we can retry. + return; + } + } + } + } +} #[tauri::command] pub fn generate_auth_id() -> String { uuid::Uuid::new_v4().to_string() } + +#[tauri::command] +pub fn set_github_token(token: String) -> Result<(), String> { + token_entry()?.set_password(&token).map_err(|e| format!("failed to store token: {e}")) +} + +#[tauri::command] +pub fn get_github_token() -> Result, String> { + match token_entry()?.get_password() { + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("failed to retrieve token: {e}")), + } +} + +#[tauri::command] +pub fn clear_github_token() -> Result<(), String> { + match token_entry()?.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), // already gone — not an error + Err(e) => Err(format!("failed to clear token: {e}")), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2afac0d..5b227d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,6 +19,13 @@ const DB_URL: &str = "sqlite:sustn.db"; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Migrate any existing GitHub token from SQLite → OS credential store + // before the SQL plugin applies migration 13 (which drops the column). + if let Some(data_dir) = dirs::data_dir() { + let db_path = data_dir.join("app.sustn.desktop").join("sustn.db"); + auth::migrate_token_to_keyring(&db_path); + } + let migrations = migrations::migrations(); let engine_state = engine::EngineState::new(); @@ -73,6 +80,9 @@ pub fn run() { command::greet, command::open_in_app, auth::generate_auth_id, + auth::set_github_token, + auth::get_github_token, + auth::clear_github_token, preflight::check_git_installed, preflight::check_claude_installed, preflight::check_claude_authenticated, diff --git a/src-tauri/src/migrations.rs b/src-tauri/src/migrations.rs index 318dd1d..b949162 100644 --- a/src-tauri/src/migrations.rs +++ b/src-tauri/src/migrations.rs @@ -251,5 +251,13 @@ pub fn migrations() -> Vec { "#, kind: MigrationKind::Up, }, + Migration { + version: 13, + description: "move github_access_token to OS credential store", + sql: r#" + ALTER TABLE auth DROP COLUMN github_access_token; + "#, + kind: MigrationKind::Up, + }, ] } diff --git a/src/core/db/auth.ts b/src/core/db/auth.ts index 126d25f..4fd2b7f 100644 --- a/src/core/db/auth.ts +++ b/src/core/db/auth.ts @@ -8,7 +8,6 @@ interface AuthRow { github_username: string; github_avatar_url: string | null; github_email: string | null; - github_access_token: string; created_at: string; updated_at: string; } @@ -26,14 +25,14 @@ async function getDb() { return await Database.load(config.dbUrl); } -function rowToRecord(row: AuthRow): AuthRecord { +function rowToRecord(row: AuthRow, accessToken: string): AuthRecord { 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, }; } @@ -41,7 +40,11 @@ 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]); + + const token = await invoke("get_github_token"); + if (!token) return undefined; + + return rowToRecord(rows[0], token); } export async function saveAuth(params: { @@ -54,19 +57,21 @@ export async function saveAuth(params: { const db = await getDb(); const id = await invoke("generate_auth_id"); + // Store the token in the OS credential store + await invoke("set_github_token", { token: params.accessToken }); + // Delete any existing auth record (single-user app) await db.execute("DELETE FROM auth"); 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)`, + `INSERT INTO auth (id, github_id, github_username, github_avatar_url, github_email) + VALUES ($1, $2, $3, $4, $5)`, [ id, params.githubId, params.username, params.avatarUrl ?? null, params.email ?? null, - params.accessToken, ], ); } @@ -74,4 +79,5 @@ export async function saveAuth(params: { export async function clearAuth(): Promise { const db = await getDb(); await db.execute("DELETE FROM auth"); + await invoke("clear_github_token"); } From 57347497d94fcd99a57602829ad46ac77bcc045a Mon Sep 17 00:00:00 2001 From: Ghvst Date: Sat, 28 Mar 2026 19:18:08 +0100 Subject: [PATCH 2/3] fix(tauri): enable keyring platform features for real credential storage The keyring v3 crate was added without platform-specific features (apple-native, windows-native, linux-native), causing it to fall back to an in-memory mock credential store on all platforms. This meant tokens stored via keyring were lost on every app restart. Combined with migration 13 (which drops the github_access_token column from SQLite), this caused permanent token loss: the pre-migration code copied the token to the mock store, the migration deleted the SQLite column, and on next launch the mock store was empty with no way to recover the token. Enabling the platform features activates the real OS credential backends: - macOS: Keychain via security-framework - Windows: Credential Manager via windows-sys - Linux: kernel keyutils SUSTN-Task: 21c49249-78ac-49b0-81fe-f1e573f12520 --- src-tauri/Cargo.lock | 56 +++++++++++++++++++++++++++++++++++++------- src-tauri/Cargo.toml | 2 +- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cff665f..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", ] @@ -2184,7 +2194,12 @@ 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", ] @@ -2303,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" @@ -3825,7 +3850,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -3843,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", @@ -3852,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", @@ -3962,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" @@ -3969,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", @@ -4753,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 3b7ad42..ab84f39 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,7 +35,7 @@ tokio = { version = "1", features = ["process", "sync", "time", "fs"] } log = "0.4" walkdir = "2" rusqlite = { version = "0.31", features = ["bundled"] } -keyring = "3" +keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.26" From 5e0922080ec54c909f12741ae87e6e7afce3617a Mon Sep 17 00:00:00 2001 From: Ghvst Date: Sun, 29 Mar 2026 08:01:56 +0100 Subject: [PATCH 3/3] docs(tauri): clarify migration 13 reference in auth.rs doc comment Replace the vague "migration 13" reference with an explicit description of what the migration does (DROP COLUMN github_access_token), so readers don't have to cross-reference migrations.rs to understand the ordering constraint. SUSTN-Task: 21c49249-78ac-49b0-81fe-f1e573f12520 --- src-tauri/src/auth.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs index fd1ecb8..65dadd3 100644 --- a/src-tauri/src/auth.rs +++ b/src-tauri/src/auth.rs @@ -15,8 +15,9 @@ fn token_entry() -> Result { } /// Migrate an existing GitHub access token from the SQLite `auth` table -/// into the OS credential store. Must run **before** migration 13 drops -/// the `github_access_token` column. +/// into the OS credential store. Must run **before** the SQL migration that +/// runs `ALTER TABLE auth DROP COLUMN github_access_token` (migration 13 in +/// `migrations.rs`), otherwise the token is lost. /// /// This is intentionally lenient: if the DB doesn't exist, the column is /// already gone, or the table is empty, it simply returns Ok(()).