diff --git a/README.md b/README.md index 5da893f..07aac1e 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,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 `C:\ProgramData\SecretSpace Server\store`: diff --git a/builder/build.py b/builder/build.py index 2c893fa..fb8495a 100644 --- a/builder/build.py +++ b/builder/build.py @@ -241,6 +241,19 @@ def remove_previous(project: str) -> None: shutil.rmtree(folder) +def file_version(version: str) -> str: + """ + The four plain numbers Windows wants (Inno's VersionInfoVersion): + '2.0.0-alpha.1' -> '2.0.0.1', '1.2.0' -> '1.2.0.0'. The same rule as + version.file_version, which the exes' own version resource uses. + """ + m = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.(\d+))?", + version) + if not m: + raise ValueError(f"Not a SecretSpace version: {version!r}") + return ".".join(g or "0" for g in m.groups()) + + def build_steps(project: str, py: "Python", iscc: str, version: str) -> list: """The commands, as (label, cmd, the file it must produce): the server's folder build, then its installer.""" @@ -255,6 +268,7 @@ def build_steps(project: str, py: "Python", iscc: str, version: str) -> list: "PassMan-Server.exe")), ("Packing SecretSpace-Server-Installer.exe", [iscc, "/Q", f"/DAppVersion={version}", + f"/DFileVersion={file_version(version)}", os.path.join(project, "installer", "SecretSpace-Server.iss")], os.path.join(project, "SecretSpace-Server-Installer.exe")), ] diff --git a/builder/tests/test_build.py b/builder/tests/test_build.py index 182f032..c9d1e8d 100644 --- a/builder/tests/test_build.py +++ b/builder/tests/test_build.py @@ -122,7 +122,8 @@ def test_an_incomplete_folder_is_reported(tmp_path): # --- versions and paths ---------------------------------------------------- def test_version_is_read_from_version_py(): import re - assert re.fullmatch(r"\d+\.\d+\.\d+", build.app_version(PROJECT)) + assert re.fullmatch(r"\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?", + build.app_version(PROJECT)) def test_iscc_found_in_the_per_user_location(monkeypatch): @@ -153,7 +154,7 @@ def test_build_runs_the_steps_in_order(project): def test_only_the_server_is_built(): """The app has its own repository and its own build.""" - steps = build.build_steps("P", build.Python(["py"], "3", "p"), "iscc", "1") + steps = build.build_steps("P", build.Python(["py"], "3", "p"), "iscc", "1.0.0") products = [os.path.basename(product) for _, _, product in steps] assert products == ["PassMan-Server.exe", "SecretSpace-Server-Installer.exe"] @@ -180,3 +181,25 @@ def test_a_missing_product_counts_as_failure(project): log=lambda s: None, runner=FakeRunner(), exists=lambda p: False) assert not ok + + +@pytest.mark.parametrize("text, numbers", [ + ("1.2.0", "1.2.0.0"), ("2.0.0-alpha.1", "2.0.0.1"), ("2.0.0", "2.0.0.0"), + ("2.0.0-rc.3", "2.0.0.3")]) +def test_file_version(text, numbers): + assert build.file_version(text) == numbers + + +def test_file_version_refuses_junk(): + with pytest.raises(ValueError): + build.file_version("2.0.0-dev") + + +def test_the_installer_gets_a_plain_file_version(): + """Inno's VersionInfoVersion takes four numbers only; a test version's + own name (2.0.0-alpha.1) would stop the installer from compiling.""" + steps = build.build_steps("P", build.Python(["py"], "3", "p"), "iscc", + "2.0.0-alpha.1") + cmd = steps[1][1] + assert "/DAppVersion=2.0.0-alpha.1" in cmd and "/DFileVersion=2.0.0.1" in cmd + diff --git a/installer/SecretSpace-Server.iss b/installer/SecretSpace-Server.iss index f73a4c2..2c99a9a 100644 --- a/installer/SecretSpace-Server.iss +++ b/installer/SecretSpace-Server.iss @@ -25,6 +25,11 @@ #ifndef AppVersion #define AppVersion "0.0.0" #endif +; Four plain numbers: a test version's AppVersion (2.0.0-alpha.1) is not +; one, so the builder passes 2.0.0.1 here as well. +#ifndef FileVersion + #define FileVersion "0.0.0.0" +#endif [Setup] ; Never change AppId: it is how an upgrade finds the copy it replaces. It is @@ -33,7 +38,7 @@ AppId={{6C1E2B7A-5F3D-4C8E-9A41-2D7B0E93F615} AppName=SecretSpace Server AppVersion={#AppVersion} AppVerName=SecretSpace Server {#AppVersion} -VersionInfoVersion={#AppVersion} +VersionInfoVersion={#FileVersion} DefaultDirName={autopf}\SecretSpace Server DisableProgramGroupPage=yes PrivilegesRequired=admin diff --git a/server/build_version.py b/server/build_version.py index 4c9291c..1d323de 100644 --- a/server/build_version.py +++ b/server/build_version.py @@ -12,12 +12,12 @@ FixedFileInfo, StringFileInfo, StringStruct, StringTable, VarFileInfo, VarStruct, VSVersionInfo) -from version import __version__ +from version import __version__, file_version def version_info(description="SecretSpace sync server", filename="PassMan-Server.exe") -> VSVersionInfo: - numbers = tuple(int(part) for part in __version__.split(".")) + (0,) + numbers = file_version() return VSVersionInfo( ffi=FixedFileInfo(filevers=numbers, prodvers=numbers), kids=[ 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 fb742f5..1311ba5 100644 --- a/server/version.py +++ b/server/version.py @@ -5,6 +5,24 @@ builder, which passes it to Inno Setup for the installer. Bump it on release and tag the release commit v. It started at 1.0.0 and is separate from the app's and from the Linux server's. + +A test version carries a pre-release tag, ``2.0.0-alpha.1``. """ -__version__ = "1.2.0" +import re + +__version__ = "2.0.0-alpha.1" + +_VERSION = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.(\d+))?") + + +def file_version(text: str = __version__) -> tuple: + """ + The four plain numbers Windows wants for a file version: (2, 0, 0, 1) + for '2.0.0-alpha.1', (2, 0, 0, 0) for '2.0.0'. The same rule as the + client's version.file_version. + """ + m = _VERSION.fullmatch(text) + if not m: + raise ValueError(f"Not a SecretSpace version: {text!r}") + return tuple(int(g or 0) for g in m.groups()) 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]