From 0dd6b9af2b7c56796a52a03513091e42b5008274 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Wed, 11 Nov 2026 08:43:09 +0200 Subject: [PATCH] Several vaults per account (API v2, phase 3, 2.0.0-alpha.2) An account can now hold further vaults besides its personal one: - POST /v2/vaults creates one, owned by the caller. The client makes the id and the key; the key arrives wrapped to the owner's public key and the name sealed under the vault key, so the server can read neither. - PUT /v2/vaults//name and DELETE /v2/vaults/, owners only, never for the personal vault. A deleted vault's folder is moved to _v2/deleted/, not erased. - /v2/me lists every vault of the account; the blob routes work for the new vaults exactly as for the personal one. - At most 50 further vaults per account. - /health lists "features": ["vaults"], so a client can tell an older server apart. 2.x routes are unchanged. An account made by 2.0.0-alpha.1 needs no change. tests/test_server_v2_vaults.py: 44 tests; 9 of 9 deliberately broken servers were caught. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 15 ++ server/passman_server.py | 153 ++++++++++- server/version.py | 8 +- tests/test_server_http.py | 3 +- tests/test_server_v2_vaults.py | 478 +++++++++++++++++++++++++++++++++ 5 files changed, 642 insertions(+), 15 deletions(-) create mode 100644 tests/test_server_v2_vaults.py diff --git a/README.md b/README.md index 07aac1e..837ed94 100644 --- a/README.md +++ b/README.md @@ -314,11 +314,26 @@ like a 2.x one, with the same revision, `If-Match` and history rules. | `/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) | 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. +Server **2.0.0-alpha.2** lets an account hold **several vaults**: the +personal one, which every account has, and up to **50** more (`MAX_VAULTS_PER_ACCOUNT` +in `passman_server.py`). The client makes a vault's id and key. A vault's +name arrives sealed under that key and the key arrives wrapped to the owner's +public key, so the server can read neither; it only stores them and hands +them back in `/v2/me`, where the personal vault comes first. A deleted vault is +not erased: its folder is **moved** to `_v2\deleted\-` inside the +store, so a vault deleted by mistake is not lost at once. Whoever runs the +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. + 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 diff --git a/server/passman_server.py b/server/passman_server.py index e02e469..0606bb9 100644 --- a/server/passman_server.py +++ b/server/passman_server.py @@ -235,6 +235,20 @@ def _archive(self, user: str, revision: int) -> None: VAULT_ID = re.compile(r"^[0-9a-f]{32}$") ROLES_THAT_WRITE = ("owner", "editor") +#: Vaults an account may own 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 + +#: A vault key wrapped to a member (account_crypto.wrap_key on the client): +#: version, a one-off X25519 public key, a nonce, the key and its tag. +WRAPPED_KEY_LEN = 1 + 32 + 12 + 32 + 16 + +#: A vault's name arrives sealed under the vault's key, so the server cannot +#: read it: a nonce, the name (up to 200 bytes) and a tag. +MIN_SEALED_NAME = 12 + 1 + 16 +MAX_SEALED_NAME = 12 + 200 + 16 + #: The account files' layouts, from the client (account_crypto.py). _KEYSET_MAGIC = b"PMK3" _KEYSET_HEADER = 4 + 1 + 1 + 4 + 1 + 1 + 16 # magic ver kdf mem passes lanes salt @@ -499,17 +513,34 @@ def _drop_expired_sessions(self, keep_user: str = None, keep_token: str = None, pass # -- the account itself ------------------------------------------------- + def _vault_view(self, username: str, vault_id: str): + """What an account is told about one of its vaults, or None when it + is not (or no longer) a member.""" + members = self._read_json(self._members_path(vault_id)) or {} + member = members.get("members", {}).get(username) + if member is None: + return None + meta = self.vaults.meta(vault_id) + view = {"id": vault_id, "kind": members.get("kind", "personal"), + "role": member.get("role", "owner"), + "revision": meta["revision"], + "key_generation": meta.get("key_generation", 0)} + # 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. + if view["kind"] != "personal": + view["name"] = members.get("name", "") + view["wrapped_key"] = member.get("wrapped_key", "") + return view + 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)}) + vaults = [] + for each in [vault_id] + list(record.get("vaults", [])): + view = self._vault_view(username, each) + if view is not None: + vaults.append(view) return {"username": record["username"], "public_key": record["public_key"], "keyset": record["keyset"], "recovery": record["recovery"], "kdf": record["kdf"], "personal_vault": vault_id, @@ -532,6 +563,79 @@ def change_keyset(self, username: str, body: dict, keep_token: str) -> None: self._drop_expired_sessions(user_to_end=username, keep_token=keep_token) # -- vaults ------------------------------------------------------------- + @staticmethod + def _sealed_name(body: dict) -> str: + name = body.get("name") + try: + size = len(base64.b64decode(name, validate=True)) + except Exception: + size = 0 + if not isinstance(name, str) or not MIN_SEALED_NAME <= size <= MAX_SEALED_NAME: + raise V2Error(400, "The vault's name is missing or too long.") + return name + + def create_vault(self, username: str, body: dict) -> dict: + """ + A further vault for ``username``, who becomes its owner. The client + made the vault's id and key; the server gets the key only wrapped to + the owner's public key, and the name only sealed under the vault key. + """ + vault_id = body.get("id") + if not isinstance(vault_id, str) or not VAULT_ID.match(vault_id): + raise V2Error(400, "The vault id is not valid.") + name = self._sealed_name(body) + wrapped = _b64(_unb64(body.get("wrapped_key"), "The vault key", + WRAPPED_KEY_LEN)) + with _lock: + record = self.account(username) + mine = list(record.get("vaults", [])) + if len(mine) >= MAX_VAULTS_PER_ACCOUNT: + 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, + "members": {username: {"role": "owner", "wrapped_key": wrapped}}}) + record["vaults"] = mine + [vault_id] + self._write_json(self._account_path(username), record) + return self._vault_view(username, vault_id) + + def rename_vault(self, username: str, vault_id: str, body: dict) -> dict: + name = self._sealed_name(body) + with _lock: + members = self._read_json(self._members_path(vault_id)) or {} + if members.get("kind") == "personal": + raise V2Error(400, "The personal vault has no name to change.") + members["name"] = name + self._write_json(self._members_path(vault_id), members) + return self._vault_view(username, vault_id) + + def delete_vault(self, username: str, vault_id: str) -> None: + """ + Take a vault away from every member. Its folder is not erased but + moved to /_v2/deleted/, so whoever runs the server can bring a + vault back that was deleted by mistake -- and can empty that folder + when they are sure. + """ + with _lock: + members = self._read_json(self._members_path(vault_id)) or {} + if members.get("kind") == "personal": + raise V2Error(400, "The personal vault cannot be deleted; it " + "goes with the account.") + for name in members.get("members", {}): + 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) + graveyard = os.path.join(self.root, "deleted") + os.makedirs(graveyard, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + os.replace(os.path.join(self.vaults.root, vault_id), + os.path.join(graveyard, f"{vault_id}-{stamp}")) + 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 ""): @@ -607,8 +711,11 @@ def _user_from_path(self): # -- routes ----------------------------------------------------------- def do_GET(self): if self.path.split("?")[0] == "/health": + # "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). return self._send(200, {"ok": True, "service": "passman-sync", - "api": [1, 2]}) + "api": [1, 2], "features": ["vaults"]}) if self._is_v2(): return self._v2("GET") @@ -637,6 +744,12 @@ def do_POST(self): # 2.x never used POST: answer as before, when there was no do_POST. self.send_error(501, "Unsupported method ('POST')") + def do_DELETE(self): + if self._is_v2(): + return self._v2("DELETE") + # Nor DELETE: a 2.x vault is never removed over the network. + self.send_error(501, "Unsupported method ('DELETE')") + # -- one blob, for 2.x users and v2 vaults alike ------------------------ def _send_blob(self, store, name, tail): meta = store.meta(name) @@ -810,14 +923,34 @@ def _v2_route(self, method, parts): return None accounts.change_keyset(username, body, keep_token=token) return self._send(200, {}) + 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): 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"): + if role is None or tail not in ("", "meta", "name"): 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": + 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 self._send_blob(accounts.vaults, vault_id, tail) if method == "PUT" and not tail: diff --git a/server/version.py b/server/version.py index 1311ba5..bc492be 100644 --- a/server/version.py +++ b/server/version.py @@ -6,20 +6,20 @@ 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``. +A test version carries a pre-release tag, ``2.0.0-alpha.2``. """ import re -__version__ = "2.0.0-alpha.1" +__version__ = "2.0.0-alpha.2" _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 + The four plain numbers Windows wants for a file version: (2, 0, 0, 2) + for '2.0.0-alpha.2', (2, 0, 0, 0) for '2.0.0'. The same rule as the client's version.file_version. """ m = _VERSION.fullmatch(text) diff --git a/tests/test_server_http.py b/tests/test_server_http.py index 0097c0f..d372559 100644 --- a/tests/test_server_http.py +++ b/tests/test_server_http.py @@ -107,7 +107,8 @@ 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", "api": [1, 2]} + assert body == {"ok": True, "service": "passman-sync", "api": [1, 2], + "features": ["vaults"]} @pytest.mark.parametrize("header", [ diff --git a/tests/test_server_v2_vaults.py b/tests/test_server_v2_vaults.py new file mode 100644 index 0000000..db9d979 --- /dev/null +++ b/tests/test_server_v2_vaults.py @@ -0,0 +1,478 @@ +""" +API v2, phase 3: several vaults per account, over HTTP. + +Names arrive sealed and keys wrapped, so (as in test_server_v2.py) the +tests only need byte strings of the right shape; the server cannot tell them +from the real thing. +""" + +import os +import json + +import pytest + +# First: it puts server/ on the path, and brings the running-server fixture. +from test_server_http import TOKEN, VAULT_1, server # noqa: F401 (fixture) +from test_server_v2 import (Account, alice, b64, put_vault, # noqa: F401 + session_for, signup, vault_blob) +import passman_server # noqa: E402 + + +def new_id() -> str: + return os.urandom(16).hex() + + +def sealed_name(size: int = 40) -> str: + return b64(os.urandom(size)) + + +def wrapped_key(size: int = passman_server.WRAPPED_KEY_LEN) -> str: + return b64(os.urandom(size)) + + +def create_body(vault_id=None, **changes): + body = {"id": vault_id or new_id(), "name": sealed_name(), + "wrapped_key": wrapped_key()} + body.update(changes) + return body + + +def create(server, session, **changes): + """Create a vault; return (status, body, the body that was sent).""" + body = create_body(**changes) + # A change of None means "leave that key out". + body = {k: v for k, v in body.items() if v is not None} + status, _, answer = server.request("POST", "/v2/vaults", body, token=session) + return status, answer, body + + +def made(server, session): + """A vault that was made successfully; return its view and request.""" + status, view, body = create(server, session) + assert status == 201, view + return view, body + + +def me(server, session): + status, _, body = server.request("GET", "/v2/me", token=session) + assert status == 200 + return body + + +def vault_dir(server, vault_id): + return os.path.join(server.store.root, "_v2", "vaults", vault_id) + + +@pytest.fixture +def bob(server): + account = Account("bob") + assert signup(server, account)[0] == 201 + return account + + +# --- /health ------------------------------------------------------------------- + +def test_health_says_the_server_has_vaults(server): + _, _, body = server.request("GET", "/health", token=None) + assert body["features"] == ["vaults"] + + +# --- create -------------------------------------------------------------------- + +def test_a_vault_is_made_and_the_owner_is_told_about_it(server, alice): + session = session_for(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, + "name": sent["name"], "wrapped_key": sent["wrapped_key"]} + + +def test_me_lists_the_personal_vault_first_then_the_others(server, alice): + session = session_for(server, alice) + first, first_sent = made(server, session) + second, second_sent = made(server, session) + vaults = me(server, session)["vaults"] + 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} + 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"] + assert vaults[2]["name"] == second_sent["name"] + assert vaults[2]["kind"] == "shared" + + +def test_the_names_size_limits_are_inclusive(server, alice): + session = session_for(server, alice) + for size in (passman_server.MIN_SEALED_NAME, passman_server.MAX_SEALED_NAME): + assert create(server, session, name=sealed_name(size))[0] == 201 + + +@pytest.mark.parametrize("changes", [ + {"id": "A" * 32}, # uppercase hex + {"id": "ab" * 15}, # too short + {"id": "ab" * 17}, # too long + {"id": "g" * 32}, # not hex + {"id": None}, # missing + {"id": 12345}, # not a string + {"name": None}, # missing + {"name": "not base64 !!"}, + {"name": b64(b"x" * (passman_server.MIN_SEALED_NAME - 1))}, # too short + {"name": b64(b"x" * (passman_server.MAX_SEALED_NAME + 1))}, # too long + {"name": 7}, + {"wrapped_key": None}, + {"wrapped_key": "not base64 !!"}, + {"wrapped_key": b64(os.urandom(passman_server.WRAPPED_KEY_LEN - 1))}, + {"wrapped_key": b64(os.urandom(passman_server.WRAPPED_KEY_LEN + 1))}, +]) +def test_a_bad_vault_is_refused_and_nothing_is_made(server, alice, changes): + session = session_for(server, alice) + status, answer, sent = create(server, session, **changes) + assert status == 400, answer + assert [v["id"] for v in me(server, session)["vaults"]] == [alice.vault] + if isinstance(sent.get("id"), str): + assert not os.path.exists(vault_dir(server, sent["id"])) + + +def test_a_vault_id_cannot_be_used_twice(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + status, _, _ = server.request("POST", "/v2/vaults", + create_body(view["id"]), token=session) + assert status == 409 + assert len(me(server, session)["vaults"]) == 2 + + +def test_an_id_is_taken_across_accounts(server, alice, bob): + alices, bobs = session_for(server, alice), session_for(server, bob) + view, _ = made(server, alices) + assert create(server, bobs, id=view["id"])[0] == 409 + + +def test_a_personal_vault_id_cannot_be_claimed(server, alice, bob): + bobs = session_for(server, bob) + status, _, _ = server.request("POST", "/v2/vaults", + create_body(alice.vault), token=bobs) + assert status == 409 + # ... and not by its own account either + status, _, _ = server.request("POST", "/v2/vaults", create_body(alice.vault), + token=session_for(server, alice)) + assert status == 409 + + +@pytest.mark.parametrize("token", [None, TOKEN, "not-a-session"]) +def test_making_a_vault_needs_a_session(server, alice, token): + status, _, _ = server.request("POST", "/v2/vaults", create_body(), + token=token) + assert status == 401 + + +def test_the_limit_of_vaults_per_account(server, alice, bob, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_VAULTS_PER_ACCOUNT", 3) + session = session_for(server, alice) + for _ in range(3): + assert create(server, session)[0] == 201 + status, _, body = server.request("POST", "/v2/vaults", create_body(), + token=session) + assert status == 403 and "3" in body["error"] + assert len(me(server, session)["vaults"]) == 4 # personal + 3 + # the limit is the account's own + assert create(server, session_for(server, bob))[0] == 201 + + +def test_deleting_a_vault_makes_room_under_the_limit(server, alice, monkeypatch): + monkeypatch.setattr(passman_server, "MAX_VAULTS_PER_ACCOUNT", 1) + session = session_for(server, alice) + view, _ = made(server, session) + assert create(server, session)[0] == 403 + assert server.request("DELETE", f"/v2/vaults/{view['id']}", + token=session)[0] == 200 + assert create(server, session)[0] == 201 + + +# --- the blob of a new vault ----------------------------------------------------- + +def test_a_new_vault_follows_the_2x_revision_rules(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + vid = view["id"] + status, _, body = server.request("GET", f"/v2/vaults/{vid}", token=session) + assert status == 200 and body["revision"] == 0 and body["blob"] is None + + first = vault_blob(vid) + status, _, meta = put_vault(server, session, vid, first, "0") + assert status == 200 and meta["revision"] == 1 + assert put_vault(server, session, vid, vault_blob(vid))[0] == 428 + assert put_vault(server, session, vid, vault_blob(vid), "0")[0] == 409 + second = vault_blob(vid) + status, _, meta = put_vault(server, session, vid, second, "1") + assert status == 200 and meta["revision"] == 2 + + _, _, body = server.request("GET", f"/v2/vaults/{vid}", token=session) + assert body["blob"] == second and body["revision"] == 2 + _, _, meta = server.request("GET", f"/v2/vaults/{vid}/meta", token=session) + assert meta["revision"] == 2 + # the account's view follows, and the personal vault is not touched + vaults = me(server, session)["vaults"] + assert vaults[1]["revision"] == 2 and vaults[0]["revision"] == 0 + + +def test_a_new_vault_refuses_another_vaults_file(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + for other in (alice.vault, new_id()): # even its owner's personal one + status, _, body = put_vault(server, session, view["id"], + vault_blob(other), "0") + assert status == 400 and "another vault" in body["error"] + assert server.request("GET", f"/v2/vaults/{view['id']}", + token=session)[2]["blob"] is None + + +def test_a_new_vault_refuses_a_file_that_is_not_pmv3(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + for blob in (b64(b"PMV2" + os.urandom(80)), VAULT_1): + status, _, body = put_vault(server, session, view["id"], blob, "0") + assert status == 400 and "SecretSpace 3" in body["error"] + + +def test_a_vault_and_the_personal_vault_hold_separate_blobs(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + mine, other = vault_blob(alice.vault), vault_blob(view["id"]) + assert put_vault(server, session, alice.vault, mine, "0")[0] == 200 + assert put_vault(server, session, view["id"], other, "0")[0] == 200 + get = lambda v: server.request("GET", f"/v2/vaults/{v}", token=session)[2] + assert get(alice.vault)["blob"] == mine + assert get(view["id"])["blob"] == other + + +# --- isolation ------------------------------------------------------------------- + +def test_someone_elses_new_vault_is_not_there(server, alice, bob): + alices, bobs = session_for(server, alice), session_for(server, bob) + view, sent = made(server, alices) + vid = view["id"] + blob = vault_blob(vid) + assert put_vault(server, alices, vid, blob, "0")[0] == 200 + + ghost = new_id() # what a vault that does not exist says + _, _, expected = server.request("GET", f"/v2/vaults/{ghost}", token=bobs) + + def asks(target): + return [ + server.request("GET", f"/v2/vaults/{target}", token=bobs), + server.request("GET", f"/v2/vaults/{target}/meta", token=bobs), + put_vault(server, bobs, target, vault_blob(target), "1"), + server.request("PUT", f"/v2/vaults/{target}/name", + {"name": sealed_name()}, token=bobs), + server.request("DELETE", f"/v2/vaults/{target}", token=bobs), + ] + + real, absent = asks(vid), asks(ghost) + for (status, _, answer), (ghost_status, _, ghost_answer) in zip(real, absent): + assert status == 404 == ghost_status + assert answer == ghost_answer == expected + assert answer["error"] == "No such vault." + + # untouched: still there, same blob, same name, same revision + _, _, body = server.request("GET", f"/v2/vaults/{vid}", token=alices) + assert body["blob"] == blob and body["revision"] == 1 + listed = me(server, alices)["vaults"] + assert listed[1]["name"] == sent["name"] and listed[1]["id"] == vid + assert [v["id"] for v in me(server, bobs)["vaults"]] == [bob.vault] + + +# --- rename ---------------------------------------------------------------------- + +def test_the_owner_renames_a_vault(server, alice): + session = session_for(server, alice) + view, sent = made(server, session) + new_name = sealed_name(60) + assert new_name != sent["name"] + status, _, answer = server.request("PUT", f"/v2/vaults/{view['id']}/name", + {"name": new_name}, token=session) + assert status == 200 + assert answer == dict(view, name=new_name) + assert me(server, session)["vaults"][1]["name"] == new_name + # the key and the blob are not touched + assert me(server, session)["vaults"][1]["wrapped_key"] == sent["wrapped_key"] + + +@pytest.mark.parametrize("body", [ + {}, {"name": None}, {"name": "not base64 !!"}, {"name": 5}, + {"name": b64(b"x" * (passman_server.MIN_SEALED_NAME - 1))}, + {"name": b64(b"x" * (passman_server.MAX_SEALED_NAME + 1))}, +]) +def test_a_bad_new_name_is_refused(server, alice, body): + session = session_for(server, alice) + view, sent = made(server, session) + status, _, _ = server.request("PUT", f"/v2/vaults/{view['id']}/name", body, + token=session) + assert status == 400 + assert me(server, session)["vaults"][1]["name"] == sent["name"] + + +def test_the_personal_vault_has_no_name_to_change(server, alice): + session = session_for(server, alice) + status, _, _ = server.request("PUT", f"/v2/vaults/{alice.vault}/name", + {"name": sealed_name()}, token=session) + assert status == 400 + assert "name" not in me(server, session)["vaults"][0] + + +def test_only_an_owner_may_rename_or_delete(server, alice, bob): + """Nobody but an owner can become a member yet, so a non-owner is made + here by editing the members file, the way a later phase will.""" + alices, bobs = session_for(server, alice), session_for(server, bob) + view, sent = made(server, alices) + path = os.path.join(vault_dir(server, view["id"]), "members.json") + with open(path, encoding="utf-8") as f: + members = json.load(f) + members["members"]["bob"] = {"role": "viewer", "wrapped_key": sent["wrapped_key"]} + with open(path, "w", encoding="utf-8") as f: + json.dump(members, f) + + assert server.request("PUT", f"/v2/vaults/{view['id']}/name", + {"name": sealed_name()}, token=bobs)[0] == 403 + assert server.request("DELETE", f"/v2/vaults/{view['id']}", + token=bobs)[0] == 403 + # a viewer cannot write the blob either + assert put_vault(server, bobs, view["id"], vault_blob(view["id"]), + "0")[0] == 403 + assert me(server, alices)["vaults"][1]["name"] == sent["name"] + assert os.path.isdir(vault_dir(server, view["id"])) + + +def test_a_name_cannot_be_put_on_a_wrong_route(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + for method in ("GET", "POST", "DELETE"): + status, _, _ = server.request(method, f"/v2/vaults/{view['id']}/name", + {"name": sealed_name()}, token=session) + assert status == 404 + status, _, _ = server.request("PUT", f"/v2/vaults/{view['id']}/other", + {}, token=session) + assert status == 404 + + +# --- delete ---------------------------------------------------------------------- + +def test_the_owner_deletes_a_vault_and_it_is_moved_not_erased(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + vid = view["id"] + blob = vault_blob(vid) + assert put_vault(server, session, vid, blob, "0")[0] == 200 + keep = made(server, session)[0] + + status, _, _ = server.request("DELETE", f"/v2/vaults/{vid}", token=session) + assert status == 200 + + assert [v["id"] for v in me(server, session)["vaults"]] == [alice.vault, + keep["id"]] + for tail in ("", "/meta"): + assert server.request("GET", f"/v2/vaults/{vid}{tail}", + token=session)[0] == 404 + assert put_vault(server, session, vid, blob, "1")[0] == 404 + assert server.request("DELETE", f"/v2/vaults/{vid}", token=session)[0] == 404 + + assert not os.path.exists(vault_dir(server, vid)) + graveyard = os.path.join(server.store.root, "_v2", "deleted") + moved = [n for n in os.listdir(graveyard) if n.startswith(vid + "-")] + assert len(moved) == 1 + folder = os.path.join(graveyard, moved[0]) + with open(os.path.join(folder, "vault.enc"), "rb") as f: + assert f.read() == blob.encode("ascii") # stored as sent + assert os.path.isfile(os.path.join(folder, "members.json")) + # the other vault is untouched + assert os.path.isdir(vault_dir(server, keep["id"])) + + +def test_a_deleted_vault_id_is_free_again(server, alice): + """Its folder left the vaults folder, so the id counts as unused: a new + vault may take it, and the old one stays in the graveyard.""" + session = session_for(server, alice) + view, _ = made(server, session) + vid = view["id"] + assert put_vault(server, session, vid, vault_blob(vid), "0")[0] == 200 + assert server.request("DELETE", f"/v2/vaults/{vid}", token=session)[0] == 200 + + status, again, _ = create(server, session, id=vid) + assert status == 201 + assert again["revision"] == 0 + assert server.request("GET", f"/v2/vaults/{vid}", + token=session)[2]["blob"] is None + graveyard = os.path.join(server.store.root, "_v2", "deleted") + assert len([n for n in os.listdir(graveyard) if n.startswith(vid)]) == 1 + + +def test_the_personal_vault_cannot_be_deleted(server, alice): + session = session_for(server, alice) + blob = vault_blob(alice.vault) + assert put_vault(server, session, alice.vault, blob, "0")[0] == 200 + status, _, _ = server.request("DELETE", f"/v2/vaults/{alice.vault}", + token=session) + assert status == 400 + status, _, body = server.request("GET", f"/v2/vaults/{alice.vault}", + token=session) + assert status == 200 and body["blob"] == blob + assert me(server, session)["vaults"][0]["id"] == alice.vault + assert os.path.isdir(vault_dir(server, alice.vault)) + assert not os.path.exists(os.path.join(server.store.root, "_v2", "deleted")) + assert put_vault(server, session, alice.vault, vault_blob(alice.vault), + "1")[0] == 200 + + +def test_delete_needs_a_session(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + for token in (None, TOKEN): + assert server.request("DELETE", f"/v2/vaults/{view['id']}", + token=token)[0] == 401 + assert os.path.isdir(vault_dir(server, view["id"])) + + +@pytest.mark.parametrize("path", ["/vault/alice", "/vault/alice/meta", "/health", + "/nowhere"]) +def test_delete_outside_v2_is_not_implemented(server, path): + server.push("alice", VAULT_1) + status, _, _ = server.request("DELETE", path) + assert status == 501 + assert server.pull("alice")[0] == 200 + + +# --- older data and 2.x -------------------------------------------------------- + +def test_an_account_from_alpha_1_has_no_vault_list_and_still_works(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) + assert record.pop("vaults", None) in (None, []) # alpha.1 never wrote it + with open(path, "w", encoding="utf-8") as f: + json.dump(record, f) + assert "vaults" not in json.load(open(path, encoding="utf-8")) + + session = session_for(server, alice) + assert [v["id"] for v in me(server, session)["vaults"]] == [alice.vault] + view, _ = made(server, session) + assert [v["id"] for v in me(server, session)["vaults"]] == [alice.vault, + view["id"]] + assert server.request("DELETE", f"/v2/vaults/{view['id']}", + token=session)[0] == 200 + assert [v["id"] for v in me(server, session)["vaults"]] == [alice.vault] + + +def test_2x_vaults_are_unaffected_by_v2_vaults(server, alice): + session = session_for(server, alice) + view, _ = made(server, session) + assert put_vault(server, session, view["id"], vault_blob(view["id"]), + "0")[0] == 200 + status, _, meta = server.push("alice", VAULT_1, if_match="0") + assert status == 200 and meta["revision"] == 1 + status, _, body = server.pull("alice") + assert status == 200 and body["blob"] == VAULT_1 + server.request("DELETE", f"/v2/vaults/{view['id']}", token=session) + assert server.pull("alice")[2]["blob"] == VAULT_1