From 828766555028c4d892a8941735ce1e84035f9441 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Thu, 12 Nov 2026 14:05:13 +0200 Subject: [PATCH] Server 2.0.0-alpha.5: keep a signature with every wrapped vault key Security fix (SecretSpace 3 test versions only). The server stores each member's wrapped copy of a vault key; a device took whatever copy was listed, so a dishonest server could wrap a key of its own, call it a new key generation, and read the vault uploaded under it. The clients now sign each wrapped key and check the signature before they take one. The server keeps the signature with the key and returns it: - key_sig in POST /v2/vaults and in PUT .../members/ (new member), key_sigs in POST .../rotate (one per member who stays; a key change without them clears the old ones); - key_sig and key_signer with each vault in /v2/me; key_signer is the account whose session sent the key, never a name from the request; - PUT /v2/vaults//key-sig: a member signs the key it already holds (vaults from before this version), only while it has no signature; - /health lists "signed-keys". The server does not verify the signatures; the devices do. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 30 ++++- server/passman_server.py | 65 +++++++++- server/version.py | 2 +- tests/test_server_http.py | 2 +- tests/test_server_v2_key_sigs.py | 198 +++++++++++++++++++++++++++++++ tests/test_server_v2_recovery.py | 2 +- tests/test_server_v2_vaults.py | 5 +- 7 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 tests/test_server_v2_key_sigs.py diff --git a/README.md b/README.md index 51ddbf3..5914ad9 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ 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.7, Linux -1.0.0-alpha.7) sync with the newest test version of the server, 2.0.0-alpha.4, +0.x sync with it. The SecretSpace 3 test versions (Windows 3.0.0-alpha.8, Linux +1.0.0-alpha.8) sync with the newest test version of the server, 2.0.0-alpha.5, through the accounts, sharing and recovery described under API v2 below. This repository is the server for **Linux**, run by systemd. The other @@ -539,6 +539,7 @@ like a 2.x one, with the same revision, `If-Match` and history rules. | `/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 | +| `/v2/vaults//key-sig` | PUT | session, member | sign the wrapped key you already hold: `{key_sig}`, 64 bytes in base64. Only while that key has no signature (**409** otherwise) | 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 @@ -603,6 +604,31 @@ 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", "recovery"]`. +Server **2.0.0-alpha.5** keeps a **signature with every wrapped vault key**. +It goes with the Windows 3.0.0-alpha.8 and Linux 1.0.0-alpha.8 test versions, +which sync only the Personal vault with an older server. + +- *Why.* The server stores each member's copy of a vault key but must never + be able to choose one. Before this version a dishonest server (or someone + who broke into it) could wrap a key of its own to a member's public key and + call it a new key generation; the device would then upload the vault under + that key, and the server could read it. +- *How.* The account that wraps a key signs it (Ed25519): which vault, which + key generation, for whom, the wrapped key itself. `key_sig` travels with + `wrapped_key` in `POST /v2/vaults`, in `PUT /v2/vaults//members/` + for a new member, and as `key_sigs` (one per member who stays) in `rotate`. + `/v2/me` returns `key_sig` and `key_signer` with each vault. +- *Who checks.* The devices, not the server: it has no key to check with and + needs none. A device takes a key only when it is signed by its own account + or by someone it has reason to trust, and never one of an older generation. +- `key_signer` is always the account whose session sent the key, never a + name from the request. +- A vault from an earlier version has unsigned keys. A device that already + holds such a key signs it at its next sync (`PUT /v2/vaults//key-sig`), + after which the account's other devices take it. A signature made by an + owner is never replaced this way. +- `/health` lists `"signed-keys"`. + Server **2.0.0-alpha.4** lets an account be **recovered with its 12-word phrase** when the master password is forgotten. It goes with the Windows 3.0.0-alpha.7 and Linux 1.0.0-alpha.7 test versions. diff --git a/server/passman_server.py b/server/passman_server.py index 8c8480a..8632510 100644 --- a/server/passman_server.py +++ b/server/passman_server.py @@ -260,6 +260,21 @@ def _archive(self, user: str, revision: int) -> None: #: version, a one-off X25519 public key, a nonce, the key and its tag. WRAPPED_KEY_LEN = 1 + 32 + 12 + 32 + 16 +#: With each wrapped key comes a signature by the account that wrapped it +#: (Ed25519, 64 bytes). A device accepts a vault key only with a signature +#: from itself or from someone it knows -- so a dishonest server cannot hand +#: out a key of its own making and read what is then uploaded under it. The +#: server only keeps and returns the signature; the devices check it. +KEY_SIG_LEN = 64 + + +def _key_sig(value): + """An optional key signature as stored ("" when none came).""" + if value is None or value == "": + return "" + return _b64(_unb64(value, "The key's signature", KEY_SIG_LEN)) + + #: 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 @@ -612,6 +627,8 @@ def _vault_view(self, username: str, vault_id: str): if view["kind"] != "personal": view["name"] = members.get("name", "") view["wrapped_key"] = member.get("wrapped_key", "") + view["key_sig"] = member.get("key_sig", "") + view["key_signer"] = member.get("key_signer", "") return view def me(self, username: str) -> dict: @@ -805,6 +822,7 @@ def create_vault(self, username: str, body: dict) -> dict: name = self._sealed_name(body) wrapped = _b64(_unb64(body.get("wrapped_key"), "The vault key", WRAPPED_KEY_LEN)) + key_sig = _key_sig(body.get("key_sig")) with _lock: record = self.account(username) mine = list(record.get("vaults", [])) @@ -819,7 +837,9 @@ def create_vault(self, username: str, body: dict) -> dict: self._write_json(self._members_path(vault_id), { "kind": "shared", "created_at": utc_now(), "created_by": username, "name": name, "key_generation": 0, - "members": {username: {"role": "owner", "wrapped_key": wrapped}}}) + "members": {username: {"role": "owner", "wrapped_key": wrapped, + "key_sig": key_sig, + "key_signer": username}}}) record["vaults"] = mine + [vault_id] self._write_json(self._account_path(username), record) return self._vault_view(username, vault_id) @@ -911,6 +931,7 @@ def put_member(self, actor: str, vault_id: str, name: str, body: dict) -> dict: return {"username": who, "role": role, "added": False} wrapped = _b64(_unb64(body.get("wrapped_key"), "The vault key", WRAPPED_KEY_LEN)) + key_sig = _key_sig(body.get("key_sig")) if len(people) >= MAX_MEMBERS_PER_VAULT: raise V2Error(403, f"A vault can have {MAX_MEMBERS_PER_VAULT} " "members.") @@ -918,6 +939,7 @@ def put_member(self, actor: str, vault_id: str, name: str, body: dict) -> dict: if len(listed) >= MAX_VAULTS_LISTED: raise V2Error(409, f"“{who}” is in too many vaults already.") people[who] = {"role": role, "wrapped_key": wrapped, + "key_sig": key_sig, "key_signer": actor, "added_by": actor, "added_at": utc_now()} self._write_json(self._members_path(vault_id), members) target["vaults"] = listed + [vault_id] @@ -979,6 +1001,12 @@ def rotate(self, actor: str, vault_id: str, body: dict) -> dict: "meantime. Sync, then try again.") clean = {name: _b64(_unb64(wrapped[name], "A vault key", WRAPPED_KEY_LEN)) for name in staying} + sigs = body.get("key_sigs") or {} + if not isinstance(sigs, dict) or \ + (sigs and sorted(sigs) != sorted(staying)): + raise V2Error(400, "The keys' signatures do not match the " + "members who stay.") + sigs = {name: _key_sig(sigs.get(name)) for name in staying} name = self._sealed_name(body) current = int(self.vaults.meta(vault_id)["revision"]) @@ -1003,6 +1031,8 @@ def rotate(self, actor: str, vault_id: str, body: dict) -> dict: del people[gone] for each in staying: people[each]["wrapped_key"] = clean[each] + people[each]["key_sig"] = sigs[each] + people[each]["key_signer"] = actor members["key_generation"] = generation members["name"] = name self._write_json(self._members_path(vault_id), members) @@ -1010,6 +1040,24 @@ def rotate(self, actor: str, vault_id: str, body: dict) -> dict: self._unlist(gone, vault_id) return self._vault_view(actor, vault_id) + def set_key_sig(self, username: str, vault_id: str, body: dict) -> None: + """ + A member vouches for the wrapped key it already holds, for a vault + made before keys were signed -- so its other devices will accept + that key. Only while that key has no signature: one made by an + owner is never replaced. + """ + sig = _key_sig(body.get("key_sig")) + if not sig: + raise V2Error(400, "The key's signature is missing.") + with _lock: + members = self._shared(vault_id) + entry = members.get("members", {}).get(username) + if entry.get("key_sig"): + raise V2Error(409, "This key is signed already.") + entry["key_sig"], entry["key_signer"] = sig, username + self._write_json(self._members_path(vault_id), members) + 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)) @@ -1101,11 +1149,12 @@ def do_GET(self): # per account (2.0.0-alpha.2). # "sharing" = members, roles, key change and the audit log # (2.0.0-alpha.3). "recovery" = an account recovered with its - # 12-word phrase (2.0.0-alpha.4). + # 12-word phrase (2.0.0-alpha.4). "signed-keys" = a signature is + # kept with every wrapped vault key (2.0.0-alpha.5). return self._send(200, {"ok": True, "service": "passman-sync", "api": [1, 2], "features": ["vaults", "sharing", - "recovery"]}) + "recovery", "signed-keys"]}) if self._is_v2(): return self._v2("GET") @@ -1385,7 +1434,7 @@ def _v2_route(self, method, parts): # 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", "members", - "rotate", "log"): + "rotate", "log", "key-sig"): return self._error(404, "No such vault.") if len(parts) == 4 and tail != "members": return self._error(404, "No such endpoint.") @@ -1479,9 +1528,15 @@ def log(accounts, vault_id, actor, address): return {"run": leave, "anyone": True} if (method, tail, count) == ("POST", "rotate", 3): return {"run": body_then(rotate), "anyone": False} + def key_sig(accounts, vault_id, actor, address, body): + accounts.set_key_sig(actor, vault_id, body) + return self._send(200, {}) + if (method, tail, count) == ("GET", "log", 3): return {"run": log, "anyone": False} - if tail in ("name", "rotate", "log") or method == "DELETE" \ + if (method, tail, count) == ("PUT", "key-sig", 3): + return {"run": body_then(key_sig), "anyone": True} + if tail in ("name", "rotate", "log", "key-sig") or method == "DELETE" \ or (tail == "members" and (count == 4 or method != "GET")): return {"run": lambda *a: self._error(404, "No such endpoint."), "anyone": True} diff --git a/server/version.py b/server/version.py index 9bdbda1..1670254 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.4" +__version__ = "2.0.0-alpha.5" diff --git a/tests/test_server_http.py b/tests/test_server_http.py index 9998744..2c76aeb 100644 --- a/tests/test_server_http.py +++ b/tests/test_server_http.py @@ -108,7 +108,7 @@ def test_health_answers_without_a_token(server): status, _, body = server.request("GET", "/health", token=None) assert status == 200 assert body == {"ok": True, "service": "passman-sync", "api": [1, 2], - "features": ["vaults", "sharing", "recovery"]} + "features": ["vaults", "sharing", "recovery", "signed-keys"]} @pytest.mark.parametrize("header", [ diff --git a/tests/test_server_v2_key_sigs.py b/tests/test_server_v2_key_sigs.py new file mode 100644 index 0000000..5e1fe20 --- /dev/null +++ b/tests/test_server_v2_key_sigs.py @@ -0,0 +1,198 @@ +""" +API v2: the signature kept with every wrapped vault key (2.0.0-alpha.5). + +The server cannot check these signatures (standard library only) and does +not need to: a device does, before it takes a key. The server's part is to +store the signature with the key it came with, say who it came from -- the +account whose session sent it, never a name from the request -- and hand +both back. +""" + +import os + +import pytest + +from test_server_http import TOKEN, server # noqa: F401 (fixture) +from test_server_v2 import Account, alice, b64, session_for, signup # noqa: F401 +from test_server_v2_vaults import bob, create, made, wrapped_key # noqa: F401 +from test_server_v2_sharing import (carol, my_vault, post_rotate, # noqa: F401 + put_member, rotate_body, state, team, + team_up) +import passman_server # noqa: E402 + + +def sig(size: int = passman_server.KEY_SIG_LEN) -> str: + return b64(os.urandom(size)) + + +def put_key_sig(server, session, vault_id, body): + status, _, answer = server.request( + "PUT", f"/v2/vaults/{vault_id}/key-sig", body, token=session) + return status, answer + + +def test_health_lists_signed_keys(server): + _, _, body = server.request("GET", "/health", token=None) + assert "signed-keys" in body["features"] + + +# --- creating a vault --------------------------------------------------------- +def test_a_vault_keeps_the_signature_it_was_made_with(server, alice): + session = session_for(server, alice) + signature = sig() + status, view, sent = create(server, session, key_sig=signature) + assert status == 201 + assert (view["key_sig"], view["key_signer"]) == (signature, "alice") + mine = my_vault(server, session, sent["id"]) + assert (mine["key_sig"], mine["key_signer"]) == (signature, "alice") + + +def test_a_vault_made_without_a_signature_says_so(server, alice): + session = session_for(server, alice) + status, view, _ = create(server, session) + assert status == 201 + assert view["key_sig"] == "" + + +@pytest.mark.parametrize("bad", [b64(os.urandom(63)), b64(os.urandom(65)), + "not base64 !!", 12345]) +def test_a_malformed_signature_is_refused_and_no_vault_is_made(server, alice, bad): + session = session_for(server, alice) + status, _, sent = create(server, session, key_sig=bad) + assert status == 400 + assert my_vault(server, session, sent["id"]) is None + + +def test_the_signer_is_the_session_not_a_name_in_the_request(server, alice): + session = session_for(server, alice) + status, view, _ = create(server, session, key_sig=sig(), key_signer="mallory") + assert status == 201 + assert view["key_signer"] == "alice" + + +# --- adding a member ----------------------------------------------------------- +def test_a_new_member_gets_the_owners_signature_with_the_key(server, alice, bob): + sa, sb = session_for(server, alice), session_for(server, bob) + view, _ = made(server, sa) + key, signature = wrapped_key(), sig() + assert put_member(server, sa, view["id"], "bob", "editor", wrapped=key, + key_sig=signature)[0] == 200 + theirs = my_vault(server, sb, view["id"]) + assert (theirs["wrapped_key"], theirs["key_sig"], theirs["key_signer"]) == \ + (key, signature, "alice") + + +def test_a_bad_signature_adds_nobody(server, alice, bob): + sa, sb = session_for(server, alice), session_for(server, bob) + view, _ = made(server, sa) + before = state(server, view["id"], "alice", "bob") + assert put_member(server, sa, view["id"], "bob", "editor", + key_sig=b64(os.urandom(10)))[0] == 400 + assert state(server, view["id"], "alice", "bob") == before + assert my_vault(server, sb, view["id"]) is None + + +def test_a_role_change_keeps_the_key_and_its_signature(server, alice, bob): + sa, sb = session_for(server, alice), session_for(server, bob) + view, _ = made(server, sa) + signature = sig() + put_member(server, sa, view["id"], "bob", "editor", key_sig=signature) + before = my_vault(server, sb, view["id"]) + assert put_member(server, sa, view["id"], "bob", "reader", wrapped=None, + key_sig=sig())[0] == 200 + after = my_vault(server, sb, view["id"]) + assert (after["wrapped_key"], after["key_sig"], after["key_signer"]) == \ + (before["wrapped_key"], signature, "alice") + + +# --- changing the key ------------------------------------------------------------ +def test_a_key_change_stores_each_members_new_signature(server, team_up): + t = team_up + body = rotate_body(t.vid, ["alice", "bob", "Carol"]) + body["key_sigs"] = {name: sig() for name in body["wrapped_keys"]} + assert post_rotate(server, t.sa, t.vid, body)[0] == 200 + for name, session in (("alice", t.sa), ("bob", t.sb), ("Carol", t.sc)): + mine = my_vault(server, session, t.vid) + assert (mine["wrapped_key"], mine["key_sig"], mine["key_signer"]) == \ + (body["wrapped_keys"][name], body["key_sigs"][name], "alice") + + +def test_a_key_change_without_signatures_clears_the_old_ones(server, alice): + sa = session_for(server, alice) + status, view, sent = create(server, sa, key_sig=sig()) + body = rotate_body(sent["id"], ["alice"], revision=0) + body.pop("blob"), body.pop("revision") + assert post_rotate(server, sa, sent["id"], body)[0] == 200 + mine = my_vault(server, sa, sent["id"]) + # an old signature must not sit beside a key it was not made for + assert mine["key_sig"] == "" and mine["wrapped_key"] == body["wrapped_keys"]["alice"] + + +@pytest.mark.parametrize("change", ["missing one", "one too many", "bad length", + "not a dict"]) +def test_signatures_that_do_not_fit_the_members_change_nothing(server, team_up, + change): + t = team_up + body = rotate_body(t.vid, ["alice", "bob", "Carol"]) + sigs = {name: sig() for name in body["wrapped_keys"]} + if change == "missing one": + sigs.pop("bob") + elif change == "one too many": + sigs["mallory"] = sig() + elif change == "bad length": + sigs["bob"] = b64(os.urandom(12)) + else: + sigs = ["x"] + body["key_sigs"] = sigs + before = state(server, t.vid, "alice", "bob", "Carol") + assert post_rotate(server, t.sa, t.vid, body)[0] == 400 + assert state(server, t.vid, "alice", "bob", "Carol") == before + + +# --- a member vouches for the key it holds (vaults from before signatures) --------- +def test_a_member_signs_its_own_unsigned_key_once(server, team): + t = team + signature = sig() + assert put_key_sig(server, t.sb, t.vid, {"key_sig": signature})[0] == 200 + mine = my_vault(server, t.sb, t.vid) + assert (mine["key_sig"], mine["key_signer"]) == (signature, "bob") + # nobody else's entry changed + assert my_vault(server, t.sa, t.vid)["key_sig"] == "" + # and it cannot be replaced + assert put_key_sig(server, t.sb, t.vid, {"key_sig": sig()})[0] == 409 + assert my_vault(server, t.sb, t.vid)["key_sig"] == signature + + +def test_a_key_signed_by_an_owner_is_not_replaced_by_the_member(server, alice, bob): + sa, sb = session_for(server, alice), session_for(server, bob) + view, _ = made(server, sa) + signature = sig() + put_member(server, sa, view["id"], "bob", "editor", key_sig=signature) + assert put_key_sig(server, sb, view["id"], {"key_sig": sig()})[0] == 409 + mine = my_vault(server, sb, view["id"]) + assert (mine["key_sig"], mine["key_signer"]) == (signature, "alice") + + +@pytest.mark.parametrize("body", [{}, {"key_sig": ""}, {"key_sig": "!!"}, + {"key_sig": b64(os.urandom(63))}]) +def test_a_missing_or_malformed_own_signature_is_refused(server, team, body): + t = team + before = state(server, t.vid, "alice", "bob", "Carol") + assert put_key_sig(server, t.sb, t.vid, body or {"x": 1})[0] == 400 + assert state(server, t.vid, "alice", "bob", "Carol") == before + + +def test_only_a_member_can_sign_and_only_with_a_session(server, team): + t = team + outsider = Account("dave") + assert signup(server, outsider)[0] == 201 + assert put_key_sig(server, session_for(server, outsider), t.vid, + {"key_sig": sig()})[0] == 404 + assert put_key_sig(server, None, t.vid, {"key_sig": sig()})[0] == 401 + status, _, _ = server.request("GET", f"/v2/vaults/{t.vid}/key-sig", token=t.sb) + assert status == 404 + + +def test_the_personal_vault_has_no_key_to_sign(server, alice): + session = session_for(server, alice) + assert put_key_sig(server, session, alice.vault, {"key_sig": sig()})[0] == 400 diff --git a/tests/test_server_v2_recovery.py b/tests/test_server_v2_recovery.py index a9030f4..1dca2a8 100644 --- a/tests/test_server_v2_recovery.py +++ b/tests/test_server_v2_recovery.py @@ -143,7 +143,7 @@ def small_lockout(monkeypatch): def test_health_lists_vaults_sharing_and_recovery(server): _, _, body = server.request("GET", "/health", token=None) - assert body["features"] == ["vaults", "sharing", "recovery"] + assert body["features"] == ["vaults", "sharing", "recovery", "signed-keys"] # --- sign-up with a proof ---------------------------------------------------- diff --git a/tests/test_server_v2_vaults.py b/tests/test_server_v2_vaults.py index 231f2c3..97113f5 100644 --- a/tests/test_server_v2_vaults.py +++ b/tests/test_server_v2_vaults.py @@ -74,7 +74,7 @@ def bob(server): def test_health_says_the_server_has_vaults(server): _, _, body = server.request("GET", "/health", token=None) - assert body["features"] == ["vaults", "sharing", "recovery"] + assert body["features"] == ["vaults", "sharing", "recovery", "signed-keys"] # --- create -------------------------------------------------------------------- @@ -85,7 +85,8 @@ def test_a_vault_is_made_and_the_owner_is_told_about_it(server, alice): assert status == 201 assert view == {"id": sent["id"], "kind": "shared", "role": "owner", "revision": 0, "key_generation": 0, "members": 1, - "name": sent["name"], "wrapped_key": sent["wrapped_key"]} + "name": sent["name"], "wrapped_key": sent["wrapped_key"], + "key_sig": "", "key_signer": "alice"} def test_me_lists_the_personal_vault_first_then_the_others(server, alice):