Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,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/<id>` | GET / PUT | session, member | the vault blob, as `/vault/<user>` (PUT: owner or editor; it must be a PMV3 file naming this vault) |
| `/v2/vaults/<id>/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/<id>/name` | PUT | session, owner | change the vault's name (not for the personal vault) |
| `/v2/vaults/<id>` | DELETE | session, owner | delete the vault (not the personal one, which goes with the account) |

Sessions are sent as `Authorization: Bearer <session>`. 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/<id>-<date>` 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/<id>/` and `server.key` (for the
Expand Down
153 changes: 143 additions & 10 deletions server/passman_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 <store>/_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 ""):
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion server/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
in SecretSpace-Server-Windows, which has its own version.
"""

__version__ = "2.0.0-alpha.1"
__version__ = "2.0.0-alpha.2"
3 changes: 2 additions & 1 deletion tests/test_server_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [
Expand Down
Loading
Loading