From 53c2d279912063692416e66ac6a5a9a69ccdc5bf Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Thu, 12 Nov 2026 10:27:10 +0200 Subject: [PATCH] Server 2.0.0-alpha.4: recover an account with its 12-word phrase SecretSpace 3, phase 5. - GET /v2/recovery/ (no login): the account's keyset under its recovery phrase, for a device that does not have the account. It opens only with the phrase. For a name with no account a made-up copy is returned (same size and header, the same on every ask), so names cannot be probed. - POST /v2/recovery (no login): the device proves it opened the keyset with a value derived from the account's own keys (recovery_proof, stored here only as a hash) and sends the same keys under a new password and a new phrase. Every session of the account ends. Wrong proofs count with wrong logins. - PUT /v2/me/recovery-proof: an account made before this sets its proof once, at its next sync. PUT /v2/me/recovery: a new phrase from a session. - Sign-up takes recovery_proof; /v2/me says can_recover; /health lists "recovery"; logins.log gets account_recovered, recovery_failed and recovery_phrase_changed lines. 2.x routes unchanged; accounts made with earlier 2.0 alphas keep working. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 45 ++- server/passman_server.py | 147 ++++++- server/version.py | 2 +- tests/test_server_http.py | 2 +- tests/test_server_v2_recovery.py | 671 +++++++++++++++++++++++++++++++ tests/test_server_v2_vaults.py | 2 +- 6 files changed, 859 insertions(+), 10 deletions(-) create mode 100644 tests/test_server_v2_recovery.py diff --git a/README.md b/README.md index ac59969..51ddbf3 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ Standard library only: no `pip install`, no virtualenv, nothing to keep patched beyond Python itself. **Which apps it serves.** SecretSpace for Windows 2.x and SecretSpace for Linux -0.x sync with it. The SecretSpace 3 test versions (Windows 3.0.0-alpha.6, Linux -1.0.0-alpha.6) sync with the newest test version of the server, 2.0.0-alpha.3, -through the accounts and sharing described under API v2 below. +0.x sync with it. The SecretSpace 3 test versions (Windows 3.0.0-alpha.7, Linux +1.0.0-alpha.7) sync with the newest test version of the server, 2.0.0-alpha.4, +through the accounts, sharing and recovery described under API v2 below. This repository is the server for **Linux**, run by systemd. The other parts of SecretSpace have their own repositories: @@ -521,7 +521,7 @@ like a 2.x one, with the same revision, `If-Match` and history rules. | `/v2/accounts` | POST | the server's **token**, as the sign-up code | make an account (and its personal vault) | | `/v2/login` | POST | anyone | Login Key → a session, valid 12 hours. After 10 wrong tries in 15 minutes the name is paused for a minute | | `/v2/logout` | POST | session | end this session | -| `/v2/me` | GET | session | the account: public key, signing key, keyset, recovery copy, its vaults (each with its role, key generation and member count) | +| `/v2/me` | GET | session | the account: public key, signing key, `can_recover` (whether a recovery proof is stored), keyset, recovery copy, its vaults (each with its role, key generation and member count) | | `/v2/me/keyset` | PUT | session | a new master password: new keyset and Login Key; every other session of the account ends | | `/v2/vaults/` | GET / PUT | session, member | the vault blob, as `/vault/` (PUT: owner or editor; it must be a PMV3 file naming this vault) | | `/v2/vaults//meta` | GET | session, member | its metadata | @@ -530,6 +530,10 @@ like a 2.x one, with the same revision, `If-Match` and history rules. | `/v2/vaults/` | DELETE | session, owner | delete the vault (not the personal one, which goes with the account); it disappears for every member | | `/v2/me/signing-key` | PUT | session | set the public key the account signs its changes with: `{signing_key}`, 32 bytes in base64. Once only: the same key again is **200**, another one **409**, a wrong length **400** | | `/v2/accounts//public-key` | GET | session | another account's public key and signing key, and its name as the server spells it (the lookup ignores letter case). **404** for an unknown name | +| `/v2/recovery/` | GET | anyone | the account's recovery copy: `{username, recovery}`, with the name as the server spells it (the lookup ignores letter case). A name with no account, or a disabled one, gets a made-up, stable copy of the same shape. **400** for an invalid name | +| `/v2/recovery` | POST | anyone | recover the account: `{username, recovery_proof, login_key, keyset, recovery}`. **200** with the right proof; **401** for a wrong proof, an unknown name or a disabled account (the same message); **409** when the account has no proof stored yet; **400** for a bad input (a proof or Login Key that is not 32 bytes, a keyset that is a recovery copy or the other way round); **429** when the name is paused | +| `/v2/me/recovery-proof` | PUT | session | store the recovery proof (hashed): `{recovery_proof}`, 32 bytes in base64. Once only: the same proof again is **200**, another one **409**, a wrong length or bad base64 **400** | +| `/v2/me/recovery` | PUT | session | a new 12-word phrase: `{recovery}`, the keyset sealed under it. **400** if it is not a recovery copy (kdf byte 0); the old phrase then opens nothing | | `/v2/vaults//members` | GET | session, member | who is in the vault: `key_generation` and a list of `{username, role, added_by, added_at}`, sorted by name | | `/v2/vaults//members/` | PUT | session, owner | add a member or change a role: `{role, wrapped_key}`. `role` is `owner`, `editor` or `reader`; `wrapped_key` is needed for a new member and ignored for an existing one. **400** for a bad role or key or for a personal vault, **404** for an unknown account, **403** past 50 members, **409** when it would leave the vault without an owner | | `/v2/vaults//members/` | DELETE | session, yourself | leave the vault. **409** for its only owner. Anyone else is removed with `rotate` (an owner who tries this gets **400**) | @@ -597,7 +601,38 @@ server never learns of it. A log file that reaches 2 MB is renamed to cannot be written (a full disk, say), the request it belongs to still succeeds and the line is missing. A name with a space in it (`ann lee`) works in paths when it is sent percent-encoded (`ann%20lee`). `/health` now lists -`"features": ["vaults", "sharing"]`. +`"features": ["vaults", "sharing", "recovery"]`. + +Server **2.0.0-alpha.4** lets an account be **recovered with its 12-word +phrase** when the master password is forgotten. It goes with the Windows +3.0.0-alpha.7 and Linux 1.0.0-alpha.7 test versions. + +- The device asks `GET /v2/recovery/` and gets the recovery copy: the + account's keyset sealed under the phrase. It opens only with the phrase, so + handing it to anyone is safe. For a name with no account the server returns + a made-up copy (the same on every ask, the same shape and size), so the + call cannot be used to find out which names exist. +- The device opens the copy with the phrase and proves it by sending a + **recovery proof**, a value derived from the account's own keys. The server + keeps only a salted hash of it, as it does for the Login Key. A new phrase + does not change the keys, so it does not change the proof. +- An account made by 2.0.0-alpha.4 sends the proof at sign-up. An older + account sends it once, from a session, at its next sync + (`PUT /v2/me/recovery-proof`); it cannot be replaced afterwards. Until then + `can_recover` in `/v2/me` is false and a recovery answers **409**. +- With the right proof, `POST /v2/recovery` stores the same keys under the new + password (a new keyset and Login Key) and under a new phrase (a new + recovery copy), and **ends every session of the account**. The old Login Key + stops working. A wrong proof changes nothing. +- A new phrase can also be set from a session, without recovering + (`PUT /v2/me/recovery`). The server checks only that it is a recovery copy. +- Wrong proofs are counted with wrong logins: they share one count per name, + so after 10 wrong tries in 15 minutes of either kind, both a login and a + recovery for that name answer **429** for a minute. A successful login or + recovery clears the count. +- `_v2/logins.log` gets `account_recovered`, `recovery_failed` (with the + status) and `recovery_phrase_changed` lines. They never hold the proof, the + Login Key or the copy. Everything v2 keeps is in `_v2/` inside the vault store (a 2.x name cannot start with `_`), so backing up the store backs it up too: `accounts/`, diff --git a/server/passman_server.py b/server/passman_server.py index aee97a7..8c8480a 100644 --- a/server/passman_server.py +++ b/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/server/version.py b/server/version.py index 03bb919..9bdbda1 100644 --- a/server/version.py +++ b/server/version.py @@ -7,4 +7,4 @@ in SecretSpace-Server-Windows, which has its own version. """ -__version__ = "2.0.0-alpha.3" +__version__ = "2.0.0-alpha.4" diff --git a/tests/test_server_http.py b/tests/test_server_http.py index 56d5e36..9998744 100644 --- a/tests/test_server_http.py +++ b/tests/test_server_http.py @@ -108,7 +108,7 @@ def test_health_answers_without_a_token(server): status, _, body = server.request("GET", "/health", token=None) assert status == 200 assert body == {"ok": True, "service": "passman-sync", "api": [1, 2], - "features": ["vaults", "sharing"]} + "features": ["vaults", "sharing", "recovery"]} @pytest.mark.parametrize("header", [ diff --git a/tests/test_server_v2_recovery.py b/tests/test_server_v2_recovery.py new file mode 100644 index 0000000..a9030f4 --- /dev/null +++ b/tests/test_server_v2_recovery.py @@ -0,0 +1,671 @@ +""" +API v2, phase 5: recovering an account with its 12-word phrase, over HTTP. + +The server cannot read keysets, so these tests use byte strings of the right +shape (a PMK3 header with kdf byte 1 for a keyset and 0 for a recovery copy). +What the server does check is covered here: the recovery proof is stored only +hashed and set once; a made-up recovery copy hides which names exist; a +successful recovery swaps the keys, ends every session of the account and is +logged; a refused request changes nothing on disk. Wrong proofs count with +wrong logins (one failure counter per name, shared by both calls). +""" + +import os +import json +import base64 +import struct + +import pytest + +# First: it puts server/ on the path, and brings the running-server fixture. +from test_server_http import TOKEN, server # noqa: F401 (fixture) +from test_server_v2 import (Account, alice, b64, keyset_blob, # noqa: F401 + login, session_for, signup) +import passman_server # noqa: E402 + + +# --- helpers ----------------------------------------------------------------- + +def new_proof() -> bytes: + return os.urandom(32) + + +def unb64(text) -> bytes: + return base64.b64decode(text, validate=True) + + +def account_file(server, name="alice"): + return os.path.join(server.store.root, "_v2", "accounts", + name.lower() + ".json") + + +def read_bytes(path): + with open(path, "rb") as f: + return f.read() + + +def record(server, name="alice"): + return json.loads(read_bytes(account_file(server, name))) + + +def write_record(server, data, name="alice"): + with open(account_file(server, name), "w", encoding="utf-8") as f: + json.dump(data, f) + + +def logins_text(server): + try: + return read_bytes(os.path.join(server.store.root, "_v2", + "logins.log")).decode("utf-8") + except FileNotFoundError: + return "" + + +def log_lines(server, event): + lines = [json.loads(line) for line in logins_text(server).splitlines() + if line.strip()] + return [line for line in lines if line["event"] == event] + + +def recovery_blob() -> str: + return keyset_blob(bytes(16), kdf=0, memory_kib=0, passes=0, lanes=0) + + +def client_sized_recovery() -> bytes: + """A recovery copy with the body size of the real client's (a nonce, 133 + bytes of keyset JSON and a tag), which the Account helper does not make.""" + header = struct.pack(">4sBBIBB16s", b"PMK3", 1, 0, 0, 0, 0, bytes(16)) + return header + os.urandom(12 + 133 + 16) + + +def with_proof(server, account, proof=None): + """Sign `account` up with a recovery proof; returns the proof.""" + proof = proof or new_proof() + assert signup(server, account, recovery_proof=b64(proof))[0] == 201 + return proof + + +class Fresh: + """What a device sends after recovering: a new Login Key, keyset and phrase.""" + + def __init__(self): + self.login_key = os.urandom(32) + self.keyset = keyset_blob() + self.recovery = recovery_blob() + + +def recover_body(account, proof, fresh=None, **changes): + fresh = fresh or Fresh() + body = {"username": account.username, "recovery_proof": b64(proof), + "login_key": b64(fresh.login_key), "keyset": fresh.keyset, + "recovery": fresh.recovery} + body.update(changes) + return body + + +def recover(server, body): + return server.request("POST", "/v2/recovery", body, token=None) + + +def get_me(server, session): + return server.request("GET", "/v2/me", token=session) + + +def get_copy(server, name): + return server.request("GET", f"/v2/recovery/{name}", token=None) + + +def put_proof(server, session, value): + return server.request("PUT", "/v2/me/recovery-proof", + {"recovery_proof": value}, token=session) + + +def put_recovery(server, session, value): + return server.request("PUT", "/v2/me/recovery", {"recovery": value}, + token=session) + + +@pytest.fixture +def proved(server): + """alice, signed up with a proof (kept as .proof).""" + account = Account("alice") + account.proof = with_proof(server, account) + return account + + +@pytest.fixture +def small_lockout(monkeypatch): + monkeypatch.setattr(passman_server, "MAX_LOGIN_FAILURES", 3) + return 3 + + +# --- health ------------------------------------------------------------------ + +def test_health_lists_vaults_sharing_and_recovery(server): + _, _, body = server.request("GET", "/health", token=None) + assert body["features"] == ["vaults", "sharing", "recovery"] + + +# --- sign-up with a proof ---------------------------------------------------- + +def test_signup_without_a_proof_still_works_and_cannot_recover(server, alice): + _, _, me = get_me(server, session_for(server, alice)) + assert me["can_recover"] is False + + +def test_signup_with_a_proof_can_recover(server, proved): + _, _, me = get_me(server, session_for(server, proved)) + assert me["can_recover"] is True + + +def test_signup_keeps_the_proof_only_as_a_hash(server, proved): + stored = read_bytes(account_file(server)) + assert proved.proof not in stored + assert b64(proved.proof).encode() not in stored + assert set(record(server)["recovery_check"]) == {"salt", "hash", "n", "r", "p"} + + +@pytest.mark.parametrize("size", [0, 16, 31, 33, 64]) +def test_signup_refuses_a_proof_of_the_wrong_length(server, size): + status, _, _ = signup(server, Account(), recovery_proof=b64(os.urandom(size))) + assert status == 400 + assert not os.path.exists(account_file(server)) + + +def test_signup_refuses_a_proof_that_is_not_base64(server): + assert signup(server, Account(), recovery_proof="not base64!!")[0] == 400 + assert not os.path.exists(account_file(server)) + + +# --- PUT /v2/me/recovery-proof ----------------------------------------------- + +def test_a_proof_can_be_set_later_by_an_older_account(server, alice): + session = session_for(server, alice) + assert put_proof(server, session, b64(new_proof()))[0] == 200 + assert get_me(server, session)[2]["can_recover"] is True + + +def test_a_proof_set_later_is_stored_only_as_a_hash(server, alice): + proof = new_proof() + put_proof(server, session_for(server, alice), b64(proof)) + stored = read_bytes(account_file(server)) + assert proof not in stored and b64(proof).encode() not in stored + + +def test_setting_the_same_proof_again_is_fine(server, proved): + session = session_for(server, proved) + assert put_proof(server, session, b64(proved.proof))[0] == 200 + + +def test_the_same_proof_again_changes_nothing_on_disk(server, proved): + session = session_for(server, proved) + before = read_bytes(account_file(server)) + put_proof(server, session, b64(proved.proof)) + assert read_bytes(account_file(server)) == before + + +def test_a_different_proof_is_refused_and_the_first_stays(server, proved): + session = session_for(server, proved) + before = read_bytes(account_file(server)) + status, _, body = put_proof(server, session, b64(new_proof())) + assert status == 409 and "cannot be replaced" in body["error"] + assert read_bytes(account_file(server)) == before + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + + +def test_the_first_proof_set_later_is_the_one_that_counts(server, alice): + session = session_for(server, alice) + first = new_proof() + assert put_proof(server, session, b64(first))[0] == 200 + assert put_proof(server, session, b64(new_proof()))[0] == 409 + assert recover(server, recover_body(alice, first))[0] == 200 + + +@pytest.mark.parametrize("value", [b64(b"short"), b64(os.urandom(33)), "", None, + "%%%not-base64%%%"]) +def test_a_proof_of_the_wrong_shape_is_refused(server, alice, value): + session = session_for(server, alice) + before = read_bytes(account_file(server)) + assert put_proof(server, session, value)[0] == 400 + assert read_bytes(account_file(server)) == before + + +@pytest.mark.parametrize("token", [None, "", TOKEN, "x" * 43]) +def test_setting_a_proof_needs_a_session(server, alice, token): + assert put_proof(server, token, b64(new_proof()))[0] == 401 + assert "recovery_check" not in record(server) or record(server)["recovery_check"] is None + + +# --- PUT /v2/me/recovery ----------------------------------------------------- + +def test_a_new_recovery_copy_replaces_the_old_one(server, alice): + session = session_for(server, alice) + new = recovery_blob() + assert put_recovery(server, session, new)[0] == 200 + assert get_me(server, session)[2]["recovery"] == new + assert get_copy(server, "alice")[2]["recovery"] == new + + +def test_a_new_recovery_copy_leaves_keyset_and_login_alone(server, alice): + session = session_for(server, alice) + put_recovery(server, session, recovery_blob()) + assert get_me(server, session)[2]["keyset"] == alice.keyset + assert login(server, alice)[0] == 200 + + +def test_a_new_recovery_copy_leaves_the_proof_alone(server, proved): + put_recovery(server, session_for(server, proved), recovery_blob()) + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + + +def test_a_new_recovery_copy_keeps_the_session(server, alice): + session = session_for(server, alice) + put_recovery(server, session, recovery_blob()) + assert get_me(server, session)[0] == 200 + + +def test_a_new_recovery_copy_is_logged_without_its_content(server, alice): + new = recovery_blob() + put_recovery(server, session_for(server, alice), new) + lines = log_lines(server, "recovery_phrase_changed") + assert len(lines) == 1 and lines[0]["by"] == "alice" + assert new not in logins_text(server) + + +@pytest.mark.parametrize("make", [ + lambda: keyset_blob(), # a keyset (kdf byte 1) + lambda: b64(os.urandom(100)), # junk + lambda: b64(b"XXXX" + os.urandom(80)), # wrong magic + lambda: b64(b"PMK3"), # far too short + lambda: "%%%not base64%%%", + lambda: None, +], ids=["keyset", "junk", "wrong-magic", "short", "not-base64", "missing"]) +def test_a_blob_that_is_not_a_recovery_copy_is_refused_and_nothing_changes(server, alice, make): + session = session_for(server, alice) + before = read_bytes(account_file(server)) + logged = logins_text(server) + assert put_recovery(server, session, make())[0] == 400 + assert read_bytes(account_file(server)) == before + assert logins_text(server) == logged + + +@pytest.mark.parametrize("token", [None, "", TOKEN]) +def test_changing_the_recovery_copy_needs_a_session(server, alice, token): + before = read_bytes(account_file(server)) + assert put_recovery(server, token, recovery_blob())[0] == 401 + assert read_bytes(account_file(server)) == before + + +# --- GET /v2/recovery/ ------------------------------------------------- + +def test_the_recovery_copy_of_an_account_needs_no_login(server, alice): + status, _, body = get_copy(server, "alice") + assert status == 200 + assert body == {"username": "alice", "recovery": alice.recovery} + + +def test_the_recovery_copy_lookup_ignores_case_and_gives_the_servers_spelling(server): + account = Account("Alice") + signup(server, account) + _, _, body = get_copy(server, "aLiCe") + assert body == {"username": "Alice", "recovery": account.recovery} + + +def test_the_recovery_copy_of_an_unknown_name_is_made_up_but_the_same_shape(server, alice): + status, _, body = get_copy(server, "mallory") + assert status == 200 + assert set(body) == {"username", "recovery"} + assert body["username"] == "mallory" + raw = unb64(body["recovery"]) + assert raw[:4] == b"PMK3" and raw[5] == 0 + + +def test_the_made_up_copy_is_the_same_on_every_ask(server): + assert get_copy(server, "mallory")[2] == get_copy(server, "mallory")[2] + + +def test_the_made_up_copy_does_not_depend_on_letter_case(server): + assert (get_copy(server, "Mallory")[2]["recovery"] + == get_copy(server, "mallory")[2]["recovery"]) + + +def test_the_made_up_copy_differs_between_names(server): + assert (get_copy(server, "mallory")[2]["recovery"] + != get_copy(server, "eve")[2]["recovery"]) + + +def test_the_made_up_copy_depends_on_a_key_only_the_server_has(server): + first = get_copy(server, "mallory")[2]["recovery"] + os.remove(os.path.join(server.store.root, "_v2", "server.key")) + assert get_copy(server, "mallory")[2]["recovery"] != first + + +def test_the_made_up_copy_has_the_length_of_a_clients_real_one(server): + """The real client's copy is a header, a nonce, 133 bytes of keyset JSON + and a tag.""" + account = Account("real") + account.recovery = b64(client_sized_recovery()) + assert signup(server, account)[0] == 201 + real = unb64(get_copy(server, "real")[2]["recovery"]) + made = unb64(get_copy(server, "nobody")[2]["recovery"]) + assert len(made) == len(real) + + +def test_the_made_up_copy_length_against_the_account_test_helper(server, alice): + """Recorded, not required to match: the Account helper's copy has a + 92-byte body (120 bytes in all), the client's and the made-up one 161 + (189 in all).""" + real = unb64(get_copy(server, "alice")[2]["recovery"]) + made = unb64(get_copy(server, "nobody")[2]["recovery"]) + assert (len(real), len(made)) == (28 + 12 + 80, 28 + 12 + 133 + 16) + + +def test_a_disabled_account_answers_like_an_unknown_name(server, alice): + data = record(server) + data["disabled"] = True + write_record(server, data) + status, _, body = get_copy(server, "alice") + assert status == 200 + assert body["recovery"] != alice.recovery + assert body["recovery"] == get_copy(server, "alice")[2]["recovery"] + raw = unb64(body["recovery"]) + assert raw[:4] == b"PMK3" and raw[5] == 0 + assert len(raw) == 28 + 12 + 133 + 16 + + +@pytest.mark.parametrize("name", ["..%2Fetc", "a%2Fb", "%20", "x" * 200]) +def test_the_recovery_copy_of_an_invalid_name_is_400(server, name): + assert get_copy(server, name)[0] == 400 + + +# --- POST /v2/recovery: the good path ----------------------------------------- + +def test_recovery_with_the_right_proof_works(server, proved): + status, _, body = recover(server, recover_body(proved, proved.proof)) + assert status == 200 and body == {"username": "alice"} + + +def test_after_recovery_the_new_login_key_logs_in_and_the_old_one_does_not(server, proved): + fresh = Fresh() + assert recover(server, recover_body(proved, proved.proof, fresh))[0] == 200 + assert login(server, proved, login_key=fresh.login_key)[0] == 200 + assert login(server, proved)[0] == 401 + + +def test_after_recovery_me_shows_the_new_keyset_and_recovery(server, proved): + fresh = Fresh() + recover(server, recover_body(proved, proved.proof, fresh)) + _, _, body = login(server, proved, login_key=fresh.login_key) + me = get_me(server, body["session"])[2] + assert me["keyset"] == fresh.keyset and me["recovery"] == fresh.recovery + assert me["keyset"] != proved.keyset + + +def test_after_recovery_prelogin_gives_the_new_salt(server, proved): + fresh = Fresh() + fresh.keyset = keyset_blob(b"\x07" * 16) + recover(server, recover_body(proved, proved.proof, fresh)) + _, _, body = server.request("GET", "/v2/prelogin/alice", token=None) + assert body["kdf"]["salt"] == b64(b"\x07" * 16) + + +def test_after_recovery_the_recovery_lookup_gives_the_new_copy(server, proved): + fresh = Fresh() + recover(server, recover_body(proved, proved.proof, fresh)) + assert get_copy(server, "alice")[2]["recovery"] == fresh.recovery + + +def test_recovery_wants_the_exact_spelling_of_the_name(server): + """Like login: the name the account was made with.""" + account = Account("Alice") + proof = with_proof(server, account) + assert recover(server, recover_body(account, proof, username="alice"))[0] == 401 + assert recover(server, recover_body(account, proof))[0] == 200 + + +def test_the_proof_still_works_for_a_second_recovery(server, proved): + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + + +def test_recovery_ends_every_earlier_session_of_the_account(server, proved): + first, second = session_for(server, proved), session_for(server, proved) + recover(server, recover_body(proved, proved.proof)) + assert get_me(server, first)[0] == 401 + assert get_me(server, second)[0] == 401 + + +def test_recovery_leaves_other_accounts_sessions_alone(server, proved): + bob = Account("bob") + signup(server, bob) + bobs = session_for(server, bob) + recover(server, recover_body(proved, proved.proof)) + assert get_me(server, bobs)[0] == 200 + + +def test_recovery_keeps_the_personal_vault_and_public_key(server, proved): + fresh = Fresh() + recover(server, recover_body(proved, proved.proof, fresh)) + session = login(server, proved, login_key=fresh.login_key)[2]["session"] + me = get_me(server, session)[2] + assert me["personal_vault"] == proved.vault + assert me["public_key"] == b64(proved.public_key) + assert me["can_recover"] is True + + +def test_recovery_is_logged_without_the_proof_or_the_login_key(server, proved): + fresh = Fresh() + recover(server, recover_body(proved, proved.proof, fresh)) + lines = log_lines(server, "account_recovered") + assert len(lines) == 1 and lines[0]["by"] == "alice" + text = logins_text(server) + for secret in (proved.proof, fresh.login_key): + assert b64(secret) not in text + assert log_lines(server, "recovery_failed") == [] + + +def test_the_new_login_key_and_the_proof_are_stored_only_as_hashes(server, proved): + fresh = Fresh() + recover(server, recover_body(proved, proved.proof, fresh)) + stored = read_bytes(account_file(server)) + for secret in (fresh.login_key, proved.proof): + assert secret not in stored and b64(secret).encode() not in stored + + +def test_recovery_needs_no_token_and_no_session(server, proved): + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + + +# --- POST /v2/recovery: refusals --------------------------------------------- + +def test_a_wrong_proof_is_401_and_changes_nothing(server, proved): + session = session_for(server, proved) + before = read_bytes(account_file(server)) + status, _, body = recover(server, recover_body(proved, new_proof())) + assert status == 401 and "recovery phrase" in body["error"] + assert read_bytes(account_file(server)) == before + assert login(server, proved)[0] == 200 + assert get_me(server, session)[0] == 200 + + +def test_an_unknown_name_gets_the_same_401_as_a_wrong_proof(server, proved): + _, _, wrong = recover(server, recover_body(proved, new_proof())) + status, _, unknown = recover(server, recover_body(Account("ghost"), new_proof())) + assert status == 401 and unknown == wrong + assert not os.path.exists(account_file(server, "ghost")) + + +def test_an_account_without_a_proof_is_409_and_says_to_sync_once(server, alice): + before = read_bytes(account_file(server)) + status, _, body = recover(server, recover_body(alice, new_proof())) + assert status == 409 and "sync it once" in body["error"] + assert read_bytes(account_file(server)) == before + + +def test_a_disabled_account_cannot_be_recovered(server, proved): + data = record(server) + data["disabled"] = True + write_record(server, data) + before = read_bytes(account_file(server)) + assert recover(server, recover_body(proved, proved.proof))[0] == 401 + assert read_bytes(account_file(server)) == before + + +def test_a_disabled_account_without_a_proof_is_401_not_409(server, alice): + data = record(server) + data["disabled"] = True + write_record(server, data) + assert recover(server, recover_body(alice, new_proof()))[0] == 401 + + +@pytest.mark.parametrize("change", [ + {"recovery_proof": None}, + {"recovery_proof": b64(os.urandom(31))}, + {"recovery_proof": b64(os.urandom(33))}, + {"recovery_proof": "%%%"}, + {"login_key": None}, + {"login_key": b64(os.urandom(31))}, + {"login_key": b64(os.urandom(33))}, + {"keyset": recovery_blob()}, # a recovery copy (kdf 0) + {"keyset": b64(os.urandom(100))}, + {"keyset": None}, + {"recovery": keyset_blob()}, # a keyset (kdf 1) + {"recovery": b64(os.urandom(100))}, + {"recovery": None}, + {"username": "../etc"}, + {"username": ""}, + {"username": None}, + {"username": 5}, +], ids=["no-proof", "short-proof", "long-proof", "bad-b64-proof", "no-key", + "short-key", "long-key", "keyset-is-recovery", "junk-keyset", + "no-keyset", "recovery-is-keyset", "junk-recovery", "no-recovery", + "bad-name", "empty-name", "null-name", "number-name"]) +def test_bad_input_is_400_and_changes_nothing(server, proved, change): + session = session_for(server, proved) + before = read_bytes(account_file(server)) + assert recover(server, recover_body(proved, proved.proof, **change))[0] == 400 + assert read_bytes(account_file(server)) == before + assert get_me(server, session)[0] == 200 + assert log_lines(server, "account_recovered") == [] + + +def test_bad_input_does_not_count_as_a_wrong_attempt(server, proved, small_lockout): + for _ in range(small_lockout + 2): + recover(server, recover_body(proved, proved.proof, login_key=None)) + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + + +def test_a_body_that_is_not_json_is_refused(server, proved): + status, _, _ = server.request("POST", "/v2/recovery", "not json", token=None) + assert status == 400 + + +# --- the failure log --------------------------------------------------------- + +def test_a_wrong_proof_is_logged_with_its_status(server, proved): + recover(server, recover_body(proved, new_proof())) + lines = log_lines(server, "recovery_failed") + assert len(lines) == 1 + assert lines[0]["by"] == "alice" and lines[0]["status"] == 401 + + +def test_an_account_without_a_proof_is_logged_as_409(server, alice): + recover(server, recover_body(alice, new_proof())) + assert [x["status"] for x in log_lines(server, "recovery_failed")] == [409] + + +def test_a_bad_request_is_logged_as_400(server, proved): + recover(server, recover_body(proved, proved.proof, login_key=None)) + assert [x["status"] for x in log_lines(server, "recovery_failed")] == [400] + + +def test_an_invalid_name_is_logged_as_a_question_mark(server, proved): + recover(server, recover_body(proved, proved.proof, username="../x")) + assert log_lines(server, "recovery_failed")[0]["by"] == "?" + + +def test_the_failure_log_holds_no_proof(server, proved): + wrong = new_proof() + recover(server, recover_body(proved, wrong)) + assert b64(wrong) not in logins_text(server) + + +# --- lock-out ---------------------------------------------------------------- + +def test_too_many_wrong_proofs_pause_recovery_even_for_the_right_one(server, proved, small_lockout): + for _ in range(small_lockout): + assert recover(server, recover_body(proved, new_proof()))[0] == 401 + status, _, body = recover(server, recover_body(proved, proved.proof)) + assert status == 429 and "minute" in body["error"] + assert log_lines(server, "recovery_failed")[-1]["status"] == 429 + + +def test_one_wrong_proof_fewer_than_the_limit_does_not_pause(server, proved, small_lockout): + for _ in range(small_lockout - 1): + recover(server, recover_body(proved, new_proof())) + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + + +def test_the_recovery_pause_ends(server, proved, small_lockout, monkeypatch): + for _ in range(small_lockout): + recover(server, recover_body(proved, new_proof())) + later = passman_server.time.time() + passman_server.LOCKOUT_SECONDS + 1 + monkeypatch.setattr(passman_server.time, "time", lambda: later) + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + + +def test_a_successful_recovery_clears_the_failure_count(server, proved, small_lockout): + for _ in range(small_lockout - 1): + recover(server, recover_body(proved, new_proof())) + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + for _ in range(small_lockout - 1): + recover(server, recover_body(proved, new_proof())) + assert recover(server, recover_body(proved, proved.proof))[0] == 200 + + +def test_wrong_logins_count_toward_the_recovery_pause(server, proved, small_lockout): + """Observed: logins and recoveries share one counter per name.""" + for _ in range(small_lockout): + login(server, proved, login_key=os.urandom(32)) + assert recover(server, recover_body(proved, proved.proof))[0] == 429 + + +def test_wrong_proofs_count_toward_the_login_pause(server, proved, small_lockout): + for _ in range(small_lockout): + recover(server, recover_body(proved, new_proof())) + assert login(server, proved)[0] == 429 + + +def test_the_pause_is_per_name(server, proved, small_lockout): + bob = Account("bob") + bob.proof = with_proof(server, bob) + for _ in range(small_lockout): + recover(server, recover_body(proved, new_proof())) + assert recover(server, recover_body(bob, bob.proof))[0] == 200 + + +def test_the_pause_ignores_letter_case_of_the_name(server, proved, small_lockout): + for _ in range(small_lockout): + recover(server, recover_body(proved, new_proof(), username="ALICE")) + assert recover(server, recover_body(proved, proved.proof))[0] == 429 + + +def test_a_name_with_no_account_can_also_be_paused(server, small_lockout): + ghost = Account("ghost") + for _ in range(small_lockout): + assert recover(server, recover_body(ghost, new_proof()))[0] == 401 + assert recover(server, recover_body(ghost, new_proof()))[0] == 429 + + +def test_an_all_zero_proof_does_not_open_an_account_without_a_proof(server, alice): + """The server compares unknown accounts against a hash of 32 zero bytes + (to take the same time); that must never let a zero proof in.""" + before = read_bytes(account_file(server)) + assert recover(server, recover_body(alice, bytes(32)))[0] == 409 + assert read_bytes(account_file(server)) == before + assert login(server, alice)[0] == 200 + + +def test_an_all_zero_proof_does_not_open_an_unknown_name(server): + assert recover(server, recover_body(Account("ghost"), bytes(32)))[0] == 401 + assert not os.path.exists(account_file(server, "ghost")) diff --git a/tests/test_server_v2_vaults.py b/tests/test_server_v2_vaults.py index 8060a60..231f2c3 100644 --- a/tests/test_server_v2_vaults.py +++ b/tests/test_server_v2_vaults.py @@ -74,7 +74,7 @@ def bob(server): def test_health_says_the_server_has_vaults(server): _, _, body = server.request("GET", "/health", token=None) - assert body["features"] == ["vaults", "sharing"] + assert body["features"] == ["vaults", "sharing", "recovery"] # --- create --------------------------------------------------------------------