diff --git a/README.md b/README.md index f706090..47c85b0 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ 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.2, a SecretSpace 3 test version — not for real passwords yet.** -For real use keep the [0.9.0 release](https://github.com/usermaneGermane/SecretSpace-Client-Linux/releases/tag/v0.9.0), +**`main` is 1.0.0-alpha.3, a SecretSpace 3 test version — not for real passwords yet.** +For real use keep the [0.10.0 release](https://github.com/usermaneGermane/SecretSpace-Client-Linux/releases/tag/v0.10.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, on a virtual display, not yet on a real desktop. @@ -30,19 +30,21 @@ 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.9.0 = Windows 2.11.0, and this test version = -Windows 3.0.0-alpha.2. +different from Windows*): 0.10.0 = Windows 2.12.0, and this test version = +Windows 3.0.0-alpha.3. 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.2 is the same as [SecretSpace for Windows -3.0.0-alpha.2](https://github.com/usermaneGermane/SecretSpace-Client-Windows#secretspace-3--a-test-version-not-for-real-passwords-yet): +1.0.0-alpha.3 is the same as [SecretSpace for Windows +3.0.0-alpha.3](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.9.0 — your vaults would not show up (they are -not changed). No sync yet. On Linux: +so do not install it over 0.10.0 — your vaults would not show up (they are +not changed). Since alpha.3 it syncs through a SecretSpace Server 2.0 +(*Settings → Sync → Set up…*) and signs in on another device with *Sign in +from a server*. On Linux: - **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 @@ -52,7 +54,7 @@ not changed). No sync yet. On Linux: - The account folders are `700` and their files `600`, like the vault folder; a saved Emergency Kit is `600` too. - *Settings → Backup → Open folder* opens the folder with `xdg-open`. -- The package spells the version `1.0.0~alpha.2`, which apt sorts after +- The package spells the version `1.0.0~alpha.3`, which apt sorts after 0.9.0 and before 1.0.0. | File | What it is | @@ -179,8 +181,10 @@ Where that README names a Windows version, read the Linux one beside it: | 2.9.0 | 0.7.0 | the dialogs redesigned to match | | 2.10.0 | 0.8.0 | 2FA codes, behind the master password | | 2.11.0 | 0.9.0 | sync on unlock, a setting that is off by default | +| 2.12.0 | 0.10.0 | renamed to SecretSpace (package `secretspace`, replaces `passman`) | | 3.0.0-alpha.1 | 1.0.0-alpha.1 | PassMan 3 test: accounts, Emergency Kit, recovery phrase, 2.x import | | 3.0.0-alpha.2 | 1.0.0-alpha.2 | renamed to SecretSpace (package `secretspace`, replaces `passman`) | +| 3.0.0-alpha.3 | 1.0.0-alpha.3 | accounts sync through SecretSpace Server 2.0; sign in from a server | (0.2.0 added the `.deb` package, which has no Windows counterpart.) diff --git a/sourceFiles/account_sync.py b/sourceFiles/account_sync.py new file mode 100644 index 0000000..a890928 --- /dev/null +++ b/sourceFiles/account_sync.py @@ -0,0 +1,350 @@ +""" +SecretSpace 3 accounts on a sync server (API v2) -- phase 2. + +What 2.x does with one shared token and a vault per username, an account +does with its own login: the server keeps the account (its sealed keyset, +recovery copy and public key) and its personal vault, and lets in only that +account. Nothing secret leaves this device: the server gets the Login Key +(derived from master password + Secret Key, and useless for opening +anything) and blobs it cannot decrypt. + +Three things live here: + +- :class:`AccountServer`, the HTTP calls, on sync_client's transport; +- :class:`AccountSync`, one sync of the personal vault in two halves, with + the same three-way merge, conflict choices and base rules as 2.x + (sync.merge_vaults, sync_client.base_matches); +- the account lifecycle around them: :func:`connect` (an account made here + goes onto a server), :func:`sign_in` (a new device, or one catching up + with a password changed elsewhere) and :func:`change_password`. + +Like sync_client, nothing here touches a widget; account_sync_ui.py runs +these on a worker thread. +""" + +import base64 +import urllib.parse + +import account_crypto +import accounts +import sync_client +import vault +from account_crypto import CryptoError +from sync import merge_vaults, resolve, merged_vault +from sync_client import SyncClient, SyncError, Conflict409 + +#: The server API this module speaks. A server lists the ones it has in +#: /health; a 1.x server answers without "api" and has only the 2.x one. +API_VERSION = 2 + + +class LoginFailed(SyncError): + """The server did not take this username + password + Secret Key.""" + + +class AccountServer(SyncClient): + """ + The /v2/ calls. ``token`` (the inherited Authorization header) is the + session once :meth:`login` has run, and the server's token only for the + one call that needs it, :meth:`sign_up`. + """ + + def __init__(self, url: str, ca_file: str = "", + timeout: int = sync_client.DEFAULT_TIMEOUT): + super().__init__(url, "", ca_file, timeout) + + @staticmethod + def _http_error(e) -> SyncError: + try: + import json + message = json.loads(e.read().decode("utf-8")).get("error", "") + except Exception: + message = "" + if e.code == 401: + return LoginFailed(message or "The server did not accept the login.") + if e.code in (409, 428): + return Conflict409(message or "The vault changed on the server.") + return SyncError(message or f"Server error {e.code}.") + + # -- calls ------------------------------------------------------------- + def check_api(self) -> None: + health = self.health() + if API_VERSION not in (health.get("api") or [1]): + raise SyncError("This server cannot hold SecretSpace 3 accounts yet: " + "it needs SecretSpace Server 2.0 or newer. (2.x " + "vaults sync with it as before.)") + + def prelogin(self, username: str) -> dict: + return self._request("GET", "/v2/prelogin/" + urllib.parse.quote( + username, safe="")) + + def sign_up(self, server_token: str, body: dict) -> dict: + session, self.token = self.token, server_token + try: + return self._request("POST", "/v2/accounts", body=body) + finally: + self.token = session + + def login(self, username: str, login_key: bytes) -> None: + reply = self._request("POST", "/v2/login", body={ + "username": username, + "login_key": base64.b64encode(login_key).decode("ascii")}) + self.token = reply["session"] + + def logout(self) -> None: + if self.token: + try: + self._request("POST", "/v2/logout", body={}) + except SyncError: + pass + self.token = "" + + def me(self) -> dict: + return self._request("GET", "/v2/me") + + def put_keyset(self, login_key: bytes, sealed_keyset: str) -> None: + self._request("PUT", "/v2/me/keyset", body={ + "login_key": base64.b64encode(login_key).decode("ascii"), + "keyset": sealed_keyset}) + + def get_vault(self, vault_id: str) -> dict: + """``blob`` (text, or None when nothing is stored yet) and + ``revision``.""" + reply = self._request("GET", f"/v2/vaults/{vault_id}") + return {"blob": reply.get("blob"), "revision": int(reply.get("revision", 0))} + + def put_vault(self, vault_id: str, blob: str, revision: int) -> int: + """Store a new revision unless the server has moved past + ``revision`` (Conflict409); returns the new revision.""" + reply = self._request("PUT", f"/v2/vaults/{vault_id}", body={"blob": blob}, + headers={"If-Match": str(revision)}) + return int(reply.get("revision", revision + 1)) + + +def server_for(account: accounts.Account) -> AccountServer: + found = account.server + if not found: + raise SyncError("This account is not on a sync server yet. Set it up in " + "Settings → Sync.") + return AccountServer(found["url"], found.get("ca_file", "")) + + +def logged_in(account: accounts.Account, server: AccountServer = None) -> AccountServer: + """A server session for an open account.""" + server = server or server_for(account) + if account.login_key is None: + raise SyncError("Lock and unlock the account once, then sync.") + try: + server.login(account.username, account.login_key) + except LoginFailed: + raise LoginFailed( + "The server did not accept this account's login. If the master " + "password was changed on another device, use “Sign in from a " + "server” on the login screen with the new password.") from None + return server + + +# --------------------------------------------------------------------------- +# An account made here goes onto a server +# --------------------------------------------------------------------------- +def connect(account: accounts.Account, url: str, ca_file: str, + server_token: str) -> str: + """ + Put ``account`` on the server at ``url`` and remember it. + + If the server already has this account (set up from another device) + the login simply works. Otherwise the account is created there with the + server's token as the sign-up code. Returns "created" or "joined". + Nothing is remembered unless it worked. + """ + server = AccountServer(url, ca_file) + server.check_api() + outcome = "joined" + try: + server.login(account.username, account.login_key) + except LoginFailed: + if not server_token: + raise LoginFailed("The server has no such account yet. Enter the " + "server's token to create it there.") from None + files = account.sealed_files() + try: + server.sign_up(server_token, { + "username": account.username, + "login_key": base64.b64encode(account.login_key).decode("ascii"), + "keyset": files["keyset"], "recovery": files["recovery"], + "public_key": account.info["public_key"], + "personal_vault": account.vault_id.hex()}) + except Conflict409: + raise SyncError( + f"The server already has an account called “{account.username}” " + "with another master password or Secret Key — or it is " + "someone else's. Nothing was changed.") from None + except LoginFailed: + raise LoginFailed("The server did not accept that token.") from None + server.login(account.username, account.login_key) + outcome = "created" + if server.me()["personal_vault"] != account.vault_id.hex(): + raise SyncError(f"The server's account “{account.username}” has a " + "different personal vault: it is another account with " + "the same name. Nothing was changed.") + server.logout() + account.set_server(url, ca_file) + return outcome + + +# --------------------------------------------------------------------------- +# One sync of the personal vault +# --------------------------------------------------------------------------- +class AccountSync: + """ + sync_client.SyncSession for an account: :meth:`prepare` pulls and merges, + :meth:`commit` saves, pushes and records the base. There is no password + dance here -- the vault key does not change with the master password. + """ + + def __init__(self, account: accounts.Account, server: AccountServer = None): + self.account = account + self.server = server + self.revision = 0 + self.result = None + self.first_push = False + self.base_ignored = False + self._fingerprint = None + + def prepare(self, local_vault: dict): + self.server = logged_in(self.account, self.server) + stored = self.server.get_vault(self.account.vault_id.hex()) + self.revision = stored["revision"] + self._fingerprint = sync_client.SyncSession.fingerprint(local_vault) + blob = stored["blob"] + self.first_push = blob is None + if blob is None: + remote = {"accounts": []} + else: + try: + remote = self.account.decrypt(blob) + except (CryptoError, ValueError) as e: + raise SyncError(f"The vault on the server cannot be read: {e}") from e + # The 2.x rules for the base (sync_client.SyncSession.prepare): none + # when the server is empty, and none if it was agreed elsewhere. + self.base_ignored = False + base = None + if blob is not None: + base = self.account.load_base() + if base is not None and not sync_client.base_matches( + base, self.server.url, self.revision): + base, self.base_ignored = None, True + self.result = merge_vaults(base, local_vault, remote) + 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 + + def commit(self, local_vault: dict, choices=None) -> dict: + if self.result is None: + raise SyncError("Nothing prepared to commit.") + if sync_client.SyncSession.fingerprint(local_vault) != self._fingerprint: + raise SyncError("The vault changed while the sync was open. " + "Press Sync again to merge the newer version.") + merged = vault.validate_vault( + merged_vault(local_vault, resolve(self.result, choices))) + # Saved here first, then pushed: if the network fails in between, + # this device holds the merge and the next sync pushes it. + self.account.save_vault(merged) + if self.result.needs_push or self.first_push: + self.revision = self.server.put_vault( + self.account.vault_id.hex(), self.account.encrypt(merged), + self.revision) + self.account.save_base(merged, sync_client.base_origin_for( + self.server.url, self.revision)) + return merged + + def close(self) -> None: + if self.server is not None: + self.server.logout() + + +# --------------------------------------------------------------------------- +# Signing in from a server +# --------------------------------------------------------------------------- +def sign_in(url: str, ca_file: str, username: str, password: str, + secret_key_text: str = None): + """ + Bring an account from the server onto this device -- a new device, or + this one after the master password was changed on another. + + ``secret_key_text`` comes from the Emergency Kit; it may be left out when + this device already has the account and keeps its Secret Key. Returns + (account, vault data or None when the server has no vault yet). A + device that already had the account keeps its own vault files; the + caller syncs to merge them. + """ + server = AccountServer(url, ca_file) + server.check_api() + pre = server.prelogin(username) + username = pre.get("username") or username # the server's spelling + + if secret_key_text: + secret = account_crypto.parse_secret_key(secret_key_text) + elif accounts.account_exists(username): + secret = accounts.stored_secret(username, password) # or SecretKeyNeeded + else: + raise accounts.SecretKeyNeeded(username) + + kdf = pre["kdf"] + try: + salt = base64.b64decode(kdf["salt"]) + auk, login = account_crypto.derive_account_keys( + password, secret, username, salt, int(kdf["passes"]), + int(kdf["memory_kib"]), int(kdf["lanes"])) + except (KeyError, TypeError, ValueError) as e: + if isinstance(e, CryptoError): + raise + raise SyncError("The server sent sign-in details this version cannot " + "use.") from e + try: + server.login(username, login) + except LoginFailed: + raise LoginFailed("Wrong username, master password or Secret Key.") from None + me = server.me() + try: + keyset = account_crypto.open_keyset(auk, me["keyset"]) + except CryptoError: + raise SyncError("The server's copy of the account does not open with " + "these details. Nothing was changed.") from None + + had_it = accounts.account_exists(username) + account = accounts.adopt_from_server( + username, me, keyset, secret, login, password, + {"url": url.rstrip("/"), "ca_file": ca_file or ""}) + data = None + if not had_it or not account.has_vault_file: + stored = server.get_vault(me["personal_vault"]) + data = account.decrypt(stored["blob"]) if stored["blob"] else {"accounts": []} + account.save_vault(data) + if stored["blob"]: + account.save_base(data, sync_client.base_origin_for( + server.url, stored["revision"])) + server.logout() + return account, data + + +# --------------------------------------------------------------------------- +# A new master password, on the server and here together +# --------------------------------------------------------------------------- +def change_password(account: accounts.Account, new_password: str) -> None: + """ + For an account on a server, the server learns the new password first: + if it cannot be reached nothing changes anywhere, so this device and the + server never disagree about which password opens the account. + """ + if not account.server: + return account.change_password(new_password) + server = logged_in(account) + try: + account.change_password( + new_password, before_write=lambda sealed, login: + server.put_keyset(login, sealed)) + finally: + server.logout() diff --git a/sourceFiles/account_sync_ui.py b/sourceFiles/account_sync_ui.py new file mode 100644 index 0000000..18d096b --- /dev/null +++ b/sourceFiles/account_sync_ui.py @@ -0,0 +1,246 @@ +""" +The account sync screens (phase 2): put an account on a server, sync it, +sign in from a server on another device. + +The same rules as sync_ui.py, whose helpers this reuses: the network and the +crypto (Argon2id takes a moment) run on a worker thread, widgets are touched +only back on the main thread, and a sync whose account was locked meanwhile +does nothing when it returns. +""" + +import threading + +import customtkinter as ctk + +import account_sync +import accounts +import sync_client +from sync_ui import _in_background, _still_open, _finish, conflict_dialog +from theme import F, MUTED, GREEN, DANGER +from widgets import ghost_button, accent_button, field, note, dialog_shell + + +# ========================================================================== +# The Sync button +# ========================================================================== +def run_sync(app): + if app.account is None or app.vault is None: + return + if not app.account.server: + app.toast("This account is not on a server yet — Settings → Sync", "warn") + return server_dialog(app) + if getattr(app, "_syncing", False): + return app.toast("A sync is already running") + app._syncing = True + app.toast("Syncing…") + attempt = {"retry_allowed": True, "session": getattr(app, "_session", 0)} + _sync_step(app, attempt) + + +def _sync_step(app, attempt): + account = app.account + + def work(): + session = account_sync.AccountSync(account) + try: + result = session.prepare(app.vault) + except BaseException: + session.close() + raise + return session, result + + def done(payload): + session, result = payload + if not _still_open(app, attempt): + return _close_in_background(session) + if not session.has_work(): + _close_in_background(session) + return _finish(app, "Already up to date") + choices = None + if result.conflicts: + choices = conflict_dialog(app, result) + if choices is None: + _close_in_background(session) + return _finish(app, "Sync cancelled — nothing changed", "warn") + _commit(app, session, result, choices, attempt) + + _in_background(app, work, done, on_error=lambda e: _failed(app, e, attempt)) + + +def _commit(app, session, result, choices, attempt): + def work(): + try: + return session.commit(app.vault, choices) + finally: + session.close() + + def done(merged): + if not _still_open(app, attempt): + return + app.vault = merged + summary = f"Synced — {result.summary()}" + if session.first_push: + summary = "Synced — the server now has this account's vault" + elif session.base_ignored: + summary += " (first sync with this server)" + app.refresh_account_list() + _finish(app, summary) + + _in_background(app, work, done, on_error=lambda e: _failed(app, e, attempt)) + + +def _close_in_background(session): + """Log out without the window waiting: a logout is a network call too, + and a slow server must never freeze the window (Lab flow, 2026-11-10: + on the main thread it stalled the UI for 20 s).""" + threading.Thread(target=session.close, daemon=True).start() + + +def _failed(app, error, attempt): + if not _still_open(app, attempt): + return + if isinstance(error, sync_client.Conflict409) and attempt["retry_allowed"]: + app.toast("The server moved on — merging again") + return _sync_step(app, dict(attempt, retry_allowed=False)) + _finish(app, str(error), "error") + + +# ========================================================================== +# Settings -> Sync: put this account on a server +# ========================================================================== +def server_dialog(app, on_done=None): + """ + Connect the open account to a server. If the server already has it (set + up from another device) logging in is enough; otherwise it is created + there, with the server's token as the sign-up code. + """ + account = app.account + top = app._modal("Sync with a server", 560, 620) + shell = dialog_shell( + top, "Sync with a server", + "The server keeps this account's vault encrypted — it never gets your " + "master password, your Secret Key or anything it could open your " + "vault with. It needs SecretSpace Server 2.0 or newer.") + wrap = shell.body + + url_entry = field(wrap, "Server address", placeholder="http://192.168.1.50:8787") + token_entry = field(wrap, "Server token (only to create the account there)", + show="•") + ca_entry = field(wrap, "Certificate file (only for https, optional)", + placeholder="/home/you/secretspace/server.crt") + if account.server: + url_entry.insert(0, account.server["url"]) + ca_entry.insert(0, account.server.get("ca_file", "")) + + note(wrap, "The token is the one the server's setup shows (the same one 2.x " + "vaults use). It lets you make an account there; it does not open " + "anyone's vault.", shell.text_width, size=11, pady=(10, 0)) + status = ctk.CTkLabel(wrap, text="", font=F(12), text_color=MUTED, + wraplength=shell.text_width, justify="left", anchor="w") + status.pack(anchor="w", fill="x", pady=(10, 0)) + + def connect(): + url = url_entry.get().strip() + if not url.startswith(("http://", "https://")): + return status.configure(text="Enter the server address, starting " + "with http:// or https://", + text_color=DANGER) + token, ca_file = token_entry.get().strip(), ca_entry.get().strip() + status.configure(text="Contacting the server…", text_color=MUTED) + + def done(outcome): + top.destroy() + if on_done: + on_done() + app.toast("Account created on the server — syncing…" + if outcome == "created" else + "Connected to the server — syncing…") + run_sync(app) + + _in_background(app, lambda: account_sync.connect(account, url, ca_file, token), + done, on_error=lambda e: status.configure( + text=str(e), text_color=DANGER)) + + ghost_button(shell.buttons, "Cancel", top.destroy, width=110).pack(side="left") + accent_button(shell.buttons, "Connect & sync", connect, + width=160).pack(side="right") + top.bind("", lambda e: top.destroy()) + app._fit_modal(top) + + +def forget_server(app): + """Stop syncing this account from this device. The server keeps its copy.""" + app.account.set_server(None) + app.toast("This PC no longer syncs the account. The server's copy is kept.") + + +# ========================================================================== +# The login screen: sign in from a server +# ========================================================================== +def sign_in_dialog(app): + """ + Bring an account onto this device from its server -- a new PC, or this + one after the master password was changed on another. + """ + top = app._modal("Sign in from a server", 540, 720) + shell = dialog_shell( + top, "Sign in from a server", + "For an account you already use on another device. You need the " + "server address, your username and master password, and — on a new " + "device — the Secret Key from your Emergency Kit.") + wrap = shell.body + + url_entry = field(wrap, "Server address", placeholder="http://192.168.1.50:8787") + ca_entry = field(wrap, "Certificate file (only for https, optional)", + placeholder="/home/you/secretspace/server.crt") + user_entry = field(wrap, "Username") + pw_entry = field(wrap, "Master password", show="•") + key_entry = field(wrap, "Secret Key (from the Emergency Kit)", + placeholder="A3-XXXXXX-XXXXX-…") + note(wrap, "Already have this account on this PC? Leave the Secret Key " + "empty; this PC keeps it.", shell.text_width, size=11, pady=(8, 0)) + status = ctk.CTkLabel(wrap, text="", font=F(12), text_color=DANGER, + wraplength=shell.text_width, justify="left", anchor="w") + status.pack(anchor="w", fill="x", pady=(10, 0)) + + def sign_in(): + url = url_entry.get().strip() + username = user_entry.get().strip() + password = pw_entry.get() + if not url.startswith(("http://", "https://")): + return status.configure(text="Enter the server address, starting " + "with http:// or https://", + text_color=DANGER) + if not (username and password): + return status.configure(text="Enter your username and master password.", + text_color=DANGER) + status.configure(text="Signing in — this takes a moment…", text_color=MUTED) + + def work(): + return account_sync.sign_in(url, ca_entry.get().strip(), username, + password, key_entry.get().strip() or None) + + def done(payload): + account, data = payload + top.destroy() + loaded = data if data is not None else account.load_vault() + app.open_account(account, loaded, password) + app.toast(f"Signed in as “{account.username}”") + if data is None: # this PC had it: merge its own changes + run_sync(app) + + def failed(e): + if isinstance(e, accounts.SecretKeyNeeded): + text = ("Enter the Secret Key from your Emergency Kit — this PC " + "does not have it for this password.") + else: + text = str(e) + status.configure(text=text, text_color=DANGER) + + _in_background(app, work, done, on_error=failed) + + ghost_button(shell.buttons, "Cancel", top.destroy, width=110).pack(side="left") + accent_button(shell.buttons, "Sign in", sign_in, width=140).pack(side="right") + pw_entry.bind("", lambda e: sign_in()) + top.bind("", lambda e: top.destroy()) + app._fit_modal(top) diff --git a/sourceFiles/accounts.py b/sourceFiles/accounts.py index 52bd0bc..935b7b1 100644 --- a/sourceFiles/accounts.py +++ b/sourceFiles/accounts.py @@ -1,5 +1,6 @@ """ -SecretSpace 3.0 accounts on this device -- phase 1: local only, no server. +SecretSpace 3.0 accounts on this device. Since phase 2 an account may also +live on a sync server (account_sync.py); this module stays files only. An account replaces the 2.x "one vault file per user". It is a folder:: @@ -9,7 +10,8 @@ it (secret_store), fingerprints seen keyset.pmk the keyset, under master password + Secret Key recovery.pmk the same keyset, under the 12-word phrase - vaults/.pmv3 the personal vault (+ .bak, the previous save) + vaults/.pmv3 the personal vault (+ .bak, the previous save, + + .base, the state last agreed with the server) ```` is the folder that holds the 2.x ``vault`` folder (beside the app for a lab copy, %APPDATA%\\SecretSpace when installed). 2.x vault files are only @@ -119,11 +121,16 @@ def _write_info(username: str, info: dict) -> None: class Account: """An unlocked account: its keys in memory, its files on disk.""" - def __init__(self, username: str, info: dict, keyset: dict, secret: bytes): + def __init__(self, username: str, info: dict, keyset: dict, secret: bytes, + login_key: bytes = None): self.username = username self.info = info self.keyset = keyset self._secret = secret + # What logs this account in to its server (account_sync). Derived at + # unlock from the password and the Secret Key, so it is kept only in + # memory, like the keys it travels with. + self.login_key = login_key self.vault_id = bytes.fromhex(info["personal_vault"]) self._vault_key = account_crypto._unb64(keyset["personal_vault_key"], "vault key") @@ -162,10 +169,59 @@ def load_vault(self) -> dict: raise first_error or FileNotFoundError("The personal vault is missing.") def save_vault(self, data: dict) -> None: - blob = account_crypto.encrypt_vault(self._vault_key, self.vault_id, 0, - vault.validate_vault(data)) vault._rotate_backup(self.vault_path) - _write_text(self.vault_path, blob) + _write_text(self.vault_path, self.encrypt(data)) + + def encrypt(self, data: dict) -> str: + """``data`` as a PMV3 blob under the personal vault's key.""" + return account_crypto.encrypt_vault(self._vault_key, self.vault_id, 0, + vault.validate_vault(data)) + + def decrypt(self, blob: str) -> dict: + """A PMV3 blob of the personal vault (it must name this vault).""" + return vault.validate_vault(account_crypto.decrypt_vault( + self._vault_key, blob, self.vault_id)) + + @property + def has_vault_file(self) -> bool: + return os.path.exists(self.vault_path) + + # -- the server ----------------------------------------------------------- + @property + def server(self) -> dict: + """{"url", "ca_file"} of the server this account syncs with, or None.""" + found = self.info.get("server") + return found if isinstance(found, dict) and found.get("url") else None + + def set_server(self, url, ca_file: str = "") -> None: + """Remember the server (None forgets it). account.json only.""" + info = dict(self.info) + info["server"] = ({"url": url.rstrip("/"), "ca_file": ca_file or ""} + if url else None) + _write_info(self.username, info) + self.info = info + + @property + def base_path(self) -> str: + return self.vault_path + vault.BASE_SUFFIX + + def load_base(self): + """ + The vault as last agreed with the server, or None -- as 2.x's + vault.load_base: a missing or unreadable base only makes the next + merge noisier, never destructive. + """ + try: + return self.decrypt(_read_text(self.base_path)) + except (OSError, ValueError): + return None + + def save_base(self, data: dict, origin: dict) -> None: + """Written at the very end of a successful sync, with the server and + revision it was agreed at (sync_client.base_matches).""" + data = dict(data) + data[vault.BASE_ORIGIN_KEY] = origin + _write_text(self.base_path, self.encrypt(data)) def check_password(self, password: str) -> bool: """Is this the master password? (Re-derives: 2FA reveal, delete.)""" @@ -177,16 +233,23 @@ def check_password(self, password: str) -> bool: except (CryptoError, OSError): return False - def change_password(self, new_password: str) -> None: + def change_password(self, new_password: str, before_write=None) -> None: """ The same keyset under a new password, so the vault file is not touched. The old keyset.pmk is replaced, not kept: a .bak of it would still open with the old password. + + ``before_write(sealed_keyset, login_key)`` runs before anything here + is written; account_sync passes one that tells the server. If it + raises, nothing changes on this device either, so the two can never + disagree about the password. """ - sealed, _login = account_crypto.reseal_with_new_password( + sealed, login = account_crypto.reseal_with_new_password( self.username, self.keyset, new_password, self._secret, account_crypto.ARGON2_PASSES, account_crypto.ARGON2_MEMORY_KIB, account_crypto.ARGON2_LANES) + if before_write is not None: + before_write(sealed, login) info = dict(self.info) # Under the password scheme the stored Secret Key is sealed with the # password too; DPAPI does not care, but re-protecting is harmless. @@ -195,6 +258,13 @@ def change_password(self, new_password: str) -> None: _write_text(_path(self.username, KEYSET_FILE), sealed) _write_info(self.username, info) self.info = info + self.login_key = login + + def sealed_files(self) -> dict: + """The sealed keyset and recovery copy, as stored -- what a server + keeps so another device can sign in.""" + return {"keyset": _read_text(_path(self.username, KEYSET_FILE)), + "recovery": _read_text(_path(self.username, RECOVERY_FILE))} # --------------------------------------------------------------------------- @@ -246,7 +316,8 @@ def save(self) -> Account: _write_text(os.path.join(folder, KEYSET_FILE), made["sealed_keyset"]) _write_text(os.path.join(folder, RECOVERY_FILE), made["sealed_recovery"]) - account = Account(username, info, made["keyset"], made["secret_key"]) + account = Account(username, info, made["keyset"], made["secret_key"], + made["login_key"]) account.save_vault({"accounts": []}) _write_info(username, info) # last: it makes it an account except BaseException: @@ -291,13 +362,69 @@ def unlock(username: str, password: str, secret_key_text: str = None) -> Account password) except secret_store.Unavailable: raise SecretKeyNeeded(username) from None - keyset, _login = account_crypto.unlock_account(username, password, secret, - sealed) + keyset, login = account_crypto.unlock_account(username, password, secret, + sealed) if typed is not None: info = dict(info) info["secret_key"] = secret_store.protect(typed, username, password) _write_info(username, info) - return Account(username, info, keyset, secret) + return Account(username, info, keyset, secret, login) + + +# --------------------------------------------------------------------------- +# An account that came from a server +# --------------------------------------------------------------------------- +def stored_secret(username: str, password: str) -> bytes: + """ + The Secret Key this device keeps for an account, or SecretKeyNeeded -- + also when it is sealed under a password that is no longer the account's + (changed on another device), since then only the Emergency Kit helps. + """ + info = _read_info(username) + try: + return secret_store.unprotect(info.get("secret_key"), username, password) + except (secret_store.Unavailable, CryptoError): + raise SecretKeyNeeded(username) from None + + +def adopt_from_server(username: str, me: dict, keyset: dict, secret: bytes, + login_key: bytes, password: str, server: dict) -> Account: + """ + Write what a server holds for an account onto this device: a new device + signing in, or this device catching up with a password changed on + another. ``keyset`` is ``me["keyset"]`` already opened by the caller, so + a wrong password or Secret Key never gets this far. + + An account already here keeps its vault files (the next sync merges + them); only its keys and the Secret Key's protection are replaced. One + with the same name but another personal vault is someone else's, and is + left alone. + """ + vault_id = me["personal_vault"] + folder = account_dir(username) + if account_exists(username): + info = _read_info(username) + if info.get("personal_vault") != vault_id: + raise FileExistsError( + f"This PC already has a different account called " + f"\u201c{username}\u201d. Delete or rename it first.") + info = dict(info) + elif os.path.exists(folder): + raise FileExistsError( + f"A folder for \u201c{username}\u201d is already there ({folder}) " + "but holds no account. Move it away to use this name.") + else: + os.makedirs(accounts_dir(), mode=0o700, exist_ok=True) + os.mkdir(folder, 0o700) + info = {"format": ACCOUNT_FORMAT, "username": username, + "mode": MODE_PERSONAL, "created_at": vault.utc_now(), + "personal_vault": vault_id, "fingerprints_seen": {}} + info.update({"public_key": me["public_key"], "server": server, + "secret_key": secret_store.protect(secret, username, password)}) + _write_text(os.path.join(folder, KEYSET_FILE), me["keyset"]) + _write_text(os.path.join(folder, RECOVERY_FILE), me["recovery"]) + _write_info(username, info) # last: it makes it an account + return Account(username, info, keyset, secret, login_key) def check_recovery_phrase(username: str, phrase: str) -> bool: diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 179d91e..0a871d9 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -18,19 +18,23 @@ from tkinter import filedialog import account_crypto +import account_sync +import account_sync_ui import accounts import appconfig import importers +import sync_client import totp import vault from theme import (F, TEXT, MUTED, GREEN, GREEN_HOVER, INPUT_BG, BORDER, - CARD, CARD_HOVER, ON_ACCENT, MONO, tile_colors) + CARD, CARD_HOVER, ON_ACCENT, MONO, DANGER, tile_colors) from vault import (add_account, validate_username, export_csv, export_json, update_entry, find_duplicate) from widgets import (dialog_shell, section, setting_row, option_menu, note, error_label, check, field_label, entry, danger_button) from crypto_utils import password_matches from password_generator import generate_password +from sync_ui import _in_background from version import long_version_string @@ -700,15 +704,27 @@ def set_autolock(choice): lock_menu.set(label_for(appconfig.autolock_minutes())) # ---- sync ---- - # SecretSpace 3 accounts sync from phase 2 (server accounts) on. syn = section(wrap, "Sync") - # The control slot gets a label: an empty CTkFrame is 200 px tall. - ctk.CTkLabel(setting_row(syn, "Not in this test version", - "Accounts sync from the next 3.0 test version " - "on. Your 2.x vaults keep syncing in SecretSpace " - "2.x.", hint, first=True), - text="Next version", font=F(12, "bold"), - text_color=MUTED).pack() + server = app.account.server + if server: + app._ghost_button( + setting_row(syn, "Synced with", server["url"], hint, first=True), + "Sync now", lambda: (top.destroy(), app.sync_now()), width=150).pack() + app._ghost_button( + setting_row(syn, "Stop syncing on this PC", + "The server keeps its copy; connect again any time.", + hint), + "Disconnect", lambda: (account_sync_ui.forget_server(app), + top.destroy()), width=150).pack() + else: + app._ghost_button( + setting_row(syn, "Not on a server yet", + "Keep this account in step across your devices " + "through your own SecretSpace Server (2.0 or newer).", + hint, first=True), + "Set up…", lambda: (top.destroy(), + account_sync_ui.server_dialog(app)), + width=150).pack() # ---- archive ---- arc = section(wrap, "Archive") @@ -848,16 +864,26 @@ def change(): err.configure(text="That is already your master password."); return if len(new) < 8: err.configure(text="Use at least 8 characters."); return - try: - # The keyset is resealed; the vault file is not touched. - app.account.change_password(new) - except Exception: - err.configure(text="Could not save. Password unchanged.") - return - app.master_password = new - top.destroy() - parent_top.destroy() - app.toast("Master password changed") + # The keyset is resealed; the vault file is not touched. An account + # on a server tells the server first (account_sync.change_password), + # so this runs off the main thread. + err.configure(text="Changing…", text_color=MUTED) + + def done(_): + app.master_password = new + top.destroy() + parent_top.destroy() + app.toast("Master password changed") + + def failed(e): + text = (str(e) if isinstance(e, sync_client.SyncError) + else "Could not save. Password unchanged.") + if isinstance(e, sync_client.SyncError): + text += " The password was not changed anywhere." + err.configure(text=text, text_color=DANGER) + + _in_background(app, lambda: account_sync.change_password(app.account, new), + done, failed) app._ghost_button(shell.buttons, "Cancel", top.destroy, width=110).pack(side="left") diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index 8190a9d..4227b1d 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -25,6 +25,7 @@ import lockout import sync_client import sync_ui +import account_sync_ui import totp from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG, BORDER, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, @@ -407,13 +408,17 @@ def toggle_pw(): links = ctk.CTkFrame(pad, fg_color="transparent") links.pack(fill="x") - # 3.0 phase 1 is local only; "Restore from server" returns with the - # server accounts of phase 2. 2.x vaults come in through Import once - # an account is open. + # 2.x vaults come in through Import once an account is open; an + # account used on another device comes in through its server. ctk.CTkButton(links, text="Create an account", command=self.create_user_dialog, width=10, height=32, fg_color="transparent", hover_color=CARD_HOVER, text_color=GREEN, font=F(13, "bold")).pack(side="left") + ctk.CTkButton(links, text="Sign in from a server", + command=lambda: account_sync_ui.sign_in_dialog(self), + width=10, height=32, fg_color="transparent", + hover_color=CARD_HOVER, text_color=GREEN, + font=F(13, "bold")).pack(side="right") ctk.CTkLabel(pad, text=version_string(), font=F(11), text_color=MUTED).pack(pady=(14, 0)) @@ -694,7 +699,9 @@ def show_main_window(self): controls = ctk.CTkFrame(bar, fg_color="transparent") controls.grid(row=0, column=3, padx=(0, 24)) - # No Sync button in 3.0 phase 1: accounts sync from phase 2 on. + syn = self._ghost_button(controls, "Sync", self.sync_now, width=70) + syn.pack(side="left", padx=(0, 8)) + Tooltip(syn, "Sync this account with its server (Settings → Sync)") imp = self._ghost_button(controls, "Import", self.import_dialog, width=80) imp.pack(side="left", padx=(0, 8)) Tooltip(imp, "Import a PassMan 2.x vault, or a CSV or JSON file") @@ -1254,6 +1261,8 @@ def restore_from_server_dialog(self): sync_ui.restore_from_server_dialog(self) def sync_now(self, **options): + if self.account is not None: + return account_sync_ui.run_sync(self) sync_ui.run_sync(self, **options) # ================================================================== diff --git a/sourceFiles/tests/sync_server/passman_server.py b/sourceFiles/tests/sync_server/passman_server.py index 47e7d98..e02e469 100644 --- a/sourceFiles/tests/sync_server/passman_server.py +++ b/sourceFiles/tests/sync_server/passman_server.py @@ -3,16 +3,20 @@ SecretSpace sync server -- a store for encrypted vault blobs. What it is NOT: it cannot read your passwords. The client encrypts the vault -with your master password before it ever reaches the network, so this process -holds AES-256-GCM ciphertext it has no key for. It never sees the master -password, and there is deliberately nothing here that could -- no user -accounts, no password reset, no "forgot my vault". +before it ever reaches the network, so this process holds AES-256-GCM +ciphertext it has no key for. It never sees a master password, a Secret Key or +a vault key, and there is deliberately nothing here that could -- no password +reset, no "forgot my vault". -What it does: stores one blob per user, stamps it with a revision number, and +What it does: stores vault blobs, stamps each with a revision number, and refuses a write that was computed against an older revision. That refusal is the whole point. Without it, two PCs that both edited offline would silently overwrite each other; with it, the second one is told to merge first. +Two APIs side by side: /vault/ with one shared token for SecretSpace +2.x clients, and /v2/ with accounts and sessions for SecretSpace 3 (see the +API v2 section below). + Standard library only, so it runs on any box with Python 3 and nothing to install. See README.md for deployment. """ @@ -28,6 +32,7 @@ import argparse import tempfile import threading +import time from datetime import datetime, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -189,6 +194,362 @@ def _archive(self, user: str, revision: int) -> None: pass +# =========================================================================== +# SecretSpace 3 accounts: API v2 +# =========================================================================== +# +# Everything above serves 2.x clients: one blob per username, one shared +# token. 3.0 clients get accounts instead. The server still never sees a +# master password, a Secret Key or a vault key: +# +# - an account is the client's Login Key (hashed here), its public key, and +# its keyset and recovery copy, both sealed on the device; +# - a vault is a blob exactly like a 2.x one, kept by the same Store with the +# same revision / If-Match / history rules, plus who may use it. +# +# It all lives in /_v2/. A 2.x username cannot start with "_", so no +# 2.x vault folder can ever collide with it. + +V2_DIR = "_v2" + +#: How long a login lasts. +SESSION_SECONDS = 12 * 3600 + +#: The Login Key is 32 bytes from HKDF (password + Secret Key), already far +#: beyond guessing, so the stored hash needs no slow KDF of its own -- scrypt +#: with modest cost only makes a stolen accounts folder useless to replay. +#: It is in the standard library, which keeps the server free of packages. +LOGIN_KEY_LEN = 32 +SCRYPT_N, SCRYPT_R, SCRYPT_P = 2 ** 14, 8, 1 + +#: Wrong logins per username before it is paused, the window they are counted +#: in, and the pause. The Login Key cannot be guessed; this is about noise. +MAX_LOGIN_FAILURES = 10 +FAILURE_WINDOW = 15 * 60 +LOCKOUT_SECONDS = 60 + +#: The client's Argon2id default, for the made-up answer to a pre-login for an +#: account that does not exist (see AccountStore.prelogin). +DEFAULT_KDF = {"memory_kib": 65536, "passes": 3, "lanes": 1} + +VAULT_ID = re.compile(r"^[0-9a-f]{32}$") +ROLES_THAT_WRITE = ("owner", "editor") + +#: The account files' layouts, from the client (account_crypto.py). +_KEYSET_MAGIC = b"PMK3" +_KEYSET_HEADER = 4 + 1 + 1 + 4 + 1 + 1 + 16 # magic ver kdf mem passes lanes salt +_VAULT_MAGIC = b"PMV3" + + +class V2Error(Exception): + """A refusal with its HTTP status and the message the user sees.""" + + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + + +def _unb64(text, what: str, length: int = None) -> bytes: + try: + raw = base64.b64decode(str(text).encode("ascii"), validate=True) + except Exception: + raise V2Error(400, f"{what} is not valid base64.") from None + if length is not None and len(raw) != length: + raise V2Error(400, f"{what} has the wrong length.") + return raw + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def keyset_kdf(sealed: str, recovery: bool = False) -> dict: + """ + The key-derivation header of a sealed keyset (what a new device needs to + turn a master password into the key that opens it). The rest is + ciphertext this server cannot and does not need to read. + """ + raw = _unb64(sealed, "The recovery copy" if recovery else "The keyset") + if len(raw) < _KEYSET_HEADER + 12 + 16 or raw[:4] != _KEYSET_MAGIC: + raise V2Error(400, "That is not a SecretSpace 3 keyset.") + kdf = raw[5] + if kdf != (0 if recovery else 1): + raise V2Error(400, "The keyset and its recovery copy are swapped.") + memory_kib = int.from_bytes(raw[6:10], "big") + return {"memory_kib": memory_kib, "passes": raw[10], "lanes": raw[11], + "salt": _b64(raw[12:28])} + + +def hash_login_key(login_key: bytes, salt: bytes = None) -> dict: + salt = salt or os.urandom(16) + digest = hashlib.scrypt(login_key, salt=salt, n=SCRYPT_N, r=SCRYPT_R, + p=SCRYPT_P, dklen=32) + return {"salt": _b64(salt), "hash": _b64(digest), "n": SCRYPT_N, + "r": SCRYPT_R, "p": SCRYPT_P} + + +def login_key_matches(login_key: bytes, stored: dict) -> bool: + try: + digest = hashlib.scrypt(login_key, salt=base64.b64decode(stored["salt"]), + n=int(stored["n"]), r=int(stored["r"]), + p=int(stored["p"]), dklen=32) + return hmac.compare_digest(digest, base64.b64decode(stored["hash"])) + except Exception: + return False + + +class AccountStore: + """Accounts, sessions and vaults of API v2, under /_v2.""" + + def __init__(self, store_root: str): + self.root = os.path.join(os.path.abspath(store_root), V2_DIR) + self.vaults = Store(os.path.join(self.root, "vaults")) + self._failures = {} # username (lower) -> [times] + self._failure_lock = threading.Lock() + + # -- files -------------------------------------------------------------- + def _account_path(self, username: str) -> str: + # One file per name regardless of case: "Alice" and "alice" are one + # account, as they are one folder on Windows. + return os.path.join(self.root, "accounts", username.lower() + ".json") + + def _members_path(self, vault_id: str) -> str: + return os.path.join(self.vaults.root, vault_id, "members.json") + + @staticmethod + def _read_json(path: str): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + return None + + @staticmethod + def _write_json(path: str, data) -> None: + write_atomic(path, json.dumps(data, indent=2, sort_keys=True).encode("utf-8")) + + def account(self, username: str): + """The stored account, or None. Matches the name exactly.""" + if not valid_username(username): + return None + record = self._read_json(self._account_path(username)) + if not record or record.get("username") != username: + return None + return record + + def _server_key(self) -> bytes: + path = os.path.join(self.root, "server.key") + try: + with open(path, "rb") as f: + key = f.read() + if len(key) == 32: + return key + except OSError: + pass + key = os.urandom(32) + write_atomic(path, key) + try: + os.chmod(path, 0o600) + except OSError: + pass + return key + + # -- pre-login ------------------------------------------------------------ + def prelogin(self, username: str) -> dict: + """ + How a device turns this account's master password into keys: the + Argon2id salt and cost from the stored keyset. For a name with no + account the answer is made up -- a salt derived from a key only this + server has, so it is the same on every ask -- and the login then simply + fails. That way the answer says nothing about which names exist. + """ + if not valid_username(username): + raise V2Error(400, "Not a valid username.") + record = self._read_json(self._account_path(username)) + if record: + return {"username": record["username"], "kdf": record["kdf"]} + salt = hmac.new(self._server_key(), b"prelogin|" + username.lower().encode(), + hashlib.sha256).digest()[:16] + return {"username": username, "kdf": {**DEFAULT_KDF, "salt": _b64(salt)}} + + # -- sign-up ------------------------------------------------------------ + def create_account(self, body: dict) -> dict: + username = body.get("username") + if not isinstance(username, str) or not valid_username(username): + raise V2Error(400, "Not a valid username.") + login_key = _unb64(body.get("login_key"), "The login key", LOGIN_KEY_LEN) + kdf = keyset_kdf(body.get("keyset")) + keyset_kdf(body.get("recovery"), recovery=True) + public_key = _unb64(body.get("public_key"), "The public 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.") + + with _lock: + if self._read_json(self._account_path(username)) is not None: + raise V2Error(409, f"There is already an account called " + f"“{username}” on this server.") + 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": "personal", "created_at": utc_now(), + "members": {username: {"role": "owner"}}}) + self._write_json(self._account_path(username), { + "username": username, + "created_at": utc_now(), + "login": hash_login_key(login_key), + "public_key": _b64(public_key), + "keyset": body["keyset"], + "recovery": body["recovery"], + "kdf": kdf, + "personal_vault": vault_id, + "disabled": False, + }) + return {"username": username, "personal_vault": vault_id} + + # -- logging in --------------------------------------------------------- + def _paused(self, username: str) -> bool: + now = time.time() + with self._failure_lock: + recent = [t for t in self._failures.get(username.lower(), []) + if now - t < FAILURE_WINDOW] + self._failures[username.lower()] = recent + return (len(recent) >= MAX_LOGIN_FAILURES + and now - recent[-1] < LOCKOUT_SECONDS) + + def _failed(self, username: str) -> None: + with self._failure_lock: + self._failures.setdefault(username.lower(), []).append(time.time()) + + def login(self, body: dict) -> dict: + username = body.get("username") + if not isinstance(username, str) or not valid_username(username): + raise V2Error(400, "Not a valid username.") + login_key = _unb64(body.get("login_key"), "The login key", LOGIN_KEY_LEN) + if self._paused(username): + raise V2Error(429, "Too many wrong attempts for this account. " + "Wait a minute, then try again.") + record = self.account(username) + # Hash even when there is no such account, so the answer takes as long + # either way and its timing does not tell which names exist. + stored = record["login"] if record else hash_login_key(b"\0" * 32) + ok = login_key_matches(login_key, stored) and record is not None + if not ok or record.get("disabled"): + self._failed(username) + raise V2Error(401, "Wrong username, master password or Secret Key.") + with self._failure_lock: + self._failures.pop(username.lower(), None) + token = base64.urlsafe_b64encode(os.urandom(32)).decode("ascii").rstrip("=") + self._write_json(self._session_path(token), { + "username": username, "expires": time.time() + SESSION_SECONDS}) + self._drop_expired_sessions() + return {"session": token, "username": username, + "expires_in": SESSION_SECONDS} + + # -- sessions ----------------------------------------------------------- + def _session_path(self, token: str) -> str: + # Stored under its hash: a copy of this folder logs nobody in. + name = hashlib.sha256(token.encode("utf-8")).hexdigest() + return os.path.join(self.root, "sessions", name + ".json") + + def session_user(self, token: str): + """The username a session token belongs to, or None.""" + if not token or len(token) > 200: + return None + try: + session = self._read_json(self._session_path(token)) + except (OSError, ValueError): + return None + if not session or session.get("expires", 0) < time.time(): + return None + record = self.account(session.get("username", "")) + if not record or record.get("disabled"): + return None + return record["username"] + + def logout(self, token: str) -> None: + try: + os.remove(self._session_path(token)) + except OSError: + pass + + def _drop_expired_sessions(self, keep_user: str = None, keep_token: str = None, + user_to_end: str = None) -> None: + """Remove expired sessions; with ``user_to_end``, also every session + of that user except ``keep_token``'s.""" + folder = os.path.join(self.root, "sessions") + keep = os.path.basename(self._session_path(keep_token)) if keep_token else None + now = time.time() + try: + names = os.listdir(folder) + except OSError: + return + for name in names: + path = os.path.join(folder, name) + try: + session = self._read_json(path) or {} + except (OSError, ValueError): + session = {} + ending = (user_to_end is not None and name != keep + and session.get("username") == user_to_end) + if ending or session.get("expires", 0) < now: + try: + os.remove(path) + except OSError: + pass + + # -- the account itself ------------------------------------------------- + def me(self, username: str) -> dict: + record = self.account(username) + vaults = [] + vault_id = record["personal_vault"] + members = self._read_json(self._members_path(vault_id)) or {} + meta = self.vaults.meta(vault_id) + vaults.append({"id": vault_id, "kind": members.get("kind", "personal"), + "role": members.get("members", {}).get(username, {}) + .get("role", "owner"), + "revision": meta["revision"], + "key_generation": meta.get("key_generation", 0)}) + return {"username": record["username"], "public_key": record["public_key"], + "keyset": record["keyset"], "recovery": record["recovery"], + "kdf": record["kdf"], "personal_vault": vault_id, + "vaults": vaults} + + def change_keyset(self, username: str, body: dict, keep_token: str) -> None: + """ + A new master password: the same keys under a new seal, and the Login + Key that goes with it. Every other session of the account ends -- a + password is changed when it may have leaked. + """ + login_key = _unb64(body.get("login_key"), "The login key", LOGIN_KEY_LEN) + kdf = keyset_kdf(body.get("keyset")) + with _lock: + record = self.account(username) + record.update({"login": hash_login_key(login_key), + "keyset": body["keyset"], "kdf": kdf, + "password_changed_at": utc_now()}) + self._write_json(self._account_path(username), record) + self._drop_expired_sessions(user_to_end=username, keep_token=keep_token) + + # -- vaults ------------------------------------------------------------- + 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 ""): + return None + members = self._read_json(self._members_path(vault_id)) or {} + member = members.get("members", {}).get(username) + return member.get("role") if member else None + + +def check_vault_blob(raw: bytes, vault_id: str) -> 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.""" + 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.") + + class Handler(BaseHTTPRequestHandler): server_version = "PassManSync/1.0" @@ -246,7 +607,10 @@ def _user_from_path(self): # -- routes ----------------------------------------------------------- def do_GET(self): if self.path.split("?")[0] == "/health": - return self._send(200, {"ok": True, "service": "passman-sync"}) + return self._send(200, {"ok": True, "service": "passman-sync", + "api": [1, 2]}) + if self._is_v2(): + return self._v2("GET") if not self._authorised(): return self._error(401, "Bad or missing token.") @@ -254,28 +618,64 @@ def do_GET(self): user, tail = self._user_from_path() if user is None: return self._error(404, "No such endpoint.") + return self._send_blob(self.server.store, user, tail) + + def do_PUT(self): + if self._is_v2(): + return self._v2("PUT") + if not self._authorised(): + return self._error(401, "Bad or missing token.") + + user, tail = self._user_from_path() + if user is None or tail: + return self._error(404, "No such endpoint.") + return self._store_blob(self.server.store, user) + + def do_POST(self): + if self._is_v2(): + return self._v2("POST") + # 2.x never used POST: answer as before, when there was no do_POST. + self.send_error(501, "Unsupported method ('POST')") - meta = self.server.store.meta(user) + # -- one blob, for 2.x users and v2 vaults alike ------------------------ + def _send_blob(self, store, name, tail): + meta = store.meta(name) if tail == "meta": return self._send(200, meta) - if not self.server.store.exists(user): + if not store.exists(name): # Not an error: it is how a client learns it is the first to push. return self._send(200, {**meta, "blob": None}) try: - blob = self.server.store.read(user) + blob = store.read(name) except OSError as e: return self._error(500, f"Could not read the stored vault: {e}") return self._send(200, {**meta, "blob": blob.decode("ascii")}) - def do_PUT(self): - if not self._authorised(): - return self._error(401, "Bad or missing token.") - - user, tail = self._user_from_path() - if user is None or tail: - return self._error(404, "No such endpoint.") - + def _read_body(self): + """The request's JSON body, or (None, already answered).""" + try: + length = int(self.headers.get("Content-Length", 0)) + except ValueError: + self._error(400, "Bad Content-Length.") + return None + if length <= 0: + self._error(400, "Empty body.") + return None + if length > MAX_BODY: + self._error(413, "That is far larger than a vault.") + return None + try: + payload = json.loads(self.rfile.read(length).decode("utf-8")) + except Exception: + self._error(400, "Body must be JSON.") + return None + if not isinstance(payload, dict): + self._error(400, "Body must be a JSON object.") + return None + return payload + + def _store_blob(self, store, name, check=None): try: length = int(self.headers.get("Content-Length", 0)) except ValueError: @@ -300,6 +700,11 @@ def do_PUT(self): return self._error(400, "Blob is not valid base64.") if len(raw) < MIN_BLOB_BYTES: return self._error(400, "Blob is too short to be a vault.") + if check is not None: + try: + check(raw) + except V2Error as e: + return self._error(e.status, str(e)) # Optional, and absent from clients older than password-change # support; the store then keeps what it had. @@ -317,7 +722,7 @@ def do_PUT(self): # One writer at a time: check-then-write has to be atomic, or two # simultaneous pushes could both pass the revision check. with _lock: - current = int(self.server.store.meta(user)["revision"]) + current = int(store.meta(name)["revision"]) expected = self.headers.get("If-Match", "").strip().strip('"') if not expected: @@ -336,13 +741,94 @@ def do_PUT(self): f"Pull and merge again.") try: - meta = self.server.store.write(user, blob, generation, - drop_history) + meta = store.write(name, blob, generation, drop_history) except OSError as e: return self._error(500, f"Could not store the vault: {e}") return self._send(200, meta) + # -- API v2: SecretSpace 3 accounts ------------------------------------- + def _is_v2(self) -> bool: + return self.path.split("?")[0].startswith("/v2/") + + @property + def accounts(self) -> AccountStore: + # Made on first use from the 2.x store, so a server set up in code + # (the tests, the clients' test copies) gets v2 without extra wiring. + found = getattr(self.server, "accounts", None) + if found is None: + found = AccountStore(self.server.store.root) + self.server.accounts = found + return found + + def _bearer(self) -> str: + header = self.headers.get("Authorization", "") + 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:] + try: + return self._v2_route(method, parts) + except V2Error as e: + return self._error(e.status, str(e)) + + def _v2_route(self, method, parts): + accounts = self.accounts + route = (method, parts[0] if parts else "", len(parts)) + + # -- no login needed --------------------------------------------------- + if route == ("GET", "prelogin", 2): + return self._send(200, accounts.prelogin(parts[1])) + if route == ("POST", "login", 1): + body = self._read_body() + return None if body is None else self._send(200, accounts.login(body)) + 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. + if not self._authorised(): + return self._error(401, "Bad or missing sign-up code (the " + "server's token).") + body = self._read_body() + if body is None: + return None + return self._send(201, accounts.create_account(body)) + + # -- everything else needs a session ------------------------------- + token = self._bearer() + username = accounts.session_user(token) + if username is None: + return self._error(401, "Not logged in, or the login has expired.") + + if route == ("GET", "me", 1): + return self._send(200, accounts.me(username)) + if route == ("POST", "logout", 1): + accounts.logout(token) + return self._send(200, {}) + if route == ("PUT", "me", 2) and parts[1] == "keyset": + body = self._read_body() + if body is None: + return None + accounts.change_keyset(username, body, keep_token=token) + return self._send(200, {}) + if parts and parts[0] == "vaults" and len(parts) in (2, 3): + vault_id = parts[1] + tail = parts[2] if len(parts) == 3 else "" + role = accounts.role(username, vault_id) + # Not a member and no such vault answer alike, so a guess at an + # id learns nothing. + if role is None or tail not in ("", "meta"): + return self._error(404, "No such vault.") + if method == "GET": + 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.") + return self._store_blob( + accounts.vaults, vault_id, + check=lambda raw: check_vault_blob(raw, vault_id)) + return self._error(404, "No such endpoint.") + def load_token(args) -> str: """ @@ -455,7 +941,6 @@ def bind(host, port, retry_seconds=0, sleep=None): yet -- a network that comes up after the server at boot -- is retried rather than ending the server; anything else still fails at once. """ - import time sleep = sleep or time.sleep warned = False while True: diff --git a/sourceFiles/tests/test_account_sync.py b/sourceFiles/tests/test_account_sync.py new file mode 100644 index 0000000..afc2e78 --- /dev/null +++ b/sourceFiles/tests/test_account_sync.py @@ -0,0 +1,332 @@ +""" +Phase 2 end to end: SecretSpace 3 accounts on a real copy of the sync server. + +Two "devices" are two data folders on one machine, switched with +PASSMAN_DATA_DIR. The server is the copy in sync_server/, run on a free port, +exactly as test_sync_server.py runs it for 2.x. +""" + +import os +import sys +import threading + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "sync_server")) + +import account_crypto # noqa: E402 +import account_sync # noqa: E402 +import accounts # noqa: E402 +import secret_store # noqa: E402 +import sync # noqa: E402 +import sync_client # noqa: E402 +import passman_server # noqa: E402 +from account_sync import LoginFailed # noqa: E402 +from sync_client import SyncError # noqa: E402 + +TOKEN = "test-token-that-is-long-enough" +PW = "correct horse battery staple" + + +@pytest.fixture(autouse=True) +def cheap_argon2(monkeypatch): + monkeypatch.setattr(account_crypto, "ARGON2_MEMORY_KIB", 64) + monkeypatch.setattr(account_crypto, "ARGON2_PASSES", 1) + + +@pytest.fixture +def server(tmp_path): + httpd = passman_server.ThreadingHTTPServer(("127.0.0.1", 0), + passman_server.Handler) + httpd.store = passman_server.Store(str(tmp_path / "store")) + httpd.token = TOKEN + os.makedirs(httpd.store.root, exist_ok=True) + thread = threading.Thread(target=httpd.serve_forever, + kwargs={"poll_interval": 0.01}, daemon=True) + thread.start() + try: + httpd.url = f"http://127.0.0.1:{httpd.server_address[1]}" + yield httpd + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + +@pytest.fixture +def device(tmp_path, monkeypatch): + """device("a") makes that PC's data folder the current one.""" + def use(name): + monkeypatch.setenv("PASSMAN_DATA_DIR", str(tmp_path / name / "data")) + use("a") + return use + + +def entry(site, password="p"): + return {"site": site, "username": "me", "password": password} + + +def sync_once(account, local, choices=None): + """One whole sync, as the UI runs it; returns the merged vault.""" + session = account_sync.AccountSync(account) + try: + result = session.prepare(local) + if not session.has_work(): + return local + if result.conflicts and choices is None: + raise AssertionError(f"unexpected conflicts: {result.conflicts}") + return session.commit(local, choices) + finally: + session.close() + + +def new_account(name="alice", pw=PW): + return accounts.prepare_account(name, pw) + + +@pytest.fixture +def alice(server, device): + """Device a: alice, made here and put on the server.""" + new = new_account() + account = new.save() + assert account_sync.connect(account, server.url, "", TOKEN) == "created" + account.kit = new.secret_key_text + return account + + +# --------------------------------------------------------------------------- +# Putting an account on a server +# --------------------------------------------------------------------------- +def test_connect_creates_the_account_and_remembers_the_server(server, alice): + assert alice.server == {"url": server.url, "ca_file": ""} + again = accounts.unlock("alice", PW) + assert again.server == alice.server # in account.json + # connecting again finds it there + assert account_sync.connect(again, server.url, "", "") == "joined" + + +@pytest.mark.parametrize("token", ["", "wrong-token-of-a-decent-length"]) +def test_connect_needs_the_servers_token_to_create(server, device, token): + account = new_account().save() + with pytest.raises(LoginFailed): + account_sync.connect(account, server.url, "", token) + assert account.server is None + assert accounts.unlock("alice", PW).server is None + + +def test_a_name_taken_on_the_server_is_not_overwritten(server, device, alice): + device("b") + other = new_account("alice", "another password").save() + with pytest.raises(SyncError, match="already has an account"): + account_sync.connect(other, server.url, "", TOKEN) + assert other.server is None + + +def test_an_account_with_another_personal_vault_is_not_joined(server, device, + alice, monkeypatch): + """Only a confused or hostile server could answer like this -- the login + already proves the keys -- but then this device must not adopt it.""" + real_me = account_sync.AccountServer.me + monkeypatch.setattr(account_sync.AccountServer, "me", lambda self: { + **real_me(self), "personal_vault": os.urandom(16).hex()}) + account = accounts.unlock("alice", PW) + account.set_server(None) + with pytest.raises(SyncError, match="different personal vault"): + account_sync.connect(account, server.url, "", "") + assert account.server is None + + +def test_a_server_without_accounts_says_so(server, device, monkeypatch): + monkeypatch.setattr(account_sync.AccountServer, "health", + lambda self: {"ok": True, "service": "passman-sync"}) + account = new_account().save() + with pytest.raises(SyncError, match="2.0 or newer"): + account_sync.connect(account, server.url, "", TOKEN) + + +def test_an_account_not_on_a_server_cannot_sync(device): + account = new_account().save() + with pytest.raises(SyncError, match="not on a sync server"): + account_sync.AccountSync(account).prepare(account.load_vault()) + + +# --------------------------------------------------------------------------- +# Syncing the personal vault +# --------------------------------------------------------------------------- +def test_the_first_sync_uploads_and_records_the_base(server, alice): + local = {"accounts": [entry("github")]} + alice.save_vault(local) + merged = sync_once(alice, local) + assert [e["site"] for e in merged["accounts"]] == ["github"] + stored = server.accounts.vaults.read(alice.vault_id.hex()).decode() + assert alice.decrypt(stored)["accounts"][0]["site"] == "github" + origin = alice.load_base()[sync_client.vault.BASE_ORIGIN_KEY] + assert origin == {"url": server.url, "revision": 1} + + +def test_a_second_device_signs_in_and_gets_the_vault(server, device, alice): + alice.save_vault({"accounts": [entry("github")]}) + sync_once(alice, alice.load_vault()) + + device("b") + account, data = account_sync.sign_in(server.url, "", "alice", PW, alice.kit) + assert [e["site"] for e in data["accounts"]] == ["github"] + assert account.load_vault() == data + assert account.server["url"] == server.url + assert account.fingerprint == alice.fingerprint + # The Secret Key is kept: next time the password alone opens it. + assert accounts.unlock("alice", PW).load_vault() == data + + +def test_two_devices_merge_through_the_server(server, device, alice): + device("b") + bob_side, _ = account_sync.sign_in(server.url, "", "alice", PW, alice.kit) + + device("a") + a_local = {"accounts": [entry("github")]} + alice.save_vault(a_local) + a_local = sync_once(alice, a_local) + + device("b") + b_local = bob_side.load_vault() + b_local["accounts"].append(entry("gitlab")) + bob_side.save_vault(b_local) + b_local = sync_once(bob_side, b_local) + assert sorted(e["site"] for e in b_local["accounts"]) == ["github", "gitlab"] + + device("a") + a_local = sync_once(alice, alice.load_vault()) + assert sorted(e["site"] for e in a_local["accounts"]) == ["github", "gitlab"] + + +def test_an_edit_on_both_devices_is_a_conflict_to_choose(server, device, alice): + alice.save_vault({"accounts": [entry("github", "v1")]}) + sync_once(alice, alice.load_vault()) + device("b") + other, _ = account_sync.sign_in(server.url, "", "alice", PW, alice.kit) + + local_b = other.load_vault() + local_b["accounts"][0]["password"] = "from-b" + other.save_vault(local_b) + sync_once(other, local_b) + + device("a") + local_a = alice.load_vault() + local_a["accounts"][0]["password"] = "from-a" + alice.save_vault(local_a) + session = account_sync.AccountSync(alice) + result = session.prepare(local_a) + assert len(result.conflicts) == 1 + merged = session.commit(local_a, {result.conflicts[0].id: sync.KEEP_REMOTE}) + session.close() + assert merged["accounts"][0]["password"] == "from-b" + + +def test_a_vault_changed_during_the_sync_is_not_overwritten(server, alice): + local = {"accounts": [entry("github")]} + session = account_sync.AccountSync(alice) + session.prepare(local) + with pytest.raises(SyncError, match="changed while the sync was open"): + session.commit({"accounts": [entry("github"), entry("new")]}) + session.close() + + +def test_the_server_never_holds_anything_readable(server, alice): + alice.save_vault({"accounts": [entry("bank", "s3cret-entry-password")]}) + sync_once(alice, alice.load_vault()) + everything = b"" + for folder, _, files in os.walk(server.store.root): + for name in files: + with open(os.path.join(folder, name), "rb") as f: + everything += f.read() + for secret in (b"s3cret-entry-password", b"bank", PW.encode(), + alice.kit.encode(), alice.kit.replace("-", "").encode()): + assert secret not in everything + + +# --------------------------------------------------------------------------- +# Signing in +# --------------------------------------------------------------------------- +def test_sign_in_with_a_wrong_password_or_key_says_neither(server, device, alice): + device("b") + wrong_key = account_crypto.format_secret_key(account_crypto.new_secret_key()) + for password, kit in ((PW + "x", alice.kit), (PW, wrong_key)): + with pytest.raises(LoginFailed, + match="Wrong username, master password or Secret Key"): + account_sync.sign_in(server.url, "", "alice", password, kit) + assert accounts.list_accounts() == [] # nothing written + + +def test_a_new_device_needs_the_emergency_kit(server, device, alice): + device("b") + with pytest.raises(accounts.SecretKeyNeeded): + account_sync.sign_in(server.url, "", "alice", PW) + + +def test_sign_in_takes_the_servers_spelling_of_the_name(server, device, alice): + device("b") + account, _ = account_sync.sign_in(server.url, "", "ALICE", PW, alice.kit) + assert account.username == "alice" + + +def test_sign_in_does_not_replace_someone_elses_account_here(server, device, alice): + device("b") + new_account("alice", "local only").save() # unrelated, same name + with pytest.raises(FileExistsError, match="different account"): + account_sync.sign_in(server.url, "", "alice", PW, alice.kit) + accounts.unlock("alice", "local only") # untouched + + +# --------------------------------------------------------------------------- +# Changing the master password +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("scheme", ["native", "password"]) +def test_a_password_changed_on_one_device_reaches_the_other(server, device, alice, + monkeypatch, scheme): + if scheme == "password": + monkeypatch.setattr(secret_store, "default_scheme", + lambda: secret_store.SCHEME_PASSWORD) + alice.save_vault({"accounts": [entry("github")]}) + sync_once(alice, alice.load_vault()) + device("b") + other, _ = account_sync.sign_in(server.url, "", "alice", PW, alice.kit) + other_local = other.load_vault() + other_local["accounts"].append(entry("only-on-b")) + other.save_vault(other_local) + + device("a") + account_sync.change_password(alice, "a brand new password") + accounts.unlock("alice", "a brand new password") # here: new one + + device("b") + stale = accounts.unlock("alice", PW) # b still has the old + with pytest.raises(LoginFailed, match="Sign in from a server"): + account_sync.AccountSync(stale).prepare(stale.load_vault()) + # b signs in again with the new password. Where the Secret Key is kept + # under the password (the scheme forced here, and Linux's own), it was + # sealed with the old one, so the kit is needed; DPAPI does not care. + kit = (alice.kit if secret_store.default_scheme() == secret_store.SCHEME_PASSWORD + else None) + if kit is None: + caught, _ = account_sync.sign_in(server.url, "", "alice", + "a brand new password") + else: + with pytest.raises(accounts.SecretKeyNeeded): + account_sync.sign_in(server.url, "", "alice", "a brand new password") + caught, _ = account_sync.sign_in(server.url, "", "alice", + "a brand new password", kit) + # b kept its own unsynced entry, and the next sync merges it in + merged = sync_once(caught, caught.load_vault()) + assert sorted(e["site"] for e in merged["accounts"]) == ["github", "only-on-b"] + accounts.unlock("alice", "a brand new password") + with pytest.raises(account_crypto.CryptoError): + accounts.unlock("alice", PW) + + +def test_no_server_no_password_change(server, alice): + server.shutdown() + server.server_close() + with pytest.raises(SyncError): + account_sync.change_password(alice, "never set") + accounts.unlock("alice", PW) # unchanged here diff --git a/sourceFiles/version.py b/sourceFiles/version.py index 5c4ea63..4624a11 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "1.0.0-alpha.2" +__version__ = "1.0.0-alpha.3" #: The container a 3.0 account's vaults are written in (account_crypto). VAULT_FORMAT = "PMV3"