diff --git a/README.md b/README.md index 5bb8e97..3670c97 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ encrypted file. There is no account and no service behind it: it works entirely offline, and if you want two PCs in step you run the sync server yourself, on your own hardware. -**Version 0.8.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or +**Version 0.9.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or newer, or runs from source. It has so far been tested in CI only, on a virtual display, not yet on a real desktop. @@ -19,8 +19,8 @@ virtual display, not yet on a real desktop. | [PassMan-Server-Linux](https://github.com/usermaneGermane/PassMan-Server-Linux) | the sync server for Linux, run by systemd | **Same vaults as on Windows.** The vault file, the sync protocol and the -features match PassMan for Windows 2.10.0, including its black-and-green -window and dialogs and 2FA codes (see the version table under *What is different from +features match PassMan for Windows 2.11.0, including its black-and-green +window and dialogs, 2FA codes and sync on unlock (see the version table under *What is different from Windows*). A vault moves between a Windows PC and a Linux PC unchanged, and both sync with either server. @@ -121,7 +121,7 @@ screen if you sync. from a copy run from source — copy the `vault` folder into `~/.local/share/passman` yourself. -Everything else — syncing, the archive, revision history, 2FA codes, +Everything else — syncing (and sync on unlock), the archive, revision history, 2FA codes, importing from other password managers, and the security notes and limits — works and reads exactly as in [PassMan for Windows's README](https://github.com/usermaneGermane/PassMan-Client-Windows#syncing-two-pcs). @@ -137,6 +137,7 @@ Where that README names a Windows version, read the Linux one beside it: | 2.8.0 | 0.6.0 | locking no longer clears a copied password | | 2.9.0 | 0.7.0 | the dialogs redesigned to match | | 2.10.0 | 0.8.0 | 2FA codes, behind the master password | +| 2.11.0 | 0.9.0 | sync on unlock, a setting that is off by default | (0.2.0 added the `.deb` package, which has no Windows counterpart.) diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 96cc015..40f52d7 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -455,6 +455,27 @@ def set_autolock(choice): lambda: (top.destroy(), app.sync_settings_dialog()), width=150).pack() + if configured: + def set_on_unlock(): + on = on_unlock.get() == 1 + sync_client.set_sync_on_unlock(app.vault, on) + if app._save(): + app.toast("This vault now syncs each time you unlock it" if on + else "Sync on unlock is off — press Sync yourself") + + on_unlock = ctk.CTkSwitch( + setting_row(syn, "Sync when I unlock", + "Off: nothing is sent until you press Sync. On: " + "PassMan syncs right after you unlock; a conflict " + "still waits for your choice.", hint), + text="", width=46, switch_width=42, switch_height=22, + progress_color=GREEN, button_color=TEXT, + button_hover_color=TEXT, fg_color=BORDER, + command=set_on_unlock) + on_unlock.pack() + if sync_client.sync_on_unlock(app.vault): + on_unlock.select() + # ---- archive ---- arc = section(wrap, "Archive") diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index df92180..74e8c70 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -22,6 +22,7 @@ import dialogs import history_ui import lockout +import sync_client import sync_ui import totp from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG, @@ -114,6 +115,9 @@ def __init__(self, root): self._last_activity = time.monotonic() self._autolock_seconds = appconfig.DEFAULT_AUTOLOCK_MINUTES * 60 self._syncing = False + # Counts unlocks and locks, so a sync that finishes after its + # vault was locked can tell (sync_ui._still_open). + self._session = 0 # The entry shown in the details pane, by id, so it survives the list # being rebuilt after an edit, a revert or a sync. self._selected_id = None @@ -589,6 +593,7 @@ def unlock_vault(self): return lockout.reset(username) + self._session += 1 self.vault = loaded self.master_password = master_password self.current_user = username @@ -608,6 +613,26 @@ def unlock_vault(self): self.toast(f"Deleted {purged} archived " f"entr{'y' if purged == 1 else 'ies'} older than " f"{archive_purge_days(self.vault)} days", "warn") + self._sync_after_unlock() + + def _sync_after_unlock(self): + """ + Settings -> Sync -> "Sync when I unlock", off by default. Started a + moment after the main window is up, so the window draws first; a + server that cannot be reached is a warning, not an error -- the vault + is open and usable either way. + """ + if not sync_client.sync_on_unlock(self.vault): + return + session = self._session + + def go(): + if self._session == session and self.vault is not None: + self.sync_now(offline_hint="Not synced — the server can't be " + "reached. Press Sync once you " + "are connected.") + + self.root.after(400, go) # ================================================================== @@ -1222,7 +1247,13 @@ def sync_now(self, **options): def lock_vault(self, auto=False): self._cancel_idle_timer() self._cancel_totp() - if self.master_password and self.current_user: + self._session += 1 + # Every change is saved as it is made, so this save is only a safety + # net -- and while a sync runs it would be a hazard: it could land + # after the sync wrote the merged vault and put the pre-merge copy + # back on disk, which the next sync would read as edits undoing the + # other PC's changes. + if self.master_password and self.current_user and not self._syncing: self._save() # A copied password outlives the lock: the usual auto-clear timer # keeps running, so it can still be pasted until that fires. diff --git a/sourceFiles/sync_client.py b/sourceFiles/sync_client.py index 6fe1173..9f6cb80 100644 --- a/sourceFiles/sync_client.py +++ b/sourceFiles/sync_client.py @@ -169,6 +169,29 @@ def is_configured(vault_data) -> bool: return bool(settings.get("url") and settings.get("token")) +#: The switch in Settings -> Sync. Off unless the user turns it on (owner, +#: 2026-11-06): pressing Sync is deliberate, and a conflict dialog nobody +#: asked for is worse than a button. +ON_UNLOCK_KEY = "on_unlock" + + +def sync_on_unlock(vault_data) -> bool: + """Whether this vault syncs by itself right after it is unlocked.""" + return (is_configured(vault_data) + and get_settings(vault_data).get(ON_UNLOCK_KEY) is True) + + +def set_sync_on_unlock(vault_data: dict, on: bool) -> None: + """ + Kept with the other sync settings, so it is per vault and per PC: a + merge keeps this PC's own settings (``sync.merged_vault``), and a vault + restored onto a new PC starts with it off. + """ + settings = get_settings(vault_data) + settings[ON_UNLOCK_KEY] = bool(on) + vault_data[SETTINGS_KEY] = settings + + # ============================== # Transport # ============================== diff --git a/sourceFiles/sync_ui.py b/sourceFiles/sync_ui.py index abd41ec..c07a98f 100644 --- a/sourceFiles/sync_ui.py +++ b/sourceFiles/sync_ui.py @@ -63,7 +63,26 @@ def run_sync(app, server_password=None, drop_history=False, offline_hint=""): app.toast("Syncing…") _sync_step(app, {"retry_allowed": True, "server_password": server_password, "drop_history": drop_history, "from_prompt": False, - "offline_hint": offline_hint}) + "offline_hint": offline_hint, + "session": getattr(app, "_session", 0)}) + + +def _still_open(app, attempt) -> bool: + """ + Whether the vault this sync started on is still the one open. + + The network work takes seconds, and the vault can be locked (by hand or + after inactivity) in the meantime -- most likely with sync on unlock, + which starts one the moment the vault opens. A late result must then do + nothing: putting the merged vault back into ``app.vault`` would hold it in + memory behind the login screen, or hand it to another vault unlocked + since. The merge itself is already saved and pushed by then, so the next + unlock reads it from disk. + """ + if app.vault is not None and getattr(app, "_session", 0) == attempt["session"]: + return True + app._syncing = False + return False def _sync_step(app, attempt): @@ -81,6 +100,8 @@ def work(): return session, session.prepare(app.vault, attempt["server_password"]) def done(payload): + if not _still_open(app, attempt): + return session, result = payload if not session.has_work(): return _finish(app, "Already up to date") @@ -102,6 +123,8 @@ def work(): return session.commit(app.vault, choices, attempt["drop_history"]) def done(merged): + if not _still_open(app, attempt): + return app.vault = merged summary = f"Synced — {result.summary()}" if session.password_changed: @@ -136,6 +159,8 @@ def _sync_failed(app, error, attempt): not yet the other. Ask for the server's password and go again; the session works out from there which way to catch up. """ + if not _still_open(app, attempt): + return if isinstance(error, sync_client.Conflict409) and attempt["retry_allowed"]: app.toast("The server moved on — merging again") return _sync_step(app, dict(attempt, retry_allowed=False)) @@ -734,6 +759,16 @@ def save_offline(): # ========================================================================== # First run on a second PC # ========================================================================== +def _settings_for_restored(fetched, url, token): + """ + The sync settings a vault restored onto this PC starts with: the address + and token just typed, and sync on unlock off -- the vault carries the + other PC's choice, and a new PC decides for itself. + """ + sync_client.set_settings(fetched, url, token) + sync_client.set_sync_on_unlock(fetched, False) + + def restore_from_server_dialog(app): """ Pull a vault down onto a PC that has none. @@ -791,7 +826,7 @@ def work(): if fetched is None: raise sync_client.SyncError( f"The server holds no vault for \u201c{username}\u201d.") - sync_client.set_settings(fetched, url, token) + _settings_for_restored(fetched, url, token) vault.create_new_user(username, password) vault.save_vault(password, fetched, username) # Both sides agree right now, so this download is the base for diff --git a/sourceFiles/tests/test_clipboard_lock.py b/sourceFiles/tests/test_clipboard_lock.py index 6522b5e..522e15b 100644 --- a/sourceFiles/tests/test_clipboard_lock.py +++ b/sourceFiles/tests/test_clipboard_lock.py @@ -32,6 +32,8 @@ def __init__(self): self._clip_job = "clear-job" self.cleared = [] self.totp_hidden = False + self._session = 0 + self._syncing = False def _cancel_idle_timer(self): pass diff --git a/sourceFiles/tests/test_sync_on_unlock.py b/sourceFiles/tests/test_sync_on_unlock.py new file mode 100644 index 0000000..c6b888c --- /dev/null +++ b/sourceFiles/tests/test_sync_on_unlock.py @@ -0,0 +1,281 @@ +""" +Sync on unlock: a setting in Settings -> Sync, off by default (owner, +2026-11-06), and the guard that keeps a late sync result away from a vault +that was locked in the meantime. + +No display needed: the app and its Tk root are stubbed. +""" + +import queue + +import pytest + +import sync +import sync_client + +sync_ui = pytest.importorskip("sync_ui", + reason="needs tkinter (bundled with Python)") +gui = pytest.importorskip("gui", reason="needs tkinter (bundled with Python)") + + +def configured(**extra): + v = {"accounts": []} + sync_client.set_settings(v, "http://127.0.0.1:8787", "token") + v["sync"].update(extra) + return v + + +# --------------------------------------------------------------------------- +# The setting +# --------------------------------------------------------------------------- +def test_off_by_default(): + assert sync_client.sync_on_unlock(configured()) is False + + +def test_on_once_switched_on_and_off_again(): + v = configured() + sync_client.set_sync_on_unlock(v, True) + assert sync_client.sync_on_unlock(v) is True + sync_client.set_sync_on_unlock(v, False) + assert sync_client.sync_on_unlock(v) is False + + +def test_never_on_without_a_server(): + """Switched on, then the settings went: nothing to sync with.""" + v = {"accounts": [], "sync": {"on_unlock": True}} + assert sync_client.sync_on_unlock(v) is False + assert sync_client.sync_on_unlock(None) is False + + +@pytest.mark.parametrize("value", ["yes", 1, "true", None]) +def test_only_a_real_true_counts(value): + assert sync_client.sync_on_unlock(configured(on_unlock=value)) is False + + +def test_editing_the_address_keeps_the_switch(): + v = configured() + sync_client.set_sync_on_unlock(v, True) + sync_client.set_settings(v, "http://10.0.0.9:8787", "new-token") + assert sync_client.sync_on_unlock(v) is True + + +def test_a_merge_keeps_this_pcs_own_choice(): + """The other PC's copy says on; this PC said off. This PC's stays.""" + local = configured() + merged = sync.merged_vault(local, [{"id": "a"}]) + assert sync_client.sync_on_unlock(merged) is False + sync_client.set_sync_on_unlock(local, True) + assert sync_client.sync_on_unlock(sync.merged_vault(local, [])) is True + + +# --------------------------------------------------------------------------- +# A sync that outlives its vault +# --------------------------------------------------------------------------- +class FakeRoot: + def __init__(self): + self.scheduled = queue.Queue() + + def after(self, _delay, callback): + self.scheduled.put(callback) + + def run_next(self, timeout=5): + self.scheduled.get(timeout=timeout)() + + +class FakeApp: + def __init__(self): + self.root = FakeRoot() + self.toasts = [] + self.vault = configured() + self.current_user = "alex" + self.master_password = "pw" + self._session = 1 + self._syncing = False + self.refreshed = 0 + + def toast(self, message, kind="info"): + self.toasts.append((message, kind)) + + def refresh_account_list(self): + self.refreshed += 1 + + def lock(self): + """What lock_vault does to the state sync looks at.""" + self._session += 1 + self.vault = self.master_password = self.current_user = None + + +class FakeResult: + conflicts = [] + + def summary(self): + return "1 change" + + +class FakeSession: + password_changed = upgrade = base_ignored = False + committed = [] + + def __init__(self, *_args): + pass + + def prepare(self, _vault, _password): + return FakeResult() + + def has_work(self): + return True + + def commit(self, _vault, _choices, _drop): + FakeSession.committed.append(True) + return {"accounts": [{"id": "merged"}], "sync": {}} + + +@pytest.fixture +def stub_network(monkeypatch): + FakeSession.committed = [] + monkeypatch.setattr(sync_client, "client_for", lambda v: object()) + monkeypatch.setattr(sync_client, "SyncSession", FakeSession) + + +def test_an_ordinary_sync_still_lands(stub_network): + app = FakeApp() + sync_ui.run_sync(app) + app.root.run_next() # prepare done -> commit + app.root.run_next() # commit done + assert app.vault == {"accounts": [{"id": "merged"}], "sync": {}} + assert app._syncing is False and app.refreshed == 1 + assert app.toasts[-1] == ("Synced — 1 change", "info") + + +def test_locked_before_the_server_answered(stub_network): + app = FakeApp() + sync_ui.run_sync(app) + app.lock() + app.root.run_next() # the late prepare result + assert app.vault is None + assert FakeSession.committed == [] # nothing pushed for a locked vault + assert app._syncing is False # the next unlock can sync again + assert app.root.scheduled.empty() + + +def test_locked_while_the_merge_was_being_pushed(stub_network): + """The merged vault must not come back into memory behind the login + screen -- it is on disk already.""" + app = FakeApp() + sync_ui.run_sync(app) + app.root.run_next() # prepare done -> commit started + app.lock() + app.root.run_next() # the late commit result + assert app.vault is None and app.refreshed == 0 + assert app._syncing is False + + +def test_locked_and_another_vault_unlocked_meanwhile(stub_network): + app = FakeApp() + sync_ui.run_sync(app) + app.root.run_next() + app.lock() + other = {"accounts": [{"id": "other-vault"}]} + app.vault, app.current_user, app.master_password = other, "sam", "pw2" + app._session += 1 + app.root.run_next() + assert app.vault is other # not replaced by alex's merge + + +def test_a_late_failure_after_lock_asks_nothing(monkeypatch): + app = FakeApp() + asked = [] + monkeypatch.setattr(sync_ui, "password_mismatch_dialog", + lambda *a, **k: asked.append(a)) + attempt = {"retry_allowed": True, "server_password": None, + "drop_history": False, "from_prompt": False, + "offline_hint": "", "session": app._session} + app._syncing = True + app.lock() + sync_ui._sync_failed(app, sync_client.PasswordMismatch.__new__( + sync_client.PasswordMismatch), attempt) + assert asked == [] and app.toasts == [] and app._syncing is False + + +# --------------------------------------------------------------------------- +# The window: after unlock, and lock +# --------------------------------------------------------------------------- +class FakeWindow: + _sync_after_unlock = gui.PasswordManagerGUI._sync_after_unlock + lock_vault = gui.PasswordManagerGUI.lock_vault + + def __init__(self, on): + self.root = FakeRoot() + self.vault = configured(on_unlock=on) + self.master_password = "pw" + self.current_user = "alex" + self._session = 1 + self._syncing = False + self.syncs = [] + self.saves = 0 + self._clip_text = None + + def sync_now(self, **options): + self.syncs.append(options) + + def _save(self, rekey=False): + self.saves += 1 + return True + + def _cancel_idle_timer(self): + pass + + def _cancel_totp(self): + pass + + def show_login_screen(self): + pass + + def toast(self, *a, **k): + pass + + +def test_switched_off_unlock_does_not_sync(): + w = FakeWindow(on=False) + w._sync_after_unlock() + assert w.root.scheduled.empty() and w.syncs == [] + + +def test_switched_on_unlock_syncs_once_the_window_is_up(): + w = FakeWindow(on=True) + w._sync_after_unlock() + assert w.syncs == [] # not inline: the window draws first + w.root.run_next() + assert len(w.syncs) == 1 + assert "can't be reached" in w.syncs[0]["offline_hint"] + + +def test_locked_before_the_delayed_start_means_no_sync(): + w = FakeWindow(on=True) + w._sync_after_unlock() + w.lock_vault() + w.root.run_next() + assert w.syncs == [] + + +def test_lock_saves_normally(): + w = FakeWindow(on=False) + w.lock_vault() + assert w.saves == 1 + + +def test_lock_does_not_save_over_a_running_sync(): + """Its save could land after the sync wrote the merged vault and put the + pre-merge copy back on disk.""" + w = FakeWindow(on=True) + w._syncing = True + w.lock_vault() + assert w.saves == 0 and w.vault is None + + +def test_a_vault_restored_onto_a_new_pc_starts_with_it_off(): + fetched = configured() + sync_client.set_sync_on_unlock(fetched, True) # the other PC's choice + sync_ui._settings_for_restored(fetched, "http://10.0.0.9:8787", "t2") + assert sync_client.sync_on_unlock(fetched) is False + assert sync_client.get_settings(fetched)["url"] == "http://10.0.0.9:8787" diff --git a/sourceFiles/version.py b/sourceFiles/version.py index 1380884..d080dcf 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -6,7 +6,7 @@ format changes. """ -__version__ = "0.8.0" +__version__ = "0.9.0" #: Container version PassMan writes today (see crypto_utils.MAGIC). VAULT_FORMAT = "PMV2"