diff --git a/README.md b/README.md index 839d6f1..44356bf 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.5, a SecretSpace 3 test version — not for real passwords yet.** +**`main` is 1.0.0-alpha.6, 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,14 +31,14 @@ 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.5. +Windows 3.0.0-alpha.6. 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.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): +1.0.0-alpha.6 is the same as [SecretSpace for Windows +3.0.0-alpha.6](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 @@ -47,7 +47,12 @@ not changed). Since alpha.3 it syncs through a SecretSpace Server 2.0 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: +2.0.0-alpha.2 for more than the Personal vault. Since alpha.6 a vault can be +**shared** with other accounts of the same server (*Vaults → Members*: +owner, editor or reader, a fingerprint check before the first share, and +*Remove* gives the vault a new key), and every vault keeps a signed record +of **who changed what** (*Vaults → Activity*) — never of who viewed or +copied a password. Sharing needs SecretSpace Server 2.0.0-alpha.3. 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 @@ -57,7 +62,7 @@ the list, and all covered by **Sync** — which needs SecretSpace Server - 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.5`, which apt sorts after +- The package spells the version `1.0.0~alpha.6`, which apt sorts after 0.9.0 and before 1.0.0. | File | What it is | @@ -191,6 +196,7 @@ Where that README names a Windows version, read the Linux one beside it: | 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) | +| 3.0.0-alpha.6 | 1.0.0-alpha.6 | sharing a vault (owner / editor / reader), who changed what (Server 2.0.0-alpha.3) | (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 5f5c6b5..5e784c5 100644 --- a/sourceFiles/account_crypto.py +++ b/sourceFiles/account_crypto.py @@ -33,6 +33,8 @@ import unicodedata from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import (Ed25519PrivateKey, + Ed25519PublicKey) from cryptography.hazmat.primitives.asymmetric.x25519 import (X25519PrivateKey, X25519PublicKey) from cryptography.hazmat.primitives.ciphers.aead import AESGCM @@ -349,6 +351,49 @@ def fingerprint(public_key: bytes) -> str: return " ".join(digits[i:i + 4] for i in range(0, 24, 4)) +def account_fingerprint(public_key: bytes, signing_key: bytes) -> str: + """ + The fingerprint two people compare before the first share: it covers + both of an account's public keys, so one check vouches for the key a + vault key is wrapped to *and* the key the account's changes are signed + with. + """ + return fingerprint(public_key + signing_key) + + +# =========================================================================== +# Signing (the change log, phase 4) +# =========================================================================== +def _signing_private(keyset: dict) -> Ed25519PrivateKey: + # Derived from the account's private key rather than stored beside it: + # every keyset ever made already has one, the recovery copy included, + # and there is nothing new to keep in step. HKDF keeps the two keys + # independent in use. + seed = _hkdf(_unb64(keyset["x25519_private"], "account key"), + b"PassMan3 signing", b"PassMan3 signing key") + return Ed25519PrivateKey.from_private_bytes(seed) + + +def signing_key_of(keyset: dict) -> bytes: + """The public half of the account's signing key (32 bytes).""" + return _signing_private(keyset).public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw) + + +def sign(keyset: dict, message: bytes) -> str: + return _b64(_signing_private(keyset).sign(message)) + + +def verify(signing_key: bytes, message: bytes, signature: str) -> bool: + """Did the owner of ``signing_key`` sign ``message``? Never raises.""" + try: + Ed25519PublicKey.from_public_bytes(signing_key).verify( + base64.b64decode(signature, validate=True), message) + return True + except Exception: + return False + + # =========================================================================== # PMV3: a vault's entries under its own key # =========================================================================== diff --git a/sourceFiles/account_sync.py b/sourceFiles/account_sync.py index f7696f8..965a5f4 100644 --- a/sourceFiles/account_sync.py +++ b/sourceFiles/account_sync.py @@ -30,6 +30,7 @@ import account_crypto import accounts +import changelog import sync_client import vault from account_crypto import CryptoError @@ -44,11 +45,20 @@ #: account (SecretSpace Server 2.0.0-alpha.2). FEATURE_VAULTS = "vaults" +#: ... and of one that shares vaults between accounts and keeps a log +#: (2.0.0-alpha.3). +FEATURE_SHARING = "sharing" + class LoginFailed(SyncError): """The server did not take this username + password + Secret Key.""" +class KeyChanged(SyncError): + """The server gives other keys for an account than this device has seen + for it: a new account under an old name, or a server that lies.""" + + class AccountServer(SyncClient): """ The /v2/ calls. ``token`` (the inherited Authorization header) is the @@ -89,6 +99,41 @@ def has_vaults(self) -> bool: self._features = list(self.health().get("features") or []) return FEATURE_VAULTS in self._features + def has_sharing(self) -> bool: + self.has_vaults() + return FEATURE_SHARING in self._features + + # -- sharing (phase 4) --------------------------------------------------- + def put_signing_key(self, signing_key: str) -> None: + self._request("PUT", "/v2/me/signing-key", + body={"signing_key": signing_key}) + + def public_keys(self, username: str) -> dict: + return self._request("GET", "/v2/accounts/" + urllib.parse.quote( + username, safe="") + "/public-key") + + def members(self, vault_id: str) -> dict: + return self._request("GET", f"/v2/vaults/{vault_id}/members") + + def put_member(self, vault_id: str, username: str, role: str, + wrapped_key: str = None) -> dict: + body = {"role": role} + if wrapped_key: + body["wrapped_key"] = wrapped_key + return self._request("PUT", f"/v2/vaults/{vault_id}/members/" + + urllib.parse.quote(username, safe=""), body=body) + + def leave_vault(self, vault_id: str, username: str) -> None: + self._request("DELETE", f"/v2/vaults/{vault_id}/members/" + + urllib.parse.quote(username, safe="")) + + def rotate(self, vault_id: str, body: dict) -> dict: + return self._request("POST", f"/v2/vaults/{vault_id}/rotate", body=body) + + def vault_log(self, vault_id: str) -> list: + return list(self._request("GET", f"/v2/vaults/{vault_id}/log") + .get("lines") or []) + 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}) @@ -199,6 +244,7 @@ def connect(account: accounts.Account, url: str, ca_file: str, "login_key": base64.b64encode(account.login_key).decode("ascii"), "keyset": files["keyset"], "recovery": files["recovery"], "public_key": account.info["public_key"], + "signing_key": account.signing_key, "personal_vault": account.vault_id.hex()}) except Conflict409: raise SyncError( @@ -239,6 +285,8 @@ def __init__(self, account: accounts.Account, server: AccountServer = None, self.first_push = False self.base_ignored = False self._fingerprint = None + self._remote_log = [] + self._log_in = self._log_out = False def prepare(self, local_vault: dict): self.server = logged_in(self.account, self.server) @@ -264,11 +312,20 @@ def prepare(self, local_vault: dict): base, self.server.url, self.revision): base, self.base_ignored = None, True self.result = merge_vaults(base, local_vault, remote) + # The change log is merged on its own: each side's lines, once. + self._remote_log = changelog.lines_of(remote) + here = {l["id"] for l in changelog.lines_of(local_vault)} + there = {l["id"] for l in self._remote_log} + merged = {l["id"] for l in changelog.merge( + changelog.lines_of(local_vault), self._remote_log)} + self._log_in = merged != here # lines to take from the server + self._log_out = merged != there and blob is not None # ... to give it return self.result def has_work(self) -> bool: return (self.result is not None and self.result.changed) \ - or self.base_ignored or self.first_push + or self.base_ignored or self.first_push \ + or self._log_in or (self._log_out and self.file.can_write) def commit(self, local_vault: dict, choices=None) -> dict: if self.result is None: @@ -278,10 +335,16 @@ def commit(self, local_vault: dict, choices=None) -> dict: "Press Sync again to merge the newer version.") merged = vault.validate_vault( merged_vault(local_vault, resolve(self.result, choices))) + log = changelog.merge(changelog.lines_of(local_vault), self._remote_log) + if log or changelog.CHANGES_KEY in merged: + merged[changelog.CHANGES_KEY] = log # Saved here first, then pushed: if the network fails in between, # this device holds the merge and the next sync pushes it. self.file.save(merged) - if self.result.needs_push or self.first_push: + # A reader only ever takes: the server would refuse its upload, and + # it has nothing of its own to give. + if (self.result.needs_push or self.first_push or self._log_out) \ + and self.file.can_write: self.revision = self.server.put_vault( self.file.hex, self.file.encrypt(merged), self.revision) self.file.save_base(merged, sync_client.base_origin_for( @@ -302,13 +365,15 @@ class VaultChanges: 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.gone = [] # names: no longer on the server, kept here self.renamed = [] # (old, new): renamed on another device + self.rekeyed = [] # names: the vault got a new key + self.roles = [] # (name, role): what this account may do changed 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) + or self.rekeyed or self.roles or self.server_too_old) def reconcile_vaults(account: accounts.Account, server: AccountServer) -> VaultChanges: @@ -331,11 +396,16 @@ def reconcile_vaults(account: accounts.Account, server: AccountServer) -> VaultC """ 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", []) + me = server.me() + listed = {v["id"]: v for v in 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 + if server.has_sharing() and not me.get("signing_key"): + # An account made before changes were signed: the server learns its + # signing key now, once, so other members can check its lines. + server.put_signing_key(account.signing_key) for vault_id in extra_here: record = account.vault_record(vault_id) @@ -358,6 +428,30 @@ def reconcile_vaults(account: accounts.Account, server: AccountServer) -> VaultC 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) + generation = int(there.get("key_generation", 0) or 0) + if generation != int(record.get("key_generation", 0) or 0): + # The key was changed (someone was removed). Before anything + # else: the name and the entries are under the new key now. + try: + account.rekey_vault(vault_id, there.get("wrapped_key", ""), + generation) + except (CryptoError, ValueError) as e: + raise SyncError(f"The new key of “{file.name}” cannot be " + f"opened by this account ({e}). Nothing was " + "changed.") from e + changes.rekeyed.append(file.name) + file = account.vault_file(vault_id) + role = there.get("role") if there.get("role") in accounts.ROLES \ + else accounts.ROLE_OWNER + if role != account.vault_role(vault_id): + account.mark_vault(vault_id, role=role) + changes.roles.append((file.name, role)) + count = int(there.get("members", 1) or 1) + if count != int(record.get("members", 1) or 1): + account.mark_vault(vault_id, members=count) + if record.get("rename_pending") and role != accounts.ROLE_OWNER: + account.mark_vault(vault_id, rename_pending=False) # not theirs to name + record = account.vault_record(vault_id) if record.get("rename_pending"): server.rename_vault(vault_id, file.sealed_name()) account.mark_vault(vault_id, rename_pending=False) @@ -378,7 +472,10 @@ def reconcile_vaults(account: accounts.Account, server: AccountServer) -> VaultC continue try: file = account.adopt_vault(vault_id, there.get("wrapped_key", ""), - there.get("name", "")) + there.get("name", ""), + there.get("role", accounts.ROLE_OWNER), + there.get("key_generation", 0), + there.get("members", 1)) except (CryptoError, ValueError) as e: raise SyncError(f"The server lists a vault this account cannot " f"open ({e}). Nothing was changed.") from e @@ -408,7 +505,12 @@ def delete_vault(account: accounts.Account, vault_id: str) -> None: and not record.get("gone_from_server"): server = logged_in(account) try: - server.delete_vault(vault_id) + if account.vault_role(vault_id) == accounts.ROLE_OWNER: + server.delete_vault(vault_id) + else: + # Someone else's vault: this account leaves it. The vault + # stays for the others. + server.leave_vault(vault_id, account.username) finally: server.logout() account.delete_vault(vault_id) @@ -417,13 +519,161 @@ def delete_vault(account: accounts.Account, vault_id: str) -> None: 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) + # It goes back as this account's own vault, with a first key generation. + account.mark_vault(vault_id, on_server=False, gone_from_server=False, + role=accounts.ROLE_OWNER, key_generation=0, members=1) try: os.remove(account.vault_file(vault_id).base_path) # agreed with a vault that is gone except OSError: pass +# --------------------------------------------------------------------------- +# Sharing a vault (phase 4) +# --------------------------------------------------------------------------- +def lookup_contact(account: accounts.Account, server: AccountServer, + username: str) -> dict: + """ + Another account's keys from the server, set against what this device + has seen for it before. Returns {"username" (as the server spells it), + "public_key", "signing_key" (bytes), "fingerprint", "status": "new" | + "known" | "changed", "verified": the user compared this fingerprint}. + Nothing is remembered here; :func:`trust_contact` does that. + """ + reply = server.public_keys(username) + name = reply.get("username") or username + try: + public = base64.b64decode(reply["public_key"], validate=True) + signing = base64.b64decode(reply.get("signing_key") or "", validate=True) + except (KeyError, ValueError, TypeError): + raise SyncError("The server sent keys this version cannot use.") from None + if len(public) != 32: + raise SyncError("The server sent keys this version cannot use.") + if len(signing) != 32: + raise SyncError(f"“{name}” has not used a SecretSpace version that can " + "share vaults yet. Ask them to update and sync once.") + if name == account.username: + raise SyncError("That is your own account.") + seen = account.contact(name) + status, verified = "new", False + if seen is not None: + same = seen["public_key"] == public and seen["signing_key"] == signing + status = "known" if same else "changed" + verified = same and seen["verified"] + return {"username": name, "public_key": public, "signing_key": signing, + "fingerprint": account_crypto.account_fingerprint(public, signing), + "status": status, "verified": verified} + + +def trust_contact(account: accounts.Account, keys: dict, verified: bool) -> None: + """Remember ``keys`` (from :func:`lookup_contact`) for that account; + ``verified`` when the user compared the fingerprint with its owner.""" + account.remember_contact(keys["username"], keys["public_key"], + keys["signing_key"], verified) + + +def share_vault(account: accounts.Account, server: AccountServer, vault_id: str, + keys: dict, role: str) -> dict: + """ + Give the account behind ``keys`` (looked up, and its fingerprint + confirmed by the user) the vault's key, wrapped so that only it can open + it. The server stores the wrapped copy; it never holds the key itself. + """ + if account.vault_role(vault_id) != accounts.ROLE_OWNER: + raise SyncError("Only an owner of this vault can share it.") + file = account.vault_file(vault_id) + if file.personal: + raise SyncError("The personal vault cannot be shared; make another " + "vault for that.") + wrapped = account_crypto.wrap_key(keys["public_key"], file._key, file.id, + keys["username"]) + done = server.put_member(vault_id, keys["username"], role, wrapped) + trust_contact(account, keys, True) + account.mark_vault(vault_id, members=len(server.members(vault_id) + .get("members") or [1])) + return done + + +def list_members(account: accounts.Account, server: AccountServer, + vault_id: str) -> list: + """The vault's members as the server lists them, each with "me" and + "verified" (this device has compared their fingerprint).""" + found = [] + for member in server.members(vault_id).get("members") or []: + name = member.get("username", "") + seen = account.contact(name) + found.append({"username": name, "role": member.get("role", ""), + "added_by": member.get("added_by", ""), + "added_at": member.get("added_at", ""), + "me": name == account.username, + "verified": bool(seen and seen["verified"])}) + account.mark_vault(vault_id, members=max(1, len(found))) + return found + + +def change_key(account: accounts.Account, server: AccountServer, vault_id: str, + local_vault: dict, remove=()) -> dict: + """ + Give a vault a new key, removing ``remove`` (usernames) in the same + step -- what "Remove" does, so that a removed member can open nothing + written afterwards. (What they already saw they may have copied; no + password manager can undo that.) + + The vault is synced first, so the copy that goes under the new key is + the one everybody agrees on; a sync that needs the user's decision stops + this with nothing changed. Returns the vault's data as it now is here. + """ + if account.vault_role(vault_id) != accounts.ROLE_OWNER: + raise SyncError("Only an owner of this vault can do that.") + sync = AccountSync(account, server, vault_id) + result = sync.prepare(local_vault) + if result.conflicts: + raise SyncError("This vault has changes here and on the server that " + "need your decision. Press Sync first, then try again.") + merged = sync.commit(local_vault) + file = sync.file + listing = server.members(vault_id) + if int(listing.get("key_generation", 0) or 0) != file.generation: + raise SyncError("This vault's key was changed on another device. " + "Press Sync first, then try again.") + names = [m.get("username", "") for m in listing.get("members") or []] + missing = [name for name in remove if name not in names] + if missing: + raise SyncError("Not a member any more: " + ", ".join(missing) + ".") + staying = [name for name in names if name not in remove] + new_key = account_crypto.new_vault_key() + generation = file.generation + 1 + wrapped = {} + for name in staying: + if name == account.username: + public = account_crypto.public_key_of(account.keyset) + else: + keys = lookup_contact(account, server, name) + if keys["status"] == "changed": + raise KeyChanged( + f"The server now gives different keys for “{name}” than " + "this PC has seen before. Nothing was changed. Check " + f"with {name} (Members → Check), or remove them too.") + if keys["status"] == "new": + trust_contact(account, keys, False) + public = keys["public_key"] + wrapped[name] = account_crypto.wrap_key(public, new_key, file.id, name) + view = server.rotate(vault_id, { + "generation": generation, "remove": list(remove), + "wrapped_keys": wrapped, + "name": account_crypto.seal_vault_name(new_key, file.id, file.name), + "blob": account_crypto.encrypt_vault(new_key, file.id, generation, + vault.validate_vault(dict(merged))), + "revision": sync.revision}) + # The server has it; now this device. (If this is interrupted, the next + # sync finds the new generation and takes the key from the server.) + account.rekey_vault(vault_id, wrapped[account.username], generation) + account.vault_file(vault_id).save_base(merged, sync_client.base_origin_for( + server.url, int(view.get("revision", sync.revision + 1)))) + account.mark_vault(vault_id, members=len(staying)) + return merged + + # --------------------------------------------------------------------------- # Signing in from a server # --------------------------------------------------------------------------- diff --git a/sourceFiles/account_sync_ui.py b/sourceFiles/account_sync_ui.py index 43b6ebc..c468b79 100644 --- a/sourceFiles/account_sync_ui.py +++ b/sourceFiles/account_sync_ui.py @@ -142,9 +142,14 @@ def _all_done(app, run): parts.append("now on the server: " + ", ".join(changes.uploaded)) for old, new in changes.renamed: parts.append(f"“{old}” is now “{new}”") + if changes.rekeyed: + parts.append("new key for: " + ", ".join(changes.rekeyed)) + for name, role in changes.roles: + parts.append(f"you are now {role} of “{name}”") kind = "info" if changes.gone: - parts.append("deleted on another device, kept on this PC only: " + + parts.append("no longer on the server for you (deleted, or you were " + "removed), kept on this PC only: " + ", ".join(changes.gone) + " (see Vaults)") kind = "warn" if changes.server_too_old: diff --git a/sourceFiles/accounts.py b/sourceFiles/accounts.py index bf8cf11..0dd2ea3 100644 --- a/sourceFiles/accounts.py +++ b/sourceFiles/accounts.py @@ -44,6 +44,10 @@ #: stored name, and no other vault may take this one. PERSONAL_NAME = "Personal" +#: What a member of a vault may do (the server enforces it; the app follows). +ROLE_OWNER, ROLE_EDITOR, ROLE_READER = "owner", "editor", "reader" +ROLES = (ROLE_OWNER, ROLE_EDITOR, ROLE_READER) + class SecretKeyNeeded(Exception): """ @@ -146,11 +150,16 @@ class VaultFile: """ def __init__(self, username: str, vault_id: bytes, key: bytes, name: str, - personal: bool): + personal: bool, generation: int = 0, role: str = ROLE_OWNER): self.id = vault_id self.hex = vault_id.hex() self.name = name self.personal = personal + # How many times the vault's key was changed (a member removed); + # written into each file's header so the server can refuse an upload + # made under a key that is no longer the vault's. + self.generation = generation + self.role = role self._key = key self.path = os.path.join(account_dir(username), VAULTS_DIR, self.hex + VAULT_SUFFIX) @@ -163,9 +172,14 @@ def base_path(self) -> str: def has_file(self) -> bool: return os.path.exists(self.path) + @property + def can_write(self) -> bool: + """May this account change the vault (owner or editor)?""" + return self.role != ROLE_READER + 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, + return account_crypto.encrypt_vault(self._key, self.id, self.generation, vault.validate_vault(data)) def decrypt(self, blob: str) -> dict: @@ -252,7 +266,53 @@ def mode(self) -> str: @property def fingerprint(self) -> str: - return account_crypto.fingerprint(account_crypto.public_key_of(self.keyset)) + """What this account's owner reads out to someone who is about to + share a vault with them.""" + return account_crypto.account_fingerprint( + account_crypto.public_key_of(self.keyset), + account_crypto.signing_key_of(self.keyset)) + + @property + def signing_key(self) -> str: + """The public signing key, as the server and other members get it.""" + return account_crypto._b64(account_crypto.signing_key_of(self.keyset)) + + # -- other people ------------------------------------------------------------ + def contact(self, username: str): + """ + The keys this device has seen for another account, or None: + {"public_key", "signing_key" (bytes), "verified" (the fingerprint was + compared by the user)}. + """ + seen = self.info.get("fingerprints_seen") + found = seen.get(username) if isinstance(seen, dict) else None + if not isinstance(found, dict): + return None + try: + return {"public_key": account_crypto._unb64(found["public_key"], "key"), + "signing_key": account_crypto._unb64(found["signing_key"], "key"), + "verified": bool(found.get("verified"))} + except (KeyError, CryptoError): + return None + + def remember_contact(self, username: str, public_key: bytes, + signing_key: bytes, verified: bool) -> None: + """Keep an account's keys, so a later change of them is noticed.""" + info = dict(self.info) + seen = dict(info.get("fingerprints_seen") or {}) + seen[username] = {"public_key": account_crypto._b64(public_key), + "signing_key": account_crypto._b64(signing_key), + "verified": bool(verified), "seen_at": vault.utc_now()} + info["fingerprints_seen"] = seen + _write_info(self.username, info) + self.info = info + + def signing_key_for(self, username: str): + """The key to check ``username``'s signatures with, or None.""" + if username == self.username: + return account_crypto.signing_key_of(self.keyset) + found = self.contact(username) + return found["signing_key"] if found else None @property def secret_key_text(self) -> str: @@ -278,9 +338,25 @@ def vault_ids(self) -> list: 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.""" + one): name, wrapped_key, on_server, gone_from_server, rename_pending, + and since phase 4 role, key_generation and members (how many).""" return dict(self._extra().get(vault_id, {})) + def vault_role(self, vault_id: str) -> str: + if vault_id is None or vault_id == self.personal_id: + return ROLE_OWNER + role = self._extra().get(vault_id, {}).get("role") + return role if role in ROLES else ROLE_OWNER + + def can_write(self, vault_id: str) -> bool: + return self.vault_role(vault_id) != ROLE_READER + + def is_shared(self, vault_id: str) -> bool: + """Is anyone else in this vault (as far as the last sync knew)?""" + record = self._extra().get(vault_id, {}) + return int(record.get("members", 1) or 1) > 1 or \ + self.vault_role(vault_id) != ROLE_OWNER + 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: @@ -296,7 +372,9 @@ def vault_file(self, vault_id: str = None) -> VaultFile: self.username) self._vault_keys[vault_id] = key return VaultFile(self.username, bytes.fromhex(vault_id), key, - record.get("name", ""), False) + record.get("name", ""), False, + int(record.get("key_generation", 0) or 0), + self.vault_role(vault_id)) def _name_taken(self, name: str, except_id: str = None) -> bool: wanted = name.lower() @@ -334,7 +412,9 @@ def create_vault(self, name: str) -> VaultFile: self._vault_keys[made.hex] = key return made - def adopt_vault(self, vault_id: str, wrapped_key: str, sealed_name: str) -> VaultFile: + def adopt_vault(self, vault_id: str, wrapped_key: str, sealed_name: str, + role: str = ROLE_OWNER, key_generation: int = 0, + members: int = 1) -> 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 @@ -353,11 +433,56 @@ def adopt_vault(self, vault_id: str, wrapped_key: str, sealed_name: str) -> Vaul n += 1 extra = dict(self._extra()) extra[vault_id] = {"name": name, "wrapped_key": wrapped_key, - "created_at": vault.utc_now(), "on_server": True} + "created_at": vault.utc_now(), "on_server": True, + "role": role if role in ROLES else ROLE_OWNER, + "key_generation": int(key_generation or 0), + "members": int(members or 1)} self._write_vaults(extra) self._vault_keys[vault_id] = key return self.vault_file(vault_id) + def rekey_vault(self, vault_id: str, wrapped_key: str, generation: int) -> None: + """ + The vault has a new key (a member was removed, here or by another + owner): take it, and put this device's copy of the vault under it. + ``wrapped_key`` must open with this account's key, or nothing changes. + """ + if vault_id == self.personal_id or vault_id not in self._extra(): + raise ValueError("That vault has no key to change.") + raw_id = bytes.fromhex(vault_id) + new_key = account_crypto.unwrap_key(self.keyset, wrapped_key, raw_id, + self.username) + old = self.vault_file(vault_id) + data = base = None + if old.has_file: + try: + data = old.load() + except (ValueError, OSError): + data = None # unreadable already: a sync refetches it + base = old.load_base() + extra = dict(self._extra()) + record = dict(extra[vault_id]) + record["wrapped_key"] = wrapped_key + record["key_generation"] = int(generation) + extra[vault_id] = record + self._write_vaults(extra) + self._vault_keys[vault_id] = new_key + new = self.vault_file(vault_id) + if data is not None: + new.save(data) # the old file becomes its .bak ... + else: + old.remove_files() + # ... which, like the old base, is under the old key and unreadable + # from now on. + for stale in (new.path + vault.BACKUP_SUFFIX, new.base_path): + try: + os.remove(stale) + except OSError: + pass + origin = (base or {}).pop(vault.BASE_ORIGIN_KEY, None) + if base is not None and origin: + new.save_base(base, origin) + 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) @@ -431,10 +556,13 @@ def set_server(self, url, ca_file: str = "") -> 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. + # (Roles and key generations were that server's too: on the next + # one each vault starts as this account's own, at generation 0.) info["vaults"] = { v: {k: x for k, x in r.items() if k not in ("on_server", "gone_from_server", - "rename_pending")} + "rename_pending", "role", "key_generation", + "members")} for v, r in self._extra().items()} _write_info(self.username, info) self.info = info @@ -509,7 +637,8 @@ def __init__(self, username: str, password: str, made: dict): self._made = made self.secret_key_text = made["secret_key_text"] self.recovery_phrase = made["recovery_phrase"] - self.fingerprint = account_crypto.fingerprint(made["public_key"]) + self.fingerprint = account_crypto.account_fingerprint( + made["public_key"], account_crypto.signing_key_of(made["keyset"])) def save(self) -> Account: username = self.username diff --git a/sourceFiles/changelog.py b/sourceFiles/changelog.py new file mode 100644 index 0000000..4a50e00 --- /dev/null +++ b/sourceFiles/changelog.py @@ -0,0 +1,266 @@ +""" +The change log inside a vault (phase 4): who changed what, and when. + +Before 3.0 everyone who could open a vault was the same person to the app, +so a change carried no name. Now every save by an account adds lines to the +vault's own log -- "nikola added Router", "user1 changed the password of +Mail" -- which travel with the vault (encrypted under its key, merged by +sync) and can be read by every member. + +What a line holds: who, when, what kind of change, which entry (its id and +its title at that time) and which *fields* changed. Never a value: no +password, no username, no note. + +What is not recorded, on purpose: who looked at an entry or copied a +password. This is accounting of changes, not surveillance. + +Every line is signed with its author's account key (Ed25519). All editors +of a vault hold the vault's key, so without a signature any of them could +write a line under someone else's name; with it, a line whose signature +does not match its author's key is shown as such. + +Pure: dicts in, dicts out. Nothing here touches a file, a server or a widget. +""" + +import json +import os + +import account_crypto +import vault + +#: The vault's top-level key holding the log. Top-level keys other than +#: "accounts" pass through vault.validate_vault untouched. +CHANGES_KEY = "changes" + +#: Lines kept per vault, newest first out. At a dozen changes a day that is +#: most of a year; an entry's own History keeps its earlier versions anyway. +CHANGES_LIMIT = 1000 + +#: More added (or deleted) entries than this in one save become one line: +#: an import of 400 entries is one event, not 400. +BULK = 20 + +ADDED, EDITED, ARCHIVED, RESTORED, DELETED, REVERTED = ( + "added", "edited", "archived", "restored", "deleted", "reverted") +IMPORTED, DELETED_MANY, HISTORY_CLEARED = ( + "imported", "deleted_many", "history_cleared") +ACTIONS = (ADDED, EDITED, ARCHIVED, RESTORED, DELETED, REVERTED, IMPORTED, + DELETED_MANY, HISTORY_CLEARED) + +#: What check() says about a line. +OK, BAD, UNKNOWN = "ok", "bad", "unknown" + +_TITLE_CHARS = 120 + + +def _clean(line): + """A stored line in its one form, or None for junk.""" + if not isinstance(line, dict): + return None + action = line.get("action") + if action not in ACTIONS: + return None + fields = line.get("fields") + try: + count = max(0, int(line.get("count", 0) or 0)) + except (TypeError, ValueError): + count = 0 + clean = { + "id": str(line.get("id", "") or "")[:32], + "at": str(line.get("at", "") or "")[:40], + "by": str(line.get("by", "") or "")[:64], + "action": action, + "entry": str(line.get("entry", "") or "")[:64], + "title": str(line.get("title", "") or "")[:_TITLE_CHARS], + "fields": ([f for f in vault.REVISION_FIELDS if f in fields] + if isinstance(fields, list) else []), + "count": count, + "sig": str(line.get("sig", "") or "")[:200], + } + if not clean["id"] or not clean["at"] or not clean["by"]: + return None + return clean + + +def lines_of(data) -> list: + """A vault's log, cleaned, oldest first.""" + raw = data.get(CHANGES_KEY) if isinstance(data, dict) else None + return merge(raw if isinstance(raw, list) else []) + + +def merge(*logs) -> list: + """ + The union of several copies of a log: each line once (by id), oldest + first, the newest CHANGES_LIMIT kept. Deterministic, so two devices that + hold the same lines end up with the same list. + """ + found = {} + for log in logs: + for raw in log or []: + line = _clean(raw) + if line is not None and line["id"] not in found: + found[line["id"]] = line + ordered = sorted(found.values(), key=lambda l: (l["at"], l["id"])) + return ordered[-CHANGES_LIMIT:] + + +# --------------------------------------------------------------------------- +# What changed between two states of a vault +# --------------------------------------------------------------------------- +def _by_id(data) -> dict: + return {a.get("id"): a for a in (data or {}).get("accounts", []) + if isinstance(a, dict) and a.get("id")} + + +def _was_a_version(before: dict, after: dict) -> bool: + """Is ``after``'s content one of ``before``'s earlier versions?""" + now = vault._content_key(vault.snapshot(after)) + return any(vault._content_key(rev) == now + for rev in before.get("history") or []) + + +def diff(before: dict, after: dict) -> list: + """ + The changes from ``before`` to ``after`` as unsigned records + ({"action", "entry", "title", "fields", "count"}), in the order of + ``after``'s entries, deletions last. + """ + old, new = _by_id(before), _by_id(after) + added, deleted, rest = [], [], [] + for entry_id, entry in new.items(): + was = old.get(entry_id) + title = str(entry.get("site", "") or "")[:_TITLE_CHARS] + if was is None: + added.append({"action": ADDED, "entry": entry_id, "title": title, + "fields": [], "count": 0}) + continue + fields = vault.changed_fields(was, entry) + if vault.is_archived(was) != vault.is_archived(entry): + action = ARCHIVED if vault.is_archived(entry) else RESTORED + elif fields: + action = REVERTED if _was_a_version(was, entry) else EDITED + elif str(was.get("history_cleared_at", "") or "") != \ + str(entry.get("history_cleared_at", "") or ""): + action = HISTORY_CLEARED + else: + continue + rest.append({"action": action, "entry": entry_id, "title": title, + "fields": fields, "count": 0}) + for entry_id, was in old.items(): + if entry_id not in new: + deleted.append({"action": DELETED, "entry": entry_id, + "title": str(was.get("site", "") or "")[:_TITLE_CHARS], + "fields": [], "count": 0}) + if len(added) > BULK: + added = [{"action": IMPORTED, "entry": "", "title": "", "fields": [], + "count": len(added)}] + if len(deleted) > BULK: + deleted = [{"action": DELETED_MANY, "entry": "", "title": "", + "fields": [], "count": len(deleted)}] + return added + rest + deleted + + +# --------------------------------------------------------------------------- +# Signing +# --------------------------------------------------------------------------- +def message(vault_hex: str, line: dict) -> bytes: + """ + What a line's signature covers: everything in it, and the vault it is + in -- so a line cannot be copied into another vault, or its author, time + or entry changed, without the signature failing. + """ + return json.dumps(["PassMan3 change", vault_hex, line["id"], line["at"], + line["by"], line["action"], line["entry"], line["title"], + list(line["fields"]), int(line["count"])], + separators=(",", ":"), ensure_ascii=True).encode("ascii") + + +def check(line: dict, vault_hex: str, signing_key) -> str: + """ + OK when ``signing_key`` (the author's public signing key, 32 bytes) + signed this line, BAD when it did not, UNKNOWN when there is no key to + check against. + """ + if not signing_key: + return UNKNOWN + line = _clean(line) + if line is None: + return BAD + return OK if account_crypto.verify(signing_key, message(vault_hex, line), + line["sig"]) else BAD + + +def record(data: dict, before: dict, username: str, keyset: dict, + vault_hex: str) -> int: + """ + Note in ``data`` what ``username`` changed since ``before`` (the vault as + it was last saved): signed lines in the log, ``updated_by`` on each entry + they touched, and ``by`` on each earlier version that became history. + Returns how many lines were added. ``data`` is changed in place. + """ + found = diff(before, data) + if not found: + return 0 + old, new = _by_id(before), _by_id(data) + lines = [] + for item in found: + line = dict(item) + line.update({"id": os.urandom(8).hex(), "at": vault.history_now(), + "by": username, "sig": ""}) + line["sig"] = account_crypto.sign(keyset, message(vault_hex, line)) + lines.append(line) + entry = new.get(item["entry"]) + if entry is None: + continue + entry["updated_by"] = username + seen = {rev.get("rid") for rev in + (old.get(item["entry"]) or {}).get("history") or []} + for rev in entry.get("history") or []: + if rev.get("rid") not in seen and not rev.get("by"): + rev["by"] = username + if any(item["action"] == IMPORTED for item in found): + # One line for the lot, but each entry still says who brought it in. + for entry_id, entry in new.items(): + if entry_id not in old: + entry["updated_by"] = username + data[CHANGES_KEY] = merge(lines_of(data), lines) + return len(lines) + + +# --------------------------------------------------------------------------- +# For the screen +# --------------------------------------------------------------------------- +_FIELD_LABELS = {"site": "site", "username": "username", "password": "password", + "notes": "notes", "totp": "2FA"} + + +def describe(line: dict) -> str: + """A line as a sentence without its author: “changed the password of + “Mail””.""" + title = f"“{line.get('title') or 'an entry'}”" + action = line.get("action") + fields = [_FIELD_LABELS[f] for f in line.get("fields") or [] + if f in _FIELD_LABELS] + what = " and ".join([", ".join(fields[:-1]), fields[-1]] + if len(fields) > 1 else fields) + count = line.get("count", 0) + if action == ADDED: + return f"added {title}" + if action == EDITED: + return f"changed the {what or 'details'} of {title}" + if action == REVERTED: + return f"put back an earlier version of {title}" + ( + f" ({what})" if what else "") + if action == ARCHIVED: + return f"archived {title}" + if action == RESTORED: + return f"restored {title} from the archive" + if action == DELETED: + return f"deleted {title} for good" + if action == IMPORTED: + return f"added {count} entries at once (an import)" + if action == DELETED_MANY: + return f"deleted {count} entries for good" + if action == HISTORY_CLEARED: + return f"cleared the earlier versions of {title}" + return "changed something" diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 1893c7e..af5107f 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -473,6 +473,8 @@ def _finish_import(app, parsed, path): def account_dialog(app, acc): editing = acc is not None + if editing and not app.entry_writable(acc["id"]): + return app.read_only_notice() title = "Edit entry" if editing else "Add an entry" subtitle = ("Update this login; the version it replaces is kept in its " "history." if editing else "Store a new login in your vault.") @@ -603,8 +605,10 @@ 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} + # (Only vaults this account may change can take a new entry.) + names = {app.vault_name(v): v for v in app.vaults + if app.account.can_write(v)} + if not editing and len(names) > 1: picker = option_menu( shell.buttons, list(names), lambda label: target.update(id=names[label]), width=140) @@ -765,7 +769,9 @@ def set_purge(choice): purge_menu.set(days_label(vault.archive_purge_days(app.vault))) return purged = 0 - for data in app.vaults.values(): # one setting, every vault + for vault_id, data in app.vaults.items(): # one setting, every vault + if not app.account.can_write(vault_id): + continue # ... that is this account's to change vault.set_archive_purge_days(data, days) purged += vault.auto_purge_archive(data) if app._save(): diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index 20b1a47..ce260af 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -22,6 +22,7 @@ import accounts import appconfig import clipboard +import changelog import dialogs import history_ui import lockout @@ -227,10 +228,29 @@ def active_vault_id(self): the personal one.""" if self.account is None: return None - if self.vault_filter in self.vaults: + # A vault this account may only read cannot take a new entry. + if self.vault_filter in self.vaults and \ + self.account.can_write(self.vault_filter): return self.vault_filter return self.account.personal_id + def entry_writable(self, entry_id): + """May the open account change this entry (it is not in a vault + shared with it for reading only)?""" + if self.account is None: + return False + return self.account.can_write(self._entry_vault.get(entry_id) + or self._vault_id_of(entry_id)) + + def _vault_id_of(self, entry_id): + for vault_id, data in self.vaults.items(): + if any(a.get("id") == entry_id for a in data["accounts"]): + return vault_id + return None + + def read_only_notice(self): + self.toast("This vault is shared with you for reading only", "warn") + @property def vault(self): return self.vaults.get(self.active_vault_id) if self.vaults else None @@ -442,18 +462,51 @@ def _hide_toast(self): self._toast = None def _save(self, rekey=False): - """Write every vault that changed since it was last written.""" + """ + Write every vault that changed since it was last written, noting in + each who changed what (changelog). A vault this account may only + read is never written: should a change to one get this far, it is + undone from the file. + """ try: - 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 + refused = [] + for vault_id, data in list(self.vaults.items()): + if self._saved.get(vault_id) == self._content_mark(data): + continue + file = self.account.vault_file(vault_id) + if not file.can_write: + self.vaults[vault_id] = (file.load() if file.has_file + else {"accounts": []}) + self._saved[vault_id] = self._content_mark( + self.vaults[vault_id]) + refused.append(file.name) + continue + self._note_changes(file, data) + file.save(data) + self._saved[vault_id] = self._content_mark(data) + if refused: + # After the caller's own message, so this is the one read. + self.root.after(60, lambda: ( + self.refresh_account_list(), + self.toast("“" + "”, “".join(refused) + "” is shared with " + "you for reading only — nothing was changed " + "there", "warn"))) + return not refused except Exception as e: self.toast(f"Save failed: {e}", "error") return False + def _note_changes(self, file, data): + """Add to ``data``'s change log what differs from the vault as it was + last written. Never stops a save: a log line is worth less than the + change it describes.""" + try: + before = file.load() if file.has_file else {"accounts": []} + changelog.record(data, before, self.account.username, + self.account.keyset, file.hex) + except Exception: + pass + def _copy(self, text, message, sensitive=False): if not text: self.toast("Nothing to copy", "warn") @@ -849,8 +902,11 @@ def open_account(self, account, loaded, master_password): self.current_user = account.username appconfig.save_config(last_username=account.username) - # Archived entries past the keep-for limit (Settings), in every vault. - purged = sum(auto_purge_archive(data) for data in self.vaults.values()) + # Archived entries past the keep-for limit (Settings), in every vault + # this account may change. + purged = sum(auto_purge_archive(data) + for vault_id, data in self.vaults.items() + if account.can_write(vault_id)) if purged: self._save() @@ -1301,6 +1357,11 @@ def _show_detail(self): text_color=TEXT, anchor="w").pack(anchor="w") changed = acc.get("updated_at") line = ("Last changed " + history_ui.local_time(changed)) if changed else "" + home = self._entry_vault.get(acc["id"]) + writable = self.entry_writable(acc["id"]) + # In a vault other people are in, a change has a name. + if line and acc.get("updated_by") and self.account.is_shared(home): + line += " by " + _clip(acc["updated_by"], 24) if len(self.vaults) > 1: line += (" · " if line else "") + _clip(self.vault_name( self._entry_vault.get(acc["id"])), 24) @@ -1308,13 +1369,22 @@ def _show_detail(self): anchor="w").pack(anchor="w") actions = ctk.CTkFrame(band, fg_color="transparent") actions.grid(row=0, column=2, padx=(8, 22)) - self._ghost_button(actions, "Edit", lambda: self.account_dialog(acc), - width=74).pack(side="left", padx=(0, 8)) - arch = self._ghost_button(actions, "Archive", - lambda: self._archive_account(acc), width=88) - arch.pack(side="left") - Tooltip(arch, "Move to archive — restorable, and it still syncs the " - "removal to your other PCs") + if writable: + self._ghost_button(actions, "Edit", lambda: self.account_dialog(acc), + width=74).pack(side="left", padx=(0, 8)) + arch = self._ghost_button(actions, "Archive", + lambda: self._archive_account(acc), + width=88) + arch.pack(side="left") + Tooltip(arch, "Move to archive — restorable, and it still syncs " + "the removal to your other PCs") + else: + only = ctk.CTkLabel(actions, text="Read only", font=F(12, "bold"), + text_color=MUTED, fg_color=CARD, + corner_radius=8, width=92, height=32) + only.pack(side="left") + Tooltip(only, "This vault is shared with you for reading: you can " + "see and copy, not change") scroll = ctk.CTkScrollableFrame(self.detail, fg_color=CARD, corner_radius=0) @@ -1506,6 +1576,8 @@ def _archive_account(self, acc): an undo for the user and the tombstone the sync merge reads -- a hard delete would be resurrected by the next sync from the other PC. """ + if not self.entry_writable(acc["id"]): + return self.read_only_notice() if not self._confirm( "Move to archive", f"Move the saved login for “{acc['site']}” to the archive?\n\n" diff --git a/sourceFiles/history_ui.py b/sourceFiles/history_ui.py index faafe70..0534856 100644 --- a/sourceFiles/history_ui.py +++ b/sourceFiles/history_ui.py @@ -113,6 +113,10 @@ def panel(app, parent, acc): for i, rev in enumerate(history, start=1): _revision_line(app, panel, i, acc, rev) + if not _writable(app, acc): + ctk.CTkLabel(panel, text="", height=6).grid(row=len(history) + 1, + column=0) + return panel clear = _small_button(panel, "Clear history", lambda: _clear(app, acc), danger=True, width=110) clear.grid(row=len(history) + 1, column=0, columnspan=6, sticky="e", @@ -121,12 +125,31 @@ def panel(app, parent, acc): return panel +def _writable(app, acc) -> bool: + """May the open account change this entry? (Yes for an app without + accounts' vault roles.)""" + check = getattr(app, "entry_writable", None) + return True if check is None else bool(check(acc.get("id"))) + + +def changed_by(app, acc, rev) -> str: + """“by user1” for a version made in a vault other people are in.""" + who = rev.get("by") + account = getattr(app, "account", None) + if not who or account is None: + return "" + home = getattr(app, "_entry_vault", {}).get(acc.get("id")) + return f"by {who}" if account.is_shared(home) else "" + + def _revision_line(app, panel, line, acc, rev): ctk.CTkLabel(panel, text=local_time(rev.get("replaced_at", "")), width=150, anchor="w", font=F(12), text_color=TEXT).grid( row=line, column=0, sticky="w", padx=(12, 8), pady=3) # Wrapped: the details pane is narrower than the old full-width row. - ctk.CTkLabel(panel, text=describe(rev), anchor="w", font=F(11), + who = changed_by(app, acc, rev) + ctk.CTkLabel(panel, text=describe(rev) + ("\n" + who if who else ""), + anchor="w", font=F(11), text_color=MUTED, wraplength=170, justify="left").grid( row=line, column=1, sticky="w", pady=3) @@ -158,6 +181,8 @@ def toggle(): lambda: app._copy(secret, "Earlier password copied", sensitive=True)).grid( row=line, column=4, padx=2, pady=3) + if not _writable(app, acc): + return revert = _small_button(panel, "Revert", lambda: _revert(app, acc, rev), accent=True) revert.grid(row=line, column=5, padx=(2, 12), pady=3) diff --git a/sourceFiles/sharing_ui.py b/sourceFiles/sharing_ui.py new file mode 100644 index 0000000..3305bb1 --- /dev/null +++ b/sourceFiles/sharing_ui.py @@ -0,0 +1,554 @@ +""" +Sharing a vault and reading its record of changes (phase 4). + +Two windows, both opened from the Vaults window: + +- **Members**: who is in a vault and what they may do. An owner adds a + person (after comparing fingerprints with them), changes a role, removes + someone -- which gives the vault a new key in the same step -- or changes + the key on its own. +- **Activity**: the vault's change log (changelog.py) -- who added, changed, + archived or deleted what, and when. Every member can read it. Owners can + also read what the server noted (uploads, members, logins are not here). + +Nothing here records who looked at an entry or copied a password. + +The same rules as the other screens: the network runs on a worker thread, +widgets are touched only on the main one. +""" + +import customtkinter as ctk + +import account_sync +import accounts +import changelog +from history_ui import local_time +from sync_ui import _in_background +from theme import F, TEXT, MUTED, CARD, BORDER, WARN, DANGER, GREEN, MONO +from widgets import (dialog_shell, ghost_button, accent_button, small_button, + field, error_label, note, option_menu, Tooltip) + +ROLE_LABELS = {accounts.ROLE_OWNER: "Owner", accounts.ROLE_EDITOR: "Editor", + accounts.ROLE_READER: "Reader"} +ROLE_NOTES = { + accounts.ROLE_OWNER: "reads, changes, and manages the members", + accounts.ROLE_EDITOR: "reads and changes entries", + accounts.ROLE_READER: "reads entries, changes nothing", +} + +#: Lines the Activity window shows (the newest). +ACTIVITY_SHOWN = 200 + + +def role_label(role: str) -> str: + return ROLE_LABELS.get(role, role or "Member") + + +def _role_from(label: str) -> str: + return next((r for r, text in ROLE_LABELS.items() if text == label), + accounts.ROLE_READER) + + +def can_share(app, vault_id) -> str: + """Why this vault cannot be shared right now, or "" when it can.""" + account = app.account + if vault_id == account.personal_id: + return "The Personal vault is yours alone. Make another vault to share." + if not account.server: + return "Put the account on a server first (Settings → Sync)." + record = account.vault_record(vault_id) + if record.get("gone_from_server"): + return "This vault is no longer on the server." + if not record.get("on_server"): + return "Press Sync once, so the vault is on the server." + return "" + + +def _with_server(app, work, done, failed): + """Run ``work(server)`` logged in, off the main thread.""" + account = app.account + + def run(): + server = account_sync.logged_in(account) + try: + if not server.has_sharing(): + raise account_sync.SyncError( + "This server cannot share vaults yet: it needs SecretSpace " + "Server 2.0.0-alpha.3 or newer.") + return work(server) + finally: + server.logout() + + def if_open(callback): + return lambda value: callback(value) if app.account is account else None + + _in_background(app, run, if_open(done), on_error=if_open(failed)) + + +# ========================================================================== +# Members +# ========================================================================== +def members_dialog(app, vault_id, changed=None): + account = app.account + name = app.vault_name(vault_id) + owner = account.vault_role(vault_id) == accounts.ROLE_OWNER + top = app._modal(f"Members of “{name}”", 620, 660) + shell = dialog_shell( + top, f"Members of “{name}”", + "Everyone here can open this vault. Owners manage the members; " + "editors change entries; readers only read." if owner else + f"You are {_a(role_label(account.vault_role(vault_id)).lower())} of " + "this vault. Its owners manage the members.", scroll=True) + listing = shell.body + listing.grid_columnconfigure(0, weight=1) + state = {"members": [], "busy": False} + + def status(text, colour=MUTED): + if top.winfo_exists(): + status_label.configure(text=text, text_color=colour) + + def failed(error): + state["busy"] = False + status(str(error), DANGER) + + def show(members): + state["members"], state["busy"] = members, False + if not top.winfo_exists(): + return + for child in listing.winfo_children(): + child.destroy() + fp = ctk.CTkFrame(listing, fg_color="transparent") + fp.grid(row=0, column=0, sticky="ew", padx=4, pady=(10, 6)) + ctk.CTkLabel(fp, text="Your fingerprint", font=F(12, "bold"), + text_color=MUTED).pack(anchor="w") + ctk.CTkLabel(fp, text=account.fingerprint, + font=ctk.CTkFont(family=MONO, size=15), + text_color=TEXT).pack(anchor="w") + ctk.CTkLabel(fp, text="Read it to someone who is about to share a " + "vault with you, so they know the key is yours.", + font=F(11), text_color=MUTED, wraplength=shell.text_width, + justify="left").pack(anchor="w") + for i, member in enumerate(members, start=1): + _member_row(listing, i, member) + status(f"{len(members)} member{'s' if len(members) != 1 else ''}") + if changed: + changed() + + def reload(): + state["busy"] = True + status("Asking the server…") + _with_server(app, lambda server: account_sync.list_members( + account, server, vault_id), show, failed) + + def busy(): + if state["busy"] or getattr(app, "_syncing", False): + status("Wait for the current step to finish.", WARN) + return True + return False + + def run(work, message): + """A change on the server, then the list again.""" + if busy(): + return + state["busy"] = True + status(message) + + def both(server): + outcome = work(server) + return outcome, account_sync.list_members(account, server, vault_id) + + def done(payload): + outcome, members = payload + if isinstance(outcome, dict) and "accounts" in outcome: + # The vault as it is after a key change (synced first). + app.set_vault_data(vault_id, outcome, saved=True) + app.refresh_account_list() + show(members) + + _with_server(app, both, done, failed) + + def _member_row(parent, index, member): + 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=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=10) + who = member["username"] + (" (you)" if member["me"] else "") + ctk.CTkLabel(info, text=who, anchor="w", font=F(14, "bold"), + text_color=TEXT).pack(anchor="w") + if member["me"]: + detail, colour = role_label(member["role"]), MUTED + elif member["verified"]: + detail, colour = (role_label(member["role"]) + + " · fingerprint checked on this PC"), MUTED + else: + detail, colour = (role_label(member["role"]) + + " · fingerprint not checked on this PC"), WARN + ctk.CTkLabel(info, text=detail, anchor="w", font=F(12), + text_color=colour).pack(anchor="w") + # (An empty frame would take its default 200 x 200: none for a row + # without buttons.) + if member["me"] or (member["verified"] and not owner): + return + buttons = ctk.CTkFrame(row, fg_color="transparent") + buttons.grid(row=0, column=1, padx=(0, 14)) + who = member["username"] + if not member["verified"]: + check = small_button(buttons, "Check", lambda: check_person(who), + width=70) + check.pack(side="left", padx=(0, 6)) + Tooltip(check, "Compare this person's fingerprint with them") + if not owner: + return + menu = option_menu(buttons, list(ROLE_LABELS.values()), + lambda label: set_role(who, _role_from(label)), + width=104) + menu.set(role_label(member["role"])) + menu.pack(side="left", padx=(0, 6)) + small_button(buttons, "Remove", lambda: remove(who), danger=True, + width=80).pack(side="left") + + def set_role(who, role): + run(lambda server: server.put_member(vault_id, who, role), + f"Changing what {who} may do…") + + def remove(who): + if not app._confirm( + f"Remove {who}", + f"Remove {who} from “{name}”?\n\n" + "The vault gets a new key in the same step, so they can open " + "nothing that is written from now on. What they have already " + "seen they may have kept: change those passwords if it " + "matters.\n\nThe vault is synced first."): + return + run(lambda server: account_sync.change_key( + account, server, vault_id, app.vaults[vault_id], remove=[who]), + f"Removing {who} and changing the key…") + + def change_key(): + if not app._confirm( + "Change the vault's key", + f"Give “{name}” a new key?\n\nEvery member gets it with their " + "next sync. Worth doing after someone left the vault, or if a " + "device that had it was lost. The vault is synced first.", + danger=False): + return + run(lambda server: account_sync.change_key( + account, server, vault_id, app.vaults[vault_id]), + "Changing the key…") + + def check_person(who): + if busy(): + return + + def looked_up(keys): + if _fingerprint_dialog(app, keys, sharing=False): + account_sync.trust_contact(account, keys, True) + reload() + else: + status("") + + status(f"Asking the server for {who}'s keys…") + _with_server(app, lambda server: account_sync.lookup_contact( + account, server, who), looked_up, failed) + + def add(): + if busy(): + return + if _add_member_dialog(app, vault_id, name): + reload() + + status_label = ctk.CTkLabel(shell.buttons, text="", font=F(12), + text_color=MUTED, anchor="w", justify="left", + wraplength=230) + ghost_button(shell.buttons, "Close", top.destroy, width=100).pack(side="left") + status_label.pack(side="left", padx=(12, 0)) + if owner: + accent_button(shell.buttons, "Add a person…", add, + width=140).pack(side="right") + rekey = ghost_button(shell.buttons, "Change key", change_key, width=110) + rekey.pack(side="right", padx=(0, 8)) + Tooltip(rekey, "Give the vault a new key without removing anyone") + top.bind("", lambda e: top.destroy()) + reload() + + +def _a(word: str) -> str: + return ("an " if word[:1] in "aeiou" else "a ") + word + + +def _add_member_dialog(app, vault_id, vault_name) -> bool: + """Ask who and as what, look them up, compare fingerprints, share. + True when someone was added.""" + account = app.account + top = app._modal("Add a person", 500, 420) + shell = dialog_shell( + top, f"Share “{vault_name}”", + "With someone who has an account on the same server. They get the " + "vault with their next sync.") + who = field(shell.body, "Their username", placeholder="e.g. user1") + ctk.CTkLabel(shell.body, text="They can", anchor="w", font=F(12, "bold"), + text_color=MUTED).pack(anchor="w", pady=(14, 6)) + choices = {f"{ROLE_LABELS[r]} — {ROLE_NOTES[r]}": r + for r in (accounts.ROLE_EDITOR, accounts.ROLE_READER, + accounts.ROLE_OWNER)} + role = {"value": accounts.ROLE_EDITOR} + menu = option_menu(shell.body, list(choices), + lambda label: role.update(value=choices[label]), + width=shell.text_width) + menu.pack(anchor="w") + err = error_label(shell.body, shell.text_width) + outcome = {"added": False, "busy": False} + + def failed(error): + outcome["busy"] = False + if top.winfo_exists(): + err.configure(text=str(error), text_color=DANGER) + + def go(): + name = who.get().strip() + if not name: + return err.configure(text="Enter their username.", text_color=DANGER) + if outcome["busy"]: + return + outcome["busy"] = True + err.configure(text="Asking the server…", text_color=MUTED) + + def looked_up(keys): + outcome["busy"] = False + if not top.winfo_exists(): + return + err.configure(text="") + if not _fingerprint_dialog(app, keys, sharing=True): + return + outcome["busy"] = True + err.configure(text="Sharing…", text_color=MUTED) + + def shared(_): + outcome["added"] = True + app.toast(f"“{vault_name}” is now shared with {keys['username']}") + if top.winfo_exists(): + top.destroy() + + _with_server(app, lambda server: account_sync.share_vault( + account, server, vault_id, keys, role["value"]), shared, failed) + + _with_server(app, lambda server: account_sync.lookup_contact( + account, server, name), looked_up, failed) + + ghost_button(shell.buttons, "Cancel", top.destroy, width=110).pack(side="left") + accent_button(shell.buttons, "Continue", go, width=140).pack(side="right") + who.bind("", lambda e: go()) + top.bind("", lambda e: top.destroy()) + top.after(200, who.focus) + app._fit_modal(top) + app.root.wait_window(top) + return outcome["added"] + + +def _fingerprint_dialog(app, keys, sharing) -> bool: + """ + The one check that keeps a dishonest server out: the person reads their + fingerprint (Vaults → Members on their device) and it must be the one + shown here. True when the user says it matches. + """ + who = keys["username"] + changed = keys["status"] == "changed" + title = f"Is this {who}?" + top = app._modal(title, 520, 460) + shell = dialog_shell( + top, title, + f"Ask {who} to open Vaults → Members in their SecretSpace and read " + "you the fingerprint shown at the top — in person or on the phone, " + "not by a message through this server.", danger=changed) + if changed: + note(shell.body, + f"Warning: the server now gives DIFFERENT keys for {who} than " + "this PC has seen before. That happens when an account was " + "deleted and made again — or when someone is pretending to be " + f"{who}. Go on only if {who} confirms the fingerprint below.", + shell.text_width, color=DANGER, pady=(12, 0)) + ctk.CTkLabel(shell.body, text=f"{who}'s fingerprint must be", + font=F(12, "bold"), text_color=MUTED).pack(anchor="w", + pady=(16, 4)) + ctk.CTkLabel(shell.body, text=keys["fingerprint"], + font=ctk.CTkFont(family=MONO, size=20, weight="bold"), + text_color=GREEN).pack(anchor="w") + note(shell.body, + "If it is different, stop: the vault's key would go to someone " + "else." if sharing else + "If it is different, do not trust changes shown under this name.", + shell.text_width, size=11, pady=(10, 0)) + outcome = {"ok": False} + + def yes(): + outcome["ok"] = True + top.destroy() + + ghost_button(shell.buttons, "It is different", top.destroy, + width=140).pack(side="left") + accent_button(shell.buttons, + "It matches — share" if sharing else "It matches", yes, + width=170).pack(side="right") + top.bind("", lambda e: top.destroy()) + app._fit_modal(top) + app.root.wait_window(top) + return outcome["ok"] + + +# ========================================================================== +# Activity +# ========================================================================== +def activity_rows(account, vault_id, data) -> list: + """ + The vault's log for the screen, newest first: (when, who, what, state) + with state one of changelog.OK / BAD / UNKNOWN. + """ + rows = [] + for line in reversed(changelog.lines_of(data)[-ACTIVITY_SHOWN:]): + state = changelog.check(line, vault_id, + account.signing_key_for(line["by"])) + rows.append((line["at"], line["by"], changelog.describe(line), state)) + return rows + + +def activity_dialog(app, vault_id): + account = app.account + name = app.vault_name(vault_id) + top = app._modal(f"Activity in “{name}”", 660, 640) + shell = dialog_shell( + top, f"Activity in “{name}”", + "Who changed what, newest first. Values are never recorded, and " + "neither is who looked at an entry or copied a password.", + scroll=True) + listing = shell.body + listing.grid_columnconfigure(1, weight=1) + + def rebuild(): + for child in listing.winfo_children(): + child.destroy() + rows = activity_rows(account, vault_id, app.vaults.get(vault_id, {})) + if not rows: + ctk.CTkLabel(listing, text="Nothing yet. Changes made from this " + "version on are listed here.", font=F(13), + text_color=MUTED).grid(row=0, column=0, columnspan=2, + pady=60) + unknown = set() + for i, (when, who, what, state) in enumerate(rows): + ctk.CTkLabel(listing, text=local_time(when), width=140, anchor="w", + font=F(12), text_color=MUTED).grid( + row=i, column=0, sticky="nw", padx=(8, 8), pady=4) + text, colour = f"{who} {what}", TEXT + if state == changelog.BAD: + text += "\n⚠ The signature does not match " \ + f"{who}'s key: this line may not be theirs." + colour = DANGER + elif state == changelog.UNKNOWN: + text += f"\n(not checked: this PC has not seen {who}'s key)" + colour = WARN + unknown.add(who) + ctk.CTkLabel(listing, text=text, anchor="w", justify="left", + wraplength=440, font=F(13), text_color=colour).grid( + row=i, column=1, sticky="w", pady=4) + check.configure(state="normal" if unknown and account.server + else "disabled") + return unknown + + def fetch_keys(): + names = sorted(rebuild()) + if not names: + return + + def work(server): + for who in names: + try: + keys = account_sync.lookup_contact(account, server, who) + except account_sync.SyncError: + continue # no such account any more + if keys["status"] == "new": + account_sync.trust_contact(account, keys, False) + + _with_server(app, work, lambda _: top.winfo_exists() and rebuild(), + lambda e: app.toast(str(e), "error")) + + ghost_button(shell.buttons, "Close", top.destroy, width=110).pack(side="left") + check = ghost_button(shell.buttons, "Check signatures", fetch_keys, width=150) + check.pack(side="right") + Tooltip(check, "Fetch the keys of people this PC has not met, to check " + "their lines") + if account.vault_role(vault_id) == accounts.ROLE_OWNER \ + and not can_share(app, vault_id): + server_log = ghost_button(shell.buttons, "Server log…", + lambda: server_log_dialog(app, vault_id), + width=120) + server_log.pack(side="right", padx=(0, 8)) + Tooltip(server_log, "What the server noted: uploads, members, key " + "changes (owners only)") + top.bind("", lambda e: top.destroy()) + rebuild() + + +_SERVER_EVENTS = { + "vault_created": "made the vault", + "vault_renamed": "renamed the vault", + "vault_deleted": "deleted the vault", + "member_left": "left the vault", +} + + +def describe_server_line(line: dict) -> str: + """One line of the server's log as a sentence without its author.""" + event = line.get("event", "") + if event == "upload": + return f"uploaded changes (revision {line.get('revision', '?')})" + if event == "member_added": + return (f"added {line.get('member', '?')} as " + f"{role_label(line.get('role', '')).lower()}") + if event == "member_role": + return (f"made {line.get('member', '?')} " + f"{_a(role_label(line.get('role', '')).lower())}") + if event == "key_changed": + gone = [str(n) for n in line.get("removed") or []] + return ("changed the vault's key" + + (" and removed " + ", ".join(gone) if gone else "")) + return _SERVER_EVENTS.get(event, event or "did something") + + +def server_log_dialog(app, vault_id): + name = app.vault_name(vault_id) + top = app._modal(f"Server log of “{name}”", 660, 600) + shell = dialog_shell( + top, f"Server log of “{name}”", + "What the server noted about this vault, newest first, with the " + "address each request came from. The server cannot see inside the " + "vault: which entry changed is in Activity.", scroll=True) + listing = shell.body + listing.grid_columnconfigure(1, weight=1) + waiting = ctk.CTkLabel(listing, text="Asking the server…", font=F(13), + text_color=MUTED) + waiting.grid(row=0, column=0, columnspan=2, pady=60) + + def show(lines): + if not top.winfo_exists(): + return + waiting.destroy() + for i, line in enumerate(reversed(lines)): + ctk.CTkLabel(listing, text=local_time(str(line.get("at", ""))), + width=140, anchor="w", font=F(12), + text_color=MUTED).grid(row=i, column=0, sticky="nw", + padx=(8, 8), pady=4) + text = f"{line.get('by', '?')} {describe_server_line(line)}" + if line.get("address"): + text += f" · from {line['address']}" + ctk.CTkLabel(listing, text=text, anchor="w", justify="left", + wraplength=440, font=F(13), text_color=TEXT).grid( + row=i, column=1, sticky="w", pady=4) + + def failed(error): + if top.winfo_exists(): + waiting.configure(text=str(error), text_color=DANGER) + + ghost_button(shell.buttons, "Close", top.destroy, width=110).pack(side="right") + top.bind("", lambda e: top.destroy()) + _with_server(app, lambda server: server.vault_log(vault_id), show, failed) diff --git a/sourceFiles/sync_ui.py b/sourceFiles/sync_ui.py index fdba73c..90038f2 100644 --- a/sourceFiles/sync_ui.py +++ b/sourceFiles/sync_ui.py @@ -566,7 +566,16 @@ def _archive_row(app, parent, index, entry, rebuild): buttons = ctk.CTkFrame(row, fg_color="transparent") buttons.grid(row=0, column=2, padx=(8, 14)) + def read_only() -> bool: + check = getattr(app, "entry_writable", None) + if check is None or check(entry["id"]): + return False + app.read_only_notice() + return True + def restore(): + if read_only(): + return vault.restore_account(app.vault_holding(entry["id"]), entry["id"]) app._save() app.refresh_account_list() @@ -574,6 +583,8 @@ def restore(): app.toast(f"Restored {entry.get('site', '')}".strip()) def purge(): + if read_only(): + return if not app._confirm( "Delete permanently", f"Permanently delete the saved login for " diff --git a/sourceFiles/tests/sync_server/passman_server.py b/sourceFiles/tests/sync_server/passman_server.py index 0606bb9..aee97a7 100644 --- a/sourceFiles/tests/sync_server/passman_server.py +++ b/sourceFiles/tests/sync_server/passman_server.py @@ -33,6 +33,7 @@ import tempfile import threading import time +import urllib.parse from datetime import datetime, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -233,9 +234,24 @@ def _archive(self, user: str, revision: int) -> None: DEFAULT_KDF = {"memory_kib": 65536, "passes": 3, "lanes": 1} VAULT_ID = re.compile(r"^[0-9a-f]{32}$") +ROLES = ("owner", "editor", "reader") ROLES_THAT_WRITE = ("owner", "editor") -#: Vaults an account may own besides its personal one. Whoever holds the +#: People in one vault, and vaults one account may be in (its own and the +#: ones shared with it). Both only keep a mistake or a prank from growing +#: without end. +MAX_MEMBERS_PER_VAULT = 50 +MAX_VAULTS_LISTED = 200 + +#: The server's own record of what happened (who logged in, who uploaded, +#: who was added or removed): one JSON object per line, only ever appended. +#: A file that reaches this size is renamed to .1 (replacing the +#: previous .1) and a new one started. +AUDIT_ROTATE_BYTES = 2 * 1024 * 1024 +#: Lines an owner is sent when asking for a vault's log. +AUDIT_LINES_SENT = 200 + +#: Vaults an account may make 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 @@ -320,6 +336,7 @@ def __init__(self, store_root: str): self.vaults = Store(os.path.join(self.root, "vaults")) self._failures = {} # username (lower) -> [times] self._failure_lock = threading.Lock() + self._audit_lock = threading.Lock() # -- files -------------------------------------------------------------- def _account_path(self, username: str) -> str: @@ -342,6 +359,51 @@ def _read_json(path: str): def _write_json(path: str, data) -> None: write_atomic(path, json.dumps(data, indent=2, sort_keys=True).encode("utf-8")) + # -- the audit log ---------------------------------------------------------- + def _audit_path(self, vault_id: str = None) -> str: + if vault_id is None: + return os.path.join(self.root, "logins.log") + return os.path.join(self.vaults.root, vault_id, "audit.log") + + def audit(self, vault_id, event: str, by: str, address: str = "", + **details) -> None: + """ + Append one line to a vault's log (``vault_id`` None: the log of + logins). Never raises: a full disk must not turn a finished upload + into an error -- the line is then simply missing. + """ + line = {"at": utc_now(), "event": event, "by": by, "address": address} + line.update(details) + path = self._audit_path(vault_id) + try: + with self._audit_lock: + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + if os.path.getsize(path) >= AUDIT_ROTATE_BYTES: + os.replace(path, path + ".1") + except OSError: + pass + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(line, sort_keys=True) + "\n") + except OSError: + pass + + def audit_lines(self, vault_id: str) -> dict: + """The newest lines of a vault's log, oldest first.""" + lines = [] + try: + with open(self._audit_path(vault_id), encoding="utf-8") as f: + for text in f.readlines()[-AUDIT_LINES_SENT:]: + try: + line = json.loads(text) + except ValueError: + continue + if isinstance(line, dict): + lines.append(line) + except OSError: + pass + return {"lines": lines} + def account(self, username: str): """The stored account, or None. Matches the name exactly.""" if not valid_username(username): @@ -395,6 +457,9 @@ def create_account(self, body: dict) -> dict: kdf = keyset_kdf(body.get("keyset")) keyset_kdf(body.get("recovery"), recovery=True) public_key = _unb64(body.get("public_key"), "The public key", 32) + signing_key = body.get("signing_key") + if signing_key is not None: + signing_key = _b64(_unb64(signing_key, "The signing key", 32)) vault_id = body.get("personal_vault") if not isinstance(vault_id, str) or not VAULT_ID.match(vault_id): raise V2Error(400, "The personal vault id is not valid.") @@ -413,6 +478,7 @@ def create_account(self, body: dict) -> dict: "created_at": utc_now(), "login": hash_login_key(login_key), "public_key": _b64(public_key), + "signing_key": signing_key or "", "keyset": body["keyset"], "recovery": body["recovery"], "kdf": kdf, @@ -524,7 +590,8 @@ def _vault_view(self, username: str, vault_id: str): view = {"id": vault_id, "kind": members.get("kind", "personal"), "role": member.get("role", "owner"), "revision": meta["revision"], - "key_generation": meta.get("key_generation", 0)} + "key_generation": int(members.get("key_generation", 0)), + "members": len(members.get("members", {}))} # 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. @@ -542,6 +609,7 @@ def me(self, username: str) -> dict: if view is not None: vaults.append(view) return {"username": record["username"], "public_key": record["public_key"], + "signing_key": record.get("signing_key", ""), "keyset": record["keyset"], "recovery": record["recovery"], "kdf": record["kdf"], "personal_vault": vault_id, "vaults": vaults} @@ -562,6 +630,45 @@ def change_keyset(self, username: str, body: dict, keep_token: str) -> None: self._write_json(self._account_path(username), record) self._drop_expired_sessions(user_to_end=username, keep_token=keep_token) + def set_signing_key(self, username: str, body: dict) -> None: + """ + The public half of the key an account signs its changes with. Set + once: an account made before signing existed sends it at its next + login, and from then on it cannot be swapped -- not even by someone + holding a session of the account. + """ + key = _b64(_unb64(body.get("signing_key"), "The signing key", 32)) + with _lock: + record = self.account(username) + had = record.get("signing_key", "") + if had and had != key: + raise V2Error(409, "This account already has a signing key; " + "it cannot be replaced.") + if not had: + record["signing_key"] = key + self._write_json(self._account_path(username), record) + + def find_account(self, name: str): + """An account by name in any letter case (as people type it when they + share a vault), or None. Disabled accounts are not found.""" + if not isinstance(name, str) or not valid_username(name): + return None + record = self._read_json(self._account_path(name)) + if not record or record.get("disabled"): + return None + return record + + def public_keys(self, name: str) -> dict: + """What is needed to share a vault with an account and to check what + it signed: its public keys, and its name as it spells it.""" + record = self.find_account(name) + if record is None: + raise V2Error(404, f"There is no account called “{name}” on this " + "server.") + return {"username": record["username"], + "public_key": record["public_key"], + "signing_key": record.get("signing_key", "")} + # -- vaults ------------------------------------------------------------- @staticmethod def _sealed_name(body: dict) -> str: @@ -589,14 +696,17 @@ def create_vault(self, username: str, body: dict) -> dict: with _lock: record = self.account(username) mine = list(record.get("vaults", [])) - if len(mine) >= MAX_VAULTS_PER_ACCOUNT: + made = sum(1 for each in mine + if (self._read_json(self._members_path(each)) or {}) + .get("created_by") == username) + if made >= MAX_VAULTS_PER_ACCOUNT or len(mine) >= MAX_VAULTS_LISTED: 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, + "created_by": username, "name": name, "key_generation": 0, "members": {username: {"role": "owner", "wrapped_key": wrapped}}}) record["vaults"] = mine + [vault_id] self._write_json(self._account_path(username), record) @@ -636,6 +746,162 @@ def delete_vault(self, username: str, vault_id: str) -> None: os.replace(os.path.join(self.vaults.root, vault_id), os.path.join(graveyard, f"{vault_id}-{stamp}")) + # -- members ------------------------------------------------------------ + def _shared(self, vault_id: str) -> dict: + """A vault's members file, for a change to who is in it.""" + members = self._read_json(self._members_path(vault_id)) or {} + if members.get("kind") == "personal": + raise V2Error(400, "A personal vault cannot be shared; make " + "another vault for that.") + return members + + @staticmethod + def _owners(members: dict) -> list: + return [n for n, m in members.get("members", {}).items() + if m.get("role") == "owner"] + + def members(self, vault_id: str) -> dict: + """Who is in a vault -- every member may know.""" + members = self._read_json(self._members_path(vault_id)) or {} + return {"key_generation": int(members.get("key_generation", 0)), + "members": [ + {"username": name, "role": m.get("role", "owner"), + "added_by": m.get("added_by", ""), + "added_at": m.get("added_at", "")} + for name, m in sorted(members.get("members", {}).items(), + key=lambda kv: kv[0].lower())]} + + def put_member(self, actor: str, vault_id: str, name: str, body: dict) -> dict: + """ + Add an account to a vault, or change a member's role (owners only; + the caller has checked). A new member comes with the vault key + wrapped to their public key by the owner's device -- the server never + holds a key it could hand out itself. Returns {"username", "role", + "added": bool}. + """ + role = body.get("role") + if role not in ROLES: + raise V2Error(400, "The role must be owner, editor or reader.") + with _lock: + members = self._shared(vault_id) + people = members.setdefault("members", {}) + target = self.find_account(name) + if target is None: + raise V2Error(404, f"There is no account called “{name}” on " + "this server.") + who = target["username"] + if who in people: + if people[who].get("role") == "owner" and role != "owner" \ + and self._owners(members) == [who]: + raise V2Error(409, "A vault needs at least one owner.") + people[who]["role"] = role + self._write_json(self._members_path(vault_id), members) + return {"username": who, "role": role, "added": False} + wrapped = _b64(_unb64(body.get("wrapped_key"), "The vault key", + WRAPPED_KEY_LEN)) + if len(people) >= MAX_MEMBERS_PER_VAULT: + raise V2Error(403, f"A vault can have {MAX_MEMBERS_PER_VAULT} " + "members.") + listed = list(target.get("vaults", [])) + if len(listed) >= MAX_VAULTS_LISTED: + raise V2Error(409, f"“{who}” is in too many vaults already.") + people[who] = {"role": role, "wrapped_key": wrapped, + "added_by": actor, "added_at": utc_now()} + self._write_json(self._members_path(vault_id), members) + target["vaults"] = listed + [vault_id] + self._write_json(self._account_path(who), target) + return {"username": who, "role": role, "added": True} + + def _unlist(self, name: str, vault_id: str) -> None: + 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) + + def leave_vault(self, username: str, vault_id: str) -> None: + """A member takes themself out of a vault. (An owner removes someone + else with :meth:`rotate`, which changes the key in the same step.)""" + with _lock: + members = self._shared(vault_id) + people = members.get("members", {}) + if self._owners(members) == [username]: + raise V2Error(409, "You are this vault's only owner: make " + "someone else an owner first, or delete " + "the vault.") + people.pop(username, None) + self._write_json(self._members_path(vault_id), members) + self._unlist(username, vault_id) + + def rotate(self, actor: str, vault_id: str, body: dict) -> dict: + """ + Give a vault a new key, optionally removing members in the same step + (owners only; the caller has checked). The owner's device made the + key; what arrives is the vault re-encrypted under it, its name sealed + again, and the key wrapped to every member who stays. It all changes + together or not at all: + + - ``generation`` must be the next one; + - ``wrapped_keys`` must name exactly the members who stay; + - ``blob`` must be this vault at that generation, made against the + revision the server holds (``revision``), or absent while the + vault has never been uploaded. + + Earlier revisions are dropped: they are under the old key, which the + removed members still have. + """ + with _lock: + members = self._shared(vault_id) + people = members.get("members", {}) + generation = int(members.get("key_generation", 0)) + 1 + if body.get("generation") != generation: + raise V2Error(409, "The vault's key was changed in the " + "meantime. Sync, then try again.") + removed = body.get("remove") or [] + if not isinstance(removed, list) or actor in removed or \ + any(name not in people for name in removed): + raise V2Error(400, "The members to remove are not valid.") + staying = [name for name in people if name not in removed] + wrapped = body.get("wrapped_keys") + if not isinstance(wrapped, dict) or sorted(wrapped) != sorted(staying): + raise V2Error(409, "The members of this vault changed in the " + "meantime. Sync, then try again.") + clean = {name: _b64(_unb64(wrapped[name], "A vault key", + WRAPPED_KEY_LEN)) for name in staying} + name = self._sealed_name(body) + + current = int(self.vaults.meta(vault_id)["revision"]) + blob = body.get("blob") + if blob is None: + if current != 0: + raise V2Error(400, "The vault under its new key is missing.") + else: + if body.get("revision") != current: + raise V2Error(409, f"Vault has moved on: server is at " + f"revision {current}. Sync, then try " + "again.") + try: + raw = base64.b64decode(str(blob).encode("ascii"), validate=True) + except Exception: + raise V2Error(400, "Blob is not valid base64.") from None + check_vault_blob(raw, vault_id, generation) + self.vaults.write(vault_id, str(blob).encode("ascii"), + generation, drop_history=True) + + for gone in removed: + del people[gone] + for each in staying: + people[each]["wrapped_key"] = clean[each] + members["key_generation"] = generation + members["name"] = name + self._write_json(self._members_path(vault_id), members) + for gone in removed: + self._unlist(gone, vault_id) + return self._vault_view(actor, vault_id) + + def key_generation(self, vault_id: str) -> int: + members = self._read_json(self._members_path(vault_id)) or {} + return int(members.get("key_generation", 0)) + 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 ""): @@ -645,13 +911,20 @@ def role(self, username: str, vault_id: str): return member.get("role") if member else None -def check_vault_blob(raw: bytes, vault_id: str) -> None: +def check_vault_blob(raw: bytes, vault_id: str, generation: int = None) -> None: """A v2 vault blob must be PMV3 and name the vault it is stored as, so a - confused client cannot put one vault's file in another's place.""" + confused client cannot put one vault's file in another's place. With + ``generation`` it must also say it is under the vault's current key: a + device that missed a key change must sync before it uploads, or it would + put back a copy that the removed members can read.""" if raw[:4] != _VAULT_MAGIC or len(raw) < 4 + 1 + 16 + 4 + 12 + 16: raise V2Error(400, "That is not a SecretSpace 3 vault.") if raw[5:21] != bytes.fromhex(vault_id): raise V2Error(400, "That vault file belongs to another vault.") + if generation is not None and \ + int.from_bytes(raw[21:25], "big") != generation: + raise V2Error(409, "This vault's key was changed on another device. " + "Sync again to get the new key.") class Handler(BaseHTTPRequestHandler): @@ -714,8 +987,11 @@ def do_GET(self): # "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). + # "sharing" = members, roles, key change and the audit log + # (2.0.0-alpha.3). return self._send(200, {"ok": True, "service": "passman-sync", - "api": [1, 2], "features": ["vaults"]}) + "api": [1, 2], + "features": ["vaults", "sharing"]}) if self._is_v2(): return self._v2("GET") @@ -788,7 +1064,7 @@ def _read_body(self): return None return payload - def _store_blob(self, store, name, check=None): + def _store_blob(self, store, name, check=None, stored=None): try: length = int(self.headers.get("Content-Length", 0)) except ValueError: @@ -858,6 +1134,8 @@ def _store_blob(self, store, name, check=None): except OSError as e: return self._error(500, f"Could not store the vault: {e}") + if stored is not None: + stored(meta) return self._send(200, meta) # -- API v2: SecretSpace 3 accounts ------------------------------------- @@ -879,7 +1157,11 @@ def _bearer(self) -> str: return header[len("Bearer "):] if header.startswith("Bearer ") else "" def _v2(self, method: str): - parts = [p for p in self.path.split("?")[0].split("/") if p][1:] + # Names in a path arrive percent-encoded ("a%20b"). Whatever a part + # decodes to, it is only ever compared with fixed words or checked as + # a username or a vault id before it is used. + parts = [urllib.parse.unquote(p) + for p in self.path.split("?")[0].split("/") if p][1:] try: return self._v2_route(method, parts) except V2Error as e: @@ -892,9 +1174,21 @@ def _v2_route(self, method, parts): # -- no login needed --------------------------------------------------- if route == ("GET", "prelogin", 2): return self._send(200, accounts.prelogin(parts[1])) + address = self.client_address[0] if route == ("POST", "login", 1): body = self._read_body() - return None if body is None else self._send(200, accounts.login(body)) + if body is None: + return None + tried = body.get("username") + tried = tried if isinstance(tried, str) and valid_username(tried) else "?" + try: + reply = accounts.login(body) + except V2Error as e: + accounts.audit(None, "login_failed", tried, address, + status=e.status) + raise + accounts.audit(None, "login", reply["username"], address) + return self._send(200, reply) if route == ("POST", "accounts", 1): # The sign-up code is the server's token: whoever was given it may # make an account here, as whoever has it may sync a 2.x vault. @@ -904,7 +1198,9 @@ def _v2_route(self, method, parts): body = self._read_body() if body is None: return None - return self._send(201, accounts.create_account(body)) + made = accounts.create_account(body) + accounts.audit(None, "account_created", made["username"], address) + return self._send(201, made) # -- everything else needs a session ------------------------------- token = self._bearer() @@ -922,46 +1218,132 @@ def _v2_route(self, method, parts): if body is None: return None accounts.change_keyset(username, body, keep_token=token) + accounts.audit(None, "password_changed", username, address) return self._send(200, {}) + if route == ("PUT", "me", 2) and parts[1] == "signing-key": + body = self._read_body() + if body is None: + return None + accounts.set_signing_key(username, body) + return self._send(200, {}) + if route == ("GET", "accounts", 3) and parts[2] == "public-key": + return self._send(200, accounts.public_keys(parts[1])) 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): + made = accounts.create_vault(username, body) + accounts.audit(made["id"], "vault_created", username, address) + return self._send(201, made) + if parts and parts[0] == "vaults" and len(parts) in (2, 3, 4): vault_id = parts[1] - tail = parts[2] if len(parts) == 3 else "" + 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", "name"): + if role is None or tail not in ("", "meta", "name", "members", + "rotate", "log"): 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": + if len(parts) == 4 and tail != "members": + return self._error(404, "No such endpoint.") + owners_only = self._v2_vault_owner_call( + method, tail, len(parts), parts[3] if len(parts) == 4 else "", + username) + if owners_only is not None: + if role != "owner" and not owners_only["anyone"]: 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 owners_only["run"](accounts, vault_id, username, address) + if tail == "members" and method == "GET" and len(parts) == 3: + return self._send(200, accounts.members(vault_id)) + if method == "GET" and tail in ("", "meta") and len(parts) <= 3: return self._send_blob(accounts.vaults, vault_id, tail) if method == "PUT" and not tail: if role not in ROLES_THAT_WRITE: return self._error(403, "You can read this vault but not " "change it.") + generation = accounts.key_generation(vault_id) return self._store_blob( accounts.vaults, vault_id, - check=lambda raw: check_vault_blob(raw, vault_id)) + check=lambda raw: check_vault_blob(raw, vault_id, generation), + stored=lambda meta: accounts.audit( + vault_id, "upload", username, address, + revision=meta["revision"])) return self._error(404, "No such endpoint.") + def _v2_vault_owner_call(self, method, tail, count, member, username): + """ + The calls about a vault itself rather than what is in it -- its + name, its members, its key, its log, deleting it. Returns None for + anything else, or {"run": the call, "anyone": may a member who is + not an owner make it}. + """ + def body_then(action): + def run(accounts, vault_id, actor, address): + body = self._read_body() + if body is None: + return None + return action(accounts, vault_id, actor, address, body) + return run + + def rename(accounts, vault_id, actor, address, body): + view = accounts.rename_vault(actor, vault_id, body) + accounts.audit(vault_id, "vault_renamed", actor, address) + return self._send(200, view) + + def delete(accounts, vault_id, actor, address): + # Logged first: the line then leaves with the vault's folder. + accounts.audit(vault_id, "vault_deleted", actor, address) + accounts.delete_vault(actor, vault_id) + return self._send(200, {}) + + def put_member(accounts, vault_id, actor, address, body): + done = accounts.put_member(actor, vault_id, member, body) + accounts.audit(vault_id, + "member_added" if done["added"] else "member_role", + actor, address, member=done["username"], + role=done["role"]) + return self._send(200, done) + + def leave(accounts, vault_id, actor, address): + accounts.leave_vault(actor, vault_id) + accounts.audit(vault_id, "member_left", actor, address) + return self._send(200, {}) + + def rotate(accounts, vault_id, actor, address, body): + view = accounts.rotate(actor, vault_id, body) + accounts.audit(vault_id, "key_changed", actor, address, + generation=view["key_generation"], + removed=list(body.get("remove") or [])) + return self._send(200, view) + + def log(accounts, vault_id, actor, address): + return self._send(200, accounts.audit_lines(vault_id)) + + if (method, tail, count) == ("PUT", "name", 3): + return {"run": body_then(rename), "anyone": False} + if (method, tail, count) == ("DELETE", "", 2): + return {"run": delete, "anyone": False} + if (method, tail, count) == ("PUT", "members", 4): + return {"run": body_then(put_member), "anyone": False} + if (method, tail, count) == ("DELETE", "members", 4): + # Only yourself; an owner removes others by changing the key. + if member != username: + return {"run": lambda *a: self._error( + 400, "Remove a member by changing the vault's key " + "(rotate), so they cannot read what is written " + "afterwards."), "anyone": False} + return {"run": leave, "anyone": True} + if (method, tail, count) == ("POST", "rotate", 3): + return {"run": body_then(rotate), "anyone": False} + if (method, tail, count) == ("GET", "log", 3): + return {"run": log, "anyone": False} + if tail in ("name", "rotate", "log") or method == "DELETE" \ + or (tail == "members" and (count == 4 or method != "GET")): + return {"run": lambda *a: self._error(404, "No such endpoint."), + "anyone": True} + return None + def load_token(args) -> str: """ diff --git a/sourceFiles/tests/test_account_sharing.py b/sourceFiles/tests/test_account_sharing.py new file mode 100644 index 0000000..fe7fde8 --- /dev/null +++ b/sourceFiles/tests/test_account_sharing.py @@ -0,0 +1,743 @@ +""" +Phase 4: a vault shared between accounts, and the record of who changed +what -- end to end through the real (test copy of the) sync server. + +alice and bob are two accounts on two "devices" (two data folders). alice +owns a vault called Team and shares it with bob. +""" + +import base64 +import copy +import json +import os + +import pytest + +import account_crypto +import account_sync +import accounts +import changelog +import vault +from account_crypto import CryptoError +from sync_client import SyncError + +from test_account_sync import (PW, TOKEN, alice, cheap_argon2, device, # noqa: F401 + new_account, passman_server, server) +from test_account_vaults import reconcile, sync_all, sync_vault, sites + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def logged_in(account): + return account_sync.logged_in(account) + + +def with_server(account, work): + session = logged_in(account) + try: + return work(session) + finally: + session.logout() + + +def add_entry(account, vault_id, site, password="pw"): + """What the app does on Save: change the data, note who did, write.""" + file = account.vault_file(vault_id) + before = file.load() + data = copy.deepcopy(before) + made = vault.add_account(data, site, "me", password) + changelog.record(data, before, account.username, account.keyset, file.hex) + file.save(data) + return made["id"] + + +def edit_entry(account, vault_id, site, **fields): + file = account.vault_file(vault_id) + before = file.load() + data = copy.deepcopy(before) + target = next(e for e in data["accounts"] if e["site"] == site) + vault.update_entry(target, **{**vault.snapshot(target), **fields}) + changelog.record(data, before, account.username, account.keyset, file.hex) + file.save(data) + + +def share(owner, vault_id, name, role=accounts.ROLE_EDITOR): + def work(session): + keys = account_sync.lookup_contact(owner, session, name) + return account_sync.share_vault(owner, session, vault_id, keys, role) + return with_server(owner, work) + + +def rotate(owner, vault_id, remove=()): + data = owner.vault_file(vault_id).load() + return with_server(owner, lambda session: account_sync.change_key( + owner, session, vault_id, data, remove=list(remove))) + + +def log_of(account, vault_id): + return changelog.lines_of(account.vault_file(vault_id).load()) + + +def server_blob(server, vault_id): + path = os.path.join(server.store.root, "_v2", "vaults", vault_id, "vault.enc") + with open(path, "rb") as f: + return f.read().decode("ascii") + + +@pytest.fixture +def bob(server, device, alice): # noqa: F811 + """Device b: bob, his own account on the same server.""" + device("b") + new = new_account("bob") + account = new.save() + assert account_sync.connect(account, server.url, "", TOKEN) == "created" + sync_all(account) # the server learns his signing key + device("a") + return account + + +@pytest.fixture +def team(server, device, alice, bob): # noqa: F811 + """alice's vault Team with one entry, on the server, shared with bob as + an editor and fetched by him. Returns (alice, bob, vault id).""" + device("a") + vault_id = alice.create_vault("Team").hex + add_entry(alice, vault_id, "Router") + sync_all(alice) + share(alice, vault_id, "bob") + device("b") + sync_all(bob) + device("a") + return alice, bob, vault_id + + +# --------------------------------------------------------------------------- +# A. The signing key +# --------------------------------------------------------------------------- +def test_a_signature_checks_out_only_for_its_message_and_its_signer(): + one, two = account_crypto.new_keyset(), account_crypto.new_keyset() + signature = account_crypto.sign(one, b"hello") + assert account_crypto.verify(account_crypto.signing_key_of(one), b"hello", + signature) + assert not account_crypto.verify(account_crypto.signing_key_of(one), + b"hellp", signature) + assert not account_crypto.verify(account_crypto.signing_key_of(two), + b"hello", signature) + + +@pytest.mark.parametrize("junk", ["", "not base64 !!", "AAAA", None]) +def test_verify_says_no_to_junk_instead_of_raising(junk): + key = account_crypto.signing_key_of(account_crypto.new_keyset()) + assert account_crypto.verify(key, b"m", junk) is False + assert account_crypto.verify(b"short", b"m", "AAAA") is False + + +def test_the_signing_key_comes_from_the_keyset_and_is_not_the_wrapping_key(): + keyset = account_crypto.new_keyset() + again = json.loads(json.dumps(keyset)) + assert account_crypto.signing_key_of(keyset) == \ + account_crypto.signing_key_of(again) + assert account_crypto.signing_key_of(keyset) != \ + account_crypto.public_key_of(keyset) + assert len(account_crypto.signing_key_of(keyset)) == 32 + + +def test_the_account_fingerprint_covers_both_keys(): + a, b = os.urandom(32), os.urandom(32) + base = account_crypto.account_fingerprint(a, b) + assert base != account_crypto.account_fingerprint(a, os.urandom(32)) + assert base != account_crypto.account_fingerprint(os.urandom(32), b) + assert len(base.split()) == 6 and all(len(g) == 4 and g.isdigit() + for g in base.split()) + + +# --------------------------------------------------------------------------- +# B. The change log on its own +# --------------------------------------------------------------------------- +def entry(site, entry_id=None, **more): + made = {"id": entry_id or vault.new_id(), "site": site, "username": "u", + "password": "p"} + made.update(more) + return made + + +def test_diff_names_what_happened_to_each_entry(): + kept, edited, gone, back, filed = (entry(s) for s in + ("Kept", "Edited", "Gone", "Back", "Filed")) + back["archived_at"] = "2026-01-01T00:00:00Z" + before = {"accounts": [kept, edited, gone, back, filed]} + after = copy.deepcopy({"accounts": [kept, edited, back, filed]}) + after["accounts"][1]["password"] = "new" + after["accounts"][1]["notes"] = "n" + del after["accounts"][2]["archived_at"] + after["accounts"][3]["archived_at"] = "2026-02-02T00:00:00Z" + after["accounts"].append(entry("New")) + found = {(d["action"], d["title"]): d for d in changelog.diff(before, after)} + assert set(found) == {("added", "New"), ("edited", "Edited"), + ("restored", "Back"), ("archived", "Filed"), + ("deleted", "Gone")} + assert found[("edited", "Edited")]["fields"] == ["password", "notes"] + + +def test_diff_of_an_unchanged_vault_is_empty(): + data = {"accounts": [entry("A"), entry("B")]} + assert changelog.diff(data, copy.deepcopy(data)) == [] + + +def test_going_back_to_an_earlier_version_is_called_a_revert(): + before = {"accounts": [entry("A")]} + vault.update_entry(before["accounts"][0], site="A", username="u", + password="second") + after = copy.deepcopy(before) + rid = after["accounts"][0]["history"][0]["rid"] + vault.revert_entry(after["accounts"][0], rid) + assert [d["action"] for d in changelog.diff(before, after)] == ["reverted"] + + +def test_many_entries_added_at_once_are_one_line(): + after = {"accounts": [entry(f"S{i}") for i in range(changelog.BULK + 1)]} + found = changelog.diff({"accounts": []}, after) + assert [(d["action"], d["count"]) for d in found] == \ + [("imported", changelog.BULK + 1)] + exactly = {"accounts": after["accounts"][:changelog.BULK]} + assert len(changelog.diff({"accounts": []}, exactly)) == changelog.BULK + + +def test_a_line_never_holds_a_value(): + keyset = account_crypto.new_keyset() + before = {"accounts": [entry("Mail", username="who@x", password="old-secret")]} + after = copy.deepcopy(before) + vault.update_entry(after["accounts"][0], site="Mail", username="new@x", + password="new-secret", notes="a private note") + changelog.record(after, before, "alice", keyset, "ab" * 16) + text = json.dumps(after[changelog.CHANGES_KEY]) + for value in ("old-secret", "new-secret", "who@x", "new@x", "a private note"): + assert value not in text + + +def test_record_signs_its_lines_and_names_the_author_on_the_entry(): + keyset = account_crypto.new_keyset() + before = {"accounts": [entry("Mail")]} + after = copy.deepcopy(before) + vault.update_entry(after["accounts"][0], site="Mail", username="u", + password="changed") + assert changelog.record(after, before, "alice", keyset, "ab" * 16) == 1 + line = changelog.lines_of(after)[0] + assert (line["by"], line["action"], line["fields"]) == \ + ("alice", "edited", ["password"]) + key = account_crypto.signing_key_of(keyset) + assert changelog.check(line, "ab" * 16, key) == changelog.OK + assert after["accounts"][0]["updated_by"] == "alice" + assert after["accounts"][0]["history"][0]["by"] == "alice" + # ... and it survives the vault's own validation + clean = vault.validate_vault(copy.deepcopy(after)) + assert clean["accounts"][0]["updated_by"] == "alice" + assert clean["accounts"][0]["history"][0]["by"] == "alice" + + +@pytest.mark.parametrize("field,value", [ + ("by", "bob"), ("at", "2020-01-01T00:00:00.000000Z"), ("action", "deleted"), + ("entry", "another-entry"), ("title", "Other"), ("fields", ["notes"]), + ("count", 7)]) +def test_a_line_changed_after_signing_is_flagged(field, value): + keyset = account_crypto.new_keyset() + before = {"accounts": [entry("Mail")]} + after = copy.deepcopy(before) + vault.update_entry(after["accounts"][0], site="Mail", username="u", + password="changed") + changelog.record(after, before, "alice", keyset, "ab" * 16) + line = dict(changelog.lines_of(after)[0]) + line[field] = value + assert changelog.check(line, "ab" * 16, + account_crypto.signing_key_of(keyset)) == changelog.BAD + + +def test_a_line_is_bound_to_its_vault_and_to_its_signer(): + keyset, other = account_crypto.new_keyset(), account_crypto.new_keyset() + after = {"accounts": [entry("Mail")]} + changelog.record(after, {"accounts": []}, "alice", keyset, "ab" * 16) + line = changelog.lines_of(after)[0] + key = account_crypto.signing_key_of(keyset) + assert changelog.check(line, "cd" * 16, key) == changelog.BAD + assert changelog.check(line, "ab" * 16, + account_crypto.signing_key_of(other)) == changelog.BAD + assert changelog.check(line, "ab" * 16, None) == changelog.UNKNOWN + unsigned = dict(line, sig="") + assert changelog.check(unsigned, "ab" * 16, key) == changelog.BAD + + +def test_merge_keeps_each_line_once_oldest_first_and_caps_the_log(monkeypatch): + def line(n): + return {"id": f"{n:016x}", "at": f"2026-01-01T00:00:{n:02d}.000000Z", + "by": "a", "action": "added", "entry": "e", "title": "t", + "fields": [], "count": 0, "sig": "x"} + one, two = [line(3), line(1)], [line(2), line(1), "junk", {"id": "x"}] + merged = changelog.merge(one, two) + assert [l["id"] for l in merged] == [f"{n:016x}" for n in (1, 2, 3)] + assert changelog.merge(two, one) == merged + monkeypatch.setattr(changelog, "CHANGES_LIMIT", 2) + assert [l["id"] for l in changelog.merge(one, two)] == \ + [f"{n:016x}" for n in (2, 3)] + + +def test_describe_reads_as_a_sentence(): + assert changelog.describe({"action": "edited", "title": "Mail", + "fields": ["username", "password"]}) == \ + "changed the username and password of “Mail”" + assert changelog.describe({"action": "added", "title": "Mail"}) == \ + "added “Mail”" + assert "12 entries" in changelog.describe({"action": "imported", "count": 12}) + + +# --------------------------------------------------------------------------- +# C. Sharing through the server +# --------------------------------------------------------------------------- +def test_a_shared_vault_arrives_at_the_other_account_with_its_entries(team, device): + alice, bob, vault_id = team + device("b") + assert vault_id in bob.vault_ids() + assert bob.vault_file(vault_id).name == "Team" + assert bob.vault_role(vault_id) == accounts.ROLE_EDITOR + assert bob.is_shared(vault_id) + assert sites(bob, vault_id) == ["Router"] + + +def test_the_server_never_holds_the_shared_vaults_key(team, server, device): + alice, bob, vault_id = team + key = alice.vault_file(vault_id)._key + 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() + assert key not in found and base64.b64encode(key) not in found + assert b"Router" not in found and b"Team" not in found + + +def test_each_members_wrapped_key_opens_only_for_them(team, device): + alice, bob, vault_id = team + device("b") + wrapped = bob.vault_record(vault_id)["wrapped_key"] + assert wrapped != alice.vault_record(vault_id)["wrapped_key"] + with pytest.raises(CryptoError): + account_crypto.unwrap_key(alice.keyset, wrapped, bytes.fromhex(vault_id), + "alice") + with pytest.raises(CryptoError): + account_crypto.unwrap_key(alice.keyset, wrapped, bytes.fromhex(vault_id), + "bob") + + +def test_a_change_by_one_member_reaches_the_other_with_their_name(team, device): + alice, bob, vault_id = team + device("b") + add_entry(bob, vault_id, "Printer") + edit_entry(bob, vault_id, "Router", password="changed-by-bob") + sync_all(bob) + device("a") + sync_all(alice) + data = alice.vault_file(vault_id).load() + router = next(e for e in data["accounts"] if e["site"] == "Router") + assert router["password"] == "changed-by-bob" + assert router["updated_by"] == "bob" + assert router["history"][0]["by"] == "bob" + lines = [(l["by"], l["action"], l["title"]) for l in log_of(alice, vault_id)] + assert lines == [("alice", "added", "Router"), ("bob", "added", "Printer"), + ("bob", "edited", "Router")] + # alice compared bob's fingerprint when she shared: his lines check out + for line in log_of(alice, vault_id): + assert changelog.check(line, vault_id, + alice.signing_key_for(line["by"])) == changelog.OK + + +def test_a_line_forged_under_another_members_name_is_flagged(team, device): + alice, bob, vault_id = team + device("b") + file = bob.vault_file(vault_id) + before = file.load() + data = copy.deepcopy(before) + vault.add_account(data, "Backdoor", "me", "pw") + # bob writes the line under alice's name (he holds the vault key, so he + # can write anything) -- but he can only sign it with his own key + changelog.record(data, before, "alice", bob.keyset, file.hex) + file.save(data) + sync_all(bob) + device("a") + sync_all(alice) + forged = [l for l in log_of(alice, vault_id) if l["title"] == "Backdoor"][0] + assert forged["by"] == "alice" + assert changelog.check(forged, vault_id, + alice.signing_key_for("alice")) == changelog.BAD + + +def test_both_members_lines_survive_changes_made_at_the_same_time(team, device): + alice, bob, vault_id = team + add_entry(alice, vault_id, "FromAlice") + device("b") + add_entry(bob, vault_id, "FromBob") + sync_all(bob) + device("a") + sync_all(alice) + device("b") + sync_all(bob) + for who in (alice, bob): + device("a" if who is alice else "b") + assert sites(who, vault_id) == ["FromAlice", "FromBob", "Router"] + assert sorted((l["by"], l["title"]) for l in log_of(who, vault_id)) == \ + [("alice", "FromAlice"), ("alice", "Router"), ("bob", "FromBob")] + + +def test_a_sync_with_only_new_log_lines_still_has_work(team, device): + """A line with no entry change left to carry it (the entry was changed + back) must not be stranded on one device.""" + alice, bob, vault_id = team + file = alice.vault_file(vault_id) + data = file.load() + extra = {"id": "f" * 16, "at": vault.history_now(), "by": "alice", + "action": "added", "entry": "x", "title": "Ghost", "fields": [], + "count": 0, "sig": ""} + data[changelog.CHANGES_KEY] = changelog.merge(changelog.lines_of(data), [extra]) + file.save(data) + sync_all(alice) + device("b") + sync_all(bob) + assert "Ghost" in [l["title"] for l in log_of(bob, vault_id)] + + +# --------------------------------------------------------------------------- +# D. Roles +# --------------------------------------------------------------------------- +def test_a_reader_gets_the_entries_and_uploads_nothing(server, device, alice, bob): # noqa: F811 + vault_id = alice.create_vault("Team").hex + add_entry(alice, vault_id, "Router") + sync_all(alice) + share(alice, vault_id, "bob", accounts.ROLE_READER) + device("b") + sync_all(bob) + assert bob.vault_role(vault_id) == accounts.ROLE_READER + assert not bob.can_write(vault_id) + assert not bob.vault_file(vault_id).can_write + assert sites(bob, vault_id) == ["Router"] + # a change that somehow got into his copy stays there and goes nowhere + add_entry(bob, vault_id, "Sneaked") + stored = server_blob(server, vault_id) + sync_all(bob) + assert server_blob(server, vault_id) == stored + device("a") + sync_all(alice) + assert sites(alice, vault_id) == ["Router"] + + +def test_the_server_refuses_a_readers_upload(server, device, alice, bob): # noqa: F811 + vault_id = alice.create_vault("Team").hex + sync_all(alice) + share(alice, vault_id, "bob", accounts.ROLE_READER) + device("b") + sync_all(bob) + file = bob.vault_file(vault_id) + with pytest.raises(SyncError, match="not\\s+change"): + with_server(bob, lambda s: s.put_vault( + vault_id, file.encrypt({"accounts": []}), + s.get_vault(vault_id)["revision"])) + + +def test_a_role_change_reaches_the_member_with_the_next_sync(team, device): + alice, bob, vault_id = team + with_server(alice, lambda s: s.put_member(vault_id, "bob", + accounts.ROLE_READER)) + device("b") + changes = sync_all(bob) + assert changes.roles == [("Team", accounts.ROLE_READER)] + assert bob.vault_role(vault_id) == accounts.ROLE_READER + + +def test_only_an_owner_can_share_or_change_the_key(team, device): + alice, bob, vault_id = team + device("b") + carol = new_account("carol").save() + assert account_sync.connect(carol, alice.server["url"], "", TOKEN) == "created" + sync_all(carol) + with pytest.raises(SyncError, match="owner"): + share(bob, vault_id, "carol") + with pytest.raises(SyncError, match="owner"): + rotate(bob, vault_id) + + +def test_the_personal_vault_cannot_be_shared(server, device, alice, bob): # noqa: F811 + with pytest.raises(SyncError, match="[Pp]ersonal"): + share(alice, alice.personal_id, "bob") + + +def test_sharing_with_a_name_the_server_does_not_know_is_refused(team): + alice, bob, vault_id = team + with pytest.raises(SyncError, match="no account"): + share(alice, vault_id, "nobody") + + +def test_sharing_with_yourself_is_refused(team): + alice, bob, vault_id = team + with pytest.raises(SyncError, match="own account"): + share(alice, vault_id, "alice") + + +def test_the_member_list_says_who_is_checked(team, device): + alice, bob, vault_id = team + listed = with_server(alice, lambda s: account_sync.list_members( + alice, s, vault_id)) + assert [(m["username"], m["role"], m["me"], m["verified"]) for m in listed] == \ + [("alice", "owner", True, False), ("bob", "editor", False, True)] + device("b") + listed = with_server(bob, lambda s: account_sync.list_members(bob, s, vault_id)) + # bob never compared alice's fingerprint + assert [(m["username"], m["verified"]) for m in listed] == \ + [("alice", False), ("bob", False)] + + +# --------------------------------------------------------------------------- +# E. Keys of other people +# --------------------------------------------------------------------------- +def test_lookup_says_new_then_known_and_shows_the_other_sides_fingerprint( + server, device, alice, bob): # noqa: F811 + keys = with_server(alice, lambda s: account_sync.lookup_contact(alice, s, "BOB")) + assert keys["username"] == "bob" and keys["status"] == "new" + assert keys["fingerprint"] == bob.fingerprint + account_sync.trust_contact(alice, keys, True) + again = with_server(alice, lambda s: account_sync.lookup_contact(alice, s, "bob")) + assert (again["status"], again["verified"]) == ("known", True) + # remembered across a lock + assert accounts.unlock("alice", PW).contact("bob")["verified"] is True + + +def test_lookup_notices_keys_that_changed(server, device, alice, bob): # noqa: F811 + keys = with_server(alice, lambda s: account_sync.lookup_contact(alice, s, "bob")) + account_sync.trust_contact(alice, keys, True) + alice.remember_contact("bob", os.urandom(32), keys["signing_key"], True) + again = with_server(alice, lambda s: account_sync.lookup_contact(alice, s, "bob")) + assert (again["status"], again["verified"]) == ("changed", False) + + +def test_a_key_change_stops_when_a_members_keys_are_not_the_ones_seen(team, server): + alice, bob, vault_id = team + seen = alice.contact("bob") + alice.remember_contact("bob", os.urandom(32), seen["signing_key"], True) + stored = server_blob(server, vault_id) + with pytest.raises(account_sync.KeyChanged): + rotate(alice, vault_id) + assert server_blob(server, vault_id) == stored + assert alice.vault_file(vault_id).generation == 0 + + +def test_an_account_from_before_signing_sends_its_key_at_the_next_sync( + server, device, alice): # noqa: F811 + path = os.path.join(server.store.root, "_v2", "accounts", "alice.json") + with open(path, encoding="utf-8") as f: + record = json.load(f) + record["signing_key"] = "" + with open(path, "w", encoding="utf-8") as f: + json.dump(record, f) + sync_all(alice) + with open(path, encoding="utf-8") as f: + assert json.load(f)["signing_key"] == alice.signing_key + + +# --------------------------------------------------------------------------- +# F. Removing a member: a new key +# --------------------------------------------------------------------------- +def test_a_removed_member_cannot_open_what_is_written_afterwards(team, server, device): + alice, bob, vault_id = team + device("b") + old_key = bob.vault_file(vault_id)._key + device("a") + rotate(alice, vault_id, remove=["bob"]) + add_entry(alice, vault_id, "AfterBob") + sync_all(alice) + assert alice.vault_file(vault_id).generation == 1 + assert sites(alice, vault_id) == ["AfterBob", "Router"] + with pytest.raises(CryptoError): + account_crypto.decrypt_vault(old_key, server_blob(server, vault_id)) + # the server lets him in no more, and his copy is kept but marked + device("b") + with pytest.raises(SyncError): + with_server(bob, lambda s: s.get_vault(vault_id)) + changes = sync_all(bob) + assert changes.gone == ["Team"] + assert bob.vault_record(vault_id)["gone_from_server"] is True + assert sites(bob, vault_id) == ["Router"] + + +def test_the_earlier_revisions_under_the_old_key_are_dropped(team, server): + alice, bob, vault_id = team + add_entry(alice, vault_id, "Second") + sync_all(alice) + history = os.path.join(server.store.root, "_v2", "vaults", vault_id, "history") + assert os.listdir(history) + rotate(alice, vault_id, remove=["bob"]) + assert os.listdir(history) == [] + + +def test_nothing_under_the_old_key_stays_on_the_owners_device(team): + alice, bob, vault_id = team + old = alice.vault_file(vault_id) + old_key = old._key + rotate(alice, vault_id, remove=["bob"]) + new = alice.vault_file(vault_id) + assert new._key != old_key + for path in (new.path, new.base_path): + with open(path, encoding="utf-8") as f: + blob = f.read().strip() + assert account_crypto.vault_header(blob)["key_generation"] == 1 + with pytest.raises(CryptoError): + account_crypto.decrypt_vault(old_key, blob) + assert not os.path.exists(new.path + vault.BACKUP_SUFFIX) + assert sites(alice, vault_id) == ["Router"] + # and it survives a lock + assert sorted(e["site"] for e in accounts.unlock("alice", PW) + .vault_file(vault_id).load()["accounts"]) == ["Router"] + + +def test_a_member_who_stays_gets_the_new_key_and_keeps_unsynced_changes(team, device): + alice, bob, vault_id = team + device("b") + add_entry(bob, vault_id, "BobOffline") # not synced yet + device("a") + rotate(alice, vault_id) # nobody removed + add_entry(alice, vault_id, "AfterRotation") + sync_all(alice) + device("b") + changes = sync_all(bob) + assert changes.rekeyed == ["Team"] + assert bob.vault_file(vault_id).generation == 1 + assert sites(bob, vault_id) == ["AfterRotation", "BobOffline", "Router"] + device("a") + sync_all(alice) + assert sites(alice, vault_id) == ["AfterRotation", "BobOffline", "Router"] + + +def test_an_upload_under_the_old_key_is_refused_after_a_key_change(team, device): + alice, bob, vault_id = team + device("b") + stale = bob.vault_file(vault_id) # generation 0 + device("a") + rotate(alice, vault_id) + device("b") + with pytest.raises(SyncError, match="key was changed"): + with_server(bob, lambda s: s.put_vault( + vault_id, stale.encrypt({"accounts": []}), + s.get_vault(vault_id)["revision"])) + + +def test_the_owners_other_device_takes_the_new_key(team, server, device): + alice, bob, vault_id = team + device("c") + other, _ = account_sync.sign_in(server.url, "", "alice", PW, alice.kit) + assert sites(other, vault_id) == ["Router"] + device("a") + rotate(alice, vault_id, remove=["bob"]) + add_entry(alice, vault_id, "New") + sync_all(alice) + device("c") + changes = sync_all(other) + assert changes.rekeyed == ["Team"] + assert sites(other, vault_id) == ["New", "Router"] + + +def test_the_vaults_name_is_readable_under_the_new_key(team, device): + alice, bob, vault_id = team + rotate(alice, vault_id) + device("b") + sync_all(bob) + listed = with_server(bob, lambda s: s.me())["vaults"] + sealed = next(v["name"] for v in listed if v["id"] == vault_id) + assert bob.vault_file(vault_id).open_name(sealed) == "Team" + + +def test_a_key_change_needs_the_vault_synced_without_open_questions(team, device): + alice, bob, vault_id = team + edit_entry(alice, vault_id, "Router", password="alice-version") + device("b") + edit_entry(bob, vault_id, "Router", password="bob-version") + sync_all(bob) + device("a") + with pytest.raises(SyncError, match="Sync first"): + rotate(alice, vault_id, remove=["bob"]) + assert alice.vault_file(vault_id).generation == 0 + + +def test_removing_someone_who_is_not_a_member_changes_nothing(team): + alice, bob, vault_id = team + with pytest.raises(SyncError, match="Not a member"): + rotate(alice, vault_id, remove=["carol"]) + assert alice.vault_file(vault_id).generation == 0 + + +# --------------------------------------------------------------------------- +# G. Leaving, and vaults on another server +# --------------------------------------------------------------------------- +def test_a_member_leaves_and_the_vault_stays_for_the_owner(team, device): + alice, bob, vault_id = team + device("b") + account_sync.delete_vault(bob, vault_id) + assert vault_id not in bob.vault_ids() + assert sync_all(bob).added == [] # it does not come back + device("a") + sync_all(alice) + assert sites(alice, vault_id) == ["Router"] + listed = with_server(alice, lambda s: account_sync.list_members( + alice, s, vault_id)) + assert [m["username"] for m in listed] == ["alice"] + + +def test_an_owner_deleting_the_vault_takes_it_from_every_member(team, device): + alice, bob, vault_id = team + account_sync.delete_vault(alice, vault_id) + device("b") + assert sync_all(bob).gone == ["Team"] + assert sites(bob, vault_id) == ["Router"] # kept on his device + + +def test_a_vault_put_back_starts_as_ones_own_again(team, device): + alice, bob, vault_id = team + rotate(alice, vault_id, remove=["bob"]) + device("b") + sync_all(bob) + account_sync.put_vault_back(bob, vault_id) + record = bob.vault_record(vault_id) + assert (record["role"], record["key_generation"], record["members"]) == \ + (accounts.ROLE_OWNER, 0, 1) + + +def test_moving_to_another_server_forgets_roles_and_generations(team): + alice, bob, vault_id = team + rotate(alice, vault_id) + assert alice.vault_record(vault_id)["key_generation"] == 1 + alice.set_server("http://127.0.0.1:1") + record = alice.vault_record(vault_id) + assert "key_generation" not in record and "role" not in record + assert alice.vault_file(vault_id).generation == 0 + assert sites(alice, vault_id) == ["Router"] # still opens + + +# --------------------------------------------------------------------------- +# H. The server's own log, as an owner reads it +# --------------------------------------------------------------------------- +def test_the_owner_reads_what_the_server_noted(team, device): + alice, bob, vault_id = team + device("b") + add_entry(bob, vault_id, "Printer") + sync_all(bob) + device("a") + lines = with_server(alice, lambda s: s.vault_log(vault_id)) + events = [(l["event"], l["by"]) for l in lines] + assert events[0] == ("vault_created", "alice") + assert ("member_added", "alice") in events + assert events[-1] == ("upload", "bob") + assert all(l.get("address") == "127.0.0.1" for l in lines) + device("b") + with pytest.raises(SyncError, match="owner"): + with_server(bob, lambda s: s.vault_log(vault_id)) + + +def test_a_new_account_gives_the_server_its_signing_key_at_sign_up(server, alice): # noqa: F811 + path = os.path.join(server.store.root, "_v2", "accounts", "alice.json") + with open(path, encoding="utf-8") as f: + assert json.load(f)["signing_key"] == alice.signing_key diff --git a/sourceFiles/vault.py b/sourceFiles/vault.py index 3dbb2e3..0e4e06a 100644 --- a/sourceFiles/vault.py +++ b/sourceFiles/vault.py @@ -28,7 +28,7 @@ #: never for deciding a merge, because the two PCs' clocks disagree. That #: decision belongs to the three-way merge in sync.py. OPTIONAL_FIELDS = ("notes", "totp", "updated_at", "archived_at", - "history_cleared_at") + "history_cleared_at", "updated_by") #: What a revision records, and what counts as an edit worth recording. #: Everything the user can type into an entry -- not just the password, since @@ -677,6 +677,11 @@ def _clean_revision(raw): changed = raw.get("changed") rev["changed"] = ([f for f in REVISION_FIELDS if f in changed] if isinstance(changed, list) else []) + # Who made the edit that replaced this version (3.0 accounts; changelog). + # Not part of the id: the same revision is the same one with or without. + by = str(raw.get("by", "") or "")[:64] + if by: + rev["by"] = by rev["rid"] = _revision_id(rev) return rev diff --git a/sourceFiles/vaults_ui.py b/sourceFiles/vaults_ui.py index b19fe06..cbcc24f 100644 --- a/sourceFiles/vaults_ui.py +++ b/sourceFiles/vaults_ui.py @@ -1,10 +1,11 @@ """ The Vaults window (phase 3): the vaults of the open account -- add one, -rename one, delete one. +rename one, delete one -- and since phase 4 the way to each vault's members +and its record of changes (sharing_ui.py). 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. +random key, kept wrapped to the account's key, so each can 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. @@ -13,6 +14,8 @@ import customtkinter as ctk import account_sync +import accounts +import sharing_ui 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, @@ -30,12 +33,24 @@ def _status(app, vault_id): if vault_id == account.personal_id: return "Your own vault. It comes with the account.", MUTED record = account.vault_record(vault_id) + role = account.vault_role(vault_id) if record.get("gone_from_server"): + if role != accounts.ROLE_OWNER: + return ("No longer shared with you, or deleted. Kept on this PC " + "only, and no longer synced.", WARN) 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"): + others = int(record.get("members", 1) or 1) - 1 + if role != accounts.ROLE_OWNER: + return (f"Shared with you — you are " + f"{sharing_ui._a(sharing_ui.role_label(role).lower())}.", + MUTED) + if others > 0: + return (f"Shared with {others} other " + f"{'person' if others == 1 else 'people'}.", MUTED) 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 @@ -48,7 +63,8 @@ def vaults_dialog(app): 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.", + "key. Share one with other people under Members; Activity shows who " + "changed what.", scroll=True) listing = shell.body listing.grid_columnconfigure(0, weight=1) @@ -101,13 +117,32 @@ def _vault_row(app, parent, index, vault_id, changed): 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), + anchor="w", justify="left", wraplength=290, font=F(12), text_color=colour).pack(anchor="w") + + side = ctk.CTkFrame(row, fg_color="transparent") + side.grid(row=0, column=1, padx=(0, 14), pady=8) + upper = ctk.CTkFrame(side, fg_color="transparent") + upper.pack(anchor="e") + owner = account.vault_role(vault_id) == accounts.ROLE_OWNER + + def members(): + why_not = sharing_ui.can_share(app, vault_id) + if why_not: + return app.toast(why_not, "warn") + sharing_ui.members_dialog(app, vault_id, changed=lambda: changed()) + + if not personal: + small_button(upper, "Members", members, width=84).pack( + side="left", padx=(0, 6)) + small_button(upper, "Activity", + lambda: sharing_ui.activity_dialog(app, vault_id), + width=78).pack(side="left") if personal: return - buttons = ctk.CTkFrame(row, fg_color="transparent") - buttons.grid(row=0, column=1, padx=(0, 14)) + buttons = ctk.CTkFrame(side, fg_color="transparent") + buttons.pack(anchor="e", pady=(6, 0)) def rename(): new = _ask_name(app, f"Rename “{name}”", "The new name shows on all " @@ -126,11 +161,12 @@ def delete(): 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( + elif owner: + small_button(buttons, "Rename", rename, width=84).pack( side="left", padx=(0, 6)) - small_button(buttons, "Delete", delete, danger=True, - width=72).pack(side="left") + leaving = not owner and not record.get("gone_from_server") + small_button(buttons, "Leave" if leaving else "Delete", delete, danger=True, + width=78).pack(side="left") def _ask_name(app, title, subtitle, initial, action, apply): @@ -172,12 +208,23 @@ def _delete_vault(app, vault_id, name, changed): 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") + leaving = on_server and account.vault_role(vault_id) != accounts.ROLE_OWNER import dialogs # late: dialogs imports this module's users - if not dialogs.ask_master_password( + if leaving: + if not dialogs.ask_master_password( + app, f"Leave “{name}”", + "You stop being a member of this vault, and its copy on this " + "PC is removed. The vault stays for the others; an owner can " + "add you again. Enter your master password to confirm.", + action="Leave vault"): + return + elif 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 " + f"{where}" + (", for every member" if on_server and + account.is_shared(vault_id) else "") + + ". It cannot be undone here. Enter your master password " "to confirm.", action="Delete vault"): return if getattr(app, "_syncing", False): @@ -187,12 +234,14 @@ def _delete_vault(app, vault_id, name, changed): def done(_): if app.account is not account: return # locked meanwhile - changed(f"Vault “{name}” deleted", "warn") + changed(f"You left “{name}”" if leaving else 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(f"The vault was not {'left' if leaving else 'deleted'}: " + f"{error}", "error") - app.toast("Deleting…") + app.toast("Leaving…" if leaving else "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 925bc90..08d436b 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "1.0.0-alpha.5" +__version__ = "1.0.0-alpha.6" #: The container a 3.0 account's vaults are written in (account_crypto). VAULT_FORMAT = "PMV3"