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
45 changes: 40 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.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.
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,
through the accounts, sharing and recovery described under API v2 below.

This repository is the server for **Linux**, run by systemd. The other
parts of SecretSpace have their own repositories:
Expand Down Expand Up @@ -521,7 +521,7 @@ 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, signing key, keyset, recovery copy, its vaults (each with its role, key generation and member count) |
| `/v2/me` | GET | session | the account: public key, signing key, `can_recover` (whether a recovery proof is stored), 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/<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 |
Expand All @@ -530,6 +530,10 @@ like a 2.x one, with the same revision, `If-Match` and history rules.
| `/v2/vaults/<id>` | 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/<user>/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/recovery/<user>` | GET | anyone | the account's recovery copy: `{username, recovery}`, with the name as the server spells it (the lookup ignores letter case). A name with no account, or a disabled one, gets a made-up, stable copy of the same shape. **400** for an invalid name |
| `/v2/recovery` | POST | anyone | recover the account: `{username, recovery_proof, login_key, keyset, recovery}`. **200** with the right proof; **401** for a wrong proof, an unknown name or a disabled account (the same message); **409** when the account has no proof stored yet; **400** for a bad input (a proof or Login Key that is not 32 bytes, a keyset that is a recovery copy or the other way round); **429** when the name is paused |
| `/v2/me/recovery-proof` | PUT | session | store the recovery proof (hashed): `{recovery_proof}`, 32 bytes in base64. Once only: the same proof again is **200**, another one **409**, a wrong length or bad base64 **400** |
| `/v2/me/recovery` | PUT | session | a new 12-word phrase: `{recovery}`, the keyset sealed under it. **400** if it is not a recovery copy (kdf byte 0); the old phrase then opens nothing |
| `/v2/vaults/<id>/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/<id>/members/<user>` | 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/<id>/members/<user>` | DELETE | session, yourself | leave the vault. **409** for its only owner. Anyone else is removed with `rotate` (an owner who tries this gets **400**) |
Expand Down Expand Up @@ -597,7 +601,38 @@ server never learns of it. A log file that reaches 2 MB is renamed to
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"]`.
`"features": ["vaults", "sharing", "recovery"]`.

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.

