diff --git a/README.md b/README.md index 44356bf..8daef6c 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.6, a SecretSpace 3 test version — not for real passwords yet.** +**`main` is 1.0.0-alpha.7, 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.6. +Windows 3.0.0-alpha.7. 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.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): +1.0.0-alpha.7 is the same as [SecretSpace for Windows +3.0.0-alpha.7](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 @@ -52,17 +52,23 @@ the list, and all covered by **Sync** — which needs SecretSpace Server 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: +copied a password. Sharing needs SecretSpace Server 2.0.0-alpha.3. Since +alpha.7 a forgotten master password is no longer the end: *Forgot your master +password?* on the login screen opens the account with its **12-word +recovery phrase**, sets a new password and makes a new phrase (needs +SecretSpace Server 2.0.0-alpha.4 for an account on 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 account folder opens on another PC with the master password alone; keep your backups as private as your vault files. The desktop keyring may take - this over later. + this over later. It also means that **after a recovery the account gets a + new Secret Key** (the stored one is sealed under the forgotten password): + save the new Emergency Kit the recovery shows you. - 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.6`, which apt sorts after +- The package spells the version `1.0.0~alpha.7`, which apt sorts after 0.9.0 and before 1.0.0. | File | What it is | @@ -197,6 +203,7 @@ Where that README names a Windows version, read the Linux one beside it: | 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) | +| 3.0.0-alpha.7 | 1.0.0-alpha.7 | recovering an account with the 12-word phrase, a new phrase (Server 2.0.0-alpha.4) | (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 5e784c5..c5fc009 100644 --- a/sourceFiles/account_crypto.py +++ b/sourceFiles/account_crypto.py @@ -572,8 +572,7 @@ def create_account(username: str, master_password: str, "login_key": login, "sealed_keyset": seal_keyset(auk, keyset, salt, KDF_ARGON2ID, passes, memory_kib, lanes), - "sealed_recovery": seal_keyset(recovery_key(phrase, username), keyset, - bytes(SALT_LEN), KDF_NONE, 0, 0, 0), + "sealed_recovery": seal_recovery(phrase, username, keyset), } @@ -589,6 +588,23 @@ def unlock_account(username: str, master_password: str, secret_key: bytes, return open_keyset(auk, sealed_keyset), login +def seal_recovery(phrase: str, username: str, keyset: dict) -> str: + """The keyset under a recovery phrase: the account's recovery copy.""" + return seal_keyset(recovery_key(phrase, username), keyset, bytes(SALT_LEN), + KDF_NONE, 0, 0, 0) + + +def recovery_proof(keyset: dict) -> bytes: + """ + What a device shows the server to prove it opened the account's keyset + (with the recovery phrase) before it may set a new password there. From + the keyset's private key, under its own label, so it reveals nothing + about that key; the server keeps only its hash. + """ + return _hkdf(_unb64(keyset["x25519_private"], "account key"), + b"PassMan3 recovery proof", b"PassMan3 recovery proof|v1") + + def recover_account(username: str, phrase: str, sealed_recovery: str) -> dict: """The keyset from the recovery phrase alone (personal accounts).""" if keyset_header(sealed_recovery)["kdf"] != KDF_NONE: diff --git a/sourceFiles/account_sync.py b/sourceFiles/account_sync.py index 965a5f4..fc88b8f 100644 --- a/sourceFiles/account_sync.py +++ b/sourceFiles/account_sync.py @@ -49,6 +49,9 @@ #: (2.0.0-alpha.3). FEATURE_SHARING = "sharing" +#: ... and of one that recovers an account with its phrase (2.0.0-alpha.4). +FEATURE_RECOVERY = "recovery" + class LoginFailed(SyncError): """The server did not take this username + password + Secret Key.""" @@ -103,6 +106,26 @@ def has_sharing(self) -> bool: self.has_vaults() return FEATURE_SHARING in self._features + def has_recovery(self) -> bool: + self.has_vaults() + return FEATURE_RECOVERY in self._features + + # -- recovery (phase 5) ---------------------------------------------------- + def recovery_copy(self, username: str) -> dict: + return self._request("GET", "/v2/recovery/" + urllib.parse.quote( + username, safe="")) + + def recover(self, body: dict) -> dict: + return self._request("POST", "/v2/recovery", body=body) + + def put_recovery_proof(self, proof: bytes) -> None: + self._request("PUT", "/v2/me/recovery-proof", body={ + "recovery_proof": base64.b64encode(proof).decode("ascii")}) + + def put_recovery(self, sealed_recovery: str) -> None: + self._request("PUT", "/v2/me/recovery", + body={"recovery": sealed_recovery}) + # -- sharing (phase 4) --------------------------------------------------- def put_signing_key(self, signing_key: str) -> None: self._request("PUT", "/v2/me/signing-key", @@ -245,6 +268,8 @@ def connect(account: accounts.Account, url: str, ca_file: str, "keyset": files["keyset"], "recovery": files["recovery"], "public_key": account.info["public_key"], "signing_key": account.signing_key, + "recovery_proof": base64.b64encode(account_crypto.recovery_proof( + account.keyset)).decode("ascii"), "personal_vault": account.vault_id.hex()}) except Conflict409: raise SyncError( @@ -406,6 +431,14 @@ def reconcile_vaults(account: accounts.Account, server: AccountServer) -> VaultC # 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) + if server.has_recovery() and not me.get("can_recover"): + # Likewise what lets the server recover the account with its phrase. + server.put_recovery_proof(account_crypto.recovery_proof(account.keyset)) + sealed = me.get("recovery") + if sealed and sealed != account.sealed_files()["recovery"]: + # A new phrase was made on another device: this one takes the copy + # that goes with it, so the old phrase opens nothing here either. + account.set_recovery(sealed) for vault_id in extra_here: record = account.vault_record(vault_id) diff --git a/sourceFiles/accounts.py b/sourceFiles/accounts.py index 0dd2ea3..00b582e 100644 --- a/sourceFiles/accounts.py +++ b/sourceFiles/accounts.py @@ -614,6 +614,11 @@ def change_password(self, new_password: str, before_write=None) -> None: self.info = info self.login_key = login + def set_recovery(self, sealed_recovery: str) -> None: + """A new recovery copy (a new phrase); the old copy is replaced, not + kept -- a .bak of it would still open with the old phrase.""" + _write_text(_path(self.username, RECOVERY_FILE), sealed_recovery) + def sealed_files(self) -> dict: """The sealed keyset and recovery copy, as stored -- what a server keeps so another device can sign in.""" @@ -783,6 +788,21 @@ def adopt_from_server(username: str, me: dict, keyset: dict, secret: bytes, return Account(username, info, keyset, secret, login_key) +def replace_keys(username: str, sealed_keyset: str, sealed_recovery: str, + secret: bytes, password: str) -> None: + """ + After a recovery on a PC that has the account and no server: the same + keys under the new password and the new phrase, and the Secret Key + stored for the new password. The vault files are not touched -- the + keys that open them have not changed. + """ + info = dict(_read_info(username)) + info["secret_key"] = secret_store.protect(secret, username, password) + _write_text(_path(username, KEYSET_FILE), sealed_keyset) + _write_text(_path(username, RECOVERY_FILE), sealed_recovery) + _write_info(username, info) + + def check_recovery_phrase(username: str, phrase: str) -> bool: """Does this phrase open the account's recovery copy? (Phase 5 recovers.)""" try: diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index af5107f..88dfac8 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -141,9 +141,10 @@ def _secret_key_box(parent, text): def _emergency_kit_dialog(app, new, password): top = app._modal("Emergency Kit", 500, 600) shell = dialog_shell(top, "Your Emergency Kit", - "Step 2 of 3 · With your master password, this " - "Secret Key opens your account on another PC. " - "SecretSpace keeps a copy on this PC only.") + getattr(new, "kit_subtitle", + "Step 2 of 3 · With your master password, this " + "Secret Key opens your account on another PC. " + "SecretSpace keeps a copy on this PC only.")) wrap = shell.body field_label(wrap, "Account") @@ -184,9 +185,10 @@ def next_step(): def _phrase_dialog(app, new, password): top = app._modal("Recovery phrase", 520, 600) shell = dialog_shell(top, "Your recovery phrase", - "Step 3 of 3 · These 12 words open your account if " - "you forget your master password. Write them on " - "paper, in order.") + getattr(new, "phrase_subtitle", + "Step 3 of 3 · These 12 words open your account " + "if you forget your master password. Write " + "them on paper, in order.")) wrap = shell.body grid = ctk.CTkFrame(wrap, fg_color=CARD, corner_radius=16, @@ -242,6 +244,10 @@ def finish(): (" does" if len(wrong) == 1 else " do") + " not match. Look at your paper copy again.") return + if hasattr(new, "finish"): + # A recovery or a new phrase: it says what happens (and may use + # the network, off this thread). + return new.finish(app, top, err) try: account = new.save() loaded = account.load_vault() @@ -258,8 +264,9 @@ def back(): app._ghost_button(shell.buttons, "Show the words again", back, width=170).pack(side="left") - app._accent_button(shell.buttons, "Create account", finish, - width=150).pack(side="right") + app._accent_button(shell.buttons, + getattr(new, "finish_label", "Create account"), finish, + width=170).pack(side="right") for b in boxes.values(): b.bind("", lambda ev: finish()) boxes[CHECK_WORDS[0]].focus() @@ -707,6 +714,17 @@ def settings_dialog(app): "another PC.", hint), "Save kit…", lambda: _save_kit_again(app), width=150).pack() + def new_phrase(): + import recovery_ui # late: it imports this module + top.destroy() + recovery_ui.new_phrase_dialog(app) + + app._ghost_button( + setting_row(sec, "Recovery phrase", + "Make a new one if it was lost or seen. The old one stops " + "working.", hint), + "New phrase…", new_phrase, width=150).pack() + def label_for(m): return "Never" if m == 0 else f"{m} minute{'s' if m != 1 else ''}" diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index ce260af..8d52b9a 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -30,6 +30,7 @@ import sync_ui import account_sync_ui import vaults_ui +import recovery_ui import totp from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG, BORDER, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER, @@ -662,8 +663,14 @@ def toggle_pw(): width=10, height=32, fg_color="transparent", hover_color=CARD_HOVER, text_color=GREEN, font=F(13, "bold")).pack(side="right") + ctk.CTkButton(pad, text="Forgot your master password?", + command=lambda: recovery_ui.recover_dialog( + self, self._login_user or ""), + width=10, height=28, fg_color="transparent", + hover_color=CARD_HOVER, text_color=MUTED, + font=F(12)).pack(pady=(6, 0)) ctk.CTkLabel(pad, text=version_string(), font=F(11), - text_color=MUTED).pack(pady=(14, 0)) + text_color=MUTED).pack(pady=(8, 0)) self.password_entry.bind("", lambda e: self.unlock_vault()) self.password_entry.focus() diff --git a/sourceFiles/recovery.py b/sourceFiles/recovery.py new file mode 100644 index 0000000..b99c85c --- /dev/null +++ b/sourceFiles/recovery.py @@ -0,0 +1,221 @@ +""" +Recovering an account with its 12-word phrase (phase 5), and making a new +phrase. + +The phrase opens a second copy of the account's keyset (recovery.pmk on the +device, "recovery" on the server). With the keyset open, nothing is lost: +the same keys are sealed under a new master password, and a new phrase +replaces the old one -- which opens nothing from then on. + +Where the copy comes from: +- this PC has the account: its own recovery.pmk; +- it does not (a new or reinstalled PC): the account's server hands it out + (GET /v2/recovery/; it opens only with the phrase). + +The Secret Key: this PC keeps the one it has when it can read it (Windows +DPAPI does not need the master password). Otherwise -- a new PC, or Linux, +where the stored key is sealed under the forgotten password -- a new Secret +Key is made and a new Emergency Kit shown. Either way the phrase alone is +enough, as it must be for someone who lost both password and kit. + +An account on a server is changed there first (POST /v2/recovery, proving +with a key derived from the keyset that the phrase really opened it): if +the server cannot be reached, nothing changes anywhere, so this PC and the +server never disagree about the password. Other devices then sign in again +with the new password (and the new kit, if one was made). + +Nothing here touches a widget; recovery_ui.py runs it on a worker thread. +""" + +import base64 + +import account_crypto +import account_sync +import accounts +import secret_store +from account_crypto import CryptoError +from sync_client import SyncError + + +class Opened: + """The keyset, opened with the phrase: what step 2 (the new password) + works with.""" + + def __init__(self, username, keyset, server, local): + self.username = username + self.keyset = keyset + self.server = server # {"url", "ca_file"} or None + self.local = local # this PC has the account + + +class Pending: + """ + Everything a recovery will write, made in memory: shown to the user + (the new phrase, the new kit) before :meth:`save` writes anything. + """ + + def __init__(self, opened, password, secret, new_secret, phrase, + sealed_keyset, login_key, sealed_recovery): + self.username = opened.username + self.opened = opened + self.password = password + self._secret = secret + self.new_secret = new_secret + self.secret_key_text = account_crypto.format_secret_key(secret) + self.recovery_phrase = phrase + self.sealed_keyset = sealed_keyset + self.login_key = login_key + self.sealed_recovery = sealed_recovery + + def save(self): + """Write it all: the server first, then this PC. Returns (account, + the personal vault's data).""" + server = self.opened.server + if server: + remote = account_sync.AccountServer(server["url"], + server.get("ca_file", "")) + remote.check_api() + if not remote.has_recovery(): + raise SyncError("This server cannot recover accounts yet: it " + "needs SecretSpace Server 2.0.0-alpha.4 or " + "newer. Nothing was changed.") + remote.recover({ + "username": self.username, + "recovery_proof": _b64(account_crypto.recovery_proof( + self.opened.keyset)), + "login_key": _b64(self.login_key), + "keyset": self.sealed_keyset, + "recovery": self.sealed_recovery}) + # The server holds the new keys now; this PC takes them from + # there exactly as a sign-in does (and a new PC gets its vaults). + try: + account, data = account_sync.sign_in( + server["url"], server.get("ca_file", ""), self.username, + self.password, self.secret_key_text) + except Exception as e: + raise SyncError( + "The account was recovered on the server, but this PC " + f"could not sign in afterwards ({e}). Use “Sign in from " + "a server” with the new master password" + + (" and the new Emergency Kit." if self.new_secret else + ".")) from e + loaded = data if data is not None else account.load_vault() + return account, loaded + accounts.replace_keys(self.username, self.sealed_keyset, + self.sealed_recovery, self._secret, self.password) + account = accounts.unlock(self.username, self.password) + return account, account.load_vault() + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def open_with_phrase(username: str, phrase: str, url: str = "", + ca_file: str = "") -> Opened: + """ + Step 1: open the account's keyset with the phrase. Raises CryptoError + for a phrase that is mistyped or not this account's (said the same way + for a name that has no account), SyncError for the server. + """ + username = (username or "").strip() + if not username: + raise ValueError("Enter the account name.") + account_crypto.phrase_to_entropy(phrase) # typos first, offline + if accounts.account_exists(username): + info = accounts._read_info(username) + sealed = accounts._read_text(accounts._path(username, + accounts.RECOVERY_FILE)) + server = info.get("server") + server = server if isinstance(server, dict) and server.get("url") else None + local = True + else: + url = (url or "").strip().rstrip("/") + if not url: + raise ValueError(f"This PC has no account “{username}”. Enter the " + "address of the server it is on.") + if not url.startswith(("http://", "https://")): + raise ValueError("The server address starts with http:// or " + "https://.") + remote = account_sync.AccountServer(url, ca_file) + remote.check_api() + if not remote.has_recovery(): + raise SyncError("This server cannot recover accounts yet: it needs " + "SecretSpace Server 2.0.0-alpha.4 or newer.") + reply = remote.recovery_copy(username) + username = reply.get("username") or username + sealed = reply.get("recovery", "") + server = {"url": url, "ca_file": ca_file or ""} + local = False + try: + keyset = account_crypto.recover_account(username, phrase, sealed) + except CryptoError: + if not (local and server): + raise + # The phrase may have been changed on another device: the server + # has the copy that goes with the newest one. + remote = account_sync.AccountServer(server["url"], server.get("ca_file", "")) + if not remote.has_recovery(): + raise + keyset = account_crypto.recover_account( + username, phrase, remote.recovery_copy(username).get("recovery", "")) + return Opened(username, keyset, server, local) + + +def prepare(opened: Opened, new_password: str) -> Pending: + """Step 2: the same keys under a new password, a new phrase, and the + Secret Key this PC keeps (or a new one) -- nothing written yet.""" + if not new_password: + raise ValueError("Enter a new master password.") + secret = None + if opened.local: + try: + # DPAPI (Windows) reads it without the master password; the + # password scheme (Linux) cannot, as that password is forgotten. + secret = secret_store.unprotect( + accounts._read_info(opened.username).get("secret_key"), + opened.username, "") + except (secret_store.Unavailable, CryptoError, ValueError, OSError): + secret = None + new_secret = secret is None + if new_secret: + secret = account_crypto.new_secret_key() + sealed_keyset, login = account_crypto.reseal_with_new_password( + opened.username, opened.keyset, new_password, secret, + account_crypto.ARGON2_PASSES, account_crypto.ARGON2_MEMORY_KIB, + account_crypto.ARGON2_LANES) + phrase = account_crypto.new_recovery_phrase() + return Pending(opened, new_password, secret, new_secret, phrase, + sealed_keyset, login, + account_crypto.seal_recovery(phrase, opened.username, + opened.keyset)) + + +# --------------------------------------------------------------------------- +# A new phrase, for an open account (Settings) +# --------------------------------------------------------------------------- +class NewPhrase: + """A new phrase for an open account: shown first, then :meth:`save` + makes it the one (the old phrase opens nothing afterwards).""" + + def __init__(self, account: accounts.Account): + self.account = account + self.username = account.username + self.recovery_phrase = account_crypto.new_recovery_phrase() + + def save(self) -> None: + sealed = account_crypto.seal_recovery(self.recovery_phrase, + self.username, + self.account.keyset) + if self.account.server: + remote = account_sync.logged_in(self.account) + try: + if not remote.has_recovery(): + raise SyncError("The server cannot take a new recovery " + "phrase yet: it needs SecretSpace Server " + "2.0.0-alpha.4 or newer. Nothing was " + "changed.") + remote.put_recovery(sealed) + finally: + remote.logout() + self.account.set_recovery(sealed) diff --git a/sourceFiles/recovery_ui.py b/sourceFiles/recovery_ui.py new file mode 100644 index 0000000..19a5053 --- /dev/null +++ b/sourceFiles/recovery_ui.py @@ -0,0 +1,222 @@ +""" +The recovery screens (phase 5): "Forgot your master password?" on the login +screen, and Settings -> New recovery phrase. + +Recovery is three or four short steps, reusing the windows that make an +account: (1) the account and its 12 words, (2) a new master password, +(3) a new Emergency Kit when a new Secret Key was needed, (4) the new +phrase, written down and checked. Nothing is written before the last step. + +The network and Argon2id run on a worker thread (sync_ui._in_background); +widgets are touched only on the main one. +""" + +import customtkinter as ctk + +import account_crypto +import accounts +import dialogs +import recovery +from sync_ui import _in_background +from theme import F, MUTED, DANGER, TEXT, BORDER, INPUT_BG, MONO +from widgets import note, error_label, dialog_shell, field, field_label + + +def _words_box(parent, width): + box = ctk.CTkTextbox(parent, height=92, wrap="word", fg_color=INPUT_BG, + border_width=1, border_color=BORDER, text_color=TEXT, + corner_radius=10, + font=ctk.CTkFont(family=MONO, size=14)) + box.pack(fill="x") + return box + + +# ========================================================================== +# Forgot your master password? +# ========================================================================== +def recover_dialog(app, username=""): + """Step 1: the account and its 12 words.""" + top = app._modal("Recover an account", 500, 700) + shell = dialog_shell( + top, "Forgot your master password?", + "Your 12-word recovery phrase opens the account again. You then set " + "a new master password and get a new phrase; the old one stops " + "working.") + wrap = shell.body + user_entry = app._field(wrap, "Account name") + if username: + user_entry.insert(0, username) + field_label(wrap, "Recovery phrase (12 words, in order)") + words = _words_box(wrap, shell.text_width) + url_entry = field(wrap, "Server address (only if this PC does not have " + "the account)", placeholder="http://192.168.1.50:8787") + note(wrap, "On a PC that already has the account, the phrase is all it " + "takes. On a new PC, the account comes from its server.", + shell.text_width, size=11, pady=(8, 0)) + err = error_label(wrap, shell.text_width) + busy = {"on": False} + + def go(): + if busy["on"]: + return + name = user_entry.get().strip() + phrase = " ".join(words.get("1.0", "end").split()) + busy["on"] = True + err.configure(text="Opening the account — this takes a moment…", + text_color=MUTED) + + def done(opened): + busy["on"] = False + if top.winfo_exists(): + top.destroy() + _password_step(app, opened) + + def failed(e): + busy["on"] = False + if top.winfo_exists(): + err.configure(text=str(e), text_color=DANGER) + + _in_background(app, lambda: recovery.open_with_phrase( + name, phrase, url_entry.get()), done, on_error=failed) + + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + app._accent_button(shell.buttons, "Next", go, width=150).pack(side="right") + top.bind("", lambda e: top.destroy()) + top.after(200, (words if username else user_entry).focus) + app._fit_modal(top) + + +def _password_step(app, opened): + """Step 2: a new master password.""" + top = app._modal("New master password", 460, 520) + shell = dialog_shell( + top, "A new master password", + f"The phrase opened “{opened.username}”. Choose the master password " + "it opens with from now on.") + wrap = shell.body + pw_entry = app._field(wrap, "New master password", show="•") + bar, label, update_strength = app._strength_widget(wrap) + confirm_entry = app._field(wrap, "Confirm password", show="•") + pw_entry.bind("", lambda e: update_strength(pw_entry.get())) + if opened.server: + note(wrap, "Your other devices then sign in again with it (Sign in " + "from a server).", shell.text_width, size=11, pady=(10, 0)) + err = error_label(wrap, shell.text_width) + busy = {"on": False} + + def go(): + if busy["on"]: + return + p, c = pw_entry.get(), confirm_entry.get() + if len(p) < 8: + return err.configure(text="Use at least 8 characters.", + text_color=DANGER) + if p != c: + return err.configure(text="Passwords do not match.", + text_color=DANGER) + busy["on"] = True + err.configure(text="Preparing…", text_color=MUTED) + + def done(pending): + busy["on"] = False + if top.winfo_exists(): + top.destroy() + flow = _RecoveryFlow(pending) + if pending.new_secret: + dialogs._emergency_kit_dialog(app, flow, p) + else: + dialogs._phrase_dialog(app, flow, p) + + def failed(e): + busy["on"] = False + if top.winfo_exists(): + err.configure(text=str(e), text_color=DANGER) + + _in_background(app, lambda: recovery.prepare(opened, p), done, + on_error=failed) + + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + app._accent_button(shell.buttons, "Next", go, width=150).pack(side="right") + for e in (pw_entry, confirm_entry): + e.bind("", lambda ev: go()) + top.after(200, pw_entry.focus) + + +class _RecoveryFlow: + """What the kit and phrase windows of dialogs.py need, for a recovery.""" + + kit_subtitle = ("This PC could not keep your old Secret Key, so the " + "account gets a new one. With your new master password " + "it opens the account on your other devices. The old " + "kit stops working.") + phrase_subtitle = ("Your NEW recovery phrase. The old one stops working " + "when you finish. Write these on paper, in order.") + finish_label = "Recover account" + + def __init__(self, pending): + self.pending = pending + self.username = pending.username + self.secret_key_text = pending.secret_key_text + self.recovery_phrase = pending.recovery_phrase + + def finish(self, app, top, err): + err.configure(text="Recovering — this takes a moment…", + text_color=MUTED) + + def done(payload): + account, loaded = payload + if top.winfo_exists(): + top.destroy() + app.open_account(account, loaded, self.pending.password) + app.toast(f"“{account.username}” recovered — your new master " + "password and phrase are in use") + + def failed(e): + if top.winfo_exists(): + err.configure(text=str(e), text_color=DANGER) + + _in_background(app, self.pending.save, done, on_error=failed) + + +# ========================================================================== +# Settings -> New recovery phrase +# ========================================================================== +class _NewPhraseFlow: + phrase_subtitle = ("Your NEW recovery phrase. The old one stops working " + "when you finish. Write these on paper, in order.") + finish_label = "Use the new phrase" + + def __init__(self, account): + self.pending = recovery.NewPhrase(account) + self.username = account.username + self.recovery_phrase = self.pending.recovery_phrase + + def finish(self, app, top, err): + err.configure(text="Saving…", text_color=MUTED) + + def done(_): + if top.winfo_exists(): + top.destroy() + app.toast("New recovery phrase in use — the old one opens " + "nothing any more") + + def failed(e): + if top.winfo_exists(): + err.configure(text=f"Nothing was changed: {e}", + text_color=DANGER) + + _in_background(app, self.pending.save, done, on_error=failed) + + +def new_phrase_dialog(app): + if app.account is None: + return + if not dialogs.ask_master_password( + app, "New recovery phrase", + "Make a new 12-word phrase if the old one was lost or someone may " + "have seen it. The old phrase stops working. Enter your master " + "password to continue.", action="Continue"): + return + dialogs._phrase_dialog(app, _NewPhraseFlow(app.account), app.master_password) diff --git a/sourceFiles/tests/sync_server/passman_server.py b/sourceFiles/tests/sync_server/passman_server.py index aee97a7..8c8480a 100644 --- a/sourceFiles/tests/sync_server/passman_server.py +++ b/sourceFiles/tests/sync_server/passman_server.py @@ -265,6 +265,15 @@ def _archive(self, user: str, revision: int) -> None: MIN_SEALED_NAME = 12 + 1 + 16 MAX_SEALED_NAME = 12 + 200 + 16 +#: What a device proves it can open the account's keyset with, when it +#: recovers the account with the 12-word phrase (account_crypto.recovery_proof): +#: 32 bytes, stored here only as a hash, like the Login Key. +RECOVERY_PROOF_LEN = 32 + +#: The size of a recovery copy for an account's usual keyset, for the +#: made-up answer about a name that has no account (see recovery_copy). +_RECOVERY_BODY = 12 + 133 + 16 + #: The account files' layouts, from the client (account_crypto.py). _KEYSET_MAGIC = b"PMK3" _KEYSET_HEADER = 4 + 1 + 1 + 4 + 1 + 1 + 16 # magic ver kdf mem passes lanes salt @@ -460,6 +469,10 @@ def create_account(self, body: dict) -> dict: signing_key = body.get("signing_key") if signing_key is not None: signing_key = _b64(_unb64(signing_key, "The signing key", 32)) + proof = body.get("recovery_proof") + if proof is not None: + proof = hash_login_key(_unb64(proof, "The recovery proof", + RECOVERY_PROOF_LEN)) 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.") @@ -479,6 +492,7 @@ def create_account(self, body: dict) -> dict: "login": hash_login_key(login_key), "public_key": _b64(public_key), "signing_key": signing_key or "", + "recovery_check": proof, "keyset": body["keyset"], "recovery": body["recovery"], "kdf": kdf, @@ -610,6 +624,7 @@ def me(self, username: str) -> dict: vaults.append(view) return {"username": record["username"], "public_key": record["public_key"], "signing_key": record.get("signing_key", ""), + "can_recover": bool(record.get("recovery_check")), "keyset": record["keyset"], "recovery": record["recovery"], "kdf": record["kdf"], "personal_vault": vault_id, "vaults": vaults} @@ -648,6 +663,103 @@ def set_signing_key(self, username: str, body: dict) -> None: record["signing_key"] = key self._write_json(self._account_path(username), record) + # -- recovery (phase 5) ----------------------------------------------------- + def set_recovery_proof(self, username: str, body: dict) -> None: + """ + What lets this account be recovered here with its 12-word phrase. + An account made before recovery existed sends it once, at its next + sync; it cannot be replaced afterwards. (It comes from the account's + own keys, which a new phrase does not change.) + """ + proof = _unb64(body.get("recovery_proof"), "The recovery proof", + RECOVERY_PROOF_LEN) + with _lock: + record = self.account(username) + had = record.get("recovery_check") + if had and not login_key_matches(proof, had): + raise V2Error(409, "This account can already be recovered; " + "that cannot be replaced.") + if not had: + record["recovery_check"] = hash_login_key(proof) + self._write_json(self._account_path(username), record) + + def set_recovery_copy(self, username: str, body: dict) -> None: + """A new 12-word phrase: the keyset under it replaces the old copy, + so the old phrase opens nothing from now on.""" + keyset_kdf(body.get("recovery"), recovery=True) + with _lock: + record = self.account(username) + record.update({"recovery": body["recovery"], + "recovery_changed_at": utc_now()}) + self._write_json(self._account_path(username), record) + + def recovery_copy(self, username: str) -> dict: + """ + The account's keyset under its 12-word phrase, for a device that + recovers the account. Handing it out is safe: it opens only with the + phrase, 128 random bits. For a name with no account the answer is + made up, the same on every ask, so it says nothing about which names + exist (as prelogin). + """ + if not valid_username(username): + raise V2Error(400, "Not a valid username.") + # Any letter case, as the account's file is found (prelogin's rule). + record = self._read_json(self._account_path(username)) + if record and record.get("username") and not record.get("disabled"): + return {"username": record["username"], "recovery": record["recovery"]} + key = self._server_key() + filler, block = b"", 0 + while len(filler) < _RECOVERY_BODY: + filler += hmac.new(key, b"recovery|%d|" % block + + username.lower().encode(), hashlib.sha256).digest() + block += 1 + header = _KEYSET_MAGIC + bytes([1, 0]) + bytes(4 + 1 + 1 + 16) + return {"username": username, + "recovery": _b64(header + filler[:_RECOVERY_BODY])} + + def recover(self, body: dict) -> dict: + """ + A forgotten master password: the device opened the recovery copy + with the phrase, proves it holds the account's keys, and sends the + same keys under a new password (and the Login Key that goes with + it) and under a new phrase. Every session of the account ends. + """ + username = body.get("username") + if not isinstance(username, str) or not valid_username(username): + raise V2Error(400, "Not a valid username.") + proof = _unb64(body.get("recovery_proof"), "The recovery proof", + RECOVERY_PROOF_LEN) + 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) + if self._paused(username): + raise V2Error(429, "Too many wrong attempts for this account. " + "Wait a minute, then try again.") + record = self.account(username) + stored = (record or {}).get("recovery_check") + # Hash even when there is nothing to compare with, as login does. + ok = login_key_matches(proof, stored or hash_login_key(b"\0" * 32)) \ + and stored is not None and not record.get("disabled") + if not ok: + self._failed(username) + if record is not None and stored is None and not record.get("disabled"): + raise V2Error(409, "This account cannot be recovered on this " + "server yet: sync it once from a device with " + "SecretSpace 3.0.0-alpha.7 or newer.") + raise V2Error(401, "The recovery phrase does not open this account.") + with self._failure_lock: + self._failures.pop(username.lower(), None) + with _lock: + record = self.account(username) + record.update({"login": hash_login_key(login_key), + "keyset": body["keyset"], "kdf": kdf, + "recovery": body["recovery"], + "password_changed_at": utc_now(), + "recovered_at": utc_now()}) + self._write_json(self._account_path(username), record) + self._drop_expired_sessions(user_to_end=username) + return {"username": username} + 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.""" @@ -988,10 +1100,12 @@ def do_GET(self): # 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). + # (2.0.0-alpha.3). "recovery" = an account recovered with its + # 12-word phrase (2.0.0-alpha.4). return self._send(200, {"ok": True, "service": "passman-sync", "api": [1, 2], - "features": ["vaults", "sharing"]}) + "features": ["vaults", "sharing", + "recovery"]}) if self._is_v2(): return self._v2("GET") @@ -1189,6 +1303,22 @@ def _v2_route(self, method, parts): raise accounts.audit(None, "login", reply["username"], address) return self._send(200, reply) + if route == ("GET", "recovery", 2): + return self._send(200, accounts.recovery_copy(parts[1])) + if route == ("POST", "recovery", 1): + body = self._read_body() + if body is None: + return None + tried = body.get("username") + tried = tried if isinstance(tried, str) and valid_username(tried) else "?" + try: + done = accounts.recover(body) + except V2Error as e: + accounts.audit(None, "recovery_failed", tried, address, + status=e.status) + raise + accounts.audit(None, "account_recovered", done["username"], address) + return self._send(200, done) 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. @@ -1226,6 +1356,19 @@ def _v2_route(self, method, parts): return None accounts.set_signing_key(username, body) return self._send(200, {}) + if route == ("PUT", "me", 2) and parts[1] == "recovery-proof": + body = self._read_body() + if body is None: + return None + accounts.set_recovery_proof(username, body) + return self._send(200, {}) + if route == ("PUT", "me", 2) and parts[1] == "recovery": + body = self._read_body() + if body is None: + return None + accounts.set_recovery_copy(username, body) + accounts.audit(None, "recovery_phrase_changed", username, address) + 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): diff --git a/sourceFiles/tests/test_account_recovery.py b/sourceFiles/tests/test_account_recovery.py new file mode 100644 index 0000000..3e25fd5 --- /dev/null +++ b/sourceFiles/tests/test_account_recovery.py @@ -0,0 +1,368 @@ +""" +Phase 5: recovering an account with its 12-word phrase, and a new phrase -- +on a PC alone, and through the real (test copy of the) sync server. +""" + +import json +import os + +import pytest + +import account_crypto +import account_sync +import accounts +import recovery +import secret_store +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 sync_all, sites + +NEW_PW = "a brand new master password" + + +def make_local(name="alice", pw=PW): + """An account on this PC only; returns (account, its phrase, its kit).""" + new = accounts.prepare_account(name, pw) + account = new.save() + return account, new.recovery_phrase, new.secret_key_text + + +def add_entry(account, site, vault_id=None): + file = account.vault_file(vault_id) + data = file.load() + vault.add_account(data, site, "me", "pw-" + site) + file.save(data) + + +def recover(name, phrase, password=NEW_PW, url=""): + pending = recovery.prepare(recovery.open_with_phrase(name, phrase, url), + password) + account, loaded = pending.save() + return pending, account, loaded + + +def folder_bytes(name): + found = {} + root = accounts.account_dir(name) + for folder, _, files in os.walk(root): + for n in files: + with open(os.path.join(folder, n), "rb") as f: + found[os.path.relpath(os.path.join(folder, n), root)] = f.read() + return found + + +def server_record(server, name): + path = os.path.join(server.store.root, "_v2", "accounts", name.lower() + ".json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +# --------------------------------------------------------------------------- +# A. The pieces +# --------------------------------------------------------------------------- +def test_the_recovery_proof_comes_from_the_keyset_and_is_not_a_key_of_it(): + keyset = account_crypto.new_keyset() + proof = account_crypto.recovery_proof(keyset) + assert proof == account_crypto.recovery_proof(json.loads(json.dumps(keyset))) + assert len(proof) == 32 + assert proof not in (account_crypto.public_key_of(keyset), + account_crypto.signing_key_of(keyset), + account_crypto._unb64(keyset["x25519_private"], "k")) + assert proof != account_crypto.recovery_proof(account_crypto.new_keyset()) + + +def test_seal_recovery_opens_only_with_its_phrase_and_name(): + keyset = account_crypto.new_keyset() + phrase = account_crypto.new_recovery_phrase() + sealed = account_crypto.seal_recovery(phrase, "alice", keyset) + assert account_crypto.recover_account("alice", phrase, sealed) == keyset + with pytest.raises(CryptoError): + account_crypto.recover_account("bob", phrase, sealed) + with pytest.raises(CryptoError): + account_crypto.recover_account( + "alice", account_crypto.new_recovery_phrase(), sealed) + + +# --------------------------------------------------------------------------- +# B. On a PC with no server +# --------------------------------------------------------------------------- +def test_a_local_account_is_recovered_with_its_phrase(): + account, phrase, kit = make_local() + add_entry(account, "Mail") + pending, again, loaded = recover("alice", phrase) + assert [e["site"] for e in loaded["accounts"]] == ["Mail"] + # the new password opens it, the old one no longer does + assert accounts.unlock("alice", NEW_PW).username == "alice" + with pytest.raises(CryptoError): + accounts.unlock("alice", PW) + # same keys: the vault and its key never changed + assert again.keyset == account.keyset + + +def test_the_old_phrase_stops_working_and_the_new_one_works(): + account, phrase, kit = make_local() + pending, again, _ = recover("alice", phrase) + with pytest.raises(CryptoError): + recovery.open_with_phrase("alice", phrase) + assert accounts.check_recovery_phrase("alice", pending.recovery_phrase) + assert pending.recovery_phrase != phrase + + +@pytest.mark.skipif(secret_store.default_scheme() != secret_store.SCHEME_DPAPI, + reason="Windows keeps the Secret Key with DPAPI") +def test_windows_keeps_the_secret_key_so_the_old_kit_still_works(): + account, phrase, kit = make_local() + pending, again, _ = recover("alice", phrase) + assert pending.new_secret is False + assert pending.secret_key_text == kit + assert again.secret_key_text == kit + + +def test_without_a_readable_secret_key_a_new_one_is_made(monkeypatch): + monkeypatch.setattr(secret_store, "default_scheme", + lambda: secret_store.SCHEME_PASSWORD) + account, phrase, kit = make_local() + pending, again, _ = recover("alice", phrase) + assert pending.new_secret is True and pending.secret_key_text != kit + # the new key is the one stored, under the new password + assert accounts.unlock("alice", NEW_PW).secret_key_text == pending.secret_key_text + + +@pytest.mark.parametrize("bad", [ + "not twelve words", + " ".join(["abandon"] * 12), # wrong checksum + "zzzz " + " ".join(["abandon"] * 11), # not in the list +]) +def test_a_mistyped_phrase_changes_nothing(bad): + make_local() + before = folder_bytes("alice") + with pytest.raises(CryptoError): + recovery.open_with_phrase("alice", bad) + assert folder_bytes("alice") == before + + +def test_another_accounts_phrase_changes_nothing(): + make_local("alice") + _, other_phrase, _ = make_local("bob") + before = folder_bytes("alice") + with pytest.raises(CryptoError): + recovery.open_with_phrase("alice", other_phrase) + assert folder_bytes("alice") == before + + +def test_nothing_is_written_before_save(): + account, phrase, kit = make_local() + before = folder_bytes("alice") + recovery.prepare(recovery.open_with_phrase("alice", phrase), NEW_PW) + assert folder_bytes("alice") == before + + +def test_a_short_or_empty_password_or_name_is_refused(): + account, phrase, kit = make_local() + with pytest.raises(ValueError): + recovery.prepare(recovery.open_with_phrase("alice", phrase), "") + with pytest.raises(ValueError): + recovery.open_with_phrase("", phrase) + + +def test_a_name_this_pc_does_not_have_needs_a_server_address(): + with pytest.raises(ValueError, match="server"): + recovery.open_with_phrase("nobody", + account_crypto.new_recovery_phrase()) + + +def test_a_new_phrase_replaces_the_old_one_locally(): + account, phrase, kit = make_local() + made = recovery.NewPhrase(account) + made.save() + assert not accounts.check_recovery_phrase("alice", phrase) + assert accounts.check_recovery_phrase("alice", made.recovery_phrase) + assert not os.path.exists(os.path.join(accounts.account_dir("alice"), + accounts.RECOVERY_FILE + ".bak")) + + +# --------------------------------------------------------------------------- +# C. With a server +# --------------------------------------------------------------------------- +@pytest.fixture +def on_server(server, device): # noqa: F811 + """alice on device a, made here (keeping her phrase) and on the server.""" + new = new_account() + account = new.save() + assert account_sync.connect(account, server.url, "", TOKEN) == "created" + add_entry(account, "Mail") + sync_all(account) + account.phrase, account.kit = new.recovery_phrase, new.secret_key_text + return account + + +def test_sign_up_gives_the_server_what_recovery_needs(server, on_server): + assert server_record(server, "alice")["recovery_check"] + me = account_sync.logged_in(on_server).me() + assert me["can_recover"] is True + + +def test_recovery_on_the_same_pc_changes_the_password_on_the_server_too( + server, on_server, device): + pending, again, loaded = recover("alice", on_server.phrase) + assert [e["site"] for e in loaded["accounts"]] == ["Mail"] + # the server takes the new password, and no longer the old one + account_sync.logged_in(again).logout() + with pytest.raises(account_sync.LoginFailed): + account_sync.sign_in(server.url, "", "alice", PW, on_server.kit) + # another device signs in with the new password (and the kit in use) + device("b") + other, data = account_sync.sign_in(server.url, "", "alice", NEW_PW, + pending.secret_key_text) + assert [e["site"] for e in data["accounts"]] == ["Mail"] + + +def test_recovery_on_a_new_pc_brings_the_account_and_its_vaults(server, on_server, + device): + team = on_server.create_vault("Team").hex + add_entry(on_server, "Router", team) + sync_all(on_server) + device("b") + assert not accounts.account_exists("alice") + pending, again, loaded = recover("alice", on_server.phrase, url=server.url) + assert pending.new_secret is True # nothing to keep on a new PC + assert sites(again) == ["Mail"] + assert sites(again, team) == ["Router"] + assert again.server["url"] == server.url + assert accounts.unlock("alice", NEW_PW).username == "alice" + + +def test_a_wrong_phrase_or_an_unknown_name_read_the_same_on_the_server( + server, on_server, device): + device("b") + wrong = account_crypto.new_recovery_phrase() + with pytest.raises(CryptoError) as one: + recovery.open_with_phrase("alice", wrong, server.url) + with pytest.raises(CryptoError) as two: + recovery.open_with_phrase("nobody", wrong, server.url) + assert str(one.value) == str(two.value) + assert not accounts.account_exists("alice") + + +def test_the_server_refuses_a_proof_that_is_not_the_accounts(server, on_server): + remote = account_sync.AccountServer(server.url) + other = account_crypto.new_keyset() + sealed, login = account_crypto.reseal_with_new_password( + "alice", other, NEW_PW, account_crypto.new_secret_key(), 1, 64, 1) + with pytest.raises(SyncError): + remote.recover({"username": "alice", + "recovery_proof": account_crypto._b64( + account_crypto.recovery_proof(other)), + "login_key": account_crypto._b64(login), + "keyset": sealed, + "recovery": account_crypto.seal_recovery( + account_crypto.new_recovery_phrase(), "alice", other)}) + # alice is untouched: her password still works + account_sync.logged_in(on_server).logout() + + +def test_an_unreachable_server_changes_nothing_here(server, on_server): + opened = recovery.open_with_phrase("alice", on_server.phrase) + pending = recovery.prepare(opened, NEW_PW) + before = folder_bytes("alice") + opened.server = {"url": "http://127.0.0.1:1", "ca_file": ""} + with pytest.raises(SyncError): + pending.save() + assert folder_bytes("alice") == before + assert accounts.unlock("alice", PW).username == "alice" + + +def test_an_account_from_before_recovery_sends_its_proof_at_the_next_sync( + server, on_server): + path = os.path.join(server.store.root, "_v2", "accounts", "alice.json") + record = server_record(server, "alice") + del record["recovery_check"] + with open(path, "w", encoding="utf-8") as f: + json.dump(record, f) + with pytest.raises(SyncError, match="sync it once"): + recover("alice", on_server.phrase) + sync_all(on_server) + assert server_record(server, "alice")["recovery_check"] + recover("alice", on_server.phrase) + assert accounts.unlock("alice", NEW_PW) + + +def test_a_new_phrase_reaches_the_server_and_the_other_devices(server, on_server, + device): + device("b") + other, _ = account_sync.sign_in(server.url, "", "alice", PW, on_server.kit) + device("a") + made = recovery.NewPhrase(on_server) + made.save() + assert server_record(server, "alice")["recovery"] == \ + on_server.sealed_files()["recovery"] + # device b takes the new copy at its next sync: the old phrase opens + # nothing there either + device("b") + assert accounts.check_recovery_phrase("alice", on_server.phrase) + sync_all(other) + assert not accounts.check_recovery_phrase("alice", on_server.phrase) + assert accounts.check_recovery_phrase("alice", made.recovery_phrase) + # and a new PC cannot use the old phrase through the server + device("c") + with pytest.raises(CryptoError): + recovery.open_with_phrase("alice", on_server.phrase, server.url) + + +def test_the_new_phrase_works_on_a_device_that_still_has_the_old_copy( + server, on_server, device): + device("b") + other, _ = account_sync.sign_in(server.url, "", "alice", PW, on_server.kit) + device("a") + made = recovery.NewPhrase(on_server) + made.save() + device("b") # not synced since + pending, again, _ = recover("alice", made.recovery_phrase) + assert accounts.unlock("alice", NEW_PW).username == "alice" + + +def test_shared_vaults_still_open_after_a_recovery(server, on_server, device): + device("b") + bob = new_account("bob").save() + account_sync.connect(bob, server.url, "", TOKEN) + sync_all(bob) + device("a") + team = on_server.create_vault("Team").hex + add_entry(on_server, "Router", team) + sync_all(on_server) + session = account_sync.logged_in(on_server) + keys = account_sync.lookup_contact(on_server, session, "bob") + account_sync.share_vault(on_server, session, team, keys, "editor") + session.logout() + # the owner recovers: same keys, so her vaults and the share all still open + pending, again, _ = recover("alice", on_server.phrase) + assert sites(again, team) == ["Router"] + sync_all(again) + assert sites(again, team) == ["Router"] + device("b") + sync_all(bob) + assert sites(bob, team) == ["Router"] + + +def test_recovery_ends_the_other_sessions_on_the_server(server, on_server): + session = account_sync.logged_in(on_server) + recover("alice", on_server.phrase) + with pytest.raises(account_sync.LoginFailed): + session.me() + + +def test_connect_alone_gives_the_server_the_proof(server, device): # noqa: F811 + account = new_account("carol").save() + assert account_sync.connect(account, server.url, "", TOKEN) == "created" + assert server_record(server, "carol")["recovery_check"] # no sync yet + + +def test_a_typo_is_reported_before_any_server_is_asked(): + # an address nothing listens on: a typo must be named, not a network error + with pytest.raises(CryptoError, match="12 words|word list|order"): + recovery.open_with_phrase("nobody", "only eleven " + "abandon " * 9, + "http://127.0.0.1:1") diff --git a/sourceFiles/version.py b/sourceFiles/version.py index 08d436b..20d8416 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,7 +12,7 @@ import re -__version__ = "1.0.0-alpha.6" +__version__ = "1.0.0-alpha.7" #: The container a 3.0 account's vaults are written in (account_crypto). VAULT_FORMAT = "PMV3"