fix(security): harden credential and path boundaries

Preserve pending pairing tokens until credential-store migration succeeds, defer keychain access until frontend hydration, reject symlink and malformed ownership paths, and restrict metadata credentials to exact origins.

Refs #15

Refs #16
This commit is contained in:
NimBold
2026-07-15 08:23:43 +03:30
parent d6af4ee2b5
commit 1da0fa7223
11 changed files with 879 additions and 378 deletions
+49 -14
View File
@@ -61,9 +61,8 @@ fn known_download_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, S
} }
fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> { fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<PathBuf, String> {
if std::fs::symlink_metadata(requested).is_ok_and(|metadata| metadata.file_type().is_symlink()) if crate::path_has_symlink_component(requested) {
{ return Err("Download path may not contain symlink components".to_string());
return Err("Download path may not be a symlink".to_string());
} }
let requested = canonicalize_with_missing_leaf(requested)?; let requested = canonicalize_with_missing_leaf(requested)?;
@@ -74,8 +73,9 @@ fn authorize_exact_path(requested: &Path, allowed_paths: &[PathBuf]) -> Result<P
} }
let authorized = allowed_paths.iter().any(|allowed| { let authorized = allowed_paths.iter().any(|allowed| {
canonicalize_with_missing_leaf(allowed) !crate::path_has_symlink_component(allowed)
.is_ok_and(|canonical_allowed| canonical_allowed == requested) && canonicalize_with_missing_leaf(allowed)
.is_ok_and(|canonical_allowed| canonical_allowed == requested)
}); });
if authorized { if authorized {
@@ -98,14 +98,30 @@ fn canonicalize_with_missing_leaf(path: &Path) -> Result<PathBuf, String> {
let mut existing = path; let mut existing = path;
let mut missing = Vec::new(); let mut missing = Vec::new();
while !existing.exists() { loop {
let name = existing match std::fs::symlink_metadata(existing) {
.file_name() Ok(metadata) => {
.ok_or_else(|| "Download path has no existing ancestor".to_string())?; if metadata.file_type().is_symlink() {
missing.push(name.to_owned()); return Err("Download path may not contain symlink components".to_string());
existing = existing }
.parent() break;
.ok_or_else(|| "Download path has no existing ancestor".to_string())?; }
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let name = existing
.file_name()
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
missing.push(name.to_owned());
existing = existing
.parent()
.ok_or_else(|| "Download path has no existing ancestor".to_string())?;
}
Err(error) => {
return Err(format!(
"Failed to inspect download path '{}': {error}",
path.display()
));
}
}
} }
let mut canonical = std::fs::canonicalize(existing) let mut canonical = std::fs::canonicalize(existing)
@@ -144,7 +160,8 @@ mod tests {
#[test] #[test]
fn authorizes_only_known_download_files_and_partials() { fn authorizes_only_known_download_files_and_partials() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let owned = root.path().join("owned.bin"); let root = fs::canonicalize(root.path()).unwrap();
let owned = root.join("owned.bin");
let outside = tempfile::NamedTempFile::new().unwrap(); let outside = tempfile::NamedTempFile::new().unwrap();
fs::write(&owned, b"download").unwrap(); fs::write(&owned, b"download").unwrap();
@@ -197,4 +214,22 @@ mod tests {
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err()); assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
} }
#[cfg(unix)]
#[test]
fn rejects_parent_directory_symlink_escape_from_download_location() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let root_path = fs::canonicalize(root.path()).unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("owned.bin");
fs::write(&outside_file, b"outside").unwrap();
let redirected_parent = root_path.join("downloads");
symlink(outside.path(), &redirected_parent).unwrap();
let escaped = redirected_parent.join("owned.bin");
assert!(authorize_exact_path(&escaped, std::slice::from_ref(&escaped)).is_err());
}
} }
+364 -204
View File
@@ -8,11 +8,20 @@ const DATABASE_NAME: &str = "firelink.sqlite";
const LEGACY_STORE_NAME: &str = "store.bin"; const LEGACY_STORE_NAME: &str = "store.bin";
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app"; const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
const CURRENT_SCHEMA_VERSION: i64 = 1; const CURRENT_SCHEMA_VERSION: i64 = 1;
const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token"; pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
const KEYCHAIN_SERVICE: &str = "com.firelink.app"; const KEYCHAIN_SERVICE: &str = "com.firelink.app";
static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(()); static KEYRING_OPERATION_LOCK: Mutex<()> = Mutex::new(());
fn is_database_path(path: &Path) -> bool {
path.file_name().is_some_and(|name| {
name == DATABASE_NAME
|| name
.to_string_lossy()
.starts_with(&format!("{DATABASE_NAME}.backup-"))
})
}
pub struct DbState { pub struct DbState {
conn: Mutex<Connection>, conn: Mutex<Connection>,
portable: bool, portable: bool,
@@ -26,20 +35,6 @@ impl DbState {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
enum PairingTokenSource {
Keychain,
LegacySettings,
Generated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PairingTokenDecision {
token: String,
source: PairingTokenSource,
changed: bool,
}
#[derive(Default)] #[derive(Default)]
struct LegacyData { struct LegacyData {
settings: Option<String>, settings: Option<String>,
@@ -50,15 +45,19 @@ struct LegacyData {
} }
pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> { pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result<DbState, String> {
init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable()) init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable(), false)
} }
#[cfg(test)] #[cfg(test)]
fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> { fn init_at_path(app_data_dir: &Path) -> Result<DbState, String> {
init_at_path_internal(app_data_dir, false) init_at_path_internal(app_data_dir, false, false)
} }
fn init_at_path_internal(app_data_dir: &Path, portable: bool) -> Result<DbState, String> { fn init_at_path_internal(
app_data_dir: &Path,
portable: bool,
migrate_legacy_keychain: bool,
) -> Result<DbState, String> {
fs::create_dir_all(app_data_dir) fs::create_dir_all(app_data_dir)
.map_err(|error| format!("failed to create app data directory: {error}"))?; .map_err(|error| format!("failed to create app data directory: {error}"))?;
let database_path = app_data_dir.join(DATABASE_NAME); let database_path = app_data_dir.join(DATABASE_NAME);
@@ -79,10 +78,12 @@ fn init_at_path_internal(app_data_dir: &Path, portable: bool) -> Result<DbState,
} }
migrate_schema(&mut connection, version)?; migrate_schema(&mut connection, version)?;
// We no longer touch the keychain on backend startup. import_legacy_data(
// Legacy imports will safely preserve any pairing token in the JSON payload. &mut connection,
// The frontend will manually trigger migration to the keychain via IPC if access is granted. app_data_dir,
import_legacy_data(&mut connection, app_data_dir, false, portable)?; portable,
migrate_legacy_keychain,
)?;
if portable { if portable {
sanitize_persisted_downloads(&mut connection)?; sanitize_persisted_downloads(&mut connection)?;
} }
@@ -186,8 +187,8 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
fn import_legacy_data( fn import_legacy_data(
connection: &mut Connection, connection: &mut Connection,
app_data_dir: &Path, app_data_dir: &Path,
migrate_keychain: bool,
portable: bool, portable: bool,
migrate_keychain: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let legacy_app_dir = app_data_dir let legacy_app_dir = app_data_dir
.parent() .parent()
@@ -206,35 +207,49 @@ fn import_legacy_data(
} }
let marker = format!("legacy-import:{}", candidate.to_string_lossy()); let marker = format!("legacy-import:{}", candidate.to_string_lossy());
if metadata_exists(connection, &marker)? { if metadata_exists(connection, &marker)? {
if portable { sanitize_legacy_source(&candidate, !portable)?;
sanitize_legacy_source(&candidate)?;
}
continue; continue;
} }
if !portable { let backup = if !portable {
backup_file(&candidate, "legacy-import")?; Some(backup_file(&candidate, "legacy-import")?)
} } else {
None
};
let mut legacy = if candidate let mut legacy = if candidate
.file_name() .file_name()
.is_some_and(|name| name == DATABASE_NAME) .is_some_and(|name| name == DATABASE_NAME)
{ {
read_legacy_database(&candidate, migrate_keychain)? read_legacy_database(&candidate, !portable)?
} else { } else {
read_legacy_store(&candidate, migrate_keychain)? read_legacy_store(&candidate, !portable)?
}; };
let mut migration_complete = true; let mut pending_pairing_token = None;
if migrate_keychain if !portable {
&& get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err() if let Some(token) = legacy.pairing_token.take() {
&& legacy.pairing_token.is_some() let migrated = if migrate_keychain {
{ let keychain_has_token = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID)
if let Some(token) = legacy.pairing_token.as_deref() { .ok()
if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, token) { .is_some_and(|value| !value.trim().is_empty());
log::warn!( if !keychain_has_token {
"Legacy pairing token could not be migrated yet; settings import will retry: {}", if let Err(error) =
error set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token)
); {
legacy.settings = None; log::warn!(
migration_complete = false; "Legacy pairing token could not be migrated to the credential store; it will remain pending in the current database: {}",
error
);
false
} else {
true
}
} else {
true
}
} else {
false
};
if !migrated {
pending_pairing_token = Some(token);
} }
} }
} }
@@ -245,67 +260,105 @@ fn import_legacy_data(
sanitize_download_strings(&mut legacy.downloads)?; sanitize_download_strings(&mut legacy.downloads)?;
} }
merge_legacy_data(connection, legacy)?; merge_legacy_data(connection, legacy)?;
if migration_complete { if let Some(token) = pending_pairing_token {
connection if load_pairing_token_from_settings(connection)?.is_none() {
.execute( save_pairing_token_to_settings(connection, &token, true)?;
"INSERT INTO metadata (key, value) VALUES (?1, 'complete')
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![marker],
)
.map_err(|error| format!("failed to record legacy import: {error}"))?;
if portable {
sanitize_legacy_source(&candidate)?;
} }
} }
if let Some(backup) = backup.as_deref() {
sanitize_legacy_source(backup, !portable)?;
}
sanitize_legacy_source(&candidate, !portable)?;
connection
.execute(
"INSERT INTO metadata (key, value) VALUES (?1, 'complete')
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![marker],
)
.map_err(|error| format!("failed to record legacy import: {error}"))?;
} }
Ok(()) Ok(())
} }
fn sanitize_legacy_source(path: &Path) -> Result<(), String> { fn sanitize_legacy_source(path: &Path, remove_pairing_token: bool) -> Result<(), String> {
if path if is_database_path(path) {
.file_name()
.is_some_and(|name| name == DATABASE_NAME)
{
let mut connection = Connection::open(path).map_err(|error| { let mut connection = Connection::open(path).map_err(|error| {
format!( format!(
"failed to open legacy database '{}' for portable sanitization: {error}", "failed to open legacy database '{}' for sanitization: {error}",
path.display() path.display()
) )
})?; })?;
if remove_pairing_token && table_exists(&connection, "settings")? {
if let Some(settings) = connection
.query_row("SELECT data FROM settings WHERE id = 1", [], |row| {
row.get::<_, String>(0)
})
.optional()
.map_err(|error| format!("failed to read legacy settings for sanitization: {error}"))?
{
let sanitized = strip_pairing_token_from_settings(&settings)?;
connection
.execute(
"UPDATE settings SET data = ?1 WHERE id = 1",
params![sanitized],
)
.map_err(|error| format!("failed to sanitize legacy settings: {error}"))?;
}
}
if table_exists(&connection, "downloads")? { if table_exists(&connection, "downloads")? {
return sanitize_persisted_downloads(&mut connection); sanitize_persisted_downloads(&mut connection)?;
} }
return Ok(()); return Ok(());
} }
let text = fs::read_to_string(path).map_err(|error| { let text = fs::read_to_string(path).map_err(|error| {
format!( format!(
"failed to read legacy store '{}' for portable sanitization: {error}", "failed to read legacy store '{}' for sanitization: {error}",
path.display() path.display()
) )
})?; })?;
let mut document: Value = serde_json::from_str(&text).map_err(|error| { let mut document: Value = serde_json::from_str(&text).map_err(|error| {
format!( format!(
"failed to decode legacy store '{}' for portable sanitization: {error}", "failed to decode legacy store '{}' for sanitization: {error}",
path.display() path.display()
) )
})?; })?;
let Some(downloads) = document if remove_pairing_token {
if let Some(settings) = document.get_mut("settings") {
let was_string = settings.is_string();
let (sanitized, _, _) = sanitize_settings_value(settings, true)?;
*settings = if was_string {
Value::String(sanitized)
} else {
serde_json::from_str(&sanitized).map_err(|error| {
format!("failed to decode sanitized legacy settings: {error}")
})?
};
}
}
if let Some(downloads) = document
.get_mut("download_queue") .get_mut("download_queue")
.and_then(Value::as_array_mut) .and_then(Value::as_array_mut)
else { {
return Ok(()); for download in downloads {
}; remove_persisted_transfer_secrets(download);
for download in downloads { }
remove_persisted_transfer_secrets(download);
} }
write_sanitized_legacy_store(path, &text, &document)
}
fn write_sanitized_legacy_store(
path: &Path,
original: &str,
document: &Value,
) -> Result<(), String> {
let sanitized = serde_json::to_string(&document).map_err(|error| { let sanitized = serde_json::to_string(&document).map_err(|error| {
format!( format!(
"failed to encode legacy store '{}' for portable sanitization: {error}", "failed to encode legacy store '{}' for sanitization: {error}",
path.display() path.display()
) )
})?; })?;
if sanitized == text { if sanitized == original {
return Ok(()); return Ok(());
} }
@@ -449,34 +502,6 @@ fn read_legacy_database(path: &Path, force_migrate: bool) -> Result<LegacyData,
Ok(data) Ok(data)
} }
pub fn sanitize_current_settings_and_restore_token(
connection: &Connection,
force_migrate: bool,
) -> Result<(bool, bool), String> {
let Some(settings) = load_settings(connection)? else {
return Ok((false, false));
};
let (sanitized, legacy_token, keychain_granted) =
sanitize_settings_text(&settings, force_migrate)?;
if sanitized == settings {
return Ok((false, keychain_granted));
}
let should_migrate = force_migrate || keychain_granted;
if should_migrate && get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err() {
if let Some(token) = legacy_token.filter(|token| !token.trim().is_empty()) {
if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token) {
log::warn!(
"Persisted pairing token could not be migrated yet; original settings retained: {}",
error
);
return Ok((true, keychain_granted));
}
}
}
save_settings(connection, &sanitized)?;
Ok((false, keychain_granted))
}
fn sanitize_settings_value( fn sanitize_settings_value(
value: &Value, value: &Value,
force_migrate: bool, force_migrate: bool,
@@ -998,63 +1023,10 @@ pub fn consume_notice(connection: &Connection, key: &str) -> Result<(), String>
Ok(()) Ok(())
} }
pub fn hydrate_pairing_token(
connection: &mut Connection,
skip_keychain: bool,
) -> Result<(String, bool), String> {
if skip_keychain {
return Ok((generate_pairing_token(), false));
}
let existing = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).ok();
let generated = generate_pairing_token();
let decision = decide_pairing_token(
existing.as_deref(),
None,
has_user_data(connection)?,
&generated,
);
if decision.source != PairingTokenSource::Keychain {
set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &decision.token)?;
}
if decision.changed {
record_notice(connection, TOKEN_CHANGED_NOTICE)?;
}
let changed = has_pending_notice(connection, TOKEN_CHANGED_NOTICE)?;
Ok((decision.token, changed))
}
pub fn acknowledge_pairing_token_notice(connection: &Connection) -> Result<(), String> { pub fn acknowledge_pairing_token_notice(connection: &Connection) -> Result<(), String> {
consume_notice(connection, TOKEN_CHANGED_NOTICE) consume_notice(connection, TOKEN_CHANGED_NOTICE)
} }
fn decide_pairing_token(
keychain_token: Option<&str>,
legacy_token: Option<&str>,
has_existing_user_data: bool,
generated_token: &str,
) -> PairingTokenDecision {
if let Some(token) = keychain_token.filter(|token| !token.trim().is_empty()) {
return PairingTokenDecision {
token: token.to_string(),
source: PairingTokenSource::Keychain,
changed: false,
};
}
if let Some(token) = legacy_token.filter(|token| !token.trim().is_empty()) {
return PairingTokenDecision {
token: token.to_string(),
source: PairingTokenSource::LegacySettings,
changed: false,
};
}
PairingTokenDecision {
token: generated_token.to_string(),
source: PairingTokenSource::Generated,
changed: has_existing_user_data,
}
}
pub(crate) fn generate_pairing_token() -> String { pub(crate) fn generate_pairing_token() -> String {
format!( format!(
"{}{}", "{}{}",
@@ -1063,10 +1035,9 @@ pub(crate) fn generate_pairing_token() -> String {
) )
} }
/// Read the extension pairing token from the persisted settings JSON. /// Read the extension pairing token from portable settings JSON.
/// Returns `None` when the field is missing, empty, or the settings haven't /// Standard-mode settings are sanitized so this field is never a credential
/// been saved yet. This is the **primary read path** — it does not touch the /// source outside the explicit portable-storage exception.
/// OS keychain and therefore never triggers a system credential prompt.
pub fn load_pairing_token_from_settings(connection: &Connection) -> Result<Option<String>, String> { pub fn load_pairing_token_from_settings(connection: &Connection) -> Result<Option<String>, String> {
let Some(settings_json) = load_settings(connection)? else { let Some(settings_json) = load_settings(connection)? else {
return Ok(None); return Ok(None);
@@ -1082,8 +1053,8 @@ pub fn load_pairing_token_from_settings(connection: &Connection) -> Result<Optio
Ok(token) Ok(token)
} }
/// Write (or update) the extension pairing token inside the persisted settings /// Write (or update) the extension pairing token inside portable settings JSON.
/// JSON document. Keeps all other settings fields intact. /// Keeps all other settings fields intact.
pub fn save_pairing_token_to_settings( pub fn save_pairing_token_to_settings(
connection: &Connection, connection: &Connection,
token: &str, token: &str,
@@ -1092,7 +1063,7 @@ pub fn save_pairing_token_to_settings(
let Some(settings_json) = load_settings(connection)? else { let Some(settings_json) = load_settings(connection)? else {
if !initialize_if_missing { if !initialize_if_missing {
// Settings have not been persisted yet. Standard mode keeps the // Settings have not been persisted yet. Standard mode keeps the
// first-run token session-only until the user grants credential // first-run token session-only until the user grants credential-
// store access; portable mode opts into initialization explicitly. // store access; portable mode opts into initialization explicitly.
return Ok(()); return Ok(());
} }
@@ -1125,6 +1096,111 @@ pub fn save_pairing_token_to_settings(
save_settings(connection, &updated) save_settings(connection, &updated)
} }
/// Remove a pairing token from a serialized settings document.
///
/// Standard-mode settings must never carry the extension HMAC secret. The
/// portable path deliberately preserves it separately through
/// `preserve_portable_pairing_token`.
pub fn strip_pairing_token_from_settings(data: &str) -> Result<String, String> {
let (sanitized, _, _) = sanitize_settings_text(data, true)?;
Ok(sanitized)
}
/// Keep a legacy token in the standard settings document while credential-store
/// migration is pending. The backend never returns this copy to the frontend;
/// it is retained only so an unavailable credential store cannot turn a later
/// settings save into permanent pairing loss.
pub fn preserve_legacy_pairing_token(
existing: Option<&str>,
incoming: &str,
) -> Result<String, String> {
let Some(existing) = existing else {
return Ok(incoming.to_string());
};
let (_, token, _) = sanitize_settings_text(existing, true)?;
let Some(token) = token.filter(|value| !value.trim().is_empty()) else {
return Ok(incoming.to_string());
};
let mut document: Value = serde_json::from_str(incoming)
.map_err(|error| format!("failed to decode settings for legacy token preservation: {error}"))?;
let state = if document.get("state").is_some() {
document
.get_mut("state")
.and_then(Value::as_object_mut)
.ok_or_else(|| "persisted settings state must be an object".to_string())?
} else {
document
.as_object_mut()
.ok_or_else(|| "persisted settings must be an object".to_string())?
};
state.insert("extensionPairingToken".to_string(), Value::String(token));
serde_json::to_string(&document)
.map_err(|error| format!("failed to encode settings with pending pairing token: {error}"))
}
/// Read a legacy pairing token from the settings database without changing it.
pub fn read_pairing_token_from_settings(
connection: &Connection,
) -> Result<Option<String>, String> {
let Some(settings) = load_settings(connection)? else {
return Ok(None);
};
let (_, token, _) = sanitize_settings_text(&settings, true)?;
Ok(token.filter(|value| !value.trim().is_empty()))
}
/// Remove a legacy pairing token from the settings database.
pub fn remove_pairing_token_from_settings(connection: &Connection) -> Result<(), String> {
let Some(settings) = load_settings(connection)? else {
return Ok(());
};
let (sanitized, _, _) = sanitize_settings_text(&settings, true)?;
if sanitized != settings {
save_settings(connection, &sanitized)?;
}
Ok(())
}
/// Migrate any legacy standard-mode token into the OS credential store.
///
/// The settings copy is removed only after the credential-store write succeeds.
/// If cleanup fails after creating a new credential, the new entry is rolled
/// back so a later retry can complete the migration without losing the token.
pub fn migrate_legacy_pairing_token(connection: &Connection) -> Result<(), String> {
let Some(legacy_token) = read_pairing_token_from_settings(connection)? else {
return Ok(());
};
// Hold the same lock used by the public credential-store commands across
// the complete read/write/cleanup sequence. Otherwise a concurrent grant,
// regeneration, or delete could invalidate the rollback decision.
let _keyring_guard = lock_keyring_operations()?;
let keychain_has_token = get_keychain_password_unlocked(PAIRING_TOKEN_KEYCHAIN_ID)
.ok()
.is_some_and(|value| !value.trim().is_empty());
let created_keychain_entry = !keychain_has_token;
if created_keychain_entry {
set_keychain_password_unlocked(PAIRING_TOKEN_KEYCHAIN_ID, &legacy_token)?;
}
if let Err(error) = remove_pairing_token_from_settings(connection) {
if created_keychain_entry {
if let Err(rollback_error) = delete_keychain_password_unlocked(PAIRING_TOKEN_KEYCHAIN_ID)
{
return Err(format!(
"failed to remove the legacy pairing token after credential-store migration: {error}; credential-store rollback also failed: {rollback_error}"
));
}
}
return Err(format!(
"failed to remove the legacy pairing token after credential-store migration: {error}"
));
}
Ok(())
}
fn ensure_keyring_store() -> Result<(), String> { fn ensure_keyring_store() -> Result<(), String> {
if keyring_core::get_default_store().is_some() { if keyring_core::get_default_store().is_some() {
return Ok(()); return Ok(());
@@ -1231,8 +1307,7 @@ fn unique_legacy_linux_keychain_entry(id: &str) -> Result<Option<keyring_core::E
} }
} }
pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> { fn set_keychain_password_unlocked(id: &str, password: &str) -> Result<(), String> {
let _guard = lock_keyring_operations()?;
let entry = keychain_entry(id)?; let entry = keychain_entry(id)?;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -1254,8 +1329,12 @@ pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
pub fn get_keychain_password(id: &str) -> Result<String, String> { pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> {
let _guard = lock_keyring_operations()?; let _guard = lock_keyring_operations()?;
set_keychain_password_unlocked(id, password)
}
fn get_keychain_password_unlocked(id: &str) -> Result<String, String> {
let entry = keychain_entry(id)?; let entry = keychain_entry(id)?;
match entry.get_password() { match entry.get_password() {
Ok(password) => Ok(password), Ok(password) => Ok(password),
@@ -1271,8 +1350,12 @@ pub fn get_keychain_password(id: &str) -> Result<String, String> {
} }
} }
pub fn delete_keychain_password(id: &str) -> Result<(), String> { pub fn get_keychain_password(id: &str) -> Result<String, String> {
let _guard = lock_keyring_operations()?; let _guard = lock_keyring_operations()?;
get_keychain_password_unlocked(id)
}
fn delete_keychain_password_unlocked(id: &str) -> Result<(), String> {
let entry = keychain_entry(id)?; let entry = keychain_entry(id)?;
match entry.delete_credential() { match entry.delete_credential() {
Ok(()) | Err(keyring_core::Error::NoEntry) => {} Ok(()) | Err(keyring_core::Error::NoEntry) => {}
@@ -1290,6 +1373,11 @@ pub fn delete_keychain_password(id: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
pub fn delete_keychain_password(id: &str) -> Result<(), String> {
let _guard = lock_keyring_operations()?;
delete_keychain_password_unlocked(id)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1362,7 +1450,7 @@ mod tests {
.unwrap(); .unwrap();
drop(connection); drop(connection);
let state = init_at_path_internal(temp.path(), true).unwrap(); let state = init_at_path_internal(temp.path(), true, true).unwrap();
let connection = state.lock().unwrap(); let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none()); assert!(saved.get("password").is_none());
@@ -1375,7 +1463,7 @@ mod tests {
} }
#[test] #[test]
fn imports_legacy_bundle_store_and_preserves_token() { fn imports_legacy_bundle_store_with_pending_token_for_deferred_migration() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
let current = root.path().join("com.nimbold.firelink"); let current = root.path().join("com.nimbold.firelink");
let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER); let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER);
@@ -1415,12 +1503,22 @@ mod tests {
let settings = load_settings(&connection).unwrap().unwrap(); let settings = load_settings(&connection).unwrap().unwrap();
assert!(settings.contains("\"theme\":\"dark\"")); assert!(settings.contains("\"theme\":\"dark\""));
assert!(settings.contains("legacy-secret")); assert!(settings.contains("legacy-secret"));
assert!(fs::read_dir(&legacy).unwrap().flatten().any(|entry| { let backup = fs::read_dir(&legacy)
entry .unwrap()
.file_name() .flatten()
.to_string_lossy() .find(|entry| {
.starts_with("store.bin.backup-legacy-import-") entry
})); .file_name()
.to_string_lossy()
.starts_with("store.bin.backup-legacy-import-")
})
.expect("legacy import should retain a sanitized backup");
assert!(!fs::read_to_string(backup.path())
.unwrap()
.contains("legacy-secret"));
assert!(!fs::read_to_string(legacy.join(LEGACY_STORE_NAME))
.unwrap()
.contains("legacy-secret"));
} }
#[test] #[test]
@@ -1442,7 +1540,7 @@ mod tests {
}); });
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap(); fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
let state = init_at_path_internal(&current, true).unwrap(); let state = init_at_path_internal(&current, true, true).unwrap();
let connection = state.lock().unwrap(); let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none()); assert!(saved.get("password").is_none());
@@ -1479,7 +1577,7 @@ mod tests {
); );
INSERT INTO settings VALUES ( INSERT INTO settings VALUES (
1, 1,
'{\"state\":{\"theme\":\"nord\"},\"version\":0}' '{\"state\":{\"theme\":\"nord\",\"extensionPairingToken\":\"legacy-sqlite-secret\"},\"version\":0}'
); );
", ",
) )
@@ -1490,16 +1588,30 @@ mod tests {
let connection = state.lock().unwrap(); let connection = state.lock().unwrap();
assert_eq!(load_downloads(&connection).unwrap().len(), 1); assert_eq!(load_downloads(&connection).unwrap().len(), 1);
assert_eq!(load_queues(&connection).unwrap().len(), 1); assert_eq!(load_queues(&connection).unwrap().len(), 1);
assert!(load_settings(&connection) let settings = load_settings(&connection).unwrap().unwrap();
assert!(settings.contains("\"nord\""));
assert!(settings.contains("legacy-sqlite-secret"));
let backup = fs::read_dir(&legacy)
.unwrap() .unwrap()
.unwrap() .flatten()
.contains("\"nord\"")); .find(|entry| {
assert!(fs::read_dir(&legacy).unwrap().flatten().any(|entry| { entry
entry .file_name()
.file_name() .to_string_lossy()
.to_string_lossy() .starts_with("firelink.sqlite.backup-legacy-import-")
.starts_with("firelink.sqlite.backup-legacy-import-") })
})); .unwrap();
let backup_connection = Connection::open(backup.path()).unwrap();
let backup_settings: String = backup_connection
.query_row("SELECT data FROM settings WHERE id = 1", [], |row| row.get(0))
.unwrap();
assert!(!backup_settings.contains("legacy-sqlite-secret"));
drop(backup_connection);
let source_connection = Connection::open(legacy.join(DATABASE_NAME)).unwrap();
let source_settings: String = source_connection
.query_row("SELECT data FROM settings WHERE id = 1", [], |row| row.get(0))
.unwrap();
assert!(!source_settings.contains("legacy-sqlite-secret"));
} }
#[test] #[test]
@@ -1571,6 +1683,75 @@ mod tests {
assert!(standard.to_string().contains("PORTABLE_TEST_QUERY_TOKEN")); assert!(standard.to_string().contains("PORTABLE_TEST_QUERY_TOKEN"));
} }
#[test]
fn standard_pairing_token_is_stripped_from_settings_documents() {
let input = json!({
"state": {
"theme": "dark",
"extensionPairingToken": "redacted-pairing-token"
},
"version": 3
})
.to_string();
let stripped = strip_pairing_token_from_settings(&input).unwrap();
assert!(!stripped.contains("redacted-pairing-token"));
assert!(stripped.contains("\"theme\":\"dark\""));
}
#[test]
fn pending_legacy_pairing_token_survives_standard_settings_save() {
let existing = json!({
"state": { "theme": "dark", "extensionPairingToken": "pending-token" },
"version": 3
})
.to_string();
let incoming = json!({
"state": { "theme": "light" },
"version": 3
})
.to_string();
let sanitized = strip_pairing_token_from_settings(&incoming).unwrap();
let preserved = preserve_legacy_pairing_token(Some(&existing), &sanitized).unwrap();
assert!(preserved.contains("pending-token"));
assert!(preserved.contains("\"theme\":\"light\""));
}
#[test]
fn reading_legacy_pairing_token_does_not_remove_it_before_migration() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
save_settings(
&connection,
&json!({
"state": { "extensionPairingToken": "redacted-legacy-token" },
"version": 3
})
.to_string(),
)
.unwrap();
assert_eq!(
read_pairing_token_from_settings(&connection)
.unwrap()
.as_deref(),
Some("redacted-legacy-token")
);
assert!(load_settings(&connection)
.unwrap()
.unwrap()
.contains("redacted-legacy-token"));
remove_pairing_token_from_settings(&connection).unwrap();
assert!(!load_settings(&connection)
.unwrap()
.unwrap()
.contains("redacted-legacy-token"));
}
#[test] #[test]
fn portable_persistence_redacts_unparseable_download_urls() { fn portable_persistence_redacts_unparseable_download_urls() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@@ -1608,7 +1789,7 @@ mod tests {
drop(connection); drop(connection);
drop(state); drop(state);
let state = init_at_path_internal(temp.path(), true).unwrap(); let state = init_at_path_internal(temp.path(), true, true).unwrap();
let connection = state.lock().unwrap(); let connection = state.lock().unwrap();
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
assert!(saved.get("password").is_none()); assert!(saved.get("password").is_none());
@@ -1648,27 +1829,6 @@ mod tests {
); );
} }
#[test]
fn token_decision_preserves_keychain_and_legacy_values() {
let keychain = decide_pairing_token(Some("keychain"), Some("legacy"), true, "generated");
assert_eq!(keychain.token, "keychain");
assert_eq!(keychain.source, PairingTokenSource::Keychain);
assert!(!keychain.changed);
let legacy = decide_pairing_token(None, Some("legacy"), true, "generated");
assert_eq!(legacy.token, "legacy");
assert_eq!(legacy.source, PairingTokenSource::LegacySettings);
assert!(!legacy.changed);
let recovery = decide_pairing_token(None, None, true, "generated");
assert_eq!(recovery.token, "generated");
assert_eq!(recovery.source, PairingTokenSource::Generated);
assert!(recovery.changed);
let fresh = decide_pairing_token(None, None, false, "generated");
assert!(!fresh.changed);
}
#[test] #[test]
fn migration_notice_is_persistent_until_acknowledged() { fn migration_notice_is_persistent_until_acknowledged() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
+45 -10
View File
@@ -59,7 +59,12 @@ pub fn expected_primary_path(
} }
let safe_filename = canonical_download_filename(filename); let safe_filename = canonical_download_filename(filename);
Ok(resolved_dest.join(safe_filename)) let path = resolved_dest.join(safe_filename);
if crate::path_has_symlink_component(&path) {
return Err("Download path may not contain symlink components".to_string());
}
crate::canonicalize_with_missing_components(&path)
.ok_or_else(|| "Download path could not be canonicalized".to_string())
} }
pub fn register_expected( pub fn register_expected(
@@ -88,13 +93,15 @@ pub fn set_primary_path(
}) { }) {
return Err("Download ownership path traversal is not allowed".to_string()); return Err("Download ownership path traversal is not allowed".to_string());
} }
if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) { if crate::path_has_symlink_component(path) {
return Err("Download ownership path may not be a symlink".to_string()); return Err("Download ownership path may not contain symlink components".to_string());
} }
let canonical_path = crate::canonicalize_with_missing_components(path)
.ok_or_else(|| "Download ownership path could not be canonicalized".to_string())?;
let database = app_handle.state::<crate::db::DbState>(); let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?; let connection = database.lock()?;
crate::db::set_ownership(&connection, id, &path.to_string_lossy()) crate::db::set_ownership(&connection, id, &canonical_path.to_string_lossy())
} }
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> { pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
@@ -147,11 +154,7 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
let downloads = { let downloads = {
let database = app_handle.state::<crate::db::DbState>(); let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?; let connection = database.lock()?;
crate::db::load_downloads(&connection)? parse_legacy_download_items(crate::db::load_downloads(&connection)?)
.into_iter()
.map(|value| serde_json::from_str::<crate::ipc::DownloadItem>(&value))
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("Invalid download queue ownership data: {error}"))?
}; };
let mut paths = Vec::new(); let mut paths = Vec::new();
@@ -223,9 +226,23 @@ fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<Path
Ok(paths) Ok(paths)
} }
fn parse_legacy_download_items(values: Vec<String>) -> Vec<crate::ipc::DownloadItem> {
values
.into_iter()
.filter_map(|value| match serde_json::from_str::<crate::ipc::DownloadItem>(&value) {
Ok(download) => Some(download),
Err(error) => {
log::warn!("Skipping malformed download ownership record: {error}");
None
}
})
.collect()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::canonical_download_filename; use super::{canonical_download_filename, parse_legacy_download_items};
use serde_json::json;
#[test] #[test]
fn canonicalizes_untrusted_download_filenames() { fn canonicalizes_untrusted_download_filenames() {
@@ -238,4 +255,22 @@ mod tests {
assert_eq!(canonical_download_filename("CON.txt"), "CON-.txt"); assert_eq!(canonical_download_filename("CON.txt"), "CON-.txt");
assert_eq!(canonical_download_filename("lpt9"), "lpt9-"); assert_eq!(canonical_download_filename("lpt9"), "lpt9-");
} }
#[test]
fn malformed_legacy_download_does_not_block_valid_ownership_records() {
let valid = json!({
"id": "download-1",
"url": "https://example.com/file",
"fileName": "file",
"status": "completed",
"category": "Other",
"dateAdded": ""
})
.to_string();
let downloads = parse_legacy_download_items(vec!["not-json".to_string(), valid]);
assert_eq!(downloads.len(), 1);
assert_eq!(downloads[0].id, "download-1");
}
} }
-7
View File
@@ -280,13 +280,6 @@ pub struct PersistedSettings {
pub prevents_sleep_while_downloading: bool, pub prevents_sleep_while_downloading: bool,
pub media_cookie_source: MediaCookieSource, pub media_cookie_source: MediaCookieSource,
pub site_logins: Vec<SiteLogin>, pub site_logins: Vec<SiteLogin>,
// HMAC shared secret for the browser extension. It is persisted in the
// settings database so startup never needs to touch the OS keychain.
// The keychain is still used as defense-in-depth by grant_keychain_access,
// 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, pub auto_check_updates: bool,
#[serde(default)] #[serde(default)]
pub keychain_access_granted: bool, pub keychain_access_granted: bool,
+309 -87
View File
@@ -1329,6 +1329,23 @@ async fn validate_url_ssrf(url: &str) -> Result<Option<(String, std::net::Socket
Ok(Some((host.to_string(), addr))) Ok(Some((host.to_string(), addr)))
} }
fn same_origin(left: &reqwest::Url, right: &reqwest::Url) -> bool {
left.scheme() == right.scheme()
&& left.host() == right.host()
&& left.port_or_known_default() == right.port_or_known_default()
}
fn should_send_metadata_credentials(
original: Option<&reqwest::Url>,
current: Option<&reqwest::Url>,
redirects: usize,
) -> bool {
redirects == 0
|| original
.zip(current)
.is_some_and(|(original, current)| same_origin(original, current))
}
#[allow(clippy::too_many_arguments)] // Keep the metadata IPC fields explicit and independently typed. #[allow(clippy::too_many_arguments)] // Keep the metadata IPC fields explicit and independently typed.
#[tauri::command] #[tauri::command]
async fn fetch_metadata( async fn fetch_metadata(
@@ -1344,7 +1361,7 @@ async fn fetch_metadata(
ensure_reqwest_crypto_provider(); ensure_reqwest_crypto_provider();
let mut current_url = url.clone(); let mut current_url = url.clone();
let original_host = reqwest::Url::parse(&url).ok().and_then(|u| u.host_str().map(|s| s.to_string())); let original_origin = reqwest::Url::parse(&url).ok();
let mut redirects = 0; let mut redirects = 0;
let cookies_available = metadata_cookie_header_present(headers.as_deref(), cookies.as_deref()); let cookies_available = metadata_cookie_header_present(headers.as_deref(), cookies.as_deref());
let defer_cookies = defer_cookies.unwrap_or(false); let defer_cookies = defer_cookies.unwrap_or(false);
@@ -1385,15 +1402,12 @@ async fn fetch_metadata(
builder = builder.resolve(&host, addr); builder = builder.resolve(&host, addr);
} }
let current_host = reqwest::Url::parse(&current_url).ok().and_then(|u| u.host_str().map(|s| s.to_string())); let current_origin = reqwest::Url::parse(&current_url).ok();
let mut should_send_auth = redirects == 0; let should_send_auth = should_send_metadata_credentials(
if !should_send_auth { original_origin.as_ref(),
if let (Some(orig), Some(curr)) = (&original_host, &current_host) { current_origin.as_ref(),
if curr == orig || curr.ends_with(&format!(".{}", orig)) { redirects,
should_send_auth = true; );
}
}
}
let header_map = if should_send_auth { let header_map = if should_send_auth {
metadata_headers(headers.as_deref(), cookies.as_deref(), send_cookies) metadata_headers(headers.as_deref(), cookies.as_deref(), send_cookies)
@@ -1926,12 +1940,51 @@ pub(crate) fn is_safe_path<R: tauri::Runtime>(path: &std::path::Path, app_handle
.any(|root| crate::platform::path_is_within(&canonical_path, &root)) .any(|root| crate::platform::path_is_within(&canonical_path, &root))
} }
fn canonicalize_with_missing_components(path: &std::path::Path) -> Option<std::path::PathBuf> { pub(crate) fn path_has_symlink_component(path: &std::path::Path) -> bool {
use std::path::Component;
let mut current = std::path::PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
current.push(component.as_os_str());
}
Component::Normal(name) => {
current.push(name);
if std::fs::symlink_metadata(&current)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
return true;
}
}
Component::CurDir | Component::ParentDir => return true,
}
}
false
}
pub(crate) fn canonicalize_with_missing_components(
path: &std::path::Path,
) -> Option<std::path::PathBuf> {
if path_has_symlink_component(path) {
return None;
}
let mut existing = path; let mut existing = path;
let mut missing = Vec::new(); let mut missing = Vec::new();
while !existing.exists() { loop {
missing.push(existing.file_name()?.to_owned()); match std::fs::symlink_metadata(existing) {
existing = existing.parent()?; Ok(metadata) => {
if metadata.file_type().is_symlink() {
return None;
}
break;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
missing.push(existing.file_name()?.to_owned());
existing = existing.parent()?;
}
Err(_) => return None,
}
} }
let mut canonical = std::fs::canonicalize(existing).ok()?; let mut canonical = std::fs::canonicalize(existing).ok()?;
for component in missing.iter().rev() { for component in missing.iter().rev() {
@@ -4854,14 +4907,9 @@ struct PairingTokenHydration {
error: Option<String>, error: Option<String>,
} }
/// Hydrate the extension pairing token on startup **without touching the OS /// Hydrate the extension pairing token after the frontend is ready. Standard
/// keychain**. The token is read from the persisted settings (SQLite) so the /// mode uses the OS credential store; portable mode intentionally keeps the
/// operating system never presents a credential-access prompt before the UI is /// token with the portable settings folder.
/// 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] #[tauri::command]
fn hydrate_extension_pairing_token( fn hydrate_extension_pairing_token(
database: tauri::State<'_, crate::db::DbState>, database: tauri::State<'_, crate::db::DbState>,
@@ -4869,37 +4917,96 @@ fn hydrate_extension_pairing_token(
) -> Result<PairingTokenHydration, String> { ) -> Result<PairingTokenHydration, String> {
let connection = database.lock()?; let connection = database.lock()?;
// Primary path: read the token from the settings DB. This is always safe if app_state.storage_layout.is_portable() {
// and never triggers an OS prompt. let token = crate::db::load_pairing_token_from_settings(&connection)?
if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)? { .unwrap_or_else(crate::db::generate_pairing_token);
crate::db::save_pairing_token_to_settings(&connection, &token, true)?;
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = existing.clone(); *pairing_token = token.clone();
} }
return Ok(PairingTokenHydration { return Ok(PairingTokenHydration {
token: existing, token,
token_changed: false, token_changed: false,
persistent: true, persistent: true,
error: None, error: None,
}); });
} }
// No token in the DB yet — generate one. Portable mode initializes the let migration_error = crate::db::migrate_legacy_pairing_token(&connection).err();
// settings row immediately so the pairing secret travels with the folder; let keychain_token = crate::db::get_keychain_password(crate::db::PAIRING_TOKEN_KEYCHAIN_ID)
// standard mode remains session-only until the user grants credential-store .ok()
// access when settings have not been persisted yet. .filter(|value| !value.trim().is_empty());
let persistent = keychain_token.is_some();
let token = keychain_token.unwrap_or_else(crate::db::generate_pairing_token);
if !persistent && crate::db::has_user_data(&connection)? {
crate::db::record_notice(&connection, crate::db::TOKEN_CHANGED_NOTICE)?;
}
let token_changed =
crate::db::has_pending_notice(&connection, crate::db::TOKEN_CHANGED_NOTICE)?;
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = token.clone();
}
Ok(PairingTokenHydration {
token,
token_changed,
persistent,
error: migration_error.or_else(|| {
(!persistent).then(|| {
"Credential store access is unavailable; browser pairing is session-only.".to_string()
})
}),
})
}
#[tauri::command]
fn regenerate_pairing_token(
database: tauri::State<'_, crate::db::DbState>,
app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> {
let connection = database.lock()?;
let generated = crate::db::generate_pairing_token(); let generated = crate::db::generate_pairing_token();
crate::db::save_pairing_token_to_settings(
&connection, if app_state.storage_layout.is_portable() {
&generated, crate::db::save_pairing_token_to_settings(&connection, &generated, true)?;
app_state.storage_layout.is_portable(), } else {
)?; if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) {
let token = app_state
.extension_pairing_token
.read()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
return Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: false,
error: Some(error),
});
}
if let Err(error) = crate::db::set_keychain_password(
crate::db::PAIRING_TOKEN_KEYCHAIN_ID,
&generated,
) {
let token = app_state
.extension_pairing_token
.read()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
return Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: false,
error: Some(error),
});
}
}
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
*pairing_token = generated.clone(); *pairing_token = generated.clone();
} }
Ok(PairingTokenHydration { Ok(PairingTokenHydration {
token: generated, token: generated,
token_changed: false, token_changed: false,
persistent: app_state.storage_layout.is_portable(), persistent: true,
error: None, error: None,
}) })
} }
@@ -4909,7 +5016,7 @@ fn grant_keychain_access(
database: tauri::State<'_, crate::db::DbState>, database: tauri::State<'_, crate::db::DbState>,
app_state: tauri::State<'_, AppState>, app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> { ) -> Result<PairingTokenHydration, String> {
let mut connection = database.lock()?; let connection = database.lock()?;
if app_state.storage_layout.is_portable() { if app_state.storage_layout.is_portable() {
let token = if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)? let token = if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)?
@@ -4931,41 +5038,55 @@ fn grant_keychain_access(
}); });
} }
// Explicitly force migration of any legacy token to the keychain. if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) {
// This is the ONLY code path that touches the OS keychain and it is let token = app_state
// reached exclusively through the frontend's "Grant Access" button, .extension_pairing_token
// so any system prompt is user-initiated. .read()
let _ = crate::db::sanitize_current_settings_and_restore_token(&connection, true); .map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
return Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: false,
error: Some(error),
});
}
match crate::db::hydrate_pairing_token(&mut connection, false) { let token = match crate::db::get_keychain_password(crate::db::PAIRING_TOKEN_KEYCHAIN_ID) {
Ok((token, token_changed)) => { Ok(token) if !token.trim().is_empty() => token,
// Persist the token to the settings DB so future startups _ => {
// can read it without touching the keychain at all. let generated = crate::db::generate_pairing_token();
let _ = crate::db::save_pairing_token_to_settings(&connection, &token, false); if let Err(error) = crate::db::set_keychain_password(
if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { crate::db::PAIRING_TOKEN_KEYCHAIN_ID,
*pairing_token = token.clone(); &generated,
) {
let current = app_state
.extension_pairing_token
.read()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
return Ok(PairingTokenHydration {
token: current,
token_changed: false,
persistent: false,
error: Some(error),
});
} }
Ok(PairingTokenHydration { generated
token,
token_changed,
persistent: true,
error: None,
})
} }
Err(error) => { };
let token = app_state
.extension_pairing_token {
.read() if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() {
.map_err(|_| "Extension pairing token lock is unavailable".to_string())? *pairing_token = token.clone();
.clone();
Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: false,
error: Some(error),
})
} }
} }
Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: true,
error: None,
})
} }
#[tauri::command] #[tauri::command]
@@ -4988,7 +5109,8 @@ fn db_save_settings(
let merged = if state.is_portable() { let merged = if state.is_portable() {
crate::settings::preserve_portable_pairing_token(existing.as_deref(), &merged)? crate::settings::preserve_portable_pairing_token(existing.as_deref(), &merged)?
} else { } else {
merged let sanitized = crate::db::strip_pairing_token_from_settings(&merged)?;
crate::db::preserve_legacy_pairing_token(existing.as_deref(), &sanitized)?
}; };
crate::db::save_settings(&connection, &merged)?; crate::db::save_settings(&connection, &merged)?;
let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?; let decoded = crate::settings::decode_stored_settings(&serde_json::Value::String(merged))?;
@@ -5001,7 +5123,13 @@ fn db_save_settings(
#[tauri::command] #[tauri::command]
fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result<Option<String>, String> { fn db_load_settings(state: tauri::State<'_, crate::db::DbState>) -> Result<Option<String>, String> {
let connection = state.lock()?; let connection = state.lock()?;
crate::db::load_settings(&connection) let settings = crate::db::load_settings(&connection)?;
if state.is_portable() {
return Ok(settings);
}
settings
.map(|data| crate::db::strip_pairing_token_from_settings(&data))
.transpose()
} }
#[tauri::command] #[tauri::command]
@@ -5046,27 +5174,59 @@ fn check_file_exists(app_handle: tauri::AppHandle, path: String) -> bool {
resolved_dest.exists() resolved_dest.exists()
} }
fn collect_log_files(log_dir: &std::path::Path) -> Result<Vec<std::path::PathBuf>, String> {
let directory_metadata = match std::fs::symlink_metadata(log_dir) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(format!("failed to inspect log directory: {error}")),
};
if directory_metadata.file_type().is_symlink() || !directory_metadata.is_dir() {
return Ok(Vec::new());
}
let canonical_log_dir = match std::fs::canonicalize(log_dir) {
Ok(path) if path.is_dir() => path,
Ok(_) | Err(_) => return Ok(Vec::new()),
};
let entries = match std::fs::read_dir(log_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(format!("failed to read log directory: {error}")),
};
let mut files = Vec::new();
for entry in entries {
let path = entry
.map_err(|error| format!("failed to inspect log directory entry: {error}"))?
.path();
let metadata = std::fs::symlink_metadata(&path)
.map_err(|error| format!("failed to inspect log file '{}': {error}", path.display()))?;
if !metadata.file_type().is_file()
|| metadata.file_type().is_symlink()
|| !path
.file_name()
.is_some_and(|name| name.to_string_lossy().contains(".log"))
{
continue;
}
let canonical_path = std::fs::canonicalize(&path)
.map_err(|error| format!("failed to resolve log file '{}': {error}", path.display()))?;
if canonical_path.starts_with(&canonical_log_dir) {
files.push(canonical_path);
}
}
files.sort();
Ok(files)
}
async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, String> { async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathBuf>, String> {
let log_dir = app_handle let log_dir = app_handle
.state::<AppState>() .state::<AppState>()
.storage_layout .storage_layout
.log_dir() .log_dir()
.to_path_buf(); .to_path_buf();
let mut files = Vec::new(); collect_log_files(&log_dir)
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.is_file()
&& path
.file_name()
.is_some_and(|name| name.to_string_lossy().contains(".log"))
{
files.push(path);
}
}
}
files.sort();
Ok(files)
} }
pub(crate) fn redact_sensitive_text(line: &str) -> String { pub(crate) fn redact_sensitive_text(line: &str) -> String {
@@ -5386,7 +5546,8 @@ mod tests {
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line, parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value, redact_log_line, redact_log_line_for_output, sanitize_ytdlp_config_value,
has_resumable_download_assets, should_cleanup_media_artifacts_after_failure, has_resumable_download_assets, should_cleanup_media_artifacts_after_failure,
retry_metadata_with_cookies, should_retry_metadata_with_cookies, FirelinkDeepLink, retry_metadata_with_cookies, should_retry_metadata_with_cookies,
should_send_metadata_credentials, collect_log_files, FirelinkDeepLink,
MediaProgress, MediaProgress,
MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
}; };
@@ -5419,6 +5580,66 @@ mod tests {
); );
} }
#[test]
fn metadata_redirect_credentials_require_the_exact_origin() {
let original = reqwest::Url::parse("https://example.com/file").unwrap();
let same_origin = reqwest::Url::parse("https://example.com/other").unwrap();
let subdomain = reqwest::Url::parse("https://cdn.example.com/file").unwrap();
let different_port = reqwest::Url::parse("https://example.com:8443/file").unwrap();
let downgraded = reqwest::Url::parse("http://example.com/file").unwrap();
assert!(should_send_metadata_credentials(
Some(&original),
Some(&original),
0
));
assert!(should_send_metadata_credentials(
Some(&original),
Some(&same_origin),
1
));
assert!(!should_send_metadata_credentials(
Some(&original),
Some(&subdomain),
1
));
assert!(!should_send_metadata_credentials(
Some(&original),
Some(&different_port),
1
));
assert!(!should_send_metadata_credentials(
Some(&original),
Some(&downgraded),
1
));
}
#[cfg(unix)]
#[test]
fn log_collection_rejects_symlink_files_and_directories() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let log_dir = root.path().join("logs");
let outside = tempfile::tempdir().unwrap();
std::fs::create_dir_all(&log_dir).unwrap();
std::fs::write(log_dir.join("firelink.log"), "safe").unwrap();
std::fs::write(outside.path().join("secret.log"), "redacted-fixture").unwrap();
symlink(
outside.path().join("secret.log"),
log_dir.join("attacker.log"),
)
.unwrap();
let files = collect_log_files(&log_dir).unwrap();
assert_eq!(files, vec![std::fs::canonicalize(log_dir.join("firelink.log")).unwrap()]);
let redirected_logs = root.path().join("redirected-logs");
symlink(outside.path(), &redirected_logs).unwrap();
assert!(collect_log_files(&redirected_logs).unwrap().is_empty());
}
#[test] #[test]
fn metadata_defers_captured_cookies_until_the_origin_challenges() { fn metadata_defers_captured_cookies_until_the_origin_challenges() {
let headers = "Cookie: oversized=secret\nReferer: https://example.com/page"; let headers = "Cookie: oversized=secret\nReferer: https://example.com/page";
@@ -6905,7 +7126,8 @@ pub fn run() {
ack_schedule_trigger, ack_schedule_trigger,
check_automation_permission, request_automation_permission, open_automation_settings, check_automation_permission, request_automation_permission, open_automation_settings,
set_keychain_password, get_keychain_password, delete_keychain_password, set_keychain_password, get_keychain_password, delete_keychain_password,
hydrate_extension_pairing_token, grant_keychain_access, acknowledge_pairing_token_change, hydrate_extension_pairing_token, regenerate_pairing_token, grant_keychain_access,
acknowledge_pairing_token_change,
check_file_exists, toggle_tray_icon, set_extension_pairing_token, check_file_exists, toggle_tray_icon, set_extension_pairing_token,
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download, get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
detach_download_for_reconfigure, detach_download_for_reconfigure,
-1
View File
@@ -446,7 +446,6 @@ fn default_settings() -> PersistedSettings {
prevents_sleep_while_downloading: true, prevents_sleep_while_downloading: true,
media_cookie_source: MediaCookieSource::default(), media_cookie_source: MediaCookieSource::default(),
site_logins: Vec::new(), site_logins: Vec::new(),
extension_pairing_token: String::new(),
auto_check_updates: true, auto_check_updates: true,
keychain_access_granted: false, keychain_access_granted: false,
} }
+96 -15
View File
@@ -55,31 +55,38 @@ impl StorageLayout {
app_handle: &AppHandle<R>, app_handle: &AppHandle<R>,
mode: StorageMode, mode: StorageMode,
) -> Result<Self, String> { ) -> Result<Self, String> {
match mode { let (mode, data_dir, log_dir, webview_dir) = match mode {
StorageMode::Standard => Ok(Self { StorageMode::Standard => (
mode: StorageMode::Standard, StorageMode::Standard,
data_dir: app_handle app_handle
.path() .path()
.app_data_dir() .app_data_dir()
.map_err(|error| format!("failed to resolve app data directory: {error}"))?, .map_err(|error| format!("failed to resolve app data directory: {error}"))?,
log_dir: app_handle app_handle
.path() .path()
.app_log_dir() .app_log_dir()
.map_err(|error| format!("failed to resolve app log directory: {error}"))?, .map_err(|error| format!("failed to resolve app log directory: {error}"))?,
webview_dir: app_handle.path().app_local_data_dir().map_err(|error| { app_handle.path().app_local_data_dir().map_err(|error| {
format!("failed to resolve app local data directory: {error}") format!("failed to resolve app local data directory: {error}")
})?, })?,
}), ),
StorageMode::Portable { root } => { StorageMode::Portable { root } => {
let data_dir = root.join(PORTABLE_DATA_DIR); let data_dir = root.join(PORTABLE_DATA_DIR);
Ok(Self { (
mode: StorageMode::Portable { root }, StorageMode::Portable { root },
log_dir: data_dir.join(PORTABLE_LOG_DIR), data_dir.clone(),
webview_dir: data_dir.join(PORTABLE_WEBVIEW_DIR), data_dir.join(PORTABLE_LOG_DIR),
data_dir, data_dir.join(PORTABLE_WEBVIEW_DIR),
}) )
} }
} };
Ok(Self {
mode,
data_dir: canonicalize_storage_path(&data_dir)?,
log_dir: canonicalize_storage_path(&log_dir)?,
webview_dir: canonicalize_storage_path(&webview_dir)?,
})
} }
pub fn is_portable(&self) -> bool { pub fn is_portable(&self) -> bool {
@@ -99,10 +106,57 @@ impl StorageLayout {
} }
} }
fn canonicalize_storage_path(path: &Path) -> Result<PathBuf, String> {
if crate::path_has_symlink_component(path) {
return Err(format!(
"storage path contains a symlinked component: '{}'",
path.display()
));
}
let mut existing = path;
let mut missing = Vec::new();
loop {
match std::fs::symlink_metadata(existing) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Err(format!(
"storage path contains a symlinked directory: '{}'",
path.display()
));
}
break;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"failed to inspect storage path '{}': {error}",
path.display()
));
}
}
missing.push(
existing
.file_name()
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?
.to_owned(),
);
existing = existing
.parent()
.ok_or_else(|| format!("storage path has no existing ancestor: '{}'", path.display()))?;
}
let mut canonical = std::fs::canonicalize(existing)
.map_err(|error| format!("failed to canonicalize storage path '{}': {error}", path.display()))?;
for component in missing.iter().rev() {
canonical.push(component);
}
Ok(canonical)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{StorageMode, PORTABLE_MARKER}; use super::{canonicalize_storage_path, StorageMode, PORTABLE_MARKER};
use std::fs; use std::fs;
use std::path::Path;
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
@@ -127,4 +181,31 @@ mod tests {
StorageMode::Standard StorageMode::Standard
); );
} }
#[cfg(unix)]
#[test]
fn rejects_symlinked_storage_directories() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let root_path = fs::canonicalize(root.path()).unwrap();
let redirected = root_path.join("logs");
symlink(target.path(), &redirected).unwrap();
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
}
#[cfg(unix)]
#[test]
fn rejects_dangling_symlinked_storage_directories() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
let root_path = fs::canonicalize(root.path()).unwrap();
let redirected = root_path.join("logs");
symlink(root_path.join("missing-target"), &redirected).unwrap();
assert!(canonicalize_storage_path(Path::new(&redirected)).is_err());
}
} }
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin"; import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme"; import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, 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, }; export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, 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, };
+1 -1
View File
@@ -1258,7 +1258,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
<p className="text-xs text-text-secondary m-0 mt-0.5"> <p className="text-xs text-text-secondary m-0 mt-0.5">
{platform.portable {platform.portable
? 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.' ? 'Your pairing token is stored with this portable Firelink folder and will persist when the folder is moved. Treat the folder as sensitive.'
: 'Your pairing token is persisted in Firelink settings and will persist across restarts.'} : 'Your pairing token is stored in the system credential store and will persist across restarts.'}
</p> </p>
</div> </div>
</div> </div>
+1
View File
@@ -54,6 +54,7 @@ type CommandMap = {
set_extension_pairing_token: { args: { token: string }; result: void }; set_extension_pairing_token: { args: { token: string }; result: void };
get_extension_server_port: { args: undefined; result: number | null }; get_extension_server_port: { args: undefined; result: number | null };
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration }; hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
grant_keychain_access: { args: undefined; result: PairingTokenHydration }; grant_keychain_access: { args: undefined; result: PairingTokenHydration };
acknowledge_pairing_token_change: { args: undefined; result: void }; acknowledge_pairing_token_change: { args: undefined; result: void };
set_extension_frontend_ready: { args: { ready: boolean }; result: void }; set_extension_frontend_ready: { args: { ready: boolean }; result: void };
+13 -38
View File
@@ -102,8 +102,6 @@ const tauriStorage: StateStorage = {
* explicit exception: its pairing token is persisted with the portable folder * explicit exception: its pairing token is persisted with the portable folder
* so extension pairing follows that folder. * so extension pairing follows that folder.
*/ */
const PAIRING_TOKEN_KEYCHAIN_ID = 'extension-pairing-token';
export type { export type {
ActiveView, ActiveView,
AppFontSize, AppFontSize,
@@ -207,32 +205,6 @@ export interface SettingsState {
dismissKeychainPrompt: () => void; dismissKeychainPrompt: () => void;
} }
const generateSecureToken = () => {
try {
const cryptoObj = typeof window !== 'undefined'
? (window as Window & { msCrypto?: Crypto }).crypto
|| (window as Window & { msCrypto?: Crypto }).msCrypto
: null;
if (cryptoObj && cryptoObj.getRandomValues) {
const arr = new Uint8Array(24);
cryptoObj.getRandomValues(arr);
let binary = '';
for (let i = 0; i < arr.byteLength; i++) {
binary += String.fromCharCode(arr[i]);
}
return btoa(binary);
}
} catch (e) {
console.warn("Secure token generation failed, falling back to random characters", e);
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let token = '';
for (let i = 0; i < 32; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
};
export const useSettingsStore = create<SettingsState>()( export const useSettingsStore = create<SettingsState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
@@ -390,14 +362,20 @@ export const useSettingsStore = create<SettingsState>()(
siteLogins: state.siteLogins.filter((login) => login.id !== id) siteLogins: state.siteLogins.filter((login) => login.id !== id)
})), })),
regeneratePairingToken: async () => { regeneratePairingToken: async () => {
const token = generateSecureToken(); const result = await invoke('regenerate_pairing_token');
await invoke('set_keychain_password', { id: PAIRING_TOKEN_KEYCHAIN_ID, password: token }); if (!result.persistent) {
set({ extensionPairingToken: token }); throw new Error(result.error || 'Credential store access is unavailable.');
}
set({
extensionPairingToken: result.token,
isPairingTokenPersistent: true,
showKeychainModal: false
});
}, },
hydratePairingToken: async () => { hydratePairingToken: async () => {
// Always use the safe hydration path that never touches the OS keychain // The backend migrates legacy settings copies and reads the token from
// on its own. The modal will be shown when needed and only the explicit // the credential store after the app state is ready to receive it.
// "Grant Access" action (→ grant_keychain_access) triggers the OS prompt. // Portable mode remains the explicit folder-contained exception.
const result = await invoke('hydrate_extension_pairing_token'); const result = await invoke('hydrate_extension_pairing_token');
set({ set({
extensionPairingToken: result.token, extensionPairingToken: result.token,
@@ -486,7 +464,6 @@ export const useSettingsStore = create<SettingsState>()(
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
mediaCookieSource: state.mediaCookieSource, mediaCookieSource: state.mediaCookieSource,
siteLogins: state.siteLogins, siteLogins: state.siteLogins,
extensionPairingToken: state.extensionPairingToken,
keychainAccessGranted: state.keychainAccessGranted, keychainAccessGranted: state.keychainAccessGranted,
keychainPromptDismissed: state.keychainPromptDismissed, keychainPromptDismissed: state.keychainPromptDismissed,
autoCheckUpdates: state.autoCheckUpdates autoCheckUpdates: state.autoCheckUpdates
@@ -500,6 +477,7 @@ export const useSettingsStore = create<SettingsState>()(
...currentState, ...currentState,
...persisted, ...persisted,
...locations, ...locations,
extensionPairingToken: currentState.extensionPairingToken,
theme: isAllowedSetting(THEME_VALUES, persisted.theme) theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme ? persisted.theme
: currentState.theme, : currentState.theme,
@@ -518,9 +496,6 @@ export const useSettingsStore = create<SettingsState>()(
activeSettingsTab: isAllowedSetting(SETTINGS_TAB_VALUES, persisted.activeSettingsTab) activeSettingsTab: isAllowedSetting(SETTINGS_TAB_VALUES, persisted.activeSettingsTab)
? persisted.activeSettingsTab ? persisted.activeSettingsTab
: currentState.activeSettingsTab, : currentState.activeSettingsTab,
extensionPairingToken: typeof persisted.extensionPairingToken === 'string'
? persisted.extensionPairingToken
: currentState.extensionPairingToken,
showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications), showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications),
playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound), playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound),
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge), showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),