mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix: eliminate OS keychain prompt on startup by persisting pairing token in DB
The root cause was hydrate_extension_pairing_token accessing the keychain when keychainAccessGranted was true in the DB. Any keychain access on macOS triggers the system prompt when the binary signature changes after an update. Architecture change: two-store model. - The SQLite settings DB is now the primary store for the token. hydrate_extension_pairing_token reads from it exclusively -- it never touches the OS keychain. No system prompt on startup. - The OS keychain remains defence-in-depth: grant_keychain_access still writes the token there, but it is only reached from the explicit Grant Access button, so any system prompt is user-initiated. DB helpers: load_pairing_token_from_settings / save_pairing_token_to_settings hydrate_extension_pairing_token: reads from DB, skips keychain entirely grant_keychain_access: syncs token to DB after keychain access Frontend: extensionPairingToken included in partialize for auto-persist
This commit is contained in:
+46
-1
@@ -811,7 +811,7 @@ fn decide_pairing_token(
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_pairing_token() -> String {
|
||||
pub(crate) fn generate_pairing_token() -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
@@ -819,6 +819,51 @@ fn generate_pairing_token() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Read the extension pairing token from the persisted settings JSON.
|
||||
/// Returns `None` when the field is missing, empty, or the settings haven't
|
||||
/// been saved yet. This is the **primary read path** — it does not touch the
|
||||
/// OS keychain and therefore never triggers a system credential prompt.
|
||||
pub fn load_pairing_token_from_settings(connection: &Connection) -> Result<Option<String>, String> {
|
||||
let Some(settings_json) = load_settings(connection)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value: serde_json::Value = serde_json::from_str(&settings_json)
|
||||
.map_err(|error| format!("failed to decode settings: {error}"))?;
|
||||
let state = value.get("state").unwrap_or(&value);
|
||||
let token = state
|
||||
.get("extensionPairingToken")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Write (or update) the extension pairing token inside the persisted settings
|
||||
/// JSON document. Keeps all other settings fields intact.
|
||||
pub fn save_pairing_token_to_settings(
|
||||
connection: &Connection,
|
||||
token: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(settings_json) = load_settings(connection)? else {
|
||||
// Settings haven't been persisted yet — nothing to update.
|
||||
return Ok(());
|
||||
};
|
||||
let mut value: serde_json::Value = serde_json::from_str(&settings_json)
|
||||
.map_err(|error| format!("failed to decode settings: {error}"))?;
|
||||
let state = if value.get("state").is_some() {
|
||||
value
|
||||
.get_mut("state")
|
||||
.expect("state is an object")
|
||||
} else {
|
||||
&mut value
|
||||
};
|
||||
state["extensionPairingToken"] =
|
||||
serde_json::Value::String(token.to_string());
|
||||
let updated = serde_json::to_string(&value)
|
||||
.map_err(|error| format!("failed to encode settings: {error}"))?;
|
||||
save_settings(connection, &updated)
|
||||
}
|
||||
|
||||
pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
|
||||
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?;
|
||||
entry
|
||||
|
||||
@@ -273,10 +273,14 @@ pub struct PersistedSettings {
|
||||
pub prevents_sleep_while_downloading: bool,
|
||||
pub media_cookie_source: MediaCookieSource,
|
||||
pub site_logins: Vec<SiteLogin>,
|
||||
// Note: `extension_pairing_token` is intentionally NOT persisted here. It
|
||||
// is an HMAC shared secret and is stored in the OS keychain by the
|
||||
// frontend. The field is kept on legacy persisted JSON only; serde ignores
|
||||
// unknown fields when decoding, so existing installs migrate cleanly.
|
||||
/// The HMAC shared secret for the browser extension. It is persisted in the
|
||||
/// settings database so that startup never needs to touch the OS keychain.
|
||||
/// The keychain is still used as defence-in-depth — grant_keychain_access
|
||||
/// writes the token there — but the DB copy is the primary read path,
|
||||
/// eliminating the OS credential prompt that macOS shows when the binary
|
||||
/// signature changes after an update.
|
||||
#[serde(default)]
|
||||
pub extension_pairing_token: String,
|
||||
pub auto_check_updates: bool,
|
||||
#[serde(default)]
|
||||
pub keychain_access_granted: bool,
|
||||
|
||||
+42
-28
@@ -3429,39 +3429,48 @@ struct PairingTokenHydration {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Hydrate the extension pairing token on startup **without touching the OS
|
||||
/// keychain**. The token is read from the persisted settings (SQLite) so the
|
||||
/// operating system never presents a credential-access prompt before the UI is
|
||||
/// visible — even after a build update where the code signature changed.
|
||||
///
|
||||
/// When no token has been persisted yet (fresh install) a new one is generated
|
||||
/// and `persistent` is returned as `false`, which causes the frontend to show
|
||||
/// the `KeychainPermissionModal`.
|
||||
#[tauri::command]
|
||||
fn hydrate_extension_pairing_token(
|
||||
database: tauri::State<'_, crate::db::DbState>,
|
||||
app_state: tauri::State<'_, AppState>,
|
||||
) -> Result<PairingTokenHydration, String> {
|
||||
let mut connection = database.lock()?;
|
||||
let skip_keychain = !crate::db::is_keychain_access_granted(&connection).unwrap_or(false);
|
||||
match crate::db::hydrate_pairing_token(&mut connection, skip_keychain) {
|
||||
Ok((token, token_changed)) => {
|
||||
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||
*pairing_token = token.clone();
|
||||
}
|
||||
Ok(PairingTokenHydration {
|
||||
token,
|
||||
token_changed,
|
||||
persistent: !skip_keychain,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(error) => {
|
||||
let token = app_state
|
||||
.extension_pairing_token
|
||||
.read()
|
||||
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
|
||||
.clone();
|
||||
Ok(PairingTokenHydration {
|
||||
token,
|
||||
token_changed: false,
|
||||
persistent: false,
|
||||
error: Some(error),
|
||||
})
|
||||
let connection = database.lock()?;
|
||||
|
||||
// Primary path: read the token from the settings DB. This is always safe
|
||||
// and never triggers an OS prompt.
|
||||
if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)? {
|
||||
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||
*pairing_token = existing.clone();
|
||||
}
|
||||
return Ok(PairingTokenHydration {
|
||||
token: existing,
|
||||
token_changed: false,
|
||||
persistent: true,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// No token in the DB yet — generate one and save it so future launches
|
||||
// find it without prompting.
|
||||
let generated = crate::db::generate_pairing_token();
|
||||
crate::db::save_pairing_token_to_settings(&connection, &generated)?;
|
||||
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||
*pairing_token = generated.clone();
|
||||
}
|
||||
Ok(PairingTokenHydration {
|
||||
token: generated,
|
||||
token_changed: false,
|
||||
persistent: false,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -3471,12 +3480,17 @@ fn grant_keychain_access(
|
||||
) -> Result<PairingTokenHydration, String> {
|
||||
let mut connection = database.lock()?;
|
||||
|
||||
// Explicitly force migration of any legacy token to the keychain
|
||||
// Explicitly force migration of any legacy token to the keychain.
|
||||
// This is the ONLY code path that touches the OS keychain and it is
|
||||
// reached exclusively through the frontend's "Grant Access" button,
|
||||
// so any system prompt is user-initiated.
|
||||
let _ = crate::db::sanitize_current_settings_and_restore_token(&connection, true);
|
||||
|
||||
match crate::db::hydrate_pairing_token(&mut connection, false) {
|
||||
Ok((token, token_changed)) => {
|
||||
// Update the extension server's token in memory
|
||||
// Persist the token to the settings DB so future startups
|
||||
// can read it without touching the keychain at all.
|
||||
let _ = crate::db::save_pairing_token_to_settings(&connection, &token);
|
||||
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
|
||||
*pairing_token = token.clone();
|
||||
}
|
||||
|
||||
@@ -298,6 +298,7 @@ fn default_settings() -> PersistedSettings {
|
||||
prevents_sleep_while_downloading: true,
|
||||
media_cookie_source: MediaCookieSource::None,
|
||||
site_logins: Vec::new(),
|
||||
extension_pairing_token: String::new(),
|
||||
auto_check_updates: true,
|
||||
keychain_access_granted: false,
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
import type { Theme } from "./Theme";
|
||||
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, extensionPairingToken: string, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -398,6 +398,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
|
||||
mediaCookieSource: state.mediaCookieSource,
|
||||
siteLogins: state.siteLogins,
|
||||
extensionPairingToken: state.extensionPairingToken,
|
||||
keychainAccessGranted: state.keychainAccessGranted,
|
||||
autoCheckUpdates: state.autoCheckUpdates
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user