From b616f4dfda090967cfdecfbbff40e5a57184a3a4 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Sun, 8 Nov 2026 11:18:19 +0200 Subject: [PATCH] 3.0 phase 1b for Linux: local accounts + 2.x import (1.0.0-alpha.1) The port of PassMan for Windows 3.0.0-alpha.1 (Windows PR #16). accounts.py, secret_store.py, dialogs.py and test_accounts.py are the Windows files unchanged; gui.py has the same changes; vault.py gains the same import functions. On Linux the Secret Key is kept under the master password (Argon2id + AES-GCM; secret_store's portable scheme), account folders are 700 and their files 600, and Open folder uses xdg-open. Version 1.0.0-alpha.1 (= Windows 3.0.0-alpha.1). The .deb spells it 1.0.0~alpha.1 (build_deb.deb_version): with a hyphen apt would sort it after 1.0.0. A dpkg --compare-versions test runs where dpkg exists. tools/smoke_gui.py now unlocks a throwaway PassMan 3 account and checks its folders and files are private. Tests here (Windows): 492 passed, 6 skipped (POSIX and dpkg checks run in CI). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ojnyVg7ccsR3pZ64s7aRH --- README.md | 44 ++- deb/build_deb.py | 11 +- deb/tests/test_build_deb.py | 17 + sourceFiles/accounts.py | 356 +++++++++++++++++++ sourceFiles/dialogs.py | 529 ++++++++++++++++++++--------- sourceFiles/gui.py | 102 +++--- sourceFiles/secret_store.py | 159 +++++++++ sourceFiles/tests/test_accounts.py | 476 ++++++++++++++++++++++++++ sourceFiles/tests/test_version.py | 26 +- sourceFiles/vault.py | 41 +++ sourceFiles/version.py | 32 +- tools/smoke_gui.py | 27 +- 12 files changed, 1589 insertions(+), 231 deletions(-) create mode 100644 sourceFiles/accounts.py create mode 100644 sourceFiles/secret_store.py create mode 100644 sourceFiles/tests/test_accounts.py diff --git a/README.md b/README.md index 1928c6d..37bb787 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ encrypted file. There is no account and no service behind it: it works entirely offline, and if you want two PCs in step you run the sync server yourself, on your own hardware. -**Version 0.9.0 — early.** It installs from a `.deb` on Ubuntu 22.04 or -newer, or runs from source. It has so far been tested in CI only, on a -virtual display, not yet on a real desktop. +**`main` is 1.0.0-alpha.1, a PassMan 3 test version — for the lab only.** +For real use keep the [0.9.0 release](https://github.com/usermaneGermane/PassMan-Client-Linux/releases/tag/v0.9.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, +on a virtual display, not yet on a real desktop. ![PassMan](sourceFiles/PassMan-logo.png) @@ -19,12 +21,41 @@ virtual display, not yet on a real desktop. | [PassMan-Server-Linux](https://github.com/usermaneGermane/PassMan-Server-Linux) | the sync server for Linux, run by systemd | **Same vaults as on Windows.** The vault file, the sync protocol and the -features match PassMan for Windows 2.11.0, including its black-and-green -window and dialogs, 2FA codes and sync on unlock (see the version table under *What is different from -Windows*). +features match PassMan for Windows (see the version table under *What is +different from Windows*): 0.9.0 = Windows 2.11.0, and this test version = +Windows 3.0.0-alpha.1. A vault moves between a Windows PC and a Linux PC unchanged, and both sync with either server. +## PassMan 3 — the test version + +1.0.0-alpha.1 is the same as [PassMan for Windows +3.0.0-alpha.1](https://github.com/usermaneGermane/PassMan-Client-Windows#passman-3--a-test-version-for-the-lab-only): +accounts with a Secret Key and a 12-word recovery phrase, Argon2id, and +importing 2.x vaults with their history. It opens PassMan 3 accounts only, +so do not install it over 0.9.0 — your vaults would not show up (they are +not changed). No sync yet. 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. +- 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.1`, which apt sorts after + 0.9.0 and before 1.0.0. + +| File | What it is | +| --- | --- | +| `accounts//account.json` | nothing secret, and the Secret Key sealed under your master password | +| `accounts//keyset.pmk` | the account's keys, under master password + Secret Key | +| `accounts//recovery.pmk` | the same keys, under the 12-word recovery phrase | +| `accounts//vaults/.pmv3` | your entries (+ `.bak`) | + +They sit beside the `vault` folder below, which the test version only reads. + ## Installing it The package is `passman__all.deb`. Download it from @@ -139,6 +170,7 @@ Where that README names a Windows version, read the Linux one beside it: | 2.9.0 | 0.7.0 | the dialogs redesigned to match | | 2.10.0 | 0.8.0 | 2FA codes, behind the master password | | 2.11.0 | 0.9.0 | sync on unlock, a setting that is off by default | +| 3.0.0-alpha.1 | 1.0.0-alpha.1 | PassMan 3 test: accounts, Emergency Kit, recovery phrase, 2.x import | (0.2.0 added the `.deb` package, which has no Windows counterpart.) diff --git a/deb/build_deb.py b/deb/build_deb.py index 7590e76..96de24c 100644 --- a/deb/build_deb.py +++ b/deb/build_deb.py @@ -116,6 +116,15 @@ def read_version(source_dir=SOURCE_DIR): return match.group(1) +def deb_version(version: str) -> str: + """ + The package's Version. In Debian a hyphen starts the package revision, + so 1.0.0-alpha.1 would sort *after* 1.0.0; a tilde sorts before + anything, so 1.0.0~alpha.1 comes after 0.9.0 and before 1.0.0. + """ + return version.replace("-", "~", 1) + + def vendored(vendor_file=VENDOR_FILE): """(name, version) pairs pinned in vendor.txt.""" pins = [] @@ -247,7 +256,7 @@ def main(argv=None): if not shutil.which("dpkg-deb"): raise SystemExit("dpkg-deb not found: build the package on Debian or Ubuntu") - version = read_version() + version = deb_version(read_version()) os.makedirs(args.out, exist_ok=True) deb = os.path.join(args.out, f"{PACKAGE}_{version}_all.deb") with tempfile.TemporaryDirectory(prefix="passman-deb-") as tmp: diff --git a/deb/tests/test_build_deb.py b/deb/tests/test_build_deb.py index 69bd942..44c49e1 100644 --- a/deb/tests/test_build_deb.py +++ b/deb/tests/test_build_deb.py @@ -39,6 +39,23 @@ def test_version_comes_from_version_py(): assert build_deb.read_version() == version.__version__ +@pytest.mark.parametrize("app, deb", [("0.9.0", "0.9.0"), + ("1.0.0-alpha.1", "1.0.0~alpha.1"), + ("1.0.0", "1.0.0")]) +def test_deb_version(app, deb): + assert build_deb.deb_version(app) == deb + + +@pytest.mark.skipif(not __import__("shutil").which("dpkg"), reason="needs dpkg") +def test_dpkg_orders_a_test_version_between_releases(): + import subprocess + alpha = build_deb.deb_version("1.0.0-alpha.1") + for op, other in (("gt", "0.9.0"), ("lt", "1.0.0"), + ("lt", build_deb.deb_version("1.0.0-alpha.2"))): + assert subprocess.run(["dpkg", "--compare-versions", alpha, op, + other]).returncode == 0, (op, other) + + def test_vendored_pins_are_exact(): pins = dict(build_deb.vendored()) assert set(pins) == {"customtkinter", "darkdetect", "packaging"} diff --git a/sourceFiles/accounts.py b/sourceFiles/accounts.py new file mode 100644 index 0000000..05b66f3 --- /dev/null +++ b/sourceFiles/accounts.py @@ -0,0 +1,356 @@ +""" +PassMan 3.0 accounts on this device -- phase 1: local only, no server. + +An account replaces the 2.x "one vault file per user". It is a folder:: + + /accounts// + account.json nothing secret: mode, public key, the personal + vault's id, the Secret Key as this device keeps + it (secret_store), fingerprints seen + keyset.pmk the keyset, under master password + Secret Key + recovery.pmk the same keyset, under the 12-word phrase + vaults/.pmv3 the personal vault (+ .bak, the previous save) + +```` is the folder that holds the 2.x ``vault`` folder (beside the app +for a lab copy, %APPDATA%\\PassMan when installed). 2.x vault files are only +ever *read*, by the importer; nothing here writes to them. + +The cryptography is account_crypto; this module is files and lifecycle. +""" + +import json +import os +import shutil + +import account_crypto +import secret_store +import vault +from account_crypto import CryptoError + +ACCOUNTS_DIR_NAME = "accounts" +ACCOUNT_FILE = "account.json" +KEYSET_FILE = "keyset.pmk" +RECOVERY_FILE = "recovery.pmk" +VAULTS_DIR = "vaults" +VAULT_SUFFIX = ".pmv3" +ACCOUNT_FORMAT = 1 +MODE_PERSONAL = "personal" + + +class SecretKeyNeeded(Exception): + """ + This device does not hold the account's Secret Key -- a copied folder on + a new PC, another Windows user. Ask for the Emergency Kit and call + :func:`unlock` again with ``secret_key_text``. + """ + + +# --------------------------------------------------------------------------- +# Where things are +# --------------------------------------------------------------------------- +def accounts_dir() -> str: + """Beside the 2.x vault folder, so PASSMAN_DATA_DIR moves both.""" + return os.path.join(os.path.dirname(os.path.abspath(vault.get_data_dir())), + ACCOUNTS_DIR_NAME) + + +def account_dir(username: str) -> str: + return os.path.join(accounts_dir(), vault.validate_username(username)) + + +def _path(username: str, name: str) -> str: + return os.path.join(account_dir(username), name) + + +def list_accounts() -> list: + """Accounts on this PC, by name. A folder without account.json is not one.""" + root = accounts_dir() + try: + names = os.listdir(root) + except OSError: + return [] + found = [] + for name in names: + try: + vault.validate_username(name) + except ValueError: + continue + if os.path.isfile(os.path.join(root, name, ACCOUNT_FILE)): + found.append(name) + return sorted(found, key=str.lower) + + +def account_exists(username: str) -> bool: + return os.path.isfile(_path(username, ACCOUNT_FILE)) + + +def _write_text(path: str, text: str) -> None: + vault._write_file_atomic(path, text.encode("utf-8")) + + +def _read_text(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + + +def _read_info(username: str) -> dict: + try: + with open(_path(username, ACCOUNT_FILE), "r", encoding="utf-8") as f: + info = json.load(f) + except FileNotFoundError: + raise FileNotFoundError(f"There is no account “{username}” on this " + "PC.") from None + except (OSError, ValueError): + raise ValueError(f"The account file of “{username}” is damaged.") from None + if not isinstance(info, dict) or info.get("format") != ACCOUNT_FORMAT: + raise ValueError(f"The account “{username}” was made by another " + "PassMan version.") + return info + + +def _write_info(username: str, info: dict) -> None: + _write_text(_path(username, ACCOUNT_FILE), + json.dumps(info, indent=2, sort_keys=True)) + + +# --------------------------------------------------------------------------- +# An open account +# --------------------------------------------------------------------------- +class Account: + """An unlocked account: its keys in memory, its files on disk.""" + + def __init__(self, username: str, info: dict, keyset: dict, secret: bytes): + self.username = username + self.info = info + self.keyset = keyset + self._secret = secret + self.vault_id = bytes.fromhex(info["personal_vault"]) + self._vault_key = account_crypto._unb64(keyset["personal_vault_key"], + "vault key") + + @property + def mode(self) -> str: + return self.info.get("mode", MODE_PERSONAL) + + @property + def fingerprint(self) -> str: + return account_crypto.fingerprint(account_crypto.public_key_of(self.keyset)) + + @property + def secret_key_text(self) -> str: + """For the Emergency Kit again (Settings).""" + return account_crypto.format_secret_key(self._secret) + + @property + def vault_path(self) -> str: + return os.path.join(account_dir(self.username), VAULTS_DIR, + self.info["personal_vault"] + VAULT_SUFFIX) + + def load_vault(self) -> dict: + """The personal vault. Falls back to .bak like a 2.x vault does.""" + first_error = None + for path in (self.vault_path, self.vault_path + vault.BACKUP_SUFFIX): + if not os.path.exists(path): + continue + try: + data = account_crypto.decrypt_vault( + self._vault_key, _read_text(path), self.vault_id) + return vault.validate_vault(data) + except (ValueError, OSError) as e: # CryptoError is a ValueError + if first_error is None: + first_error = e + raise first_error or FileNotFoundError("The personal vault is missing.") + + def save_vault(self, data: dict) -> None: + blob = account_crypto.encrypt_vault(self._vault_key, self.vault_id, 0, + vault.validate_vault(data)) + vault._rotate_backup(self.vault_path) + _write_text(self.vault_path, blob) + + def check_password(self, password: str) -> bool: + """Is this the master password? (Re-derives: 2FA reveal, delete.)""" + try: + account_crypto.unlock_account(self.username, password, self._secret, + _read_text(_path(self.username, + KEYSET_FILE))) + return True + except (CryptoError, OSError): + return False + + def change_password(self, new_password: str) -> None: + """ + The same keyset under a new password, so the vault file is not + touched. The old keyset.pmk is replaced, not kept: a .bak of it would + still open with the old password. + """ + sealed, _login = account_crypto.reseal_with_new_password( + self.username, self.keyset, new_password, self._secret, + account_crypto.ARGON2_PASSES, account_crypto.ARGON2_MEMORY_KIB, + account_crypto.ARGON2_LANES) + info = dict(self.info) + # Under the password scheme the stored Secret Key is sealed with the + # password too; DPAPI does not care, but re-protecting is harmless. + info["secret_key"] = secret_store.protect(self._secret, self.username, + new_password) + _write_text(_path(self.username, KEYSET_FILE), sealed) + _write_info(self.username, info) + self.info = info + + +# --------------------------------------------------------------------------- +# Making one +# --------------------------------------------------------------------------- +class NewAccount: + """ + An account made in memory and not yet written. The create flow shows + the Emergency Kit and the phrase first and writes only at the end, so + closing the window half-way leaves nothing behind. + """ + + def __init__(self, username: str, password: str, made: dict): + self.username = username + self._password = password + self._made = made + self.secret_key_text = made["secret_key_text"] + self.recovery_phrase = made["recovery_phrase"] + self.fingerprint = account_crypto.fingerprint(made["public_key"]) + + def save(self) -> Account: + username = self.username + if account_exists(username): + raise FileExistsError(f"There is already an account “{username}”.") + folder = account_dir(username) + if os.path.exists(folder): + raise FileExistsError( + f"A folder for “{username}” is already there ({folder}) but " + "holds no account. Move it away to use this name.") + made = self._made + vault_id = account_crypto.new_vault_id() + info = { + "format": ACCOUNT_FORMAT, + "username": username, + "mode": MODE_PERSONAL, + "created_at": vault.utc_now(), + "public_key": account_crypto._b64(made["public_key"]), + "personal_vault": vault_id.hex(), + "secret_key": secret_store.protect(made["secret_key"], username, + self._password), + "server": None, + "fingerprints_seen": {}, + } + # This user's only, as the 2.x vault folder is on Linux: the other + # users of the machine cannot even list whose accounts are here. + os.makedirs(accounts_dir(), mode=0o700, exist_ok=True) + os.mkdir(folder, 0o700) + try: + _write_text(os.path.join(folder, KEYSET_FILE), made["sealed_keyset"]) + _write_text(os.path.join(folder, RECOVERY_FILE), + made["sealed_recovery"]) + account = Account(username, info, made["keyset"], made["secret_key"]) + account.save_vault({"accounts": []}) + _write_info(username, info) # last: it makes it an account + except BaseException: + shutil.rmtree(folder, ignore_errors=True) + raise + self._password = None + return account + + +def prepare_account(username: str, password: str) -> NewAccount: + username = vault.validate_username(username) + if not password: + raise ValueError("Master password is required.") + if account_exists(username): + raise FileExistsError(f"There is already an account “{username}”.") + made = account_crypto.create_account( + username, password, account_crypto.ARGON2_PASSES, + account_crypto.ARGON2_MEMORY_KIB, account_crypto.ARGON2_LANES) + return NewAccount(username, password, made) + + +# --------------------------------------------------------------------------- +# Opening one +# --------------------------------------------------------------------------- +def unlock(username: str, password: str, secret_key_text: str = None) -> Account: + """ + Open an account. Raises :class:`SecretKeyNeeded` when this device does + not hold the Secret Key and none was given, and CryptoError("Wrong + master password or Secret Key.") for a wrong one. A Secret Key typed in + is stored (secret_store) once it has opened the account, so it is asked + for once per device. + """ + info = _read_info(username) + sealed = _read_text(_path(username, KEYSET_FILE)) + typed = None + if secret_key_text: + typed = account_crypto.parse_secret_key(secret_key_text) + secret = typed + else: + try: + secret = secret_store.unprotect(info.get("secret_key"), username, + password) + except secret_store.Unavailable: + raise SecretKeyNeeded(username) from None + keyset, _login = account_crypto.unlock_account(username, password, secret, + sealed) + if typed is not None: + info = dict(info) + info["secret_key"] = secret_store.protect(typed, username, password) + _write_info(username, info) + return Account(username, info, keyset, secret) + + +def check_recovery_phrase(username: str, phrase: str) -> bool: + """Does this phrase open the account's recovery copy? (Phase 5 recovers.)""" + try: + account_crypto.recover_account(username, phrase, + _read_text(_path(username, RECOVERY_FILE))) + return True + except (CryptoError, OSError): + return False + + +def delete_account(username: str) -> None: + """Remove the account folder. The caller has checked the password.""" + folder = account_dir(username) + if not os.path.isfile(os.path.join(folder, ACCOUNT_FILE)): + raise FileNotFoundError(f"There is no account “{username}” on this PC.") + shutil.rmtree(folder) + + +# --------------------------------------------------------------------------- +# Emergency Kit +# --------------------------------------------------------------------------- +def write_emergency_kit(path: str, username: str, secret_key_text: str) -> None: + """Save the kit readable by this user only: it holds the Secret Key.""" + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.chmod(path, 0o600) # an existing file keeps its mode + except OSError: + pass + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(emergency_kit_text(username, secret_key_text)) + + +def emergency_kit_text(username: str, secret_key_text: str, created: str = "") -> str: + """The Emergency Kit as a text file (Save Emergency Kit…).""" + return "\n".join([ + "PassMan Emergency Kit", + "=====================", + "", + f"Account: {username}", + f"Secret Key: {secret_key_text}", + "Server: (none - this account is on this PC only)", + "Master password: ______________________________", + "", + f"Made: {created or vault.utc_now()}", + "", + "You need the Secret Key and your master password to open this", + "account on another PC, or on this one after Windows is reinstalled.", + "Print this page or keep the file somewhere safe and offline. Do not", + "keep it in the same place as your master password.", + "", + "Your 12-word recovery phrase is NOT on this page, on purpose: it", + "opens the account on its own. Keep it apart from this kit.", + "", + ]) diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 40f52d7..d01d6a0 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -11,24 +11,25 @@ """ import os +import subprocess +import sys import customtkinter as ctk from tkinter import filedialog +import account_crypto +import accounts import appconfig import importers -import sync_client import totp import vault from theme import (F, TEXT, MUTED, GREEN, GREEN_HOVER, INPUT_BG, BORDER, - CARD_HOVER, ON_ACCENT, MONO, tile_colors) -from vault import (add_account, create_new_user, delete_user, get_vault_path, - validate_username, export_encrypted, export_csv, - export_json, update_entry, change_master_password, - find_duplicate) + CARD, CARD_HOVER, ON_ACCENT, MONO, tile_colors) +from vault import (add_account, validate_username, export_csv, export_json, + update_entry, find_duplicate) from widgets import (dialog_shell, section, setting_row, option_menu, note, error_label, check, field_label, entry, danger_button) -from crypto_utils import decrypt, password_matches +from crypto_utils import password_matches from password_generator import generate_password from version import long_version_string @@ -38,14 +39,24 @@ def _site_tile(site): return (site[:1].upper() or "?"), bg, ink +#: The words typed back after the phrase is shown, to prove it was written down. +CHECK_WORDS = (3, 7, 11) + + def create_user_dialog(app): - top = app._modal("Create new user", 460, 560) - shell = dialog_shell(top, "Create new user", - "Your master password encrypts the vault and " - "cannot be recovered.") + """ + A new PassMan 3 account, in three steps: name and master password, the + Emergency Kit (Secret Key), the 12-word recovery phrase. Nothing is + written until the last step, so closing a window half-way leaves no + account behind. + """ + top = app._modal("Create an account", 460, 600) + shell = dialog_shell(top, "Create an account", + "Step 1 of 3 · Your master password opens this " + "account on this PC. Nobody can reset it for you.") wrap = shell.body - user_entry = app._field(wrap, "Username", placeholder="e.g. alex") + user_entry = app._field(wrap, "Account name", placeholder="e.g. alex") pw_entry = app._field(wrap, "Master password", show="•") bar, label, update_strength = app._strength_widget(wrap) confirm_entry = app._field(wrap, "Confirm password", show="•") @@ -75,53 +86,280 @@ def create(): err.configure(text="Master password is required."); return if p != c: err.configure(text="Passwords do not match."); return - if os.path.exists(get_vault_path(u)): - err.configure(text="That user already exists."); return + if accounts.account_exists(u): + err.configure(text="There is already an account with that " + "name."); return if len(p) < 8: - err.configure(text="Use at least 8 characters — this password " - "cannot be recovered or reset."); return + err.configure(text="Use at least 8 characters — nobody can " + "reset this password for you."); return try: - create_new_user(u, p) - except FileExistsError: - err.configure(text="That user already exists."); return - except Exception as e: + new = accounts.prepare_account(u, p) + except (ValueError, FileExistsError) as e: err.configure(text=str(e)); return - app.vault = {"accounts": []} - app.master_password = p - app.current_user = u - appconfig.save_config(last_username=u) top.destroy() - app.show_main_window() - app.toast(f"Welcome, {u}!") + _emergency_kit_dialog(app, new, p) app._ghost_button(shell.buttons, "Cancel", top.destroy, width=120).pack(side="left") - app._accent_button(shell.buttons, "Create user", create, + app._accent_button(shell.buttons, "Next", create, width=150).pack(side="right") for e in (user_entry, pw_entry, confirm_entry): e.bind("", lambda ev: create()) user_entry.focus() +def _save_kit_file(app, username, secret_key_text): + """Save Emergency Kit… -- True once a file is written.""" + path = filedialog.asksaveasfilename( + parent=app.root, title="Save Emergency Kit", + defaultextension=".txt", + initialfile=f"PassMan Emergency Kit - {username}.txt", + filetypes=[("Text file", "*.txt"), ("All files", "*.*")]) + if not path: + return False + try: + accounts.write_emergency_kit(path, username, secret_key_text) + except OSError as e: + app.toast(f"Could not save the kit: {e}", "error") + return False + app.toast("Emergency Kit saved — print it or keep it offline") + return True + + +def _secret_key_box(parent, text): + box = entry(parent, height=52, font=ctk.CTkFont(family=MONO, size=15)) + box.pack(fill="x") + box.insert(0, text) + box.configure(state="readonly") + return box + + +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. " + "PassMan keeps a copy on this PC only.") + wrap = shell.body + + field_label(wrap, "Account") + ctk.CTkLabel(wrap, text=new.username, font=F(15, "bold"), + text_color=TEXT, anchor="w").pack(anchor="w") + field_label(wrap, "Secret Key") + _secret_key_box(wrap, new.secret_key_text) + + row = ctk.CTkFrame(wrap, fg_color="transparent") + row.pack(fill="x", pady=(10, 0)) + app._accent_button(row, "Save Emergency Kit…", + lambda: _save_kit_file(app, new.username, + new.secret_key_text), + width=190).pack(side="left") + app._ghost_button(row, "Copy", lambda: app._copy( + new.secret_key_text, "Secret Key copied", sensitive=True), + width=90).pack(side="left", padx=(8, 0)) + + note(wrap, "Print the kit, or keep the file somewhere offline — not next " + "to your master password. Without the Secret Key, a stolen copy " + "of your account cannot be opened by guessing the password.", + shell.text_width, pady=(14, 0)) + saved = check(wrap, "I have saved my Emergency Kit", shell.text_width) + err = error_label(wrap, shell.text_width) + + def next_step(): + if not saved.get(): + err.configure(text="Save or print the kit, then tick the box."); return + top.destroy() + _phrase_dialog(app, new, password) + + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=120).pack(side="left") + app._accent_button(shell.buttons, "Next", next_step, + width=150).pack(side="right") + + +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.") + wrap = shell.body + + grid = ctk.CTkFrame(wrap, fg_color=CARD, corner_radius=16, + border_width=1, border_color=BORDER) + grid.pack(fill="x", pady=(14, 0)) + for c in range(3): + grid.grid_columnconfigure(c, weight=1, uniform="word") + for i, word in enumerate(new.recovery_phrase.split()): + cell = ctk.CTkFrame(grid, fg_color="transparent") + cell.grid(row=i // 3, column=i % 3, sticky="w", padx=16, pady=8) + ctk.CTkLabel(cell, text=f"{i + 1:>2}", font=F(12), text_color=MUTED, + width=22, anchor="e").pack(side="left") + ctk.CTkLabel(cell, text=word, text_color=TEXT, + font=ctk.CTkFont(family=MONO, size=15, weight="bold") + ).pack(side="left", padx=(8, 0)) + + note(wrap, "Anyone with these words can open everything in this account. " + "Keep them like a passport: not in a file or a photo, and not with " + "the Emergency Kit. PassMan does not show them again.", + shell.text_width, pady=(14, 0)) + + def next_step(): + top.destroy() + _phrase_check_dialog(app, new, password) + + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=120).pack(side="left") + app._accent_button(shell.buttons, "I wrote them down", next_step, + width=170).pack(side="right") + + +def phrase_mismatches(phrase, typed): + """The word numbers in ``typed`` ({n: text}) that do not match ``phrase``.""" + words = phrase.split() + return [n for n, text in sorted(typed.items()) + if text.strip().lower() != words[n - 1]] + + +def _phrase_check_dialog(app, new, password): + top = app._modal("Check your phrase", 460, 460) + shell = dialog_shell(top, "Check your phrase", + "Type words " + ", ".join(map(str, CHECK_WORDS[:-1])) + + f" and {CHECK_WORDS[-1]} from your paper copy.") + wrap = shell.body + boxes = {n: app._field(wrap, f"Word {n}") for n in CHECK_WORDS} + err = error_label(wrap, shell.text_width) + + def finish(): + wrong = phrase_mismatches(new.recovery_phrase, + {n: b.get() for n, b in boxes.items()}) + if wrong: + err.configure(text="Word " + ", ".join(map(str, wrong)) + + (" does" if len(wrong) == 1 else " do") + + " not match. Look at your paper copy again.") + return + try: + account = new.save() + loaded = account.load_vault() + except Exception as e: + err.configure(text=str(e)); return + top.destroy() + app.open_account(account, loaded, password) + app.toast(f"Welcome, {account.username}! Import brings in your 2.x " + "vaults.") + + def back(): + top.destroy() + _phrase_dialog(app, new, password) + + 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") + for b in boxes.values(): + b.bind("", lambda ev: finish()) + boxes[CHECK_WORDS[0]].focus() + + +def ask_secret_key(app, username): + """ + The Secret Key, for a PC that has not opened this account before. The + text typed (checked for typos), or None if the dialog is closed. + """ + top = app._modal("Secret Key", 480, 360) + shell = dialog_shell(top, "Enter your Secret Key", + f"This PC does not hold the Secret Key of " + f"“{username}” — it is new here, or it can no " + "longer read the copy it kept. It is on your " + "Emergency Kit and starts with A3-.") + box = app._field(shell.body, "Secret Key", placeholder="A3-XXXXX-XXXXX-…") + box.configure(font=ctk.CTkFont(family=MONO, size=14)) + err = error_label(shell.body, shell.text_width) + out = {"text": None} + + def go(): + try: + account_crypto.parse_secret_key(box.get()) + except ValueError as e: + err.configure(text=str(e)); return + out["text"] = box.get() + top.destroy() + + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=110).pack(side="left") + app._accent_button(shell.buttons, "Unlock", go, + width=150).pack(side="right") + box.bind("", lambda e: go()) + top.bind("", lambda e: top.destroy()) + top.after(200, box.focus) + app.root.wait_window(top) + return out["text"] + + def import_dialog(app): + """Where to import from: a 2.x vault (whole entries) or a CSV/JSON file.""" + top = app._modal("Import", 520, 560) + shell = dialog_shell(top, "Import", + f"Bring logins into “{app.current_user}”. What you " + "import from is only read, never changed.", + scroll=True) + wrap = shell.body + hint = shell.text_width - 190 + + def go(action): + top.destroy() + action() + + two = section(wrap, "PassMan 2.x vaults on this PC", first=True) + names = vault.list_users() + if not names: + setting_row(two, "None found", + f"No 2.x vault in {vault.get_data_dir()}.", hint, + first=True) + for i, name in enumerate(names): + app._ghost_button( + setting_row(two, name, "With its history and archive.", hint, + first=i == 0), + "Import…", lambda n=name: go(lambda: _import_2x( + app, vault.get_vault_path(n), n)), width=130).pack() + + other = section(wrap, "From a file") + app._ghost_button( + setting_row(other, "A 2.x vault file", + "A backup (.vault.enc) or a copy from another PC.", hint, + first=True), + "Choose…", lambda: go(lambda: _import_2x_file(app)), width=130).pack() + app._ghost_button( + setting_row(other, "CSV or JSON", + "Exported from another password manager or a browser.", + hint), + "Choose…", lambda: go(lambda: _import_plain_file(app)), + width=130).pack() + + app._ghost_button(shell.buttons, "Close", top.destroy, + width=120).pack(side="right") + top.bind("", lambda e: top.destroy()) + + +def _import_2x_file(app): + path = filedialog.askopenfilename( + parent=app.root, title="Import a PassMan 2.x vault", + filetypes=[("PassMan vault", "*.enc *.vault *.bak"), + ("All files", "*.*")]) + if path: + _import_2x(app, path, os.path.basename(path)) + + +def _import_plain_file(app): path = filedialog.askopenfilename( parent=app.root, title="Import accounts from file", filetypes=[ - ("All supported", "*.csv *.json *.vault *.enc"), + ("All supported", "*.csv *.json"), ("CSV files", "*.csv"), ("JSON files", "*.json"), - ("Encrypted vault", "*.vault *.enc"), ("All files", "*.*")]) if not path: return - - # Encrypted PassMan vault files (.vault / .vault.enc / .enc) must be - # decrypted with their own master password before we can read accounts. - ext = os.path.splitext(path)[1].lower() - if ext in (".vault", ".enc"): - _import_encrypted_vault(app, path) - return - try: parsed = importers.parse_import_file(path) except Exception as e: @@ -130,40 +368,54 @@ def import_dialog(app): _finish_import(app, parsed, path) -def _import_encrypted_vault(app, path): - try: - with open(path, "rb") as f: - blob = f.read() - except Exception as e: - app.toast(f"Could not read file: {e}", "error") - return - - top = app._modal("Unlock vault file", 460, 280) - shell = dialog_shell(top, "Unlock vault file", - f"“{os.path.basename(path)}” is encrypted. Enter its " - "master password to import the accounts inside.") - pw = app._field(shell.body, "Master password of the file", show="•") +def _import_2x(app, path, label): + """ + A 2.x vault into the open account, entries whole (ids, history, archive). + The 2.x file is read once and left as it was; its sync settings stay + with it. + """ + top = app._modal("Import a 2.x vault", 460, 300) + shell = dialog_shell(top, f"Import “{label}”", + "Enter the master password of that 2.x vault. " + "Its file is only read.") + pw = app._field(shell.body, "Its master password", show="•") err = error_label(shell.body, shell.text_width) def unlock(): try: - data = decrypt(pw.get(), blob) - except Exception: - err.configure(text="Wrong password or not a valid vault file.") + data = vault.read_vault_file(path, pw.get()) + except OSError as e: + err.configure(text=f"Could not read the file: {e}"); return + except ValueError: + err.configure(text="Wrong password, or not a PassMan vault file.") return - result = importers.accounts_from_vault(data) + import copy + trial = copy.deepcopy(app.vault) + added, skipped = vault.import_entries(trial, data) top.destroy() - if not result.rows: - app.toast("That vault file has no accounts", "warn") + if not added: + app.toast("Everything in that vault is already here", "warn") return - _finish_import(app, result, path) + msg = (f"Import {added} entr{'y' if added == 1 else 'ies'} from " + f"“{label}”, with their history and archive state?") + if skipped: + msg += (f"\n\n{skipped} already in this account will be " + "skipped.") + msg += ("\n\nThe 2.x vault is not changed, and its sync settings " + "stay with it.") + if not app._confirm("Import a 2.x vault", msg, danger=False): + return + app.vault = trial + if app._save(): + app.refresh_account_list() + app.toast(f"Imported {added} entr{'y' if added == 1 else 'ies'}") app._ghost_button(shell.buttons, "Cancel", top.destroy, width=110).pack(side="left") - app._accent_button(shell.buttons, "Unlock & import", unlock, - width=170).pack(side="right") + app._accent_button(shell.buttons, "Import", unlock, + width=150).pack(side="right") pw.bind("", lambda e: unlock()) - pw.focus() + top.after(200, pw.focus) def _finish_import(app, parsed, path): @@ -410,7 +662,7 @@ def settings_dialog(app): # metrics), and growing the window to fit pushed Close off the screen. top = app._modal("Settings", 540, 660) shell = dialog_shell(top, "Settings", - f"For the vault “{app.current_user}” on this PC.", + f"For the account “{app.current_user}” on this PC.", scroll=True) wrap = shell.body hint = shell.text_width - 210 @@ -419,9 +671,15 @@ def settings_dialog(app): sec = section(wrap, "Security", first=True) app._ghost_button( setting_row(sec, "Master password", - "Re-encrypts your vault with a new one.", hint, first=True), + "Your Secret Key and recovery phrase stay the same.", + hint, first=True), "Change…", lambda: _change_master_dialog(app, top), width=150).pack() + app._ghost_button( + setting_row(sec, "Emergency Kit", + "Your Secret Key again — needed to open this account on " + "another PC.", hint), + "Save kit…", lambda: _save_kit_again(app), width=150).pack() def label_for(m): return "Never" if m == 0 else f"{m} minute{'s' if m != 1 else ''}" @@ -442,39 +700,15 @@ def set_autolock(choice): lock_menu.set(label_for(appconfig.autolock_minutes())) # ---- sync ---- + # PassMan 3 accounts sync from phase 2 (server accounts) on. syn = section(wrap, "Sync") - configured = sync_client.is_configured(app.vault) - app._ghost_button( - setting_row(syn, "Syncing" if configured else "Not set up", - (f"With {sync_client.get_settings(app.vault)['url']}" - if configured else - "Keep this vault in step with another PC through your " - "own server. It is encrypted before it is sent."), - hint, first=True), - "Sync settings" if configured else "Set up sync", - lambda: (top.destroy(), app.sync_settings_dialog()), - width=150).pack() - - if configured: - def set_on_unlock(): - on = on_unlock.get() == 1 - sync_client.set_sync_on_unlock(app.vault, on) - if app._save(): - app.toast("This vault now syncs each time you unlock it" if on - else "Sync on unlock is off — press Sync yourself") - - on_unlock = ctk.CTkSwitch( - setting_row(syn, "Sync when I unlock", - "Off: nothing is sent until you press Sync. On: " - "PassMan syncs right after you unlock; a conflict " - "still waits for your choice.", hint), - text="", width=46, switch_width=42, switch_height=22, - progress_color=GREEN, button_color=TEXT, - button_hover_color=TEXT, fg_color=BORDER, - command=set_on_unlock) - on_unlock.pack() - if sync_client.sync_on_unlock(app.vault): - on_unlock.select() + # The control slot gets a label: an empty CTkFrame is 200 px tall. + ctk.CTkLabel(setting_row(syn, "Not in this test version", + "Accounts sync from the next 3.0 test version " + "on. Your 2.x vaults keep syncing in PassMan " + "2.x.", hint, first=True), + text="Next version", font=F(12, "bold"), + text_color=MUTED).pack() # ---- archive ---- arc = section(wrap, "Archive") @@ -517,10 +751,10 @@ def set_purge(choice): # ---- backup & export ---- exp = section(wrap, "Backup & export") app._ghost_button( - setting_row(exp, "Encrypted backup", - "Stays protected by your master password, so it is safe " - "on a USB stick or in cloud storage.", hint, first=True), - "Save backup…", lambda: _backup_encrypted(app), width=150).pack() + setting_row(exp, "Backup", + "Copy this account's folder. The copy opens only with " + "your master password and Secret Key.", hint, first=True), + "Open folder", lambda: _open_account_folder(app), width=150).pack() app._ghost_button( setting_row(exp, "Plain export", "Readable by anyone who opens the file.", hint), @@ -530,9 +764,10 @@ def set_purge(choice): # ---- danger zone ---- danger = section(wrap, "Danger zone", danger=True) danger_button( - setting_row(danger, "Delete this user", - f"Permanently deletes the vault for " - f"“{app.current_user}” on this PC.", hint, first=True), + setting_row(danger, "Delete this account", + f"Permanently deletes “{app.current_user}” and its vault " + "on this PC. 2.x vaults are not touched.", hint, + first=True), "Delete…", lambda: _delete_user_flow(app, top), width=150).pack() ctk.CTkFrame(wrap, height=8, fg_color="transparent").pack() @@ -543,23 +778,22 @@ def set_purge(choice): top.bind("", lambda e: top.destroy()) -def _backup_encrypted(app): - path = filedialog.asksaveasfilename( - parent=app.root, title="Save encrypted backup", - defaultextension=".vault.enc", - initialfile=f"{app.current_user}-backup.vault.enc", - filetypes=[("Encrypted vault", "*.enc"), ("All files", "*.*")]) - if not path: - return +def _save_kit_again(app): + if ask_master_password(app, "Emergency Kit", + "Enter your master password to save your Secret " + "Key again.", action="Continue"): + _save_kit_file(app, app.current_user, app.account.secret_key_text) + + +def _open_account_folder(app): + folder = accounts.account_dir(app.current_user) try: - # Save first so the backup includes anything not yet written. - if not app._save(): - return - export_encrypted(app.current_user, path) - except Exception as e: - app.toast(f"Backup failed: {e}", "error") - return - app.toast("Encrypted backup saved") + if sys.platform == "win32": + os.startfile(folder) # Explorer + else: + subprocess.Popen(["xdg-open", folder]) # the desktop's file manager + except OSError as e: + app.toast(f"The folder is {folder} ({e})", "warn") def _export_plaintext(app): @@ -589,10 +823,10 @@ def _export_plaintext(app): def _change_master_dialog(app, parent_top): - top = app._modal("Change master password", 460, 560) + top = app._modal("Change master password", 460, 520) shell = dialog_shell(top, "Change master password", - "The vault is re-encrypted immediately. The new " - "password cannot be recovered if forgotten.") + "Takes effect at once. Your Secret Key and recovery " + "phrase stay the same.") wrap = shell.body cur_entry = app._field(wrap, "Current master password", show="•") @@ -600,19 +834,6 @@ def _change_master_dialog(app, parent_top): bar, label, update_strength = app._strength_widget(wrap) confirm_entry = app._field(wrap, "Confirm new password", show="•") new_entry.bind("", lambda e: update_strength(new_entry.get())) - - syncing = sync_client.is_configured(app.vault) - drop = None - if syncing: - note(wrap, "This vault syncs. Your server is updated straight away, " - "and your other PCs ask for the new password on their next " - "sync.", shell.text_width, pady=(14, 0)) - # Checked by default: a master password is usually changed because - # the old one may be exposed, and these copies still open with it. - drop = check(wrap, "Also delete the older copies on the server — " - "they still open with the old password", - shell.text_width, checked=True) - err = error_label(wrap, shell.text_width) def change(): @@ -625,38 +846,18 @@ def change(): err.configure(text="New passwords do not match."); return if new == app.master_password: err.configure(text="That is already your master password."); return - old = app.master_password - - # The generation is stored inside the vault, so it is bumped before - # the save that writes it. Other PCs compare it with the server's to - # tell "the server is ahead" from "the server is behind". - generation = sync_client.key_generation(app.vault) - if syncing: - sync_client.note_password_change(app.vault) + if len(new) < 8: + err.configure(text="Use at least 8 characters."); return try: - # Re-encrypts the vault, its .bak and the sync base together. - # The base matters: dropping it would turn the next sync into a - # merge with no common ancestor and a wall of needless conflicts. - change_master_password(app.vault, app.current_user, old, new) + # The keyset is resealed; the vault file is not touched. + app.account.change_password(new) except Exception: - if syncing: - sync_client.set_key_generation(app.vault, generation) - err.configure(text="Could not save the vault. Password unchanged.") + err.configure(text="Could not save. Password unchanged.") return app.master_password = new top.destroy() parent_top.destroy() - - if not syncing: - return app.toast("Master password changed") - app.toast("Master password changed — updating the server…") - # The old password is handed over rather than asked for again: it is - # what the server's copy is still under. - app.sync_now( - server_password=old, drop_history=bool(drop.get()), - offline_hint="Master password changed. The server could not be " - "reached — the next Sync will ask for your previous " - "password once to update it.") + app.toast("Master password changed") app._ghost_button(shell.buttons, "Cancel", top.destroy, width=110).pack(side="left") @@ -699,10 +900,11 @@ def go(): def _delete_user_flow(app, parent_top): - top = app._modal("Delete user", 440, 300) + top = app._modal("Delete account", 440, 300) shell = dialog_shell(top, f"Delete “{app.current_user}”", "Re-enter your master password to confirm. This " - "deletes the vault file permanently.", danger=True) + "deletes the account and its vault on this PC " + "permanently.", danger=True) pw = app._field(shell.body, "Master password", show="•") err = error_label(shell.body, shell.text_width) @@ -710,13 +912,14 @@ def do_delete(): if not password_matches(pw.get(), app.master_password): err.configure(text="Incorrect master password."); return try: - delete_user(app.current_user, app.master_password) + accounts.delete_account(app.current_user) except Exception as e: err.configure(text=str(e)); return top.destroy(); parent_top.destroy() app.vault = app.master_password = app.current_user = None + app.account = None app.show_login_screen() - app.toast("User deleted") + app.toast("Account deleted") app._ghost_button(shell.buttons, "Cancel", top.destroy, width=110).pack(side="left") diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index 74e8c70..e43099f 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -17,6 +17,7 @@ import customtkinter as ctk +import accounts import appconfig import clipboard import dialogs @@ -33,9 +34,9 @@ strength_widget, modal, fit_modal, safe_grab, confirm, STRENGTH_SEGMENTS) from password_generator import estimate_strength -from vault import (load_vault, save_vault, needs_rekey, active_accounts, - archive_account, sort_accounts, SORT_ORDERS, - auto_purge_archive, archive_purge_days, list_users, +from account_crypto import CryptoError +from vault import (active_accounts, archive_account, sort_accounts, + SORT_ORDERS, auto_purge_archive, archive_purge_days, window_title) from version import version_string @@ -95,6 +96,8 @@ def __init__(self, root): self.vault = None self.master_password = None self.current_user = None + # The unlocked 3.0 account (accounts.Account): its keys, its files. + self.account = None self.search_query = "" self.sort_order = appconfig.sort_order() @@ -255,8 +258,7 @@ def _hide_toast(self): def _save(self, rekey=False): try: - save_vault(self.master_password, self.vault, self.current_user, - rekey=rekey) + self.account.save_vault(self.vault) return True except Exception as e: self.toast(f"Save failed: {e}", "error") @@ -358,10 +360,11 @@ def show_login_screen(self): self._logo(pad).pack() ctk.CTkLabel(pad, text="Welcome back", font=F(26, "bold"), text_color=TEXT).pack(pady=(12, 0)) - ctk.CTkLabel(pad, text="Pick your vault and enter its master password.", + ctk.CTkLabel(pad, text="Pick your account and enter its master " + "password.", font=F(13), text_color=MUTED).pack(pady=(4, 20)) - users = list_users() + users = accounts.list_accounts() last = appconfig.load_config().get("last_username", "") self._login_user = last if last in users else (users[0] if users else "") self._login_tiles = ctk.CTkFrame(pad, fg_color="transparent") @@ -404,16 +407,13 @@ def toggle_pw(): links = ctk.CTkFrame(pad, fg_color="transparent") links.pack(fill="x") - # The second PC's starting point: there is no vault here yet, so there - # is nothing to unlock and nothing to merge -- just a download. - for text, command, side in ( - ("Create a new user", self.create_user_dialog, "left"), - ("Restore from server", self.restore_from_server_dialog, - "right")): - ctk.CTkButton(links, text=text, command=command, width=10, - height=32, fg_color="transparent", - hover_color=CARD_HOVER, text_color=GREEN, - font=F(13, "bold")).pack(side=side) + # 3.0 phase 1 is local only; "Restore from server" returns with the + # server accounts of phase 2. 2.x vaults come in through Import once + # an account is open. + ctk.CTkButton(links, text="Create an account", + command=self.create_user_dialog, width=10, height=32, + fg_color="transparent", hover_color=CARD_HOVER, + text_color=GREEN, font=F(13, "bold")).pack(side="left") ctk.CTkLabel(pad, text=version_string(), font=F(11), text_color=MUTED).pack(pady=(14, 0)) @@ -449,15 +449,16 @@ def _draw_backdrop(self, canvas, w, h): fill="#1F3A2A", outline="") def _draw_login_tiles(self, users): - """One tile per vault on this PC, plus one to create another.""" + """One tile per account on this PC, plus one to create another.""" for w in self._login_tiles.winfo_children(): w.destroy() for c in range(LOGIN_TILES_PER_ROW): self._login_tiles.grid_columnconfigure(c, weight=1, uniform="tile") if not users: ctk.CTkLabel(self._login_tiles, - text="There is no vault on this PC yet. Create one, " - "or restore one from your server.", + text="There is no PassMan 3 account on this PC yet. " + "Create one, then import your 2.x vaults into " + "it.", font=F(13), text_color=MUTED, wraplength=360).grid( row=0, column=0, columnspan=LOGIN_TILES_PER_ROW) return @@ -477,7 +478,7 @@ def _login_tile(self, name): if name is None: avatar, letters = self._circle(tile, "+", 50, INPUT_BG, MUTED, F(22, "bold")) - label = "Add vault" + label = "Add account" command = self.create_user_dialog else: bg, ink, _ = tile_colors(name) @@ -497,7 +498,7 @@ def _login_tile(self, name): def _pick_login_user(self, name): self._login_user = name - self._draw_login_tiles(list_users()) + self._draw_login_tiles(accounts.list_accounts()) self._cancel_lockout_timer() wait = lockout.remaining(name) if wait: @@ -550,12 +551,11 @@ def _cancel_lockout_timer(self): def _login_error(self, msg): self.login_error.configure(text=msg) - def unlock_vault(self): + def unlock_vault(self, secret_key_text=None): username = self._login_user master_password = self.password_entry.get() if not username: - self._login_error("Create a vault or restore one from your " - "server first.") + self._login_error("Create an account first.") return if not master_password: self._login_error("Enter the master password.") @@ -570,21 +570,33 @@ def unlock_vault(self): # the handler below, which then wrote to a login widget that # show_main_window()'s _clear() had already destroyed. try: - loaded = load_vault(master_password, username) + account = accounts.unlock(username, master_password, + secret_key_text) + loaded = account.load_vault() + except accounts.SecretKeyNeeded: + # A new PC, or another Windows user: the Secret Key is asked for + # once, from the Emergency Kit, then kept on this device. + typed = dialogs.ask_secret_key(self, username) + if typed: + self.unlock_vault(secret_key_text=typed) + return except FileNotFoundError: - self._login_error("No vault found for that username.") + self._login_error("No account found with that name.") return except ValueError as e: msg = str(e) - if "Decryption failed" not in msg: - self._login_error(msg) # a real vault problem, not a typo + if not (isinstance(e, CryptoError) + and msg.startswith("Wrong master password")): + self._login_error(msg) # a real file problem, not a typo return wait = lockout.record_failure(username) if wait: self._begin_lockout(username, wait) else: left = lockout.FREE_ATTEMPTS - lockout.failures(username) - self._login_error("Incorrect master password." + + self._login_error(("Incorrect master password or Secret Key." + if secret_key_text else + "Incorrect master password.") + (f" {left} attempt(s) before a delay." if left else "")) return @@ -593,15 +605,19 @@ def unlock_vault(self): return lockout.reset(username) + self.open_account(account, loaded, master_password) + if secret_key_text: + self.toast("Secret Key saved on this PC — it won't be asked " + "for again here") + + def open_account(self, account, loaded, master_password): + """After an unlock or a new account: the main window.""" self._session += 1 + self.account = account self.vault = loaded self.master_password = master_password - self.current_user = username - appconfig.save_config(last_username=username) - - # Silently bring an older vault up to the current KDF cost. - if needs_rekey(username): - self._save() + self.current_user = account.username + appconfig.save_config(last_username=account.username) # Archived entries past this vault's keep-for limit (Settings). purged = auto_purge_archive(self.vault) @@ -666,7 +682,8 @@ def show_main_window(self): ctk.CTkLabel(chip, text=_clip(self.current_user, 24), font=F(13, "bold"), text_color=TEXT).pack( side="left", padx=(0, 14)) - Tooltip(chip, "The vault you unlocked. Lock to switch to another.") + Tooltip(chip, "The account you unlocked. Lock to switch to " + "another.") self.search_entry = ctk.CTkEntry( bar, height=44, corner_radius=22, fg_color=INPUT_BG, @@ -677,12 +694,10 @@ def show_main_window(self): controls = ctk.CTkFrame(bar, fg_color="transparent") controls.grid(row=0, column=3, padx=(0, 24)) - sync_btn = self._ghost_button(controls, "Sync", self.sync_now, width=74) - sync_btn.pack(side="left", padx=(0, 8)) - Tooltip(sync_btn, "Merge this vault with the one on your server") + # No Sync button in 3.0 phase 1: accounts sync from phase 2 on. imp = self._ghost_button(controls, "Import", self.import_dialog, width=80) imp.pack(side="left", padx=(0, 8)) - Tooltip(imp, "Import accounts from a CSV or JSON file") + Tooltip(imp, "Import a PassMan 2.x vault, or a CSV or JSON file") gen = self._ghost_button(controls, "Generate", self.generator_dialog, width=92) gen.pack(side="left", padx=(0, 8)) @@ -692,7 +707,7 @@ def show_main_window(self): Tooltip(gear, "Settings") lock = self._ghost_button(controls, "Lock", self.lock_vault, width=70) lock.pack(side="left") - Tooltip(lock, "Lock the vault and sign out") + Tooltip(lock, "Lock the account and sign out") # ---- body: the list on the left, the selected entry on the right ---- body = ctk.CTkFrame(self.root, fg_color="transparent") @@ -1258,11 +1273,12 @@ def lock_vault(self, auto=False): # A copied password outlives the lock: the usual auto-clear timer # keeps running, so it can still be pasted until that fires. self.vault = self.master_password = self.current_user = None + self.account = None self._selected_id = None self.search_query = "" self.show_login_screen() if auto: - self.toast("Vault locked after inactivity", "warn") + self.toast("Locked after inactivity", "warn") def _on_close(self): self._cancel_idle_timer() diff --git a/sourceFiles/secret_store.py b/sourceFiles/secret_store.py new file mode 100644 index 0000000..bef8705 --- /dev/null +++ b/sourceFiles/secret_store.py @@ -0,0 +1,159 @@ +""" +How a 3.0 account keeps its Secret Key on this device. + +The Secret Key is typed once per device, from the Emergency Kit, and kept +here afterwards, so unlocking needs only the master password (as in +1Password). It is never stored in the clear: + +* **Windows -- DPAPI** (``CryptProtectData``), tied to the Windows user. A + copy of the account folder taken to another PC, or opened by another + Windows user, cannot read it back; that device asks for the Emergency Kit + once and stores the key its own way. +* **Elsewhere -- under the master password**: Argon2id with its own salt, + then AES-GCM. A stolen account folder is then as strong as the master + password (and Argon2id), never weaker -- the Secret Key's extra 128 bits + protect what is on the *server*. The Linux keyring would do better, but it + needs a library and a running keyring daemon; it can be added later as a + third scheme without changing the others. + +A record is a small dict stored in ``account.json``; ``scheme`` says which +of the two it is, so a file written on one OS is recognised (and refused +cleanly) on the other. +""" + +import os +import sys +import unicodedata + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +import account_crypto +from account_crypto import CryptoError, _b64, _unb64 + +SCHEME_DPAPI = "dpapi" +SCHEME_PASSWORD = "password" + + +class Unavailable(Exception): + """This device cannot read the stored Secret Key: ask for the Emergency Kit.""" + + +def default_scheme() -> str: + return SCHEME_DPAPI if sys.platform == "win32" else SCHEME_PASSWORD + + +def _context(username: str) -> bytes: + # Binds a record to its account, so one copied into another account's + # file does not open there. + return b"PassMan 3 Secret Key\x00" + username.encode("utf-8") + + +# --------------------------------------------------------------------------- +# DPAPI (Windows) +# --------------------------------------------------------------------------- +def _dpapi(data: bytes, entropy: bytes, protect: bool) -> bytes: + import ctypes + from ctypes import wintypes + + class Blob(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), + ("pbData", ctypes.POINTER(ctypes.c_char))] + + def blob(raw): + buf = ctypes.create_string_buffer(raw, len(raw)) + return Blob(len(raw), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))), buf + + crypt32 = ctypes.windll.crypt32 + kernel32 = ctypes.windll.kernel32 + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + data_in, keep1 = blob(data) + extra, keep2 = blob(entropy) + out = Blob() + ui_forbidden = 0x1 + if protect: + ok = crypt32.CryptProtectData(ctypes.byref(data_in), "PassMan Secret Key", + ctypes.byref(extra), None, None, + ui_forbidden, ctypes.byref(out)) + else: + ok = crypt32.CryptUnprotectData(ctypes.byref(data_in), None, + ctypes.byref(extra), None, None, + ui_forbidden, ctypes.byref(out)) + if not ok: + raise Unavailable("Windows could not read the stored Secret Key " + "(another PC or another Windows user).") + try: + return ctypes.string_at(out.pbData, out.cbData) + finally: + kernel32.LocalFree(ctypes.cast(out.pbData, ctypes.c_void_p)) + + +# --------------------------------------------------------------------------- +# Under the master password (everywhere else) +# --------------------------------------------------------------------------- +def _password_key(password: str, salt: bytes, passes: int, memory_kib: int, + lanes: int) -> bytes: + account_crypto._check_cost(passes, memory_kib, lanes) + pw = unicodedata.normalize("NFKC", password).encode("utf-8") + return account_crypto.argon2id(pw, salt, passes, memory_kib, lanes) + + +# --------------------------------------------------------------------------- +def protect(secret: bytes, username: str, password: str, + scheme: str = None) -> dict: + """A record that gives ``secret`` back to this device (see the module doc).""" + scheme = scheme or default_scheme() + if scheme == SCHEME_DPAPI: + return {"scheme": SCHEME_DPAPI, + "blob": _b64(_dpapi(secret, _context(username), True))} + if scheme == SCHEME_PASSWORD: + salt = os.urandom(account_crypto.SALT_LEN) + nonce = os.urandom(account_crypto.NONCE_LEN) + cost = (account_crypto.ARGON2_PASSES, account_crypto.ARGON2_MEMORY_KIB, + account_crypto.ARGON2_LANES) + key = _password_key(password, salt, *cost) + return {"scheme": SCHEME_PASSWORD, "salt": _b64(salt), + "nonce": _b64(nonce), "passes": cost[0], + "memory_kib": cost[1], "lanes": cost[2], + "sealed": _b64(AESGCM(key).encrypt(nonce, secret, + _context(username)))} + raise ValueError(f"Unknown Secret Key scheme {scheme!r}") + + +def unprotect(record, username: str, password: str) -> bytes: + """ + The Secret Key, or :class:`Unavailable` when this device cannot read the + record (none stored, another PC's DPAPI, a scheme this OS lacks). A wrong + master password under the password scheme is a :class:`CryptoError`, + worded like the keyset's own, so the two failures read the same. + """ + if not isinstance(record, dict): + raise Unavailable("This PC does not hold the Secret Key yet.") + scheme = record.get("scheme") + if scheme == SCHEME_DPAPI: + if sys.platform != "win32": + raise Unavailable("The Secret Key was stored by Windows.") + try: + blob = _unb64(record.get("blob"), "Secret Key") + except CryptoError: + raise Unavailable("The stored Secret Key is damaged.") from None + raw = _dpapi(blob, _context(username), False) + elif scheme == SCHEME_PASSWORD: + try: + salt = _unb64(record["salt"], "Secret Key") + nonce = _unb64(record["nonce"], "Secret Key") + sealed = _unb64(record["sealed"], "Secret Key") + cost = (int(record["passes"]), int(record["memory_kib"]), + int(record["lanes"])) + except (KeyError, TypeError, ValueError): # CryptoError is a ValueError + raise Unavailable("The stored Secret Key is damaged.") from None + key = _password_key(password, salt, *cost) + try: + raw = AESGCM(key).decrypt(nonce, sealed, _context(username)) + except Exception: + raise CryptoError("Wrong master password or Secret Key.") from None + else: + raise Unavailable("The stored Secret Key is in a form this PassMan " + "does not know.") + if len(raw) != account_crypto.SECRET_KEY_LEN: + raise Unavailable("The stored Secret Key is damaged.") + return raw diff --git a/sourceFiles/tests/test_accounts.py b/sourceFiles/tests/test_accounts.py new file mode 100644 index 0000000..e0751dd --- /dev/null +++ b/sourceFiles/tests/test_accounts.py @@ -0,0 +1,476 @@ +"""PassMan 3.0 local accounts: files, unlock, Secret Key per device, 2.x import.""" + +import hashlib +import json +import os +import sys + +import pytest + +import account_crypto +import accounts +import secret_store +import vault +from account_crypto import CryptoError + +PW = "correct horse battery staple" + + +@pytest.fixture(autouse=True) +def cheap_argon2(monkeypatch): + # The real cost is 64 MiB x 3; the files record whatever was used, so a + # small one exercises the same code in a fraction of the time. + monkeypatch.setattr(account_crypto, "ARGON2_MEMORY_KIB", 64) + monkeypatch.setattr(account_crypto, "ARGON2_PASSES", 1) + + +@pytest.fixture(params=["native", "password"]) +def scheme(request, monkeypatch): + """Run the Secret Key tests under this OS's scheme and the portable one.""" + if request.param == "password": + monkeypatch.setattr(secret_store, "default_scheme", + lambda: secret_store.SCHEME_PASSWORD) + return request.param + + +def make(name="alice", pw=PW): + new = accounts.prepare_account(name, pw) + return new, new.save() + + +def entry(site, user="u", pw="p", **extra): + e = {"site": site, "username": user, "password": pw} + e.update(extra) + return e + + +# --------------------------------------------------------------------------- +# Making an account +# --------------------------------------------------------------------------- +def test_files_and_layout(data_dir): + new, acc = make() + folder = os.path.join(os.path.dirname(str(data_dir)), "accounts", "alice") + assert accounts.account_dir("alice") == folder + assert sorted(os.listdir(folder)) == ["account.json", "keyset.pmk", + "recovery.pmk", "vaults"] + assert os.listdir(os.path.join(folder, "vaults")) == [ + acc.info["personal_vault"] + ".pmv3"] + assert accounts.list_accounts() == ["alice"] + assert acc.load_vault() == {"accounts": []} + + +def test_account_json_holds_nothing_secret(scheme): + new, acc = make() + text = open(os.path.join(accounts.account_dir("alice"), "account.json"), + encoding="utf-8").read() + raw = acc._secret + for secret in (PW, new.secret_key_text, new.recovery_phrase, + account_crypto._b64(raw), raw.hex(), + acc.keyset["personal_vault_key"], acc.keyset["x25519_private"]): + assert secret not in text + info = json.loads(text) + assert info["mode"] == "personal" and info["server"] is None + assert info["secret_key"]["scheme"] in (secret_store.SCHEME_DPAPI, + secret_store.SCHEME_PASSWORD) + + +def test_nothing_is_written_until_save(): + new = accounts.prepare_account("alice", PW) + assert accounts.list_accounts() == [] + assert not os.path.exists(accounts.accounts_dir()) + assert new.secret_key_text.startswith("A3-") + assert len(new.recovery_phrase.split()) == 12 + new.save() + assert accounts.list_accounts() == ["alice"] + + +def test_duplicate_name_refused(): + make() + with pytest.raises(FileExistsError): + accounts.prepare_account("alice", "another password") + + +def test_leftover_folder_is_not_overwritten(): + os.makedirs(accounts.account_dir("bob")) + marker = os.path.join(accounts.account_dir("bob"), "keep.txt") + open(marker, "w").write("mine") + new = accounts.prepare_account("bob", PW) + with pytest.raises(FileExistsError, match="holds no account"): + new.save() + assert open(marker).read() == "mine" + assert accounts.list_accounts() == [] + + +def test_failed_save_leaves_no_half_account(monkeypatch): + new = accounts.prepare_account("alice", PW) + + def boom(*a, **k): + raise OSError("disk full") + monkeypatch.setattr(accounts, "_write_info", boom) + with pytest.raises(OSError): + new.save() + assert not os.path.exists(accounts.account_dir("alice")) + + +@pytest.mark.parametrize("bad", ["", "../x", "a/b", "con", " "]) +def test_bad_usernames_refused(bad): + with pytest.raises(ValueError): + accounts.prepare_account(bad, PW) + + +def test_empty_password_refused(): + with pytest.raises(ValueError): + accounts.prepare_account("alice", "") + + +def test_list_ignores_junk(data_dir): + make() + root = accounts.accounts_dir() + os.makedirs(os.path.join(root, "half")) # no account.json + open(os.path.join(root, "stray.txt"), "w").close() + assert accounts.list_accounts() == ["alice"] + + +# --------------------------------------------------------------------------- +# Unlocking +# --------------------------------------------------------------------------- +def test_unlock_with_password_only(scheme): + new, acc = make() + acc.save_vault({"accounts": [entry("github")]}) + again = accounts.unlock("alice", PW) + assert [a["site"] for a in again.load_vault()["accounts"]] == ["github"] + assert again.fingerprint == new.fingerprint + assert again.secret_key_text == new.secret_key_text + + +def test_wrong_password(scheme): + make() + with pytest.raises(CryptoError, match="Wrong master password"): + accounts.unlock("alice", PW + "x") + + +def test_unknown_account(): + with pytest.raises(FileNotFoundError): + accounts.unlock("nobody", PW) + + +def _forget_secret_key(name="alice"): + """What a copied folder looks like on a new PC: the key does not open.""" + path = os.path.join(accounts.account_dir(name), "account.json") + info = json.load(open(path, encoding="utf-8")) + info.pop("secret_key") + json.dump(info, open(path, "w", encoding="utf-8")) + + +def test_new_device_asks_for_secret_key_once(scheme): + new, acc = make() + _forget_secret_key() + with pytest.raises(accounts.SecretKeyNeeded): + accounts.unlock("alice", PW) + opened = accounts.unlock("alice", PW, secret_key_text=new.secret_key_text) + assert opened.load_vault() == {"accounts": []} + # Stored now: the next unlock needs the password only. + accounts.unlock("alice", PW) + + +def test_secret_key_typed_loosely_is_accepted(): + new, acc = make() + _forget_secret_key() + loose = new.secret_key_text.lower().replace("-", " ") + accounts.unlock("alice", PW, secret_key_text=loose) + + +def test_wrong_secret_key_is_not_stored(): + new, acc = make() + _forget_secret_key() + other = account_crypto.format_secret_key(account_crypto.new_secret_key()) + with pytest.raises(CryptoError, match="Wrong master password or Secret Key"): + accounts.unlock("alice", PW, secret_key_text=other) + with pytest.raises(accounts.SecretKeyNeeded): + accounts.unlock("alice", PW) + + +def test_typo_in_secret_key_is_caught_before_unlocking(): + new, acc = make() + _forget_secret_key() + text = new.secret_key_text + last = text[-1] + typo = text[:-1] + ("A" if last != "A" else "B") + with pytest.raises(ValueError): + accounts.unlock("alice", PW, secret_key_text=typo) + + +def test_secret_key_record_of_another_account_does_not_open(scheme): + make("alice") + make("bob") + a = os.path.join(accounts.account_dir("alice"), "account.json") + b = os.path.join(accounts.account_dir("bob"), "account.json") + info_a = json.load(open(a, encoding="utf-8")) + info_b = json.load(open(b, encoding="utf-8")) + info_b["secret_key"] = info_a["secret_key"] + json.dump(info_b, open(b, "w", encoding="utf-8")) + with pytest.raises((accounts.SecretKeyNeeded, CryptoError)): + accounts.unlock("bob", PW) + + +def test_damaged_account_file(): + make() + path = os.path.join(accounts.account_dir("alice"), "account.json") + open(path, "w").write("{not json") + with pytest.raises(ValueError, match="damaged"): + accounts.unlock("alice", PW) + + +def test_newer_account_format_refused(): + make() + path = os.path.join(accounts.account_dir("alice"), "account.json") + info = json.load(open(path, encoding="utf-8")) + info["format"] = 99 + json.dump(info, open(path, "w", encoding="utf-8")) + with pytest.raises(ValueError, match="another PassMan version"): + accounts.unlock("alice", PW) + + +@pytest.mark.skipif(sys.platform != "win32", reason="DPAPI is Windows-only") +def test_windows_keeps_the_secret_key_with_dpapi(): + new, acc = make() + assert acc.info["secret_key"]["scheme"] == secret_store.SCHEME_DPAPI + + +# --------------------------------------------------------------------------- +# The personal vault file +# --------------------------------------------------------------------------- +def test_vault_file_is_pmv3_bound_to_its_id(): + new, acc = make() + acc.save_vault({"accounts": [entry("x")]}) + blob = open(acc.vault_path, encoding="utf-8").read() + head = account_crypto.vault_header(blob) + assert head["vault_id"] == acc.vault_id and head["key_generation"] == 0 + + +def test_vault_of_another_account_is_refused(): + _, a = make("alice") + _, b = make("bob") + a.save_vault({"accounts": [entry("alice-site")]}) + # bob's file replaced by alice's: a different vault id and key. + os.replace(a.vault_path, b.vault_path) + if os.path.exists(b.vault_path + ".bak"): + os.remove(b.vault_path + ".bak") + with pytest.raises(CryptoError): + accounts.unlock("bob", PW).load_vault() + + +def test_save_keeps_previous_as_bak_and_falls_back(): + new, acc = make() + acc.save_vault({"accounts": [entry("one")]}) + acc.save_vault({"accounts": [entry("one"), entry("two")]}) + open(acc.vault_path, "w").write("garbage") + sites = [a["site"] for a in acc.load_vault()["accounts"]] + assert sites == ["one"] + + +def test_save_validates_like_2x(): + new, acc = make() + with pytest.raises(ValueError): + acc.save_vault({"no": "accounts"}) + + +# --------------------------------------------------------------------------- +# Password checks and changes +# --------------------------------------------------------------------------- +def test_check_password(): + new, acc = make() + assert acc.check_password(PW) + assert not acc.check_password(PW + "!") + + +def test_change_password(scheme): + new, acc = make() + acc.save_vault({"accounts": [entry("keep")]}) + before = open(acc.vault_path, "rb").read() + acc.change_password("a brand new password") + with pytest.raises(CryptoError): + accounts.unlock("alice", PW) + again = accounts.unlock("alice", "a brand new password") + assert [a["site"] for a in again.load_vault()["accounts"]] == ["keep"] + # Same keyset, so the vault file did not need rewriting. + assert open(acc.vault_path, "rb").read() == before + assert again.check_password("a brand new password") + assert not again.check_password(PW) + + +def test_change_password_leaves_no_old_keyset_copy(): + new, acc = make() + acc.change_password("new one here") + folder = accounts.account_dir("alice") + assert sorted(os.listdir(folder)) == ["account.json", "keyset.pmk", + "recovery.pmk", "vaults"] + + +def test_change_password_then_new_device(scheme): + new, acc = make() + acc.change_password("second password") + _forget_secret_key() + accounts.unlock("alice", "second password", + secret_key_text=new.secret_key_text) + + +# --------------------------------------------------------------------------- +# Recovery phrase (shown at creation; the recovery flow is phase 5) +# --------------------------------------------------------------------------- +def test_recovery_phrase_opens_the_recovery_copy(): + new, acc = make() + assert accounts.check_recovery_phrase("alice", new.recovery_phrase) + other = account_crypto.new_recovery_phrase() + assert not accounts.check_recovery_phrase("alice", other) + + +def test_recovery_phrase_survives_a_password_change(): + new, acc = make() + acc.change_password("changed") + assert accounts.check_recovery_phrase("alice", new.recovery_phrase) + + +# --------------------------------------------------------------------------- +# Deleting +# --------------------------------------------------------------------------- +def test_delete_account_only_removes_its_folder(): + make("alice") + make("bob") + vault.create_new_user("legacy", "pw2x") + accounts.delete_account("alice") + assert accounts.list_accounts() == ["bob"] + assert vault.list_users() == ["legacy"] + with pytest.raises(FileNotFoundError): + accounts.delete_account("alice") + + +# --------------------------------------------------------------------------- +# Emergency Kit +# --------------------------------------------------------------------------- +def test_emergency_kit_has_the_key_but_not_the_phrase(): + new, acc = make() + kit = accounts.emergency_kit_text("alice", new.secret_key_text) + assert new.secret_key_text in kit and "alice" in kit + # Not word by word: the kit is plain English, and the BIP39 list has + # "account", "page", "file"... -- no two phrase words in a row, though. + text = " ".join(kit.lower().split()) + words = new.recovery_phrase.split() + for a, b in zip(words, words[1:]): + assert f"{a} {b}" not in text + assert PW not in kit + + +def test_write_emergency_kit(tmp_path): + new, acc = make() + path = tmp_path / "kit.txt" + path.write_text("old") + accounts.write_emergency_kit(str(path), "alice", new.secret_key_text) + assert new.secret_key_text in path.read_text(encoding="utf-8") + if os.name == "posix": + assert path.stat().st_mode & 0o777 == 0o600 + + +@pytest.mark.skipif(os.name != "posix", reason="Unix permissions") +def test_account_folders_are_private(): + new, acc = make() + for d in (accounts.accounts_dir(), accounts.account_dir("alice"), + os.path.dirname(acc.vault_path)): + assert os.stat(d).st_mode & 0o777 == 0o700, d + for name in ("account.json", "keyset.pmk", "recovery.pmk"): + path = os.path.join(accounts.account_dir("alice"), name) + assert os.stat(path).st_mode & 0o777 == 0o600, name + + +# --------------------------------------------------------------------------- +# Importing a 2.x vault +# --------------------------------------------------------------------------- +def _two_x_vault(): + data = {"accounts": []} + vault.add_account(data, "github", "al", "pw1", notes="n", totp="JBSWY3DPEHPK3PXP") + e = data["accounts"][0] + vault.update_entry(e, password="pw2") # makes history + vault.add_account(data, "old", "al", "gone") + vault.archive_account(data, data["accounts"][1]["id"]) + data["sync"] = {"url": "https://server", "token": "SECRET-TOKEN"} + data["archive_purge_days"] = 30 + return data + + +def test_import_keeps_entries_whole(): + src = _two_x_vault() + target = {"accounts": []} + added, skipped = vault.import_entries(target, src) + assert (added, skipped) == (2, 0) + by_site = {a["site"]: a for a in target["accounts"]} + gh = by_site["github"] + assert gh["password"] == "pw2" and gh["notes"] == "n" + assert gh["totp"] == "JBSWY3DPEHPK3PXP" + assert gh["id"] == src["accounts"][0]["id"] + assert [r["password"] for r in gh["history"]] == ["pw1"] + assert vault.is_archived(by_site["old"]) + assert by_site["old"]["archived_at"] == src["accounts"][1]["archived_at"] + + +def test_import_leaves_2x_settings_behind(): + target = {"accounts": []} + vault.import_entries(target, _two_x_vault()) + assert set(target) == {"accounts"} + + +def test_import_does_not_change_the_source(): + src = _two_x_vault() + before = json.dumps(src, sort_keys=True) + target = {"accounts": []} + vault.import_entries(target, src) + target["accounts"][0]["password"] = "edited after import" + target["accounts"][0]["history"].append({"x": 1}) + assert json.dumps(src, sort_keys=True) == before + + +def test_import_twice_adds_nothing(): + src = _two_x_vault() + target = {"accounts": []} + vault.import_entries(target, src) + assert vault.import_entries(target, src) == (0, 2) + assert len(target["accounts"]) == 2 + + +def test_import_skips_same_login_with_other_id(): + target = {"accounts": []} + vault.add_account(target, "github", "al", "pw2") + added, skipped = vault.import_entries(target, _two_x_vault()) + assert (added, skipped) == (1, 1) + + +def test_import_from_a_2x_file_into_an_account_leaves_the_file_alone(): + src = _two_x_vault() + vault.create_new_user("legacy", "pw2x") + vault.save_vault("pw2x", src, "legacy") + path = vault.get_vault_path("legacy") + digest = hashlib.sha256(open(path, "rb").read()).hexdigest() + listing = sorted(os.listdir(vault.get_data_dir())) + + new, acc = make() + data = acc.load_vault() + vault.import_entries(data, vault.read_vault_file(path, "pw2x")) + acc.save_vault(data) + + assert hashlib.sha256(open(path, "rb").read()).hexdigest() == digest + assert sorted(os.listdir(vault.get_data_dir())) == listing + again = accounts.unlock("alice", PW).load_vault() + assert sorted(a["site"] for a in again["accounts"]) == ["github", "old"] + + +def test_read_vault_file_wrong_password(): + vault.create_new_user("legacy", "pw2x") + with pytest.raises(ValueError, match="Decryption failed"): + vault.read_vault_file(vault.get_vault_path("legacy"), "nope") + + +def test_phrase_type_back_check(): + dialogs = pytest.importorskip("dialogs", reason="needs tkinter") + phrase = "abandon ability able about above absent absorb abstract absurd abuse access accident" + assert dialogs.CHECK_WORDS == (3, 7, 11) + assert dialogs.phrase_mismatches(phrase, {3: " ABLE ", 7: "absorb", 11: "access"}) == [] + assert dialogs.phrase_mismatches(phrase, {3: "able", 7: "absurd", 11: ""}) == [7, 11] diff --git a/sourceFiles/tests/test_version.py b/sourceFiles/tests/test_version.py index f1383c2..87dcb03 100644 --- a/sourceFiles/tests/test_version.py +++ b/sourceFiles/tests/test_version.py @@ -2,12 +2,33 @@ import re +import pytest + +import account_crypto import crypto_utils import version def test_version_is_semver(): - assert re.fullmatch(r"\d+\.\d+\.\d+", version.__version__) + assert re.fullmatch(r"\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?", + version.__version__) + + +@pytest.mark.parametrize("text, numbers", [ + ("0.9.0", (0, 9, 0, 0)), ("1.0.0-alpha.1", (1, 0, 0, 1)), + ("3.0.0-beta.2", (3, 0, 0, 2)), ("3.0.0", (3, 0, 0, 0))]) +def test_file_version(text, numbers): + assert version.file_version(text) == numbers + + +@pytest.mark.parametrize("bad", ["3.0", "3.0.0-alpha", "3.0.0-dev.1", "v3.0.0"]) +def test_file_version_refuses_junk(bad): + with pytest.raises(ValueError): + version.file_version(bad) + + +def test_current_version_has_a_file_version(): + assert len(version.file_version()) == 4 def test_version_string_is_prefixed(): @@ -23,4 +44,5 @@ def test_long_version_mentions_app_and_format(): def test_declared_format_matches_what_we_actually_write(): """The label must not drift from the container the code emits.""" - assert version.VAULT_FORMAT.encode() == crypto_utils.MAGIC + assert version.VAULT_FORMAT.encode() == account_crypto.VAULT_MAGIC + assert version.LEGACY_VAULT_FORMAT.encode() == crypto_utils.MAGIC diff --git a/sourceFiles/vault.py b/sourceFiles/vault.py index d5205c4..fc27ebf 100644 --- a/sourceFiles/vault.py +++ b/sourceFiles/vault.py @@ -1007,3 +1007,44 @@ def auto_purge_archive(vault: dict, now=None) -> int: if not days: return 0 return purge_archived_before(vault, purge_cutoff(days, now)) + + +# ============================== +# Moving from 2.x into a 3.0 account +# ============================== +def read_vault_file(path: str, master_password: str) -> dict: + """ + A 2.x vault file anywhere on disk (a backup, another PC's copy), read + only. ValueError("Decryption failed ...") for a wrong password. + """ + with open(path, "rb") as f: + return validate_vault(decrypt(master_password, f.read())) + + +def import_entries(target: dict, source: dict) -> tuple: + """ + Copy every entry of a 2.x vault into ``target`` whole: its id, history, + archive state and timestamps, which the CSV-style importers drop. + Returns (added, skipped). An entry is skipped when ``target`` already + has its id (imported before) or the same site, username and password. + + Only entries are taken. The 2.x vault's settings -- its sync server and + token above all -- belong to 2.x and stay behind. ``source`` is not + changed. + """ + import copy + + validate_vault(target) + have_ids = {a["id"] for a in target["accounts"]} + have = {(a["site"], a["username"], a["password"]) for a in target["accounts"]} + added = skipped = 0 + for entry in validate_vault(copy.deepcopy(source))["accounts"]: + key = (entry["site"], entry["username"], entry["password"]) + if entry["id"] in have_ids or key in have: + skipped += 1 + continue + target["accounts"].append(entry) + have_ids.add(entry["id"]) + have.add(key) + added += 1 + return added, skipped diff --git a/sourceFiles/version.py b/sourceFiles/version.py index d080dcf..30c16d9 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -2,22 +2,40 @@ Single source of truth for the application version. Read by gui.py for the labels shown in the UI. Bump this on release; keep it -in step with the vault container version in crypto_utils.py when the on-disk -format changes. +in step with the vault container versions when the on-disk format changes. + +A test version carries a pre-release tag, ``1.0.0-alpha.1``; the .deb +spells it 1.0.0~alpha.1 (deb/build_deb.deb_version) so it sorts before +1.0.0. :func:`file_version` is the same as in PassMan for Windows, where it +gives the exe four plain numbers. """ -__version__ = "0.9.0" +import re + +__version__ = "1.0.0-alpha.1" + +#: The container a 3.0 account's vaults are written in (account_crypto). +VAULT_FORMAT = "PMV3" +#: The 2.x container, which 3.0 only reads, to import it (crypto_utils). +LEGACY_VAULT_FORMAT = "PMV2" + +_VERSION = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.(\d+))?") + -#: Container version PassMan writes today (see crypto_utils.MAGIC). -VAULT_FORMAT = "PMV2" +def file_version(text: str = __version__) -> tuple: + """(1, 0, 0, 1) for '1.0.0-alpha.1', (1, 0, 0, 0) for '1.0.0'.""" + m = _VERSION.fullmatch(text) + if not m: + raise ValueError(f"Not a PassMan version: {text!r}") + return tuple(int(g or 0) for g in m.groups()) def version_string() -> str: - """e.g. 'v2.0.0' — what the UI shows.""" + """e.g. 'v1.0.0' — what the UI shows.""" return f"v{__version__}" def long_version_string() -> str: - """e.g. 'PassMan for Linux v0.1.0 · vault format PMV2' — for the + """e.g. 'PassMan for Linux v1.0.0 · vault format PMV3' — for the Settings footer.""" return f"PassMan for Linux {version_string()} · vault format {VAULT_FORMAT}" diff --git a/tools/smoke_gui.py b/tools/smoke_gui.py index 7aa2e91..1ff0240 100644 --- a/tools/smoke_gui.py +++ b/tools/smoke_gui.py @@ -1,6 +1,7 @@ """ -Start the real app on a display, unlock a throwaway vault, copy a password, -and save screenshots -- the check that the unit tests cannot make. +Start the real app on a display, unlock a throwaway PassMan 3 account, copy +a password, and save screenshots -- the check that the unit tests cannot +make. CI runs it under a virtual display: @@ -43,6 +44,7 @@ import customtkinter as ctk # noqa: E402 +import accounts # noqa: E402 import appconfig # noqa: E402 import clipboard # noqa: E402 import vault # noqa: E402 @@ -74,8 +76,8 @@ def screenshot(root, path): def main(out_dir): os.makedirs(out_dir, exist_ok=True) - vault.create_new_user(USER, PASSWORD) - data = vault.load_vault(PASSWORD, USER) + account = accounts.prepare_account(USER, PASSWORD).save() + data = account.load_vault() for site, user in (("github.com", "alice"), ("mail.example.org", "alice@example.org"), ("bank.example", "a.smith")): vault.add_account(data, site, user, "hunter2-" + site) @@ -86,14 +88,14 @@ def main(out_dir): fresh = vault.add_account(data, "recent.example", "alice", "new-secret") vault.archive_account(data, fresh["id"]) vault.set_archive_purge_days(data, 30) - vault.save_vault(PASSWORD, data, USER) + account.save_vault(data) root = ctk.CTk() app = PasswordManagerGUI(root) def step_login(): check("login screenshot", screenshot(root, os.path.join(out_dir, "login.png"))) - # The login screen has one tile per vault; pick ours, then type. + # The login screen has one tile per account; pick ours, then type. app._pick_login_user(USER) app.password_entry.insert(0, PASSWORD) app.unlock_vault() @@ -102,7 +104,9 @@ def step_login(): def step_main(): check("unlocked", app.current_user == USER) check("main screenshot", screenshot(root, os.path.join(out_dir, "main.png"))) - on_disk = {a["id"] for a in vault.load_vault(PASSWORD, USER)["accounts"]} + check("unlocked through Argon2id + the stored Secret Key", + app.account is not None and app.account.username == USER) + on_disk = {a["id"] for a in app.account.load_vault()["accounts"]} check("old archived entry purged at unlock", old["id"] not in on_disk) check("recent archived entry kept", fresh["id"] in on_disk) app.settings_dialog() @@ -131,8 +135,13 @@ def step_entry(): clipboard.paste(root) == "hunter2-github.com") app._clear_clipboard("hunter2-github.com", quiet=True) check("clipboard cleared", clipboard.paste(root) in ("", None)) - check("vault folder private", - os.stat(os.environ["PASSMAN_DATA_DIR"]).st_mode & 0o777 == 0o700) + folder = accounts.account_dir(USER) + check("account folders private", + all(os.stat(d).st_mode & 0o777 == 0o700 + for d in (accounts.accounts_dir(), folder))) + check("account files private", + all(os.stat(os.path.join(folder, n)).st_mode & 0o777 == 0o600 + for n in ("account.json", "keyset.pmk", "recovery.pmk"))) root.after(200, root.destroy) def guarded(fn):