From d25613a6ec4d21a23fa51bd3cc59dd128fcb64a2 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Wed, 11 Nov 2026 08:55:15 +0200 Subject: [PATCH] Several vaults per account (phase 3, 1.0.0-alpha.5) An account had exactly one vault. It can now have more -- Home, Work, a project -- each with its own random key, kept wrapped to the account's public key. That is also how a vault's key will be handed to another member once vaults are shared (phase 4). - accounts.py: VaultFile (one vault: its files, key, name), and on Account: vault_ids, vault_file, create_vault, adopt_vault, rename_vault, delete_vault. The personal-vault methods delegate to it. - account_crypto.py: a vault's name sealed under the vault key, so the server lists vaults without learning their names. - account_sync.py: reconcile_vaults makes the list of vaults the same here and on the server, then every vault is synced on its own (AccountSync takes a vault id). A vault deleted on another device is KEPT here, marked and left out of the sync. Deleting a vault deletes it on the server first, so an unreachable server deletes nothing. - gui.py: a vault menu above the list (All vaults / Personal / ...), rows name their vault in the All view; _save writes only vaults that changed. - vaults_ui.py (new): the Vaults window -- new, rename, delete (asks for the master password), put back. - dialogs.py: a vault picker in Add an entry; import says which vault; the archive-purge setting applies to every vault; export follows the menu. sync_ui.py: the archive and the conflict window follow the vault. - Needs SecretSpace Server 2.0.0-alpha.2 for more than the personal vault; an older 2.0 server keeps syncing the personal one and the app says so. tests/test_account_vaults.py: 57 tests; 10 of 10 deliberately broken versions were caught. tests/sync_server/passman_server.py is the server 2.0.0-alpha.2 copy. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 16 +- sourceFiles/account_crypto.py | 43 + sourceFiles/account_sync.py | 192 +++- sourceFiles/account_sync_ui.py | 157 ++- sourceFiles/accounts.py | 311 +++++- sourceFiles/dialogs.py | 41 +- sourceFiles/gui.py | 256 ++++- sourceFiles/sync_ui.py | 13 +- .../tests/sync_server/passman_server.py | 153 ++- sourceFiles/tests/test_account_vaults.py | 918 ++++++++++++++++++ sourceFiles/vaults_ui.py | 198 ++++ sourceFiles/version.py | 2 +- 12 files changed, 2138 insertions(+), 162 deletions(-) create mode 100644 sourceFiles/tests/test_account_vaults.py create mode 100644 sourceFiles/vaults_ui.py diff --git a/README.md b/README.md index b7376ba..839d6f1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ package replaces it in one step (`Conflicts`/`Replaces: passman`), and your vaults stay in `~/.local/share/passman`, which SecretSpace keeps using — nothing is moved or renamed. The vault files are the same. -**`main` is 1.0.0-alpha.4, a SecretSpace 3 test version — not for real passwords yet.** +**`main` is 1.0.0-alpha.5, a SecretSpace 3 test version — not for real passwords yet.** For real use keep the [0.11.0 release](https://github.com/usermaneGermane/SecretSpace-Client-Linux/releases/tag/v0.11.0), which the rest of this README describes. It installs from a `.deb` on Ubuntu 22.04 or newer, or runs from source. It has so far been tested in CI only, @@ -31,20 +31,23 @@ on a virtual display, not yet on a real desktop. **Same vaults as on Windows.** The vault file, the sync protocol and the features match SecretSpace for Windows (see the version table under *What is different from Windows*): 0.11.0 = Windows 2.13.0, and this test version = -Windows 3.0.0-alpha.4. +Windows 3.0.0-alpha.5. A vault moves between a Windows PC and a Linux PC unchanged, and both sync with either server. ## SecretSpace 3 — the test version -1.0.0-alpha.4 is the same as [SecretSpace for Windows -3.0.0-alpha.4](https://github.com/usermaneGermane/SecretSpace-Client-Windows#secretspace-3--a-test-version-not-for-real-passwords-yet): +1.0.0-alpha.5 is the same as [SecretSpace for Windows +3.0.0-alpha.5](https://github.com/usermaneGermane/SecretSpace-Client-Windows#secretspace-3--a-test-version-not-for-real-passwords-yet): accounts with a Secret Key and a 12-word recovery phrase, Argon2id, and importing 2.x vaults with their history. It opens SecretSpace 3 accounts only, so do not install it over 0.11.0 — your vaults would not show up (they are not changed). Since alpha.3 it syncs through a SecretSpace Server 2.0 (*Settings → Sync → Set up…*) and signs in on another device with *Sign in -from a server*. On Linux: +from a server*. Since alpha.5 an account can have **several vaults** +(*Vaults* above the list): each with its own key, picked in the menu above +the list, and all covered by **Sync** — which needs SecretSpace Server +2.0.0-alpha.2 for more than the Personal vault. On Linux: - **The Secret Key is kept under your master password** (Argon2id, then AES-GCM), not by the system as Windows does with DPAPI. So a copy of the @@ -54,7 +57,7 @@ from a server*. On Linux: - The account folders are `700` and their files `600`, like the vault folder; a saved Emergency Kit is `600` too. - *Settings → Backup → Open folder* opens the folder with `xdg-open`. -- The package spells the version `1.0.0~alpha.4`, which apt sorts after +- The package spells the version `1.0.0~alpha.5`, which apt sorts after 0.9.0 and before 1.0.0. | File | What it is | @@ -187,6 +190,7 @@ Where that README names a Windows version, read the Linux one beside it: | 3.0.0-alpha.2 | 1.0.0-alpha.2 | renamed to SecretSpace (package `secretspace`, replaces `passman`) | | 3.0.0-alpha.3 | 1.0.0-alpha.3 | accounts sync through SecretSpace Server 2.0; sign in from a server | | 3.0.0-alpha.4 | 1.0.0-alpha.4 | a smoother look: smoothed corners, redrawn sign-in backdrop | +| 3.0.0-alpha.5 | 1.0.0-alpha.5 | several vaults per account; Sync covers them all (Server 2.0.0-alpha.2) | (0.2.0 added the `.deb` package, which has no Windows counterpart.) diff --git a/sourceFiles/account_crypto.py b/sourceFiles/account_crypto.py index a2189a8..5f5c6b5 100644 --- a/sourceFiles/account_crypto.py +++ b/sourceFiles/account_crypto.py @@ -406,6 +406,49 @@ def decrypt_vault(vault_key: bytes, blob: str, expect_vault_id: bytes = None) -> return json.loads(body) +# =========================================================================== +# A vault's name, as the server keeps it +# =========================================================================== +#: Longest vault name, in characters and in UTF-8 bytes (the server's limit). +VAULT_NAME_CHARS = 60 +VAULT_NAME_BYTES = 200 + + +def new_vault_key() -> bytes: + return os.urandom(KEY_LEN) + + +def _name_key(vault_key: bytes, vault_id: bytes) -> bytes: + # Its own key, so a name and the vault's entries never share one. + return _hkdf(vault_key, vault_id, b"PassMan3 vault name") + + +def seal_vault_name(vault_key: bytes, vault_id: bytes, name: str) -> str: + """ + ``name`` under the vault's key, for the server: it lists an account's + vaults without learning what they are called. Bound to the vault id, so + one vault's name cannot be shown as another's. + """ + raw = name.encode("utf-8") + if not raw or len(raw) > VAULT_NAME_BYTES: + raise CryptoError("A vault name must be 1 to 200 bytes long.") + nonce = os.urandom(NONCE_LEN) + return _b64(nonce + AESGCM(_name_key(vault_key, vault_id)).encrypt( + nonce, raw, vault_id)) + + +def open_vault_name(vault_key: bytes, vault_id: bytes, sealed: str) -> str: + raw = _unb64(sealed, "vault name") + if len(raw) < NONCE_LEN + 1 + 16: + raise CryptoError("The vault's name is damaged.") + try: + return AESGCM(_name_key(vault_key, vault_id)).decrypt( + raw[:NONCE_LEN], raw[NONCE_LEN:], vault_id).decode("utf-8") + except Exception: + raise CryptoError("The vault's name could not be read: wrong key, or " + "it is damaged.") from None + + # =========================================================================== # The 12-word recovery phrase (BIP39, English) # =========================================================================== diff --git a/sourceFiles/account_sync.py b/sourceFiles/account_sync.py index a890928..f7696f8 100644 --- a/sourceFiles/account_sync.py +++ b/sourceFiles/account_sync.py @@ -11,9 +11,11 @@ Three things live here: - :class:`AccountServer`, the HTTP calls, on sync_client's transport; -- :class:`AccountSync`, one sync of the personal vault in two halves, with - the same three-way merge, conflict choices and base rules as 2.x +- :class:`AccountSync`, one sync of one vault in two halves, with the same + three-way merge, conflict choices and base rules as 2.x (sync.merge_vaults, sync_client.base_matches); +- :func:`reconcile_vaults` (phase 3), which makes the account's list of + vaults the same here and on the server before they are synced; - the account lifecycle around them: :func:`connect` (an account made here goes onto a server), :func:`sign_in` (a new device, or one catching up with a password changed elsewhere) and :func:`change_password`. @@ -23,6 +25,7 @@ """ import base64 +import os import urllib.parse import account_crypto @@ -37,6 +40,10 @@ #: /health; a 1.x server answers without "api" and has only the 2.x one. API_VERSION = 2 +#: /health "features" entry of a server that keeps several vaults per +#: account (SecretSpace Server 2.0.0-alpha.2). +FEATURE_VAULTS = "vaults" + class LoginFailed(SyncError): """The server did not take this username + password + Secret Key.""" @@ -52,6 +59,7 @@ class AccountServer(SyncClient): def __init__(self, url: str, ca_file: str = "", timeout: int = sync_client.DEFAULT_TIMEOUT): super().__init__(url, "", ca_file, timeout) + self._features = None @staticmethod def _http_error(e) -> SyncError: @@ -74,6 +82,24 @@ def check_api(self) -> None: "it needs SecretSpace Server 2.0 or newer. (2.x " "vaults sync with it as before.)") + def has_vaults(self) -> bool: + """Can this server keep more than the personal vault? Asked once + per connection.""" + if self._features is None: + self._features = list(self.health().get("features") or []) + return FEATURE_VAULTS in self._features + + def create_vault(self, vault_id: str, sealed_name: str, wrapped_key: str) -> dict: + return self._request("POST", "/v2/vaults", body={ + "id": vault_id, "name": sealed_name, "wrapped_key": wrapped_key}) + + def rename_vault(self, vault_id: str, sealed_name: str) -> dict: + return self._request("PUT", f"/v2/vaults/{vault_id}/name", + body={"name": sealed_name}) + + def delete_vault(self, vault_id: str) -> None: + self._request("DELETE", f"/v2/vaults/{vault_id}") + def prelogin(self, username: str) -> dict: return self._request("GET", "/v2/prelogin/" + urllib.parse.quote( username, safe="")) @@ -202,9 +228,12 @@ class AccountSync: dance here -- the vault key does not change with the master password. """ - def __init__(self, account: accounts.Account, server: AccountServer = None): + def __init__(self, account: accounts.Account, server: AccountServer = None, + vault_id: str = None): self.account = account self.server = server + # The vault this sync is for: the personal one unless told otherwise. + self.file = account.vault_file(vault_id) self.revision = 0 self.result = None self.first_push = False @@ -213,7 +242,7 @@ def __init__(self, account: accounts.Account, server: AccountServer = None): def prepare(self, local_vault: dict): self.server = logged_in(self.account, self.server) - stored = self.server.get_vault(self.account.vault_id.hex()) + stored = self.server.get_vault(self.file.hex) self.revision = stored["revision"] self._fingerprint = sync_client.SyncSession.fingerprint(local_vault) blob = stored["blob"] @@ -222,7 +251,7 @@ def prepare(self, local_vault: dict): remote = {"accounts": []} else: try: - remote = self.account.decrypt(blob) + remote = self.file.decrypt(blob) except (CryptoError, ValueError) as e: raise SyncError(f"The vault on the server cannot be read: {e}") from e # The 2.x rules for the base (sync_client.SyncSession.prepare): none @@ -230,7 +259,7 @@ def prepare(self, local_vault: dict): self.base_ignored = False base = None if blob is not None: - base = self.account.load_base() + base = self.file.load_base() if base is not None and not sync_client.base_matches( base, self.server.url, self.revision): base, self.base_ignored = None, True @@ -251,12 +280,11 @@ def commit(self, local_vault: dict, choices=None) -> dict: merged_vault(local_vault, resolve(self.result, choices))) # Saved here first, then pushed: if the network fails in between, # this device holds the merge and the next sync pushes it. - self.account.save_vault(merged) + self.file.save(merged) if self.result.needs_push or self.first_push: self.revision = self.server.put_vault( - self.account.vault_id.hex(), self.account.encrypt(merged), - self.revision) - self.account.save_base(merged, sync_client.base_origin_for( + self.file.hex, self.file.encrypt(merged), self.revision) + self.file.save_base(merged, sync_client.base_origin_for( self.server.url, self.revision)) return merged @@ -265,6 +293,137 @@ def close(self) -> None: self.server.logout() +# --------------------------------------------------------------------------- +# The account's list of vaults, here and on the server +# --------------------------------------------------------------------------- +class VaultChanges: + """What :func:`reconcile_vaults` did, for the user to be told.""" + + def __init__(self): + self.added = [] # names: came from the server to this device + self.uploaded = [] # names: made here, now on the server too + self.gone = [] # names: deleted on the server, kept here + self.renamed = [] # (old, new): renamed on another device + self.server_too_old = False + + def __bool__(self): + return bool(self.added or self.uploaded or self.gone or self.renamed + or self.server_too_old) + + +def reconcile_vaults(account: accounts.Account, server: AccountServer) -> VaultChanges: + """ + Make the list of vaults the same on this device and on the server. + ``server`` is logged in. Vault *contents* are not touched here; the + caller syncs each vault afterwards. + + - A vault the server has and this device does not is added here (its + entries arrive with its first sync). + - A vault made here goes onto the server. + - A vault that was on the server and is no longer there was deleted from + another device. It is **kept** on this one, marked, and left out of the + sync: deleting someone's entries is their decision, not a sync's. + - A name changed here is told to the server; one changed elsewhere is + taken over. + + A server older than 2.0.0-alpha.2 keeps only the personal vault; the + others then simply stay on this device. + """ + changes = VaultChanges() + extra_here = [v for v in account.vault_ids() if v != account.personal_id] + listed = {v["id"]: v for v in server.me().get("vaults", []) + if v.get("kind") != "personal" and v.get("id") != account.personal_id} + if not server.has_vaults(): + changes.server_too_old = bool(extra_here) + return changes + + for vault_id in extra_here: + record = account.vault_record(vault_id) + file = account.vault_file(vault_id) + there = listed.get(vault_id) + if there is None: + if record.get("on_server"): + if not record.get("gone_from_server"): + account.mark_vault(vault_id, gone_from_server=True) + changes.gone.append(file.name) + continue + try: + server.create_vault(vault_id, file.sealed_name(), + record["wrapped_key"]) + except Conflict409: + raise SyncError(f"The server already has another vault with the " + f"id of “{file.name}”. Nothing was changed.") from None + account.mark_vault(vault_id, on_server=True, gone_from_server=False) + changes.uploaded.append(file.name) + continue + if not record.get("on_server") or record.get("gone_from_server"): + account.mark_vault(vault_id, on_server=True, gone_from_server=False) + if record.get("rename_pending"): + server.rename_vault(vault_id, file.sealed_name()) + account.mark_vault(vault_id, rename_pending=False) + continue + try: + name = accounts.clean_vault_name(file.open_name(there.get("name", ""))) + except (CryptoError, ValueError): + continue # an unreadable name changes nothing + if name != file.name: + try: + account.rename_vault(vault_id, name, pending=False) + changes.renamed.append((file.name, name)) + except ValueError: + pass # taken here: keep this device's name + + for vault_id, there in listed.items(): + if vault_id in extra_here: + continue + try: + file = account.adopt_vault(vault_id, there.get("wrapped_key", ""), + there.get("name", "")) + except (CryptoError, ValueError) as e: + raise SyncError(f"The server lists a vault this account cannot " + f"open ({e}). Nothing was changed.") from e + changes.added.append(file.name) + return changes + + +def syncable_vaults(account: accounts.Account, server: AccountServer) -> list: + """Ids of the vaults a sync covers: the personal one, and every other + that is on the server.""" + if not server.has_vaults(): + return [account.personal_id] + return [v for v in account.vault_ids() + if v == account.personal_id or ( + account.vault_record(v).get("on_server") + and not account.vault_record(v).get("gone_from_server"))] + + +def delete_vault(account: accounts.Account, vault_id: str) -> None: + """ + Delete a vault for good. On a server it is deleted there first: if the + server cannot be reached nothing is deleted anywhere, so a vault never + comes back from the server after being deleted here. + """ + record = account.vault_record(vault_id) + if account.server and record.get("on_server") \ + and not record.get("gone_from_server"): + server = logged_in(account) + try: + server.delete_vault(vault_id) + finally: + server.logout() + account.delete_vault(vault_id) + + +def put_vault_back(account: accounts.Account, vault_id: str) -> None: + """A vault deleted on the server and kept here goes onto the server + again with the next sync (the user's choice, in the Vaults window).""" + account.mark_vault(vault_id, on_server=False, gone_from_server=False) + try: + os.remove(account.vault_file(vault_id).base_path) # agreed with a vault that is gone + except OSError: + pass + + # --------------------------------------------------------------------------- # Signing in from a server # --------------------------------------------------------------------------- @@ -326,6 +485,19 @@ def sign_in(url: str, ca_file: str, username: str, password: str, if stored["blob"]: account.save_base(data, sync_client.base_origin_for( server.url, stored["revision"])) + # The account's other vaults come with it, so a new device opens with + # everything; a later sync merges whatever this device already had. + reconcile_vaults(account, server) + for vault_id in syncable_vaults(account, server): + file = account.vault_file(vault_id) + if file.personal or file.has_file: + continue + stored = server.get_vault(vault_id) + fetched = file.decrypt(stored["blob"]) if stored["blob"] else {"accounts": []} + file.save(fetched) + if stored["blob"]: + file.save_base(fetched, sync_client.base_origin_for( + server.url, stored["revision"])) server.logout() return account, data diff --git a/sourceFiles/account_sync_ui.py b/sourceFiles/account_sync_ui.py index 18d096b..43b6ebc 100644 --- a/sourceFiles/account_sync_ui.py +++ b/sourceFiles/account_sync_ui.py @@ -24,6 +24,11 @@ # The Sync button # ========================================================================== def run_sync(app): + """ + One press of Sync: log in once, make the list of vaults the same here + and on the server (account_sync.reconcile_vaults), then sync each vault + in turn -- its own merge, its own conflict questions. + """ if app.account is None or app.vault is None: return if not app.account.server: @@ -33,76 +38,128 @@ def run_sync(app): return app.toast("A sync is already running") app._syncing = True app.toast("Syncing…") - attempt = {"retry_allowed": True, "session": getattr(app, "_session", 0)} - _sync_step(app, attempt) - - -def _sync_step(app, attempt): account = app.account + attempt = {"session": getattr(app, "_session", 0)} def work(): - session = account_sync.AccountSync(account) + server = account_sync.logged_in(account) try: - result = session.prepare(app.vault) + changes = account_sync.reconcile_vaults(account, server) + return server, changes, account_sync.syncable_vaults(account, server) except BaseException: - session.close() + server.logout() raise - return session, result def done(payload): - session, result = payload + server, changes, vault_ids = payload if not _still_open(app, attempt): - return _close_in_background(session) - if not session.has_work(): - _close_in_background(session) - return _finish(app, "Already up to date") - choices = None - if result.conflicts: - choices = conflict_dialog(app, result) - if choices is None: - _close_in_background(session) - return _finish(app, "Sync cancelled — nothing changed", "warn") - _commit(app, session, result, choices, attempt) + return _logout_in_background(server) + app.load_vaults() # vaults that just arrived, or left + run = {"server": server, "changes": changes, "queue": list(vault_ids), + "many": len(vault_ids) > 1, "updated": [], "summary": None, + "account": account} + _next_vault(app, run, attempt) - _in_background(app, work, done, on_error=lambda e: _failed(app, e, attempt)) + _in_background(app, work, done, + on_error=lambda e: _finish(app, str(e), "error") + if _still_open(app, attempt) else None) -def _commit(app, session, result, choices, attempt): - def work(): - try: - return session.commit(app.vault, choices) - finally: - session.close() +def _next_vault(app, run, attempt): + if not run["queue"]: + return _all_done(app, run) + _sync_step(app, run, attempt, run["queue"].pop(0), retry_allowed=True) + - def done(merged): +def _sync_step(app, run, attempt, vault_id, retry_allowed): + def failed(error): if not _still_open(app, attempt): - return - app.vault = merged - summary = f"Synced — {result.summary()}" - if session.first_push: - summary = "Synced — the server now has this account's vault" - elif session.base_ignored: - summary += " (first sync with this server)" + return _logout_in_background(run["server"]) + if isinstance(error, sync_client.Conflict409) and retry_allowed: + app.toast("The server moved on — merging again") + return _sync_step(app, run, attempt, vault_id, retry_allowed=False) + _logout_in_background(run["server"]) app.refresh_account_list() - _finish(app, summary) - - _in_background(app, work, done, on_error=lambda e: _failed(app, e, attempt)) + _finish(app, str(error), "error") + def work(): + session = account_sync.AccountSync(run["account"], run["server"], vault_id) + return session, session.prepare(app.vaults[vault_id]) -def _close_in_background(session): + def prepared(payload): + session, result = payload + if not _still_open(app, attempt): + return _logout_in_background(run["server"]) + if not session.has_work(): + return _next_vault(app, run, attempt) + choices = None + if result.conflicts: + choices = conflict_dialog( + app, result, app.vault_name(vault_id) if run["many"] else "") + if choices is None: + _logout_in_background(run["server"]) + app.refresh_account_list() + return _finish( + app, "Sync cancelled — nothing changed" + ( + f" in “{app.vault_name(vault_id)}”" if run["many"] else ""), + "warn") + + def commit(): + return session.commit(app.vaults[vault_id], choices) + + def committed(merged): + if not _still_open(app, attempt): + return _logout_in_background(run["server"]) + app.set_vault_data(vault_id, merged, saved=True) + run["updated"].append(vault_id) + summary = f"Synced — {result.summary()}" + if session.first_push: + summary = "Synced — the server now has this account's vault" + elif session.base_ignored: + summary += " (first sync with this server)" + run["summary"] = summary + _next_vault(app, run, attempt) + + _in_background(app, commit, committed, on_error=failed) + + _in_background(app, work, prepared, on_error=failed) + + +def _all_done(app, run): + _logout_in_background(run["server"]) + app.refresh_account_list() + changes, updated = run["changes"], run["updated"] + if not run["many"] and not changes: + # An account with only its personal vault: what phase 2 said. + return _finish(app, run["summary"] or "Already up to date") + parts = [] + if updated: + parts.append(f"{len(updated)} vault{'s' if len(updated) != 1 else ''} " + "updated") + if changes.added: + parts.append("new from the server: " + ", ".join(changes.added)) + if changes.uploaded: + parts.append("now on the server: " + ", ".join(changes.uploaded)) + for old, new in changes.renamed: + parts.append(f"“{old}” is now “{new}”") + kind = "info" + if changes.gone: + parts.append("deleted on another device, kept on this PC only: " + + ", ".join(changes.gone) + " (see Vaults)") + kind = "warn" + if changes.server_too_old: + parts.append("this server keeps only the Personal vault — the others " + "need SecretSpace Server 2.0.0-alpha.2 or newer") + kind = "warn" + _finish(app, "Synced — " + "; ".join(parts) if parts else "Already up to date", + kind) + + +def _logout_in_background(server): """Log out without the window waiting: a logout is a network call too, and a slow server must never freeze the window (Lab flow, 2026-11-10: on the main thread it stalled the UI for 20 s).""" - threading.Thread(target=session.close, daemon=True).start() - - -def _failed(app, error, attempt): - 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)) - _finish(app, str(error), "error") + threading.Thread(target=server.logout, daemon=True).start() # ========================================================================== diff --git a/sourceFiles/accounts.py b/sourceFiles/accounts.py index 935b7b1..bf8cf11 100644 --- a/sourceFiles/accounts.py +++ b/sourceFiles/accounts.py @@ -10,8 +10,10 @@ it (secret_store), fingerprints seen keyset.pmk the keyset, under master password + Secret Key recovery.pmk the same keyset, under the 12-word phrase - vaults/.pmv3 the personal vault (+ .bak, the previous save, - + .base, the state last agreed with the server) + vaults/.pmv3 a vault (+ .bak, the previous save, + .base, + the state last agreed with the server): the + personal one, and since phase 3 any further + vaults the account has (account.json "vaults") ```` is the folder that holds the 2.x ``vault`` folder (beside the app for a lab copy, %APPDATA%\\SecretSpace when installed). 2.x vault files are only @@ -38,6 +40,10 @@ ACCOUNT_FORMAT = 1 MODE_PERSONAL = "personal" +#: What the personal vault is called wherever vaults are listed. It has no +#: stored name, and no other vault may take this one. +PERSONAL_NAME = "Personal" + class SecretKeyNeeded(Exception): """ @@ -115,6 +121,109 @@ def _write_info(username: str, info: dict) -> None: json.dumps(info, indent=2, sort_keys=True)) +# --------------------------------------------------------------------------- +# One vault of an open account +# --------------------------------------------------------------------------- +def clean_vault_name(name: str) -> str: + """A vault name as it is stored, or ValueError saying what is wrong.""" + name = " ".join((name or "").split()) + if not name: + raise ValueError("Give the vault a name.") + if len(name) > account_crypto.VAULT_NAME_CHARS or \ + len(name.encode("utf-8")) > account_crypto.VAULT_NAME_BYTES: + raise ValueError(f"A vault name can be up to " + f"{account_crypto.VAULT_NAME_CHARS} characters.") + if any(ord(c) < 32 or ord(c) == 127 for c in name): + raise ValueError("A vault name cannot contain control characters.") + return name + + +class VaultFile: + """ + One vault of an unlocked account: its id, name and key in memory, its + files on disk. Everything that reads, writes or syncs a vault goes + through one of these, the personal vault and any other alike. + """ + + def __init__(self, username: str, vault_id: bytes, key: bytes, name: str, + personal: bool): + self.id = vault_id + self.hex = vault_id.hex() + self.name = name + self.personal = personal + self._key = key + self.path = os.path.join(account_dir(username), VAULTS_DIR, + self.hex + VAULT_SUFFIX) + + @property + def base_path(self) -> str: + return self.path + vault.BASE_SUFFIX + + @property + def has_file(self) -> bool: + return os.path.exists(self.path) + + def encrypt(self, data: dict) -> str: + """``data`` as a PMV3 blob under this vault's key.""" + return account_crypto.encrypt_vault(self._key, self.id, 0, + vault.validate_vault(data)) + + def decrypt(self, blob: str) -> dict: + """A PMV3 blob of this vault (it must name this vault).""" + return vault.validate_vault(account_crypto.decrypt_vault( + self._key, blob, self.id)) + + def load(self) -> dict: + """The vault's data. Falls back to .bak like a 2.x vault does.""" + first_error = None + for path in (self.path, self.path + vault.BACKUP_SUFFIX): + if not os.path.exists(path): + continue + try: + return self.decrypt(_read_text(path)) + except (ValueError, OSError) as e: # CryptoError is a ValueError + if first_error is None: + first_error = e + raise first_error or FileNotFoundError( + f"The vault “{self.name}” is missing on this PC.") + + def save(self, data: dict) -> None: + vault._rotate_backup(self.path) + _write_text(self.path, self.encrypt(data)) + + def load_base(self): + """ + The vault as last agreed with the server, or None -- as 2.x's + vault.load_base: a missing or unreadable base only makes the next + merge noisier, never destructive. + """ + try: + return self.decrypt(_read_text(self.base_path)) + except (OSError, ValueError): + return None + + def save_base(self, data: dict, origin: dict) -> None: + """Written at the very end of a successful sync, with the server and + revision it was agreed at (sync_client.base_matches).""" + data = dict(data) + data[vault.BASE_ORIGIN_KEY] = origin + _write_text(self.base_path, self.encrypt(data)) + + def sealed_name(self) -> str: + """The name as the server keeps it (it cannot read it).""" + return account_crypto.seal_vault_name(self._key, self.id, self.name) + + def open_name(self, sealed: str) -> str: + return account_crypto.open_vault_name(self._key, self.id, sealed) + + def remove_files(self) -> None: + for path in (self.path, self.path + vault.BACKUP_SUFFIX, self.base_path): + try: + os.remove(path) + except FileNotFoundError: + pass + + # --------------------------------------------------------------------------- # An open account # --------------------------------------------------------------------------- @@ -134,6 +243,8 @@ def __init__(self, username: str, info: dict, keyset: dict, secret: bytes, self.vault_id = bytes.fromhex(info["personal_vault"]) self._vault_key = account_crypto._unb64(keyset["personal_vault_key"], "vault key") + # Keys of the further vaults, unwrapped once per unlock. + self._vault_keys = {} @property def mode(self) -> str: @@ -148,43 +259,161 @@ def secret_key_text(self) -> str: """For the Emergency Kit again (Settings).""" return account_crypto.format_secret_key(self._secret) + # -- its vaults ----------------------------------------------------------- + @property + def personal_id(self) -> str: + return self.info["personal_vault"] + + def _extra(self) -> dict: + """account.json's record of the vaults besides the personal one.""" + found = self.info.get("vaults") + return found if isinstance(found, dict) else {} + + def vault_ids(self) -> list: + """Every vault of the account, by id: the personal one first, then + the others by name.""" + extra = self._extra() + return [self.personal_id] + sorted( + extra, key=lambda v: (str(extra[v].get("name", "")).lower(), v)) + + def vault_record(self, vault_id: str) -> dict: + """What account.json holds about a further vault ({} for the personal + one): name, wrapped_key, on_server, gone_from_server, rename_pending.""" + return dict(self._extra().get(vault_id, {})) + + def vault_file(self, vault_id: str = None) -> VaultFile: + """The vault with this id (the personal one when None).""" + if vault_id is None or vault_id == self.personal_id: + return VaultFile(self.username, self.vault_id, self._vault_key, + PERSONAL_NAME, True) + record = self._extra().get(vault_id) + if record is None: + raise KeyError(f"This account has no vault {vault_id}.") + key = self._vault_keys.get(vault_id) + if key is None: + key = account_crypto.unwrap_key( + self.keyset, record["wrapped_key"], bytes.fromhex(vault_id), + self.username) + self._vault_keys[vault_id] = key + return VaultFile(self.username, bytes.fromhex(vault_id), key, + record.get("name", ""), False) + + def _name_taken(self, name: str, except_id: str = None) -> bool: + wanted = name.lower() + if wanted == PERSONAL_NAME.lower(): + return True + return any(str(r.get("name", "")).lower() == wanted + for v, r in self._extra().items() if v != except_id) + + def _write_vaults(self, extra: dict) -> None: + info = dict(self.info) + info["vaults"] = extra + _write_info(self.username, info) + self.info = info + + def create_vault(self, name: str) -> VaultFile: + """ + A further vault, empty, on this device. Its key is random and kept + wrapped to the account's own public key -- exactly how it will be + handed to another member once vaults are shared. + """ + name = clean_vault_name(name) + if self._name_taken(name): + raise ValueError(f"There is already a vault called “{name}”.") + vault_id = account_crypto.new_vault_id() + key = account_crypto.new_vault_key() + wrapped = account_crypto.wrap_key( + account_crypto.public_key_of(self.keyset), key, vault_id, + self.username) + made = VaultFile(self.username, vault_id, key, name, False) + made.save({"accounts": []}) + extra = dict(self._extra()) + extra[made.hex] = {"name": name, "wrapped_key": wrapped, + "created_at": vault.utc_now(), "on_server": False} + self._write_vaults(extra) # last: it makes it a vault + self._vault_keys[made.hex] = key + return made + + def adopt_vault(self, vault_id: str, wrapped_key: str, sealed_name: str) -> VaultFile: + """ + A vault the server holds for this account and this device does not + have yet. The key must open with this account's key, and the name + with the vault's -- otherwise it is not ours and nothing is written. + """ + raw_id = bytes.fromhex(vault_id) + key = account_crypto.unwrap_key(self.keyset, wrapped_key, raw_id, + self.username) + name = clean_vault_name(account_crypto.open_vault_name(key, raw_id, + sealed_name)) + # Two devices may each have made a vault of the same name while + # offline: both are kept, and told apart. + base, n = name, 2 + while self._name_taken(name, except_id=vault_id): + name = f"{base} ({n})" + n += 1 + extra = dict(self._extra()) + extra[vault_id] = {"name": name, "wrapped_key": wrapped_key, + "created_at": vault.utc_now(), "on_server": True} + self._write_vaults(extra) + self._vault_keys[vault_id] = key + return self.vault_file(vault_id) + + def rename_vault(self, vault_id: str, name: str, pending: bool = True) -> None: + """``pending`` marks a name the server has not been told yet.""" + name = clean_vault_name(name) + if vault_id == self.personal_id or vault_id not in self._extra(): + raise ValueError("That vault cannot be renamed.") + if self._name_taken(name, except_id=vault_id): + raise ValueError(f"There is already a vault called “{name}”.") + extra = dict(self._extra()) + record = dict(extra[vault_id]) + record["name"] = name + record["rename_pending"] = bool(pending and record.get("on_server")) + extra[vault_id] = record + self._write_vaults(extra) + + def mark_vault(self, vault_id: str, **flags) -> None: + """Set on_server / gone_from_server / rename_pending for a vault.""" + extra = dict(self._extra()) + if vault_id not in extra: + return + record = dict(extra[vault_id]) + record.update(flags) + extra[vault_id] = record + self._write_vaults(extra) + + def delete_vault(self, vault_id: str) -> None: + """Forget a further vault on this device: its record and its files. + The caller has checked the password (and told the server first).""" + if vault_id == self.personal_id or vault_id not in self._extra(): + raise ValueError("That vault cannot be deleted.") + target = self.vault_file(vault_id) + extra = dict(self._extra()) + del extra[vault_id] + self._write_vaults(extra) # first: no record, no vault + self._vault_keys.pop(vault_id, None) + target.remove_files() + + # -- the personal vault, as before phase 3 ----------------------------------- @property def vault_path(self) -> str: - return os.path.join(account_dir(self.username), VAULTS_DIR, - self.info["personal_vault"] + VAULT_SUFFIX) + return self.vault_file().path def load_vault(self) -> dict: - """The personal vault. Falls back to .bak like a 2.x vault does.""" - first_error = None - for path in (self.vault_path, self.vault_path + vault.BACKUP_SUFFIX): - if not os.path.exists(path): - continue - try: - data = account_crypto.decrypt_vault( - self._vault_key, _read_text(path), self.vault_id) - return vault.validate_vault(data) - except (ValueError, OSError) as e: # CryptoError is a ValueError - if first_error is None: - first_error = e - raise first_error or FileNotFoundError("The personal vault is missing.") + return self.vault_file().load() def save_vault(self, data: dict) -> None: - vault._rotate_backup(self.vault_path) - _write_text(self.vault_path, self.encrypt(data)) + self.vault_file().save(data) def encrypt(self, data: dict) -> str: - """``data`` as a PMV3 blob under the personal vault's key.""" - return account_crypto.encrypt_vault(self._vault_key, self.vault_id, 0, - vault.validate_vault(data)) + return self.vault_file().encrypt(data) def decrypt(self, blob: str) -> dict: - """A PMV3 blob of the personal vault (it must name this vault).""" - return vault.validate_vault(account_crypto.decrypt_vault( - self._vault_key, blob, self.vault_id)) + return self.vault_file().decrypt(blob) @property def has_vault_file(self) -> bool: - return os.path.exists(self.vault_path) + return self.vault_file().has_file # -- the server ----------------------------------------------------------- @property @@ -196,32 +425,29 @@ def server(self) -> dict: def set_server(self, url, ca_file: str = "") -> None: """Remember the server (None forgets it). account.json only.""" info = dict(self.info) + before = (info.get("server") or {}).get("url") info["server"] = ({"url": url.rstrip("/"), "ca_file": ca_file or ""} if url else None) + if (info["server"] or {}).get("url") != before: + # Another server (or none) knows nothing of these vaults: they + # are this device's again, to be put on whichever comes next. + info["vaults"] = { + v: {k: x for k, x in r.items() + if k not in ("on_server", "gone_from_server", + "rename_pending")} + for v, r in self._extra().items()} _write_info(self.username, info) self.info = info @property def base_path(self) -> str: - return self.vault_path + vault.BASE_SUFFIX + return self.vault_file().base_path def load_base(self): - """ - The vault as last agreed with the server, or None -- as 2.x's - vault.load_base: a missing or unreadable base only makes the next - merge noisier, never destructive. - """ - try: - return self.decrypt(_read_text(self.base_path)) - except (OSError, ValueError): - return None + return self.vault_file().load_base() def save_base(self, data: dict, origin: dict) -> None: - """Written at the very end of a successful sync, with the server and - revision it was agreed at (sync_client.base_matches).""" - data = dict(data) - data[vault.BASE_ORIGIN_KEY] = origin - _write_text(self.base_path, self.encrypt(data)) + self.vault_file().save_base(data, origin) def check_password(self, password: str) -> bool: """Is this the master password? (Re-derives: 2FA reveal, delete.)""" @@ -418,7 +644,8 @@ def adopt_from_server(username: str, me: dict, keyset: dict, secret: bytes, os.mkdir(folder, 0o700) info = {"format": ACCOUNT_FORMAT, "username": username, "mode": MODE_PERSONAL, "created_at": vault.utc_now(), - "personal_vault": vault_id, "fingerprints_seen": {}} + "personal_vault": vault_id, "fingerprints_seen": {}, + "vaults": {}} info.update({"public_key": me["public_key"], "server": server, "secret_key": secret_store.protect(secret, username, password)}) _write_text(os.path.join(folder, KEYSET_FILE), me["keyset"]) diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 0a871d9..1893c7e 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -30,7 +30,7 @@ CARD, CARD_HOVER, ON_ACCENT, MONO, DANGER, tile_colors) from vault import (add_account, validate_username, export_csv, export_json, update_entry, find_duplicate) -from widgets import (dialog_shell, section, setting_row, option_menu, note, +from widgets import (Tooltip, dialog_shell, section, setting_row, option_menu, note, error_label, check, field_label, entry, danger_button) from crypto_utils import password_matches from password_generator import generate_password @@ -401,9 +401,10 @@ def unlock(): app.toast("Everything in that vault is already here", "warn") return msg = (f"Import {added} entr{'y' if added == 1 else 'ies'} from " - f"“{label}”, with their history and archive state?") + f"“{label}” into {app.vault_phrase()}, with their history " + "and archive state?") if skipped: - msg += (f"\n\n{skipped} already in this account will be " + msg += (f"\n\n{skipped} already in that vault will be " "skipped.") msg += ("\n\nThe 2.x vault is not changed, and its sync settings " "stay with it.") @@ -458,7 +459,7 @@ def _finish_import(app, parsed, path): msg += ("\n\nThis file also contains " + ", ".join(parsed.dropped) + ", which SecretSpace cannot store. Keep the original file " "until you have checked you still have them.") - msg += "\n\nAdd them to your vault?" + msg += f"\n\nAdd them to {app.vault_phrase()}?" if not app._confirm("Import accounts", msg, danger=False): return @@ -482,6 +483,14 @@ def account_dialog(app, acc): else "")) wrap = shell.body + # A new entry goes to the vault picked in the main window; with several + # vaults it can be changed here. An edit stays in the entry's own vault. + # (The picker sits in the button bar, so the form is no taller.) + target = {"id": app.active_vault_id} + + def target_vault(): + return app.vault_holding(acc["id"]) if editing else app.vaults[target["id"]] + site_entry = app._field(wrap, "Website / App", placeholder="e.g. GitHub") user_entry = app._field(wrap, "Username / Email", placeholder="e.g. you@example.com") @@ -566,7 +575,7 @@ def save(): username = user_entry.get().strip() # Ask before the same login is stored twice -- on add, or when an # edit would turn this entry into a copy of another one. - twin = find_duplicate(app.vault, site, username, + twin = find_duplicate(target_vault(), site, username, exclude_id=acc["id"] if editing else None) if twin is not None and not app._confirm( "Already saved", @@ -582,7 +591,7 @@ def save(): update_entry(acc, site=site, username=username, password=password, notes=notes, totp=totp_secret) else: - add_account(app.vault, site, username, + add_account(target_vault(), site, username, password, notes=notes, totp=totp_secret) app._save() top.destroy() @@ -594,6 +603,15 @@ def save(): app._accent_button(shell.buttons, "Save changes" if editing else "Save entry", save, width=150).pack(side="right") + if not editing and len(app.vaults) > 1: + names = {app.vault_name(v): v for v in app.vaults} + picker = option_menu( + shell.buttons, list(names), + lambda label: target.update(id=names[label]), width=140) + picker.configure(height=40) + picker.pack(side="right", padx=(0, 10)) + picker.set(app.vault_name(target["id"])) + Tooltip(picker, "The vault this entry is saved in") for e in (site_entry, user_entry, pw_entry, totp_entry): e.bind("", lambda ev: save()) site_entry.focus() @@ -735,7 +753,8 @@ def days_label(d): def set_purge(choice): days = next(d for d in vault.ARCHIVE_PURGE_CHOICES if days_label(d) == choice) - due = (vault.archived_before(app.vault, vault.purge_cutoff(days)) + due = ([e for data in app.vaults.values() + for e in vault.archived_before(data, vault.purge_cutoff(days))] if days else []) if due and not app._confirm( "Delete archived entries", @@ -745,8 +764,10 @@ def set_purge(choice): "This cannot be undone."): purge_menu.set(days_label(vault.archive_purge_days(app.vault))) return - vault.set_archive_purge_days(app.vault, days) - purged = vault.auto_purge_archive(app.vault) + purged = 0 + for data in app.vaults.values(): # one setting, every vault + vault.set_archive_purge_days(data, days) + purged += vault.auto_purge_archive(data) if app._save(): app.refresh_account_list() if purged: @@ -831,7 +852,7 @@ def _export_plaintext(app): return try: writer = export_json if path.lower().endswith(".json") else export_csv - count = writer(app.vault, path) + count = writer(app.scope_data(), path) except Exception as e: app.toast(f"Export failed: {e}", "error") return diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index 5dfe4c6..20b1a47 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -11,6 +11,8 @@ to load_vault / save_vault. """ +import hashlib +import json import os import time import tkinter as tk @@ -26,6 +28,7 @@ import sync_client import sync_ui import account_sync_ui +import vaults_ui import totp from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG, BORDER, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, @@ -53,6 +56,9 @@ BACKDROP_REDRAW_MS = 60 # the login backdrop waits for a resize to settle BACKDROP_OVERSAMPLE = 3 # painted this many times larger, then scaled down +#: The vault filter's entry that shows every vault of the account. +ALL_VAULTS = "All vaults" + #: What the Sort menu shows for each of vault.SORT_ORDERS. SORT_LABELS = { "modified_desc": "Recently modified", @@ -62,13 +68,14 @@ } -def _row_signature(acc): +def _row_signature(acc, label=""): """ Everything an account row shows or closes over. A cached row is reused only while this is unchanged; ``id(acc)`` covers a sync or a reload that - replaces the entry dicts, which the row's buttons hold on to. + replaces the entry dicts, which the row's buttons hold on to. ``label`` + is the vault's name, when the row shows it. """ - return (id(acc), acc.get("site"), acc.get("username"), + return (label, id(acc), acc.get("site"), acc.get("username"), acc.get("password"), bool(acc.get("notes")), bool(acc.get("totp")), acc.get("updated_at"), tuple(r.get("rid") for r in acc.get("history") or [])) @@ -157,7 +164,14 @@ def __init__(self, root): self.root.minsize(980, 640) self.root.configure(fg_color=BG) - self.vault = None + # Every vault of the open account, by id, as account.vault_ids() + # orders them. ``self.vault`` (a property) is the one new entries go + # to: the vault picked in the filter, or the personal one. + self.vaults = {} + self._saved = {} # vault id -> its content when last saved + self.vault_filter = None # None = all vaults + self._entry_vault = {} # listed entry id -> its vault's id + self.vault_menu = None self.master_password = None self.current_user = None # The unlocked 3.0 account (accounts.Account): its keys, its files. @@ -204,6 +218,108 @@ def __init__(self, root): self.root.bind("", self._focus_search) self.show_login_screen() + # ------------------------------------------------------------------ + # The open account's vaults + # ------------------------------------------------------------------ + @property + def active_vault_id(self): + """The vault new entries go to: the one picked in the filter, else + the personal one.""" + if self.account is None: + return None + if self.vault_filter in self.vaults: + return self.vault_filter + return self.account.personal_id + + @property + def vault(self): + return self.vaults.get(self.active_vault_id) if self.vaults else None + + @vault.setter + def vault(self, data): + if data is None: # locking: nothing stays in memory + self.vaults, self._saved = {}, {} + self.vault_filter = None + self._entry_vault = {} + else: + self.vaults[self.active_vault_id] = data + + @staticmethod + def _content_mark(data): + return hashlib.sha256(json.dumps(data, sort_keys=True, default=str) + .encode("utf-8")).hexdigest() + + def load_vaults(self): + """ + Bring ``self.vaults`` in line with the account's list: read vaults + that are new (made here, or arrived with a sync), forget ones that + are gone, keep the rest as they are in memory. Returns the names of + vaults whose file could not be read. + """ + if self.account is None: + return [] + unreadable, current = [], {} + for vault_id in self.account.vault_ids(): + if vault_id in self.vaults: + current[vault_id] = self.vaults[vault_id] + continue + file = self.account.vault_file(vault_id) + if not file.has_file: + # Known from the server, its entries not fetched yet: the + # sync that listed it fills it in. + current[vault_id] = {"accounts": []} + continue + try: + current[vault_id] = file.load() + self._saved[vault_id] = self._content_mark(current[vault_id]) + except Exception: + unreadable.append(file.name) + self.vaults = current + self._saved = {v: m for v, m in self._saved.items() if v in current} + if self.vault_filter not in self.vaults: + self.vault_filter = None + return unreadable + + def set_vault_data(self, vault_id, data, saved=False): + """Replace a vault's data (a sync's merge). ``saved``: it is already + on disk like this.""" + self.vaults[vault_id] = data + if saved: + self._saved[vault_id] = self._content_mark(data) + + def scope_ids(self): + """The vaults the list shows: the picked one, or all of them.""" + if self.vault_filter in self.vaults: + return [self.vault_filter] + return list(self.vaults) + + def scope_vaults(self): + return [self.vaults[v] for v in self.scope_ids()] + + def scope_data(self): + """Every entry in view as one vault-shaped dict (the export).""" + return {"accounts": [a for data in self.scope_vaults() + for a in data["accounts"]]} + + def vault_name(self, vault_id): + if self.account is None or vault_id == self.account.personal_id: + return accounts.PERSONAL_NAME + return self.account.vault_record(vault_id).get("name", "") + + def vault_holding(self, entry_id): + """The data of the vault that holds this entry (the active one if + none does).""" + for data in self.vaults.values(): + if any(a.get("id") == entry_id for a in data["accounts"]): + return data + return self.vault + + def vault_phrase(self, vault_id=None): + """“your vault”, or the vault by name once there are several.""" + if len(self.vaults) < 2: + return "your vault" + return f"the vault “{self.vault_name(vault_id or self.active_vault_id)}”" + # ------------------------------------------------------------------ # Branding (window icon + logo) - all optional / best effort # ------------------------------------------------------------------ @@ -284,6 +400,7 @@ def _clear(self): self._rows = {} self._empty_box = None self.search_entry = None + self.vault_menu = None # ------------------------------------------------------------------ # Thin wrappers over widgets.py, so dialog code reads the same as before @@ -325,8 +442,13 @@ def _hide_toast(self): self._toast = None def _save(self, rekey=False): + """Write every vault that changed since it was last written.""" try: - self.account.save_vault(self.vault) + for vault_id, data in self.vaults.items(): + mark = self._content_mark(data) + if self._saved.get(vault_id) != mark: + self.account.vault_file(vault_id).save(data) + self._saved[vault_id] = mark return True except Exception as e: self.toast(f"Save failed: {e}", "error") @@ -719,17 +841,23 @@ def open_account(self, account, loaded, master_password): """After an unlock or a new account: the main window.""" self._session += 1 self.account = account - self.vault = loaded + self.vaults, self._saved, self.vault_filter = {}, {}, None + self.vault = loaded # the personal vault + self._saved[account.personal_id] = self._content_mark(loaded) + unreadable = self.load_vaults() # and every other one self.master_password = master_password self.current_user = account.username appconfig.save_config(last_username=account.username) - # Archived entries past this vault's keep-for limit (Settings). - purged = auto_purge_archive(self.vault) + # Archived entries past the keep-for limit (Settings), in every vault. + purged = sum(auto_purge_archive(data) for data in self.vaults.values()) if purged: self._save() self.show_main_window() + if unreadable: + self.toast("Could not read the vault “" + "”, “".join(unreadable) + + "” on this PC — sync to fetch it again", "error") if purged: self.toast(f"Deleted {purged} archived " f"entr{'y' if purged == 1 else 'ies'} older than " @@ -825,11 +953,32 @@ def show_main_window(self): left = ctk.CTkFrame(body, fg_color="transparent") left.grid(row=0, column=0, sticky="nsew", padx=(0, 20)) - left.grid_rowconfigure(1, weight=1) + left.grid_rowconfigure(2, weight=1) left.grid_columnconfigure(0, weight=1) + # Which vault the list shows, and the way to the Vaults window. + vault_row = ctk.CTkFrame(left, fg_color="transparent") + vault_row.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + vault_row.grid_columnconfigure(0, weight=1) + self.vault_menu = ctk.CTkOptionMenu( + vault_row, height=34, corner_radius=10, values=[ALL_VAULTS], + fg_color=INPUT_BG, button_color=BORDER, + button_hover_color=CARD_HOVER, text_color=TEXT, font=F(12, "bold"), + dropdown_fg_color=CARD, dropdown_hover_color=CARD_HOVER, + dropdown_text_color=TEXT, anchor="w", command=self._on_vault_pick) + self.vault_menu.grid(row=0, column=0, sticky="ew") + Tooltip(self.vault_menu, "Which vault the list shows. New entries go " + "to the vault picked here.") + manage = ctk.CTkButton( + vault_row, text="Vaults", command=self.vaults_dialog, width=76, + height=34, corner_radius=10, fg_color="transparent", + border_width=1, border_color=BORDER, hover_color=CARD_HOVER, + text_color=TEXT, font=F(12, "bold")) + manage.grid(row=0, column=1, padx=(8, 0)) + Tooltip(manage, "Add, rename or delete vaults") + chips = ctk.CTkFrame(left, fg_color="transparent") - chips.grid(row=0, column=0, sticky="ew", pady=(0, 12)) + chips.grid(row=1, column=0, sticky="ew", pady=(0, 12)) self.all_chip = ctk.CTkLabel(chips, text="All", height=34, corner_radius=17, fg_color=GREEN, text_color=ON_ACCENT, font=F(12, "bold")) @@ -856,20 +1005,20 @@ def show_main_window(self): list_card = ctk.CTkFrame(left, fg_color=CARD, corner_radius=20, border_width=1, border_color=BORDER) - list_card.grid(row=1, column=0, sticky="nsew") + list_card.grid(row=2, column=0, sticky="nsew") self.list_frame = ctk.CTkScrollableFrame(list_card, fg_color=CARD, corner_radius=0) self.list_frame.pack(fill="both", expand=True, padx=6, pady=8) self.list_frame.grid_columnconfigure(0, weight=1) add_row = ctk.CTkFrame(left, fg_color="transparent", height=48) - add_row.grid(row=2, column=0, sticky="ew", pady=(12, 0)) + add_row.grid(row=3, column=0, sticky="ew", pady=(12, 0)) add = self._accent_button(add_row, "+ Add an entry", self.add_account_dialog) add.configure(height=48, corner_radius=16, font=F(14, "bold")) even_width(add, add_row) ctk.CTkLabel(left, text=version_string(), font=F(11), - text_color=MUTED).grid(row=3, column=0, pady=(8, 0)) + text_color=MUTED).grid(row=4, column=0, pady=(8, 0)) self.detail = ctk.CTkFrame(body, fg_color=CARD, corner_radius=24, border_width=1, border_color=BORDER) @@ -906,16 +1055,50 @@ def _on_sort(self, label): break self.refresh_account_list() + def _on_vault_pick(self, label): + picked = None + for vault_id in self.vaults: + if self.vault_name(vault_id) == label: + picked = vault_id + self.vault_filter = None if label == ALL_VAULTS else picked + self.refresh_account_list() + + def _refresh_vault_menu(self): + if self.vault_menu is None or not self.vault_menu.winfo_exists(): + return + names = [self.vault_name(v) for v in self.vaults] + # One vault needs no "all of them". + self.vault_menu.configure( + values=names if len(names) < 2 else [ALL_VAULTS] + names) + self.vault_menu.set(self.vault_name(self.vault_filter) + if self.vault_filter in self.vaults + else (ALL_VAULTS if len(names) > 1 else names[0])) + + def _row_label(self, acc): + """The vault's name on a row -- only where rows of several vaults + are mixed.""" + if self.vault_filter in self.vaults or len(self.vaults) < 2: + return "" + return self.vault_name(self._entry_vault.get(acc["id"])) + def refresh_account_list(self): self._cancel_row_build() if self._empty_box is not None: self._empty_box.destroy() self._empty_box = None - - accounts = active_accounts(self.vault) + self._refresh_vault_menu() + + accounts, stored = [], 0 + self._entry_vault = {} + for vault_id in self.scope_ids(): + data = self.vaults[vault_id] + stored += len(data["accounts"]) + for a in active_accounts(data): + accounts.append(a) + self._entry_vault[a["id"]] = vault_id active = {a["id"]: a for a in accounts} total = len(accounts) - archived = len(self.vault["accounts"]) - total + archived = stored - total q = self.search_query.strip().lower() if q: accounts = [a for a in accounts @@ -939,7 +1122,7 @@ def refresh_account_list(self): for eid, cached in list(self._rows.items()): sig, frame, pos = cached acc = active.get(eid) - if acc is None or _row_signature(acc) != sig: + if acc is None or _row_signature(acc, self._row_label(acc)) != sig: frame.destroy() del self._rows[eid] else: @@ -974,7 +1157,8 @@ def _place_rows(self, pending): cached[1].grid(row=i, column=0, sticky="ew", pady=2, padx=2) cached[2] = i else: - self._rows[acc["id"]] = [_row_signature(acc), + self._rows[acc["id"]] = [_row_signature(acc, + self._row_label(acc)), self._add_row(i, acc), i] if pending: self._row_job = self.root.after(1, lambda: self._place_rows(pending)) @@ -1030,11 +1214,20 @@ def _add_row(self, i, acc): text_color=MUTED) user.pack(anchor="w") - when = ctk.CTkLabel(row, text=history_ui.short_when( + side = ctk.CTkFrame(row, fg_color="transparent") + side.grid(row=0, column=2, padx=(8, 14)) + when = ctk.CTkLabel(side, text=history_ui.short_when( acc.get("updated_at", "")), font=F(11), text_color=MUTED) - when.grid(row=0, column=2, padx=(8, 14)) - - for w in (row, tile, info, site, user, when): + when.pack(anchor="e") + clickable = [row, tile, info, site, user, side, when] + label = self._row_label(acc) + if label: + where = ctk.CTkLabel(side, text=_clip(label, 16), font=F(11, "bold"), + text_color=MUTED, height=16) + where.pack(anchor="e") + clickable.append(where) + + for w in clickable: w.bind("", lambda e, eid=acc["id"]: self._select(eid)) return row @@ -1052,9 +1245,10 @@ def _select(self, eid): def _selected_entry(self): if self._selected_id is None or self.vault is None: return None - for acc in active_accounts(self.vault): - if acc["id"] == self._selected_id: - return acc + for data in self.scope_vaults(): + for acc in active_accounts(data): + if acc["id"] == self._selected_id: + return acc return None def _cancel_totp(self): @@ -1106,8 +1300,11 @@ def _show_detail(self): ctk.CTkLabel(names, text=_clip(acc["site"], 34), font=F(24, "bold"), text_color=TEXT, anchor="w").pack(anchor="w") changed = acc.get("updated_at") - ctk.CTkLabel(names, text=("Last changed " + history_ui.local_time( - changed)) if changed else "", font=F(12), text_color=MUTED, + line = ("Last changed " + history_ui.local_time(changed)) if changed else "" + if len(self.vaults) > 1: + line += (" · " if line else "") + _clip(self.vault_name( + self._entry_vault.get(acc["id"])), 24) + ctk.CTkLabel(names, text=line, font=F(12), text_color=MUTED, anchor="w").pack(anchor="w") actions = ctk.CTkFrame(band, fg_color="transparent") actions.grid(row=0, column=2, padx=(8, 22)) @@ -1315,7 +1512,7 @@ def _archive_account(self, acc): "It leaves this list but stays in your vault, and you can " "restore it at any time.", danger=False): return - archive_account(self.vault, acc["id"]) + archive_account(self.vault_holding(acc["id"]), acc["id"]) self._save() self.refresh_account_list() self.toast("Moved to archive") @@ -1356,6 +1553,9 @@ def settings_dialog(self): def archive_dialog(self): sync_ui.archive_dialog(self) + def vaults_dialog(self): + vaults_ui.vaults_dialog(self) + def sync_settings_dialog(self): sync_ui.sync_settings_dialog(self) diff --git a/sourceFiles/sync_ui.py b/sourceFiles/sync_ui.py index 374192f..fdba73c 100644 --- a/sourceFiles/sync_ui.py +++ b/sourceFiles/sync_ui.py @@ -214,7 +214,7 @@ def runner(): # ========================================================================== # Conflict resolution # ========================================================================== -def conflict_dialog(app, result): +def conflict_dialog(app, result, where=""): """ Show every entry both sides changed and collect a decision for each. @@ -232,7 +232,9 @@ def conflict_dialog(app, result): count = len(result.conflicts) shell = dialog_shell( top, "Review sync conflicts", - f"{count} entr{'y' if count == 1 else 'ies'} changed on both this PC " + f"{count} entr{'y' if count == 1 else 'ies'} " + + (f"in the vault “{where}” " if where else "") + + f"changed on both this PC " f"and the server. Everything else merged on its own: " f"{result.summary()}.", scroll=True) listing = shell.body @@ -517,7 +519,8 @@ def rebuild(): for child in listing.winfo_children(): child.destroy() - entries = vault.archived_accounts(app.vault) + entries = [e for data in app.scope_vaults() + for e in vault.archived_accounts(data)] if not entries: box = ctk.CTkFrame(listing, fg_color="transparent") box.grid(row=0, column=0, pady=80) @@ -564,7 +567,7 @@ def _archive_row(app, parent, index, entry, rebuild): buttons.grid(row=0, column=2, padx=(8, 14)) def restore(): - vault.restore_account(app.vault, entry["id"]) + vault.restore_account(app.vault_holding(entry["id"]), entry["id"]) app._save() app.refresh_account_list() rebuild() @@ -578,7 +581,7 @@ def purge(): "This cannot be undone, and it is the only action in SecretSpace " "that really destroys a password."): return - vault.purge_account(app.vault, entry["id"]) + vault.purge_account(app.vault_holding(entry["id"]), entry["id"]) app._save() app.refresh_account_list() rebuild() diff --git a/sourceFiles/tests/sync_server/passman_server.py b/sourceFiles/tests/sync_server/passman_server.py index e02e469..0606bb9 100644 --- a/sourceFiles/tests/sync_server/passman_server.py +++ b/sourceFiles/tests/sync_server/passman_server.py @@ -235,6 +235,20 @@ def _archive(self, user: str, revision: int) -> None: VAULT_ID = re.compile(r"^[0-9a-f]{32}$") ROLES_THAT_WRITE = ("owner", "editor") +#: Vaults an account may own besides its personal one. Whoever holds the +#: sign-up code can make an account; this keeps one from filling the disk +#: with empty vaults. +MAX_VAULTS_PER_ACCOUNT = 50 + +#: A vault key wrapped to a member (account_crypto.wrap_key on the client): +#: version, a one-off X25519 public key, a nonce, the key and its tag. +WRAPPED_KEY_LEN = 1 + 32 + 12 + 32 + 16 + +#: A vault's name arrives sealed under the vault's key, so the server cannot +#: read it: a nonce, the name (up to 200 bytes) and a tag. +MIN_SEALED_NAME = 12 + 1 + 16 +MAX_SEALED_NAME = 12 + 200 + 16 + #: The account files' layouts, from the client (account_crypto.py). _KEYSET_MAGIC = b"PMK3" _KEYSET_HEADER = 4 + 1 + 1 + 4 + 1 + 1 + 16 # magic ver kdf mem passes lanes salt @@ -499,17 +513,34 @@ def _drop_expired_sessions(self, keep_user: str = None, keep_token: str = None, pass # -- the account itself ------------------------------------------------- + def _vault_view(self, username: str, vault_id: str): + """What an account is told about one of its vaults, or None when it + is not (or no longer) a member.""" + members = self._read_json(self._members_path(vault_id)) or {} + member = members.get("members", {}).get(username) + if member is None: + return None + meta = self.vaults.meta(vault_id) + view = {"id": vault_id, "kind": members.get("kind", "personal"), + "role": member.get("role", "owner"), + "revision": meta["revision"], + "key_generation": meta.get("key_generation", 0)} + # The personal vault's key is in the keyset and it has no name; every + # other vault carries its key wrapped to this member, and its name + # sealed under that key. + if view["kind"] != "personal": + view["name"] = members.get("name", "") + view["wrapped_key"] = member.get("wrapped_key", "") + return view + def me(self, username: str) -> dict: record = self.account(username) - vaults = [] vault_id = record["personal_vault"] - members = self._read_json(self._members_path(vault_id)) or {} - meta = self.vaults.meta(vault_id) - vaults.append({"id": vault_id, "kind": members.get("kind", "personal"), - "role": members.get("members", {}).get(username, {}) - .get("role", "owner"), - "revision": meta["revision"], - "key_generation": meta.get("key_generation", 0)}) + vaults = [] + for each in [vault_id] + list(record.get("vaults", [])): + view = self._vault_view(username, each) + if view is not None: + vaults.append(view) return {"username": record["username"], "public_key": record["public_key"], "keyset": record["keyset"], "recovery": record["recovery"], "kdf": record["kdf"], "personal_vault": vault_id, @@ -532,6 +563,79 @@ def change_keyset(self, username: str, body: dict, keep_token: str) -> None: self._drop_expired_sessions(user_to_end=username, keep_token=keep_token) # -- vaults ------------------------------------------------------------- + @staticmethod + def _sealed_name(body: dict) -> str: + name = body.get("name") + try: + size = len(base64.b64decode(name, validate=True)) + except Exception: + size = 0 + if not isinstance(name, str) or not MIN_SEALED_NAME <= size <= MAX_SEALED_NAME: + raise V2Error(400, "The vault's name is missing or too long.") + return name + + def create_vault(self, username: str, body: dict) -> dict: + """ + A further vault for ``username``, who becomes its owner. The client + made the vault's id and key; the server gets the key only wrapped to + the owner's public key, and the name only sealed under the vault key. + """ + vault_id = body.get("id") + if not isinstance(vault_id, str) or not VAULT_ID.match(vault_id): + raise V2Error(400, "The vault id is not valid.") + name = self._sealed_name(body) + wrapped = _b64(_unb64(body.get("wrapped_key"), "The vault key", + WRAPPED_KEY_LEN)) + with _lock: + record = self.account(username) + mine = list(record.get("vaults", [])) + if len(mine) >= MAX_VAULTS_PER_ACCOUNT: + raise V2Error(403, f"An account can have {MAX_VAULTS_PER_ACCOUNT} " + "vaults besides its personal one.") + if os.path.exists(os.path.join(self.vaults.root, vault_id)): + raise V2Error(409, "That vault id is already in use.") + self._write_json(self._members_path(vault_id), { + "kind": "shared", "created_at": utc_now(), + "created_by": username, "name": name, + "members": {username: {"role": "owner", "wrapped_key": wrapped}}}) + record["vaults"] = mine + [vault_id] + self._write_json(self._account_path(username), record) + return self._vault_view(username, vault_id) + + def rename_vault(self, username: str, vault_id: str, body: dict) -> dict: + name = self._sealed_name(body) + with _lock: + members = self._read_json(self._members_path(vault_id)) or {} + if members.get("kind") == "personal": + raise V2Error(400, "The personal vault has no name to change.") + members["name"] = name + self._write_json(self._members_path(vault_id), members) + return self._vault_view(username, vault_id) + + def delete_vault(self, username: str, vault_id: str) -> None: + """ + Take a vault away from every member. Its folder is not erased but + moved to /_v2/deleted/, so whoever runs the server can bring a + vault back that was deleted by mistake -- and can empty that folder + when they are sure. + """ + with _lock: + members = self._read_json(self._members_path(vault_id)) or {} + if members.get("kind") == "personal": + raise V2Error(400, "The personal vault cannot be deleted; it " + "goes with the account.") + for name in members.get("members", {}): + record = self.account(name) + if record and vault_id in record.get("vaults", []): + record["vaults"] = [v for v in record["vaults"] + if v != vault_id] + self._write_json(self._account_path(name), record) + graveyard = os.path.join(self.root, "deleted") + os.makedirs(graveyard, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + os.replace(os.path.join(self.vaults.root, vault_id), + os.path.join(graveyard, f"{vault_id}-{stamp}")) + def role(self, username: str, vault_id: str): """This user's role in the vault, or None (also for no such vault).""" if not VAULT_ID.match(vault_id or ""): @@ -607,8 +711,11 @@ def _user_from_path(self): # -- routes ----------------------------------------------------------- def do_GET(self): if self.path.split("?")[0] == "/health": + # "features" lets a 3.0 client say "this server is too old for + # that" instead of failing half-way: "vaults" = several vaults + # per account (2.0.0-alpha.2). return self._send(200, {"ok": True, "service": "passman-sync", - "api": [1, 2]}) + "api": [1, 2], "features": ["vaults"]}) if self._is_v2(): return self._v2("GET") @@ -637,6 +744,12 @@ def do_POST(self): # 2.x never used POST: answer as before, when there was no do_POST. self.send_error(501, "Unsupported method ('POST')") + def do_DELETE(self): + if self._is_v2(): + return self._v2("DELETE") + # Nor DELETE: a 2.x vault is never removed over the network. + self.send_error(501, "Unsupported method ('DELETE')") + # -- one blob, for 2.x users and v2 vaults alike ------------------------ def _send_blob(self, store, name, tail): meta = store.meta(name) @@ -810,14 +923,34 @@ def _v2_route(self, method, parts): return None accounts.change_keyset(username, body, keep_token=token) return self._send(200, {}) + if route == ("POST", "vaults", 1): + body = self._read_body() + if body is None: + return None + return self._send(201, accounts.create_vault(username, body)) if parts and parts[0] == "vaults" and len(parts) in (2, 3): vault_id = parts[1] tail = parts[2] if len(parts) == 3 else "" role = accounts.role(username, vault_id) # Not a member and no such vault answer alike, so a guess at an # id learns nothing. - if role is None or tail not in ("", "meta"): + if role is None or tail not in ("", "meta", "name"): return self._error(404, "No such vault.") + if tail == "name" or method == "DELETE": + # The vault itself, not what is in it: its owners' business. + if (tail, method) not in (("name", "PUT"), ("", "DELETE")): + return self._error(404, "No such endpoint.") + if role != "owner": + return self._error(403, "Only an owner of this vault can " + "do that.") + if method == "DELETE": + accounts.delete_vault(username, vault_id) + return self._send(200, {}) + body = self._read_body() + if body is None: + return None + return self._send(200, accounts.rename_vault(username, vault_id, + body)) if method == "GET": return self._send_blob(accounts.vaults, vault_id, tail) if method == "PUT" and not tail: diff --git a/sourceFiles/tests/test_account_vaults.py b/sourceFiles/tests/test_account_vaults.py new file mode 100644 index 0000000..fac24cb --- /dev/null +++ b/sourceFiles/tests/test_account_vaults.py @@ -0,0 +1,918 @@ +""" +Phase 3: several vaults per account -- names and crypto, vaults on one device, +and vaults between devices through the real (test copy of the) sync server. +""" + +import base64 +import glob +import os +import threading + +import pytest + +import account_crypto +import account_sync +import accounts +import sync +import vault +from account_crypto import CryptoError +from sync_client import SyncError + +# Reused from the phase 2 sync tests: the server, the "device" switch, the +# cheap Argon2 settings and the two-device helpers. +from test_account_sync import (PW, TOKEN, alice, cheap_argon2, device, # noqa: F401 + entry, new_account, passman_server, server) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def make(name="alice", pw=PW): + return accounts.prepare_account(name, pw).save() + + +def put_entry(account, vault_id, site, password=None): + """Add an entry to one vault of the account, on this device.""" + file = account.vault_file(vault_id) + data = file.load() + vault.add_account(data, site, "me", password or f"pw-{site}") + file.save(data) + + +def sites(account, vault_id=None): + return sorted(e["site"] for e in account.vault_file(vault_id).load()["accounts"]) + + +def sync_vault(account, vault_id, choices=None): + """One whole sync of one vault, as the UI runs it; returns its data.""" + session = account_sync.AccountSync(account, None, vault_id) + try: + # an adopted vault has no file until its first sync + local = session.file.load() if session.file.has_file else {"accounts": []} + result = session.prepare(local) + if not session.has_work(): + return local + if result.conflicts and choices is None: + raise AssertionError(f"unexpected conflicts: {result.conflicts}") + return session.commit(local, choices) + finally: + session.close() + + +def reconcile(account): + server = account_sync.logged_in(account) + try: + return account_sync.reconcile_vaults(account, server) + finally: + server.logout() + + +def syncable(account): + server = account_sync.logged_in(account) + try: + return account_sync.syncable_vaults(account, server) + finally: + server.logout() + + +def sync_all(account): + """Reconcile, then sync every syncable vault.""" + changes = reconcile(account) + for vault_id in syncable(account): + sync_vault(account, vault_id) + return changes + + +def store_bytes(server): + found = b"" + for folder, _, files in os.walk(server.store.root): + for name in files: + with open(os.path.join(folder, name), "rb") as f: + found += f.read() + return found + + +def read_file(path): + with open(path, "rb") as f: + return f.read() + + +def start_server(path): + httpd = passman_server.ThreadingHTTPServer(("127.0.0.1", 0), + passman_server.Handler) + httpd.store = passman_server.Store(str(path)) + httpd.token = TOKEN + os.makedirs(httpd.store.root, exist_ok=True) + thread = threading.Thread(target=httpd.serve_forever, + kwargs={"poll_interval": 0.01}, daemon=True) + thread.start() + httpd.url = f"http://127.0.0.1:{httpd.server_address[1]}" + return httpd, thread + + +def stop_server(httpd, thread): + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + +@pytest.fixture +def two_devices(server, device, alice): # noqa: F811 + """alice on device a, and the same account signed in on device b. + Returns (a, b); switch with device("a") / device("b") before using one.""" + device("b") + other, _ = account_sync.sign_in(server.url, "", "alice", PW, alice.kit) + device("a") + return alice, other + + +# --------------------------------------------------------------------------- +# A. Names and crypto +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("name", ["Work", "Família 家族 🔐", "a"]) +def test_a_sealed_name_opens_again(name): + key, vid = account_crypto.new_vault_key(), account_crypto.new_vault_id() + sealed = account_crypto.seal_vault_name(key, vid, name) + assert account_crypto.open_vault_name(key, vid, sealed) == name + + +def test_a_sealed_name_hides_the_name_and_differs_each_time(): + key, vid = account_crypto.new_vault_key(), account_crypto.new_vault_id() + one = account_crypto.seal_vault_name(key, vid, "Secret Project") + two = account_crypto.seal_vault_name(key, vid, "Secret Project") + assert one != two + for sealed in (one, two): + assert "Secret Project" not in sealed + assert b"Secret Project" not in base64.b64decode(sealed) + + +def test_a_name_opens_only_with_its_own_key_and_vault_id(): + key, vid = account_crypto.new_vault_key(), account_crypto.new_vault_id() + sealed = account_crypto.seal_vault_name(key, vid, "Work") + with pytest.raises(CryptoError): + account_crypto.open_vault_name(account_crypto.new_vault_key(), vid, sealed) + with pytest.raises(CryptoError): + account_crypto.open_vault_name(key, account_crypto.new_vault_id(), sealed) + + +def test_seal_refuses_an_empty_or_too_long_name(): + key, vid = account_crypto.new_vault_key(), account_crypto.new_vault_id() + with pytest.raises(CryptoError): + account_crypto.seal_vault_name(key, vid, "") + with pytest.raises(CryptoError): + account_crypto.seal_vault_name(key, vid, "x" * 201) + with pytest.raises(CryptoError): + account_crypto.seal_vault_name(key, vid, "\U0001F510" * 51) # 204 bytes + account_crypto.seal_vault_name(key, vid, "x" * 200) # the limit + + +def test_a_damaged_sealed_name_is_refused(): + key, vid = account_crypto.new_vault_key(), account_crypto.new_vault_id() + sealed = account_crypto.seal_vault_name(key, vid, "Work") + raw = bytearray(base64.b64decode(sealed)) + flipped = bytearray(raw) + flipped[-1] ^= 1 + for damaged in (base64.b64encode(bytes(raw[:10])).decode(), + base64.b64encode(bytes(flipped)).decode(), + base64.b64encode(bytes(raw[:-3])).decode(), + "", "not base64 !!"): + with pytest.raises(CryptoError): + account_crypto.open_vault_name(key, vid, damaged) + + +def test_clean_vault_name_tidies_whitespace(): + assert accounts.clean_vault_name(" Family photos \t ") == "Family photos" + assert accounts.clean_vault_name("a\n b") == "a b" + assert accounts.clean_vault_name("x" * 60) == "x" * 60 + + +@pytest.mark.parametrize("bad", [None, "", " \t ", "x" * 61, "a\x00b", "a\x07b", + "a\x7fb", "\U0001F510" * 60]) +def test_clean_vault_name_refuses(bad): + with pytest.raises(ValueError): + accounts.clean_vault_name(bad) + + +# --------------------------------------------------------------------------- +# B. Vaults on one device +# --------------------------------------------------------------------------- +def test_a_fresh_account_has_only_its_personal_vault(): + acc = make() + assert acc.vault_ids() == [acc.personal_id] + assert acc.personal_id == acc.vault_id.hex() + file = acc.vault_file() + assert file.personal and file.name == accounts.PERSONAL_NAME + assert file.hex == acc.personal_id + assert acc.vault_file(acc.personal_id).hex == acc.personal_id + + +def test_create_vault_lists_it_after_the_personal_one_by_name(): + acc = make() + zed = acc.create_vault("zed") + bravo = acc.create_vault("bravo") + alpha = acc.create_vault("Alpha") + assert acc.vault_ids() == [acc.personal_id, alpha.hex, bravo.hex, zed.hex] + assert not alpha.personal and alpha.name == "Alpha" + assert acc.vault_record(alpha.hex)["name"] == "Alpha" + assert acc.vault_record(acc.personal_id) == {} + + +def test_a_new_vault_file_is_a_pmv3_blob_of_that_vault(): + acc = make() + made = acc.create_vault("Work") + assert os.path.isfile(made.path) + header = account_crypto.vault_header(read_file(made.path).decode()) + assert header["vault_id"] == bytes.fromhex(made.hex) + assert header["vault_id"] != acc.vault_id + assert made.load() == {"accounts": []} + + +def test_entries_saved_in_a_vault_load_back_and_survive_a_lock(): + acc = make() + made = acc.create_vault("Work") + put_entry(acc, made.hex, "intranet") + assert sites(acc, made.hex) == ["intranet"] + again = accounts.unlock("alice", PW) + assert again.vault_ids() == acc.vault_ids() + assert again.vault_file(made.hex).name == "Work" + assert sites(again, made.hex) == ["intranet"] + assert sites(again) == [] # not in the personal vault + + +def test_nothing_of_a_vault_is_readable_on_disk(): + acc = make() + made = acc.create_vault("Work") + put_entry(acc, made.hex, "marker-site-xyz", "marker-password-xyz") + key = made._key + on_disk = read_file(os.path.join(accounts.account_dir("alice"), + "account.json")) + read_file(made.path) + for needle in (key, key.hex().encode(), account_crypto._b64(key).encode(), + b"marker-site-xyz", b"marker-password-xyz"): + assert needle not in on_disk + # account.json does hold the (wrapped) key and the name, nothing more + assert acc.vault_record(made.hex)["wrapped_key"].encode() in on_disk + + +def test_a_blob_of_one_vault_is_refused_by_another(): + acc = make() + a, b = acc.create_vault("A"), acc.create_vault("B") + blob_a = a.encrypt({"accounts": []}) + blob_p = acc.encrypt({"accounts": []}) + with pytest.raises(CryptoError): + b.decrypt(blob_a) + with pytest.raises(CryptoError): + acc.decrypt(blob_a) + with pytest.raises(CryptoError): + a.decrypt(blob_p) + with pytest.raises(CryptoError): + b.decrypt(blob_p) + assert a.decrypt(blob_a) == {"accounts": []} + + +def test_a_blob_with_another_vaults_id_is_refused_even_under_the_same_key(): + """Same key, other vault id: the id check alone has to refuse it.""" + acc = make() + a = acc.create_vault("A") + other_id = account_crypto.new_vault_id() + blob = account_crypto.encrypt_vault(a._key, other_id, 0, {"accounts": []}) + with pytest.raises(CryptoError): + a.decrypt(blob) + + +def test_every_vault_has_its_own_key(): + acc = make() + a, b = acc.create_vault("A"), acc.create_vault("B") + assert len({a._key, b._key, acc.vault_file()._key}) == 3 + # and a blob made with the personal key, labelled as A, does not open as A + blob = account_crypto.encrypt_vault(acc.vault_file()._key, + bytes.fromhex(a.hex), 0, {"accounts": []}) + with pytest.raises(CryptoError): + a.decrypt(blob) + + +def test_swapped_vault_files_fail_to_load_instead_of_showing_the_other(): + acc = make() + a, b = acc.create_vault("A"), acc.create_vault("B") + for f in (a, b): + for leftover in (f.path + vault.BACKUP_SUFFIX,): + if os.path.exists(leftover): + os.remove(leftover) + blob_a, blob_b = read_file(a.path), read_file(b.path) + with open(a.path, "wb") as f: + f.write(blob_b) + with open(b.path, "wb") as f: + f.write(blob_a) + with pytest.raises(CryptoError): + a.load() + with pytest.raises(CryptoError): + b.load() + + +def test_the_personal_file_in_place_of_another_vault_fails_to_load(): + acc = make() + a = acc.create_vault("A") + for leftover in (a.path + vault.BACKUP_SUFFIX,): + if os.path.exists(leftover): + os.remove(leftover) + put_entry(acc, acc.personal_id, "personal-only") + with open(a.path, "wb") as f: + f.write(read_file(acc.vault_path)) + os.remove(a.path + vault.BACKUP_SUFFIX) if os.path.exists( + a.path + vault.BACKUP_SUFFIX) else None + with pytest.raises(CryptoError): + a.load() + + +def test_vault_names_are_unique_ignoring_case_and_never_personal(): + acc = make() + acc.create_vault("Work") + for bad in ("work", "WORK", " Work ", "Personal", "personal", "PERSONAL"): + with pytest.raises(ValueError): + acc.create_vault(bad) + assert len(acc.vault_ids()) == 2 + + +def test_a_bad_name_creates_nothing(): + acc = make() + for bad in ("", "x" * 61, "a\x00"): + with pytest.raises(ValueError): + acc.create_vault(bad) + assert acc.vault_ids() == [acc.personal_id] + assert os.listdir(os.path.dirname(acc.vault_path)) == [ + acc.personal_id + ".pmv3"] + + +def test_rename_a_vault(): + acc = make() + work = acc.create_vault("Work") + other = acc.create_vault("Other") + acc.rename_vault(work.hex, " Office ") + assert acc.vault_file(work.hex).name == "Office" + assert accounts.unlock("alice", PW).vault_file(work.hex).name == "Office" + # not on a server, so there is nothing to tell it + assert not acc.vault_record(work.hex)["rename_pending"] + acc.rename_vault(work.hex, "WORK") # its own name, new case + assert acc.vault_file(work.hex).name == "WORK" + with pytest.raises(ValueError): + acc.rename_vault(work.hex, "other") # taken + with pytest.raises(ValueError): + acc.rename_vault(work.hex, "Personal") + with pytest.raises(ValueError): + acc.rename_vault(acc.personal_id, "Mine") + with pytest.raises(ValueError): + acc.rename_vault(os.urandom(16).hex(), "Ghost") + with pytest.raises(ValueError): + acc.rename_vault(work.hex, "") + assert acc.vault_file(other.hex).name == "Other" + + +def test_rename_marks_a_name_the_server_does_not_know_yet(): + acc = make() + work = acc.create_vault("Work") + acc.mark_vault(work.hex, on_server=True) + acc.rename_vault(work.hex, "Office") + assert acc.vault_record(work.hex)["rename_pending"] is True + acc.rename_vault(work.hex, "Office 2", pending=False) + assert acc.vault_record(work.hex)["rename_pending"] is False + + +def test_delete_vault_removes_its_record_and_files_only(): + acc = make() + a, b = acc.create_vault("A"), acc.create_vault("B") + put_entry(acc, a.hex, "in-a") + put_entry(acc, b.hex, "in-b") + put_entry(acc, acc.personal_id, "in-personal") + a.save_base({"accounts": []}, {"url": "u", "revision": 1}) + assert all(os.path.exists(p) for p in (a.path, a.path + ".bak", a.base_path)) + acc.delete_vault(a.hex) + assert not any(os.path.exists(p) for p in (a.path, a.path + ".bak", + a.base_path)) + assert acc.vault_ids() == [acc.personal_id, b.hex] + with pytest.raises(KeyError): + acc.vault_file(a.hex) + again = accounts.unlock("alice", PW) + assert again.vault_ids() == [acc.personal_id, b.hex] + assert sites(again, b.hex) == ["in-b"] + assert sites(again) == ["in-personal"] + + +def test_delete_vault_refuses_the_personal_vault_and_strangers(): + acc = make() + with pytest.raises(ValueError): + acc.delete_vault(acc.personal_id) + with pytest.raises(ValueError): + acc.delete_vault(os.urandom(16).hex()) + assert acc.vault_ids() == [acc.personal_id] + assert acc.vault_file().has_file + + +def test_a_vault_wrapped_for_another_account_cannot_be_adopted(): + acc_a = make("alice") + acc_b = make("bob") + vid = account_crypto.new_vault_id() + key = account_crypto.new_vault_key() + wrapped = account_crypto.wrap_key( + account_crypto.public_key_of(acc_b.keyset), key, vid, "bob") + sealed = account_crypto.seal_vault_name(key, vid, "Bobs") + with pytest.raises(CryptoError): + acc_a.adopt_vault(vid.hex(), wrapped, sealed) + assert acc_a.vault_ids() == [acc_a.personal_id] + assert accounts.unlock("alice", PW).vault_ids() == [acc_a.personal_id] + # ... but bob can + assert acc_b.adopt_vault(vid.hex(), wrapped, sealed).name == "Bobs" + + +def test_adopting_with_a_name_that_is_not_the_vaults_is_refused(): + acc = make() + vid = account_crypto.new_vault_id() + key = account_crypto.new_vault_key() + wrapped = account_crypto.wrap_key(account_crypto.public_key_of(acc.keyset), + key, vid, "alice") + wrong = account_crypto.seal_vault_name(account_crypto.new_vault_key(), vid, "X") + with pytest.raises(CryptoError): + acc.adopt_vault(vid.hex(), wrapped, wrong) + assert acc.vault_ids() == [acc.personal_id] + + +def test_an_adopted_vault_with_a_taken_name_gets_a_suffix(): + acc = make() + mine = acc.create_vault("Work") + vid = account_crypto.new_vault_id() + key = account_crypto.new_vault_key() + wrapped = account_crypto.wrap_key(account_crypto.public_key_of(acc.keyset), + key, vid, "alice") + adopted = acc.adopt_vault(vid.hex(), wrapped, + account_crypto.seal_vault_name(key, vid, "work")) + assert adopted.name == "work (2)" + assert acc.vault_file(mine.hex).name == "Work" + assert acc.vault_record(vid.hex())["on_server"] is True + # a third of the same name goes on counting + vid2 = account_crypto.new_vault_id() + key2 = account_crypto.new_vault_key() + wrapped2 = account_crypto.wrap_key(account_crypto.public_key_of(acc.keyset), + key2, vid2, "alice") + third = acc.adopt_vault(vid2.hex(), wrapped2, + account_crypto.seal_vault_name(key2, vid2, "Work")) + assert third.name == "Work (3)" + assert len(acc.vault_ids()) == 4 + + +def test_the_personal_vault_methods_work_as_before(): + acc = make() + acc.save_vault({"accounts": [entry("one")]}) + assert [e["site"] for e in acc.load_vault()["accounts"]] == ["one"] + assert acc.has_vault_file + assert acc.vault_path == acc.vault_file().path + assert acc.decrypt(acc.encrypt({"accounts": []})) == {"accounts": []} + acc.save_base({"accounts": []}, {"url": "u", "revision": 3}) + assert acc.load_base()[vault.BASE_ORIGIN_KEY] == {"url": "u", "revision": 3} + assert acc.base_path == acc.vault_file().base_path + + +def test_a_damaged_vault_file_falls_back_to_the_previous_save(): + acc = make() + for vault_id in (None, acc.create_vault("Work").hex): + file = acc.vault_file(vault_id) + file.save({"accounts": [entry("first")]}) + file.save({"accounts": [entry("first"), entry("second")]}) + with open(file.path, "wb") as f: + f.write(b"damaged beyond reading") + assert [e["site"] for e in file.load()["accounts"]] == ["first"] + # and for the personal vault through the old method + assert [e["site"] for e in acc.load_vault()["accounts"]] == ["first"] + + +# --------------------------------------------------------------------------- +# C. With the server +# --------------------------------------------------------------------------- +def test_a_vault_made_on_a_appears_on_b_with_its_entries(server, device, + two_devices): + a, b = two_devices + work = a.create_vault("Work") + put_entry(a, work.hex, "intranet") + put_entry(a, a.personal_id, "personal-site") + sync_all(a) + + device("b") + changes = reconcile(b) + assert changes.added == ["Work"] + assert b.vault_ids() == [b.personal_id, work.hex] + assert b.vault_file(work.hex).name == "Work" + assert syncable(b) == [b.personal_id, work.hex] + for vault_id in syncable(b): + sync_vault(b, vault_id) + assert sites(b, work.hex) == ["intranet"] + assert sites(b) == ["personal-site"] + + put_entry(b, work.hex, "from-b") + sync_vault(b, work.hex) + device("a") + sync_vault(a, work.hex) + assert sites(a, work.hex) == ["from-b", "intranet"] + assert sites(a) == ["personal-site"] + + +def test_entries_stay_in_their_own_vault_across_devices(server, device, + two_devices): + a, b = two_devices + x, y = a.create_vault("X"), a.create_vault("Y") + put_entry(a, x.hex, "only-x") + put_entry(a, y.hex, "only-y") + put_entry(a, a.personal_id, "only-personal") + sync_all(a) + device("b") + sync_all(b) + assert sites(b, x.hex) == ["only-x"] + assert sites(b, y.hex) == ["only-y"] + assert sites(b) == ["only-personal"] + put_entry(b, x.hex, "x-from-b") + sync_vault(b, x.hex) + device("a") + sync_all(a) + assert sites(a, x.hex) == ["only-x", "x-from-b"] + assert sites(a, y.hex) == ["only-y"] + assert sites(a) == ["only-personal"] + + +def test_reconcile_reports_what_it_did_once(server, device, two_devices): + a, b = two_devices + a.create_vault("Work") + first = reconcile(a) + assert first.uploaded == ["Work"] and not first.added and bool(first) + again = reconcile(a) + assert not again and not again.uploaded and not again.added + assert not (again.gone or again.renamed or again.server_too_old) + + device("b") + got = reconcile(b) + assert got.added == ["Work"] and not got.uploaded and bool(got) + assert not reconcile(b) + + +def test_sign_in_on_a_new_device_brings_every_vault(server, device, alice): + x, y = alice.create_vault("Xray"), alice.create_vault("Yankee") + put_entry(alice, x.hex, "x1") + put_entry(alice, x.hex, "x2") + put_entry(alice, y.hex, "y1") + put_entry(alice, alice.personal_id, "p1") + sync_all(alice) + + device("b") + other, _ = account_sync.sign_in(server.url, "", "alice", PW, alice.kit) + assert other.vault_ids() == [other.personal_id, x.hex, y.hex] + assert [other.vault_file(v).name for v in other.vault_ids()] == [ + "Personal", "Xray", "Yankee"] + assert sites(other, x.hex) == ["x1", "x2"] + assert sites(other, y.hex) == ["y1"] + assert sites(other) == ["p1"] + # signed in with everything agreed: nothing more to report or merge + assert not reconcile(other) + unlocked = accounts.unlock("alice", PW) + assert sites(unlocked, x.hex) == ["x1", "x2"] + + +def test_a_rename_on_a_reaches_b(server, device, two_devices): + a, b = two_devices + work = a.create_vault("Work") + sync_all(a) + device("b") + sync_all(b) + + device("a") + a.rename_vault(work.hex, "Office é家") + assert a.vault_record(work.hex)["rename_pending"] is True + assert not reconcile(a).renamed # told to the server + assert a.vault_record(work.hex)["rename_pending"] is False + + device("b") + changes = reconcile(b) + assert changes.renamed == [("Work", "Office é家")] + assert b.vault_file(work.hex).name == "Office é家" + assert accounts.unlock("alice", PW).vault_file(work.hex).name == \ + "Office é家" + assert not reconcile(b) # and only once + + +def test_the_server_never_holds_a_name_entry_or_vault_key(server, device, + alice): + work = alice.create_vault("Visible-Name-Marker") + alt = alice.create_vault("Faília 家") + put_entry(alice, work.hex, "entry-marker-site", "entry-marker-password") + put_entry(alice, alt.hex, "other-marker-site") + sync_all(alice) + alice.rename_vault(work.hex, "Renamed-Name-Marker") + reconcile(alice) + everything = store_bytes(server) + assert len(everything) > 0 + for vault_id in (work.hex, alt.hex): + key = alice.vault_file(vault_id)._key + assert key not in everything + assert key.hex().encode() not in everything + assert account_crypto._b64(key).encode() not in everything + for needle in ("Visible-Name-Marker", "Renamed-Name-Marker", + "Faília 家", "entry-marker-site", + "entry-marker-password", "other-marker-site"): + assert needle.encode("utf-8") not in everything, needle + + +def test_delete_on_a_is_seen_by_b_which_keeps_its_vault(server, device, + two_devices): + a, b = two_devices + work = a.create_vault("Work") + put_entry(a, work.hex, "precious") + sync_all(a) + device("b") + sync_all(b) + put_entry(b, work.hex, "b-only-entry") # never synced + + device("a") + account_sync.delete_vault(a, work.hex) + assert a.vault_ids() == [a.personal_id] + assert not os.path.exists(work.path) + assert not os.path.exists(os.path.join(server.accounts.vaults.root, work.hex)) + graveyard = glob.glob(os.path.join(server.store.root, "_v2", "deleted", + work.hex + "-*")) + assert len(graveyard) == 1 + + device("b") + changes = reconcile(b) + assert changes.gone == ["Work"] and bool(changes) + assert b.vault_ids() == [b.personal_id, work.hex] + assert sites(b, work.hex) == ["b-only-entry", "precious"] + assert b.vault_record(work.hex)["gone_from_server"] is True + assert syncable(b) == [b.personal_id] + assert not reconcile(b) + assert syncable(b) == [b.personal_id] + # and it is still kept after a restart of the app + assert accounts.unlock("alice", PW).vault_record(work.hex)[ + "gone_from_server"] is True + + +def test_a_vault_put_back_goes_up_again_with_its_entries(server, device, + two_devices): + a, b = two_devices + work = a.create_vault("Work") + put_entry(a, work.hex, "precious") + sync_all(a) + device("b") + sync_all(b) + put_entry(b, work.hex, "b-entry") + device("a") + account_sync.delete_vault(a, work.hex) + + device("b") + assert reconcile(b).gone == ["Work"] + account_sync.put_vault_back(b, work.hex) + assert not b.vault_record(work.hex)["gone_from_server"] + assert not b.vault_record(work.hex)["on_server"] + assert not os.path.exists(b.vault_file(work.hex).base_path) + changes = reconcile(b) + assert changes.uploaded == ["Work"] and not changes.gone + assert work.hex in syncable(b) + sync_vault(b, work.hex) + + device("a") + got = reconcile(a) + assert got.added == ["Work"] + sync_vault(a, work.hex) + assert sites(a, work.hex) == ["b-entry", "precious"] + + +def test_a_vault_is_not_deleted_when_the_server_cannot_be_reached(server, device, + alice): + work = alice.create_vault("Work") + put_entry(alice, work.hex, "keep-me") + sync_all(alice) + server.shutdown() + server.server_close() + with pytest.raises(SyncError): + account_sync.delete_vault(alice, work.hex) + assert alice.vault_ids() == [alice.personal_id, work.hex] + assert sites(alice, work.hex) == ["keep-me"] + assert accounts.unlock("alice", PW).vault_ids() == [alice.personal_id, + work.hex] + + +def test_a_vault_is_not_deleted_when_the_server_address_is_dead(server, device, + alice): + work = alice.create_vault("Work") + sync_all(alice) + alice.info["server"] = {"url": "http://127.0.0.1:9", "ca_file": ""} + with pytest.raises(SyncError): + account_sync.delete_vault(alice, work.hex) + assert work.hex in alice.vault_ids() + assert os.path.exists(work.path) + + +def test_a_vault_is_not_deleted_when_the_server_refuses_the_delete( + server, device, alice, monkeypatch): + """The login works, the delete call itself fails.""" + work = alice.create_vault("Work") + put_entry(alice, work.hex, "keep-me") + sync_all(alice) + + def refuse(self, vault_id): + raise SyncError("The server is having a bad day.") + monkeypatch.setattr(account_sync.AccountServer, "delete_vault", refuse) + with pytest.raises(SyncError, match="bad day"): + account_sync.delete_vault(alice, work.hex) + assert alice.vault_ids() == [alice.personal_id, work.hex] + assert sites(alice, work.hex) == ["keep-me"] + assert os.path.isdir(os.path.join(server.accounts.vaults.root, work.hex)) + + +def test_a_vault_never_on_a_server_is_deleted_without_asking_it(server, device, + alice): + work = alice.create_vault("Local only") + server.shutdown() + server.server_close() + account_sync.delete_vault(alice, work.hex) # nothing to tell + assert alice.vault_ids() == [alice.personal_id] + + +def _health_without_features(self): + return {"ok": True, "service": "passman-sync", "api": [1, 2]} + + +@pytest.mark.parametrize("how", ["no_features_in_health", "has_vaults_false"]) +def test_a_server_without_vaults_keeps_extra_vaults_here(server, device, alice, + monkeypatch, how): + if how == "no_features_in_health": + monkeypatch.setattr(account_sync.AccountServer, "health", + _health_without_features) + else: + monkeypatch.setattr(account_sync.AccountServer, "has_vaults", + lambda self: False) + work = alice.create_vault("Work") + put_entry(alice, work.hex, "local-entry") + put_entry(alice, alice.personal_id, "personal-entry") + changes = reconcile(alice) + assert changes.server_too_old is True and bool(changes) + assert not changes.uploaded + assert syncable(alice) == [alice.personal_id] + assert not alice.vault_record(work.hex).get("on_server") + api = account_sync.logged_in(alice) + try: + assert [v["id"] for v in api.me()["vaults"]] == [alice.personal_id] + finally: + api.logout() + assert not os.path.exists(os.path.join(server.accounts.vaults.root, work.hex)) + # the personal vault still syncs + assert sites(alice) == ["personal-entry"] + sync_vault(alice, alice.personal_id) + stored = server.accounts.vaults.read(alice.personal_id).decode() + assert alice.decrypt(stored)["accounts"][0]["site"] == "personal-entry" + assert sites(alice, work.hex) == ["local-entry"] + + +def test_without_extra_vaults_an_old_server_is_not_a_problem(server, device, + alice, monkeypatch): + monkeypatch.setattr(account_sync.AccountServer, "has_vaults", + lambda self: False) + changes = reconcile(alice) + assert not changes and not changes.server_too_old + + +def test_two_accounts_on_one_server_never_see_each_others_vaults(server, device, + alice): + work = alice.create_vault("Work") + put_entry(alice, work.hex, "alice-secret-entry") + sync_all(alice) + + bob = new_account("bob", "bobs own password").save() + assert account_sync.connect(bob, server.url, "", TOKEN) == "created" + assert not reconcile(bob) + assert bob.vault_ids() == [bob.personal_id] + session = account_sync.logged_in(bob) + try: + with pytest.raises(SyncError): + session.get_vault(work.hex) + with pytest.raises(SyncError): + session.rename_vault(work.hex, "x") + with pytest.raises(SyncError): + session.delete_vault(work.hex) + finally: + session.logout() + # still there for alice + assert reconcile(alice) is not None + assert work.hex in alice.vault_ids() + assert os.path.isdir(os.path.join(server.accounts.vaults.root, work.hex)) + + +def test_conflicts_are_per_vault(server, device, two_devices): + a, b = two_devices + work = a.create_vault("Work") + put_entry(a, work.hex, "shared-site", "v1") + put_entry(a, a.personal_id, "personal-site", "v1") + sync_all(a) + device("b") + sync_all(b) + + def edit(account, vault_id, password): + file = account.vault_file(vault_id) + data = file.load() + data["accounts"][0]["password"] = password + file.save(data) + + edit(b, work.hex, "from-b") + sync_vault(b, work.hex) + device("a") + edit(a, work.hex, "from-a") + + in_work = account_sync.AccountSync(a, None, work.hex) + try: + result = in_work.prepare(a.vault_file(work.hex).load()) + assert len(result.conflicts) == 1 + finally: + in_work.close() + in_personal = account_sync.AccountSync(a, None) + try: + result = in_personal.prepare(a.load_vault()) + assert result.conflicts == [] + assert not in_personal.has_work() + finally: + in_personal.close() + + # resolving it in X gives B's version there, and leaves the others alone + session = account_sync.AccountSync(a, None, work.hex) + try: + local = a.vault_file(work.hex).load() + result = session.prepare(local) + merged = session.commit(local, {result.conflicts[0].id: sync.KEEP_REMOTE}) + finally: + session.close() + assert merged["accounts"][0]["password"] == "from-b" + assert a.load_vault()["accounts"][0]["password"] == "v1" + + +def test_an_account_sync_works_on_the_vault_it_was_given(server, device, alice): + work = alice.create_vault("Work") + put_entry(alice, work.hex, "in-work") + put_entry(alice, alice.personal_id, "in-personal") + reconcile(alice) + session = account_sync.AccountSync(alice, None, work.hex) + assert session.file.hex == work.hex + session.close() + sync_vault(alice, work.hex) + stored_work = server.accounts.vaults.read(work.hex).decode() + assert work.decrypt(stored_work)["accounts"][0]["site"] == "in-work" + # the personal vault on the server is still empty: it was not synced + api = account_sync.logged_in(alice) + try: + assert api.get_vault(alice.personal_id)["blob"] is None + finally: + api.logout() + # the work blob is not a personal blob + with pytest.raises(CryptoError): + alice.decrypt(stored_work) + + +def test_changing_the_server_clears_what_the_old_one_knew(server, device, alice, + tmp_path): + work = alice.create_vault("Work") + gone = alice.create_vault("Gone") + put_entry(alice, work.hex, "keep-this") + sync_all(alice) + alice.rename_vault(work.hex, "Office") + alice.mark_vault(gone.hex, gone_from_server=True) + wrapped = {v: alice.vault_record(v)["wrapped_key"] for v in (work.hex, gone.hex)} + assert alice.vault_record(work.hex)["on_server"] + assert alice.vault_record(work.hex)["rename_pending"] + + # the same address again changes nothing + alice.set_server(server.url + "/", "") + assert alice.vault_record(work.hex)["on_server"] + assert alice.vault_record(work.hex)["rename_pending"] + assert alice.vault_record(gone.hex)["gone_from_server"] + + second, thread = start_server(tmp_path / "second-store") + try: + alice.set_server(second.url, "") + for vault_id, name in ((work.hex, "Office"), (gone.hex, "Gone")): + record = alice.vault_record(vault_id) + for flag in ("on_server", "gone_from_server", "rename_pending"): + assert flag not in record + assert record["name"] == name + assert record["wrapped_key"] == wrapped[vault_id] + # persisted too + assert "on_server" not in accounts.unlock("alice", PW).vault_record( + work.hex) + assert account_sync.connect(alice, second.url, "", TOKEN) == "created" + changes = reconcile(alice) + assert sorted(changes.uploaded) == ["Gone", "Office"] + assert alice.vault_record(work.hex)["on_server"] is True + assert work.hex in syncable(alice) + sync_vault(alice, work.hex) + assert os.path.exists(os.path.join(second.accounts.vaults.root, work.hex)) + assert sites(alice, work.hex) == ["keep-this"] + finally: + stop_server(second, thread) + + +def test_forgetting_the_server_clears_the_flags_too(server, device, alice): + work = alice.create_vault("Work") + reconcile(alice) + assert alice.vault_record(work.hex)["on_server"] + alice.set_server(None) + assert "on_server" not in alice.vault_record(work.hex) + assert alice.vault_file(work.hex).name == "Work" diff --git a/sourceFiles/vaults_ui.py b/sourceFiles/vaults_ui.py new file mode 100644 index 0000000..b19fe06 --- /dev/null +++ b/sourceFiles/vaults_ui.py @@ -0,0 +1,198 @@ +""" +The Vaults window (phase 3): the vaults of the open account -- add one, +rename one, delete one. + +An account always has its personal vault. Any further vault has its own +random key, kept wrapped to the account's key, so each can later be shared +with other people without touching the rest. + +The same rules as the other screens: the network (deleting a vault on the +server) runs on a worker thread, widgets are touched only on the main one. +""" + +import customtkinter as ctk + +import account_sync +from sync_ui import _in_background +from theme import F, TEXT, MUTED, CARD, BORDER, WARN +from widgets import (dialog_shell, ghost_button, accent_button, small_button, + field, error_label) + + +def _count(data) -> str: + live = sum(1 for a in data.get("accounts", []) if not a.get("archived")) + return f"{live} entr{'y' if live == 1 else 'ies'}" + + +def _status(app, vault_id): + """(text, colour) under a vault's name: where it lives.""" + account = app.account + if vault_id == account.personal_id: + return "Your own vault. It comes with the account.", MUTED + record = account.vault_record(vault_id) + if record.get("gone_from_server"): + return ("Deleted on another device. Kept on this PC only, and no " + "longer synced.", WARN) + if not account.server: + return "On this PC.", MUTED + if record.get("on_server"): + return "On the server — your other devices get it when they sync.", MUTED + return "On this PC until the next sync puts it on the server.", MUTED + + +def vaults_dialog(app): + account = app.account + if account is None: + return + top = app._modal("Vaults", 580, 640) + shell = dialog_shell( + top, "Vaults", + "Keep logins apart: home, work, a project. Every vault has its own " + "key. Sharing a vault with other people comes in a later version.", + scroll=True) + listing = shell.body + listing.grid_columnconfigure(0, weight=1) + + def changed(message=None, kind="info"): + app.load_vaults() + app.refresh_account_list() + if top.winfo_exists(): + rebuild() + if message: + app.toast(message, kind) + + def rebuild(): + for child in listing.winfo_children(): + child.destroy() + for i, vault_id in enumerate(account.vault_ids()): + _vault_row(app, listing, i, vault_id, changed) + + def new_vault(): + name = _ask_name(app, "New vault", "What should it be called? You can " + "change the name later.", "", "Create vault", + lambda text: account.create_vault(text)) + if name: + changed(f"Vault “{name}” created" + ( + " — it goes to the server with the next sync" + if account.server else "")) + + rebuild() + ghost_button(shell.buttons, "Close", top.destroy, width=110).pack(side="left") + accent_button(shell.buttons, "New vault…", new_vault, + width=150).pack(side="right") + top.bind("", lambda e: top.destroy()) + + +def _vault_row(app, parent, index, vault_id, changed): + account = app.account + personal = vault_id == account.personal_id + name = app.vault_name(vault_id) + record = account.vault_record(vault_id) + + row = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=14, + border_width=1, border_color=BORDER) + row.grid(row=index, column=0, sticky="ew", padx=2, + pady=(10 if index == 0 else 4, 4)) + row.grid_columnconfigure(0, weight=1) + + info = ctk.CTkFrame(row, fg_color="transparent") + info.grid(row=0, column=0, sticky="w", padx=(16, 8), pady=12) + ctk.CTkLabel(info, text=name, anchor="w", font=F(14, "bold"), + text_color=TEXT).pack(anchor="w") + text, colour = _status(app, vault_id) + ctk.CTkLabel(info, text=f"{_count(app.vaults.get(vault_id, {}))} · {text}", + anchor="w", justify="left", wraplength=300, font=F(12), + text_color=colour).pack(anchor="w") + if personal: + return + + buttons = ctk.CTkFrame(row, fg_color="transparent") + buttons.grid(row=0, column=1, padx=(0, 14)) + + def rename(): + new = _ask_name(app, f"Rename “{name}”", "The new name shows on all " + "your devices after they sync.", name, "Rename", + lambda text: account.rename_vault(vault_id, text)) + if new: + changed(f"Renamed to “{new}”") + + def put_back(): + account_sync.put_vault_back(account, vault_id) + changed(f"“{name}” goes back onto the server with the next sync") + + def delete(): + _delete_vault(app, vault_id, name, changed) + + if record.get("gone_from_server") and account.server: + small_button(buttons, "Put back", put_back, accent=True, + width=84).pack(side="left", padx=(0, 6)) + else: + small_button(buttons, "Rename", rename, width=78).pack( + side="left", padx=(0, 6)) + small_button(buttons, "Delete", delete, danger=True, + width=72).pack(side="left") + + +def _ask_name(app, title, subtitle, initial, action, apply): + """ + Ask for a vault name and run ``apply(name)``, which raises ValueError + with the reason when the name will not do. Returns the name as it was + stored, or None when the window was closed. + """ + top = app._modal(title, 460, 280) + shell = dialog_shell(top, title, subtitle) + box = field(shell.body, "Name", placeholder="e.g. Home") + box.insert(0, initial) + err = error_label(shell.body, shell.text_width) + outcome = {"name": None} + + def go(): + try: + apply(box.get()) + except ValueError as e: + err.configure(text=str(e)) + return + outcome["name"] = " ".join(box.get().split()) + top.destroy() + + ghost_button(shell.buttons, "Cancel", top.destroy, width=110).pack(side="left") + accent_button(shell.buttons, action, go, width=150).pack(side="right") + box.bind("", lambda e: go()) + top.bind("", lambda e: top.destroy()) + top.after(200, box.focus) + app.root.wait_window(top) + return outcome["name"] + + +def _delete_vault(app, vault_id, name, changed): + account = app.account + record = account.vault_record(vault_id) + on_server = bool(account.server and record.get("on_server") + and not record.get("gone_from_server")) + stored = len(app.vaults.get(vault_id, {}).get("accounts", [])) + where = ("on this PC and on the server, so it disappears from your other " + "devices too" if on_server else "on this PC") + import dialogs # late: dialogs imports this module's users + if not dialogs.ask_master_password( + app, f"Delete “{name}”", + f"This deletes the vault and everything in it " + f"({stored} entr{'y' if stored == 1 else 'ies'}, archive included) " + f"{where}. It cannot be undone here. Enter your master password " + "to confirm.", action="Delete vault"): + return + if getattr(app, "_syncing", False): + return app.toast("Wait for the sync to finish, then delete the vault", + "warn") + + def done(_): + if app.account is not account: + return # locked meanwhile + changed(f"Vault “{name}” deleted", "warn") + + def failed(error): + if app.account is account: + app.toast(f"The vault was not deleted: {error}", "error") + + app.toast("Deleting…") + _in_background(app, lambda: account_sync.delete_vault(account, vault_id), + done, on_error=failed) diff --git a/sourceFiles/version.py b/sourceFiles/version.py index 2338f73..925bc90 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "1.0.0-alpha.4" +__version__ = "1.0.0-alpha.5" #: The container a 3.0 account's vaults are written in (account_crypto). VAULT_FORMAT = "PMV3"