- The device asks `GET /v2/recovery/<user>` and gets the recovery copy: the
account's keyset sealed under the phrase. It opens only with the phrase, so
handing it to anyone is safe. For a name with no account the server returns
a made-up copy (the same on every ask, the same shape and size), so the
call cannot be used to find out which names exist.
- The device opens the copy with the phrase and proves it by sending a
**recovery proof**, a value derived from the account's own keys. The server
keeps only a salted hash of it, as it does for the Login Key. A new phrase
does not change the keys, so it does not change the proof.
- An account made by 2.0.0-alpha.4 sends the proof at sign-up. An older
account sends it once, from a session, at its next sync
(`PUT /v2/me/recovery-proof`); it cannot be replaced afterwards. Until then
`can_recover` in `/v2/me` is false and a recovery answers **409**.
- With the right proof, `POST /v2/recovery` stores the same keys under the new
password (a new keyset and Login Key) and under a new phrase (a new
recovery copy), and **ends every session of the account**. The old Login Key
stops working. A wrong proof changes nothing.
- A new phrase can also be set from a session, without recovering
(`PUT /v2/me/recovery`). The server checks only that it is a recovery copy.
- Wrong proofs are counted with wrong logins: they share one count per name,
so after 10 wrong tries in 15 minutes of either kind, both a login and a
recovery for that name answer **429** for a minute. A successful login or
recovery clears the count.
- `_v2/logins.log` gets `account_recovered`, `recovery_failed` (with the
status) and `recovery_phrase_changed` lines. They never hold the proof, the
Login Key or the copy.

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/`,
Expand Down
147 changes: 145 additions & 2 deletions server/passman_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,15 @@ def _archive(self, user: str, revision: int) -> None:
MIN_SEALED_NAME = 12 + 1 + 16
MAX_SEALED_NAME = 12 + 200 + 16

#: What a device proves it can open the account's keyset with, when it
#: recovers the account with the 12-word phrase (account_crypto.recovery_proof):
#: 32 bytes, stored here only as a hash, like the Login Key.
RECOVERY_PROOF_LEN = 32

#: The size of a recovery copy for an account's usual keyset, for the
#: made-up answer about a name that has no account (see recovery_copy).
_RECOVERY_BODY = 12 + 133 + 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 @@ -460,6 +469,10 @@ def create_account(self, body: dict) -> dict:
signing_key = body.get("signing_key")
if signing_key is not None:
signing_key = _b64(_unb64(signing_key, "The signing key", 32))
proof = body.get("recovery_proof")
if proof is not None:
proof = hash_login_key(_unb64(proof, "The recovery proof",
RECOVERY_PROOF_LEN))
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.")
Expand All @@ -479,6 +492,7 @@ def create_account(self, body: dict) -> dict:
"login": hash_login_key(login_key),
"public_key": _b64(public_key),
"signing_key": signing_key or "",
"recovery_check": proof,
"keyset": body["keyset"],
"recovery": body["recovery"],
"kdf": kdf,
Expand Down Expand Up @@ -610,6 +624,7 @@ def me(self, username: str) -> dict:
vaults.append(view)
return {"username": record["username"], "public_key": record["public_key"],
"signing_key": record.get("signing_key", ""),
"can_recover": bool(record.get("recovery_check")),
"keyset": record["keyset"], "recovery": record["recovery"],
"kdf": record["kdf"], "personal_vault": vault_id,
"vaults": vaults}
Expand Down Expand Up @@ -648,6 +663,103 @@ def set_signing_key(self, username: str, body: dict) -> None:
record["signing_key"] = key
self._write_json(self._account_path(username), record)

# -- recovery (phase 5) -----------------------------------------------------
def set_recovery_proof(self, username: str, body: dict) -> None:
"""
What lets this account be recovered here with its 12-word phrase.
An account made before recovery existed sends it once, at its next
sync; it cannot be replaced afterwards. (It comes from the account's
own keys, which a new phrase does not change.)
"""
proof = _unb64(body.get("recovery_proof"), "The recovery proof",
RECOVERY_PROOF_LEN)
with _lock:
record = self.account(username)
had = record.get("recovery_check")
if had and not login_key_matches(proof, had):
raise V2Error(409, "This account can already be recovered; "
"that cannot be replaced.")
if not had:
record["recovery_check"] = hash_login_key(proof)
self._write_json(self._account_path(username), record)

def set_recovery_copy(self, username: str, body: dict) -> None:
"""A new 12-word phrase: the keyset under it replaces the old copy,
so the old phrase opens nothing from now on."""
keyset_kdf(body.get("recovery"), recovery=True)
with _lock:
record = self.account(username)
record.update({"recovery": body["recovery"],
"recovery_changed_at": utc_now()})
self._write_json(self._account_path(username), record)

def recovery_copy(self, username: str) -> dict:
"""
The account's keyset under its 12-word phrase, for a device that
recovers the account. Handing it out is safe: it opens only with the
phrase, 128 random bits. For a name with no account the answer is
made up, the same on every ask, so it says nothing about which names
exist (as prelogin).
"""
if not valid_username(username):
raise V2Error(400, "Not a valid username.")
# Any letter case, as the account's file is found (prelogin's rule).
record = self._read_json(self._account_path(username))
if record and record.get("username") and not record.get("disabled"):
return {"username": record["username"], "recovery": record["recovery"]}
key = self._server_key()
filler, block = b"", 0
while len(filler) < _RECOVERY_BODY:
filler += hmac.new(key, b"recovery|%d|" % block +
username.lower().encode(), hashlib.sha256).digest()
block += 1
header = _KEYSET_MAGIC + bytes([1, 0]) + bytes(4 + 1 + 1 + 16)
return {"username": username,
"recovery": _b64(header + filler[:_RECOVERY_BODY])}

def recover(self, body: dict) -> dict:
"""
A forgotten master password: the device opened the recovery copy
with the phrase, proves it holds the account's keys, and sends the
same keys under a new password (and the Login Key that goes with
it) and under a new phrase. Every session of the account ends.
"""
username = body.get("username")
if not isinstance(username, str) or not valid_username(username):
raise V2Error(400, "Not a valid username.")
proof = _unb64(body.get("recovery_proof"), "The recovery proof",
RECOVERY_PROOF_LEN)
login_key = _unb64(body.get("login_key"), "The login key", LOGIN_KEY_LEN)
kdf = keyset_kdf(body.get("keyset"))
keyset_kdf(body.get("recovery"), recovery=True)
if self._paused(username):
raise V2Error(429, "Too many wrong attempts for this account. "
"Wait a minute, then try again.")
record = self.account(username)
stored = (record or {}).get("recovery_check")
# Hash even when there is nothing to compare with, as login does.
ok = login_key_matches(proof, stored or hash_login_key(b"\0" * 32)) \
and stored is not None and not record.get("disabled")
if not ok:
self._failed(username)
if record is not None and stored is None and not record.get("disabled"):
raise V2Error(409, "This account cannot be recovered on this "
"server yet: sync it once from a device with "
"SecretSpace 3.0.0-alpha.7 or newer.")
raise V2Error(401, "The recovery phrase does not open this account.")
with self._failure_lock:
self._failures.pop(username.lower(), None)
with _lock:
record = self.account(username)
record.update({"login": hash_login_key(login_key),
"keyset": body["keyset"], "kdf": kdf,
"recovery": body["recovery"],
"password_changed_at": utc_now(),
"recovered_at": utc_now()})
self._write_json(self._account_path(username), record)
self._drop_expired_sessions(user_to_end=username)
return {"username": username}

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."""
Expand Down Expand Up @@ -988,10 +1100,12 @@ def do_GET(self):
# 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).
# (2.0.0-alpha.3). "recovery" = an account recovered with its
# 12-word phrase (2.0.0-alpha.4).
return self._send(200, {"ok": True, "service": "passman-sync",
"api": [1, 2],
"features": ["vaults", "sharing"]})
"features": ["vaults", "sharing",
"recovery"]})
if self._is_v2():
return self._v2("GET")

Expand Down Expand Up @@ -1189,6 +1303,22 @@ def _v2_route(self, method, parts):
raise
accounts.audit(None, "login", reply["username"], address)
return self._send(200, reply)
if route == ("GET", "recovery", 2):
return self._send(200, accounts.recovery_copy(parts[1]))
if route == ("POST", "recovery", 1):
body = self._read_body()
if body is None:
return None
tried = body.get("username")
tried = tried if isinstance(tried, str) and valid_username(tried) else "?"
try:
done = accounts.recover(body)
except V2Error as e:
accounts.audit(None, "recovery_failed", tried, address,
status=e.status)
raise
accounts.audit(None, "account_recovered", done["username"], address)
return self._send(200, done)
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.
Expand Down Expand Up @@ -1226,6 +1356,19 @@ def _v2_route(self, method, parts):
return None
accounts.set_signing_key(username, body)
return self._send(200, {})
if route == ("PUT", "me", 2) and parts[1] == "recovery-proof":
body = self._read_body()
if body is None:
return None
accounts.set_recovery_proof(username, body)
return self._send(200, {})
if route == ("PUT", "me", 2) and parts[1] == "recovery":
body = self._read_body()
if body is None:
return None
accounts.set_recovery_copy(username, body)
accounts.audit(None, "recovery_phrase_changed", username, address)
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):
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.3"
__version__ = "2.0.0-alpha.4"
2 changes: 1 addition & 1 deletion tests/test_server_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}
"features": ["vaults", "sharing", "recovery"]}


@pytest.mark.parametrize("header", [
Expand Down
Loading
Loading