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:
NimBold
2026-06-25 03:09:50 +03:30
parent 09f103ea04
commit 8eb1a55e72
6 changed files with 99 additions and 34 deletions
+46 -1
View File
@@ -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