diff --git a/README.md b/README.md index abe712e..2a111b0 100644 --- a/README.md +++ b/README.md @@ -505,6 +505,36 @@ The server replies: A first push may omit `If-Match`. `If-Match: *` overwrites whatever is there, which the client never sends — it exists for recovering by hand. +### API v2: SecretSpace 3 accounts (test version) + +Server **2.0.0-alpha** adds accounts for the SecretSpace 3 test versions +(Windows 3.0.0-alpha.3, Linux 1.0.0-alpha.3). It sits beside the routes +above, which stay exactly as they are for 2.x clients. The server still never +sees a master password, a Secret Key or a vault key: an account is the +client's **Login Key** (stored as a salted scrypt hash), its public key, and +its keyset and recovery copy, both sealed on the device. A vault is a blob +like a 2.x one, with the same revision, `If-Match` and history rules. + +| Route | Method | Who | Purpose | +| --- | --- | --- | --- | +| `/v2/prelogin/` | GET | anyone | the Argon2id salt and cost a device needs to turn the master password into keys. A name with no account gets a made-up, stable answer, so this says nothing about which names exist | +| `/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, keyset, recovery copy, its vaults | +| `/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 | + +Sessions are sent as `Authorization: Bearer `. The server's token +is not a session and opens no account; a session opens no 2.x vault. A vault +you are not a member of answers 404, the same as one that does not exist. + +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/`, +`sessions/` (stored hashed), `vaults//` and `server.key` (for the +made-up pre-login answers). + ## What it keeps Inside the store directory (`/var/lib/passman/store` with the service): diff --git a/server/passman_server.py b/server/passman_server.py index 47e7d98..e02e469 100644 --- a/server/passman_server.py +++ b/server/passman_server.py @@ -3,16 +3,20 @@ SecretSpace sync server -- a store for encrypted vault blobs. What it is NOT: it cannot read your passwords. The client encrypts the vault -with your master password before it ever reaches the network, so this process -holds AES-256-GCM ciphertext it has no key for. It never sees the master -password, and there is deliberately nothing here that could -- no user -accounts, no password reset, no "forgot my vault". +before it ever reaches the network, so this process holds AES-256-GCM +ciphertext it has no key for. It never sees a master password, a Secret Key or +a vault key, and there is deliberately nothing here that could -- no password +reset, no "forgot my vault". -What it does: stores one blob per user, stamps it with a revision number, and +What it does: stores vault blobs, stamps each with a revision number, and refuses a write that was computed against an older revision. That refusal is the whole point. Without it, two PCs that both edited offline would silently overwrite each other; with it, the second one is told to merge first. +Two APIs side by side: /vault/ with one shared token for SecretSpace +2.x clients, and /v2/ with accounts and sessions for SecretSpace 3 (see the +API v2 section below). + Standard library only, so it runs on any box with Python 3 and nothing to install. See README.md for deployment. """ @@ -28,6 +32,7 @@ import argparse import tempfile import threading +import time from datetime import datetime, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -189,6 +194,362 @@ def _archive(self, user: str, revision: int) -> None: pass +# =========================================================================== +# SecretSpace 3 accounts: API v2 +# =========================================================================== +# +# Everything above serves 2.x clients: one blob per username, one shared +# token. 3.0 clients get accounts instead. The server still never sees a +# master password, a Secret Key or a vault key: +# +# - an account is the client's Login Key (hashed here), its public key, and +# its keyset and recovery copy, both sealed on the device; +# - a vault is a blob exactly like a 2.x one, kept by the same Store with the +# same revision / If-Match / history rules, plus who may use it. +# +# It all lives in /_v2/. A 2.x username cannot start with "_", so no +# 2.x vault folder can ever collide with it. + +V2_DIR = "_v2" + +#: How long a login lasts. +SESSION_SECONDS = 12 * 3600 + +#: The Login Key is 32 bytes from HKDF (password + Secret Key), already far +#: beyond guessing, so the stored hash needs no slow KDF of its own -- scrypt +#: with modest cost only makes a stolen accounts folder useless to replay. +#: It is in the standard library, which keeps the server free of packages. +LOGIN_KEY_LEN = 32 +SCRYPT_N, SCRYPT_R, SCRYPT_P = 2 ** 14, 8, 1 + +#: Wrong logins per username before it is paused, the window they are counted +#: in, and the pause. The Login Key cannot be guessed; this is about noise. +MAX_LOGIN_FAILURES = 10 +FAILURE_WINDOW = 15 * 60 +LOCKOUT_SECONDS = 60 + +#: The client's Argon2id default, for the made-up answer to a pre-login for an +#: account that does not exist (see AccountStore.prelogin). +DEFAULT_KDF = {"memory_kib": 65536, "passes": 3, "lanes": 1} + +VAULT_ID = re.compile(r"^[0-9a-f]{32}$") +ROLES_THAT_WRITE = ("owner", "editor") + +#: The account files' layouts, from the client (account_crypto.py). +_KEYSET_MAGIC = b"PMK3" +_KEYSET_HEADER = 4 + 1 + 1 + 4 + 1 + 1 + 16 # magic ver kdf mem passes lanes salt +_VAULT_MAGIC = b"PMV3" + + +class V2Error(Exception): + """A refusal with its HTTP status and the message the user sees.""" + + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + + +def _unb64(text, what: str, length: int = None) -> bytes: + try: + raw = base64.b64decode(str(text).encode("ascii"), validate=True) + except Exception: + raise V2Error(400, f"{what} is not valid base64.") from None + if length is not None and len(raw) != length: + raise V2Error(400, f"{what} has the wrong length.") + return raw + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def keyset_kdf(sealed: str, recovery: bool = False) -> dict: + """ + The key-derivation header of a sealed keyset (what a new device needs to + turn a master password into the key that opens it). The rest is + ciphertext this server cannot and does not need to read. + """ + raw = _unb64(sealed, "The recovery copy" if recovery else "The keyset") + if len(raw) < _KEYSET_HEADER + 12 + 16 or raw[:4] != _KEYSET_MAGIC: + raise V2Error(400, "That is not a SecretSpace 3 keyset.") + kdf = raw[5] + if kdf != (0 if recovery else 1): + raise V2Error(400, "The keyset and its recovery copy are swapped.") + memory_kib = int.from_bytes(raw[6:10], "big") + return {"memory_kib": memory_kib, "passes": raw[10], "lanes": raw[11], + "salt": _b64(raw[12:28])} + + +def hash_login_key(login_key: bytes, salt: bytes = None) -> dict: + salt = salt or os.urandom(16) + digest = hashlib.scrypt(login_key, salt=salt, n=SCRYPT_N, r=SCRYPT_R, + p=SCRYPT_P, dklen=32) + return {"salt": _b64(salt), "hash": _b64(digest), "n": SCRYPT_N, + "r": SCRYPT_R, "p": SCRYPT_P} + + +def login_key_matches(login_key: bytes, stored: dict) -> bool: + try: + digest = hashlib.scrypt(login_key, salt=base64.b64decode(stored["salt"]), + n=int(stored["n"]), r=int(stored["r"]), + p=int(stored["p"]), dklen=32) + return hmac.compare_digest(digest, base64.b64decode(stored["hash"])) + except Exception: + return False + + +class AccountStore: + """Accounts, sessions and vaults of API v2, under /_v2.""" + + def __init__(self, store_root: str): + self.root = os.path.join(os.path.abspath(store_root), V2_DIR) + self.vaults = Store(os.path.join(self.root, "vaults")) + self._failures = {} # username (lower) -> [times] + self._failure_lock = threading.Lock() + + # -- files -------------------------------------------------------------- + def _account_path(self, username: str) -> str: + # One file per name regardless of case: "Alice" and "alice" are one + # account, as they are one folder on Windows. + return os.path.join(self.root, "accounts", username.lower() + ".json") + + def _members_path(self, vault_id: str) -> str: + return os.path.join(self.vaults.root, vault_id, "members.json") + + @staticmethod + def _read_json(path: str): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except FileNotFoundError: + return None + + @staticmethod + def _write_json(path: str, data) -> None: + write_atomic(path, json.dumps(data, indent=2, sort_keys=True).encode("utf-8")) + + def account(self, username: str): + """The stored account, or None. Matches the name exactly.""" + if not valid_username(username): + return None + record = self._read_json(self._account_path(username)) + if not record or record.get("username") != username: + return None + return record + + def _server_key(self) -> bytes: + path = os.path.join(self.root, "server.key") + try: + with open(path, "rb") as f: + key = f.read() + if len(key) == 32: + return key + except OSError: + pass + key = os.urandom(32) + write_atomic(path, key) + try: + os.chmod(path, 0o600) + except OSError: + pass + return key + + # -- pre-login ------------------------------------------------------------ + def prelogin(self, username: str) -> dict: + """ + How a device turns this account's master password into keys: the + Argon2id salt and cost from the stored keyset. For a name with no + account the answer is made up -- a salt derived from a key only this + server has, so it is the same on every ask -- and the login then simply + fails. That way the answer says nothing about which names exist. + """ + if not valid_username(username): + raise V2Error(400, "Not a valid username.") + record = self._read_json(self._account_path(username)) + if record: + return {"username": record["username"], "kdf": record["kdf"]} + salt = hmac.new(self._server_key(), b"prelogin|" + username.lower().encode(), + hashlib.sha256).digest()[:16] + return {"username": username, "kdf": {**DEFAULT_KDF, "salt": _b64(salt)}} + + # -- sign-up ------------------------------------------------------------ + def create_account(self, body: dict) -> dict: + username = body.get("username") + if not isinstance(username, str) or not valid_username(username): + raise V2Error(400, "Not a valid username.") + login_key = _unb64(body.get("login_key"), "The login key", LOGIN_KEY_LEN) + kdf = keyset_kdf(body.get("keyset")) + keyset_kdf(body.get("recovery"), recovery=True) + public_key = _unb64(body.get("public_key"), "The public key", 32) + vault_id = body.get("personal_vault") + if not isinstance(vault_id, str) or not VAULT_ID.match(vault_id): + raise V2Error(400, "The personal vault id is not valid.") + + with _lock: + if self._read_json(self._account_path(username)) is not None: + raise V2Error(409, f"There is already an account called " + f"“{username}” on this server.") + if os.path.exists(os.path.join(self.vaults.root, vault_id)): + raise V2Error(409, "That vault id is already in use.") + self._write_json(self._members_path(vault_id), { + "kind": "personal", "created_at": utc_now(), + "members": {username: {"role": "owner"}}}) + self._write_json(self._account_path(username), { + "username": username, + "created_at": utc_now(), + "login": hash_login_key(login_key), + "public_key": _b64(public_key), + "keyset": body["keyset"], + "recovery": body["recovery"], + "kdf": kdf, + "personal_vault": vault_id, + "disabled": False, + }) + return {"username": username, "personal_vault": vault_id} + + # -- logging in --------------------------------------------------------- + def _paused(self, username: str) -> bool: + now = time.time() + with self._failure_lock: + recent = [t for t in self._failures.get(username.lower(), []) + if now - t < FAILURE_WINDOW] + self._failures[username.lower()] = recent + return (len(recent) >= MAX_LOGIN_FAILURES + and now - recent[-1] < LOCKOUT_SECONDS) + + def _failed(self, username: str) -> None: + with self._failure_lock: + self._failures.setdefault(username.lower(), []).append(time.time()) + + def login(self, body: dict) -> dict: + username = body.get("username") + if not isinstance(username, str) or not valid_username(username): + raise V2Error(400, "Not a valid username.") + login_key = _unb64(body.get("login_key"), "The login key", LOGIN_KEY_LEN) + if self._paused(username): + raise V2Error(429, "Too many wrong attempts for this account. " + "Wait a minute, then try again.") + record = self.account(username) + # Hash even when there is no such account, so the answer takes as long + # either way and its timing does not tell which names exist. + stored = record["login"] if record else hash_login_key(b"\0" * 32) + ok = login_key_matches(login_key, stored) and record is not None + if not ok or record.get("disabled"): + self._failed(username) + raise V2Error(401, "Wrong username, master password or Secret Key.") + with self._failure_lock: + self._failures.pop(username.lower(), None) + token = base64.urlsafe_b64encode(os.urandom(32)).decode("ascii").rstrip("=") + self._write_json(self._session_path(token), { + "username": username, "expires": time.time() + SESSION_SECONDS}) + self._drop_expired_sessions() + return {"session": token, "username": username, + "expires_in": SESSION_SECONDS} + + # -- sessions ----------------------------------------------------------- + def _session_path(self, token: str) -> str: + # Stored under its hash: a copy of this folder logs nobody in. + name = hashlib.sha256(token.encode("utf-8")).hexdigest() + return os.path.join(self.root, "sessions", name + ".json") + + def session_user(self, token: str): + """The username a session token belongs to, or None.""" + if not token or len(token) > 200: + return None + try: + session = self._read_json(self._session_path(token)) + except (OSError, ValueError): + return None + if not session or session.get("expires", 0) < time.time(): + return None + record = self.account(session.get("username", "")) + if not record or record.get("disabled"): + return None + return record["username"] + + def logout(self, token: str) -> None: + try: + os.remove(self._session_path(token)) + except OSError: + pass + + def _drop_expired_sessions(self, keep_user: str = None, keep_token: str = None, + user_to_end: str = None) -> None: + """Remove expired sessions; with ``user_to_end``, also every session + of that user except ``keep_token``'s.""" + folder = os.path.join(self.root, "sessions") + keep = os.path.basename(self._session_path(keep_token)) if keep_token else None + now = time.time() + try: + names = os.listdir(folder) + except OSError: + return + for name in names: + path = os.path.join(folder, name) + try: + session = self._read_json(path) or {} + except (OSError, ValueError): + session = {} + ending = (user_to_end is not None and name != keep + and session.get("username") == user_to_end) + if ending or session.get("expires", 0) < now: + try: + os.remove(path) + except OSError: + pass + + # -- the account itself ------------------------------------------------- + def me(self, username: str) -> dict: + record = self.account(username) + vaults = [] + vault_id = record["personal_vault"] + members = self._read_json(self._members_path(vault_id)) or {} + meta = self.vaults.meta(vault_id) + vaults.append({"id": vault_id, "kind": members.get("kind", "personal"), + "role": members.get("members", {}).get(username, {}) + .get("role", "owner"), + "revision": meta["revision"], + "key_generation": meta.get("key_generation", 0)}) + return {"username": record["username"], "public_key": record["public_key"], + "keyset": record["keyset"], "recovery": record["recovery"], + "kdf": record["kdf"], "personal_vault": vault_id, + "vaults": vaults} + + def change_keyset(self, username: str, body: dict, keep_token: str) -> None: + """ + A new master password: the same keys under a new seal, and the Login + Key that goes with it. Every other session of the account ends -- a + password is changed when it may have leaked. + """ + login_key = _unb64(body.get("login_key"), "The login key", LOGIN_KEY_LEN) + kdf = keyset_kdf(body.get("keyset")) + with _lock: + record = self.account(username) + record.update({"login": hash_login_key(login_key), + "keyset": body["keyset"], "kdf": kdf, + "password_changed_at": utc_now()}) + self._write_json(self._account_path(username), record) + self._drop_expired_sessions(user_to_end=username, keep_token=keep_token) + + # -- vaults ------------------------------------------------------------- + def role(self, username: str, vault_id: str): + """This user's role in the vault, or None (also for no such vault).""" + if not VAULT_ID.match(vault_id or ""): + return None + members = self._read_json(self._members_path(vault_id)) or {} + member = members.get("members", {}).get(username) + return member.get("role") if member else None + + +def check_vault_blob(raw: bytes, vault_id: str) -> None: + """A v2 vault blob must be PMV3 and name the vault it is stored as, so a + confused client cannot put one vault's file in another's place.""" + if raw[:4] != _VAULT_MAGIC or len(raw) < 4 + 1 + 16 + 4 + 12 + 16: + raise V2Error(400, "That is not a SecretSpace 3 vault.") + if raw[5:21] != bytes.fromhex(vault_id): + raise V2Error(400, "That vault file belongs to another vault.") + + class Handler(BaseHTTPRequestHandler): server_version = "PassManSync/1.0" @@ -246,7 +607,10 @@ def _user_from_path(self): # -- routes ----------------------------------------------------------- def do_GET(self): if self.path.split("?")[0] == "/health": - return self._send(200, {"ok": True, "service": "passman-sync"}) + return self._send(200, {"ok": True, "service": "passman-sync", + "api": [1, 2]}) + if self._is_v2(): + return self._v2("GET") if not self._authorised(): return self._error(401, "Bad or missing token.") @@ -254,28 +618,64 @@ def do_GET(self): user, tail = self._user_from_path() if user is None: return self._error(404, "No such endpoint.") + return self._send_blob(self.server.store, user, tail) + + def do_PUT(self): + if self._is_v2(): + return self._v2("PUT") + if not self._authorised(): + return self._error(401, "Bad or missing token.") + + user, tail = self._user_from_path() + if user is None or tail: + return self._error(404, "No such endpoint.") + return self._store_blob(self.server.store, user) + + def do_POST(self): + if self._is_v2(): + return self._v2("POST") + # 2.x never used POST: answer as before, when there was no do_POST. + self.send_error(501, "Unsupported method ('POST')") - meta = self.server.store.meta(user) + # -- one blob, for 2.x users and v2 vaults alike ------------------------ + def _send_blob(self, store, name, tail): + meta = store.meta(name) if tail == "meta": return self._send(200, meta) - if not self.server.store.exists(user): + if not store.exists(name): # Not an error: it is how a client learns it is the first to push. return self._send(200, {**meta, "blob": None}) try: - blob = self.server.store.read(user) + blob = store.read(name) except OSError as e: return self._error(500, f"Could not read the stored vault: {e}") return self._send(200, {**meta, "blob": blob.decode("ascii")}) - def do_PUT(self): - if not self._authorised(): - return self._error(401, "Bad or missing token.") - - user, tail = self._user_from_path() - if user is None or tail: - return self._error(404, "No such endpoint.") - + def _read_body(self): + """The request's JSON body, or (None, already answered).""" + try: + length = int(self.headers.get("Content-Length", 0)) + except ValueError: + self._error(400, "Bad Content-Length.") + return None + if length <= 0: + self._error(400, "Empty body.") + return None + if length > MAX_BODY: + self._error(413, "That is far larger than a vault.") + return None + try: + payload = json.loads(self.rfile.read(length).decode("utf-8")) + except Exception: + self._error(400, "Body must be JSON.") + return None + if not isinstance(payload, dict): + self._error(400, "Body must be a JSON object.") + return None + return payload + + def _store_blob(self, store, name, check=None): try: length = int(self.headers.get("Content-Length", 0)) except ValueError: @@ -300,6 +700,11 @@ def do_PUT(self): return self._error(400, "Blob is not valid base64.") if len(raw) < MIN_BLOB_BYTES: return self._error(400, "Blob is too short to be a vault.") + if check is not None: + try: + check(raw) + except V2Error as e: + return self._error(e.status, str(e)) # Optional, and absent from clients older than password-change # support; the store then keeps what it had. @@ -317,7 +722,7 @@ def do_PUT(self): # One writer at a time: check-then-write has to be atomic, or two # simultaneous pushes could both pass the revision check. with _lock: - current = int(self.server.store.meta(user)["revision"]) + current = int(store.meta(name)["revision"]) expected = self.headers.get("If-Match", "").strip().strip('"') if not expected: @@ -336,13 +741,94 @@ def do_PUT(self): f"Pull and merge again.") try: - meta = self.server.store.write(user, blob, generation, - drop_history) + meta = store.write(name, blob, generation, drop_history) except OSError as e: return self._error(500, f"Could not store the vault: {e}") return self._send(200, meta) + # -- API v2: SecretSpace 3 accounts ------------------------------------- + def _is_v2(self) -> bool: + return self.path.split("?")[0].startswith("/v2/") + + @property + def accounts(self) -> AccountStore: + # Made on first use from the 2.x store, so a server set up in code + # (the tests, the clients' test copies) gets v2 without extra wiring. + found = getattr(self.server, "accounts", None) + if found is None: + found = AccountStore(self.server.store.root) + self.server.accounts = found + return found + + def _bearer(self) -> str: + header = self.headers.get("Authorization", "") + return header[len("Bearer "):] if header.startswith("Bearer ") else "" + + def _v2(self, method: str): + parts = [p for p in self.path.split("?")[0].split("/") if p][1:] + try: + return self._v2_route(method, parts) + except V2Error as e: + return self._error(e.status, str(e)) + + def _v2_route(self, method, parts): + accounts = self.accounts + route = (method, parts[0] if parts else "", len(parts)) + + # -- no login needed --------------------------------------------------- + if route == ("GET", "prelogin", 2): + return self._send(200, accounts.prelogin(parts[1])) + if route == ("POST", "login", 1): + body = self._read_body() + return None if body is None else self._send(200, accounts.login(body)) + if route == ("POST", "accounts", 1): + # The sign-up code is the server's token: whoever was given it may + # make an account here, as whoever has it may sync a 2.x vault. + if not self._authorised(): + return self._error(401, "Bad or missing sign-up code (the " + "server's token).") + body = self._read_body() + if body is None: + return None + return self._send(201, accounts.create_account(body)) + + # -- everything else needs a session ------------------------------- + token = self._bearer() + username = accounts.session_user(token) + if username is None: + return self._error(401, "Not logged in, or the login has expired.") + + if route == ("GET", "me", 1): + return self._send(200, accounts.me(username)) + if route == ("POST", "logout", 1): + accounts.logout(token) + return self._send(200, {}) + if route == ("PUT", "me", 2) and parts[1] == "keyset": + body = self._read_body() + if body is None: + return None + accounts.change_keyset(username, body, keep_token=token) + return self._send(200, {}) + if parts and parts[0] == "vaults" and len(parts) in (2, 3): + vault_id = parts[1] + tail = parts[2] if len(parts) == 3 else "" + role = accounts.role(username, vault_id) + # Not a member and no such vault answer alike, so a guess at an + # id learns nothing. + if role is None or tail not in ("", "meta"): + return self._error(404, "No such vault.") + if method == "GET": + return self._send_blob(accounts.vaults, vault_id, tail) + if method == "PUT" and not tail: + if role not in ROLES_THAT_WRITE: + return self._error(403, "You can read this vault but not " + "change it.") + return self._store_blob( + accounts.vaults, vault_id, + check=lambda raw: check_vault_blob(raw, vault_id)) + return self._error(404, "No such endpoint.") + def load_token(args) -> str: """ @@ -455,7 +941,6 @@ def bind(host, port, retry_seconds=0, sleep=None): yet -- a network that comes up after the server at boot -- is retried rather than ending the server; anything else still fails at once. """ - import time sleep = sleep or time.sleep warned = False while True: diff --git a/server/version.py b/server/version.py index 3aace2e..5db53da 100644 --- a/server/version.py +++ b/server/version.py @@ -7,4 +7,4 @@ in SecretSpace-Server-Windows, which has its own version. """ -__version__ = "1.5.0" +__version__ = "2.0.0-alpha.1" diff --git a/tests/test_server_http.py b/tests/test_server_http.py index 4e93323..0097c0f 100644 --- a/tests/test_server_http.py +++ b/tests/test_server_http.py @@ -107,7 +107,7 @@ def server(tmp_path): 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"} + assert body == {"ok": True, "service": "passman-sync", "api": [1, 2]} @pytest.mark.parametrize("header", [ diff --git a/tests/test_server_v2.py b/tests/test_server_v2.py new file mode 100644 index 0000000..2555b61 --- /dev/null +++ b/tests/test_server_v2.py @@ -0,0 +1,401 @@ +""" +API v2 over HTTP: SecretSpace 3 accounts, sessions and per-vault blobs. + +The server cannot decrypt anything, so these tests need no cryptography: +keysets and vaults are byte strings shaped like the client's (the right +magic and header, random "ciphertext"). The client's own tests check the +real thing end to end against a copy of this server. +""" + +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) +import passman_server # noqa: E402 + + +def b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def keyset_blob(salt: bytes = None, kdf: int = 1, memory_kib: int = 65536, + passes: int = 3, lanes: int = 1) -> str: + """Shaped like account_crypto.seal_keyset: header, nonce, ciphertext.""" + salt = salt if salt is not None else os.urandom(16) + header = struct.pack(">4sBBIBB16s", b"PMK3", 1, kdf, memory_kib, passes, + lanes, salt) + return b64(header + os.urandom(12) + os.urandom(80)) + + +def vault_blob(vault_id: str, generation: int = 0) -> str: + """Shaped like account_crypto.encrypt_vault.""" + header = struct.pack(">4sB16sI", b"PMV3", 1, bytes.fromhex(vault_id), + generation) + return b64(header + os.urandom(12) + os.urandom(64)) + + +class Account: + """What a client sends to make an account, kept to use it afterwards.""" + + def __init__(self, username="alice"): + self.username = username + self.login_key = os.urandom(32) + self.salt = os.urandom(16) + self.keyset = keyset_blob(self.salt) + self.recovery = keyset_blob(bytes(16), kdf=0, memory_kib=0, passes=0, + lanes=0) + self.public_key = os.urandom(32) + self.vault = os.urandom(16).hex() + + def signup_body(self, **changes): + body = {"username": self.username, "login_key": b64(self.login_key), + "keyset": self.keyset, "recovery": self.recovery, + "public_key": b64(self.public_key), + "personal_vault": self.vault} + body.update(changes) + return body + + +def signup(server, account, token=TOKEN, **changes): + return server.request("POST", "/v2/accounts", account.signup_body(**changes), + token=token) + + +def login(server, account, login_key=None, username=None): + return server.request("POST", "/v2/login", { + "username": username or account.username, + "login_key": b64(login_key or account.login_key)}, token=None) + + +def session_for(server, account) -> str: + status, _, body = login(server, account) + assert status == 200, body + return body["session"] + + +def put_vault(server, session, vault_id, blob, if_match=None): + headers = {} if if_match is None else {"If-Match": if_match} + return server.request("PUT", f"/v2/vaults/{vault_id}", {"blob": blob}, + headers, token=session) + + +@pytest.fixture +def alice(server): + account = Account("alice") + status, _, body = signup(server, account) + assert status == 201, body + return account + + +# --- pre-login --------------------------------------------------------------- + +def test_prelogin_gives_the_salt_and_cost_from_the_stored_keyset(server, alice): + status, _, body = server.request("GET", "/v2/prelogin/alice", token=None) + assert status == 200 + assert body["username"] == "alice" + assert body["kdf"] == {"salt": b64(alice.salt), "memory_kib": 65536, + "passes": 3, "lanes": 1} + + +def test_prelogin_gives_the_stored_spelling_of_the_name(server, alice): + """Keys are derived with the username, so a device must use its exact + spelling; the pre-login tells it.""" + _, _, body = server.request("GET", "/v2/prelogin/ALICE", token=None) + assert body["username"] == "alice" + + +def test_prelogin_for_no_such_account_looks_like_a_real_one(server, alice): + _, _, first = server.request("GET", "/v2/prelogin/mallory", token=None) + _, _, again = server.request("GET", "/v2/prelogin/mallory", token=None) + _, _, other = server.request("GET", "/v2/prelogin/eve", token=None) + assert first == again # stable: asking twice tells nothing + assert first["kdf"]["salt"] != other["kdf"]["salt"] + assert len(base64.b64decode(first["kdf"]["salt"])) == 16 + assert set(first["kdf"]) == {"salt", "memory_kib", "passes", "lanes"} + + +def test_prelogin_refuses_a_name_that_could_not_exist(server): + status, _, _ = server.request("GET", "/v2/prelogin/..%2Fetc", token=None) + assert status == 400 + + +# --- sign-up ----------------------------------------------------------------- + +@pytest.mark.parametrize("token", [None, "wrong-token-of-a-decent-length"]) +def test_signup_needs_the_servers_token(server, token): + status, _, _ = signup(server, Account(), token=token) + assert status == 401 + assert not os.path.exists(os.path.join(server.store.root, "_v2", "accounts")) + + +def test_signup_makes_the_account_and_its_personal_vault(server, alice): + session = session_for(server, alice) + status, _, me = server.request("GET", "/v2/me", token=session) + assert status == 200 + assert me["username"] == "alice" + assert me["keyset"] == alice.keyset and me["recovery"] == alice.recovery + assert me["public_key"] == b64(alice.public_key) + assert me["personal_vault"] == alice.vault + assert me["vaults"] == [{"id": alice.vault, "kind": "personal", + "role": "owner", "revision": 0, + "key_generation": 0}] + + +@pytest.mark.parametrize("name", ["alice", "Alice", "ALICE"]) +def test_a_name_is_taken_whatever_its_case(server, alice, name): + other = Account(name) + status, _, body = signup(server, other) + assert status == 409 + assert "already an account" in body["error"] + # ...and the first account is unchanged + assert login(server, alice)[0] == 200 + + +@pytest.mark.parametrize("change, reason", [ + ({"username": "../x"}, "username"), + ({"username": "_v2"}, "username"), + ({"login_key": b64(os.urandom(16))}, "login key"), + ({"login_key": "not base64!"}, "login key"), + ({"keyset": keyset_blob(kdf=0)}, "swapped"), + ({"recovery": keyset_blob(kdf=1)}, "swapped"), + ({"keyset": b64(b"PMV2" + os.urandom(80))}, "keyset"), + ({"public_key": b64(os.urandom(31))}, "public key"), + ({"personal_vault": "not-hex"}, "vault id"), + ({"personal_vault": "AB" * 16}, "vault id"), +]) +def test_signup_refuses_what_is_not_an_account(server, change, reason): + status, _, body = signup(server, Account(), **change) + assert status == 400, body + assert reason in body["error"].lower() + + +def test_two_accounts_cannot_claim_one_vault(server, alice): + other = Account("bob") + other.vault = alice.vault + status, _, _ = signup(server, other) + assert status == 409 + + +# --- logging in -------------------------------------------------------------- + +def test_a_wrong_login_key_and_no_such_account_answer_alike(server, alice): + wrong = login(server, alice, login_key=os.urandom(32)) + missing = login(server, Account("nobody")) + assert wrong[0] == missing[0] == 401 + assert wrong[2] == missing[2] + + +def test_login_wants_the_exact_spelling(server, alice): + assert login(server, alice, username="Alice")[0] == 401 + + +def test_a_disabled_account_cannot_log_in(server, alice): + path = os.path.join(server.store.root, "_v2", "accounts", "alice.json") + with open(path, encoding="utf-8") as f: + record = json.load(f) + session = session_for(server, alice) + record["disabled"] = True + with open(path, "w", encoding="utf-8") as f: + json.dump(record, f) + assert login(server, alice)[0] == 401 + # a session from before is no longer accepted either + assert server.request("GET", "/v2/me", token=session)[0] == 401 + + +def test_too_many_wrong_logins_pause_the_account(server, alice): + for _ in range(passman_server.MAX_LOGIN_FAILURES): + assert login(server, alice, login_key=os.urandom(32))[0] == 401 + # paused: even the right key waits + status, _, body = login(server, alice) + assert status == 429 and "minute" in body["error"] + + +def test_the_pause_ends(server, alice, monkeypatch): + for _ in range(passman_server.MAX_LOGIN_FAILURES): + login(server, alice, login_key=os.urandom(32)) + later = passman_server.time.time() + passman_server.LOCKOUT_SECONDS + 1 + monkeypatch.setattr(passman_server.time, "time", lambda: later) + assert login(server, alice)[0] == 200 + + +def test_nothing_on_disk_logs_anyone_in(server, alice): + """The login hash is not the Login Key, and sessions are stored hashed: + a copy of the store opens no account.""" + session = session_for(server, alice) + root = os.path.join(server.store.root, "_v2") + everything = b"" + for folder, _, files in os.walk(root): + for name in files: + with open(os.path.join(folder, name), "rb") as f: + everything += f.read() + name.encode() + assert alice.login_key not in everything + assert b64(alice.login_key).encode() not in everything + assert session.encode() not in everything + + +# --- sessions ---------------------------------------------------------------- + +@pytest.mark.parametrize("token", [None, "", TOKEN, "x" * 43]) +def test_v2_calls_need_a_session(server, alice, token): + """Not even the server's token opens an account.""" + assert server.request("GET", "/v2/me", token=token)[0] == 401 + assert server.request("GET", f"/v2/vaults/{alice.vault}", token=token)[0] == 401 + + +def test_a_session_expires(server, alice, monkeypatch): + session = session_for(server, alice) + later = passman_server.time.time() + passman_server.SESSION_SECONDS + 1 + monkeypatch.setattr(passman_server.time, "time", lambda: later) + assert server.request("GET", "/v2/me", token=session)[0] == 401 + + +def test_logout_ends_the_session(server, alice): + session = session_for(server, alice) + other = session_for(server, alice) + assert server.request("POST", "/v2/logout", {}, token=session)[0] == 200 + assert server.request("GET", "/v2/me", token=session)[0] == 401 + assert server.request("GET", "/v2/me", token=other)[0] == 200 + + +# --- the personal vault -------------------------------------------------------- + +def test_a_vault_follows_the_2x_revision_rules(server, alice): + session = session_for(server, alice) + status, _, body = server.request("GET", f"/v2/vaults/{alice.vault}", + token=session) + assert status == 200 and body["revision"] == 0 and body["blob"] is None + + first = vault_blob(alice.vault) + assert put_vault(server, session, alice.vault, first)[0] == 200 + # thereafter If-Match is required, and must be current + assert put_vault(server, session, alice.vault, vault_blob(alice.vault))[0] == 428 + assert put_vault(server, session, alice.vault, vault_blob(alice.vault), "0")[0] == 409 + second = vault_blob(alice.vault) + status, _, meta = put_vault(server, session, alice.vault, second, "1") + assert status == 200 and meta["revision"] == 2 + + status, _, body = server.request("GET", f"/v2/vaults/{alice.vault}", + token=session) + assert body["blob"] == second and body["revision"] == 2 + status, _, meta = server.request("GET", f"/v2/vaults/{alice.vault}/meta", + token=session) + assert meta["revision"] == 2 + history = os.path.join(server.store.root, "_v2", "vaults", alice.vault, + "history") + assert os.listdir(history) == ["00000001.enc"] + # the account's view follows + _, _, me = server.request("GET", "/v2/me", token=session) + assert me["vaults"][0]["revision"] == 2 + + +def test_a_vault_refuses_another_vaults_file(server, alice): + session = session_for(server, alice) + status, _, body = put_vault(server, session, alice.vault, + vault_blob(os.urandom(16).hex())) + assert status == 400 and "another vault" in body["error"] + + +def test_a_vault_refuses_a_2x_vault(server, alice): + session = session_for(server, alice) + blob = b64(b"PMV2" + os.urandom(80)) + status, _, body = put_vault(server, session, alice.vault, blob) + assert status == 400 and "SecretSpace 3" in body["error"] + + +def test_someone_elses_vault_is_not_there(server, alice): + bob = Account("bob") + assert signup(server, bob)[0] == 201 + bobs = session_for(server, bob) + alices = session_for(server, alice) + assert put_vault(server, alices, alice.vault, vault_blob(alice.vault))[0] == 200 + + missing = server.request("GET", f"/v2/vaults/{os.urandom(16).hex()}", token=bobs) + theirs = server.request("GET", f"/v2/vaults/{alice.vault}", token=bobs) + assert missing[0] == theirs[0] == 404 + assert missing[2] == theirs[2] # no hint that it exists + assert put_vault(server, bobs, alice.vault, vault_blob(alice.vault), "*")[0] == 404 + + +def test_a_reader_cannot_write(server, alice): + bob = Account("bob") + assert signup(server, bob)[0] == 201 + members = os.path.join(server.store.root, "_v2", "vaults", alice.vault, + "members.json") + with open(members, encoding="utf-8") as f: + record = json.load(f) + record["members"]["bob"] = {"role": "reader"} + with open(members, "w", encoding="utf-8") as f: + json.dump(record, f) + bobs = session_for(server, bob) + assert server.request("GET", f"/v2/vaults/{alice.vault}", token=bobs)[0] == 200 + status, _, body = put_vault(server, bobs, alice.vault, vault_blob(alice.vault)) + assert status == 403 and "read" in body["error"] + + +@pytest.mark.parametrize("path", ["/v2/vaults/../../accounts/alice", + "/v2/vaults/ABC", "/v2/vaults/x/y/z", + "/v2/nothing", "/v2/"]) +def test_paths_that_are_not_endpoints(server, alice, path): + session = session_for(server, alice) + assert server.request("GET", path, token=session)[0] == 404 + + +# --- changing the master password --------------------------------------------- + +def test_a_new_password_replaces_the_login_and_ends_other_sessions(server, alice): + this_device = session_for(server, alice) + other_device = session_for(server, alice) + new_key, new_salt = os.urandom(32), os.urandom(16) + new_keyset = keyset_blob(new_salt) + status, _, _ = server.request("PUT", "/v2/me/keyset", { + "login_key": b64(new_key), "keyset": new_keyset}, token=this_device) + assert status == 200 + + assert login(server, alice)[0] == 401 # old password + assert login(server, alice, login_key=new_key)[0] == 200 # new one + assert server.request("GET", "/v2/me", token=this_device)[0] == 200 + assert server.request("GET", "/v2/me", token=other_device)[0] == 401 + _, _, pre = server.request("GET", "/v2/prelogin/alice", token=None) + assert pre["kdf"]["salt"] == b64(new_salt) + _, _, me = server.request("GET", "/v2/me", token=this_device) + assert me["keyset"] == new_keyset + assert me["recovery"] == alice.recovery # untouched + + +def test_a_new_password_must_come_with_a_real_keyset(server, alice): + session = session_for(server, alice) + status, _, _ = server.request("PUT", "/v2/me/keyset", { + "login_key": b64(os.urandom(32)), "keyset": keyset_blob(kdf=0)}, + token=session) + assert status == 400 + assert login(server, alice)[0] == 200 # unchanged + + +# --- 2.x beside it -------------------------------------------------------------- + +def test_2x_sync_is_untouched_by_accounts(server, alice): + blob = b64(b"PMV2" + os.urandom(60)) + assert server.push("alice", blob)[0] == 200 # 2.x user "alice" + status, _, body = server.pull("alice") + assert body["blob"] == blob + # the two "alice"s are unrelated: the account's vault is still empty + session = session_for(server, alice) + _, _, mine = server.request("GET", f"/v2/vaults/{alice.vault}", token=session) + assert mine["blob"] is None + # and no 2.x name can reach the v2 folder + assert server.pull("_v2")[0] == 404 + + +def test_a_session_does_not_open_2x_vaults(server, alice): + session = session_for(server, alice) + assert server.request("GET", "/vault/alice", token=session)[0] == 401 + + +def test_health_says_which_apis(server): + _, _, body = server.request("GET", "/health", token=None) + assert body["api"] == [1, 2]