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
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ package replaces it in one step (`Conflicts`/`Replaces: passman`), and your
vaults stay in `~/.local/share/passman`, which SecretSpace keeps using —
nothing is moved or renamed. The vault files are the same.

**`main` is 1.0.0-alpha.7, a SecretSpace 3 test version — not for real passwords yet.**
**`main` is 1.0.0-alpha.8, a SecretSpace 3 test version — not for real passwords yet.**
For real use keep the [0.11.0 release](https://github.com/usermaneGermane/SecretSpace-Client-Linux/releases/tag/v0.11.0),
which the rest of this README describes. It installs from a `.deb` on Ubuntu
22.04 or newer, or runs from source. It has so far been tested in CI only,
Expand All @@ -31,14 +31,14 @@ on a virtual display, not yet on a real desktop.
**Same vaults as on Windows.** The vault file, the sync protocol and the
features match SecretSpace for Windows (see the version table under *What is
different from Windows*): 0.11.0 = Windows 2.13.0, and this test version =
Windows 3.0.0-alpha.7.
Windows 3.0.0-alpha.8.
A vault moves between a Windows PC and a Linux PC unchanged, and both sync
with either server.

## SecretSpace 3 — the test version

1.0.0-alpha.7 is the same as [SecretSpace for Windows
3.0.0-alpha.7](https://github.com/usermaneGermane/SecretSpace-Client-Windows#secretspace-3--a-test-version-not-for-real-passwords-yet):
1.0.0-alpha.8 is the same as [SecretSpace for Windows
3.0.0-alpha.8](https://github.com/usermaneGermane/SecretSpace-Client-Windows#secretspace-3--a-test-version-not-for-real-passwords-yet):
accounts with a Secret Key and a 12-word recovery phrase, Argon2id, and
importing 2.x vaults with their history. It opens SecretSpace 3 accounts only,
so do not install it over 0.11.0 — your vaults would not show up (they are
Expand All @@ -56,7 +56,11 @@ copied a password. Sharing needs SecretSpace Server 2.0.0-alpha.3. Since
alpha.7 a forgotten master password is no longer the end: *Forgot your master
password?* on the login screen opens the account with its **12-word
recovery phrase**, sets a new password and makes a new phrase (needs
SecretSpace Server 2.0.0-alpha.4 for an account on a server). On Linux:
SecretSpace Server 2.0.0-alpha.4 for an account on a server). **alpha.8 is
a security fix** for alpha.6 and alpha.7: a vault key is taken only with
a signature of your own account or of someone you have reason to trust,
so a dishonest server cannot slip in a key of its own (needs SecretSpace
Server 2.0.0-alpha.5 for anything but the Personal vault). On Linux:

- **The Secret Key is kept under your master password** (Argon2id, then
AES-GCM), not by the system as Windows does with DPAPI. So a copy of the
Expand All @@ -68,7 +72,7 @@ SecretSpace Server 2.0.0-alpha.4 for an account on a server). On Linux:
- The account folders are `700` and their files `600`, like the vault
folder; a saved Emergency Kit is `600` too.
- *Settings → Backup → Open folder* opens the folder with `xdg-open`.
- The package spells the version `1.0.0~alpha.7`, which apt sorts after
- The package spells the version `1.0.0~alpha.8`, which apt sorts after
0.9.0 and before 1.0.0.

| File | What it is |
Expand Down Expand Up @@ -204,6 +208,7 @@ Where that README names a Windows version, read the Linux one beside it:
| 3.0.0-alpha.5 | 1.0.0-alpha.5 | several vaults per account; Sync covers them all (Server 2.0.0-alpha.2) |
| 3.0.0-alpha.6 | 1.0.0-alpha.6 | sharing a vault (owner / editor / reader), who changed what (Server 2.0.0-alpha.3) |
| 3.0.0-alpha.7 | 1.0.0-alpha.7 | recovering an account with the 12-word phrase, a new phrase (Server 2.0.0-alpha.4) |
| 3.0.0-alpha.8 | 1.0.0-alpha.8 | security fix: signed vault keys (Server 2.0.0-alpha.5) |

(0.2.0 added the `.deb` package, which has no Windows counterpart.)

Expand Down
182 changes: 170 additions & 12 deletions sourceFiles/account_sync.py

Large diffs are not rendered by default.

22 changes: 21 additions & 1 deletion sourceFiles/account_sync_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,28 @@ def _all_done(app, run):
kind = "warn"
if changes.server_too_old:
parts.append("this server keeps only the Personal vault — the others "
"need SecretSpace Server 2.0.0-alpha.2 or newer")
"need SecretSpace Server 2.0.0-alpha.5 or newer")
kind = "warn"
_finish(app, "Synced — " + "; ".join(parts) if parts else "Already up to date",
kind)


def plain_http_ok(app, url) -> bool:
"""Ask before an unencrypted address of another machine is used. True
to go on (always for https:// and for this PC itself)."""
if not account_sync.is_plain_remote(url):
return True
return app._confirm(
"This connection is not encrypted",
"The address starts with http://, so what this PC and the server "
"say to each other can be read on the network in between.\n\n"
"Your vaults stay safe: they are encrypted on this PC before they "
"leave it. But someone listening could take over the account ON "
"THE SERVER: lock you out there, or delete what it holds.\n\n"
"Fine inside a network you trust (your home, your office). Anywhere "
"else use an https:// address.\n\nContinue?", danger=False)


def _logout_in_background(server):
"""Log out without the window waiting: a logout is a network call too,
and a slow server must never freeze the window (Lab flow, 2026-11-10:
Expand Down Expand Up @@ -208,6 +224,8 @@ def connect():
"with http:// or https://",
text_color=DANGER)
token, ca_file = token_entry.get().strip(), ca_entry.get().strip()
if not plain_http_ok(app, url):
return
status.configure(text="Contacting the server…", text_color=MUTED)

def done(outcome):
Expand Down Expand Up @@ -276,6 +294,8 @@ def sign_in():
if not (username and password):
return status.configure(text="Enter your username and master password.",
text_color=DANGER)
if not plain_http_ok(app, url):
return
status.configure(text="Signing in — this takes a moment…", text_color=MUTED)

def work():
Expand Down
3 changes: 2 additions & 1 deletion sourceFiles/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,8 @@ def create_vault(self, name: str) -> VaultFile:
made.save({"accounts": []})
extra = dict(self._extra())
extra[made.hex] = {"name": name, "wrapped_key": wrapped,
"created_at": vault.utc_now(), "on_server": False}
"created_at": vault.utc_now(), "on_server": False,
"key_signer": self.username}
self._write_vaults(extra) # last: it makes it a vault
self._vault_keys[made.hex] = key
return made
Expand Down
4 changes: 4 additions & 0 deletions sourceFiles/recovery_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ def go():
return
name = user_entry.get().strip()
phrase = " ".join(words.get("1.0", "end").split())
import account_sync_ui # late: it imports dialogs' users
if url_entry.get().strip() and not accounts.account_exists(name) \
and not account_sync_ui.plain_http_ok(app, url_entry.get()):
return
busy["on"] = True
err.configure(text="Opening the account — this takes a moment…",
text_color=MUTED)
Expand Down
23 changes: 18 additions & 5 deletions sourceFiles/sharing_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,10 @@ def _with_server(app, work, done, failed):
def run():
server = account_sync.logged_in(account)
try:
if not server.has_sharing():
if not server.has_sharing() or not server.has_signed_keys():
raise account_sync.SyncError(
"This server cannot share vaults yet: it needs SecretSpace "
"Server 2.0.0-alpha.3 or newer.")
"Server 2.0.0-alpha.5 or newer.")
return work(server)
finally:
server.logout()
Expand Down Expand Up @@ -213,14 +213,27 @@ def set_role(who, role):
run(lambda server: server.put_member(vault_id, who, role),
f"Changing what {who} may do…")

def unchecked(leaving=None):
"""A paragraph naming the members who stay and whose keys were never
compared on this PC: the new key goes to whatever key the server
gives for them."""
names = [m["username"] for m in state["members"]
if not m["me"] and not m["verified"]
and m["username"] != leaving]
if not names:
return ""
return ("\n\nNot checked on this PC: " + ", ".join(names) + ". The "
"new key goes to the keys the server gives for them. Compare "
"fingerprints first (Check) if you can.")

def remove(who):
if not app._confirm(
f"Remove {who}",
f"Remove {who} from “{name}”?\n\n"
"The vault gets a new key in the same step, so they can open "
"nothing that is written from now on. What they have already "
"seen they may have kept: change those passwords if it "
"matters.\n\nThe vault is synced first."):
"matters.\n\nThe vault is synced first." + unchecked(who)):
return
run(lambda server: account_sync.change_key(
account, server, vault_id, app.vaults[vault_id], remove=[who]),
Expand All @@ -231,8 +244,8 @@ def change_key():
"Change the vault's key",
f"Give “{name}” a new key?\n\nEvery member gets it with their "
"next sync. Worth doing after someone left the vault, or if a "
"device that had it was lost. The vault is synced first.",
danger=False):
"device that had it was lost. The vault is synced first."
+ unchecked(), danger=False):
return
run(lambda server: account_sync.change_key(
account, server, vault_id, app.vaults[vault_id]),
Expand Down
65 changes: 60 additions & 5 deletions sourceFiles/tests/sync_server/passman_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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", []))
Expand All @@ -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)
Expand Down Expand Up @@ -911,13 +931,15 @@ 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.")
listed = list(target.get("vaults", []))
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]
Expand Down Expand Up @@ -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"])
Expand All @@ -1003,13 +1031,33 @@ 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)
for gone in removed:
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))
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading