mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-10 11:37:21 +00:00
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:
+364
-204
@@ -8,11 +8,20 @@ const DATABASE_NAME: &str = "firelink.sqlite";
|
||||
const LEGACY_STORE_NAME: &str = "store.bin";
|
||||
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
|
||||
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";
|
||||
const KEYCHAIN_SERVICE: &str = "com.firelink.app";
|
||||
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 {
|
||||
conn: Mutex<Connection>,
|
||||
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)]
|
||||
struct LegacyData {
|
||||
settings: Option<String>,
|
||||
@@ -50,15 +45,19 @@ struct LegacyData {
|
||||
}
|
||||
|
||||
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)]
|
||||
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)
|
||||
.map_err(|error| format!("failed to create app data directory: {error}"))?;
|
||||
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)?;
|
||||
|
||||
// We no longer touch the keychain on backend startup.
|
||||
// Legacy imports will safely preserve any pairing token in the JSON payload.
|
||||
// The frontend will manually trigger migration to the keychain via IPC if access is granted.
|
||||
import_legacy_data(&mut connection, app_data_dir, false, portable)?;
|
||||
import_legacy_data(
|
||||
&mut connection,
|
||||
app_data_dir,
|
||||
portable,
|
||||
migrate_legacy_keychain,
|
||||
)?;
|
||||
if portable {
|
||||
sanitize_persisted_downloads(&mut connection)?;
|
||||
}
|
||||
@@ -186,8 +187,8 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(),
|
||||
fn import_legacy_data(
|
||||
connection: &mut Connection,
|
||||
app_data_dir: &Path,
|
||||
migrate_keychain: bool,
|
||||
portable: bool,
|
||||
migrate_keychain: bool,
|
||||
) -> Result<(), String> {
|
||||
let legacy_app_dir = app_data_dir
|
||||
.parent()
|
||||
@@ -206,35 +207,49 @@ fn import_legacy_data(
|
||||
}
|
||||
let marker = format!("legacy-import:{}", candidate.to_string_lossy());
|
||||
if metadata_exists(connection, &marker)? {
|
||||
if portable {
|
||||
sanitize_legacy_source(&candidate)?;
|
||||
}
|
||||
sanitize_legacy_source(&candidate, !portable)?;
|
||||
continue;
|
||||
}
|
||||
if !portable {
|
||||
backup_file(&candidate, "legacy-import")?;
|
||||
}
|
||||
let backup = if !portable {
|
||||
Some(backup_file(&candidate, "legacy-import")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut legacy = if candidate
|
||||
.file_name()
|
||||
.is_some_and(|name| name == DATABASE_NAME)
|
||||
{
|
||||
read_legacy_database(&candidate, migrate_keychain)?
|
||||
read_legacy_database(&candidate, !portable)?
|
||||
} else {
|
||||
read_legacy_store(&candidate, migrate_keychain)?
|
||||
read_legacy_store(&candidate, !portable)?
|
||||
};
|
||||
let mut migration_complete = true;
|
||||
if migrate_keychain
|
||||
&& get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).is_err()
|
||||
&& legacy.pairing_token.is_some()
|
||||
{
|
||||
if let Some(token) = legacy.pairing_token.as_deref() {
|
||||
if let Err(error) = set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, token) {
|
||||
log::warn!(
|
||||
"Legacy pairing token could not be migrated yet; settings import will retry: {}",
|
||||
error
|
||||
);
|
||||
legacy.settings = None;
|
||||
migration_complete = false;
|
||||
let mut pending_pairing_token = None;
|
||||
if !portable {
|
||||
if let Some(token) = legacy.pairing_token.take() {
|
||||
let migrated = if migrate_keychain {
|
||||
let keychain_has_token = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID)
|
||||
.ok()
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if !keychain_has_token {
|
||||
if let Err(error) =
|
||||
set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token)
|
||||
{
|
||||
log::warn!(
|
||||
"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)?;
|
||||
}
|
||||
merge_legacy_data(connection, legacy)?;
|
||||
if migration_complete {
|
||||
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}"))?;
|
||||
if portable {
|
||||
sanitize_legacy_source(&candidate)?;
|
||||
if let Some(token) = pending_pairing_token {
|
||||
if load_pairing_token_from_settings(connection)?.is_none() {
|
||||
save_pairing_token_to_settings(connection, &token, true)?;
|
||||
}
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
fn sanitize_legacy_source(path: &Path) -> Result<(), String> {
|
||||
if path
|
||||
.file_name()
|
||||
.is_some_and(|name| name == DATABASE_NAME)
|
||||
{
|
||||
fn sanitize_legacy_source(path: &Path, remove_pairing_token: bool) -> Result<(), String> {
|
||||
if is_database_path(path) {
|
||||
let mut connection = Connection::open(path).map_err(|error| {
|
||||
format!(
|
||||
"failed to open legacy database '{}' for portable sanitization: {error}",
|
||||
"failed to open legacy database '{}' for sanitization: {error}",
|
||||
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")? {
|
||||
return sanitize_persisted_downloads(&mut connection);
|
||||
sanitize_persisted_downloads(&mut connection)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let text = fs::read_to_string(path).map_err(|error| {
|
||||
format!(
|
||||
"failed to read legacy store '{}' for portable sanitization: {error}",
|
||||
"failed to read legacy store '{}' for sanitization: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let mut document: Value = serde_json::from_str(&text).map_err(|error| {
|
||||
format!(
|
||||
"failed to decode legacy store '{}' for portable sanitization: {error}",
|
||||
"failed to decode legacy store '{}' for sanitization: {error}",
|
||||
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")
|
||||
.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| {
|
||||
format!(
|
||||
"failed to encode legacy store '{}' for portable sanitization: {error}",
|
||||
"failed to encode legacy store '{}' for sanitization: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
if sanitized == text {
|
||||
if sanitized == original {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -449,34 +502,6 @@ fn read_legacy_database(path: &Path, force_migrate: bool) -> Result<LegacyData,
|
||||
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(
|
||||
value: &Value,
|
||||
force_migrate: bool,
|
||||
@@ -998,63 +1023,10 @@ pub fn consume_notice(connection: &Connection, key: &str) -> Result<(), String>
|
||||
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> {
|
||||
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 {
|
||||
format!(
|
||||
"{}{}",
|
||||
@@ -1063,10 +1035,9 @@ pub(crate) 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.
|
||||
/// Read the extension pairing token from portable settings JSON.
|
||||
/// Standard-mode settings are sanitized so this field is never a credential
|
||||
/// source outside the explicit portable-storage exception.
|
||||
pub fn load_pairing_token_from_settings(connection: &Connection) -> Result<Option<String>, String> {
|
||||
let Some(settings_json) = load_settings(connection)? else {
|
||||
return Ok(None);
|
||||
@@ -1082,8 +1053,8 @@ pub fn load_pairing_token_from_settings(connection: &Connection) -> Result<Optio
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Write (or update) the extension pairing token inside the persisted settings
|
||||
/// JSON document. Keeps all other settings fields intact.
|
||||
/// Write (or update) the extension pairing token inside portable settings JSON.
|
||||
/// Keeps all other settings fields intact.
|
||||
pub fn save_pairing_token_to_settings(
|
||||
connection: &Connection,
|
||||
token: &str,
|
||||
@@ -1092,7 +1063,7 @@ pub fn save_pairing_token_to_settings(
|
||||
let Some(settings_json) = load_settings(connection)? else {
|
||||
if !initialize_if_missing {
|
||||
// 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.
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1125,6 +1096,111 @@ pub fn save_pairing_token_to_settings(
|
||||
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> {
|
||||
if keyring_core::get_default_store().is_some() {
|
||||
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> {
|
||||
let _guard = lock_keyring_operations()?;
|
||||
fn set_keychain_password_unlocked(id: &str, password: &str) -> Result<(), String> {
|
||||
let entry = keychain_entry(id)?;
|
||||
|
||||
#[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())
|
||||
}
|
||||
|
||||
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()?;
|
||||
set_keychain_password_unlocked(id, password)
|
||||
}
|
||||
|
||||
fn get_keychain_password_unlocked(id: &str) -> Result<String, String> {
|
||||
let entry = keychain_entry(id)?;
|
||||
match entry.get_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()?;
|
||||
get_keychain_password_unlocked(id)
|
||||
}
|
||||
|
||||
fn delete_keychain_password_unlocked(id: &str) -> Result<(), String> {
|
||||
let entry = keychain_entry(id)?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring_core::Error::NoEntry) => {}
|
||||
@@ -1290,6 +1373,11 @@ pub fn delete_keychain_password(id: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_keychain_password(id: &str) -> Result<(), String> {
|
||||
let _guard = lock_keyring_operations()?;
|
||||
delete_keychain_password_unlocked(id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1362,7 +1450,7 @@ mod tests {
|
||||
.unwrap();
|
||||
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 saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||
assert!(saved.get("password").is_none());
|
||||
@@ -1375,7 +1463,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 current = root.path().join("com.nimbold.firelink");
|
||||
let legacy = root.path().join(LEGACY_BUNDLE_IDENTIFIER);
|
||||
@@ -1415,12 +1503,22 @@ mod tests {
|
||||
let settings = load_settings(&connection).unwrap().unwrap();
|
||||
assert!(settings.contains("\"theme\":\"dark\""));
|
||||
assert!(settings.contains("legacy-secret"));
|
||||
assert!(fs::read_dir(&legacy).unwrap().flatten().any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("store.bin.backup-legacy-import-")
|
||||
}));
|
||||
let backup = fs::read_dir(&legacy)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.find(|entry| {
|
||||
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]
|
||||
@@ -1442,7 +1540,7 @@ mod tests {
|
||||
});
|
||||
fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap();
|
||||
|
||||
let state = init_at_path_internal(¤t, true).unwrap();
|
||||
let state = init_at_path_internal(¤t, true, true).unwrap();
|
||||
let connection = state.lock().unwrap();
|
||||
let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||
assert!(saved.get("password").is_none());
|
||||
@@ -1479,7 +1577,7 @@ mod tests {
|
||||
);
|
||||
INSERT INTO settings VALUES (
|
||||
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();
|
||||
assert_eq!(load_downloads(&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()
|
||||
.contains("\"nord\""));
|
||||
assert!(fs::read_dir(&legacy).unwrap().flatten().any(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("firelink.sqlite.backup-legacy-import-")
|
||||
}));
|
||||
.flatten()
|
||||
.find(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.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]
|
||||
@@ -1571,6 +1683,75 @@ mod tests {
|
||||
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]
|
||||
fn portable_persistence_redacts_unparseable_download_urls() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -1608,7 +1789,7 @@ mod tests {
|
||||
drop(connection);
|
||||
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 saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap();
|
||||
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]
|
||||
fn migration_notice_is_persistent_until_acknowledged() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user