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):