diff --git a/README.md b/README.md index 6fa4442..ac59969 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, Linux -1.0.0-alpha) do not sync yet: they need accounts on the server, which come -with server 2.0. +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. This repository is the server for **Linux**, run by systemd. The other parts of SecretSpace have their own repositories: @@ -521,13 +521,20 @@ 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, keyset, recovery copy, its vaults | +| `/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/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 | | `/v2/vaults` | POST | session | make a further vault, owned by the caller (**201**): `{id, name, wrapped_key}`. **400** for a bad id, name or key, **409** if the id is in use, **403** past 50 vaults | | `/v2/vaults//name` | PUT | session, owner | change the vault's name (not for the personal vault) | -| `/v2/vaults/` | DELETE | session, owner | delete the vault (not the personal one, which goes with the account) | +| `/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/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**) | +| `/v2/vaults//rotate` | POST | session, owner | change the vault's key, and remove members in the same call: `{generation, remove, wrapped_keys, name, blob, revision}` (see below) | +| `/v2/vaults//log` | GET | session, owner | the vault's log: `{lines}`, oldest first, the newest 200 | 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 @@ -545,10 +552,58 @@ server may empty `_v2/deleted/` when sure. An account made by 2.0.0-alpha.1 needs no change. `/health` now also lists `"features": ["vaults"]`, so a client can tell whether a server has this. +Server **2.0.0-alpha.3** lets an account **share a vault** with other accounts +on the same server. It goes with the Windows 3.0.0-alpha.6 and Linux +1.0.0-alpha.6 test versions. Each member has a role: + +- **owner**: everything, including adding and removing members, changing the + vault's key, renaming or deleting it, and reading its log. A vault always + keeps at least one owner. +- **editor**: reads and uploads. +- **reader**: reads only. The server refuses a reader's upload (**403**), so + this does not depend on the app behaving. + +The server never holds a key it could hand out. The owner's device wraps the +vault key to the new member's public key, and that wrapped key is all the +server stores for them. To remove a member, an owner's device makes a **new +vault key** and sends, in one call to `rotate`, the vault encrypted under it, +its name sealed again, and the new key wrapped to every member who stays. +The server checks that the keys are for exactly the members who stay, that +the vault file is this vault at the next key generation, and that it was +made against the revision the server holds. It then applies everything +together; if any check fails, nothing changes. The earlier revisions in +`history/` are deleted, because they are under the old key, which the removed +member still has. From then on the server refuses an upload that is still +under the old key (**409**), so a device that missed the change has to sync +first. A member can leave a vault on their own at any time. + +The server keeps a log, one JSON line per event, only ever appended: + +- `_v2/vaults//audit.log` for each vault: the vault was created, renamed + or deleted, an upload (with its revision), a member was added, had their + role changed or left, and the key was changed (with the new generation and + who was removed). The vault's owners can read it with `GET + /v2/vaults//log`. +- `_v2/logins.log` for the whole server: logins, failed logins (with the + status), accounts made and passwords changed. Nothing in the API reads it; + read it on the server. + +Each line has the time, the event, the account that did it and the address +it came from. The log holds **no vault content, no keys and no names**: the +server cannot read those, so it cannot write them down. It does not record +who viewed or copied an entry either; that happens on the device and the +server never learns of it. A log file that reaches 2 MB is renamed to +`.1`, replacing the earlier one, and a new file is started. If a line +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"]`. + 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). +`sessions/` (stored hashed), `vaults//` (with its `members.json` and +`audit.log`), `logins.log` and `server.key` (for the made-up pre-login +answers). ## What it keeps diff --git a/server/passman_server.py b/server/passman_server.py index 0606bb9..aee97a7 100644 --- a/server/passman_server.py +++ b/server/passman_server.py @@ -33,6 +33,7 @@ import tempfile import threading import time +import urllib.parse from datetime import datetime, timezone from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -233,9 +234,24 @@ def _archive(self, user: str, revision: int) -> None: DEFAULT_KDF = {"memory_kib": 65536, "passes": 3, "lanes": 1} VAULT_ID = re.compile(r"^[0-9a-f]{32}$") +ROLES = ("owner", "editor", "reader") ROLES_THAT_WRITE = ("owner", "editor") -#: Vaults an account may own besides its personal one. Whoever holds the +#: People in one vault, and vaults one account may be in (its own and the +#: ones shared with it). Both only keep a mistake or a prank from growing +#: without end. +MAX_MEMBERS_PER_VAULT = 50 +MAX_VAULTS_LISTED = 200 + +#: The server's own record of what happened (who logged in, who uploaded, +#: who was added or removed): one JSON object per line, only ever appended. +#: A file that reaches this size is renamed to .1 (replacing the +#: previous .1) and a new one started. +AUDIT_ROTATE_BYTES = 2 * 1024 * 1024 +#: Lines an owner is sent when asking for a vault's log. +AUDIT_LINES_SENT = 200 + +#: Vaults an account may make besides its personal one. Whoever holds the #: sign-up code can make an account; this keeps one from filling the disk #: with empty vaults. MAX_VAULTS_PER_ACCOUNT = 50 @@ -320,6 +336,7 @@ def __init__(self, store_root: str): self.vaults = Store(os.path.join(self.root, "vaults")) self._failures = {} # username (lower) -> [times] self._failure_lock = threading.Lock() + self._audit_lock = threading.Lock() # -- files -------------------------------------------------------------- def _account_path(self, username: str) -> str: @@ -342,6 +359,51 @@ def _read_json(path: str): def _write_json(path: str, data) -> None: write_atomic(path, json.dumps(data, indent=2, sort_keys=True).encode("utf-8")) + # -- the audit log ---------------------------------------------------------- + def _audit_path(self, vault_id: str = None) -> str: + if vault_id is None: + return os.path.join(self.root, "logins.log") + return os.path.join(self.vaults.root, vault_id, "audit.log") + + def audit(self, vault_id, event: str, by: str, address: str = "", + **details) -> None: + """ + Append one line to a vault's log (``vault_id`` None: the log of + logins). Never raises: a full disk must not turn a finished upload + into an error -- the line is then simply missing. + """ + line = {"at": utc_now(), "event": event, "by": by, "address": address} + line.update(details) + path = self._audit_path(vault_id) + try: + with self._audit_lock: + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + if os.path.getsize(path) >= AUDIT_ROTATE_BYTES: + os.replace(path, path + ".1") + except OSError: + pass + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(line, sort_keys=True) + "\n") + except OSError: + pass + + def audit_lines(self, vault_id: str) -> dict: + """The newest lines of a vault's log, oldest first.""" + lines = [] + try: + with open(self._audit_path(vault_id), encoding="utf-8") as f: + for text in f.readlines()[-AUDIT_LINES_SENT:]: + try: + line = json.loads(text) + except ValueError: + continue + if isinstance(line, dict): + lines.append(line) + except OSError: + pass + return {"lines": lines} + def account(self, username: str): """The stored account, or None. Matches the name exactly.""" if not valid_username(username): @@ -395,6 +457,9 @@ def create_account(self, body: dict) -> dict: kdf = keyset_kdf(body.get("keyset")) keyset_kdf(body.get("recovery"), recovery=True) public_key = _unb64(body.get("public_key"), "The public key", 32) + signing_key = body.get("signing_key") + if signing_key is not None: + signing_key = _b64(_unb64(signing_key, "The signing 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.") @@ -413,6 +478,7 @@ def create_account(self, body: dict) -> dict: "created_at": utc_now(), "login": hash_login_key(login_key), "public_key": _b64(public_key), + "signing_key": signing_key or "", "keyset": body["keyset"], "recovery": body["recovery"], "kdf": kdf, @@ -524,7 +590,8 @@ def _vault_view(self, username: str, vault_id: str): view = {"id": vault_id, "kind": members.get("kind", "personal"), "role": member.get("role", "owner"), "revision": meta["revision"], - "key_generation": meta.get("key_generation", 0)} + "key_generation": int(members.get("key_generation", 0)), + "members": len(members.get("members", {}))} # The personal vault's key is in the keyset and it has no name; every # other vault carries its key wrapped to this member, and its name # sealed under that key. @@ -542,6 +609,7 @@ def me(self, username: str) -> dict: if view is not None: vaults.append(view) return {"username": record["username"], "public_key": record["public_key"], + "signing_key": record.get("signing_key", ""), "keyset": record["keyset"], "recovery": record["recovery"], "kdf": record["kdf"], "personal_vault": vault_id, "vaults": vaults} @@ -562,6 +630,45 @@ def change_keyset(self, username: str, body: dict, keep_token: str) -> None: self._write_json(self._account_path(username), record) self._drop_expired_sessions(user_to_end=username, keep_token=keep_token) + def set_signing_key(self, username: str, body: dict) -> None: + """ + The public half of the key an account signs its changes with. Set + once: an account made before signing existed sends it at its next + login, and from then on it cannot be swapped -- not even by someone + holding a session of the account. + """ + key = _b64(_unb64(body.get("signing_key"), "The signing key", 32)) + with _lock: + record = self.account(username) + had = record.get("signing_key", "") + if had and had != key: + raise V2Error(409, "This account already has a signing key; " + "it cannot be replaced.") + if not had: + record["signing_key"] = key + self._write_json(self._account_path(username), record) + + 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.""" + if not isinstance(name, str) or not valid_username(name): + return None + record = self._read_json(self._account_path(name)) + if not record or record.get("disabled"): + return None + return record + + def public_keys(self, name: str) -> dict: + """What is needed to share a vault with an account and to check what + it signed: its public keys, and its name as it spells it.""" + record = self.find_account(name) + if record is None: + raise V2Error(404, f"There is no account called “{name}” on this " + "server.") + return {"username": record["username"], + "public_key": record["public_key"], + "signing_key": record.get("signing_key", "")} + # -- vaults ------------------------------------------------------------- @staticmethod def _sealed_name(body: dict) -> str: @@ -589,14 +696,17 @@ def create_vault(self, username: str, body: dict) -> dict: with _lock: record = self.account(username) mine = list(record.get("vaults", [])) - if len(mine) >= MAX_VAULTS_PER_ACCOUNT: + made = sum(1 for each in mine + if (self._read_json(self._members_path(each)) or {}) + .get("created_by") == username) + if made >= MAX_VAULTS_PER_ACCOUNT or len(mine) >= MAX_VAULTS_LISTED: raise V2Error(403, f"An account can have {MAX_VAULTS_PER_ACCOUNT} " "vaults besides its personal one.") 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": "shared", "created_at": utc_now(), - "created_by": username, "name": name, + "created_by": username, "name": name, "key_generation": 0, "members": {username: {"role": "owner", "wrapped_key": wrapped}}}) record["vaults"] = mine + [vault_id] self._write_json(self._account_path(username), record) @@ -636,6 +746,162 @@ def delete_vault(self, username: str, vault_id: str) -> None: os.replace(os.path.join(self.vaults.root, vault_id), os.path.join(graveyard, f"{vault_id}-{stamp}")) + # -- members ------------------------------------------------------------ + def _shared(self, vault_id: str) -> dict: + """A vault's members file, for a change to who is in it.""" + members = self._read_json(self._members_path(vault_id)) or {} + if members.get("kind") == "personal": + raise V2Error(400, "A personal vault cannot be shared; make " + "another vault for that.") + return members + + @staticmethod + def _owners(members: dict) -> list: + return [n for n, m in members.get("members", {}).items() + if m.get("role") == "owner"] + + def members(self, vault_id: str) -> dict: + """Who is in a vault -- every member may know.""" + members = self._read_json(self._members_path(vault_id)) or {} + return {"key_generation": int(members.get("key_generation", 0)), + "members": [ + {"username": name, "role": m.get("role", "owner"), + "added_by": m.get("added_by", ""), + "added_at": m.get("added_at", "")} + for name, m in sorted(members.get("members", {}).items(), + key=lambda kv: kv[0].lower())]} + + def put_member(self, actor: str, vault_id: str, name: str, body: dict) -> dict: + """ + Add an account to a vault, or change a member's role (owners only; + the caller has checked). A new member comes with the vault key + wrapped to their public key by the owner's device -- the server never + holds a key it could hand out itself. Returns {"username", "role", + "added": bool}. + """ + role = body.get("role") + if role not in ROLES: + raise V2Error(400, "The role must be owner, editor or reader.") + with _lock: + members = self._shared(vault_id) + people = members.setdefault("members", {}) + target = self.find_account(name) + if target is None: + raise V2Error(404, f"There is no account called “{name}” on " + "this server.") + who = target["username"] + if who in people: + if people[who].get("role") == "owner" and role != "owner" \ + and self._owners(members) == [who]: + raise V2Error(409, "A vault needs at least one owner.") + people[who]["role"] = role + self._write_json(self._members_path(vault_id), members) + return {"username": who, "role": role, "added": False} + wrapped = _b64(_unb64(body.get("wrapped_key"), "The vault key", + WRAPPED_KEY_LEN)) + if len(people) >= MAX_MEMBERS_PER_VAULT: + raise V2Error(403, f"A vault can have {MAX_MEMBERS_PER_VAULT} " + "members.") + listed = list(target.get("vaults", [])) + if len(listed) >= MAX_VAULTS_LISTED: + raise V2Error(409, f"“{who}” is in too many vaults already.") + people[who] = {"role": role, "wrapped_key": wrapped, + "added_by": actor, "added_at": utc_now()} + self._write_json(self._members_path(vault_id), members) + target["vaults"] = listed + [vault_id] + self._write_json(self._account_path(who), target) + return {"username": who, "role": role, "added": True} + + def _unlist(self, name: str, vault_id: str) -> None: + record = self.account(name) + if record and vault_id in record.get("vaults", []): + record["vaults"] = [v for v in record["vaults"] if v != vault_id] + self._write_json(self._account_path(name), record) + + def leave_vault(self, username: str, vault_id: str) -> None: + """A member takes themself out of a vault. (An owner removes someone + else with :meth:`rotate`, which changes the key in the same step.)""" + with _lock: + members = self._shared(vault_id) + people = members.get("members", {}) + if self._owners(members) == [username]: + raise V2Error(409, "You are this vault's only owner: make " + "someone else an owner first, or delete " + "the vault.") + people.pop(username, None) + self._write_json(self._members_path(vault_id), members) + self._unlist(username, vault_id) + + def rotate(self, actor: str, vault_id: str, body: dict) -> dict: + """ + Give a vault a new key, optionally removing members in the same step + (owners only; the caller has checked). The owner's device made the + key; what arrives is the vault re-encrypted under it, its name sealed + again, and the key wrapped to every member who stays. It all changes + together or not at all: + + - ``generation`` must be the next one; + - ``wrapped_keys`` must name exactly the members who stay; + - ``blob`` must be this vault at that generation, made against the + revision the server holds (``revision``), or absent while the + vault has never been uploaded. + + Earlier revisions are dropped: they are under the old key, which the + removed members still have. + """ + with _lock: + members = self._shared(vault_id) + people = members.get("members", {}) + generation = int(members.get("key_generation", 0)) + 1 + if body.get("generation") != generation: + raise V2Error(409, "The vault's key was changed in the " + "meantime. Sync, then try again.") + removed = body.get("remove") or [] + if not isinstance(removed, list) or actor in removed or \ + any(name not in people for name in removed): + raise V2Error(400, "The members to remove are not valid.") + staying = [name for name in people if name not in removed] + wrapped = body.get("wrapped_keys") + if not isinstance(wrapped, dict) or sorted(wrapped) != sorted(staying): + raise V2Error(409, "The members of this vault changed in the " + "meantime. Sync, then try again.") + clean = {name: _b64(_unb64(wrapped[name], "A vault key", + WRAPPED_KEY_LEN)) for name in staying} + name = self._sealed_name(body) + + current = int(self.vaults.meta(vault_id)["revision"]) + blob = body.get("blob") + if blob is None: + if current != 0: + raise V2Error(400, "The vault under its new key is missing.") + else: + if body.get("revision") != current: + raise V2Error(409, f"Vault has moved on: server is at " + f"revision {current}. Sync, then try " + "again.") + try: + raw = base64.b64decode(str(blob).encode("ascii"), validate=True) + except Exception: + raise V2Error(400, "Blob is not valid base64.") from None + check_vault_blob(raw, vault_id, generation) + self.vaults.write(vault_id, str(blob).encode("ascii"), + generation, drop_history=True) + + for gone in removed: + del people[gone] + for each in staying: + people[each]["wrapped_key"] = clean[each] + members["key_generation"] = generation + members["name"] = name + self._write_json(self._members_path(vault_id), members) + for gone in removed: + self._unlist(gone, vault_id) + return self._vault_view(actor, vault_id) + + def key_generation(self, vault_id: str) -> int: + members = self._read_json(self._members_path(vault_id)) or {} + return int(members.get("key_generation", 0)) + 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 ""): @@ -645,13 +911,20 @@ def role(self, username: str, vault_id: str): return member.get("role") if member else None -def check_vault_blob(raw: bytes, vault_id: str) -> None: +def check_vault_blob(raw: bytes, vault_id: str, generation: int = None) -> 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.""" + confused client cannot put one vault's file in another's place. With + ``generation`` it must also say it is under the vault's current key: a + device that missed a key change must sync before it uploads, or it would + put back a copy that the removed members can read.""" 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.") + if generation is not None and \ + int.from_bytes(raw[21:25], "big") != generation: + raise V2Error(409, "This vault's key was changed on another device. " + "Sync again to get the new key.") class Handler(BaseHTTPRequestHandler): @@ -714,8 +987,11 @@ def do_GET(self): # "features" lets a 3.0 client say "this server is too old for # 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). return self._send(200, {"ok": True, "service": "passman-sync", - "api": [1, 2], "features": ["vaults"]}) + "api": [1, 2], + "features": ["vaults", "sharing"]}) if self._is_v2(): return self._v2("GET") @@ -788,7 +1064,7 @@ def _read_body(self): return None return payload - def _store_blob(self, store, name, check=None): + def _store_blob(self, store, name, check=None, stored=None): try: length = int(self.headers.get("Content-Length", 0)) except ValueError: @@ -858,6 +1134,8 @@ def _store_blob(self, store, name, check=None): except OSError as e: return self._error(500, f"Could not store the vault: {e}") + if stored is not None: + stored(meta) return self._send(200, meta) # -- API v2: SecretSpace 3 accounts ------------------------------------- @@ -879,7 +1157,11 @@ def _bearer(self) -> str: 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:] + # Names in a path arrive percent-encoded ("a%20b"). Whatever a part + # decodes to, it is only ever compared with fixed words or checked as + # a username or a vault id before it is used. + parts = [urllib.parse.unquote(p) + for p in self.path.split("?")[0].split("/") if p][1:] try: return self._v2_route(method, parts) except V2Error as e: @@ -892,9 +1174,21 @@ def _v2_route(self, method, parts): # -- no login needed --------------------------------------------------- if route == ("GET", "prelogin", 2): return self._send(200, accounts.prelogin(parts[1])) + address = self.client_address[0] if route == ("POST", "login", 1): body = self._read_body() - return None if body is None else self._send(200, accounts.login(body)) + if body is None: + return None + tried = body.get("username") + tried = tried if isinstance(tried, str) and valid_username(tried) else "?" + try: + reply = accounts.login(body) + except V2Error as e: + accounts.audit(None, "login_failed", tried, address, + status=e.status) + raise + accounts.audit(None, "login", reply["username"], address) + return self._send(200, reply) 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. @@ -904,7 +1198,9 @@ def _v2_route(self, method, parts): body = self._read_body() if body is None: return None - return self._send(201, accounts.create_account(body)) + made = accounts.create_account(body) + accounts.audit(None, "account_created", made["username"], address) + return self._send(201, made) # -- everything else needs a session ------------------------------- token = self._bearer() @@ -922,46 +1218,132 @@ def _v2_route(self, method, parts): if body is None: return None accounts.change_keyset(username, body, keep_token=token) + accounts.audit(None, "password_changed", username, address) return self._send(200, {}) + if route == ("PUT", "me", 2) and parts[1] == "signing-key": + body = self._read_body() + if body is None: + return None + accounts.set_signing_key(username, body) + 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): body = self._read_body() if body is None: return None - return self._send(201, accounts.create_vault(username, body)) - if parts and parts[0] == "vaults" and len(parts) in (2, 3): + made = accounts.create_vault(username, body) + accounts.audit(made["id"], "vault_created", username, address) + return self._send(201, made) + if parts and parts[0] == "vaults" and len(parts) in (2, 3, 4): vault_id = parts[1] - tail = parts[2] if len(parts) == 3 else "" + 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", "name"): + if role is None or tail not in ("", "meta", "name", "members", + "rotate", "log"): return self._error(404, "No such vault.") - if tail == "name" or method == "DELETE": - # The vault itself, not what is in it: its owners' business. - if (tail, method) not in (("name", "PUT"), ("", "DELETE")): - return self._error(404, "No such endpoint.") - if role != "owner": + if len(parts) == 4 and tail != "members": + return self._error(404, "No such endpoint.") + owners_only = self._v2_vault_owner_call( + method, tail, len(parts), parts[3] if len(parts) == 4 else "", + username) + if owners_only is not None: + if role != "owner" and not owners_only["anyone"]: return self._error(403, "Only an owner of this vault can " "do that.") - if method == "DELETE": - accounts.delete_vault(username, vault_id) - return self._send(200, {}) - body = self._read_body() - if body is None: - return None - return self._send(200, accounts.rename_vault(username, vault_id, - body)) - if method == "GET": + return owners_only["run"](accounts, vault_id, username, address) + if tail == "members" and method == "GET" and len(parts) == 3: + return self._send(200, accounts.members(vault_id)) + if method == "GET" and tail in ("", "meta") and len(parts) <= 3: 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.") + generation = accounts.key_generation(vault_id) return self._store_blob( accounts.vaults, vault_id, - check=lambda raw: check_vault_blob(raw, vault_id)) + check=lambda raw: check_vault_blob(raw, vault_id, generation), + stored=lambda meta: accounts.audit( + vault_id, "upload", username, address, + revision=meta["revision"])) return self._error(404, "No such endpoint.") + def _v2_vault_owner_call(self, method, tail, count, member, username): + """ + The calls about a vault itself rather than what is in it -- its + name, its members, its key, its log, deleting it. Returns None for + anything else, or {"run": the call, "anyone": may a member who is + not an owner make it}. + """ + def body_then(action): + def run(accounts, vault_id, actor, address): + body = self._read_body() + if body is None: + return None + return action(accounts, vault_id, actor, address, body) + return run + + def rename(accounts, vault_id, actor, address, body): + view = accounts.rename_vault(actor, vault_id, body) + accounts.audit(vault_id, "vault_renamed", actor, address) + return self._send(200, view) + + def delete(accounts, vault_id, actor, address): + # Logged first: the line then leaves with the vault's folder. + accounts.audit(vault_id, "vault_deleted", actor, address) + accounts.delete_vault(actor, vault_id) + return self._send(200, {}) + + def put_member(accounts, vault_id, actor, address, body): + done = accounts.put_member(actor, vault_id, member, body) + accounts.audit(vault_id, + "member_added" if done["added"] else "member_role", + actor, address, member=done["username"], + role=done["role"]) + return self._send(200, done) + + def leave(accounts, vault_id, actor, address): + accounts.leave_vault(actor, vault_id) + accounts.audit(vault_id, "member_left", actor, address) + return self._send(200, {}) + + def rotate(accounts, vault_id, actor, address, body): + view = accounts.rotate(actor, vault_id, body) + accounts.audit(vault_id, "key_changed", actor, address, + generation=view["key_generation"], + removed=list(body.get("remove") or [])) + return self._send(200, view) + + def log(accounts, vault_id, actor, address): + return self._send(200, accounts.audit_lines(vault_id)) + + if (method, tail, count) == ("PUT", "name", 3): + return {"run": body_then(rename), "anyone": False} + if (method, tail, count) == ("DELETE", "", 2): + return {"run": delete, "anyone": False} + if (method, tail, count) == ("PUT", "members", 4): + return {"run": body_then(put_member), "anyone": False} + if (method, tail, count) == ("DELETE", "members", 4): + # Only yourself; an owner removes others by changing the key. + if member != username: + return {"run": lambda *a: self._error( + 400, "Remove a member by changing the vault's key " + "(rotate), so they cannot read what is written " + "afterwards."), "anyone": False} + return {"run": leave, "anyone": True} + if (method, tail, count) == ("POST", "rotate", 3): + return {"run": body_then(rotate), "anyone": False} + if (method, tail, count) == ("GET", "log", 3): + return {"run": log, "anyone": False} + if tail in ("name", "rotate", "log") or method == "DELETE" \ + or (tail == "members" and (count == 4 or method != "GET")): + return {"run": lambda *a: self._error(404, "No such endpoint."), + "anyone": True} + return None + def load_token(args) -> str: """ diff --git a/server/version.py b/server/version.py index 7a49574..03bb919 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.2" +__version__ = "2.0.0-alpha.3" diff --git a/tests/test_server_http.py b/tests/test_server_http.py index d372559..56d5e36 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"]} + "features": ["vaults", "sharing"]} @pytest.mark.parametrize("header", [ diff --git a/tests/test_server_v2.py b/tests/test_server_v2.py index 2555b61..267e1ee 100644 --- a/tests/test_server_v2.py +++ b/tests/test_server_v2.py @@ -143,7 +143,7 @@ def test_signup_makes_the_account_and_its_personal_vault(server, alice): assert me["personal_vault"] == alice.vault assert me["vaults"] == [{"id": alice.vault, "kind": "personal", "role": "owner", "revision": 0, - "key_generation": 0}] + "key_generation": 0, "members": 1}] @pytest.mark.parametrize("name", ["alice", "Alice", "ALICE"]) diff --git a/tests/test_server_v2_sharing.py b/tests/test_server_v2_sharing.py new file mode 100644 index 0000000..ad2e183 --- /dev/null +++ b/tests/test_server_v2_sharing.py @@ -0,0 +1,1144 @@ +""" +API v2, phase 4: sharing a vault, roles, key change and the audit log, over +HTTP. + +As in the other v2 test files, the server cannot read what it stores, so keys, +names and vault files are byte strings of the right shape. The tests that +refuse a request also check that nothing changed on disk: a half-done key +change would be worse than none. +""" + +import os +import json +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, put_vault, session_for, signup, vault_blob) +from test_server_v2_vaults import (bob, create, create_body, made, me, # noqa: F401 + new_id, sealed_name, vault_dir, wrapped_key) +import passman_server # noqa: E402 + + +@pytest.fixture +def carol(server): + account = Account("Carol") + assert signup(server, account)[0] == 201 + return account + + +class Team: + """alice owns a vault; bob is an editor in it, Carol a reader.""" + + +@pytest.fixture +def team(server, alice, bob, carol): + t = Team() + t.alice, t.bob, t.carol = alice, bob, carol + t.sa = session_for(server, alice) + t.sb = session_for(server, bob) + t.sc = session_for(server, carol) + t.view, t.sent = made(server, t.sa) + t.vid = t.view["id"] + assert put_member(server, t.sa, t.vid, "bob", "editor")[0] == 200 + assert put_member(server, t.sa, t.vid, "Carol", "reader")[0] == 200 + return t + + +@pytest.fixture +def team_up(server, team): + """The same team, after three uploads (revision 3, two in history).""" + assert put_vault(server, team.sa, team.vid, vault_blob(team.vid))[0] == 200 + for rev in ("1", "2"): + assert put_vault(server, team.sa, team.vid, vault_blob(team.vid), + if_match=rev)[0] == 200 + return team + + +# --- helpers ------------------------------------------------------------------- + +def put_member(server, session, vault_id, name, role, wrapped="new", **changes): + body = {"role": role} + if wrapped == "new": + body["wrapped_key"] = wrapped_key() + elif wrapped is not None: + body["wrapped_key"] = wrapped + body.update(changes) + body = {k: v for k, v in body.items() if v is not None} + status, _, answer = server.request( + "PUT", f"/v2/vaults/{vault_id}/members/{name}", body, token=session) + return status, answer + + +def members(server, session, vault_id): + return server.request("GET", f"/v2/vaults/{vault_id}/members", token=session) + + +def api_log(server, session, vault_id): + return server.request("GET", f"/v2/vaults/{vault_id}/log", token=session) + + +def my_vault(server, session, vault_id): + """This account's view of one vault in /v2/me, or None.""" + for each in me(server, session)["vaults"]: + if each["id"] == vault_id: + return each + return None + + +def file_lines(path): + with open(path, encoding="utf-8") as f: + return [json.loads(line) for line in f if line.strip()] + + +def vault_log_path(server, vault_id): + return os.path.join(vault_dir(server, vault_id), "audit.log") + + +def logins_path(server): + return os.path.join(server.store.root, "_v2", "logins.log") + + +def state(server, vault_id, *names): + """Every file of a vault (except its log) and of the named accounts.""" + out = {} + base = vault_dir(server, vault_id) + for root, _, files in os.walk(base): + for each in files: + if each == "audit.log": + continue + path = os.path.join(root, each) + with open(path, "rb") as f: + out[os.path.relpath(path, base)] = f.read() + for name in names: + path = os.path.join(server.store.root, "_v2", "accounts", + name.lower() + ".json") + with open(path, "rb") as f: + out["account:" + name] = f.read() + return out + + +def rotate_body(vault_id, staying, remove=(), generation=1, revision=3): + body = {"generation": generation, + "wrapped_keys": {name: wrapped_key() for name in staying}, + "name": sealed_name(), + "blob": vault_blob(vault_id, generation), + "revision": revision} + if remove: + body["remove"] = list(remove) + return body + + +def post_rotate(server, session, vault_id, body): + status, _, answer = server.request( + "POST", f"/v2/vaults/{vault_id}/rotate", body, token=session) + return status, answer + + +# --- /health and the account's keys ---------------------------------------------- + +def test_health_lists_sharing(server): + _, _, body = server.request("GET", "/health", token=None) + assert "sharing" in body["features"] + + +def test_me_has_an_empty_signing_key_until_one_is_set(server, alice): + assert me(server, session_for(server, alice))["signing_key"] == "" + + +def test_signup_may_carry_a_signing_key(server): + key = os.urandom(32) + account = Account("dora") + assert signup(server, account, signing_key=b64(key))[0] == 201 + assert me(server, session_for(server, account))["signing_key"] == b64(key) + + +@pytest.mark.parametrize("key", [b64(os.urandom(31)), b64(os.urandom(33)), + "not base64 !!"]) +def test_signup_refuses_a_bad_signing_key_and_makes_no_account(server, key): + account = Account("dora") + assert signup(server, account, signing_key=key)[0] == 400 + assert login(server, account)[0] == 401 + + +def test_a_signing_key_can_be_set_later(server, alice): + session = session_for(server, alice) + key = b64(os.urandom(32)) + status, _, _ = server.request("PUT", "/v2/me/signing-key", + {"signing_key": key}, token=session) + assert status == 200 + assert me(server, session)["signing_key"] == key + + +def test_setting_the_same_signing_key_again_is_fine(server, alice): + session = session_for(server, alice) + key = b64(os.urandom(32)) + for _ in range(2): + status, _, _ = server.request("PUT", "/v2/me/signing-key", + {"signing_key": key}, token=session) + assert status == 200 + + +def test_a_different_signing_key_is_refused_and_the_first_stays(server, alice): + session = session_for(server, alice) + first = b64(os.urandom(32)) + server.request("PUT", "/v2/me/signing-key", {"signing_key": first}, + token=session) + status, _, _ = server.request("PUT", "/v2/me/signing-key", + {"signing_key": b64(os.urandom(32))}, + token=session) + assert status == 409 + assert me(server, session)["signing_key"] == first + + +@pytest.mark.parametrize("key", [b64(os.urandom(16)), b64(os.urandom(64)), + "not base64 !!", None]) +def test_a_signing_key_of_the_wrong_shape_is_refused(server, alice, key): + session = session_for(server, alice) + status, _, _ = server.request("PUT", "/v2/me/signing-key", + {"signing_key": key}, token=session) + assert status == 400 + assert me(server, session)["signing_key"] == "" + + +def test_setting_a_signing_key_needs_a_session(server, alice): + status, _, _ = server.request("PUT", "/v2/me/signing-key", + {"signing_key": b64(os.urandom(32))}, + token=None) + assert status == 401 + + +def test_public_key_lookup_gives_both_keys_and_the_servers_spelling(server, alice, carol): + session = session_for(server, alice) + status, _, body = server.request("GET", "/v2/accounts/CAROL/public-key", + token=session) + assert status == 200 + assert body == {"username": "Carol", "public_key": b64(carol.public_key), + "signing_key": ""} + + +def test_public_key_lookup_carries_the_signing_key_when_there_is_one(server, alice): + key = os.urandom(32) + dora = Account("dora") + signup(server, dora, signing_key=b64(key)) + _, _, body = server.request("GET", "/v2/accounts/dora/public-key", + token=session_for(server, alice)) + assert body["signing_key"] == b64(key) + + +@pytest.mark.parametrize("name", ["nobody", "bad%2Fname", "..", "con"]) +def test_public_key_of_an_unknown_name_is_404(server, alice, name): + status, _, _ = server.request("GET", f"/v2/accounts/{name}/public-key", + token=session_for(server, alice)) + assert status == 404 + + +@pytest.mark.parametrize("token", [None, TOKEN, "not-a-session"]) +def test_public_key_lookup_needs_a_login(server, alice, token): + status, _, _ = server.request("GET", "/v2/accounts/alice/public-key", + token=token) + assert status == 401 + + +def test_a_name_with_a_space_works_in_paths(server, alice): + ann = Account("ann lee") + assert signup(server, ann)[0] == 201 + status, _, pre = server.request("GET", "/v2/prelogin/ann%20lee", token=None) + assert status == 200 + assert pre["username"] == "ann lee" + assert pre["kdf"]["salt"] == b64(ann.salt) # the real one + session = session_for(server, alice) + status, _, keys = server.request("GET", "/v2/accounts/ANN%20LEE/public-key", + token=session) + assert status == 200 and keys["username"] == "ann lee" + view, _ = made(server, session) + status, answer = put_member(server, session, view["id"], "ann%20lee", "reader") + assert status == 200 and answer["username"] == "ann lee" + assert my_vault(server, session_for(server, ann), view["id"])["role"] == "reader" + + +# --- adding members and changing roles --------------------------------------------- + +def test_an_owner_adds_a_member_who_then_sees_the_vault_with_their_own_key(server, alice, bob): + session = session_for(server, alice) + view, _ = made(server, session) + key = wrapped_key() + status, answer = put_member(server, session, view["id"], "bob", "editor", + wrapped=key) + assert status == 200 + assert answer == {"username": "bob", "role": "editor", "added": True} + seen = my_vault(server, session_for(server, bob), view["id"]) + assert seen["role"] == "editor" and seen["wrapped_key"] == key + assert seen["kind"] == "shared" and seen["members"] == 2 + assert seen["name"] == view["name"] + # the owner's own key is not touched + assert my_vault(server, session, view["id"])["wrapped_key"] == view["wrapped_key"] + + +def test_a_member_may_be_added_by_any_spelling_and_the_servers_is_stored(server, team): + signup(server, Account("dora")) + status, answer = put_member(server, team.sa, team.vid, "DORA", "reader") + assert status == 200 and answer["username"] == "dora" + names = [m["username"] for m in members(server, team.sa, team.vid)[2]["members"]] + assert names == ["alice", "bob", "Carol", "dora"] + # the same person again, in another case, is a role change and not a second member + assert put_member(server, team.sa, team.vid, "Dora", "editor", + wrapped=None)[1]["added"] is False + + +def test_members_are_counted_in_me(server, team): + assert my_vault(server, team.sa, team.vid)["members"] == 3 + assert my_vault(server, team.sc, team.vid)["members"] == 3 + + +def test_a_role_change_leaves_the_wrapped_key_alone(server, team): + before = my_vault(server, team.sb, team.vid) + status, answer = put_member(server, team.sa, team.vid, "bob", "reader", + wrapped=None) + assert status == 200 + assert answer == {"username": "bob", "role": "reader", "added": False} + after = my_vault(server, team.sb, team.vid) + assert after["role"] == "reader" + assert after["wrapped_key"] == before["wrapped_key"] + assert after["members"] == 3 + + +def test_a_wrapped_key_sent_with_a_role_change_is_ignored(server, team): + before = my_vault(server, team.sb, team.vid)["wrapped_key"] + assert put_member(server, team.sa, team.vid, "bob", "owner")[0] == 200 + assert my_vault(server, team.sb, team.vid)["wrapped_key"] == before + + +def test_a_promoted_reader_can_then_upload(server, team): + blob = vault_blob(team.vid) + assert put_vault(server, team.sc, team.vid, blob)[0] == 403 + put_member(server, team.sa, team.vid, "Carol", "editor", wrapped=None) + assert put_vault(server, team.sc, team.vid, blob)[0] == 200 + + +def test_a_demoted_editor_can_no_longer_upload(server, team): + put_member(server, team.sa, team.vid, "bob", "reader", wrapped=None) + assert put_vault(server, team.sb, team.vid, vault_blob(team.vid))[0] == 403 + + +@pytest.mark.parametrize("role", ["admin", "Owner", "", None, 7]) +def test_a_bad_role_is_refused(server, team, role): + assert put_member(server, team.sa, team.vid, "bob", role, wrapped=None)[0] == 400 + assert put_member(server, team.sa, team.vid, "dora", role)[0] == 400 + + +def test_an_unknown_account_cannot_be_added(server, team): + assert put_member(server, team.sa, team.vid, "nobody", "reader")[0] == 404 + + +def test_a_personal_vault_cannot_be_shared(server, alice, bob): + session = session_for(server, alice) + before = my_vault(server, session, alice.vault) + assert put_member(server, session, alice.vault, "bob", "reader")[0] == 400 + assert my_vault(server, session, alice.vault) == before + assert my_vault(server, session_for(server, bob), alice.vault) is None + + +@pytest.mark.parametrize("key", [None, "not base64 !!", + b64(os.urandom(passman_server.WRAPPED_KEY_LEN - 1)), + b64(os.urandom(passman_server.WRAPPED_KEY_LEN + 1))]) +def test_a_new_member_needs_a_whole_wrapped_key(server, alice, bob, key): + session = session_for(server, alice) + view, _ = made(server, session) + before = state(server, view["id"], "alice", "bob") + assert put_member(server, session, view["id"], "bob", "reader", + wrapped=key)[0] == 400 + assert state(server, view["id"], "alice", "bob") == before + + +def test_the_member_limit_refuses_a_new_member(server, team, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_MEMBERS_PER_VAULT", 3) + dora = Account("dora") + signup(server, dora) + before = state(server, team.vid, "dora") + assert put_member(server, team.sa, team.vid, "dora", "reader")[0] == 403 + assert state(server, team.vid, "dora") == before + assert my_vault(server, session_for(server, dora), team.vid) is None + + +def test_the_member_limit_does_not_stop_a_role_change(server, team, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_MEMBERS_PER_VAULT", 3) + assert put_member(server, team.sa, team.vid, "bob", "reader", wrapped=None)[0] == 200 + + +def test_the_member_limit_allows_one_more_below_it(server, team, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_MEMBERS_PER_VAULT", 4) + signup(server, Account("dora")) + assert put_member(server, team.sa, team.vid, "dora", "reader")[0] == 200 + + +def test_an_account_in_too_many_vaults_cannot_be_added_to_another(server, alice, bob, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_VAULTS_LISTED", 1) + sa, sb = session_for(server, alice), session_for(server, bob) + first, _ = made(server, sb) # bob lists one vault: the limit + other, _ = made(server, sa) + before = state(server, other["id"], "bob") + assert put_member(server, sa, other["id"], "bob", "reader")[0] == 409 + assert state(server, other["id"], "bob") == before + assert my_vault(server, sb, first["id"]) is not None + + +def test_the_only_owner_cannot_be_demoted(server, team): + before = state(server, team.vid, "alice") + for role in ("editor", "reader"): + assert put_member(server, team.sa, team.vid, "alice", role, + wrapped=None)[0] == 409 + assert state(server, team.vid, "alice") == before + assert my_vault(server, team.sa, team.vid)["role"] == "owner" + + +def test_an_owner_can_be_demoted_when_another_owner_remains(server, team): + put_member(server, team.sa, team.vid, "bob", "owner", wrapped=None) + assert put_member(server, team.sb, team.vid, "alice", "reader", wrapped=None)[0] == 200 + assert my_vault(server, team.sa, team.vid)["role"] == "reader" + # and now bob is the only owner, so bob is the one who cannot step down + assert put_member(server, team.sb, team.vid, "bob", "editor", wrapped=None)[0] == 409 + + +def test_an_owner_may_set_their_own_role_to_owner(server, team): + status, answer = put_member(server, team.sa, team.vid, "alice", "owner", + wrapped=None) + assert status == 200 and answer["added"] is False + + +@pytest.mark.parametrize("who", ["bob", "carol"]) +def test_only_an_owner_may_add_or_change_members(server, team, who): + session = team.sb if who == "bob" else team.sc + signup(server, Account("dora")) + before = state(server, team.vid, "dora", "bob", "Carol") + assert put_member(server, session, team.vid, "dora", "reader")[0] == 403 + assert put_member(server, session, team.vid, who if who == "bob" else "Carol", + "owner", wrapped=None)[0] == 403 + assert state(server, team.vid, "dora", "bob", "Carol") == before + + +def test_a_stranger_cannot_tell_the_vault_exists_when_adding_members(server, team, alice): + dora = Account("dora") + signup(server, dora) + session = session_for(server, dora) + assert put_member(server, session, team.vid, "dora", "owner")[0] == 404 + assert put_member(server, session, new_id(), "dora", "owner")[0] == 404 + + +# --- the members list ----------------------------------------------------------------- + +def test_the_members_list_is_sorted_and_says_who_added_whom(server, team): + status, _, body = members(server, team.sb, team.vid) + assert status == 200 + assert body["key_generation"] == 0 + assert [(m["username"], m["role"], m["added_by"]) for m in body["members"]] == [ + ("alice", "owner", ""), ("bob", "editor", "alice"), + ("Carol", "reader", "alice")] + assert all(m["added_at"].endswith("Z") for m in body["members"][1:]) + assert set(body["members"][0]) == {"username", "role", "added_by", "added_at"} + + +def test_every_member_may_read_the_members_list_but_it_holds_no_keys(server, team): + for session in (team.sa, team.sb, team.sc): + status, _, body = members(server, session, team.vid) + assert status == 200 + assert "wrapped_key" not in json.dumps(body) + + +def test_a_non_member_gets_404_for_the_members_list(server, team, alice): + erin = Account("erin") + signup(server, erin) + session = session_for(server, erin) + assert members(server, session, team.vid)[0] == 404 + assert members(server, session, new_id())[0] == 404 + + +# --- roles on the blob -------------------------------------------------------------------- + +def test_a_reader_cannot_upload_but_can_read(server, team): + put_vault(server, team.sa, team.vid, vault_blob(team.vid)) + before = state(server, team.vid) + status, _, _ = put_vault(server, team.sc, team.vid, vault_blob(team.vid), + if_match="1") + assert status == 403 + assert state(server, team.vid) == before + status, _, body = server.request("GET", f"/v2/vaults/{team.vid}", token=team.sc) + assert status == 200 and body["revision"] == 1 and body["blob"] + assert server.request("GET", f"/v2/vaults/{team.vid}/meta", + token=team.sc)[0] == 200 + + +def test_an_editor_can_upload(server, team): + status, _, meta = put_vault(server, team.sb, team.vid, vault_blob(team.vid)) + assert status == 200 and meta["revision"] == 1 + + +def test_an_owner_can_upload(server, team): + assert put_vault(server, team.sa, team.vid, vault_blob(team.vid))[0] == 200 + + +def test_a_non_member_cannot_upload_or_read(server, team): + erin = Account("erin") + signup(server, erin) + session = session_for(server, erin) + assert put_vault(server, session, team.vid, vault_blob(team.vid))[0] == 404 + assert server.request("GET", f"/v2/vaults/{team.vid}", token=session)[0] == 404 + + +@pytest.mark.parametrize("who", ["editor", "reader"]) +def test_only_owners_may_rename_delete_rotate_or_read_the_log(server, team_up, who): + session = team_up.sb if who == "editor" else team_up.sc + vid = team_up.vid + before = state(server, vid, "alice", "bob", "Carol") + calls = [ + ("PUT", f"/v2/vaults/{vid}/name", {"name": sealed_name()}), + ("DELETE", f"/v2/vaults/{vid}", None), + ("PUT", f"/v2/vaults/{vid}/members/alice", {"role": "reader"}), + ("POST", f"/v2/vaults/{vid}/rotate", rotate_body(vid, ["alice", "bob", "Carol"])), + ("GET", f"/v2/vaults/{vid}/log", None), + ] + for method, path, body in calls: + status, _, _ = server.request(method, path, body, token=session) + assert status == 403, (method, path) + assert state(server, vid, "alice", "bob", "Carol") == before + + +def test_a_non_member_gets_404_for_the_owner_calls(server, team): + erin = Account("erin") + signup(server, erin) + session = session_for(server, erin) + vid = team.vid + for method, path, body in [ + ("PUT", f"/v2/vaults/{vid}/name", {"name": sealed_name()}), + ("DELETE", f"/v2/vaults/{vid}", None), + ("POST", f"/v2/vaults/{vid}/rotate", {"generation": 1}), + ("GET", f"/v2/vaults/{vid}/log", None), + ("DELETE", f"/v2/vaults/{vid}/members/alice", None)]: + assert server.request(method, path, body, token=session)[0] == 404, path + + +@pytest.mark.parametrize("method, tail", [ + ("GET", "rotate"), ("PUT", "rotate"), ("PUT", "log"), ("POST", "log"), + ("GET", "name"), ("POST", "members"), ("GET", "members/bob"), + ("POST", "members/bob")]) +def test_other_methods_on_the_sharing_paths_are_404(server, team, method, tail): + body = {} if method in ("PUT", "POST") else None + status, _, _ = server.request(method, f"/v2/vaults/{team.vid}/{tail}", body, + token=team.sa) + assert status == 404 + + +# --- the key generation on the blob -------------------------------------------------------- + +def test_an_upload_at_another_generation_than_the_vaults_is_refused(server, team): + before = state(server, team.vid) + status, _, body = put_vault(server, team.sa, team.vid, vault_blob(team.vid, 1)) + assert status == 409 and "key" in body["error"] + assert state(server, team.vid) == before + + +def test_the_personal_vault_takes_generation_zero_only(server, alice): + session = session_for(server, alice) + assert put_vault(server, session, alice.vault, vault_blob(alice.vault, 1))[0] == 409 + assert put_vault(server, session, alice.vault, vault_blob(alice.vault, 0))[0] == 200 + + +# --- leaving --------------------------------------------------------------------------------- + +@pytest.mark.parametrize("who", ["bob", "carol"]) +def test_a_member_can_leave_and_the_vault_goes_from_their_list(server, team, who): + session = team.sb if who == "bob" else team.sc + name = "bob" if who == "bob" else "Carol" + status, _, _ = server.request("DELETE", f"/v2/vaults/{team.vid}/members/{name}", + token=session) + assert status == 200 + assert my_vault(server, session, team.vid) is None + assert server.request("GET", f"/v2/vaults/{team.vid}", token=session)[0] == 404 + left = [m["username"] for m in members(server, team.sa, team.vid)[2]["members"]] + assert left == [n for n in ("alice", "bob", "Carol") if n != name] + assert my_vault(server, team.sa, team.vid)["members"] == 2 + + +def test_the_only_owner_cannot_leave(server, team): + before = state(server, team.vid, "alice") + status, _, _ = server.request("DELETE", f"/v2/vaults/{team.vid}/members/alice", + token=team.sa) + assert status == 409 + assert state(server, team.vid, "alice") == before + assert my_vault(server, team.sa, team.vid) is not None + + +def test_an_owner_can_leave_when_another_owner_remains(server, team): + put_member(server, team.sa, team.vid, "bob", "owner", wrapped=None) + status, _, _ = server.request("DELETE", f"/v2/vaults/{team.vid}/members/alice", + token=team.sa) + assert status == 200 + assert my_vault(server, team.sa, team.vid) is None + assert my_vault(server, team.sb, team.vid)["role"] == "owner" + + +def test_an_owner_cannot_remove_someone_else_that_way(server, team): + before = state(server, team.vid, "alice", "bob", "Carol") + status, _, body = server.request("DELETE", f"/v2/vaults/{team.vid}/members/bob", + token=team.sa) + assert status == 400 and "rotate" in body["error"] + assert state(server, team.vid, "alice", "bob", "Carol") == before + + +def test_a_non_owner_cannot_remove_someone_else(server, team): + before = state(server, team.vid, "alice", "bob", "Carol") + for session, victim in ((team.sb, "alice"), (team.sc, "bob"), (team.sb, "Carol")): + status, _, _ = server.request( + "DELETE", f"/v2/vaults/{team.vid}/members/{victim}", token=session) + assert status == 403 + assert state(server, team.vid, "alice", "bob", "Carol") == before + + +def test_leaving_a_personal_vault_is_refused(server, alice): + status, _, _ = server.request("DELETE", f"/v2/vaults/{alice.vault}/members/alice", + token=session_for(server, alice)) + assert status == 400 + + +def test_a_member_who_left_can_be_added_again(server, team): + server.request("DELETE", f"/v2/vaults/{team.vid}/members/bob", token=team.sb) + assert put_member(server, team.sa, team.vid, "bob", "reader")[1]["added"] is True + assert my_vault(server, team.sb, team.vid)["role"] == "reader" + + +# --- deleting -------------------------------------------------------------------------------- + +def test_a_deleted_vault_disappears_from_every_members_list(server, team): + status, _, _ = server.request("DELETE", f"/v2/vaults/{team.vid}", token=team.sa) + assert status == 200 + for session in (team.sa, team.sb, team.sc): + assert my_vault(server, session, team.vid) is None + assert server.request("GET", f"/v2/vaults/{team.vid}", token=session)[0] == 404 + + +# --- changing the key and removing members ---------------------------------------------------- + +def test_rotating_removes_members_and_gives_the_rest_new_keys(server, team_up): + t = team_up + body = rotate_body(t.vid, ["alice", "bob"], remove=["Carol"]) + status, view = post_rotate(server, t.sa, t.vid, body) + assert status == 200 + assert view["key_generation"] == 1 and view["members"] == 2 + assert view["wrapped_key"] == body["wrapped_keys"]["alice"] + + # the removed member loses the vault altogether + assert my_vault(server, t.sc, t.vid) is None + assert server.request("GET", f"/v2/vaults/{t.vid}", token=t.sc)[0] == 404 + assert put_vault(server, t.sc, t.vid, vault_blob(t.vid, 1), if_match="4")[0] == 404 + + # the others got their new key, the new generation and the new name + for session, name in ((t.sa, "alice"), (t.sb, "bob")): + seen = my_vault(server, session, t.vid) + assert seen["wrapped_key"] == body["wrapped_keys"][name] + assert seen["key_generation"] == 1 + assert seen["name"] == body["name"] + assert members(server, t.sa, t.vid)[2]["key_generation"] == 1 + assert [m["username"] for m in members(server, t.sa, t.vid)[2]["members"]] \ + == ["alice", "bob"] + + +def test_rotating_stores_the_new_blob_as_a_new_revision_and_empties_the_history(server, team_up): + t = team_up + history = os.path.join(vault_dir(server, t.vid), "history") + assert len(os.listdir(history)) == 2 + body = rotate_body(t.vid, ["alice", "bob", "Carol"]) + assert post_rotate(server, t.sa, t.vid, body)[0] == 200 + _, _, stored = server.request("GET", f"/v2/vaults/{t.vid}", token=t.sa) + assert stored["revision"] == 4 + assert stored["blob"] == body["blob"] + assert stored["key_generation"] == 1 + assert not os.path.isdir(history) or os.listdir(history) == [] + + +def test_after_a_rotation_the_old_generation_is_refused_and_the_new_one_works(server, team_up): + t = team_up + assert post_rotate(server, t.sa, t.vid, + rotate_body(t.vid, ["alice", "bob", "Carol"]))[0] == 200 + assert put_vault(server, t.sb, t.vid, vault_blob(t.vid, 0), if_match="4")[0] == 409 + status, _, meta = put_vault(server, t.sb, t.vid, vault_blob(t.vid, 1), + if_match="4") + assert status == 200 and meta["revision"] == 5 + + +def test_rotating_without_removing_anyone_changes_the_key_for_all(server, team_up): + t = team_up + body = rotate_body(t.vid, ["alice", "bob", "Carol"]) + assert post_rotate(server, t.sa, t.vid, body)[0] == 200 + for session, name in ((t.sa, "alice"), (t.sb, "bob"), (t.sc, "Carol")): + assert my_vault(server, session, t.vid)["wrapped_key"] \ + == body["wrapped_keys"][name] + + +def test_a_vault_never_uploaded_can_be_rotated_without_a_blob(server, team): + body = rotate_body(team.vid, ["alice", "bob"], remove=["Carol"]) + del body["blob"], body["revision"] + status, view = post_rotate(server, team.sa, team.vid, body) + assert status == 200 and view["key_generation"] == 1 + assert my_vault(server, team.sc, team.vid) is None + assert server.request("GET", f"/v2/vaults/{team.vid}", token=team.sa)[2]["blob"] is None + # and the first upload must already be at the new generation + assert put_vault(server, team.sa, team.vid, vault_blob(team.vid, 0))[0] == 409 + assert put_vault(server, team.sa, team.vid, vault_blob(team.vid, 1))[0] == 200 + + +def test_a_vault_never_uploaded_may_still_be_rotated_with_a_blob(server, team): + body = rotate_body(team.vid, ["alice", "bob", "Carol"], revision=0) + status, _ = post_rotate(server, team.sa, team.vid, body) + assert status == 200 + assert server.request("GET", f"/v2/vaults/{team.vid}", + token=team.sa)[2]["revision"] == 1 + + +def test_the_removed_member_may_be_an_owner_when_another_owner_stays(server, team_up): + t = team_up + put_member(server, t.sa, t.vid, "bob", "owner", wrapped=None) + status, _ = post_rotate(server, t.sb, t.vid, + rotate_body(t.vid, ["bob", "Carol"], remove=["alice"])) + assert status == 200 + assert my_vault(server, t.sa, t.vid) is None + + +def test_two_members_may_be_removed_at_once(server, team_up): + t = team_up + status, view = post_rotate(server, t.sa, t.vid, + rotate_body(t.vid, ["alice"], remove=["bob", "Carol"])) + assert status == 200 and view["members"] == 1 + assert my_vault(server, t.sb, t.vid) is None and my_vault(server, t.sc, t.vid) is None + + +def test_a_second_rotation_needs_the_next_generation(server, team_up): + t = team_up + assert post_rotate(server, t.sa, t.vid, + rotate_body(t.vid, ["alice", "bob", "Carol"]))[0] == 200 + again = rotate_body(t.vid, ["alice", "bob", "Carol"], revision=4) + assert post_rotate(server, t.sa, t.vid, again)[0] == 409 # still 1 + again["generation"] = 2 + again["blob"] = vault_blob(t.vid, 2) + assert post_rotate(server, t.sa, t.vid, again)[0] == 200 + + +def _mutate(**changes): + def apply(body): + body.update(changes) + return body + return apply + + +def _without(*keys): + def apply(body): + for key in keys: + body.pop(key, None) + return body + return apply + + +def _drop_key(name): + def apply(body): + del body["wrapped_keys"][name] + return body + return apply + + +def _add_key(name): + def apply(body): + body["wrapped_keys"][name] = wrapped_key() + return body + return apply + + +def _short_key(body): + body["wrapped_keys"]["bob"] = b64(os.urandom(passman_server.WRAPPED_KEY_LEN - 1)) + return body + + +REFUSED = [ + ("generation too far ahead", 409, _mutate(generation=2)), + ("generation not advanced", 409, _mutate(generation=0)), + ("generation missing", 409, _without("generation")), + ("a staying member has no key", 409, _drop_key("bob")), + ("the removed member still has a key", 409, _add_key("Carol")), + ("a key for an unknown account", 409, _add_key("zed")), + ("no keys at all", 409, _without("wrapped_keys")), + ("keys not an object", 409, _mutate(wrapped_keys=["alice", "bob"])), + ("removing yourself", 400, _mutate(remove=["alice", "Carol"])), + ("removing a non-member", 400, _mutate(remove=["Carol", "zed"])), + ("remove is not a list", 400, _mutate(remove="Carol")), + ("a key of the wrong length", 400, _short_key), + ("a key that is not base64", 400, + lambda b: _mutate(wrapped_keys={**b["wrapped_keys"], "bob": "!!"})(b)), + ("stale revision", 409, _mutate(revision=2)), + ("revision missing but a blob given", 409, _without("revision")), + ("blob for another vault", 400, lambda b: _mutate(blob=vault_blob(new_id(), 1))(b)), + ("blob with the old generation", 409, lambda b: _mutate(blob=vault_blob(b["blob_vault"], 0))(b)), + ("blob not base64", 400, _mutate(blob="!!")), + ("blob too short", 400, _mutate(blob=b64(b"PMV3"))), + ("blob missing from a vault that has one", 400, _without("blob", "revision")), + ("name missing", 400, _without("name")), + ("name not base64", 400, _mutate(name="not base64 !!")), + ("name too short", 400, _mutate(name=b64(b"x" * (passman_server.MIN_SEALED_NAME - 1)))), + ("name too long", 400, _mutate(name=b64(b"x" * (passman_server.MAX_SEALED_NAME + 1)))), +] + + +@pytest.mark.parametrize("why, status, change", REFUSED, ids=[r[0] for r in REFUSED]) +def test_a_bad_rotation_is_refused_and_changes_nothing(server, team_up, why, status, change): + t = team_up + body = rotate_body(t.vid, ["alice", "bob"], remove=["Carol"]) + body["blob_vault"] = t.vid # for the lambdas above; not sent + body = change(body) + body.pop("blob_vault", None) + before = state(server, t.vid, "alice", "bob", "Carol") + sessions = [(t.sa, "alice"), (t.sb, "bob"), (t.sc, "Carol")] + seen_before = [my_vault(server, s, t.vid) for s, _ in sessions] + assert post_rotate(server, t.sa, t.vid, body)[0] == status + assert state(server, t.vid, "alice", "bob", "Carol") == before + assert [my_vault(server, s, t.vid) for s, _ in sessions] == seen_before + + +def test_rotating_a_personal_vault_is_refused(server, alice): + session = session_for(server, alice) + body = rotate_body(alice.vault, ["alice"], revision=0) + before = state(server, alice.vault) + assert post_rotate(server, session, alice.vault, body)[0] == 400 + assert state(server, alice.vault) == before + + +def test_rotating_needs_a_body(server, team): + status, _, _ = server.request("POST", f"/v2/vaults/{team.vid}/rotate", + token=team.sa) + assert status == 400 + + +def test_a_revision_other_than_zero_is_stale_for_a_vault_never_uploaded(server, team): + body = rotate_body(team.vid, ["alice", "bob", "Carol"], revision=5) + assert post_rotate(server, team.sa, team.vid, body)[0] == 409 + + +# --- vault limits count only what an account made ------------------------------------------------- + +def test_a_vault_shared_with_you_does_not_count_against_your_own_limit(server, alice, bob, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_VAULTS_PER_ACCOUNT", 1) + sa, sb = session_for(server, alice), session_for(server, bob) + theirs, _ = made(server, sb) + put_member(server, sb, theirs["id"], "alice", "editor") + assert create(server, sa)[0] == 201 # the shared one is not hers + assert create(server, sa)[0] == 403 # but this one is + + +def test_the_limit_on_own_vaults_still_applies_to_a_member_of_many(server, alice, bob, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_VAULTS_PER_ACCOUNT", 2) + sa, sb = session_for(server, alice), session_for(server, bob) + for _ in range(2): + put_member(server, sb, made(server, sb)[0]["id"], "alice", "reader") + assert create(server, sa)[0] == 201 + assert create(server, sa)[0] == 201 + assert create(server, sa)[0] == 403 + + +def test_the_total_listed_limit_counts_shared_vaults_too(server, alice, bob, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_VAULTS_LISTED", 2) + sa, sb = session_for(server, alice), session_for(server, bob) + put_member(server, sb, made(server, sb)[0]["id"], "alice", "reader") + assert create(server, sa)[0] == 201 + assert create(server, sa)[0] == 403 + + +# --- the vault's log ---------------------------------------------------------------------------------- + +def test_the_log_records_a_vault_from_its_creation(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + status, _, body = api_log(server, session, view["id"]) + assert status == 200 + [line] = body["lines"] + assert line["event"] == "vault_created" and line["by"] == "alice" + assert line["address"] == "127.0.0.1" + assert line["at"].endswith("Z") and "T" in line["at"] + + +def test_the_log_tells_what_happened_oldest_first(server, team): + t = team + put_vault(server, t.sa, t.vid, vault_blob(t.vid)) + put_vault(server, t.sb, t.vid, vault_blob(t.vid), if_match="1") + server.request("PUT", f"/v2/vaults/{t.vid}/name", {"name": sealed_name()}, + token=t.sa) + put_member(server, t.sa, t.vid, "bob", "reader", wrapped=None) + server.request("DELETE", f"/v2/vaults/{t.vid}/members/Carol", token=t.sc) + assert post_rotate(server, t.sa, t.vid, + rotate_body(t.vid, ["alice", "bob"], remove=[], + revision=2))[0] == 200 + lines = api_log(server, t.sa, t.vid)[2]["lines"] + assert [(l["event"], l["by"]) for l in lines] == [ + ("vault_created", "alice"), ("member_added", "alice"), + ("member_added", "alice"), ("upload", "alice"), ("upload", "bob"), + ("vault_renamed", "alice"), ("member_role", "alice"), + ("member_left", "Carol"), ("key_changed", "alice")] + by_event = {} + for each in lines: + by_event.setdefault(each["event"], []).append(each) + assert [u["revision"] for u in by_event["upload"]] == [1, 2] + assert [(m["member"], m["role"]) for m in by_event["member_added"]] == [ + ("bob", "editor"), ("Carol", "reader")] + assert (by_event["member_role"][0]["member"], + by_event["member_role"][0]["role"]) == ("bob", "reader") + assert by_event["key_changed"][0]["generation"] == 1 + assert by_event["key_changed"][0]["removed"] == [] + assert [l["at"] for l in lines] == sorted(l["at"] for l in lines) + + +def test_a_key_change_logs_who_was_removed(server, team_up): + t = team_up + post_rotate(server, t.sa, t.vid, + rotate_body(t.vid, ["alice", "bob"], remove=["Carol"])) + last = api_log(server, t.sa, t.vid)[2]["lines"][-1] + assert last["event"] == "key_changed" and last["removed"] == ["Carol"] + assert last["generation"] == 1 and last["by"] == "alice" + + +def test_a_refused_call_leaves_no_line_in_the_log(server, team_up): + t = team_up + before = api_log(server, t.sa, t.vid)[2] + put_vault(server, t.sa, t.vid, vault_blob(t.vid, 1), if_match="3") # 409 + put_vault(server, t.sc, t.vid, vault_blob(t.vid), if_match="3") # 403 + put_member(server, t.sa, t.vid, "nobody", "reader") # 404 + post_rotate(server, t.sa, t.vid, {"generation": 5}) # 409 + assert api_log(server, t.sa, t.vid)[2] == before + + +def test_the_log_of_a_deleted_vault_goes_with_its_folder(server, team): + server.request("DELETE", f"/v2/vaults/{team.vid}", token=team.sa) + graveyard = os.path.join(server.store.root, "_v2", "deleted") + [folder] = [f for f in os.listdir(graveyard) if f.startswith(team.vid)] + lines = file_lines(os.path.join(graveyard, folder, "audit.log")) + assert lines[-1]["event"] == "vault_deleted" and lines[-1]["by"] == "alice" + + +def test_the_log_is_cut_to_the_newest_lines(server, team, monkeypatch): + monkeypatch.setattr(passman_server, "AUDIT_LINES_SENT", 3) + put_vault(server, team.sa, team.vid, vault_blob(team.vid)) + for rev in "123": + put_vault(server, team.sa, team.vid, vault_blob(team.vid), if_match=rev) + lines = api_log(server, team.sa, team.vid)[2]["lines"] + assert [l["revision"] for l in lines] == [2, 3, 4] + assert len(file_lines(vault_log_path(server, team.vid))) == 7 # all kept on disk + + +def test_the_log_skips_a_line_it_cannot_read(server, team): + with open(vault_log_path(server, team.vid), "a", encoding="utf-8") as f: + f.write("this is not json\n[1, 2]\n") + lines = api_log(server, team.sa, team.vid)[2]["lines"] + assert [l["event"] for l in lines] == ["vault_created", "member_added", + "member_added"] + + +def test_the_log_of_a_vault_without_one_is_empty(server, team): + os.remove(vault_log_path(server, team.vid)) + assert api_log(server, team.sa, team.vid)[2] == {"lines": []} + + +def test_the_log_holds_no_blob_key_or_name_material(server, team_up): + t = team_up + sent = [t.sent["name"], t.sent["wrapped_key"]] + key = wrapped_key() + put_member(server, t.sa, t.vid, "bob", "owner", wrapped=None) + signup(server, Account("dora")) + put_member_body = {"role": "reader", "wrapped_key": key} + server.request("PUT", f"/v2/vaults/{t.vid}/members/dora", put_member_body, + token=t.sa) + renamed = sealed_name() + server.request("PUT", f"/v2/vaults/{t.vid}/name", {"name": renamed}, token=t.sa) + uploaded = vault_blob(t.vid) + put_vault(server, t.sa, t.vid, uploaded, if_match="3") + body = rotate_body(t.vid, ["alice", "bob", "Carol"], remove=["dora"], revision=4) + assert post_rotate(server, t.sa, t.vid, body)[0] == 200 + sent += [key, renamed, uploaded, body["name"], body["blob"], + *body["wrapped_keys"].values()] + with open(vault_log_path(server, t.vid), encoding="utf-8") as f: + text = f.read() + assert text.count("\n") >= 10 + for secret in sent: + assert secret not in text + for fragment in ("wrapped_key", "blob", "PMV3"): + assert fragment not in text + + +# --- the logins log ------------------------------------------------------------------------------------- + +def test_the_logins_log_records_sign_up_and_login(server, alice): + session_for(server, alice) + lines = file_lines(logins_path(server)) + assert [(l["event"], l["by"]) for l in lines] == [ + ("account_created", "alice"), ("login", "alice")] + assert all(l["address"] == "127.0.0.1" for l in lines) + + +def test_a_failed_login_is_recorded_with_its_status(server, alice): + status, _, _ = login(server, alice, login_key=os.urandom(32)) + assert status == 401 + last = file_lines(logins_path(server))[-1] + assert last["event"] == "login_failed" and last["by"] == "alice" + assert last["status"] == 401 + + +def test_a_failed_login_for_a_name_with_no_account_is_recorded_under_that_name(server): + login(server, Account("nobody")) + last = file_lines(logins_path(server))[-1] + assert (last["event"], last["by"], last["status"]) == ("login_failed", "nobody", 401) + + +@pytest.mark.parametrize("name", ["../etc", "", "a/b", "x" * 80, 7]) +def test_a_failed_login_for_an_invalid_name_is_recorded_as_a_question_mark(server, name): + status, _, _ = server.request("POST", "/v2/login", { + "username": name, "login_key": b64(os.urandom(32))}, token=None) + assert status == 400 + last = file_lines(logins_path(server))[-1] + assert (last["event"], last["by"], last["status"]) == ("login_failed", "?", 400) + + +def test_a_password_change_is_recorded(server, alice): + session = session_for(server, alice) + new_key = os.urandom(32) + status, _, _ = server.request("PUT", "/v2/me/keyset", { + "login_key": b64(new_key), "keyset": keyset_blob()}, token=session) + assert status == 200 + last = file_lines(logins_path(server))[-1] + assert last["event"] == "password_changed" and last["by"] == "alice" + + +def test_a_refused_sign_up_is_not_recorded(server, alice): + signup(server, Account("alice")) # name taken: 409 + signup(server, Account("zed"), token=None) # no code: 401 + assert [l["event"] for l in file_lines(logins_path(server))] == ["account_created"] + + +def test_the_logins_log_never_holds_a_key_or_a_session(server, alice): + session = session_for(server, alice) + wrong = os.urandom(32) + login(server, alice, login_key=wrong) + new_key = os.urandom(32) + server.request("PUT", "/v2/me/keyset", + {"login_key": b64(new_key), "keyset": keyset_blob()}, token=session) + with open(logins_path(server), encoding="utf-8") as f: + text = f.read() + for secret in (alice.login_key, wrong, new_key): + assert b64(secret) not in text + assert session not in text and alice.keyset not in text + assert b64(alice.public_key) not in text + + +def test_the_logins_log_is_not_readable_through_the_api(server, alice): + session = session_for(server, alice) + for path in ("/v2/logins", "/v2/log", "/v2/me/log", "/v2/vaults/logins"): + assert server.request("GET", path, token=session)[0] == 404 + + +# --- rotation and failure of the log files ---------------------------------------------------------------- + +def test_a_log_that_reaches_the_size_is_renamed_and_a_new_one_started(tmp_path, monkeypatch): + monkeypatch.setattr(passman_server, "AUDIT_ROTATE_BYTES", 300) + store = passman_server.AccountStore(str(tmp_path)) + path = os.path.join(store.root, "logins.log") + written = 0 + while not os.path.exists(path + ".1") and written < 20: + store.audit(None, "login", "alice", "127.0.0.1") + written += 1 + assert os.path.exists(path + ".1") + old, new = file_lines(path + ".1"), file_lines(path) + assert len(old) + len(new) == written + assert len(new) == 1 # a new file, not the old one grown + assert os.path.getsize(path + ".1") >= 300 + + +def test_a_second_rotation_replaces_the_earlier_dot_one(tmp_path, monkeypatch): + monkeypatch.setattr(passman_server, "AUDIT_ROTATE_BYTES", 200) + store = passman_server.AccountStore(str(tmp_path)) + for number in range(40): + store.audit(None, "login", f"user{number}", "127.0.0.1") + path = os.path.join(store.root, "logins.log") + assert sorted(os.listdir(store.root)) == ["logins.log", "logins.log.1"] + first = file_lines(path + ".1")[0]["by"] + assert first != "user0" # the oldest file is gone + + +def test_a_vault_log_rotates_in_its_own_folder(server, team, monkeypatch): + monkeypatch.setattr(passman_server, "AUDIT_ROTATE_BYTES", 400) + put_vault(server, team.sa, team.vid, vault_blob(team.vid)) + for rev in range(1, 6): + put_vault(server, team.sa, team.vid, vault_blob(team.vid), if_match=str(rev)) + folder = vault_dir(server, team.vid) + assert os.path.exists(os.path.join(folder, "audit.log.1")) + # the API reads the current file only + lines = api_log(server, team.sa, team.vid)[2]["lines"] + assert lines and lines[-1]["revision"] == 6 + + +def test_a_log_below_the_size_is_not_rotated(tmp_path): + store = passman_server.AccountStore(str(tmp_path)) + for _ in range(5): + store.audit(None, "login", "alice") + assert os.listdir(store.root) == ["logins.log"] + + +def test_audit_never_raises_when_the_folder_cannot_be_made(tmp_path, monkeypatch): + store = passman_server.AccountStore(str(tmp_path)) + + def refuse(*args, **kwargs): + raise OSError("disk full") + monkeypatch.setattr(passman_server.os, "makedirs", refuse) + store.audit(None, "login", "alice", "127.0.0.1") + store.audit("ab" * 16, "upload", "alice", revision=1) + + +def test_audit_never_raises_when_the_file_cannot_be_opened(tmp_path, monkeypatch): + store = passman_server.AccountStore(str(tmp_path)) + blocker = tmp_path / "blocker" + blocker.write_text("a file where a folder should be") + monkeypatch.setattr(store, "_audit_path", + lambda vault_id=None: str(blocker / "x.log")) + store.audit(None, "login", "alice") + assert store.audit_lines("ab" * 16) == {"lines": []} + + +def test_an_upload_still_succeeds_when_the_log_cannot_be_written(server, team, tmp_path, monkeypatch): + blocker = tmp_path / "blocker" + blocker.write_text("not a folder") + monkeypatch.setattr(passman_server.AccountStore, "_audit_path", + lambda self, vault_id=None: str(blocker / "x.log")) + status, _, meta = put_vault(server, team.sa, team.vid, vault_blob(team.vid)) + assert status == 200 and meta["revision"] == 1 + assert put_member(server, team.sa, team.vid, "bob", "reader", wrapped=None)[0] == 200 + + +# --- the account's own list ---------------------------------------------------------------------------- + +def _listed(server, name): + path = os.path.join(server.store.root, "_v2", "accounts", name.lower() + ".json") + with open(path, encoding="utf-8") as f: + return json.load(f).get("vaults", []) + + +def test_a_removed_member_no_longer_has_the_vault_in_their_account_file(server, team_up): + assert team_up.vid in _listed(server, "Carol") + post_rotate(server, team_up.sa, team_up.vid, + rotate_body(team_up.vid, ["alice", "bob"], remove=["Carol"])) + assert team_up.vid not in _listed(server, "Carol") + assert team_up.vid in _listed(server, "bob") + + +def test_a_member_who_left_no_longer_has_the_vault_in_their_account_file(server, team): + server.request("DELETE", f"/v2/vaults/{team.vid}/members/bob", token=team.sb) + assert team.vid not in _listed(server, "bob") + + +def test_a_deleted_vault_leaves_every_account_file(server, team): + server.request("DELETE", f"/v2/vaults/{team.vid}", token=team.sa) + for name in ("alice", "bob", "Carol"): + assert team.vid not in _listed(server, name) diff --git a/tests/test_server_v2_vaults.py b/tests/test_server_v2_vaults.py index db9d979..8060a60 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"] + assert body["features"] == ["vaults", "sharing"] # --- create -------------------------------------------------------------------- @@ -84,7 +84,7 @@ def test_a_vault_is_made_and_the_owner_is_told_about_it(server, alice): status, view, sent = create(server, session) assert status == 201 assert view == {"id": sent["id"], "kind": "shared", "role": "owner", - "revision": 0, "key_generation": 0, + "revision": 0, "key_generation": 0, "members": 1, "name": sent["name"], "wrapped_key": sent["wrapped_key"]} @@ -96,7 +96,7 @@ def test_me_lists_the_personal_vault_first_then_the_others(server, alice): assert [v["id"] for v in vaults] == [alice.vault, first["id"], second["id"]] # the personal vault has no name and no wrapped key; the key is in the keyset assert vaults[0] == {"id": alice.vault, "kind": "personal", "role": "owner", - "revision": 0, "key_generation": 0} + "revision": 0, "key_generation": 0, "members": 1} assert "name" not in vaults[0] and "wrapped_key" not in vaults[0] assert vaults[1]["name"] == first_sent["name"] assert vaults[1]["wrapped_key"] == first_sent["wrapped_key"]