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
21 changes: 14 additions & 7 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.6, a SecretSpace 3 test version — not for real passwords yet.**
**`main` is 1.0.0-alpha.7, 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.6.
Windows 3.0.0-alpha.7.
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.6 is the same as [SecretSpace for Windows
3.0.0-alpha.6](https://github.com/usermaneGermane/SecretSpace-Client-Windows#secretspace-3--a-test-version-not-for-real-passwords-yet):
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):
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 @@ -52,17 +52,23 @@ the list, and all covered by **Sync** — which needs SecretSpace Server
owner, editor or reader, a fingerprint check before the first share, and
*Remove* gives the vault a new key), and every vault keeps a signed record
of **who changed what** (*Vaults → Activity*) — never of who viewed or
copied a password. Sharing needs SecretSpace Server 2.0.0-alpha.3. On Linux:
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:

- **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
account folder opens on another PC with the master password alone; keep
your backups as private as your vault files. The desktop keyring may take
this over later.
this over later. It also means that **after a recovery the account gets a
new Secret Key** (the stored one is sealed under the forgotten password):
save the new Emergency Kit the recovery shows you.
- 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.6`, which apt sorts after
- The package spells the version `1.0.0~alpha.7`, which apt sorts after
0.9.0 and before 1.0.0.

| File | What it is |
Expand Down Expand Up @@ -197,6 +203,7 @@ Where that README names a Windows version, read the Linux one beside it:
| 3.0.0-alpha.4 | 1.0.0-alpha.4 | a smoother look: smoothed corners, redrawn sign-in backdrop |
| 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) |

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

Expand Down
20 changes: 18 additions & 2 deletions sourceFiles/account_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,8 +572,7 @@ def create_account(username: str, master_password: str,
"login_key": login,
"sealed_keyset": seal_keyset(auk, keyset, salt, KDF_ARGON2ID, passes,
memory_kib, lanes),
"sealed_recovery": seal_keyset(recovery_key(phrase, username), keyset,
bytes(SALT_LEN), KDF_NONE, 0, 0, 0),
"sealed_recovery": seal_recovery(phrase, username, keyset),
}


Expand All @@ -589,6 +588,23 @@ def unlock_account(username: str, master_password: str, secret_key: bytes,
return open_keyset(auk, sealed_keyset), login


def seal_recovery(phrase: str, username: str, keyset: dict) -> str:
"""The keyset under a recovery phrase: the account's recovery copy."""
return seal_keyset(recovery_key(phrase, username), keyset, bytes(SALT_LEN),
KDF_NONE, 0, 0, 0)


def recovery_proof(keyset: dict) -> bytes:
"""
What a device shows the server to prove it opened the account's keyset
(with the recovery phrase) before it may set a new password there. From
the keyset's private key, under its own label, so it reveals nothing
about that key; the server keeps only its hash.
"""
return _hkdf(_unb64(keyset["x25519_private"], "account key"),
b"PassMan3 recovery proof", b"PassMan3 recovery proof|v1")


def recover_account(username: str, phrase: str, sealed_recovery: str) -> dict:
"""The keyset from the recovery phrase alone (personal accounts)."""
if keyset_header(sealed_recovery)["kdf"] != KDF_NONE:
Expand Down
33 changes: 33 additions & 0 deletions sourceFiles/account_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
#: (2.0.0-alpha.3).
FEATURE_SHARING = "sharing"

#: ... and of one that recovers an account with its phrase (2.0.0-alpha.4).
FEATURE_RECOVERY = "recovery"


class LoginFailed(SyncError):
"""The server did not take this username + password + Secret Key."""
Expand Down Expand Up @@ -103,6 +106,26 @@ def has_sharing(self) -> bool:
self.has_vaults()
return FEATURE_SHARING in self._features

def has_recovery(self) -> bool:
self.has_vaults()
return FEATURE_RECOVERY in self._features

# -- recovery (phase 5) ----------------------------------------------------
def recovery_copy(self, username: str) -> dict:
return self._request("GET", "/v2/recovery/" + urllib.parse.quote(
username, safe=""))

def recover(self, body: dict) -> dict:
return self._request("POST", "/v2/recovery", body=body)

def put_recovery_proof(self, proof: bytes) -> None:
self._request("PUT", "/v2/me/recovery-proof", body={
"recovery_proof": base64.b64encode(proof).decode("ascii")})

def put_recovery(self, sealed_recovery: str) -> None:
self._request("PUT", "/v2/me/recovery",
body={"recovery": sealed_recovery})

# -- sharing (phase 4) ---------------------------------------------------
def put_signing_key(self, signing_key: str) -> None:
self._request("PUT", "/v2/me/signing-key",
Expand Down Expand Up @@ -245,6 +268,8 @@ def connect(account: accounts.Account, url: str, ca_file: str,
"keyset": files["keyset"], "recovery": files["recovery"],
"public_key": account.info["public_key"],
"signing_key": account.signing_key,
"recovery_proof": base64.b64encode(account_crypto.recovery_proof(
account.keyset)).decode("ascii"),
"personal_vault": account.vault_id.hex()})
except Conflict409:
raise SyncError(
Expand Down Expand Up @@ -406,6 +431,14 @@ def reconcile_vaults(account: accounts.Account, server: AccountServer) -> VaultC
# An account made before changes were signed: the server learns its
# signing key now, once, so other members can check its lines.
server.put_signing_key(account.signing_key)
if server.has_recovery() and not me.get("can_recover"):
# Likewise what lets the server recover the account with its phrase.
server.put_recovery_proof(account_crypto.recovery_proof(account.keyset))
sealed = me.get("recovery")
if sealed and sealed != account.sealed_files()["recovery"]:
# A new phrase was made on another device: this one takes the copy
# that goes with it, so the old phrase opens nothing here either.
account.set_recovery(sealed)

for vault_id in extra_here:
record = account.vault_record(vault_id)
Expand Down
20 changes: 20 additions & 0 deletions sourceFiles/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,11 @@ def change_password(self, new_password: str, before_write=None) -> None:
self.info = info
self.login_key = login

def set_recovery(self, sealed_recovery: str) -> None:
"""A new recovery copy (a new phrase); the old copy is replaced, not
kept -- a .bak of it would still open with the old phrase."""
_write_text(_path(self.username, RECOVERY_FILE), sealed_recovery)

def sealed_files(self) -> dict:
"""The sealed keyset and recovery copy, as stored -- what a server
keeps so another device can sign in."""
Expand Down Expand Up @@ -783,6 +788,21 @@ def adopt_from_server(username: str, me: dict, keyset: dict, secret: bytes,
return Account(username, info, keyset, secret, login_key)


def replace_keys(username: str, sealed_keyset: str, sealed_recovery: str,
secret: bytes, password: str) -> None:
"""
After a recovery on a PC that has the account and no server: the same
keys under the new password and the new phrase, and the Secret Key
stored for the new password. The vault files are not touched -- the
keys that open them have not changed.
"""
info = dict(_read_info(username))
info["secret_key"] = secret_store.protect(secret, username, password)
_write_text(_path(username, KEYSET_FILE), sealed_keyset)
_write_text(_path(username, RECOVERY_FILE), sealed_recovery)
_write_info(username, info)


def check_recovery_phrase(username: str, phrase: str) -> bool:
"""Does this phrase open the account's recovery copy? (Phase 5 recovers.)"""
try:
Expand Down
34 changes: 26 additions & 8 deletions sourceFiles/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ def _secret_key_box(parent, text):
def _emergency_kit_dialog(app, new, password):
top = app._modal("Emergency Kit", 500, 600)
shell = dialog_shell(top, "Your Emergency Kit",
"Step 2 of 3 · With your master password, this "
"Secret Key opens your account on another PC. "
"SecretSpace keeps a copy on this PC only.")
getattr(new, "kit_subtitle",
"Step 2 of 3 · With your master password, this "
"Secret Key opens your account on another PC. "
"SecretSpace keeps a copy on this PC only."))
wrap = shell.body

field_label(wrap, "Account")
Expand Down Expand Up @@ -184,9 +185,10 @@ def next_step():
def _phrase_dialog(app, new, password):
top = app._modal("Recovery phrase", 520, 600)
shell = dialog_shell(top, "Your recovery phrase",
"Step 3 of 3 · These 12 words open your account if "
"you forget your master password. Write them on "
"paper, in order.")
getattr(new, "phrase_subtitle",
"Step 3 of 3 · These 12 words open your account "
"if you forget your master password. Write "
"them on paper, in order."))
wrap = shell.body

grid = ctk.CTkFrame(wrap, fg_color=CARD, corner_radius=16,
Expand Down Expand Up @@ -242,6 +244,10 @@ def finish():
(" does" if len(wrong) == 1 else " do") +
" not match. Look at your paper copy again.")
return
if hasattr(new, "finish"):
# A recovery or a new phrase: it says what happens (and may use
# the network, off this thread).
return new.finish(app, top, err)
try:
account = new.save()
loaded = account.load_vault()
Expand All @@ -258,8 +264,9 @@ def back():

app._ghost_button(shell.buttons, "Show the words again", back,
width=170).pack(side="left")
app._accent_button(shell.buttons, "Create account", finish,
width=150).pack(side="right")
app._accent_button(shell.buttons,
getattr(new, "finish_label", "Create account"), finish,
width=170).pack(side="right")
for b in boxes.values():
b.bind("<Return>", lambda ev: finish())
boxes[CHECK_WORDS[0]].focus()
Expand Down Expand Up @@ -707,6 +714,17 @@ def settings_dialog(app):
"another PC.", hint),
"Save kit…", lambda: _save_kit_again(app), width=150).pack()

def new_phrase():
import recovery_ui # late: it imports this module
top.destroy()
recovery_ui.new_phrase_dialog(app)

app._ghost_button(
setting_row(sec, "Recovery phrase",
"Make a new one if it was lost or seen. The old one stops "
"working.", hint),
"New phrase…", new_phrase, width=150).pack()

def label_for(m):
return "Never" if m == 0 else f"{m} minute{'s' if m != 1 else ''}"

Expand Down
9 changes: 8 additions & 1 deletion sourceFiles/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import sync_ui
import account_sync_ui
import vaults_ui
import recovery_ui
import totp
from theme import (F, BG, HEADER_BG, CARD, CARD_HOVER, SELECTED, INPUT_BG,
BORDER, TEXT, MUTED, GREEN, GREEN_HOVER, DANGER,
Expand Down Expand Up @@ -662,8 +663,14 @@ def toggle_pw():
width=10, height=32, fg_color="transparent",
hover_color=CARD_HOVER, text_color=GREEN,
font=F(13, "bold")).pack(side="right")
ctk.CTkButton(pad, text="Forgot your master password?",
command=lambda: recovery_ui.recover_dialog(
self, self._login_user or ""),
width=10, height=28, fg_color="transparent",
hover_color=CARD_HOVER, text_color=MUTED,
font=F(12)).pack(pady=(6, 0))
ctk.CTkLabel(pad, text=version_string(), font=F(11),
text_color=MUTED).pack(pady=(14, 0))
text_color=MUTED).pack(pady=(8, 0))

self.password_entry.bind("<Return>", lambda e: self.unlock_vault())
self.password_entry.focus()
Expand Down
Loading
Loading