From c91edc3ec40b3c4c093f078140b8e3d560c30aed Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Sat, 14 Nov 2026 20:45:02 +0200 Subject: [PATCH 1/3] SecretSpace 2.0.0 for Linux: nothing carries the old name any more The same change as SecretSpace for Windows 4.0.0, with the shared files byte for byte: a new file format (format 2) whose labels, markers and file names carry the project's own name, and a one-time conversion of an account made by 1.x (SecretSpace 3). The cryptography itself is unchanged. Linux's own part: - The data folder ~/.local/share/passman is renamed to ~/.local/share/secretspace at start (renamed, not copied; used where it is if it cannot be). The package upgrade test in CI now checks that. - SECRETSPACE_DATA_DIR, the smoke test's file names, the temp folders. - The package still replaces the old `passman` package: that is the clean-up of an older install, and stays. Tests: 851 pass locally on Windows (6 skipped: POSIX file modes, the deb build). The conversion is tested on accounts made by the real 3.1.0 with its server 2.0.0 (tests/fixtures/format1) - the Secret Key in them is kept under the master password, as on Linux. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyXb6JY4UPVU4waZe1w --- .gitattributes | 4 + .github/workflows/tests.yml | 8 +- README.md | 91 +- deb/build_deb.py | 2 +- sourceFiles/account_crypto.py | 296 +++-- sourceFiles/account_sync.py | 145 ++- sourceFiles/account_sync_ui.py | 40 +- sourceFiles/accounts.py | 393 +++++- sourceFiles/changelog.py | 15 +- sourceFiles/conversion_ui.py | 149 +++ sourceFiles/crypto_utils.py | 22 +- sourceFiles/dialogs.py | 21 +- sourceFiles/gui.py | 33 +- sourceFiles/legacy_names.py | 45 + sourceFiles/recovery.py | 38 +- sourceFiles/secret_store.py | 35 +- sourceFiles/sync_client.py | 4 +- sourceFiles/tests/conftest.py | 2 +- .../format1/a/accounts/alice/account.json | 48 + .../format1/a/accounts/alice/keyset.pmk | 1 + .../format1/a/accounts/alice/recovery.pmk | 1 + .../3a3880dafb35690bf7b05c8886099ce6.pmv3 | 1 + .../3a3880dafb35690bf7b05c8886099ce6.pmv3.bak | 1 + ...3a3880dafb35690bf7b05c8886099ce6.pmv3.base | 1 + .../abecdcdbbc86d06c42fd0ce0cce415b3.pmv3 | 1 + .../abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.bak | 1 + ...abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.base | 1 + .../de14496e0164d98bc0341ffb8f489d57.pmv3 | 1 + .../de14496e0164d98bc0341ffb8f489d57.pmv3.bak | 1 + ...de14496e0164d98bc0341ffb8f489d57.pmv3.base | 1 + .../format1/b/accounts/bob/account.json | 41 + .../format1/b/accounts/bob/keyset.pmk | 1 + .../format1/b/accounts/bob/recovery.pmk | 1 + .../de14496e0164d98bc0341ffb8f489d57.pmv3 | 1 + .../de14496e0164d98bc0341ffb8f489d57.pmv3.bak | 1 + ...de14496e0164d98bc0341ffb8f489d57.pmv3.base | 1 + .../f0b486df14d88813822845953c4e6b02.pmv3 | 1 + .../f0b486df14d88813822845953c4e6b02.pmv3.bak | 1 + ...f0b486df14d88813822845953c4e6b02.pmv3.base | 1 + .../format1/c/accounts/carol/account.json | 28 + .../format1/c/accounts/carol/keyset.pmk | 1 + .../format1/c/accounts/carol/recovery.pmk | 1 + .../3497bc4903532ca15aee33a5357f60a0.pmv3 | 1 + .../3497bc4903532ca15aee33a5357f60a0.pmv3.bak | 1 + .../73399c6b749a6bd0920c5a6cffbd3d01.pmv3 | 1 + .../73399c6b749a6bd0920c5a6cffbd3d01.pmv3.bak | 1 + sourceFiles/tests/fixtures/format1/facts.json | 74 ++ .../format1/store/_v2/accounts/alice.json | 34 + .../format1/store/_v2/accounts/bob.json | 33 + .../fixtures/format1/store/_v2/logins.log | 27 + .../audit.log | 1 + .../members.json | 9 + .../meta.json | 7 + .../vault.enc | 1 + .../audit.log | 2 + .../members.json | 15 + .../meta.json | 7 + .../vault.enc | 1 + .../audit.log | 4 + .../history/00000001.enc | 1 + .../members.json | 23 + .../meta.json | 7 + .../vault.enc | 1 + .../audit.log | 1 + .../members.json | 9 + .../meta.json | 7 + .../vault.enc | 1 + sourceFiles/tests/fixtures/make_format1.py | 181 +++ sourceFiles/tests/sync_server/README.md | 2 +- ...assman_server.py => secretspace_server.py} | 229 +++- sourceFiles/tests/test_account_crypto.py | 22 +- sourceFiles/tests/test_account_key_trust.py | 2 +- sourceFiles/tests/test_account_recovery.py | 2 +- sourceFiles/tests/test_account_sharing.py | 2 +- sourceFiles/tests/test_account_sync.py | 16 +- sourceFiles/tests/test_account_vaults.py | 35 +- sourceFiles/tests/test_accounts.py | 16 +- sourceFiles/tests/test_conversion.py | 1097 +++++++++++++++++ sourceFiles/tests/test_old_name.py | 149 +++ sourceFiles/tests/test_sync_server.py | 32 +- sourceFiles/tests/test_vault.py | 108 +- sourceFiles/tests/test_version.py | 3 +- sourceFiles/vault.py | 66 +- sourceFiles/version.py | 13 +- tools/smoke_gui.py | 6 +- 85 files changed, 3401 insertions(+), 330 deletions(-) create mode 100644 sourceFiles/conversion_ui.py create mode 100644 sourceFiles/legacy_names.py create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/account.json create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/keyset.pmk create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/recovery.pmk create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3 create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3.bak create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3.base create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3 create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.bak create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.base create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3 create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.bak create mode 100644 sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.base create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/account.json create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/keyset.pmk create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/recovery.pmk create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3 create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.bak create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.base create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3 create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3.bak create mode 100644 sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3.base create mode 100644 sourceFiles/tests/fixtures/format1/c/accounts/carol/account.json create mode 100644 sourceFiles/tests/fixtures/format1/c/accounts/carol/keyset.pmk create mode 100644 sourceFiles/tests/fixtures/format1/c/accounts/carol/recovery.pmk create mode 100644 sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/3497bc4903532ca15aee33a5357f60a0.pmv3 create mode 100644 sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/3497bc4903532ca15aee33a5357f60a0.pmv3.bak create mode 100644 sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/73399c6b749a6bd0920c5a6cffbd3d01.pmv3 create mode 100644 sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/73399c6b749a6bd0920c5a6cffbd3d01.pmv3.bak create mode 100644 sourceFiles/tests/fixtures/format1/facts.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/accounts/alice.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/accounts/bob.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/logins.log create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/audit.log create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/members.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/meta.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/vault.enc create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/audit.log create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/members.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/meta.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/vault.enc create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/audit.log create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/history/00000001.enc create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/members.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/meta.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/vault.enc create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/audit.log create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/members.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/meta.json create mode 100644 sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/vault.enc create mode 100644 sourceFiles/tests/fixtures/make_format1.py rename sourceFiles/tests/sync_server/{passman_server.py => secretspace_server.py} (88%) create mode 100644 sourceFiles/tests/test_conversion.py create mode 100644 sourceFiles/tests/test_old_name.py diff --git a/.gitattributes b/.gitattributes index baa06c5..68e24d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,7 @@ *.png binary *.ico binary *.webp binary + +# Accounts and a server store written by SecretSpace 3.1.0, kept byte for +# byte: the conversion is tested on exactly what that version wrote. +sourceFiles/tests/fixtures/format1/** -text diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d631eb..ff2c4c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -93,8 +93,12 @@ jobs: test ! -e /usr/lib/passman test ! -e /usr/bin/passman grep -qx kept ~/.local/share/passman/vault/alice.vault.enc - /usr/bin/python3 -I -c "import sys; sys.path.insert(0, '/usr/lib/secretspace/sourceFiles'); sys.path.insert(0, '/usr/lib/secretspace/lib'); import vault, os; h = vault.get_home_dir(); print(h); assert h == os.path.expanduser('~/.local/share/passman'), h" - rm -r ~/.local/share/passman # the steps below check a fresh install + # At its first start the app gives the data folder its current + # name: renamed, with the vault in it, and no second copy. + /usr/bin/python3 -I -c "import sys; sys.path.insert(0, '/usr/lib/secretspace/sourceFiles'); sys.path.insert(0, '/usr/lib/secretspace/lib'); import vault, os; print(vault.settle_home()); h = vault.get_home_dir(); print(h); assert h == os.path.expanduser('~/.local/share/secretspace'), h" + grep -qx kept ~/.local/share/secretspace/vault/alice.vault.enc + test ! -e ~/.local/share/passman + rm -r ~/.local/share/secretspace # the steps below check a fresh install - name: Check what was installed run: | dpkg -s secretspace diff --git a/README.md b/README.md index fa7f9a6..b2b0fd6 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,24 @@ step, or a vault shared with other people, you run the sync server yourself, on your own hardware. **Formerly PassMan.** The project was renamed to SecretSpace in November -2026, because another password manager already uses the name PassMan. -Releases up to 0.9.0 and 1.0.0-alpha.1 are the `passman` package (command -`passman`, data in `~/.local/share/passman`). Installing the `secretspace` -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. - -**This README describes SecretSpace 3** (1.0.0 and later). The 0.x line — -one vault file per user, last release +2026, because another password manager already uses the name PassMan. The +names you see changed then; SecretSpace 3 (Linux 1.x) still carried the old +name where nobody looks — in the labels its keys are made with, in the +markers at the start of its files, in the name of its data folder. **Since +2.0.0 nothing the app makes carries the old name.** That is why 2.0.0 has a +new file format and converts an account from 1.x; the cryptography itself +is unchanged. (Releases up to 0.9.0 were the `passman` package; installing +`secretspace` replaces it in one step.) + +**This README describes SecretSpace 4 for Linux** (2.0.0), the same +release as SecretSpace for Windows 4.0.0. Coming from 1.x? Read +[Upgrading from 1.x](#upgrading-from-1x) first: your account is converted +the first time you unlock it. Coming from 0.x? Read +[Upgrading from 0.x](#upgrading-from-0x). The 0.x line — one vault file per +user, last release [0.11.0](https://github.com/usermaneGermane/SecretSpace-Client-Linux/releases/tag/v0.11.0) — -has its own README on the branch +is finished; its README is on the branch [`release-0.x`](https://github.com/usermaneGermane/SecretSpace-Client-Linux/tree/release-0.x). -Coming from 0.x? Read [Upgrading from 0.x](#upgrading-from-0x) first. It installs from a `.deb` on Ubuntu 22.04 or newer, or runs from source. It has so far been tested in CI only (Ubuntu 22.04 and 24.04, on a virtual @@ -38,11 +43,11 @@ display), not yet on a real desktop. **The same as on Windows.** The account and vault files, the sync protocol and the features match SecretSpace for Windows (see the version table under *What is different from Windows*): 1.0.0 = Windows 3.0.0, 1.1.0 = -Windows 3.1.0. An account moves +Windows 3.1.0, 2.0.0 = Windows 4.0.0. An account moves between a Windows PC and a Linux PC through the server, and both sync with either server. -## What SecretSpace 3 is +## What SecretSpace is 1.0.0 is the same release as [SecretSpace for Windows 3.0.0](https://github.com/usermaneGermane/SecretSpace-Client-Windows#what-secretspace-3-is), whose README @@ -53,7 +58,7 @@ describes each part in full: from the master password and the Secret Key through Argon2id. - **Several vaults** in one account (*Vaults* above the list), each with its own key. -- **Sync** through your own SecretSpace Server **2.0.0** or newer +- **Sync** through your own SecretSpace Server **2.1.0** or newer (*Settings → Sync → Set up…*; on another device *Sign in from a server*). The account and its vaults are files on this PC, so they open with no network at all. @@ -88,9 +93,9 @@ On Linux: | 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` | one vault's entries (+ `.bak`) | +| `accounts//keyset.ssk` | the account's keys, under master password + Secret Key | +| `accounts//recovery.ssk` | the same keys, under the 12-word recovery phrase | +| `accounts//vaults/.ssv` | one vault's entries (+ `.bak`) | They sit beside the `vault` folder described under *Where your data lives*, which holds 0.x vaults and is only read, for *Import*. @@ -111,6 +116,51 @@ which holds 0.x vaults and is only read, for *Import*. 1.1.0 is the same release as SecretSpace for Windows 3.1.0. +## Upgrading from 1.x + +2.0.0 writes accounts in a new format and **converts an account from 1.x +the first time you unlock it**. Your entries, vaults, master password and +12 recovery words stay the same. What you need to know first: + +- **SecretSpace 1.x (and Windows 3.x) cannot open a converted account.** + Update SecretSpace on **every device** that uses the account before you + sync them again; a device still on the old version is refused by the + server afterwards (its message speaks of a changed master password — it + means "update me"). +- **The sync server must be 2.1.0 or newer.** Update it first; it keeps + serving older apps. Until then 2.0.0 works on your PC and says the server + is too old when you press Sync. +- **Your Emergency Kit stays valid.** A Secret Key printed by 1.x starts + with `A3-`; 2.0.0 accepts it and shows the same key as `A4-…`. + +The steps: + +1. **Back up first.** Close SecretSpace and copy `~/.local/share/secretspace` + (or `~/.local/share/passman`, if that is the one you have). +2. **Update the server** to 2.1.0, if you sync. +3. **Install 2.0.0** (below). At its first start the app renames the data + folder `~/.local/share/passman` to `~/.local/share/secretspace`, if + yours still had the old name (renamed, not copied). +4. **Unlock.** After your master password, SecretSpace says the account + will be converted. *Save a copy first…* puts a copy of the account as it + is in a folder you choose; *Convert* does it. It needs no network. +5. **Type your 12 recovery words once**, when asked. The recovery copy opens + only with those words, so only you can have it converted; the words stay + the same. You can do it later (Settings → Security → *Recovery copy*). +6. **Sync.** The server is told about the conversion, and your vaults are + stored in the new format. A vault you **share** moves to the new format + by itself once every member has updated and unlocked once; until then it + stays readable for a member on the older version. + +**Going back.** Install 1.1.0 again +(`sudo apt install --allow-downgrades ./secretspace_1.1.0_all.deb`) and put +back the copy of the account you saved in step 4, or the backup from step 1. + +The formats, and how a conversion keeps an account's keys and signatures +valid, are described in the Windows app's README under +[The account files](https://github.com/usermaneGermane/SecretSpace-Client-Windows#the-account-files); +they are the same here. + ## Upgrading from 0.x SecretSpace 3 does not open 0.x vaults directly: you make an account and @@ -128,7 +178,7 @@ deleted**, so the way back stays open. master password. Its entries arrive whole, with history and archive, in the vault that is shown. 5. **Sync.** The old sync settings are not carried over. Update your server - to **2.0.0** (it keeps serving 0.x and 2.x apps exactly as before), then + to **2.1.0** (it keeps serving 0.x and 2.x apps exactly as before), then use *Settings → Sync → Set up…*. **Going back.** Install the @@ -192,8 +242,8 @@ Run from a checkout, as above, the files are in the checkout itself, next to `sourceFiles/` — the same rule as a portable copy on Windows. The package keeps them in **`~/.local/share/secretspace`** (or `$XDG_DATA_HOME/secretspace`) instead, so that upgrading or removing it never -touches them — or in `~/.local/share/passman`, if that folder was there -first, from the `passman` package before the rename. `PASSMAN_DATA_DIR` points the app at any other vault folder. +touches them. (A folder `~/.local/share/passman` from before the rename is +renamed to that at the app's first start.) `SECRETSPACE_DATA_DIR` points the app at any other vault folder. **Lab and production.** The package is the production copy. A checkout is a handy lab copy beside it: it never touches `~/.local/share/secretspace`, and its @@ -277,6 +327,7 @@ Where that README names a Windows version, read the Linux one beside it: | — | 1.1.0-alpha.3 | Linux only: the desktop is asked for light or dark once, not every 30 ms | | 3.1.0-alpha.3 | 1.1.0-alpha.4 | icons drawn by the app, vault tiles in the Vaults window, no outlined cards | | 3.1.0 | 1.1.0 | the new look, released: everything from the 3.1 / 1.1 alpha rows | +| 4.0.0 | 2.0.0 | nothing carries the project's old name any more: a new file format, accounts from 1.x are converted (Server 2.1.0) | (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 91a45e5..5935736 100644 --- a/deb/build_deb.py +++ b/deb/build_deb.py @@ -276,7 +276,7 @@ def main(argv=None): 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: + with tempfile.TemporaryDirectory(prefix="secretspace-deb-") as tmp: wheels = fetch_wheels(os.path.join(tmp, "wheels")) tree = stage(os.path.join(tmp, "tree"), version, wheels, pins=vendored()) subprocess.run(["dpkg-deb", "--root-owner-group", "-Zxz", "--build", tree, deb], diff --git a/sourceFiles/account_crypto.py b/sourceFiles/account_crypto.py index c5fc009..af10072 100644 --- a/sourceFiles/account_crypto.py +++ b/sourceFiles/account_crypto.py @@ -1,7 +1,7 @@ """ -The cryptography of SecretSpace 3.0 accounts (design: E:\\CLAUDE\\SecretSpace\\DESIGN-3.0.md). +The cryptography of SecretSpace accounts (design: DESIGN-3.0.md, and +DESIGN-RENAME.md for the two formats). -Nothing in the 2.x app calls this yet: phase 1 builds the local account on it. Every function is pure -- bytes and dicts in, bytes and dicts out, no files -- so the format can be pinned by tests before any screen depends on it. @@ -13,7 +13,7 @@ AUK --AES-GCM--> keyset {X25519 private key, personal vault key} X25519 key --unwraps--> a shared vault's key - vault key --AES-GCM--> PMV3: the vault's entries + vault key --AES-GCM--> the vault's entries Recovery (personal accounts): a 12-word BIP39 phrase carries 128 random bits; they make a Recovery Key that seals a second copy of the keyset. @@ -22,6 +22,13 @@ old 3.4 that Ubuntu 22.04 ships); Argon2id from ``cryptography`` >= 44 where it exists (Windows), else from argon2-cffi (Ubuntu's python3-argon2). Both give the same bytes for the same inputs -- the tests hold them to it. + +Two formats. Format 2 is what this version writes. Format 1 is what +SecretSpace 3 wrote, when the project was still called PassMan and that name +was inside every label and marker: it is read here so an account can be +converted, and written only for a vault that is still shared with someone +whose app has not been updated. What differs between them is in ``Format``; +the old names themselves are in legacy_names.py. """ import base64 @@ -40,6 +47,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.hkdf import HKDF +import legacy_names from bip39_words import WORDS KEY_LEN = 32 @@ -66,6 +74,49 @@ class CryptoError(ValueError): """A sentence the user can be shown: wrong password, damaged file, typo.""" +class Format: + """ + What differs between the account formats: the word every label starts + with (a label keeps two keys made from the same secret apart), the + markers at the start of the files, and how a Secret Key is spelled. + The algorithms and the layouts are the same in both. + """ + + def __init__(self, number: int, prefix: str, keyset_magic: bytes, + keyset_version: int, vault_magic: bytes, secret_prefix: str, + wrap_version: int): + self.number = number + self.prefix = prefix + self.keyset_magic = keyset_magic + self.keyset_version = keyset_version + self.vault_magic = vault_magic + self.secret_prefix = secret_prefix + self.wrap_version = wrap_version + + def label(self, what: str) -> bytes: + return (self.prefix + " " + what).encode("ascii") + + def __repr__(self): + return f"" + + +#: What this version writes. +NOW = Format(2, "SecretSpace4", b"SSK4", 2, b"SSV4", "A4", 2) +#: What SecretSpace 3 wrote (see the module docstring). +OLD = Format(1, legacy_names.LABEL_PREFIX, legacy_names.KEYSET_MAGIC, 1, + legacy_names.VAULT_MAGIC, legacy_names.SECRET_KEY_PREFIX, 1) +FORMATS = {OLD.number: OLD, NOW.number: NOW} + + +def format_of(number) -> Format: + """The Format for a stored number; an unknown or missing one is OLD + (accounts and vaults from before formats were numbered).""" + try: + return FORMATS.get(int(number or OLD.number), OLD) + except (TypeError, ValueError): + return OLD + + def _b64(raw: bytes) -> str: return base64.b64encode(raw).decode("ascii") @@ -136,12 +187,12 @@ def _check_cost(passes, memory_kib, lanes): # =========================================================================== # Secret Key # =========================================================================== -SECRET_KEY_PREFIX = "A3" +SECRET_KEY_PREFIX = NOW.secret_prefix _B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" -def _secret_check_char(raw: bytes) -> str: - return _B32[hashlib.sha256(b"PassMan3 secret key" + raw).digest()[0] % 32] +def _secret_check_char(raw: bytes, fmt: Format = NOW) -> str: + return _B32[hashlib.sha256(fmt.label("secret key") + raw).digest()[0] % 32] def new_secret_key() -> bytes: @@ -150,7 +201,7 @@ def new_secret_key() -> bytes: def format_secret_key(raw: bytes) -> str: """ - ``A3-XXXXX-XXXXX-XXXXX-XXXXXX-XXXXXX``: 26 base32 characters for the 128 + ``A4-XXXXX-XXXXX-XXXXX-XXXXXX-XXXXXX``: 26 base32 characters for the 128 bits plus one check character, so a mistyped character is caught as a typo instead of reported as a wrong password. """ @@ -165,13 +216,19 @@ def parse_secret_key(text: str) -> bytes: """The 16 bytes of a typed Secret Key, or CryptoError saying what is wrong.""" cleaned = "".join((text or "").split()).replace("-", "").upper() if not cleaned: - raise CryptoError("Enter your Secret Key (it starts with A3-).") - if not cleaned.startswith(SECRET_KEY_PREFIX): - raise CryptoError("A Secret Key starts with A3-; check your Emergency Kit.") - body = cleaned[len(SECRET_KEY_PREFIX):] + raise CryptoError("Enter your Secret Key (it starts with A4-).") + # An Emergency Kit printed by SecretSpace 3 spells the same 128 bits + # with A3- and another check character. It stays valid. + fmt = next((f for f in (NOW, OLD) if cleaned.startswith(f.secret_prefix)), + None) + if fmt is None: + raise CryptoError("A Secret Key starts with A4- (A3- on an Emergency " + "Kit from SecretSpace 3); check your Emergency Kit.") + start = fmt.secret_prefix + "-" + body = cleaned[len(fmt.secret_prefix):] if len(body) != 27: - raise CryptoError(f"A Secret Key has 27 characters after A3-; this one " - f"has {len(body)}.") + raise CryptoError(f"A Secret Key has 27 characters after {start}; " + f"this one has {len(body)}.") bad = sorted({c for c in body if c not in _B32}) if bad: hint = " (0 and 1 are never used: did you mean O or I?)" \ @@ -186,7 +243,7 @@ def parse_secret_key(text: str) -> bytes: # The 26th character carries only 3 of the 128 bits; decoding ignores the # rest, so compare against the canonical spelling to catch a typo there. canonical = base64.b32encode(raw).decode("ascii")[:26] - if canonical != body[:26] or _secret_check_char(raw) != body[26]: + if canonical != body[:26] or _secret_check_char(raw, fmt) != body[26]: raise CryptoError("That Secret Key has a typo; check it against your " "Emergency Kit.") return raw @@ -204,7 +261,7 @@ def _normalise(password: str) -> bytes: def derive_account_keys(master_password: str, secret_key: bytes, username: str, salt: bytes, passes: int = ARGON2_PASSES, memory_kib: int = ARGON2_MEMORY_KIB, - lanes: int = ARGON2_LANES): + lanes: int = ARGON2_LANES, fmt: Format = NOW): """ (AUK, Login Key). Both need the password AND the Secret Key; the HKDF labels keep them independent, so a server that sees the Login Key learns @@ -219,16 +276,16 @@ def derive_account_keys(master_password: str, secret_key: bytes, username: str, stretched = argon2id(_normalise(master_password), salt, passes, memory_kib, lanes) who = unicodedata.normalize("NFKC", username).encode("utf-8") - auk = _hkdf(stretched, secret_key, b"PassMan3 account unlock key|" + who) - login = _hkdf(stretched, secret_key, b"PassMan3 login key|" + who) + auk = _hkdf(stretched, secret_key, fmt.label("account unlock key|") + who) + login = _hkdf(stretched, secret_key, fmt.label("login key|") + who) return auk, login # =========================================================================== # Sealed containers (keyset, recovery copy) # =========================================================================== -KEYSET_MAGIC = b"PMK3" -KEYSET_VERSION = 1 +KEYSET_MAGIC = NOW.keyset_magic +KEYSET_VERSION = NOW.keyset_version KDF_ARGON2ID = 1 # sealed under the AUK (master password + Secret Key) KDF_NONE = 0 # sealed under a full-strength key (the Recovery Key) _KEYSET_HEADER = struct.Struct(">4sBBIBB16s") # magic ver kdf mem passes lanes salt @@ -237,6 +294,8 @@ def derive_account_keys(master_password: str, secret_key: bytes, username: str, def seal_keyset(key: bytes, keyset: dict, salt: bytes, kdf: int = KDF_ARGON2ID, passes: int = ARGON2_PASSES, memory_kib: int = ARGON2_MEMORY_KIB, lanes: int = ARGON2_LANES) -> str: + # Always the current format: a keyset is only ever sealed again by the + # account's own device, which has converted it. """ The keyset as base64 text: a header (how to get the key back from the password: KDF, cost, salt) authenticated as AES-GCM associated data, so @@ -245,7 +304,8 @@ def seal_keyset(key: bytes, keyset: dict, salt: bytes, kdf: int = KDF_ARGON2ID, header = _KEYSET_HEADER.pack(KEYSET_MAGIC, KEYSET_VERSION, kdf, memory_kib, passes, lanes, salt) nonce = os.urandom(NONCE_LEN) - body = json.dumps(keyset, separators=(",", ":"), sort_keys=True).encode() + body = json.dumps(complete_keyset(keyset), separators=(",", ":"), + sort_keys=True).encode() return _b64(header + nonce + AESGCM(key).encrypt(nonce, body, header)) @@ -257,9 +317,10 @@ def keyset_header(blob: str) -> dict: raise CryptoError("The account file is too short to be SecretSpace's.") magic, version, kdf, memory_kib, passes, lanes, salt = \ _KEYSET_HEADER.unpack(raw[:_KEYSET_HEADER.size]) - if magic != KEYSET_MAGIC: - raise CryptoError("That is not a SecretSpace 3 account file.") - if version != KEYSET_VERSION: + fmt = next((f for f in (NOW, OLD) if f.keyset_magic == magic), None) + if fmt is None: + raise CryptoError("That is not a SecretSpace account file.") + if version != fmt.keyset_version: raise CryptoError("The account file was written by a newer SecretSpace; " "update this one.") if kdf not in (KDF_ARGON2ID, KDF_NONE): @@ -267,7 +328,12 @@ def keyset_header(blob: str) -> dict: if kdf == KDF_ARGON2ID: _check_cost(passes, memory_kib, lanes) return {"kdf": kdf, "memory_kib": memory_kib, "passes": passes, - "lanes": lanes, "salt": salt} + "lanes": lanes, "salt": salt, "format": fmt.number} + + +def keyset_format(blob: str) -> Format: + """Which format a sealed keyset (or recovery copy) is in.""" + return format_of(keyset_header(blob)["format"]) def open_keyset(key: bytes, blob: str) -> dict: @@ -280,16 +346,42 @@ def open_keyset(key: bytes, blob: str) -> dict: header) except Exception: raise CryptoError("Wrong master password or Secret Key.") from None - return json.loads(body) + return complete_keyset(json.loads(body)) def new_keyset() -> dict: - """A fresh account: an X25519 key pair and the personal vault's key.""" + """ + A fresh account: an X25519 key pair, the personal vault's key, the seed + of its signing key and the secret it proves a recovery with. + """ private = X25519PrivateKey.generate() return {"x25519_private": _b64(private.private_bytes( serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption())), - "personal_vault_key": _b64(os.urandom(KEY_LEN))} + "personal_vault_key": _b64(os.urandom(KEY_LEN)), + "ed25519_seed": _b64(os.urandom(KEY_LEN)), + "recovery_proof": _b64(os.urandom(KEY_LEN))} + + +def complete_keyset(keyset: dict) -> dict: + """ + A keyset with all four secrets. One from SecretSpace 3 holds two: its + signing key and its recovery proof were not stored but *derived* from + the account's private key, with labels that carried the project's old + name. Here they are derived that one last time and from then on kept in + the keyset -- so the account's signing key, and with it everything it + ever signed and everyone's record of it, stays what it was. + """ + if ("ed25519_seed" in keyset and "recovery_proof" in keyset) \ + or "x25519_private" not in keyset: + return keyset + private = _unb64(keyset["x25519_private"], "account key") + done = dict(keyset) + done.setdefault("ed25519_seed", _b64(_hkdf( + private, OLD.label("signing"), OLD.label("signing key")))) + done.setdefault("recovery_proof", _b64(_hkdf( + private, OLD.label("recovery proof"), OLD.label("recovery proof|v1")))) + return done def public_key_of(keyset: dict) -> bytes: @@ -302,43 +394,57 @@ def public_key_of(keyset: dict) -> bytes: # =========================================================================== # Wrapping a vault key to a member (sealed box) # =========================================================================== -WRAP_VERSION = 1 +WRAP_VERSION = NOW.wrap_version -def _wrap_context(vault_id: bytes, member: str) -> bytes: - return (b"PassMan3 wrap|" + vault_id + b"|" + +def _wrap_context(vault_id: bytes, member: str, fmt: Format = NOW) -> bytes: + return (fmt.label("wrap|") + vault_id + b"|" + unicodedata.normalize("NFKC", member).encode("utf-8")) def wrap_key(recipient_public: bytes, vault_key: bytes, vault_id: bytes, - member: str) -> str: + member: str, fmt: Format = NOW) -> str: """ ``vault_key`` sealed so only ``recipient_public``'s owner can open it: a one-off X25519 key, HKDF over the shared secret, AES-GCM. The vault id and member name are associated data, so a wrapped key cannot be passed off as another vault's or another person's. + + ``fmt`` is the vault's format: a member whose app is still SecretSpace 3 + can only open a key wrapped the old way. """ ephemeral = X25519PrivateKey.generate() eph_pub = ephemeral.public_key().public_bytes( serialization.Encoding.Raw, serialization.PublicFormat.Raw) shared = ephemeral.exchange(X25519PublicKey.from_public_bytes(recipient_public)) - key = _hkdf(shared, eph_pub + recipient_public, b"PassMan3 wrap key") + key = _hkdf(shared, eph_pub + recipient_public, fmt.label("wrap key")) nonce = os.urandom(NONCE_LEN) - ct = AESGCM(key).encrypt(nonce, vault_key, _wrap_context(vault_id, member)) - return _b64(bytes([WRAP_VERSION]) + eph_pub + nonce + ct) + ct = AESGCM(key).encrypt(nonce, vault_key, + _wrap_context(vault_id, member, fmt)) + return _b64(bytes([fmt.wrap_version]) + eph_pub + nonce + ct) + + +def wrap_format(wrapped: str) -> Format: + """Which format a wrapped key is in (by its first byte).""" + raw = _unb64(wrapped, "shared vault key") + return next((f for f in (NOW, OLD) if raw[:1] == bytes([f.wrap_version])), + OLD) def unwrap_key(keyset: dict, wrapped: str, vault_id: bytes, member: str) -> bytes: raw = _unb64(wrapped, "shared vault key") - if len(raw) != 1 + 32 + NONCE_LEN + KEY_LEN + 16 or raw[0] != WRAP_VERSION: + fmt = next((f for f in (NOW, OLD) if raw[:1] == bytes([f.wrap_version])), + None) + if len(raw) != 1 + 32 + NONCE_LEN + KEY_LEN + 16 or fmt is None: raise CryptoError("A shared vault key is damaged or from a newer SecretSpace.") eph_pub, nonce, ct = raw[1:33], raw[33:33 + NONCE_LEN], raw[33 + NONCE_LEN:] private = X25519PrivateKey.from_private_bytes( _unb64(keyset["x25519_private"], "account key")) shared = private.exchange(X25519PublicKey.from_public_bytes(eph_pub)) - key = _hkdf(shared, eph_pub + public_key_of(keyset), b"PassMan3 wrap key") + key = _hkdf(shared, eph_pub + public_key_of(keyset), fmt.label("wrap key")) try: - return AESGCM(key).decrypt(nonce, ct, _wrap_context(vault_id, member)) + return AESGCM(key).decrypt(nonce, ct, + _wrap_context(vault_id, member, fmt)) except Exception: raise CryptoError("This shared vault key was not made for this account " "and vault.") from None @@ -346,7 +452,7 @@ def unwrap_key(keyset: dict, wrapped: str, vault_id: bytes, member: str) -> byte def fingerprint(public_key: bytes) -> str: """Six groups of four digits, read aloud before the first share.""" - digest = hashlib.sha256(b"PassMan3 fingerprint" + public_key).digest() + digest = hashlib.sha256(NOW.label("fingerprint") + public_key).digest() digits = str(int.from_bytes(digest[:12], "big") % 10 ** 24).zfill(24) return " ".join(digits[i:i + 4] for i in range(0, 24, 4)) @@ -365,12 +471,7 @@ def account_fingerprint(public_key: bytes, signing_key: bytes) -> str: # Signing (the change log, phase 4) # =========================================================================== def _signing_private(keyset: dict) -> Ed25519PrivateKey: - # Derived from the account's private key rather than stored beside it: - # every keyset ever made already has one, the recovery copy included, - # and there is nothing new to keep in step. HKDF keeps the two keys - # independent in use. - seed = _hkdf(_unb64(keyset["x25519_private"], "account key"), - b"PassMan3 signing", b"PassMan3 signing key") + seed = _unb64(complete_keyset(keyset)["ed25519_seed"], "signing key") return Ed25519PrivateKey.from_private_bytes(seed) @@ -395,9 +496,9 @@ def verify(signing_key: bytes, message: bytes, signature: str) -> bool: # =========================================================================== -# PMV3: a vault's entries under its own key +# A vault's entries under its own key # =========================================================================== -VAULT_MAGIC = b"PMV3" +VAULT_MAGIC = NOW.vault_magic VAULT_VERSION = 1 _VAULT_HEADER = struct.Struct(">4sB16sI") # magic ver vault-id key-generation @@ -407,8 +508,8 @@ def new_vault_id() -> bytes: def encrypt_vault(vault_key: bytes, vault_id: bytes, key_generation: int, - data: dict) -> str: - header = _VAULT_HEADER.pack(VAULT_MAGIC, VAULT_VERSION, vault_id, + data: dict, fmt: Format = NOW) -> str: + header = _VAULT_HEADER.pack(fmt.vault_magic, VAULT_VERSION, vault_id, key_generation) nonce = os.urandom(NONCE_LEN) body = json.dumps(data, separators=(",", ":")).encode("utf-8") @@ -418,15 +519,17 @@ def encrypt_vault(vault_key: bytes, vault_id: bytes, key_generation: int, def vault_header(blob: str) -> dict: raw = _unb64(blob, "vault") if len(raw) < _VAULT_HEADER.size + NONCE_LEN + 16: - raise CryptoError("The vault file is too short to be a SecretSpace 3 vault.") + raise CryptoError("The vault file is too short to be a SecretSpace vault.") magic, version, vault_id, generation = _VAULT_HEADER.unpack( raw[:_VAULT_HEADER.size]) - if magic != VAULT_MAGIC: - raise CryptoError("That is not a SecretSpace 3 vault.") + fmt = next((f for f in (NOW, OLD) if f.vault_magic == magic), None) + if fmt is None: + raise CryptoError("That is not a SecretSpace vault.") if version != VAULT_VERSION: raise CryptoError("The vault was written by a newer SecretSpace; update " "this one.") - return {"vault_id": vault_id, "key_generation": generation} + return {"vault_id": vault_id, "key_generation": generation, + "format": fmt.number} def decrypt_vault(vault_key: bytes, blob: str, expect_vault_id: bytes = None) -> dict: @@ -463,12 +566,13 @@ def new_vault_key() -> bytes: return os.urandom(KEY_LEN) -def _name_key(vault_key: bytes, vault_id: bytes) -> bytes: +def _name_key(vault_key: bytes, vault_id: bytes, fmt: Format = NOW) -> bytes: # Its own key, so a name and the vault's entries never share one. - return _hkdf(vault_key, vault_id, b"PassMan3 vault name") + return _hkdf(vault_key, vault_id, fmt.label("vault name")) -def seal_vault_name(vault_key: bytes, vault_id: bytes, name: str) -> str: +def seal_vault_name(vault_key: bytes, vault_id: bytes, name: str, + fmt: Format = NOW) -> str: """ ``name`` under the vault's key, for the server: it lists an account's vaults without learning what they are called. Bound to the vault id, so @@ -478,7 +582,7 @@ def seal_vault_name(vault_key: bytes, vault_id: bytes, name: str) -> str: if not raw or len(raw) > VAULT_NAME_BYTES: raise CryptoError("A vault name must be 1 to 200 bytes long.") nonce = os.urandom(NONCE_LEN) - return _b64(nonce + AESGCM(_name_key(vault_key, vault_id)).encrypt( + return _b64(nonce + AESGCM(_name_key(vault_key, vault_id, fmt)).encrypt( nonce, raw, vault_id)) @@ -486,12 +590,16 @@ def open_vault_name(vault_key: bytes, vault_id: bytes, sealed: str) -> str: raw = _unb64(sealed, "vault name") if len(raw) < NONCE_LEN + 1 + 16: raise CryptoError("The vault's name is damaged.") - try: - return AESGCM(_name_key(vault_key, vault_id)).decrypt( - raw[:NONCE_LEN], raw[NONCE_LEN:], vault_id).decode("utf-8") - except Exception: - raise CryptoError("The vault's name could not be read: wrong key, or " - "it is damaged.") from None + # A sealed name carries no marker of its format; the vault's key opens + # it under exactly one of the two labels. + for fmt in (NOW, OLD): + try: + return AESGCM(_name_key(vault_key, vault_id, fmt)).decrypt( + raw[:NONCE_LEN], raw[NONCE_LEN:], vault_id).decode("utf-8") + except Exception: + continue + raise CryptoError("The vault's name could not be read: wrong key, or " + "it is damaged.") # =========================================================================== @@ -535,14 +643,14 @@ def phrase_to_entropy(phrase: str) -> bytes: return entropy -def recovery_key(phrase: str, username: str) -> bytes: +def recovery_key(phrase: str, username: str, fmt: Format = NOW) -> bytes: """ The key that seals the recovery copy of the keyset. No slow KDF: the phrase is 128 random bits, already beyond guessing. """ who = unicodedata.normalize("NFKC", username).encode("utf-8") - return _hkdf(phrase_to_entropy(phrase), b"PassMan3 recovery", - b"PassMan3 recovery key|" + who) + return _hkdf(phrase_to_entropy(phrase), fmt.label("recovery"), + fmt.label("recovery key|") + who) # =========================================================================== @@ -578,16 +686,56 @@ def create_account(username: str, master_password: str, def unlock_account(username: str, master_password: str, secret_key: bytes, sealed_keyset: str): - """(keyset, login key) from the master password and the Secret Key.""" + """ + (keyset, login key) from the master password and the Secret Key, in + whichever format the keyset is sealed. The login key is the one of that + format -- what a server that holds this keyset expects. + """ head = keyset_header(sealed_keyset) if head["kdf"] != KDF_ARGON2ID: raise CryptoError("That is a recovery copy, not the account file.") auk, login = derive_account_keys(master_password, secret_key, username, head["salt"], head["passes"], - head["memory_kib"], head["lanes"]) + head["memory_kib"], head["lanes"], + format_of(head["format"])) return open_keyset(auk, sealed_keyset), login +def convert_keyset(username: str, master_password: str, secret_key: bytes, + sealed_keyset: str) -> dict: + """ + A keyset from SecretSpace 3 (format 1), sealed again in the current + format. The salt and the cost are KEPT: with the same password and Secret + Key every device of the account then arrives at the same new login key + on its own, so whichever converts first can tell the server, and the + others find it already done. + + Returns {"keyset", "sealed_keyset", "login_key" (the new one), + "old_login_key" (what the server still expects until it is told)}. + """ + head = keyset_header(sealed_keyset) + if head["kdf"] != KDF_ARGON2ID: + raise CryptoError("That is a recovery copy, not the account file.") + cost = (head["passes"], head["memory_kib"], head["lanes"]) + keyset, old_login = unlock_account(username, master_password, secret_key, + sealed_keyset) + auk, login = derive_account_keys(master_password, secret_key, username, + head["salt"], *cost) + return {"keyset": keyset, + "sealed_keyset": seal_keyset(auk, keyset, head["salt"], + KDF_ARGON2ID, *cost), + "login_key": login, "old_login_key": old_login} + + +def login_key_for(username: str, master_password: str, secret_key: bytes, + sealed_keyset: str, fmt: Format) -> bytes: + """The login key this keyset's password and salt give in ``fmt``.""" + head = keyset_header(sealed_keyset) + return derive_account_keys(master_password, secret_key, username, + head["salt"], head["passes"], + head["memory_kib"], head["lanes"], fmt)[1] + + 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), @@ -599,18 +747,22 @@ 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. + about that key; the server keeps only its hash. (A random secret in the + keyset; for an account from SecretSpace 3, the value that version + derived, so the server's check still holds after a conversion.) """ - return _hkdf(_unb64(keyset["x25519_private"], "account key"), - b"PassMan3 recovery proof", b"PassMan3 recovery proof|v1") + return _unb64(complete_keyset(keyset)["recovery_proof"], "recovery proof") 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: + head = keyset_header(sealed_recovery) + if head["kdf"] != KDF_NONE: raise CryptoError("That is the account file, not its recovery copy.") try: - return open_keyset(recovery_key(phrase, username), sealed_recovery) + return open_keyset(recovery_key(phrase, username, + format_of(head["format"])), + sealed_recovery) except CryptoError as e: if "Wrong master password" in str(e): raise CryptoError("That recovery phrase is not the one for this " diff --git a/sourceFiles/account_sync.py b/sourceFiles/account_sync.py index d056a0f..dce6bdb 100644 --- a/sourceFiles/account_sync.py +++ b/sourceFiles/account_sync.py @@ -59,6 +59,10 @@ #: a key that comes with no signature is never taken (see check_key). FEATURE_SIGNED_KEYS = "signed-keys" +#: ... and of one that holds accounts and vaults in the current format +#: (SecretSpace Server 2.1.0). This version syncs with nothing older. +FEATURE_FORMAT_2 = "format-2" + class LoginFailed(SyncError): """The server did not take this username + password + Secret Key.""" @@ -103,10 +107,13 @@ def _http_error(e) -> SyncError: # -- calls ------------------------------------------------------------- def check_api(self) -> None: health = self.health() - if API_VERSION not in (health.get("api") or [1]): - raise SyncError("This server cannot hold SecretSpace 3 accounts yet: " - "it needs SecretSpace Server 2.0 or newer. (2.x " - "vaults sync with it as before.)") + self._features = list(health.get("features") or []) + if API_VERSION not in (health.get("api") or [1]) or \ + FEATURE_FORMAT_2 not in self._features: + raise SyncError("This server is too old for this SecretSpace: it " + "needs SecretSpace Server 2.1.0 or newer. Update " + "the server, then sync again. Nothing was " + "changed; your vaults are on this PC.") def has_vaults(self) -> bool: """Can this server keep more than the personal vault? Asked once @@ -264,7 +271,10 @@ def key_message(vault_hex: str, generation: int, member: str, wrapped_key: str, signer: str) -> bytes: """What the signature on a wrapped key covers: which vault, which key generation, for whom, the wrapped key itself, and who vouches.""" - return json.dumps(["PassMan3 vault key", vault_hex, int(generation), member, + # In the format the key itself is wrapped in: a member whose app is + # still SecretSpace 3 gets a key and a signature it can check. + label = account_crypto.wrap_format(wrapped_key).label("vault key") + return json.dumps([label.decode("ascii"), vault_hex, int(generation), member, hashlib.sha256(wrapped_key.encode("ascii")).hexdigest(), signer], separators=(",", ":"), ensure_ascii=True).encode("ascii") @@ -346,8 +356,9 @@ def logged_in(account: accounts.Account, server: AccountServer = None) -> Accoun server = server or server_for(account) if account.login_key is None: raise SyncError("Lock and unlock the account once, then sync.") + server.check_api() try: - server.login(account.username, account.login_key) + _login(server, account) except LoginFailed: raise LoginFailed( "The server did not accept this account's login. If the master " @@ -356,6 +367,25 @@ def logged_in(account: accounts.Account, server: AccountServer = None) -> Accoun return server +def _login(server: AccountServer, account: accounts.Account) -> None: + """ + Log in. An account converted from SecretSpace 3 has a new login key; a + server that has not heard of the conversion still expects the old one. + Then log in with that, and hand over the converted keyset and the new + login key -- from here on the server holds the account in the current + format. If another device was first, the new key simply works. + """ + try: + server.login(account.username, account.login_key) + except LoginFailed: + if account.old_login_key is None: + raise + server.login(account.username, account.old_login_key) + server.put_keyset(account.login_key, account.sealed_files()["keyset"]) + if account.server_conversion_pending: + account.server_converted() + + # --------------------------------------------------------------------------- # An account made here goes onto a server # --------------------------------------------------------------------------- @@ -373,7 +403,7 @@ def connect(account: accounts.Account, url: str, ca_file: str, server.check_api() outcome = "joined" try: - server.login(account.username, account.login_key) + _login(server, account) except LoginFailed: if not server_token: raise LoginFailed("The server has no such account yet. Enter the " @@ -430,6 +460,10 @@ def __init__(self, account: accounts.Account, server: AccountServer = None, self._fingerprint = None self._remote_log = [] self._log_in = self._log_out = False + # The server still holds this vault as SecretSpace 3 wrote it, and + # the vault itself is in the current format (the personal vault of + # a converted account): it is uploaded again as it is here. + self._reformat = False def prepare(self, local_vault: dict): self.server = logged_in(self.account, self.server) @@ -445,6 +479,8 @@ def prepare(self, local_vault: dict): remote = self.file.decrypt(blob) except (CryptoError, ValueError) as e: raise SyncError(f"The vault on the server cannot be read: {e}") from e + self._reformat = account_crypto.vault_header(blob)["format"] \ + < self.file.fmt.number # The 2.x rules for the base (sync_client.SyncSession.prepare): none # when the server is empty, and none if it was agreed elsewhere. self.base_ignored = False @@ -468,7 +504,8 @@ def prepare(self, local_vault: dict): def has_work(self) -> bool: return (self.result is not None and self.result.changed) \ or self.base_ignored or self.first_push \ - or self._log_in or (self._log_out and self.file.can_write) + or self._log_in \ + or ((self._log_out or self._reformat) and self.file.can_write) def commit(self, local_vault: dict, choices=None) -> dict: if self.result is None: @@ -486,10 +523,11 @@ def commit(self, local_vault: dict, choices=None) -> dict: self.file.save(merged) # A reader only ever takes: the server would refuse its upload, and # it has nothing of its own to give. - if (self.result.needs_push or self.first_push or self._log_out) \ - and self.file.can_write: + if (self.result.needs_push or self.first_push or self._log_out + or self._reformat) and self.file.can_write: self.revision = self.server.put_vault( - self.file.hex, self.file.encrypt(merged), self.revision) + self.file.hex, self.file.encrypt_for_server(merged), + self.revision) self.file.save_base(merged, sync_client.base_origin_for( self.server.url, self.revision)) return merged @@ -772,7 +810,7 @@ def share_vault(account: accounts.Account, server: AccountServer, vault_id: str, raise SyncError("The personal vault cannot be shared; make another " "vault for that.") wrapped = account_crypto.wrap_key(keys["public_key"], file._key, file.id, - keys["username"]) + keys["username"], file.fmt) done = server.put_member(vault_id, keys["username"], role, wrapped, sign_key(account, vault_id, file.generation, keys["username"], wrapped)) @@ -829,6 +867,11 @@ def change_key(account: accounts.Account, server: AccountServer, vault_id: str, if missing: raise SyncError("Not a member any more: " + ", ".join(missing) + ".") staying = [name for name in names if name not in remove] + # The vault moves to the current format with its new key when every + # member who stays has converted their account; otherwise it keeps the + # old one, which their apps can still read. + fmt = account_crypto.NOW if members_converted(listing, staying) \ + else account_crypto.OLD new_key = account_crypto.new_vault_key() generation = file.generation + 1 wrapped = {} @@ -845,15 +888,18 @@ def change_key(account: accounts.Account, server: AccountServer, vault_id: str, if keys["status"] == "new": trust_contact(account, keys, False) public = keys["public_key"] - wrapped[name] = account_crypto.wrap_key(public, new_key, file.id, name) + wrapped[name] = account_crypto.wrap_key(public, new_key, file.id, name, + fmt) view = server.rotate(vault_id, { "generation": generation, "remove": list(remove), "wrapped_keys": wrapped, "key_sigs": {name: sign_key(account, vault_id, generation, name, key) for name, key in wrapped.items()}, - "name": account_crypto.seal_vault_name(new_key, file.id, file.name), + "name": account_crypto.seal_vault_name(new_key, file.id, file.name, + fmt), "blob": account_crypto.encrypt_vault(new_key, file.id, generation, - vault.validate_vault(dict(merged))), + vault.validate_vault(dict(merged)), + fmt), "revision": sync.revision}) # The server has it; now this device. (If this is interrupted, the next # sync finds the new generation and takes the key from the server.) @@ -865,6 +911,50 @@ def change_key(account: accounts.Account, server: AccountServer, vault_id: str, return merged +def members_converted(listing: dict, names=None) -> bool: + """Has every member (or every one of ``names``) of a vault, as the + server lists them, an account in the current format?""" + members = [m for m in listing.get("members") or [] + if names is None or m.get("username", "") in names] + return bool(members) and all( + account_crypto.format_of(m.get("format")) is account_crypto.NOW + for m in members) + + +def convert_vaults(account: accounts.Account, server: AccountServer, + local_vaults: dict) -> dict: + """ + Move every vault this account owns from the format of SecretSpace 3 to + the current one, where that is possible now: when every member's + account is converted. It is done by changing the vault's key + (:func:`change_key` with nobody removed), which writes the key for each + member, its signature, the name and the vault itself anew in one step. + + ``local_vaults`` maps vault id to its data on this device. Returns + {vault id: its data afterwards} for the vaults that were converted. A + vault that cannot be converted yet -- a member has not updated, or it + needs a sync decision first -- is left for the next sync; nothing here + is an error the user must act on. + """ + done = {} + for vault_id in account.vault_ids(): + if vault_id == account.personal_id or vault_id not in local_vaults: + continue + record = account.vault_record(vault_id) + if account.vault_format(vault_id) is account_crypto.NOW \ + or not record.get("on_server") or record.get("gone_from_server") \ + or account.vault_role(vault_id) != accounts.ROLE_OWNER: + continue + try: + if not members_converted(server.members(vault_id)): + continue + done[vault_id] = change_key(account, server, vault_id, + local_vaults[vault_id]) + except (SyncError, CryptoError): + continue + return done + + # --------------------------------------------------------------------------- # Signing in from a server # --------------------------------------------------------------------------- @@ -895,24 +985,41 @@ def sign_in(url: str, ca_file: str, username: str, password: str, kdf = pre["kdf"] try: salt = base64.b64decode(kdf["salt"]) + cost = (int(kdf["passes"]), int(kdf["memory_kib"]), int(kdf["lanes"])) auk, login = account_crypto.derive_account_keys( - password, secret, username, salt, int(kdf["passes"]), - int(kdf["memory_kib"]), int(kdf["lanes"])) + password, secret, username, salt, *cost) except (KeyError, TypeError, ValueError) as e: if isinstance(e, CryptoError): raise raise SyncError("The server sent sign-in details this version cannot " "use.") from e + old_auk = None try: server.login(username, login) except LoginFailed: - raise LoginFailed("Wrong username, master password or Secret Key.") from None + # The same password and Secret Key give another login key in the + # format of SecretSpace 3: an account no device has converted yet. + old_auk, old_login = account_crypto.derive_account_keys( + password, secret, username, salt, *cost, account_crypto.OLD) + try: + server.login(username, old_login) + except LoginFailed: + raise LoginFailed("Wrong username, master password or Secret " + "Key.") from None me = server.me() try: - keyset = account_crypto.open_keyset(auk, me["keyset"]) + keyset = account_crypto.open_keyset(old_auk or auk, me["keyset"]) except CryptoError: raise SyncError("The server's copy of the account does not open with " "these details. Nothing was changed.") from None + if old_auk is not None: + # Converted here and now: the same keys, sealed in the current + # format with the same salt, and the server told at once. + sealed = account_crypto.seal_keyset(auk, keyset, salt, + account_crypto.KDF_ARGON2ID, *cost) + server.put_keyset(login, sealed) + me = dict(me) + me["keyset"] = sealed had_it = accounts.account_exists(username) account = accounts.adopt_from_server( diff --git a/sourceFiles/account_sync_ui.py b/sourceFiles/account_sync_ui.py index f1a2966..b2ff627 100644 --- a/sourceFiles/account_sync_ui.py +++ b/sourceFiles/account_sync_ui.py @@ -12,6 +12,7 @@ import customtkinter as ctk +import account_crypto import account_sync import accounts import sync_client @@ -67,10 +68,40 @@ def done(payload): def _next_vault(app, run, attempt): if not run["queue"]: - return _all_done(app, run) + return _convert_step(app, run, attempt) _sync_step(app, run, attempt, run["queue"].pop(0), retry_allowed=True) +def _convert_step(app, run, attempt): + """ + After every vault is in step: the vaults this account owns that are + still in the format of SecretSpace 3 move to the current one, where + every member has converted (account_sync.convert_vaults). For an account + that has none left this asks the server nothing. + """ + account = run["account"] + waiting = [v for v in account.vault_ids() + if v != account.personal_id and v in app.vaults + and account.vault_format(v) is not account_crypto.NOW + and account.vault_role(v) == accounts.ROLE_OWNER] + if not waiting: + return _all_done(app, run) + local = {v: app.vaults[v] for v in waiting} + + def done(converted): + if not _still_open(app, attempt): + return _logout_in_background(run["server"]) + for vault_id, data in converted.items(): + app.set_vault_data(vault_id, data, saved=True) + run["converted"] = [app.vault_name(v) for v in converted] + _all_done(app, run) + + _in_background(app, lambda: account_sync.convert_vaults( + account, run["server"], local), done, + on_error=lambda e: _all_done(app, run) + if _still_open(app, attempt) else _logout_in_background(run["server"])) + + def _sync_step(app, run, attempt, vault_id, retry_allowed): def failed(error): if not _still_open(app, attempt): @@ -129,7 +160,8 @@ def _all_done(app, run): _logout_in_background(run["server"]) app.refresh_account_list() changes, updated = run["changes"], run["updated"] - if not run["many"] and not changes: + converted = run.get("converted") or [] + if not run["many"] and not changes and not converted: # An account with only its personal vault: what phase 2 said. return _finish(app, run["summary"] or "Already up to date") parts = [] @@ -144,6 +176,8 @@ def _all_done(app, run): parts.append(f"“{old}” is now “{new}”") if changes.rekeyed: parts.append("new key for: " + ", ".join(changes.rekeyed)) + if converted: + parts.append("moved to the new format: " + ", ".join(converted)) for name, role in changes.roles: parts.append(f"you are now {role} of “{name}”") kind = "info" @@ -276,7 +310,7 @@ def sign_in_dialog(app): user_entry = field(wrap, "Username") pw_entry = field(wrap, "Master password", show="•") key_entry = field(wrap, "Secret Key (from the Emergency Kit)", - placeholder="A3-XXXXXX-XXXXX-…") + placeholder="A4-XXXXX-XXXXX-…") note(wrap, "Already have this account on this PC? Leave the Secret Key " "empty; this PC keeps it.", shell.text_width, size=11, pady=(8, 0)) status = ctk.CTkLabel(wrap, text="", font=F(12), text_color=DANGER, diff --git a/sourceFiles/accounts.py b/sourceFiles/accounts.py index 5c7fd73..5fd2365 100644 --- a/sourceFiles/accounts.py +++ b/sourceFiles/accounts.py @@ -1,19 +1,24 @@ """ -SecretSpace 3.0 accounts on this device. Since phase 2 an account may also -live on a sync server (account_sync.py); this module stays files only. +SecretSpace accounts on this device. An account may also live on a sync +server (account_sync.py); this module stays files only. 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 a vault (+ .bak, the previous save, + .base, + account.json nothing secret: format, mode, public key, the + personal vault's id, the Secret Key as this + device keeps it (secret_store), fingerprints seen + keyset.ssk the keyset, under master password + Secret Key + recovery.ssk the same keyset, under the 12-word phrase + vaults/.ssv a vault (+ .bak, the previous save, + .base, the state last agreed with the server): the - personal one, and since phase 3 any further - vaults the account has (account.json "vaults") + personal one, and any further vaults the account + has (account.json "vaults") + +That is format 2. An account made by SecretSpace 3 is format 1: the same +folder with files named after the project's old name and sealed with its +old labels (legacy_names). It is opened once more the old way and written +again in the current format -- ``convert`` -- and never used unconverted. ```` is the folder that holds the 2.x ``vault`` folder (beside the app for a lab copy, %APPDATA%\\SecretSpace when installed). 2.x vault files are only @@ -27,17 +32,19 @@ import shutil import account_crypto +import legacy_names 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" +KEYSET_FILE = "keyset.ssk" +RECOVERY_FILE = "recovery.ssk" VAULTS_DIR = "vaults" -VAULT_SUFFIX = ".pmv3" -ACCOUNT_FORMAT = 1 +VAULT_SUFFIX = ".ssv" +ACCOUNT_FORMAT = account_crypto.NOW.number +OLD_ACCOUNT_FORMAT = account_crypto.OLD.number MODE_PERSONAL = "personal" #: What the personal vault is called wherever vaults are listed. It has no @@ -49,6 +56,14 @@ ROLES = (ROLE_OWNER, ROLE_EDITOR, ROLE_READER) +class NeedsConversion(Exception): + """ + The account was made by SecretSpace 3 and is still in its format. The + password (and Secret Key) given were right; ask the user, then call + :func:`convert`. Nothing has been changed. + """ + + class SecretKeyNeeded(Exception): """ This device does not hold the account's Secret Key -- a copied folder on @@ -61,7 +76,7 @@ class SecretKeyNeeded(Exception): # Where things are # --------------------------------------------------------------------------- def accounts_dir() -> str: - """Beside the 2.x vault folder, so PASSMAN_DATA_DIR moves both.""" + """Beside the 2.x vault folder, so SECRETSPACE_DATA_DIR moves both.""" return os.path.join(os.path.dirname(os.path.abspath(vault.get_data_dir())), ACCOUNTS_DIR_NAME) @@ -105,7 +120,8 @@ def _read_text(path: str) -> str: return f.read().strip() -def _read_info(username: str) -> dict: +def _read_any_info(username: str) -> dict: + """account.json in either format (see :func:`account_format`).""" try: with open(_path(username, ACCOUNT_FILE), "r", encoding="utf-8") as f: info = json.load(f) @@ -114,12 +130,26 @@ def _read_info(username: str) -> dict: "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 " - "SecretSpace version.") + if not isinstance(info, dict) or \ + info.get("format") not in (ACCOUNT_FORMAT, OLD_ACCOUNT_FORMAT): + raise ValueError(f"The account “{username}” was made by a newer " + "SecretSpace; update this one.") + return info + + +def _read_info(username: str) -> dict: + """account.json of an account in the current format.""" + info = _read_any_info(username) + if info["format"] != ACCOUNT_FORMAT: + raise NeedsConversion(username) return info +def account_format(username: str) -> int: + """2, or 1 for an account from SecretSpace 3 that is not converted yet.""" + return _read_any_info(username)["format"] + + def _write_info(username: str, info: dict) -> None: _write_text(_path(username, ACCOUNT_FILE), json.dumps(info, indent=2, sort_keys=True)) @@ -150,7 +180,12 @@ class VaultFile: """ def __init__(self, username: str, vault_id: bytes, key: bytes, name: str, - personal: bool, generation: int = 0, role: str = ROLE_OWNER): + personal: bool, generation: int = 0, role: str = ROLE_OWNER, + fmt: account_crypto.Format = account_crypto.NOW): + # The format the vault has on the server and for its other members + # (see Account.vault_format). The file on this device is always in + # the current one. + self.fmt = fmt self.id = vault_id self.hex = vault_id.hex() self.name = name @@ -178,12 +213,18 @@ def can_write(self) -> bool: return self.role != ROLE_READER def encrypt(self, data: dict) -> str: - """``data`` as a PMV3 blob under this vault's key.""" + """``data`` under this vault's key, as this device stores it.""" return account_crypto.encrypt_vault(self._key, self.id, self.generation, vault.validate_vault(data)) + def encrypt_for_server(self, data: dict) -> str: + """``data`` under this vault's key, as the server and the other + members' apps expect it: in the vault's own format.""" + return account_crypto.encrypt_vault(self._key, self.id, self.generation, + vault.validate_vault(data), self.fmt) + def decrypt(self, blob: str) -> dict: - """A PMV3 blob of this vault (it must name this vault).""" + """A blob of this vault, in either format (it must name this vault).""" return vault.validate_vault(account_crypto.decrypt_vault( self._key, blob, self.id)) @@ -225,7 +266,8 @@ def save_base(self, data: dict, origin: dict) -> None: def sealed_name(self) -> str: """The name as the server keeps it (it cannot read it).""" - return account_crypto.seal_vault_name(self._key, self.id, self.name) + return account_crypto.seal_vault_name(self._key, self.id, self.name, + self.fmt) def open_name(self, sealed: str) -> str: return account_crypto.open_vault_name(self._key, self.id, sealed) @@ -245,7 +287,7 @@ class Account: """An unlocked account: its keys in memory, its files on disk.""" def __init__(self, username: str, info: dict, keyset: dict, secret: bytes, - login_key: bytes = None): + login_key: bytes = None, old_login_key: bytes = None): self.username = username self.info = info self.keyset = keyset @@ -254,6 +296,9 @@ def __init__(self, username: str, info: dict, keyset: dict, secret: bytes, # unlock from the password and the Secret Key, so it is kept only in # memory, like the keys it travels with. self.login_key = login_key + # Only while a converted account's server has not been told yet + # (``server_conversion_pending``): what that server still expects. + self.old_login_key = old_login_key self.vault_id = bytes.fromhex(info["personal_vault"]) self._vault_key = account_crypto._unb64(keyset["personal_vault_key"], "vault key") @@ -319,6 +364,46 @@ def secret_key_text(self) -> str: """For the Emergency Kit again (Settings).""" return account_crypto.format_secret_key(self._secret) + # -- after a conversion from SecretSpace 3 ------------------------------------- + @property + def server_conversion_pending(self) -> bool: + """The account was converted on this device and its server may still + hold the old keyset and login key.""" + return bool((self.info.get("convert") or {}).get("server")) + + def server_converted(self) -> None: + """The server holds the converted account (told by this device, or + found so because another device was first).""" + if "convert" in self.info: + info = dict(self.info) + del info["convert"] + _write_info(self.username, info) + self.info = info + self.old_login_key = None + + @property + def recovery_converted(self) -> bool: + """Is the recovery copy in the current format? (False until the 12 + words have been typed once after a conversion.)""" + try: + sealed = _read_text(_path(self.username, RECOVERY_FILE)) + return account_crypto.keyset_format(sealed) is account_crypto.NOW + except (CryptoError, OSError): + return True # nothing here to convert + + def convert_recovery(self, phrase: str) -> str: + """ + The recovery copy sealed again in the current format, under the SAME + 12 words -- which only the user has, so this cannot be part of + :func:`convert`. Returns the new copy (for the server). CryptoError + when the words are not this account's. + """ + sealed = _read_text(_path(self.username, RECOVERY_FILE)) + account_crypto.recover_account(self.username, phrase, sealed) + fresh = account_crypto.seal_recovery(phrase, self.username, self.keyset) + self.set_recovery(fresh) + return fresh + # -- its vaults ----------------------------------------------------------- @property def personal_id(self) -> str: @@ -351,6 +436,23 @@ def vault_role(self, vault_id: str) -> str: def can_write(self, vault_id: str) -> bool: return self.vault_role(vault_id) != ROLE_READER + def vault_format(self, vault_id: str = None) -> account_crypto.Format: + """ + The format a vault has for the server and its other members. The + personal vault is the account's own, so the current one. A further + vault has the format its key is wrapped in: a vault that came from + SecretSpace 3 keeps the old one -- so a member who has not updated + can still open it -- until an owner changes its key, which happens + by itself once every member has converted (account_sync). + """ + if vault_id is None or vault_id == self.personal_id: + return account_crypto.NOW + wrapped = self._extra().get(vault_id, {}).get("wrapped_key") + try: + return account_crypto.wrap_format(wrapped) + except CryptoError: + return account_crypto.NOW + def is_shared(self, vault_id: str) -> bool: """Is anyone else in this vault (as far as the last sync knew)?""" record = self._extra().get(vault_id, {}) @@ -374,7 +476,7 @@ def vault_file(self, vault_id: str = None) -> VaultFile: return VaultFile(self.username, bytes.fromhex(vault_id), key, record.get("name", ""), False, int(record.get("key_generation", 0) or 0), - self.vault_role(vault_id)) + self.vault_role(vault_id), self.vault_format(vault_id)) def _name_taken(self, name: str, except_id: str = None) -> bool: wanted = name.lower() @@ -591,7 +693,7 @@ def check_password(self, password: str) -> bool: def change_password(self, new_password: str, before_write=None) -> 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 + touched. The old keyset file is replaced, not kept: a .bak of it would still open with the old password. ``before_write(sealed_keyset, login_key)`` runs before anything here @@ -711,8 +813,10 @@ def unlock(username: str, password: str, secret_key_text: str = None) -> Account 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)) + info = _read_any_info(username) + old = info["format"] == OLD_ACCOUNT_FORMAT + sealed = _read_text(_path(username, legacy_names.KEYSET_FILE if old + else KEYSET_FILE)) typed = None if secret_key_text: typed = account_crypto.parse_secret_key(secret_key_text) @@ -725,11 +829,204 @@ def unlock(username: str, password: str, secret_key_text: str = None) -> Account raise SecretKeyNeeded(username) from None keyset, login = account_crypto.unlock_account(username, password, secret, sealed) + if old: + # Right password, right Secret Key -- and an account this version + # does not use as it is. Nothing is written until the user agrees. + raise NeedsConversion(username) + _remove_old_files(username) 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, login) + old_login = None + if (info.get("convert") or {}).get("server"): + old_login = account_crypto.login_key_for(username, password, secret, + sealed, account_crypto.OLD) + return Account(username, info, keyset, secret, login, old_login) + + +# --------------------------------------------------------------------------- +# An account from SecretSpace 3 +# --------------------------------------------------------------------------- +def _old_vault_path(username: str, vault_id: str) -> str: + return os.path.join(account_dir(username), VAULTS_DIR, + vault_id + legacy_names.VAULT_SUFFIX) + + +def _new_vault_path(username: str, vault_id: str) -> str: + return os.path.join(account_dir(username), VAULTS_DIR, + vault_id + VAULT_SUFFIX) + + +def _old_files(username: str) -> list: + """Every file of the old format in an account's folder.""" + folder = account_dir(username) + found = [os.path.join(folder, name) for name in + (legacy_names.KEYSET_FILE, legacy_names.RECOVERY_FILE)] + try: + names = os.listdir(os.path.join(folder, VAULTS_DIR)) + except OSError: + names = [] + found += [os.path.join(folder, VAULTS_DIR, name) for name in names + if name.endswith((legacy_names.VAULT_SUFFIX, + legacy_names.VAULT_SUFFIX + vault.BACKUP_SUFFIX, + legacy_names.VAULT_SUFFIX + vault.BASE_SUFFIX))] + return [path for path in found if os.path.isfile(path)] + + +def _remove_old_files(username: str) -> None: + """What a conversion that was interrupted after its last write left.""" + for path in _old_files(username): + try: + os.remove(path) + except OSError: + pass + + +def copy_account_folder(username: str, destination: str) -> str: + """ + A copy of the account's folder as it is now, in ``destination`` -- what + "Save a copy first" does before a conversion. The user chose to make it + and where, so it is theirs to keep or delete. Returns the copy's path. + """ + target = os.path.join(destination, f"{username} (before conversion)") + if os.path.exists(target): + raise FileExistsError(f"There is already a folder {target}.") + shutil.copytree(account_dir(username), target) + return target + + +def convert(username: str, password: str, secret_key_text: str = None) -> Account: + """ + Write an account from SecretSpace 3 again in the current format, and + open it. Everything is first opened the old way with the password and + the Secret Key; nothing of the old account is touched until the new + files are complete: + + 1. the keyset, sealed again (same salt, so every device of the account + arrives at the same new login key); + 2. the Secret Key, stored for this device the current way; + 3. every vault file and its sync base, encrypted again; + 4. the key of every vault that is on no server, wrapped again; + 5. ``account.json`` with ``"format": 2`` -- from here on the account is + converted; then the old files are deleted. + + Stopped before 5, the old account is whole and this simply runs again. + Stopped after it, the next unlock removes what is left of the old files. + + The recovery copy is kept as it is: it opens only with the 12 words + (see Account.convert_recovery). A server is told at the next sync + (account_sync). + """ + info = _read_any_info(username) + if info["format"] != OLD_ACCOUNT_FORMAT: + raise ValueError(f"The account “{username}” is already converted.") + folder = account_dir(username) + if secret_key_text: + secret = account_crypto.parse_secret_key(secret_key_text) + else: + try: + secret = secret_store.unprotect(info.get("secret_key"), username, + password) + except secret_store.Unavailable: + raise SecretKeyNeeded(username) from None + made = account_crypto.convert_keyset( + username, password, secret, + _read_text(os.path.join(folder, legacy_names.KEYSET_FILE))) + account = _rewrite_old_account(username, info, made["keyset"], secret, + password, made["sealed_keyset"]) + account.login_key = made["login_key"] + if info.get("server"): + account.old_login_key = made["old_login_key"] + return account + + +def _rewrite_old_account(username: str, info: dict, keyset: dict, secret: bytes, + password: str, sealed_keyset: str, + sealed_recovery: str = None) -> Account: + """ + Steps 2 to 5 of :func:`convert`, for an old account whose keyset is + already open: the conversion itself, and also a recovery or a sign-in + from a server on a device that still holds the account the old way + (they bring ``sealed_keyset`` -- and a new ``sealed_recovery`` -- with + them). The account comes back without login keys; the caller has them. + """ + folder = account_dir(username) + public = account_crypto.public_key_of(keyset) + + # Every vault, opened with its key as the old account holds it. + extra = dict(info.get("vaults") or {}) + keys = {info["personal_vault"]: account_crypto._unb64( + keyset["personal_vault_key"], "vault key")} + for vault_id, record in extra.items(): + keys[vault_id] = account_crypto.unwrap_key( + keyset, record["wrapped_key"], bytes.fromhex(vault_id), username) + + written = [] + try: + for vault_id, key in keys.items(): + raw_id = bytes.fromhex(vault_id) + on_server = bool(extra.get(vault_id, {}).get("on_server")) or ( + vault_id == info["personal_vault"] and bool(info.get("server"))) + for suffix in ("", vault.BASE_SUFFIX): + source = _old_vault_path(username, vault_id) + suffix + if not os.path.isfile(source): + continue + try: + blob = _read_text(source) + data = account_crypto.decrypt_vault(key, blob, raw_id) + except (CryptoError, OSError, ValueError): + # A sync base is only an aid to merging, and a vault the + # server has is fetched again; anything else must not be + # lost by converting. + if suffix or on_server: + continue + raise CryptoError( + "A vault of this account could not be read, so " + "nothing was converted. Open the account with " + "SecretSpace 3 and check its vaults.") from None + generation = account_crypto.vault_header(blob)["key_generation"] + target = _new_vault_path(username, vault_id) + suffix + _write_text(target, account_crypto.encrypt_vault( + key, raw_id, generation, data)) + written.append(target) + + fresh = dict(info) + fresh["format"] = ACCOUNT_FORMAT + fresh["converted_at"] = vault.utc_now() + fresh["secret_key"] = secret_store.protect(secret, username, password) + vaults = {} + for vault_id, record in extra.items(): + record = dict(record) + if not record.get("on_server"): + # No server and nobody else holds this key the old way. + record["wrapped_key"] = account_crypto.wrap_key( + public, keys[vault_id], bytes.fromhex(vault_id), username) + vaults[vault_id] = record + if extra: + fresh["vaults"] = vaults + if info.get("server"): + fresh["convert"] = {"server": True} + + recovery = os.path.join(folder, legacy_names.RECOVERY_FILE) + if sealed_recovery is not None or os.path.isfile(recovery): + target = os.path.join(folder, RECOVERY_FILE) + _write_text(target, sealed_recovery if sealed_recovery is not None + else _read_text(recovery)) + written.append(target) + target = os.path.join(folder, KEYSET_FILE) + _write_text(target, sealed_keyset) + written.append(target) + _write_info(username, fresh) # from here on it is converted + except BaseException: + for path in written: + try: + os.remove(path) + except OSError: + pass + raise + _remove_old_files(username) + return Account(username, fresh, keyset, secret) # --------------------------------------------------------------------------- @@ -741,7 +1038,7 @@ def stored_secret(username: str, password: str) -> bytes: also when it is sealed under a password that is no longer the account's (changed on another device), since then only the Emergency Kit helps. """ - info = _read_info(username) + info = _read_any_info(username) try: return secret_store.unprotect(info.get("secret_key"), username, password) except (secret_store.Unavailable, CryptoError): @@ -760,15 +1057,25 @@ def adopt_from_server(username: str, me: dict, keyset: dict, secret: bytes, them); only its keys and the Secret Key's protection are replaced. One with the same name but another personal vault is someone else's, and is left alone. + + The server holds the account in the current format by now (account_sync + converts it during the sign-in if no device had). A copy of it on this + PC that is still as SecretSpace 3 left it is converted here. """ vault_id = me["personal_vault"] folder = account_dir(username) if account_exists(username): - info = _read_info(username) + info = _read_any_info(username) if info.get("personal_vault") != vault_id: raise FileExistsError( f"This PC already has a different account called " f"\u201c{username}\u201d. Delete or rename it first.") + if info["format"] == OLD_ACCOUNT_FORMAT: + # This PC still holds it as SecretSpace 3 left it: its vault + # files are converted with the keys that just came. + _rewrite_old_account(username, info, keyset, secret, password, + me["keyset"], me["recovery"]) + info = _read_info(username) info = dict(info) elif os.path.exists(folder): raise FileExistsError( @@ -783,6 +1090,7 @@ def adopt_from_server(username: str, me: dict, keyset: dict, secret: bytes, "vaults": {}} info.update({"public_key": me["public_key"], "server": server, "secret_key": secret_store.protect(secret, username, password)}) + info.pop("convert", None) # the server is in step: nothing to tell _write_text(os.path.join(folder, KEYSET_FILE), me["keyset"]) _write_text(os.path.join(folder, RECOVERY_FILE), me["recovery"]) _write_info(username, info) # last: it makes it an account @@ -790,14 +1098,22 @@ def adopt_from_server(username: str, me: dict, keyset: dict, secret: bytes, def replace_keys(username: str, sealed_keyset: str, sealed_recovery: str, - secret: bytes, password: str) -> None: + secret: bytes, password: str, keyset: dict = None) -> 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. + keys that open them have not changed. (An account still as SecretSpace 3 + left it is converted on the way; that needs its open ``keyset``.) """ - info = dict(_read_info(username)) + info = _read_any_info(username) + if info["format"] == OLD_ACCOUNT_FORMAT: + if keyset is None: + raise NeedsConversion(username) + _rewrite_old_account(username, info, keyset, secret, password, + sealed_keyset, sealed_recovery) + return + info = dict(info) 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) @@ -808,12 +1124,19 @@ 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))) + read_recovery_copy(username)) return True except (CryptoError, OSError): return False +def read_recovery_copy(username: str) -> str: + """The recovery copy on this device, whichever format the account is in.""" + name = legacy_names.RECOVERY_FILE \ + if account_format(username) == OLD_ACCOUNT_FORMAT else RECOVERY_FILE + return _read_text(_path(username, name)) + + def delete_account(username: str) -> None: """Remove the account folder. The caller has checked the password.""" folder = account_dir(username) diff --git a/sourceFiles/changelog.py b/sourceFiles/changelog.py index 4a50e00..04f98ba 100644 --- a/sourceFiles/changelog.py +++ b/sourceFiles/changelog.py @@ -19,6 +19,11 @@ write a line under someone else's name; with it, a line whose signature does not match its author's key is shown as such. +A line says which format it was signed in (``v``; none means 1, from +SecretSpace 3). Lines are never signed again -- only their author could -- +so an old line keeps being checked the way it was signed, and a vault that +a member with an older app still opens gets its new lines the old way too. + Pure: dicts in, dicts out. Nothing here touches a file, a server or a widget. """ @@ -77,6 +82,8 @@ def _clean(line): "count": count, "sig": str(line.get("sig", "") or "")[:200], } + if account_crypto.format_of(line.get("v")) is account_crypto.NOW: + clean["v"] = account_crypto.NOW.number if not clean["id"] or not clean["at"] or not clean["by"]: return None return clean @@ -169,7 +176,8 @@ def message(vault_hex: str, line: dict) -> bytes: in -- so a line cannot be copied into another vault, or its author, time or entry changed, without the signature failing. """ - return json.dumps(["PassMan3 change", vault_hex, line["id"], line["at"], + label = account_crypto.format_of(line.get("v")).label("change") + return json.dumps([label.decode("ascii"), vault_hex, line["id"], line["at"], line["by"], line["action"], line["entry"], line["title"], list(line["fields"]), int(line["count"])], separators=(",", ":"), ensure_ascii=True).encode("ascii") @@ -191,12 +199,13 @@ def check(line: dict, vault_hex: str, signing_key) -> str: def record(data: dict, before: dict, username: str, keyset: dict, - vault_hex: str) -> int: + vault_hex: str, fmt=account_crypto.NOW) -> int: """ Note in ``data`` what ``username`` changed since ``before`` (the vault as it was last saved): signed lines in the log, ``updated_by`` on each entry they touched, and ``by`` on each earlier version that became history. Returns how many lines were added. ``data`` is changed in place. + ``fmt`` is the vault's format (see the module docstring). """ found = diff(before, data) if not found: @@ -207,6 +216,8 @@ def record(data: dict, before: dict, username: str, keyset: dict, line = dict(item) line.update({"id": os.urandom(8).hex(), "at": vault.history_now(), "by": username, "sig": ""}) + if fmt is account_crypto.NOW: + line["v"] = fmt.number line["sig"] = account_crypto.sign(keyset, message(vault_hex, line)) lines.append(line) entry = new.get(item["entry"]) diff --git a/sourceFiles/conversion_ui.py b/sourceFiles/conversion_ui.py new file mode 100644 index 0000000..87bc658 --- /dev/null +++ b/sourceFiles/conversion_ui.py @@ -0,0 +1,149 @@ +""" +The windows of a conversion from SecretSpace 3. + +An account made by SecretSpace 3 is written again in the current format the +first time it is unlocked here (accounts.convert). Two questions go with it: + +* before -- "this account will be converted", with the chance to save a + copy first, because SecretSpace 3 cannot open it afterwards; +* after -- the 12 recovery words, once, to convert the recovery copy too: + that copy opens only with the words, which the app does not have. + +And one notice, at start: what happened to the data folder of the old name +(vault.settle_home). +""" + +import customtkinter as ctk +from tkinter import filedialog + +import accounts +import recovery +import vault +from sync_ui import _in_background +from theme import F, MUTED, GREEN +from widgets import dialog_shell, error_label, field_label, note + + +def ask_conversion(app, username) -> bool: + """ + Tell the user their account is about to be converted and let them save + a copy first. True to go ahead, False to stay locked (nothing changed). + """ + top = app._modal("Convert this account", 520, 420) + shell = dialog_shell( + top, f"“{username}” will be converted", + "This account was made with SecretSpace 3. This version keeps " + "accounts in a new format, and converts yours now. Your entries, " + "vaults, master password and recovery phrase stay the same.") + wrap = shell.body + note(wrap, "Afterwards SecretSpace 3.0 and 3.1 can no longer open this " + "account. If you use it on other devices, update SecretSpace there " + "too before you sync them again.", shell.text_width, pady=(0, 10)) + note(wrap, "To be able to go back, save a copy of the account as it is " + "now. It is a folder of encrypted files; keep it or delete it as " + "you like.", shell.text_width, size=11) + saved = ctk.CTkLabel(wrap, text="", font=F(11), text_color=GREEN, + wraplength=shell.text_width, justify="left") + saved.pack(anchor="w", pady=(8, 0)) + err = error_label(wrap, shell.text_width) + out = {"go": False} + + def save_copy(): + folder = filedialog.askdirectory( + parent=top, title="Where to save a copy of the account") + if not folder: + return + try: + made = accounts.copy_account_folder(username, folder) + except Exception as e: # noqa: BLE001 - shown + saved.configure(text="") + err.configure(text=f"The copy was not saved: {e}") + return + err.configure(text="") + saved.configure(text=f"Copy saved in {made}") + + def convert(): + out["go"] = True + top.destroy() + + app._ghost_button(shell.buttons, "Cancel", top.destroy, + width=100).pack(side="left") + app._accent_button(shell.buttons, "Convert", convert, + width=120).pack(side="right") + app._ghost_button(shell.buttons, "Save a copy first…", save_copy, + width=170).pack(side="right", padx=(0, 8)) + top.bind("", lambda e: top.destroy()) + app.root.wait_window(top) + return out["go"] + + +def offer_recovery_conversion(app, asked=False): + """ + Ask once for the 12 recovery words, to convert the recovery copy of an + account that came from SecretSpace 3. Nothing happens for an account + whose copy is already in the current format. ``asked``: the user chose + this in Settings (so no "Later" framing is needed). + """ + account = app.account + if account is None or account.recovery_converted: + if asked: + app.toast("The recovery copy is already in the new format") + return + import recovery_ui # late: it imports dialogs' users + top = app._modal("Recovery copy", 500, 470) + shell = dialog_shell( + top, "Convert the recovery copy too", + "Your account is converted. One thing is left: the copy that your " + "12 recovery words open. Only the words can open it, so type them " + "once and it is converted as well — under the same words, no new " + "paper.") + wrap = shell.body + field_label(wrap, "Recovery phrase (12 words, in order)") + words = recovery_ui._words_box(wrap, shell.text_width) + note(wrap, "You can do this later in Settings → Security. Until then " + "the words keep working for a recovery exactly as before.", + shell.text_width, size=11, pady=(8, 0)) + err = error_label(wrap, shell.text_width) + busy = {"on": False} + session = getattr(app, "_session", 0) + + def done(_): + top.destroy() + if getattr(app, "_session", 0) == session: + app.toast("Recovery copy converted — the same 12 words open it") + + def failed(error): + busy["on"] = False + if top.winfo_exists(): + err.configure(text=str(error)) + + def go(): + if busy["on"]: + return + phrase = " ".join(words.get("1.0", "end").split()) + busy["on"] = True + err.configure(text="") + _in_background(app, lambda: recovery.convert_copy(account, phrase), + done, on_error=failed) + + app._ghost_button(shell.buttons, "Later", top.destroy, + width=100).pack(side="left") + app._accent_button(shell.buttons, "Convert", go, + width=120).pack(side="right") + top.bind("", lambda e: top.destroy()) + top.after(200, words.focus) + + +def home_notice(app, outcome) -> None: + """Say what vault.settle_home did with the data folder of the old name.""" + status, old, new = outcome + if status == vault.HOME_MOVED: + app.toast(f"Your data folder has its new name: {new}") + elif status == vault.HOME_BUSY: + app.toast(f"The data folder {old} could not be renamed (something " + "has it open). It is used where it is; SecretSpace tries " + "again at its next start.", "warn") + elif status == vault.HOME_BOTH: + app.toast(f"There are two data folders. SecretSpace uses {new}; " + f"{old} was left alone — look at it, and delete it if " + "nothing in it is needed.", "warn") diff --git a/sourceFiles/crypto_utils.py b/sourceFiles/crypto_utils.py index 626dde8..b477b07 100644 --- a/sourceFiles/crypto_utils.py +++ b/sourceFiles/crypto_utils.py @@ -1,9 +1,15 @@ """ -Vault encryption for SecretSpace. +The vault file of SecretSpace 2.x: one file per user, under the master +password alone. SecretSpace 3 and later keep vaults in accounts +(account_crypto); this container is read to import a 2.x vault, and written +for the encrypted backup that 2.x can restore. -Container format (all vaults written by this version):: +Container format:: - b"PMV2" || kdf_id(1) || iterations(4, big endian) || salt(16) || nonce(12) || ciphertext + marker(4) || kdf_id(1) || iterations(4, big endian) || salt(16) || nonce(12) || ciphertext + +The marker is from when the project was called PassMan (legacy_names); the +2.x line is finished, so the format keeps it. the whole thing base64-encoded. The 9-byte header is passed to AES-GCM as additional authenticated data, so the KDF parameters are covered by the @@ -23,11 +29,13 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers.aead import AESGCM +import legacy_names + # ============================== # Container constants # ============================== -MAGIC = b"PMV2" +MAGIC = legacy_names.VAULT_2X_MAGIC KDF_PBKDF2_SHA256 = 1 SALT_LEN = 16 @@ -39,7 +47,7 @@ #: every vault's header, so old vaults keep opening with their own count. DEFAULT_ITERATIONS = 600_000 -#: What headerless (pre-PMV2) vaults were written with. +#: What headerless vaults (older still) were written with. LEGACY_ITERATIONS = 200_000 #: Refuse absurd iteration counts from a hostile or corrupt file, which would @@ -68,7 +76,7 @@ def derive_key(master_password: str, salt: bytes, # ============================== def encrypt(master_password: str, data: dict, iterations: int = DEFAULT_ITERATIONS) -> bytes: - """Encrypt a dictionary into a base64 PMV2 blob.""" + """Encrypt a dictionary into a base64 2.x vault blob.""" salt = os.urandom(SALT_LEN) nonce = os.urandom(NONCE_LEN) header = MAGIC + bytes([KDF_PBKDF2_SHA256]) + iterations.to_bytes(4, "big") @@ -85,7 +93,7 @@ def encrypt(master_password: str, data: dict, # Decryption # ============================== def _decrypt_legacy(master_password: str, raw: bytes) -> dict: - """Read a pre-PMV2 vault: salt(16) || nonce(12) || ct, 200k iters, no AAD.""" + """Read a headerless vault: salt(16) || nonce(12) || ct, 200k iters, no AAD.""" salt = raw[:SALT_LEN] nonce = raw[SALT_LEN:SALT_LEN + NONCE_LEN] ciphertext = raw[SALT_LEN + NONCE_LEN:] diff --git a/sourceFiles/dialogs.py b/sourceFiles/dialogs.py index 5a88b37..0984b04 100644 --- a/sourceFiles/dialogs.py +++ b/sourceFiles/dialogs.py @@ -282,8 +282,9 @@ def ask_secret_key(app, username): 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-…") + "Emergency Kit and starts with A4- (A3- on a kit " + "from SecretSpace 3; that one works too).") + box = app._field(shell.body, "Secret Key", placeholder="A4-XXXXX-XXXXX-…") box.configure(font=ctk.CTkFont(family=MONO, size=14)) err = error_label(shell.body, shell.text_width) out = {"text": None} @@ -321,7 +322,7 @@ def go(action): top.destroy() action() - two = section(wrap, "PassMan 2.x vaults on this PC", first=True) + two = section(wrap, "2.x vaults on this PC", first=True) names = vault.list_users() if not names: setting_row(two, "None found", @@ -354,7 +355,7 @@ def go(action): def _import_2x_file(app): path = filedialog.askopenfilename( - parent=app.root, title="Import a PassMan 2.x vault", + parent=app.root, title="Import a 2.x vault", filetypes=[("SecretSpace vault", "*.enc *.vault *.bak"), ("All files", "*.*")]) if path: @@ -725,6 +726,18 @@ def new_phrase(): "working.", hint), "New phrase…", new_phrase, width=150).pack() + if app.account is not None and not app.account.recovery_converted: + def convert_copy(): + import conversion_ui # late: it imports this module's users + top.destroy() + conversion_ui.offer_recovery_conversion(app, asked=True) + + app._ghost_button( + setting_row(sec, "Recovery copy", + "Still in the format of SecretSpace 3. Type your 12 " + "words once to convert it; they stay the same.", hint), + "Convert…", convert_copy, width=150).pack() + def label_for(m): return "Never" if m == 0 else f"{m} minute{'s' if m != 1 else ''}" diff --git a/sourceFiles/gui.py b/sourceFiles/gui.py index c532b77..b344ace 100644 --- a/sourceFiles/gui.py +++ b/sourceFiles/gui.py @@ -23,12 +23,14 @@ import appconfig import clipboard import changelog +import conversion_ui import dialogs import history_ui from icons import icon import lockout import sync_client import sync_ui +import vault import account_sync_ui import vaults_ui import recovery_ui @@ -592,7 +594,7 @@ def _note_changes(self, file, data): try: before = file.load() if file.has_file else {"accounts": []} changelog.record(data, before, self.account.username, - self.account.keyset, file.hex) + self.account.keyset, file.hex, file.fmt) except Exception: pass @@ -964,9 +966,20 @@ def unlock_vault(self, secret_key_text=None): # two together meant a failure inside show_main_window() was caught by # the handler below, which then wrote to a login widget that # show_main_window()'s _clear() had already destroyed. + converted = False try: - account = accounts.unlock(username, master_password, - secret_key_text) + try: + account = accounts.unlock(username, master_password, + secret_key_text) + except accounts.NeedsConversion: + # An account from SecretSpace 3: the password was right. It + # is converted to the current format, if the user agrees -- + # this version does not use it as it is. + if not conversion_ui.ask_conversion(self, username): + return + account = accounts.convert(username, master_password, + secret_key_text) + converted = True loaded = account.load_vault() except accounts.SecretKeyNeeded: # A new PC, or another Windows user: the Secret Key is asked for @@ -1001,7 +1014,12 @@ def unlock_vault(self, secret_key_text=None): lockout.reset(username) self.open_account(account, loaded, master_password) - if secret_key_text: + if converted: + self.toast("Account converted to the new format") + # The recovery copy opens only with the 12 words: ask for them + # once, now that the account is open. + self.root.after(700, lambda: conversion_ui.offer_recovery_conversion(self)) + elif secret_key_text: self.toast("Secret Key saved on this PC — it won't be asked " "for again here") @@ -1129,7 +1147,7 @@ def show_main_window(self): gear.pack(side="bottom", fill="x", pady=(0, 10)) imp = self._nav_button(side, "Import", self.import_dialog, "import") imp.pack(side="bottom", fill="x") - Tooltip(imp, "Import a PassMan 2.x vault, or a CSV or JSON file") + Tooltip(imp, "Import a 2.x vault, or a CSV or JSON file") gen = self._nav_button(side, "Password generator", self.generator_dialog, "key") gen.pack(side="bottom", fill="x") @@ -1801,7 +1819,12 @@ def _on_close(self): if __name__ == "__main__": + # First of all, before anything is read: an installed copy's data folder + # from before the rename gets its current name. + home = vault.settle_home() apply_appearance(appconfig.appearance()) root = ctk.CTk() app = PasswordManagerGUI(root) + if home[0]: + root.after(900, lambda: conversion_ui.home_notice(app, home)) root.mainloop() diff --git a/sourceFiles/legacy_names.py b/sourceFiles/legacy_names.py new file mode 100644 index 0000000..2637dba --- /dev/null +++ b/sourceFiles/legacy_names.py @@ -0,0 +1,45 @@ +""" +The names from before the rename. + +SecretSpace was called PassMan until November 2026, and SecretSpace 3 still +carried that name where nobody saw it: in the labels its keys are made +with, in the markers at the start of its files, in the name of its data +folder. SecretSpace 4 uses its own name everywhere. + +This module is the one place the old names live. They are here to *read* +what an older version wrote, so that it can be converted, or -- for another +member's signature, which only its author could make again -- still be +checked. Nothing new is ever made with them, with one exception that the +format itself requires: a vault shared with someone whose app is still +SecretSpace 3 stays in the old format, so that they can keep opening it, +until every member has updated (see ``FORMAT`` in account_crypto). + +Strings only, and no imports: anything may import this. +""" + +#: What every label of a format-1 key or signature starts with. +LABEL_PREFIX = "PassMan3" + +#: The first bytes of a format-1 keyset / recovery copy and vault, and of +#: the 2.x vault file that the importer reads. +KEYSET_MAGIC = b"PMK3" +VAULT_MAGIC = b"PMV3" +VAULT_2X_MAGIC = b"PMV2" + +#: A Secret Key printed by SecretSpace 3 starts with this. +SECRET_KEY_PREFIX = "A3" + +#: How a format-1 account's files are called inside its folder. +KEYSET_FILE = "keyset.pmk" +RECOVERY_FILE = "recovery.pmk" +VAULT_SUFFIX = ".pmv3" + +#: What bound the stored Secret Key to its account on this device. +DEVICE_CONTEXT = b"PassMan 3 Secret Key\x00" + +#: The data folder: in %APPDATA% on Windows, in ~/.local/share on Linux. +DATA_FOLDER_WINDOWS = "PassMan" +DATA_FOLDER_LINUX = "passman" + +#: The variable that pointed tests and tools at another data folder. +DATA_DIR_VARIABLE = "PASSMAN_DATA_DIR" diff --git a/sourceFiles/recovery.py b/sourceFiles/recovery.py index ae63bbc..c72db5f 100644 --- a/sourceFiles/recovery.py +++ b/sourceFiles/recovery.py @@ -2,13 +2,13 @@ Recovering an account with its 12-word phrase (phase 5), and making a new phrase. -The phrase opens a second copy of the account's keyset (recovery.pmk on the +The phrase opens a second copy of the account's keyset (recovery.ssk on the device, "recovery" on the server). With the keyset open, nothing is lost: the same keys are sealed under a new master password, and a new phrase replaces the old one -- which opens nothing from then on. Where the copy comes from: -- this PC has the account: its own recovery.pmk; +- this PC has the account: its own recovery.ssk; - it does not (a new or reinstalled PC): the account's server hands it out (GET /v2/recovery/; it opens only with the phrase). @@ -102,7 +102,8 @@ def save(self): loaded = data if data is not None else account.load_vault() return account, loaded accounts.replace_keys(self.username, self.sealed_keyset, - self.sealed_recovery, self._secret, self.password) + self.sealed_recovery, self._secret, self.password, + self.opened.keyset) account = accounts.unlock(self.username, self.password) return account, account.load_vault() @@ -123,9 +124,8 @@ def open_with_phrase(username: str, phrase: str, url: str = "", raise ValueError("Enter the account name.") account_crypto.phrase_to_entropy(phrase) # typos first, offline if accounts.account_exists(username): - info = accounts._read_info(username) - sealed = accounts._read_text(accounts._path(username, - accounts.RECOVERY_FILE)) + info = accounts._read_any_info(username) + sealed = accounts.read_recovery_copy(username) server = info.get("server") server = server if isinstance(server, dict) and server.get("url") else None local = True @@ -173,7 +173,7 @@ def prepare(opened: Opened, new_password: str) -> Pending: # DPAPI (Windows) reads it without the master password; the # password scheme (Linux) cannot, as that password is forgotten. secret = secret_store.unprotect( - accounts._read_info(opened.username).get("secret_key"), + accounts._read_any_info(opened.username).get("secret_key"), opened.username, "") except (secret_store.Unavailable, CryptoError, ValueError, OSError): secret = None @@ -191,6 +191,30 @@ def prepare(opened: Opened, new_password: str) -> Pending: opened.keyset)) +# --------------------------------------------------------------------------- +# The recovery copy of an account converted from SecretSpace 3 +# --------------------------------------------------------------------------- +def convert_copy(account: accounts.Account, phrase: str) -> None: + """ + Seal the recovery copy again in the current format, under the SAME 12 + words. A conversion cannot do this by itself: the copy opens only with + the words. As with a new phrase, the server (if any) gets it first, so + this PC and the server never hold different copies. CryptoError when the + words are mistyped or not this account's. + """ + account_crypto.recover_account( + account.username, phrase, accounts.read_recovery_copy(account.username)) + sealed = account_crypto.seal_recovery(phrase, account.username, + account.keyset) + if account.server: + remote = account_sync.logged_in(account) + try: + remote.put_recovery(sealed) + finally: + remote.logout() + account.set_recovery(sealed) + + # --------------------------------------------------------------------------- # A new phrase, for an open account (Settings) # --------------------------------------------------------------------------- diff --git a/sourceFiles/secret_store.py b/sourceFiles/secret_store.py index b26a892..d94dee2 100644 --- a/sourceFiles/secret_store.py +++ b/sourceFiles/secret_store.py @@ -18,7 +18,10 @@ 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. +cleanly) on the other. ``v`` says how the record is bound to its account: +2 is the current way; a record without it was written by SecretSpace 3, +under the project's old name (legacy_names), and is read so the account can +be converted -- ``protect`` only ever writes the current one. """ import os @@ -28,10 +31,14 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM import account_crypto +import legacy_names from account_crypto import CryptoError, _b64, _unb64 SCHEME_DPAPI = "dpapi" SCHEME_PASSWORD = "password" +RECORD_VERSION = 2 +#: What Windows shows as the description of the protected data. +DPAPI_DESCRIPTION = "SecretSpace Secret Key" class Unavailable(Exception): @@ -42,10 +49,20 @@ def default_scheme() -> str: return SCHEME_DPAPI if sys.platform == "win32" else SCHEME_PASSWORD -def _context(username: str) -> bytes: +def _context(username: str, version: int = RECORD_VERSION) -> 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") + start = b"SecretSpace 4 Secret Key\x00" if version >= RECORD_VERSION \ + else legacy_names.DEVICE_CONTEXT + return start + username.encode("utf-8") + + +def record_version(record) -> int: + """How a stored record is bound to its account (1: by SecretSpace 3).""" + try: + return int(record.get("v", 1)) + except (AttributeError, TypeError, ValueError): + return 1 # --------------------------------------------------------------------------- @@ -71,7 +88,7 @@ def blob(raw): out = Blob() ui_forbidden = 0x1 if protect: - ok = crypt32.CryptProtectData(ctypes.byref(data_in), "PassMan Secret Key", + ok = crypt32.CryptProtectData(ctypes.byref(data_in), DPAPI_DESCRIPTION, ctypes.byref(extra), None, None, ui_forbidden, ctypes.byref(out)) else: @@ -103,7 +120,7 @@ def protect(secret: bytes, username: str, password: str, """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, + return {"scheme": SCHEME_DPAPI, "v": RECORD_VERSION, "blob": _b64(_dpapi(secret, _context(username), True))} if scheme == SCHEME_PASSWORD: salt = os.urandom(account_crypto.SALT_LEN) @@ -111,7 +128,8 @@ def protect(secret: bytes, username: str, password: str, 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), + return {"scheme": SCHEME_PASSWORD, "v": RECORD_VERSION, + "salt": _b64(salt), "nonce": _b64(nonce), "passes": cost[0], "memory_kib": cost[1], "lanes": cost[2], "sealed": _b64(AESGCM(key).encrypt(nonce, secret, @@ -129,6 +147,7 @@ def unprotect(record, username: str, password: str) -> bytes: if not isinstance(record, dict): raise Unavailable("This PC does not hold the Secret Key yet.") scheme = record.get("scheme") + context = _context(username, record_version(record)) if scheme == SCHEME_DPAPI: if sys.platform != "win32": raise Unavailable("The Secret Key was stored by Windows.") @@ -136,7 +155,7 @@ def unprotect(record, username: str, password: str) -> bytes: 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) + raw = _dpapi(blob, context, False) elif scheme == SCHEME_PASSWORD: try: salt = _unb64(record["salt"], "Secret Key") @@ -148,7 +167,7 @@ def unprotect(record, username: str, password: str) -> bytes: 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)) + raw = AESGCM(key).decrypt(nonce, sealed, context) except Exception: raise CryptoError("Wrong master password or Secret Key.") from None else: diff --git a/sourceFiles/sync_client.py b/sourceFiles/sync_client.py index 9f6cb80..0fb5109 100644 --- a/sourceFiles/sync_client.py +++ b/sourceFiles/sync_client.py @@ -292,9 +292,9 @@ def push(self, username: str, blob: bytes, revision: int, revisions -- used after a password change.""" headers = {"If-Match": str(revision)} if key_generation is not None: - headers["X-PassMan-Key-Generation"] = str(int(key_generation)) + headers["X-SecretSpace-Key-Generation"] = str(int(key_generation)) if drop_history: - headers["X-PassMan-Drop-History"] = "1" + headers["X-SecretSpace-Drop-History"] = "1" return self._request( "PUT", f"/vault/{username}", body={"blob": blob.decode("ascii")}, headers=headers) diff --git a/sourceFiles/tests/conftest.py b/sourceFiles/tests/conftest.py index 6a4ab1b..ab72cc0 100644 --- a/sourceFiles/tests/conftest.py +++ b/sourceFiles/tests/conftest.py @@ -9,5 +9,5 @@ @pytest.fixture(autouse=True) def data_dir(tmp_path, monkeypatch): """Point every test at a throwaway vault directory.""" - monkeypatch.setenv("PASSMAN_DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("SECRETSPACE_DATA_DIR", str(tmp_path / "data")) return tmp_path / "data" diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/account.json b/sourceFiles/tests/fixtures/format1/a/accounts/alice/account.json new file mode 100644 index 0000000..11522fa --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/account.json @@ -0,0 +1,48 @@ +{ + "created_at": "2026-11-14T09:12:32Z", + "fingerprints_seen": { + "bob": { + "public_key": "34mwEYgX6nDhPrIoehLAfcIUFW/ftm6Kj6Mrr/Wl9Qw=", + "seen_at": "2026-11-14T09:12:37Z", + "signing_key": "hDwTgizvA+jMrf0V9V4T114Q45EpYY0LD2THCVGhLn4=", + "verified": true + } + }, + "format": 1, + "mode": "personal", + "personal_vault": "3a3880dafb35690bf7b05c8886099ce6", + "public_key": "mRrtkVLrJhdUXVBg468CekE84qKOwIUkhwCUlo2H+F0=", + "secret_key": { + "lanes": 1, + "memory_kib": 64, + "nonce": "urfb8A6tLQ+MVuB0", + "passes": 1, + "salt": "nT6YAcQjc99aGuWwltd9zw==", + "scheme": "password", + "sealed": "2q2GJ9+HgXNZFFnNKwNnfvaGwbUb7vYD3npiKWCEg54=" + }, + "server": { + "ca_file": "", + "url": "http://127.0.0.1:46349" + }, + "username": "alice", + "vaults": { + "abecdcdbbc86d06c42fd0ce0cce415b3": { + "created_at": "2026-11-14T09:12:33Z", + "gone_from_server": false, + "key_signer": "alice", + "name": "Solo", + "on_server": true, + "wrapped_key": "Ad14VpbAMj1fQsM8NfBbnX2+mbaf0swxufFkTV28E2Is1L8mq3gbaH9lg3taO2t56qvZWDMcf68fb8HE6Ne91utsZ7zBjL+v09QFviAN2MdHlMG699pS7uVVQ6Wc" + }, + "de14496e0164d98bc0341ffb8f489d57": { + "created_at": "2026-11-14T09:12:33Z", + "gone_from_server": false, + "key_signer": "alice", + "members": 2, + "name": "Team", + "on_server": true, + "wrapped_key": "AZXgJ1629y7+sSNRDFFrKSwzAan0Ix+A6rFclxRalehbQjs4EsoKEmy5xj8AWFzmqvpqetUt5vC+2+V5sgUO65v8DUKNkxJifS19Gbf1O5mcWl6fUnEAupW1WcbB" + } + } +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/keyset.pmk b/sourceFiles/tests/fixtures/format1/a/accounts/alice/keyset.pmk new file mode 100644 index 0000000..b46368a --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/keyset.pmk @@ -0,0 +1 @@ +UE1LMwEBAAAAQAEB+A1kNAgaJvGtBj0LY6XcDqONi0MuX41s9o4B37bihECYAjmdjvKZYZdrzHF1+NAVc0PuL2Yk3VAklo75ovyjm+KK0CoY8GfSmdFw3dWNUJrqo91skNtrzWsl1dr1hdw0e0dkG8aqYps2l7K3aqPZ68ItNWVJY0SMcx0UDhdOOfQxNUvaRfkwacQWoJWmjXQfZ61QJJi5k+dyelmonkJhB/SHZzqGTc07akNAolCoJlJU \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/recovery.pmk b/sourceFiles/tests/fixtures/format1/a/accounts/alice/recovery.pmk new file mode 100644 index 0000000..e067693 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/recovery.pmk @@ -0,0 +1 @@ +UE1LMwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJ1zSBkRSsrOtXyVpXysMPU72gFvS1VZIpKm12Xko0GBhT2wHcbhouURSUCcVdM84XYmFbIMxOARX81mahlKTbPbw87RhHHU+eSMNO74lKZTdmAn/jwGVoNHP6mWGb3Vc2eNBJUCTIn/3uZEtzlqPx4F/ozZwi/OSzjOb4Mnq+MRL9yipoKeMx3TixWpNbURvyNcblVLHMSlMLHAKY5s2gVF \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3 b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3 new file mode 100644 index 0000000..ee15835 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3 @@ -0,0 +1 @@ +UE1WMwE6OIDa+zVpC/ewXIiGCZzmAAAAAIzIXIYaGa4Xdfx3ejVP7KB3H5W8hJ7dQd4HICoXKBapRuqxhFcEzWsmT/0AXWN0dmc6O1HCg1IzVd+R7KIXQzHJMiPEvMBMjkuJmhDw8gaHFhV+zVaviYw0/bEa0e7FKScdmxuFdwCEQtHCnZ3CUwB6grtIKJTqfUa42itjb6JsAzBum7rT/UCQ1aO9xPy+dcFgazg+PksZZN5Nj2ihmEqzxn1ewtzQyookn4eIsalAClIy5ssD6m7Qz0ChwTqgngqmkssCnDwhEsXkGmBcKxO+MfBldCxom0Eu6vw0dxl41VN9vqYF5DtnSmWdEjtaS30y1LnXpu1VohGn2jzJdDCJSpRPWXx4OZ8oivI759pzmyem7PULkJuZ9bXH95SXmMQ8B36cvjSxwoaOh+Yym4m/Sp5AL9HD7raH3eAt3LLpsS8B8NVW0bTIrJU0xVEmCcZNH5ctzqTQcjLUhsEUWiw0xMlswytPF+VcslNEhucaxyexhPDOZdk6WG+UPiJQh2aOLQ9MvLN0ynhHxnLfs8tx/9HkDr/JInFQOzzGC5YOlRdtDRufJJ52L/oUuMd2O1peHiejjEZVvAQBOkuFbKjlVZ70tvpifuf4zUYGm0NSDB4io8jjvH8ZqSf3IHJgLo+IWyVuPjVxoNVxi8s/CcyhQcX0OAKjOahYhhJsypkEeCLIZJZHdg2RPWXVAH+O5hp9CmEXm4zH4qxJHHHTMysPTRnGT1rF5QMypUriVAYmJOEt4Kwuq2jI/H7AfJSBsDO0DXjECPuNnRgde/pYUt+sPHr3NaY0sZeAjLEnI2rktcAskylLTcsFrNkgk1jm4xN75rTmfXOnK85hraMP3ceU3Pd/8O7xRvfY/YYhXEm/hv3xw18VL1ilxptkXm6fNat4brGnOJR5LyilEHrQ1HAh/iaEUFGf8/RZ97YQ3lBjQkNvHC0V6fdSsur5xFB66HMj6K6TWz07XPYNYvIAVeb7kxD434WqUNO/RAenQdGLCfOvK3G6g9xuOgXGaSmhwPBWqebv4lqAAnHvX2H0VdWiLDANmj0yg7+Usjll/aItvYAZOOtFgspGUSLkuFez222x0H/IFDpfs/yii9HfHiUiIvDk7rdvYIosFxfzvG2MlY7sau4lrQ+M96Ookt9YndFPJ9wVmKzpS0KZxbmZzfJ4t1vu1AVsvt+RMGcXNfaGrfKxpDfw \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3.bak b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3.bak new file mode 100644 index 0000000..11daa07 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3.bak @@ -0,0 +1 @@ +UE1WMwE6OIDa+zVpC/ewXIiGCZzmAAAAAHRzBLC9nCCoEplOpCgN1LkwI/ShAIefV5fd64zqEespNeLs2PlTQ+JWb2kNKCkqgW3pP1xQ3S587/tlVa4q8udb6VnEzKl6nBP6k26sg/yCdpfUkPa9aUOOhqDyarLP8VSHV6vO6sGpYOHlO9cEwUATKsXoaiYdyhTAp8MDa6dJH00MldiqW8qpZT2HSToAWawrNJwkvxz81wGnfZS3e3prLKjLDRCJsSu1RH2Bu1rgqGs3DselkjL5A98YGyqQ2ylThE+IEp5zy7G/nfAit5Q2nj1jv13l9/4+LulvkvTxwqZe+QHljJ9sZtTNkNeig6NCA3B3W3wFpDj13nXhu/+Ju8lxBuouKdN4Gt4sETEAqPdwU1J6EqKsoRVGgwTge2Zl7+vLTAtE+CRE1AIYJ0PU5seEaceAzfzBFSvonGnBTPnuum5u15HEZLpjtVexXni9N5PC73bz6rHX5yce23p1Y9+mlcrGz+wCQKVLq1MLMnwJWHw026wWYwYpjkYDu9/dzyP7P5N272YhvwfKEP7MfzCU5KVSDGEQQdz7A0Pu8RgYhszDl+gwll2mFQIu3KLH5LOh+Zg+UQbPiuDE8yqms8ci6fic5xpOkiaLvuxlSbbRf8demeztS1aSPJ+Wk5mmx6A7vV9FiFcAXjxdV42CjGHdPtFHpuNtKcrCtLRjEqcV+4JzRlnTSVsX5R/CKHTq4YD6XglxmRKFuf6myN/hHUmPesG9mO1H5Ab+wOYN4r1xvU/vx+lJb22Lrv6JsKvkb9s4tkhCx8a/CJookvy7QN2iomdTWCqQhbwKH4aPK8zCvRyLxYBIo38ZmdMxiSWc4AUDAck0XxnxrhGX/KHtK0oUuJ1ScpPkOVUNfLPDPw2szOU3GbHBW4Ivx2u5PmtN+Rp5PIxvmNK0X25aWHqBvkscmH106sqaoiPaW2QTC/GUPQvNHQnRuj/W7ytkDmbBxWwS2WpYfewQY3np3clomebNnz2mYFlxR+/0a/zfQ1DHUuxHyaE/gQXstz0H1jinPQ55P8m8JUME2YoOXDGxcKxOBB8EBY5mNDfBVAHKOMKyaUhN71zMFfZCDugpCRm4FWGlu5jFHYHNSyHFYWdqcxf+oMjuTW1vO2ilrtwPfXK44HYNO1d0d1CFBy+ItK0PftysRWwpYmKdoSuiaW9d19TzqfztB6Urgj9MeWtAu4sseIgs \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3.base b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3.base new file mode 100644 index 0000000..0938488 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/3a3880dafb35690bf7b05c8886099ce6.pmv3.base @@ -0,0 +1 @@ +UE1WMwE6OIDa+zVpC/ewXIiGCZzmAAAAAGbAnEUblYr1A++x51ToGPk+5DROUoLrsvT5xtcyjDpECfRMOqjxoJMSPe2OmsXCa5mqEddvC71eWPN36C7MH3J9v/cYkNGrYlFvYZ7gweQF4iG+8ijdgLEDjxZbZNUs/wb1DIqX/ieIKn9HJ/q/bLpFrc06I7gKWdQ+FH/FCmmVlKy74GsRdQZEryW/USXOhzsFYdDIrYoINnFKiQfK0rgLZxjcF6Qd4G1HPYlUKgnbD3WX3cs/kG8NG0zoI8Gyn9zoAxl+TMKxSDNfSIpZie/2L9xf5XVcHpCur+jUVWbeuENpDyCqsxCbiVQFqEsYzVKGFd4IybYxC9LLqm62iXciLws8qCNqQEsY3W8IdJxhwE2gqfmtbTs2QxDYd5OWcXcSufXfUhy0/2qnQhUB4L7XavyZh/RtJAl1tk3LCyh5vJdMk3WQG3hz8QWqdK3ZYpYi0pN4GNlGX5L+g/Pyc6eWrNrf5RJ42w1kLcLEVgI6QZDENs/YUnxSlA9mSs27In01vuRrRd2kFU4qnNEH5cDG0xrjy3Y9hwPAeWGDqXxfQ8U4DivBETaGaArSrKqo2eHPNDV6xjfaFAPb2CKdI4fNVgccbjmJSeS1xsXDzwCuidotWLx7uDXrVO9V1r4V59My2tRuGmwBQA1jZhGAU0An+tPTiEtZomCcPeWzu4juNZy2xKHZX/2M0V8GZfN34tF6B0ZoEzsi75Hg2v7kO7E8Y5MH1ampNUzGOc719Wk9Cpamo6wzO0NrDchTadZEaglRxRPeoGQTX5wgGWJLtZB9mZ+fTRx5CmEv4ikqKIPnW2VuuZnXJM1fZal2JypFOEOc1sNHhdp8hlT495mXOM0alsfs2gk2g5SHJo2xVhVb8O3QZ9ZCD+0BknsKr2+9x32zah7vW7Pte7BPGu1V73cqaMnHQ+l3wQRW+p0SAQhhntrycG6ZHyKoCK2xFbs2TYNqN4ba9saEXHTMjiw1HJU2duOZYbWP7GXhoiewcd79uaAfPsjxgbcxyGyGPXRX21fRQ2t2gOcDTe2WLvoIAIuVNwN1RmaH+FNJvo10vnea+yp9l9ClZe0kJsljNsGL5KgB9fnZMI1GFdzCnHXccoidppqUVFKHlHkf/OVl/lmLN7ypjtkBQXpNrvIrMVgxzyEGfTaYC2cbDzc2CplCJjpLknbz05sgkB41p14Endv5FyXW+iKdN+gjLIAxCsWN+WGzwVxPRJ29t55VxMAPywNHCtlvW0s0vd350I1ZfaDSaKnoHtzGdhAtSAJ/t2uLwlcP \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3 b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3 new file mode 100644 index 0000000..a1cc985 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3 @@ -0,0 +1 @@ +UE1WMwGr7NzbvIbQbEL9DODM5BWzAAAAAFdgiafNoCUpv3155D9ePHx2g2WaPqdfxDtAtUtRT2kJ0AuqXh9WdrQYvWrLu7/sKPsDNgHS4WWF2/gg06JukQcugeo4ERl3Z90MnHNgXkfb9cJXql4/KvPQ4v/FWjs5qOVvC4SDi2JEWMA63FMEq2klHi0d86pc0Mz+U4MzGLuq210SFh0LNzB/E71CZIstJqVVuBNiYrT7KC7n+z7iuV+5Adu17+NU6rf+DH1SAga0TlgWhFvT8Zr2jtGnsiE1qBqXNw+GU0r9G5GhR4nmndIbG06Gm37DjyFV5rvGLjlcbmD4wXLHqnRSSQpTmAGDZQD8rJ02cHUriPxBUht/dXU6d7L61ZcXpbMIiHsL8C0IsWnYDcQEAfBh2WTfcviy5ucmGbmGQvlWp0Euf1t04RtmP71+FXEhVnvkJYqECb57AmS+EgSCdXYVDCGdfkAQTgL7i0ptXtpbdaFjg1cVkRIAJQ7M2qCZmAR+yDLx7qN/+M1O0qTtzezBVjJWupAaE3stcyLWqWNU9SnTw5tuaZwLo5zYg5rFd7gfBMgJDw/fY89KjaY4iOnPb7ypO3RIynXe9s4Y7R2HRfRPtla3kbrs/2PxvAaOtP0b0N32a0aRH3Wb0vqZgqoJ170Z9BGJDs4= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.bak b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.bak new file mode 100644 index 0000000..beafa74 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.bak @@ -0,0 +1 @@ +UE1WMwGr7NzbvIbQbEL9DODM5BWzAAAAALlXT+q7ifgebFkPJSa6Icp1Pl+ggBrVMGkqwXm2InrtcFCRxNAVqEaRgOblfmM2B5At8nxwPoavZ0SKTP8kyXSNQYzwUM/B7gnBtPxCr/8lskYhfP3rqUilit1ECigJBMowlhMEkgMWIJdrH+AQou+F9neuc6ug90o8HmQXo5z+zKxEhKjm3O69aL9isXYIBgnSRc7clwUnlBceTQMpa1r4Rc4VJ5eMiICVwfOtfuOd62mf37XJ/4iUfugw6aMuBuneOkK0waBD2lRIvuSGsRI/A8wn8g+UTqrpRudNEu4oWQeP1SL+CvvT3ieRnUNVUE9OQ5l1SBMU0Enn6rFn9yCl0KsG8QhyWS0EaxhcUzvgwyvJimLCowL+nDjV+xkqVpMJvVLgh7YrodO+KiFDywEHKoBiIRtvRwDjYhbu3+7D2+vtYzcBktMU9Z/Hsplk97G0bLvRR6ddEdP77ffOKp4JYRt4CuC1ZAnYfvrq5KSzV0XU3LnBADvZ+h9HTM76tnYjaN5dgVrZg11C1jUVKxFzZCx0026vUqepMmuIWHmhWf0MuhwL+x54PGrVUk7uHmYHU0MsmHA9aOB9jgRJXiU82716mJHKhC1SD7YP4QLDwc0+nTC60yMZp/VT7pT+YLg= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.base b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.base new file mode 100644 index 0000000..358c46d --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/abecdcdbbc86d06c42fd0ce0cce415b3.pmv3.base @@ -0,0 +1 @@ +UE1WMwGr7NzbvIbQbEL9DODM5BWzAAAAALDAAiOvqdzf+lNXgq5HmStIE1yvNYxhOfGTaVrWxYxBPuagfsNBFrDqNXlhE16G3NZK3OqydV+3YEHB41GnjggvNzA7MCBZJNPL4lxmC3D5pBJRwsQG0VbztW1fbYzPjpzUnO9rafsHBKLGyptvVnSUegmCw4A2wA1t/5y6mlU+lwGQrzB8uijJhz3rURLniislZ8JK/FCxzXVmeiZ3SnN91f6fJJhk1qiF9NpCCnRo8R54x92y+qPQiOMuR/Aonc9qjdEK5SUrDjcY4kTyj3eTvJKxmZo8R9wrtZ2DpxG63BxMSVNp7KqDYAr6g/E7e/hPkugVvfEAiJ67bljGQ8pJrRUUrT6dbfe5NuEmbEAAo4kfvt9nlWP2oLfMqlQ2yZZDjK+fthBo+vC2iL+RfEAiZVA5evZjieiXvzCU7m0O3rHGyEskHrp/i5YQ2pAdhYm/PjZPGJFj7jZVne65BO3HHP3kHyEFDpWmWPAMneQPgkVmnEfSYdB19wMXsqrX/hjJBucpV9jn58iWM0CznrfiSrmpJuv53V/Eskbss/c9FfHs9fvdq+h8M3lRE4afXRg1P8F/fTCEEreHH9rNFHTzV+rH+OcYCPCmRe68QF1EmyUYguIPs8Lglm2P3uPOCVb+3slx/JkByQt2R99N3dSI/QR36/2cGfltDypubXooB6CdNu1uLYgmbigcGMRrzpAakIMwEmr08e+wipA= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3 b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3 new file mode 100644 index 0000000..e0629c9 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3 @@ -0,0 +1 @@ +UE1WMwHeFEluAWTZi8A0H/uPSJ1XAAAAAE/KnvCaR3o0tkSNu28Sp+3HifcbVv5IGRkNh+eO6kJQAAUWA6EYglIDHHSf9RvkwfZpZ5oiKGpLYmKmRaVEM9X7PS++kLzfkjs9dL7DdoPnAy+X/rjzX87RIjT/vH1R7Ex/IyVzc5FiZ4K/pzPlZLfusrIArdrxExt8IVSaGsrZky4p4PpIbjcFt8TtFgXuor1L+MtaFFzgw01TBheVug6mIAtpRmWqVV65ikCYm96IpvwqCczirlw/J/h2WmERSY+NKUAtHEUFKVNyIGMSs5Kf6pOdipV3HlhmMuJ+a0peLMDeZi+17u7jvlReAg4uaNfY0FxmkMb7nnvBMTF8KbJiT3GtAz0pq7zBofu2wMjrxrpE1Nj0/Q6GCT8qyp7jaTo78KoKHiOYovaaVjmAe4DEP7mKhBfvEN5kM8H99k2F8drHDw1v/xbDJDmepZHwGah7JwspbftTJuvg4R4tLEClmFepKPtxrfi6WjwqrN2cL0aTkPmT+w5cfkc3536UZKBUdpR01ervx05lwX9tk4I/h/1LWTGL+v3mby28QwOoT/XiXt4A4P9jZIGhmxQLG+9iCO/PUU8o2LsQaA4Ksq1pnzFEiaN0ExiIXkPMx1fTaACEGm7zvuVFf4AnEM+/vhZXhTchNVS24VQFyr6dau7oh/fX0k0H/UB50FFYJptZP2/sP0wbkdwSAxbFxM4uYeMxyGp7jx/rrjLhdK7PcAV32bZzzWwcU17Zo052+uH47mYi3elmkR1jvDC9+kyzn8/2R5230p+IVhmAZ9SLGYs4F8rBaXa9j9E1ejut4YA+EHl4xB4EFPt9IBX8yl/5Rjdegb3JdWi7KQKhkODNgbWJvxgrfzTrVSLWbxJIcAuvBo5zkuyzjIiGJUWmNhM0L9DpTQmPmupUHRfIt52ObE+aMX3pUYXbXKdmRz0LeIZTdb7+9NI7ZNa0z6Es1fPSzI0ROs5IDOlAV+dwxs73X9Pc9IG8mN3KN+4yhdqBg3FQ+4cQbSur+pjWG1QK93lxaEtZoAIVc68ZJ8+IZvwGfV7IhQm5A5ouoC4A+w+x8KazzYRAAL0C/95iAmuRlX2Mqzpo+wy87KovtbVHOdQo4DYdHVlgF9HwkS6StI6n3kUfwUUp1RlPMmrhheYPGalg9MCp220D7T4ntevGYhhTRkCb0T7kkvfsgTUG3DJhMI0uzpbzDocZm0wZI7prSUoPnWw= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.bak b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.bak new file mode 100644 index 0000000..cc1d833 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.bak @@ -0,0 +1 @@ +UE1WMwHeFEluAWTZi8A0H/uPSJ1XAAAAACQJnvpDUUhPBiubiNMcdc3OI2O8zfpaeOIqoHDvvUcQ+MEQ3LND0sMm+CY6ZdwObP9XWj/f9HXleABjgLMTeVnOdw4zHSXIdfDtcmWnUc+iIZ5fEjj14Oy4LS3qwTjJbdCNqQiitsTsfZBCCdlzPOeIaCKvyU9yW4BeiA8A1NtQz6wHHYRp/eVBQUB614d69DcFRdNafIM6Ss79CJyRBkY+GI7PBEx7LyRmTr2VD5ykCPma6bwRc9IedusFjMh8xBY2NVO+v89xqQoUb8v1ANNOkrhFSBo0M9HFDhpK92WlYTLIpKIXHIYM9FuCUL5y8cZsLwm7ByFnI/I14E0lIL+qTouGPOj3wiR6fkysSTQxV9AkKQm+qvzOvjNjM6rr8uumpxWjxxnUSA+og07uOAuGYfnXj+tl/sltjhibRE9dtM2Cv+DfLWLMqwE+0GK9zX1Nct+bL6M3YBDyFGUbDmTSx1omEznENLfpxKLEvdvI9Nlsn5w/SA45cIl26gEI6EdVaKSu7Z7oGoduqVb0cj+9CloaqmAMg9Z9cOC8zjHH0+98LQQpmWiX8yFvoPx8YlNog+xzl4Kl6O0z7mWgI/mMbGcjMy1YlAi+fzMFUM/9WdAXnQH1zwhmup6ck2oEiE7f8cE= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.base b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.base new file mode 100644 index 0000000..e006313 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/a/accounts/alice/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.base @@ -0,0 +1 @@ +UE1WMwHeFEluAWTZi8A0H/uPSJ1XAAAAAF1NS0b4PEa9kypCg7pJWEKpjvZNlECuPt1jPL9faXtYrAxS0QoMcuqnR7uKNf7xDGVLx7qWjnOWN6a0QHq+G7PChSF+mSegCdqmUViELdmchXMj0feXdHvCquIcrZR8dCGkc56CxUhnmO43aaP+50PNoHGYSpAHSRFauC3Czabx7QGPQYku+Yja81dzRn6XC5NhOrNt/r5M/LnM5Zf6j7VsH3UtuJKlQIh5IbRJlHPnxkdAlx5FrVV0ChynQfiIVt+Nu6GCqSply19BdeqPRvU3ZvJPoakvzsGLTZv9rYu/BIQnVugvwdq3rlmiKgUryiMlpaQUst8s9VxapymOLI6Mh8AUNK8kKZWT0SjnaUX5BftVHuTPrOcOt87k/G5rcXz+ISKGIH+rlPLMCw4j5DiRLKqgqfmc2qUqbWgF5jSOZBEqgMbzKekqAS3N2s8o9yR0KJrIypLQNfZIckQEZKfwgbwoLihzgwUy85HkzO8/ERL21BVwWTRQDHMJo1jN54SZMGsFlPz86fKFSXtfdtk/2sOe1BXPu7zEeCLRMc0i4Itj7jaCjmWAPcUs2kl0fqSmLJ5T4d+m0NtU6K3sx7eP0NU0DvC8ah2bb/1MWtKnkQRbaj7U2+UcNmh5ZheVtBZkQpQajmvXTrPQ9MiN+ZKlsdUKukpT8xN6yY+01KZSObGzfc+ZuWBJWEseoKmoVYwV4uEzl1RiUcu+lon3nov4y4L54o5nDIJ16e7C5cIR8lHwBcr63sXW2RZXVTtApdS/JCNidJrpR1zH+ZP1Pwha9Yo4VbEUe6gP39XmpplAZAnUzRabCCfuuTe9MIuLbOVcCHG2lBPU0OljjpdnUxc/vteboGBjHDSk3wDHb0hzul3eK3jAlFyKkYws3dEd/+D6eV3ueQahbTQU9pRgAWRqoeIdFUP/I+RXgEWrZrxI3mkHc3eX0jLfzxvE6sY4bNws5+516ROGuZnO1o1D1M9TRX2Uj+d8BVR1kzHaDdDGEmy+EW8A7OiCWTOT9F6uwfpvPxdMFPVrdvrPX7rWrW4g4jU9gFxp4D7RR7rAXh2aRY7A9Pozzh/mCipxn7wwyqMhmx8wOnHHy1ovUfEnaBM5eGXQrZ6UmsRS2iql5TWEbiEsthKRAZBSvKU+2JeAstoSk8oqxJkDFchA/K+0uNG4A4Vmdy6VYHxvwdo4JIH0YkJxkRyUWmuRC9YMzT9rjffCbL/8Ns24EL4DsupyhmCUexpserxQElU7tQSObkXI8koNXP1XhsoNL2WYmw26aULQy2LMVfs4Pqn+63U= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/account.json b/sourceFiles/tests/fixtures/format1/b/accounts/bob/account.json new file mode 100644 index 0000000..c65f00f --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/account.json @@ -0,0 +1,41 @@ +{ + "created_at": "2026-11-14T09:12:35Z", + "fingerprints_seen": { + "alice": { + "public_key": "mRrtkVLrJhdUXVBg468CekE84qKOwIUkhwCUlo2H+F0=", + "seen_at": "2026-11-14T09:12:37Z", + "signing_key": "SgUKQ3tjle8RefuDFebVRFloYFDDiVRbOYNKUUz7+g4=", + "verified": false + } + }, + "format": 1, + "mode": "personal", + "personal_vault": "f0b486df14d88813822845953c4e6b02", + "public_key": "34mwEYgX6nDhPrIoehLAfcIUFW/ftm6Kj6Mrr/Wl9Qw=", + "secret_key": { + "lanes": 1, + "memory_kib": 64, + "nonce": "+BSG9dMbsSLsJi++", + "passes": 1, + "salt": "HPsW6fIkVCEqHx3hcPX2Kw==", + "scheme": "password", + "sealed": "AU3ujJtbB+bNooK8xcEpVHhFOoFzFI+OmvKJ/5yAgm8=" + }, + "server": { + "ca_file": "", + "url": "http://127.0.0.1:46349" + }, + "username": "bob", + "vaults": { + "de14496e0164d98bc0341ffb8f489d57": { + "created_at": "2026-11-14T09:12:37Z", + "key_generation": 0, + "key_signer": "alice", + "members": 2, + "name": "Team", + "on_server": true, + "role": "editor", + "wrapped_key": "ARVdJyKBv33DmMALW2xesKN8tRUunfRZVbUWG7M0u5Q6gXPqak4HmXbnMCeFVg10/vhJVmDHRxEq9i7gEslutDuq9yBXPMkVF3L57ozUKem7qdVXzROwEsdyiSKE" + } + } +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/keyset.pmk b/sourceFiles/tests/fixtures/format1/b/accounts/bob/keyset.pmk new file mode 100644 index 0000000..7926e41 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/keyset.pmk @@ -0,0 +1 @@ +UE1LMwEBAAAAQAEBV05a+hMJqueXm2+LqWDXjovBO5EPKhVeH0rQUq279Xe6oMEe+6n2un4vhSWrMucmx3RQxFjmLvCTuvLqDEMsnLCHevJOYqAaiJJPV8FN3axfznwu3BoQtXfzC+Pq7lxTgGjKl+zK5hOgIzye0X4F981d7+7cL76yU7wUR7HYvoen7va5/85SmYY4P1E45WlnXfqED3IeyDRYFydrC1UsIpbBXrHRAVwoDxK+FeiJrJps \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/recovery.pmk b/sourceFiles/tests/fixtures/format1/b/accounts/bob/recovery.pmk new file mode 100644 index 0000000..88ae152 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/recovery.pmk @@ -0,0 +1 @@ +UE1LMwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALOZszL/N5XpfYo1wj4kbZE53WJKvve0ht1PqVZKwFDOG61GbNKFMSeNhg1BXqyBqkZxTlDm41DvPZHPzbitY665/8i+u0U0/O29jTfnIiId+S1iiPlcCEnD+hnGbNyGzHCxf75FZfzg1iau5CLxPFXtc8BhBstE3Q1nzoHT4oSPbRsbL/iuNFapLqonGv1002hfBz7k4xkt6kDbmdN4c/sw \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3 b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3 new file mode 100644 index 0000000..9812893 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3 @@ -0,0 +1 @@ +UE1WMwHeFEluAWTZi8A0H/uPSJ1XAAAAAH7e9yQnGBC4s6ISNYOMpZEZ1hIyTdxsWz/wj7NLWZYK0UW/XOpth9SDFDv14kHIF7Qq0KxPLBAQPq0O/9r7ql3R9r4LbB76VaKaVFHqYM2WdMJ2YFxuXXVXWfSOh4/dwHOGEWHslgM+B9ySZWGLVwoiC4o/7i6SlU2OH0lKBq369q7EaAcuXkceKjOEumeARuF3hae8yRLsofZ4OD1pgq7G02lPZKhCJdjjs8pLwJkkEjViTRYicvoR16TyOPBZK0QMjawx5UeJ+sXj1T4kR2JvDGTqFWXOV/fha0QyfC62JyYXHNGnOT6ilQSGTpMW8sZzpmIPOfxkA1hIsnBy+BleF2J1Z4p0tJwZ6NwQUssZzZo1JZpyKz7cAiMjUSDRQYoyju8/2vjSt+VSTPR/4frzqq8LlUWgdwJXl5TrAvLFFSnOqmTD905d5iL/sTMx28JcnMJQxMBfDzOCeH7fE9CS70j5ie+D4GoteTAWDAPBwWX2jelbt9PTMX68b0vpoR5++jsw1WvxYdpdTiaEpWI9LEb9X0yfLxqtfUFmKxyUE7NnTWZNFqNuos9oMzzuUASnlICpdLwhREj31lLDhCBDXjHClo1BpEPsoma86UzNflrXeqqPrklR2m3ijuTGiWxdCngh4AajfqsCPWiovYyyMcPcty42t8KWNSsnQoLD9wDLEC1MA1dXj3P/3YBiUwBB7NO/F8YON2AWENLrS8kfR4kFwbXoAQ7MDoU/5JhONvfDogklCXOGAtabKvOVnEWBEmYSKzhBOwYtDWtRTzVD11ARhT6zk1UwPCY+SULaYvoJ+znkSu5YDBTOIMybYygXtl+rZKaI1i2bJEyFJI8cc5Mg8ntkklVAUtFZx0vBWqyhSChKxvmI5tUX9DPnnnzKhHLyXCksEDLLBsIR5zkCLkrTbcT1Yi4M42H1FH2HU1UT6Kd5z2hW72ooGQSHyKrnIEJeboKn5IGuvJoMvzKLccG17pCgiOXxNg33h/0nixvjgmIYM5Z02eMxDD3I9gY/97T8GskSYr5Hsb53gMNb4h/kC23eROe7R2e2EajyyqQ0vWAAV8rvJppD+netZZm0gnX+8Cg+iWH7bFNQ1td/lPHzo6/aA+RTq5KLpQwKTIZFZdf8UlWU5pxyXjpE6oDet3QizJACHCeU/GUqo1Nrl8/ltG0c7vc4vKc6uL/9DMB0Hdc+cfbNlLNOqWsNCSw= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.bak b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.bak new file mode 100644 index 0000000..f3662ae --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.bak @@ -0,0 +1 @@ +UE1WMwHeFEluAWTZi8A0H/uPSJ1XAAAAAA6WL0u3gdxqyYqWYbZZwgz60JaJZrx+JJrfCODGo+Er4pT3/YC/2KXQXF/m/oGXeUAxy3eTk3NZiXCNWn6DGVuvXGdgS3FdEsp3Tzz0KUwnWy0O6QZoQlUhG/+D9ron5ueUMo8exbtrTJHWqH2/mCW8HRjjWVIoc9vIxKVsyM8HmVwAsPLJRrtifHvUM3038AIkZVTyMOK9SCgMGSULeYrqoZdHuHAmEevttoVx0kSFiSHgLzrXD1M5LK6cpE365V9S5WXxhjiuf8IbQMN6b/ZHTsZ0gMJMIzNCxkNKsYNPDNLl55QjU5FOmcUU8rH9Sim0oGbNSu0Yd7t6hbdQN/mzqbIBXvlHMhRRIW3M17v8tUXDih2K39L24s0gLP4JItqfN2Av0OMqxzS8FK3KcJJfuS0gLkjclmEbmRFjMUVoOqzOB84/x+y3GszdQa5XbrejqWxdmxM73YaDxzGlIkFJw3C3nFCcGcoZW1DDOpgm2Td1H4QNFWyuop0SwR6CpjWtswlNrtuw6f/oJOSGR8V0DOrAYjt5AdP0HhnHwtYLjdmv0aPYHi4cMbDtKDT7KWeQQcgmlwmPMIqU1960AxgmVePSRmEKLwhtBKC57ScymJitVZPXraInNMCJRQEJWa1aTiHbQ9qXCLpwWLMNX1OA55B58+SwhGQR4pgRXErUjOZb46ExLRMFbzm17thHbYpzq6wEx7bbbpz2nTqOlKUbExrTUpPZeqlEAYmOJSgxec5z7NJF7vg+Ba4KK8O2K+Oomf0GzbLQw3D7SFFkKjtGAFIRdB+ZwhOMNkNZrDzh0ffoprai46JxpogQKVJ8QUhMcAdupPhuY7naqk9As4gKkJ4fb+3a0h8t33vMkQS5hEc7OaSLkh+jnkaQRwIy5+nTd/JXgNxV3F0FSkokk7wcNYCUhCD4P5iYRyr7JElFmdG6bvh23y9xI9F3e/lpHRVqVrhEk91KVhtUtm6tbjiBuvKB9vkyTuHyNEvnYSEHZ7vf3pHLQPOLyD51ITJ/ozyF3LpjznKYaNwOpsc5qWt2nDBZ5UoED2RaJMaJyE/KLPjNFSAFc4yWfmGIeQ2I4CQE3I8QY2ckkHwRE5UYI3ykC7NaJdW67WTci4D4jhVizUh7kfGc9reD+6Qzp6/u/YiY/Sl5L1QeROskD4FEg7iTpNrgaW/dVJa1oPE0/sPy2WWcRLJDYdwGpr1Tfv1akm8= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.base b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.base new file mode 100644 index 0000000..f3fb77e --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/de14496e0164d98bc0341ffb8f489d57.pmv3.base @@ -0,0 +1 @@ +UE1WMwHeFEluAWTZi8A0H/uPSJ1XAAAAAGuLCJNL6i3mmb5HIASFIvikfUlTrqRglHY6ltVClPDiMZmYT++4IJccdJpEgo02Bt+77ptHdbFQZ3M2YZr0X/ZnweOIx3ysGCOTtLs5GabFI03iVdVQBADB5hbOgWmVamCv9xJ2z1FI2W6Ga9wlkoK8YWwG/NPLxYHSL7Mv1frKa8EKStFjr62V7sLcKJlbawfAIOMPay+kl/P28wZpuJvzVoU3KNrnZdZjRCBccel692z4ZgbYcPTSgQaqizknHBa5trQidtBg96E1nT3d9wWGvbWOVZPOBwTAqcHeqbBzsLQbTNhT/cg44NP0m6L7zDrL2kEsADXvBo3haTJWnrgGMfyccAgoCXh8xiW2Uw4nLAAcvEZrXvAPxqAUYVXQTvtI77cdQTsJnfECfghAWfak6fJ0j7XK5WtgtPdkb42bD3rNYNGMjwoGNPfUIMm5m5lB3hBtTe8NsezRKNnRoAEF4klrpkFIttDyinKDU+gRULeXNFMCna/X6ANu12unKjrsVAgQjLrKGa3XobvMqg+hUdUYcAG8OfKVNQJYo16WhUsw5pmPuTp9AkZ6PL+Irn/Wffy8LhVHhSU+oAv6PMcVLMpMx+5SulMnTrzoXiNpEqv6mOJ35ycSg/1GSAOH7aw1dS1vzS9t+ma4trj3sBOClC4AYgtR3laQwpQ7+1eNOHr54jv57sozP+2KXyTeswdYDV4CstGNSXJF1pGSqxtII4YRwKTmzqgiZANda6WzJ6yivD7f9fkZAJqd5xqH+qkVWZ8Qn2I9Kd3YfiOFy3p/U4ubqgzr3EWDf01QZIicr2iloFMWOFyrntwPOLJNNjCym0OlVIiLvG+w3JqvcZUC0aNyOZ82+Y1b0mygze1OIny4OcwMULOXycsogrj/B71ACWqb+nReDgIzRvHFwVHJkFmWE4m4GjgSPXV9v9gxf8U/IGuvLtIgwOpL8EEJOrDYllfYqczKSzmfpJbeW+AcVMScWvUphDvIXpYhjapuZoJSD+m4PDMyqw8xjfx2RzsNZk5fSrVGsNxBiSaS/oMuE1yjGwLsbFU8b0EiAwNIzy4DY9aykpUQBl5G00Whe38Y1N5HWz2Lt6NnQHYbShpyBUqPpLnFmh3khcsPuVllytWuI13Sa0iIbNuZVip+UEWVu81RpZ7Vq6aqLTKw12A+SFjvpHkHnVFsP+r7jfGFH8ujZVaXEARPK5NgF2jub4HvmOM6fFjxp9ZHWCPIhBHe3Arz1rlUlxPGvQG46DkxxdhaL7tLkZSzQ/xAJkAfR3z1w32481EmB75+JXI= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3 b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3 new file mode 100644 index 0000000..c2bcd92 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3 @@ -0,0 +1 @@ +UE1WMwHwtIbfFNiIE4IoRZU8TmsCAAAAADo5P46+CJNzBO3Lx46UNKBhwDGE5aQ/iF41saM3gliaNAwUbgoggfk2/VV1LfC8Rwnvf0y1irjGHYXCG1O3wyxsPkU0FxCuSgCEf0I1qx5x2HS15mWdWK42ku0gTK8Mx+MRJQajBYeZ50BoydCmnjt7jKiuns4IBAI6p544tKgnVzs7aE8KRGxQSqCACXuP4npB52J007fxQXn7kdKNLGPwnqFW0oBP4ukIflTgUs37UVCx06TQV1fdKFVBPmR5J3zpmOxOfIwMvHIRhR548SoczoUwIsltQ1hsCtK9m9+glWfoCKv++WSXBh56DW48GOa++ZMWxs92mgUINTI3/EJjreL+BCRsZhqQN4pILKsUrw3oUjRCPXiFZlADGNczquOfkV7l7ktx8+VAsXnPVQCVYZ7yraIcN4aboz5r7NeCPWY4PV+H9Psra3UTUACpkHM+qt5qonfnR3TqVkpK25zMfFi1sgjeIZFzuI6ow1mpkzVi8uh4k3YVuFNqJ4cMHc7xPYGvbgVDJ+zigqbT3nNB5KYI9PC5uQkNzdnNmTrSwzdIAlaHXsQZtNhu1ynIRZjFOPrespJl942ebxj9BncW5AQdt0T0+31kw31ry2wnAoFtCUDtO07nbwvJ0A== \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3.bak b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3.bak new file mode 100644 index 0000000..5f575e1 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3.bak @@ -0,0 +1 @@ +UE1WMwHwtIbfFNiIE4IoRZU8TmsCAAAAAL5DqPH8uH7bj8mC7X8xocnK2zXfe1LqMX5k4sjgT/6cw71hRLHvxEpboDHIoEFffWbgahI+RAFzdyKcuBrWykU6h9P2PaAju43ZsnLOFfzVX9qCvY3EgdNz2Egat7oQ3ZqYEftTkXZi1LA8ZrAfdYxAbf2BzDj5ZDncwQsvDLBVUuU+iZbryIJEXpOM0qELAd1s/uOAa8Hbx09aWPZKj9MUgZXCyLnVndFkA7zniyVHMgcYE/iKr7zjH70qDYW8mC3x72IVbgQWVkiaxQqUXdleHQ6zV7CSlMZktyc2K+igJ2F7wK0Wew7cRDfl6vBewfywM4bv3l0Fnyjtqx1UEZ3gHjglX65R41zqZ+o8pagTexZ/2z7FNwR2BC0bgZRYphlEngMagYEfCHUHz4Tv/PzOV/YZ9VRtBHfJiu+LX8Wgb0sC6L2HdISBy7Vhr6ZQW9KkoeuptiZuUhBtinRpzRMlLDsd9Wf+me9cRYY02zGuLoxc2D51B4ZnXLwVPptqFFxv2jw8XU+ZEXFnlRiEM1WlqPKiBzxB5sUUwsc0TM9aYX30cctJacgHyYBFGM9JnQy+SYokheFkGeXA7WM077QrOxfAL9ilQOHQUoLUaZTYBgqdQu5+JCEhiViVeQ== \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3.base b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3.base new file mode 100644 index 0000000..fe22701 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/b/accounts/bob/vaults/f0b486df14d88813822845953c4e6b02.pmv3.base @@ -0,0 +1 @@ +UE1WMwHwtIbfFNiIE4IoRZU8TmsCAAAAAB7MP0PDZXuei3qhSQ9Cun4kmCI/XrbKdxUTFoHU8tFbOpk0jU7wiBSEBU9GyolOdK/t1xHVsvquyl0LV7OcDq++nZPelheT3x/sDhHsLyqx8++asP503NTbiYzLJV5BXLzcGbBR7HvnNVNPMjWZ8AS4f8CcQzsd08NIZBw6LUh581FMNtzVwSzFbReJKXv+iBXYPRe423C9zF1GcQ/rrl8CmKpgAny/uBrIARdoa3uaCzzEx8ZXxGrNH3xPAvQNd11q2etZQRLx8dfGrwx3p3a/5jo4bGSS8KcgfOn3woa7oAknknDD4xoo5wfQKljmEMiTPDQePGOxwhryo0efdtVu+KmIoCsv0k+xFz9tqGGtqfLAsVdqCXl/YAAglSGGg17O8mXRZoK04AQ4JxS2Zsl9yIzBYQE0QeQP+jN3oOGqJvja8A3rCkzSkru0K8iG5yschs+EOdt5Nq5/nJ7n4ztBpPz0uJNbhvYEezAw1sa9XePtNlF6cXFJFySK0TWd2TDxVXhIgGFDv6Np/mjTMWjyWKWYIzyEyMv1rQFJeyi+SiDMfVZHNSjYBrhUxl71Bu9vr4tFOSZvH7+Kxpp+yAp/7ncd5D/jz3RtfW9wXLvRX7NmFJJvJiYik1jtx+iPp32pGDHIDCcxHsOItbAYG0VdtFKua2Cr/6wLJU2IOQIgM123DfCiBTN5Vh6IcPZ4ESqeGsgeHiqyjg== \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/c/accounts/carol/account.json b/sourceFiles/tests/fixtures/format1/c/accounts/carol/account.json new file mode 100644 index 0000000..dffdae5 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/c/accounts/carol/account.json @@ -0,0 +1,28 @@ +{ + "created_at": "2026-11-14T09:12:39Z", + "fingerprints_seen": {}, + "format": 1, + "mode": "personal", + "personal_vault": "3497bc4903532ca15aee33a5357f60a0", + "public_key": "QUSTLe0bMMIrZ+uDAdqFGnvMj5wx9yTmN2JjLqh/6xk=", + "secret_key": { + "lanes": 1, + "memory_kib": 64, + "nonce": "cz/Q9EvBNEzbw/6G", + "passes": 1, + "salt": "th4oQJjgrMhfCZbCZy/vyQ==", + "scheme": "password", + "sealed": "Mjh/ZZ3f+McGUrGlSuuaJGUHlggIVPChZiM+x/AewAM=" + }, + "server": null, + "username": "carol", + "vaults": { + "73399c6b749a6bd0920c5a6cffbd3d01": { + "created_at": "2026-11-14T09:12:40Z", + "key_signer": "carol", + "name": "Box", + "on_server": false, + "wrapped_key": "AVbEUKLSchIo4Bx8zFyXF5BNrZQaERkkmrqSEWeEalcrqd2EMDHbqq3UVKDRzX5a4NTzMpNbPIaqUW0XFfyMxja2p4q9n9KiAB84FrFWQFHKR2fAV2fAwraIFN2g" + } + } +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/c/accounts/carol/keyset.pmk b/sourceFiles/tests/fixtures/format1/c/accounts/carol/keyset.pmk new file mode 100644 index 0000000..969d496 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/c/accounts/carol/keyset.pmk @@ -0,0 +1 @@ +UE1LMwEBAAAAQAEBOlrz/QJCiNIYojDjQA5nR/VGatkQ5ETlposgC7Pvjhwwl/zymbWzfokU4V5QXJQTLGEGoedTaRHk7Hp5CC+aHhLj1spUxPVw4Tg4riSi6BpYvCuCS6PxT+sSzQ0+u2JPIWorBbGz525xhXKJtLtSsOJVWupIFZzP5FYDmnx83t7AfU728jvo7VyhMl5lAOj51Sd++dXBDR2vZ8Bu250EoTgaqHhkgIYce4FPVhqAkYbS \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/c/accounts/carol/recovery.pmk b/sourceFiles/tests/fixtures/format1/c/accounts/carol/recovery.pmk new file mode 100644 index 0000000..623900f --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/c/accounts/carol/recovery.pmk @@ -0,0 +1 @@ +UE1LMwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANfnMjVA+i3SS0V9vkm+nkY2TQFJ3JBUFdUk4VFmO95X7g8xXL0W9++hjQO7pMn8B3oN2cxkfCZwF+s7NE/WA/Zx7H66llOktWlJ+VdCMnQfp0dOjC8YWEHLafviSLSWXX0vegCtBl4hXySCprxrlGS4/amNVIiIu/9u0jcUAnHM52cA1s4vz4Q6jo//qVlo8u2reCyS/tuE2v+2KSC9nXWX \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/3497bc4903532ca15aee33a5357f60a0.pmv3 b/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/3497bc4903532ca15aee33a5357f60a0.pmv3 new file mode 100644 index 0000000..896b47e --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/3497bc4903532ca15aee33a5357f60a0.pmv3 @@ -0,0 +1 @@ +UE1WMwE0l7xJA1MsoVruM6U1f2CgAAAAABDb2qljOTOzDRV9QNEc7z3gsxGfMAD+o26bhz5XDZmT8eZv5sAiUR1/WbuF9n5uQuV0qNAXhcqIHM9VaFzJQs+95uGFjya62xmOMOCfj0Tc4ksziaB8u017Qz0AQd5cTNpBb1J0ywtjFA/akVFiirCT2tXXcrYVgw/qJAsVKQ2jc7x64Ldagu4DRi9tNO2fDQXNU4x8cZ487fpSR/0oYAsMK7FJQfUDnxryK3x+UwpvgXKbv3gjakB3IC5gXnseVjQbHAF1ApITT3kUYjuYWda491d09f1oHvQ0/L9tkIfQGkVCriC2wfrjyHGaY1IG8YUbYIal/N9bgBbh6hLa1v0FDra/7RPiVNFBTeJ9bHWjHGX18y4MRZDGsEpDijsBor5RM9hu1qxOH7xpoVubMm/BpjVoCxqGfZFiFZ+kbOvQN+neYrIQFwBnQD5QATKnBtaJfjwoK0Yfu84LOdyU4qAGr1CSXl5KWSl57bUqbg4wAjBALVhOEHOKfy4hfd3d+dDzToA/Uae7v/DkYUIpPeDT0l/+3QabMuIx8Tx/qaL2Nf7LOR65ZRp60FIKlta4h899qAHmyOvMxZywixIFtg9WbWkO+OEPB6n/kjbfmzkU5WZ7cYcf+IrW/+RA9CrTlT0= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/3497bc4903532ca15aee33a5357f60a0.pmv3.bak b/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/3497bc4903532ca15aee33a5357f60a0.pmv3.bak new file mode 100644 index 0000000..a351d57 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/3497bc4903532ca15aee33a5357f60a0.pmv3.bak @@ -0,0 +1 @@ +UE1WMwE0l7xJA1MsoVruM6U1f2CgAAAAACfge/5CyCBCehymbv6h0mnqa9qVL4KQU1dGkjL1RsE8NuEbuFplgOYXh28= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/73399c6b749a6bd0920c5a6cffbd3d01.pmv3 b/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/73399c6b749a6bd0920c5a6cffbd3d01.pmv3 new file mode 100644 index 0000000..b36b33d --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/73399c6b749a6bd0920c5a6cffbd3d01.pmv3 @@ -0,0 +1 @@ +UE1WMwFzOZxrdJpr0JIMWmz/vT0BAAAAAHYRbkOoXBXMWEzziX2dI0k/4hxsXLbF+wySA9VCzmlXSd9BnHN0RE/9eU+Cb6jnU8P1mO3tnRjPh3Uh9dmTFgqjPcj9f3asc5lQz7FuqBLDW0ip5D1nOM33sfgw6B0sxFFTqeonjNUD1yY6K+dYPaP0bhq3AUY7+46fRqxicvAcmXVT4tcvDn/9xbmp1pJ/XbbbqbepqUdOcBeHlntRB0eCmW+woORMaGZWDizuYk/BMCampDDSFUo1B7AXoP5Mw8MF2Txv14o48nWEAAMDomIOEYI1f5xHT2hDGeriLnod0B/SE6O6As6ApspSXT4dmoyKz54Ih16wyu2VJeipluZz0ebw04IKca20lhuknL8ViBDa52kTfXLUzMHhEwEa7E0ElskLdDvtApRoeBq5AE+q5zITWEFWW6Fcw6wVnschCVGap8kO1uhzAiik0oVxOkgyz+ZS3NCW94O1EE4U7U71biCqg6chGDo/uvgY2nrs0sBoM3Y5E0APhInleQep+/jg3obo9YvvMCFMla+a7AyfEZz/4obe85P9+CcSPhJKqK9N09BRa7eXXgu1EMn2xHGVv+8QJ4Bk/cCX/g5Q9ua8TJbUFO6XQZcCxRCKXk+yqroTge99TgMnk7S6i0yWXJaFnUPfipxZ2w== \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/73399c6b749a6bd0920c5a6cffbd3d01.pmv3.bak b/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/73399c6b749a6bd0920c5a6cffbd3d01.pmv3.bak new file mode 100644 index 0000000..e94ce3d --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/c/accounts/carol/vaults/73399c6b749a6bd0920c5a6cffbd3d01.pmv3.bak @@ -0,0 +1 @@ +UE1WMwFzOZxrdJpr0JIMWmz/vT0BAAAAAJPVj4sUeRfykDwGelj2cebzfl3ikqyzUF5cKraO+Kl2VyT50mBgL//fyvs= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/facts.json b/sourceFiles/tests/fixtures/format1/facts.json new file mode 100644 index 0000000..b917bcd --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/facts.json @@ -0,0 +1,74 @@ +{ + "accounts": { + "alice": { + "fingerprint": "4568 4190 1731 1316 3189 9396", + "password": "correct horse battery staple", + "personal_vault": "3a3880dafb35690bf7b05c8886099ce6", + "phrase": "armed casual sound sibling squirrel attract cause gasp favorite affair burden trouble", + "public_key": "mRrtkVLrJhdUXVBg468CekE84qKOwIUkhwCUlo2H+F0=", + "recovery_proof": "DI4t9F0vcI3MXOpNXPKyA+LuRfMyxOzW0GanS3b6R5Q=", + "secret_key": "A3-4IP4D-IVCTH-GDPMJ-65YXQE-CKTVMJ", + "signing_key": "SgUKQ3tjle8RefuDFebVRFloYFDDiVRbOYNKUUz7+g4=" + }, + "bob": { + "fingerprint": "7880 1825 4016 0098 6643 6449", + "password": "bob's own master password", + "personal_vault": "f0b486df14d88813822845953c4e6b02", + "phrase": "system lunch spatial report balcony clip fatal cry rough tiger general network", + "public_key": "34mwEYgX6nDhPrIoehLAfcIUFW/ftm6Kj6Mrr/Wl9Qw=", + "recovery_proof": "OUH44EbCOeOiMVj2F7H88J2Th/LihyrLVKrAG+5spLg=", + "secret_key": "A3-VJG2P-WXHFA-JGKOO-3COVQY-2KJTYX", + "signing_key": "hDwTgizvA+jMrf0V9V4T114Q45EpYY0LD2THCVGhLn4=" + }, + "carol": { + "fingerprint": "1810 0779 3872 6806 2859 1479", + "password": "carol never syncs", + "personal_vault": "3497bc4903532ca15aee33a5357f60a0", + "phrase": "fiber build feel judge senior million fruit eternal scan quality month middle", + "public_key": "QUSTLe0bMMIrZ+uDAdqFGnvMj5wx9yTmN2JjLqh/6xk=", + "recovery_proof": "99CVIV7B4i5dxAoWoW9gebwvfoklrtAuMG0b9KpwLBc=", + "secret_key": "A3-KLUVQ-WD3UT-V4OBU-X544OO-NDR54U", + "signing_key": "TaNn6v2tW+GP5MgTEguTDsEYDnZFXr+bihnvKtfPMlk=" + } + }, + "entries": { + "alice": { + "Solo": [ + "Notes" + ], + "Team": [ + "Printer", + "Router" + ], + "personal": [ + "Bank", + "Mail" + ] + }, + "bob": { + "Team": [ + "Printer", + "Router" + ], + "personal": [ + "Forum" + ] + }, + "carol": { + "Box": [ + "Spare key" + ], + "personal": [ + "Diary" + ] + } + }, + "made_with": "3.1.0", + "token": "fixture-token-that-is-long-enough", + "url": "http://127.0.0.1:46349", + "vaults": { + "Box": "73399c6b749a6bd0920c5a6cffbd3d01", + "Solo": "abecdcdbbc86d06c42fd0ce0cce415b3", + "Team": "de14496e0164d98bc0341ffb8f489d57" + } +} diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/accounts/alice.json b/sourceFiles/tests/fixtures/format1/store/_v2/accounts/alice.json new file mode 100644 index 0000000..b138922 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/accounts/alice.json @@ -0,0 +1,34 @@ +{ + "created_at": "2026-11-14T09:12:33Z", + "disabled": false, + "kdf": { + "lanes": 1, + "memory_kib": 64, + "passes": 1, + "salt": "+A1kNAgaJvGtBj0LY6XcDg==" + }, + "keyset": "UE1LMwEBAAAAQAEB+A1kNAgaJvGtBj0LY6XcDqONi0MuX41s9o4B37bihECYAjmdjvKZYZdrzHF1+NAVc0PuL2Yk3VAklo75ovyjm+KK0CoY8GfSmdFw3dWNUJrqo91skNtrzWsl1dr1hdw0e0dkG8aqYps2l7K3aqPZ68ItNWVJY0SMcx0UDhdOOfQxNUvaRfkwacQWoJWmjXQfZ61QJJi5k+dyelmonkJhB/SHZzqGTc07akNAolCoJlJU", + "login": { + "hash": "CF+AR7z2Pzvd8h9ZfuCM0w0wd34M53ttLxl1o5BfKfE=", + "n": 16384, + "p": 1, + "r": 8, + "salt": "qUTz3C+n2JYf9em3glXbQw==" + }, + "personal_vault": "3a3880dafb35690bf7b05c8886099ce6", + "public_key": "mRrtkVLrJhdUXVBg468CekE84qKOwIUkhwCUlo2H+F0=", + "recovery": "UE1LMwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJ1zSBkRSsrOtXyVpXysMPU72gFvS1VZIpKm12Xko0GBhT2wHcbhouURSUCcVdM84XYmFbIMxOARX81mahlKTbPbw87RhHHU+eSMNO74lKZTdmAn/jwGVoNHP6mWGb3Vc2eNBJUCTIn/3uZEtzlqPx4F/ozZwi/OSzjOb4Mnq+MRL9yipoKeMx3TixWpNbURvyNcblVLHMSlMLHAKY5s2gVF", + "recovery_check": { + "hash": "1M/dNXT1ZmF0oho9naFwCqs01v9BwFbOEfsStHdmgzc=", + "n": 16384, + "p": 1, + "r": 8, + "salt": "snAuwGKVe4OVFn3mTR0c2A==" + }, + "signing_key": "SgUKQ3tjle8RefuDFebVRFloYFDDiVRbOYNKUUz7+g4=", + "username": "alice", + "vaults": [ + "abecdcdbbc86d06c42fd0ce0cce415b3", + "de14496e0164d98bc0341ffb8f489d57" + ] +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/accounts/bob.json b/sourceFiles/tests/fixtures/format1/store/_v2/accounts/bob.json new file mode 100644 index 0000000..b6dc347 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/accounts/bob.json @@ -0,0 +1,33 @@ +{ + "created_at": "2026-11-14T09:12:35Z", + "disabled": false, + "kdf": { + "lanes": 1, + "memory_kib": 64, + "passes": 1, + "salt": "V05a+hMJqueXm2+LqWDXjg==" + }, + "keyset": "UE1LMwEBAAAAQAEBV05a+hMJqueXm2+LqWDXjovBO5EPKhVeH0rQUq279Xe6oMEe+6n2un4vhSWrMucmx3RQxFjmLvCTuvLqDEMsnLCHevJOYqAaiJJPV8FN3axfznwu3BoQtXfzC+Pq7lxTgGjKl+zK5hOgIzye0X4F981d7+7cL76yU7wUR7HYvoen7va5/85SmYY4P1E45WlnXfqED3IeyDRYFydrC1UsIpbBXrHRAVwoDxK+FeiJrJps", + "login": { + "hash": "pMYcVKnso5yCICNZHcfI18LWL8TuFfECTqWDCks7tOA=", + "n": 16384, + "p": 1, + "r": 8, + "salt": "nH8RKlb+iKXcrgvbyGPvVA==" + }, + "personal_vault": "f0b486df14d88813822845953c4e6b02", + "public_key": "34mwEYgX6nDhPrIoehLAfcIUFW/ftm6Kj6Mrr/Wl9Qw=", + "recovery": "UE1LMwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALOZszL/N5XpfYo1wj4kbZE53WJKvve0ht1PqVZKwFDOG61GbNKFMSeNhg1BXqyBqkZxTlDm41DvPZHPzbitY665/8i+u0U0/O29jTfnIiId+S1iiPlcCEnD+hnGbNyGzHCxf75FZfzg1iau5CLxPFXtc8BhBstE3Q1nzoHT4oSPbRsbL/iuNFapLqonGv1002hfBz7k4xkt6kDbmdN4c/sw", + "recovery_check": { + "hash": "tz4SntDwOFF3UkDcOOt6t82hsFDOSy3Hp9eWyQyeuD4=", + "n": 16384, + "p": 1, + "r": 8, + "salt": "qGSi3+5aKBXuGXs/J01G3A==" + }, + "signing_key": "hDwTgizvA+jMrf0V9V4T114Q45EpYY0LD2THCVGhLn4=", + "username": "bob", + "vaults": [ + "de14496e0164d98bc0341ffb8f489d57" + ] +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/logins.log b/sourceFiles/tests/fixtures/format1/store/_v2/logins.log new file mode 100644 index 0000000..2a52d19 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/logins.log @@ -0,0 +1,27 @@ +{"address": "127.0.0.1", "at": "2026-11-14T09:12:32Z", "by": "alice", "event": "login_failed", "status": 401} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:33Z", "by": "alice", "event": "account_created"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:33Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:33Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:34Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:34Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:35Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:35Z", "by": "bob", "event": "login_failed", "status": 401} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:36Z", "by": "bob", "event": "account_created"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:36Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:36Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:36Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:36Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:37Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:37Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:37Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:37Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:37Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:38Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:38Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:38Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:38Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:38Z", "by": "bob", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:39Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:39Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:39Z", "by": "alice", "event": "login"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:39Z", "by": "alice", "event": "login"} diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/audit.log b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/audit.log new file mode 100644 index 0000000..5d0a011 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/audit.log @@ -0,0 +1 @@ +{"address": "127.0.0.1", "at": "2026-11-14T09:12:34Z", "by": "alice", "event": "upload", "revision": 1} diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/members.json b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/members.json new file mode 100644 index 0000000..77584ba --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/members.json @@ -0,0 +1,9 @@ +{ + "created_at": "2026-11-14T09:12:33Z", + "kind": "personal", + "members": { + "alice": { + "role": "owner" + } + } +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/meta.json b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/meta.json new file mode 100644 index 0000000..78eb89c --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/meta.json @@ -0,0 +1,7 @@ +{ + "revision": 1, + "sha256": "2f2e4577ee89b88d26d797412774068b082c7f9019c6d212eb61f8d8e2e01e9d", + "updated_at": "2026-11-14T09:12:34Z", + "size": 1236, + "key_generation": 0 +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/vault.enc b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/vault.enc new file mode 100644 index 0000000..3c5bc42 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/3a3880dafb35690bf7b05c8886099ce6/vault.enc @@ -0,0 +1 @@ +UE1WMwE6OIDa+zVpC/ewXIiGCZzmAAAAAG5vRE8YRffvBitTwX2/+z2VjP7XYeZnoHOpAYkE457ClGoms8tektRjruDktJanzqPPYW0lvO+8iQ0lwNFCSpIu0C45KBVH0I2L2WQx0i1T+KqQiiJidvZiaVfUHQnUTAWSnhqpL6UKUWNWb7wZaoVwywjGMM6DOJL+cTfo7rfNUix1BkYwzTFSZdGQ0yUkt6XyDW8VcXxlq23zMHomWB/+pw5qGJU3dxAWk2DHqR5fs8NzKmoaJfNUdm6ojjviWU8n9XbH+ov83elqzzMCCfZAkjKJh41Olgg5ccn8VuYeCnWigf5QvpTFcg2u/iy3IGm5pYIA8x/C9Mic/wbw8LdtwXk9klOfKxGT0LjK4UFrpSyEC3xkMlVUFvKHoKaL6EwYK40w6VwL8ckx3aiqFAubkd1kmeVjyDQ7w6c2J4QZDjjslUVgeAvs44H4BN4byJu8n5YF5tKwxLmuY1lg2cGOud6KCfGlBnSn1urnfDLGPXI4H9A/JM3hDdASAlF4JqZsCOt2iwOQWNFeeUvBPUPt3tTUhzBYry8GaLz2loaHLFQqT6HULaqyWte1EFUmD3RyIxujqOnQhpJ6MjKRE8DC/lYEDasLWMCLFoVOK5yZng8zPnal3+2GVEzfL3pm4fdscW91FomNKf7QTBj3TB/eslQxNrXykuxsv7Z7EkKfo3O1/G/g2JAnnNqMsxO13qwtiaAyclPHbBHmxNRQ7f00VkgmPOZ7nQhzTDEAjeVboZMuWKDJFFbX4Sv5h12giw1vnnwUH/bSnuiPCHmo/hVTn1MGE9I3A9rs3MtBSuTNHweF7HlL4u+RZH69zS/hrysHUe3Ogcc48dJclpG9T4OsW+tZuNv4zvaNiXDKmQzcm3JspV/omROGyw2McCbtGSHMa+Hd8xH79ReiEdjeMQp3bp5i7V8rQQf4RWKUSztOgDuRDkZn1pP8YYT7eEFCh6KSi/OQ3joBKt9q9ch8AcAZo7K4LcgOOeEfiEF2x9jx5+InDlMofWFtVyUVNgSx3Ri8wDJ6eS75soFNTHl2oCtjwID2dF72Vba/33J9DyrQx6PdD5klWT2/IKuO582Fjqnnk/w0WifvkOLP6co8rJ+JSSFUUEUKG1sppEBbR+8Rjl7DVM0tQ89KjEOpjWuDgYCwLwcS9w6dhsjIK0EOmpvsrL9XoFj9v1ld9e8rrbSGdQMAcadw \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/audit.log b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/audit.log new file mode 100644 index 0000000..01c51e7 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/audit.log @@ -0,0 +1,2 @@ +{"address": "127.0.0.1", "at": "2026-11-14T09:12:34Z", "by": "alice", "event": "vault_created"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:35Z", "by": "alice", "event": "upload", "revision": 1} diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/members.json b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/members.json new file mode 100644 index 0000000..c466d06 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/members.json @@ -0,0 +1,15 @@ +{ + "created_at": "2026-11-14T09:12:34Z", + "created_by": "alice", + "key_generation": 0, + "kind": "shared", + "members": { + "alice": { + "key_sig": "71ppEpSLuNIy23BDk0LVs4mSZKCBLW5bsoVWiA3aXSws1IHgM8yv9PgLMSccXeCHSAD/gnsyFf5/ZWrey+3HCQ==", + "key_signer": "alice", + "role": "owner", + "wrapped_key": "Ad14VpbAMj1fQsM8NfBbnX2+mbaf0swxufFkTV28E2Is1L8mq3gbaH9lg3taO2t56qvZWDMcf68fb8HE6Ne91utsZ7zBjL+v09QFviAN2MdHlMG699pS7uVVQ6Wc" + } + }, + "name": "fbk+/R7gSoD1Zs+g96/lgK6diiwgOegywf9ftlQ4YAA=" +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/meta.json b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/meta.json new file mode 100644 index 0000000..bdf83b2 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/meta.json @@ -0,0 +1,7 @@ +{ + "revision": 1, + "sha256": "0189ae7e1eabd2c07345baf9556c750b30cc0f5cdfc51cc0f78fd70b11aec764", + "updated_at": "2026-11-14T09:12:34Z", + "size": 676, + "key_generation": 0 +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/vault.enc b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/vault.enc new file mode 100644 index 0000000..46a4fe4 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/abecdcdbbc86d06c42fd0ce0cce415b3/vault.enc @@ -0,0 +1 @@ +UE1WMwGr7NzbvIbQbEL9DODM5BWzAAAAAHB+eIfk6b3weEnWUrHI7m5AwHJx5FzKotD7RqYZmnTZktaj+vg+oVfQ5c3n6DMy8j/HbAENfJmCaKr3+3yBkKuQW5EjpE5XpBwAaJucSPyw7H72D+zyLpIUxYDZEjzRk7oGWLK9PUpOlFp6vjvnH+TBuZEq62O/2NF8fauWpTrMCev0nK8ammQFJq0CMmYagkKPQltBBmALQC8pnJsyTHcRM2XwYklK05Fg2rHoygJDIcsocr9GIff7X6gJ1Jtx1VZOmgtEILTDy6Cgbd3F5LIof9Onz77HC5pr4EEnQbx9Z1EYWVnhgAJWB7c52lRu6mDT9vNIT4UF2rra/2z31KEGAO3q9fg3nGVHcirFAjLQJvPFCBjs+D2oGD2BPdCezvgbDyIsfOaMxYH+iTxOmZ2z/ex9o8nRRV4SiJSCDF3dm3AEzOlhSBSLu/iWyUGLizkgJRgW8O/9iLvqFI01j00vCKdvSodhK+5DTDg3TBPNNlppT+HgALZ3sclP7eFvDp4FdEfSo4I7hfHXzULy7duNSxTmf4EKo4fIjfpMCkrLCXz0CZryws7lX3gvr6drbfX5J4ydkq+21L8DCiI7wM+iHcmNv828muccHDo/5/0KdFeGFYeFctaVvO9jZjSn5PM= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/audit.log b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/audit.log new file mode 100644 index 0000000..cd537d8 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/audit.log @@ -0,0 +1,4 @@ +{"address": "127.0.0.1", "at": "2026-11-14T09:12:34Z", "by": "alice", "event": "vault_created"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:35Z", "by": "alice", "event": "upload", "revision": 1} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:37Z", "by": "alice", "event": "member_added", "member": "bob", "role": "editor"} +{"address": "127.0.0.1", "at": "2026-11-14T09:12:39Z", "by": "bob", "event": "upload", "revision": 2} diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/history/00000001.enc b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/history/00000001.enc new file mode 100644 index 0000000..1e0ee21 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/history/00000001.enc @@ -0,0 +1 @@ +UE1WMwHeFEluAWTZi8A0H/uPSJ1XAAAAAGgisKBN4RUyjxwaEFeuB9rML/tTuuQiZnDbloEiD2XWPMpPiOvhQ0iI24ouXQ529+qSb3vvlyBi93nWj8g9sF3Ac+hy8pbH1510h8da4T5jF303jPu4FXrpRBP57ASejYWX7VRYAS1+a6g1q4YDOADXAAEOHitbR/Sgs9+Gn1nt7k6ubDdjBNlddKIgMMr2LV/vbOI7rph35lT2KxLO8AqTx/FfbgPE1BGShSJfZLxa6XSK8uaGDcahBylJqmQfIY7EgG4sC9DW6eJuPRNVY+vdFk8QA3JyEQka1rMLTzRzkbYg/jJe9LArxTaT1UxwitGr6yEsfEJPm3DVYZQmfy0XAB5YBEY4MXh7U73RQIJ7EUw32Q46flLvtLcwGKwEU2FXaSW3sgQys6BimURv9joSkFEOZK/kpzkkBdDxlFoH8ApIavk092Vmawic64QasNUmH01uWeTET1R1eqJsty6dxvxY/ZhljSpQKGviZve9K1+Q3Sj4gY7YVnYhABmlkqjKg4QmIhiH+a2daT9ZKxgr2w+Fnx1BaB1NNk4bWpiiPguRgWfvR2qk1e3FEhfe6dU+ppNXy9/rt3+hYTBU/H7huSkitU8aNlDlwhN7k8cfv87deCbt9Tl4sztEohvR36C2FDc= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/members.json b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/members.json new file mode 100644 index 0000000..ee9c8b5 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/members.json @@ -0,0 +1,23 @@ +{ + "created_at": "2026-11-14T09:12:34Z", + "created_by": "alice", + "key_generation": 0, + "kind": "shared", + "members": { + "alice": { + "key_sig": "gOqZ7WnVUf4gQaY7uSkOe+EI7Yr1JRQNX/xYWh7x6Z1SW9AnmZ+umNpK+5Kgt1CSmSgmdGgzyePhasdCb3VfAA==", + "key_signer": "alice", + "role": "owner", + "wrapped_key": "AZXgJ1629y7+sSNRDFFrKSwzAan0Ix+A6rFclxRalehbQjs4EsoKEmy5xj8AWFzmqvpqetUt5vC+2+V5sgUO65v8DUKNkxJifS19Gbf1O5mcWl6fUnEAupW1WcbB" + }, + "bob": { + "added_at": "2026-11-14T09:12:36Z", + "added_by": "alice", + "key_sig": "zSybr/2Vz7MpaLxVgssEyvdGEelhhm59FcQJlxoJo0l96fSR5/fOcC+nEe0Bn9s1OqS7mcQLz8nq4Lu38FqqCw==", + "key_signer": "alice", + "role": "editor", + "wrapped_key": "ARVdJyKBv33DmMALW2xesKN8tRUunfRZVbUWG7M0u5Q6gXPqak4HmXbnMCeFVg10/vhJVmDHRxEq9i7gEslutDuq9yBXPMkVF3L57ozUKem7qdVXzROwEsdyiSKE" + } + }, + "name": "vLUTyGNs0Povmlz+GSCffk5SU9SteDrwxMrOwFFfcWI=" +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/meta.json b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/meta.json new file mode 100644 index 0000000..8a27e9f --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/meta.json @@ -0,0 +1,7 @@ +{ + "revision": 2, + "sha256": "dbeb60458c17bc88ecbb49c479a4ec00473286a13a565a80c64f58a0597c7a2f", + "updated_at": "2026-11-14T09:12:39Z", + "size": 1252, + "key_generation": 0 +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/vault.enc b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/vault.enc new file mode 100644 index 0000000..01c8222 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/de14496e0164d98bc0341ffb8f489d57/vault.enc @@ -0,0 +1 @@ +UE1WMwHeFEluAWTZi8A0H/uPSJ1XAAAAALLEpJ9LyXJBIctR4DNsPCzwIs1Cs4Ooo8PbwOEhO78UIQ0LsdS47+WPaC93DEPTw+JIe0qbeS0GyQH0WNGAGUDV2GnKqjOJ5uGSOq0kIZLuLx3vGvRo0lPi4IbOagBceaIqeFfJEyZGDXEdr3+bwF0w3NmPJSBwmT4a9S7MEvUtJ308ulPtBf+GY7M/lG3JLH+TYt3n8+Sh5DZcLkWY/niCCdBzSfrYy++klKIxF3j167jgaDIIEcRcr68y4VwofLkc5rNr+oVGk5p7CNMaLiaQlgPBm3xvpNQhiL+851lgXI2ND7yPtWXZDYkTABmwR8pDNYLwVkZnC3pCG+pVUtW4V6qmAMTHz1uc7r+ZuqzXIlKjZ0LXMLgb5zfzqyTZwZWPCAIvdd1grk8iTM1G6c7oSUvssD1AKJmz6TZWO+zuC9/WPFCAJMMrsW/sEQrZ80dwEFWbJcJpH6uY8bIL919d9iYxCNsFrXDYYVsiRJGAUFIWT35IOf2JEM63/VO/kdSxV3YxqRNzEMS0GJMhwpRwXXuJn2m2NGVegWDtVyxf+60DYEJ+S/qyTTZyjWFNY1eT7lbjXvI2DmtuJe/kakMsYkbw1h80lKPPWi+AZMVBNeJsD1uRU1TDxW619zMoJUCjzD8wUc6RhwJ3/ssR/r/iTTJJBp8Vkl8AJzZMH0mLyBKIlLJqp/CjjCrDXUzL3Y1HIGoMgOs1h/o1lh4UOfInjSPgYJ7TtlT4twNDXIzz6nkBPi/AF0Vvpj9L+3ceB0DQULUI06buzWTJz5O04hIjUlMBT7vpjJF6V3MvVclpCaIWGpekiQyMjwRR0sfQj7ooSNC0fWrilIqCthXHIdqUhE3DgJHWKio9YAAR4HTe57LarfcA1WMdd2qnSB56kRsxdR4Q27ZNy2ENS0d7hr0m1Bag96SgC+DKHJsfVDro2jfEafIoNPQNx3KhyA2sz7P/vaBR0WmaeLbZO2FhHyhQ7iaOTRGIlEKm/Lp+zDLQZ3OqqxDVPdZpWDOqRngRT0Fhqt9cCOuWxXJqdFQpZe9BPefvQKlpr/FnvpzGOoQMrfSwLST/ih79d4YzN3A3xCHSYPB+h3VYEeuKNkcSc8ZoVZz3P24SgNTIGIrSiIfW6cwh1AS70jdsj1k8rgjsgYVd+Yvy3W0cAF/HnpU0YB3Q+zD/0F5VrzLnm8xfpo4hNjRoJeJop1ce1hENC/oHsgY= \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/audit.log b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/audit.log new file mode 100644 index 0000000..31e7aa8 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/audit.log @@ -0,0 +1 @@ +{"address": "127.0.0.1", "at": "2026-11-14T09:12:36Z", "by": "bob", "event": "upload", "revision": 1} diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/members.json b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/members.json new file mode 100644 index 0000000..6f6db0b --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/members.json @@ -0,0 +1,9 @@ +{ + "created_at": "2026-11-14T09:12:35Z", + "kind": "personal", + "members": { + "bob": { + "role": "owner" + } + } +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/meta.json b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/meta.json new file mode 100644 index 0000000..53f5e59 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/meta.json @@ -0,0 +1,7 @@ +{ + "revision": 1, + "sha256": "40c9071accc8317815934f16085d58083ec69dd5a2fc9657348188eb1d44ab30", + "updated_at": "2026-11-14T09:12:36Z", + "size": 672, + "key_generation": 0 +} \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/vault.enc b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/vault.enc new file mode 100644 index 0000000..7fee5f9 --- /dev/null +++ b/sourceFiles/tests/fixtures/format1/store/_v2/vaults/f0b486df14d88813822845953c4e6b02/vault.enc @@ -0,0 +1 @@ +UE1WMwHwtIbfFNiIE4IoRZU8TmsCAAAAAHxGtnTrP6F5omBP03EZnhLHyzyn2FZrH+NdZ1THmEmExclsl1ELg0pAmKeBcxcDwrn0rWILYdZPnAvBw1qtatd2icSwOXDfPfONJvzO7p0LyzoikaPaq4UHD6rvVELujMytG2o/V7Mwuj4SDLksMoSkRBJJjMOdrsSabfVRSlX/B55LZNXV62QU9qix2pGtckqaGGNIXMquFtFNuAKrBomRpH3m4LLw4Vq937sHzC5AK371Bmz/+Mpm3i9Us8KZP0HRBiA0BKqo4WSGXEuaY9ZK02nDvMX9LOQNKJPigiiNKiJLRqQFvRchytlOnjU3mbzq9M55e3PPBLTqrY1ZthkVY+WjeyCz8bW8tGiCCYrYqU4M/xLpKeaL8MJX/sQeDMFHXCuCUtXXbXKx77hctRTPxMUlQC7fWReEHkRDMlqBpuYp29/plcYKqMhWNWVzQZSFEV3aIJW0KyYgDFk/s2xqMCTUr62IF9E5EOtVj3t8Hw4JjNMjdrHfM66VdT3O9Hdxv3QNwlvCj7lUoo8mj+JkAAroVNAsQPCbpZlL+a5Vv7/QOxtswCxrom5Uc4NIbHnWrLFvC7Lv4Kud9kFgCu6R7VMlllbx3GVqjx3Kirex09POEdeLImGNKV0Tew== \ No newline at end of file diff --git a/sourceFiles/tests/fixtures/make_format1.py b/sourceFiles/tests/fixtures/make_format1.py new file mode 100644 index 0000000..e88e38b --- /dev/null +++ b/sourceFiles/tests/fixtures/make_format1.py @@ -0,0 +1,181 @@ +""" +How the folder ``format1/`` beside this file was made. Not a test, and not +run by the tests: it needs the source of SecretSpace **3.1.0**. + +The conversion to the current format must be proved on accounts that the +old program really wrote, not on ones the new program pretends are old. So +this script was run once, with the 3.1.0 source on the path, and what it +left behind is kept: + + git worktree add v3.1.0 + py -3 make_format1.py /sourceFiles + +What it makes, all with SecretSpace 3.1.0 and its copy of server 2.0.0: + + a/accounts/alice on the server. Personal vault: Mail, Bank. + Vault "Team" (she owns it; bob is an editor): + Router (hers), Printer (bob's). + Vault "Solo" (hers alone, on the server): Notes. + b/accounts/bob on the server. Personal vault: Forum. Member of Team. + c/accounts/carol never on a server. Personal vault: Diary. + Vault "Box" (local only): Spare key. + store/ the server's store after all of it was synced + facts.json passwords, Secret Keys, recovery phrases, vault ids + +The Secret Key is stored under the master password on every "device" +(``secret_store.SCHEME_PASSWORD``), not with Windows DPAPI, so the fixture +opens on any machine. The Argon2id cost is the cheapest allowed, so the +tests are quick; the cost is in each file's header, as always. +""" + +import copy +import json +import os +import shutil +import sys +import threading + +SOURCE, OUT = sys.argv[1], os.path.abspath(sys.argv[2]) +sys.path.insert(0, SOURCE) +sys.path.insert(0, os.path.join(SOURCE, "tests", "sync_server")) + +import account_crypto # noqa: E402 +import account_sync # noqa: E402 +import accounts # noqa: E402 +import changelog # noqa: E402 +import secret_store # noqa: E402 +import vault # noqa: E402 +import version # noqa: E402 +import passman_server # noqa: E402 + +assert version.__version__ == "3.1.0", version.__version__ +account_crypto.ARGON2_MEMORY_KIB = 64 +account_crypto.ARGON2_PASSES = 1 +secret_store.default_scheme = lambda: secret_store.SCHEME_PASSWORD + +TOKEN = "fixture-token-that-is-long-enough" +PASSWORDS = {"alice": "correct horse battery staple", + "bob": "bob's own master password", + "carol": "carol never syncs"} + +if os.path.exists(OUT): + shutil.rmtree(OUT) +os.makedirs(OUT) + + +def device(name): + os.environ["PASSMAN_DATA_DIR"] = os.path.join(OUT, name, "data") + + +def add(account, vault_id, site, password): + file = account.vault_file(vault_id) + before = file.load() + data = copy.deepcopy(before) + vault.add_account(data, site, "me", password) + changelog.record(data, before, account.username, account.keyset, file.hex) + file.save(data) + + +def sync_all(account): + server = account_sync.logged_in(account) + try: + account_sync.reconcile_vaults(account, server) + ids = account_sync.syncable_vaults(account, server) + finally: + server.logout() + for vault_id in ids: + session = account_sync.AccountSync(account, None, vault_id) + try: + local = session.file.load() if session.file.has_file \ + else {"accounts": []} + result = session.prepare(local) + assert not result.conflicts + if session.has_work(): + session.commit(local) + finally: + session.close() + + +httpd = passman_server.ThreadingHTTPServer(("127.0.0.1", 0), + passman_server.Handler) +httpd.store = passman_server.Store(os.path.join(OUT, "store")) +httpd.token = TOKEN +os.makedirs(httpd.store.root) +threading.Thread(target=httpd.serve_forever, kwargs={"poll_interval": 0.01}, + daemon=True).start() +url = f"http://127.0.0.1:{httpd.server_address[1]}" +facts = {"made_with": version.__version__, "token": TOKEN, "url": url, + "accounts": {}} + + +def make(name, on_server=True): + new = accounts.prepare_account(name, PASSWORDS[name]) + account = new.save() + facts["accounts"][name] = { + "password": PASSWORDS[name], "secret_key": new.secret_key_text, + "phrase": new.recovery_phrase, "personal_vault": account.personal_id, + "public_key": account.info["public_key"], + "signing_key": account.signing_key, "fingerprint": account.fingerprint, + "recovery_proof": account_crypto._b64( + account_crypto.recovery_proof(account.keyset))} + if on_server: + assert account_sync.connect(account, url, "", TOKEN) == "created" + return account + + +device("a") +alice = make("alice") +add(alice, None, "Mail", "mail-password") +add(alice, None, "Bank", "bank-password") +team = alice.create_vault("Team").hex +add(alice, team, "Router", "router-password") +solo = alice.create_vault("Solo").hex +add(alice, solo, "Notes", "notes-password") +sync_all(alice) + +device("b") +bob = make("bob") +add(bob, None, "Forum", "forum-password") +sync_all(bob) + +device("a") +server = account_sync.logged_in(alice) +try: + keys = account_sync.lookup_contact(alice, server, "bob") + account_sync.share_vault(alice, server, team, keys, accounts.ROLE_EDITOR) +finally: + server.logout() +sync_all(alice) + +device("b") +sync_all(bob) +add(bob, team, "Printer", "printer-password") +sync_all(bob) + +device("a") +sync_all(alice) + +device("c") +carol = make("carol", on_server=False) +add(carol, None, "Diary", "diary-password") +box = carol.create_vault("Box").hex +add(carol, box, "Spare key", "spare-password") + +facts["vaults"] = {"Team": team, "Solo": solo, "Box": box} +facts["entries"] = { + "alice": {"personal": ["Bank", "Mail"], "Team": ["Printer", "Router"], + "Solo": ["Notes"]}, + "bob": {"personal": ["Forum"], "Team": ["Printer", "Router"]}, + "carol": {"personal": ["Diary"], "Box": ["Spare key"]}} +httpd.shutdown() +httpd.server_close() + +# Nothing the fixture needs: sessions, and the empty 2.x data folders. +shutil.rmtree(os.path.join(OUT, "store", "_v2", "sessions"), ignore_errors=True) +for name in "abc": + shutil.rmtree(os.path.join(OUT, name, "data"), ignore_errors=True) +with open(os.path.join(OUT, "facts.json"), "w", encoding="utf-8", + newline="\n") as f: + json.dump(facts, f, indent=2, sort_keys=True) + f.write("\n") +print("made", OUT) diff --git a/sourceFiles/tests/sync_server/README.md b/sourceFiles/tests/sync_server/README.md index 6b4307f..6dbd68a 100644 --- a/sourceFiles/tests/sync_server/README.md +++ b/sourceFiles/tests/sync_server/README.md @@ -1,6 +1,6 @@ # A copy of the sync server, for the tests -`passman_server.py` here is an unchanged copy of `server/passman_server.py` +`secretspace_server.py` here is an unchanged copy of `server/secretspace_server.py` from the server repositories (SecretSpace-Server-Windows and SecretSpace-Server-Linux hold the same file). `test_sync_server.py` runs it on a free port and syncs two simulated PCs through it over a real socket, so the client is tested diff --git a/sourceFiles/tests/sync_server/passman_server.py b/sourceFiles/tests/sync_server/secretspace_server.py similarity index 88% rename from sourceFiles/tests/sync_server/passman_server.py rename to sourceFiles/tests/sync_server/secretspace_server.py index 8632510..5b74ae7 100644 --- a/sourceFiles/tests/sync_server/passman_server.py +++ b/sourceFiles/tests/sync_server/secretspace_server.py @@ -46,9 +46,9 @@ #: the client's archive: getting it wrong should cost a restore, not a vault. HISTORY_LIMIT = 20 -#: A vault blob is base64 of "PMV2"||header||salt||nonce||ct, or a legacy -#: salt||nonce||ct. Below this it cannot be either, so it is a misdirected -#: request rather than a vault. +#: A 2.x vault blob is base64 of marker||header||salt||nonce||ct, or the +#: older salt||nonce||ct. Below this it cannot be either, so it is a +#: misdirected request rather than a vault. MIN_BLOB_BYTES = 28 #: Same rule the client applies, because these become directory names. @@ -65,6 +65,33 @@ _lock = threading.Lock() +# --------------------------------------------------------------------------- +# Names +# --------------------------------------------------------------------------- +#: What this server is called on the wire. +SERVICE_NAME = "secretspace-sync" +#: Settings come from the environment under this prefix (HOST, PORT, TOKEN). +ENV_PREFIX = "SECRETSPACE_" +#: The two optional headers of an upload. +HEADER_KEY_GENERATION = "X-SecretSpace-Key-Generation" +HEADER_DROP_HISTORY = "X-SecretSpace-Drop-History" + +# The project was called PassMan until November 2026. These are the names +# from then. They are only ever READ -- from a settings file written by an +# older setup, a request from an older app, an account not converted yet -- +# and never written. They go when the old apps do. +LEGACY_ENV_PREFIX = "PASSMAN_" +LEGACY_HEADER_KEY_GENERATION = "X-PassMan-Key-Generation" +LEGACY_HEADER_DROP_HISTORY = "X-PassMan-Drop-History" +LEGACY_KEYSET_MAGIC = b"PMK3" +LEGACY_VAULT_MAGIC = b"PMV3" + + +def env_setting(environ, name: str) -> str: + """A setting from the environment: the current name, else the old one.""" + return (environ.get(ENV_PREFIX + name, "").strip() + or environ.get(LEGACY_ENV_PREFIX + name, "").strip()) + def utc_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -287,12 +314,16 @@ def _key_sig(value): #: The size of a recovery copy for an account's usual keyset, for the #: made-up answer about a name that has no account (see recovery_copy). -_RECOVERY_BODY = 12 + 133 + 16 +_RECOVERY_BODY = 12 + 259 + 16 -#: The account files' layouts, from the client (account_crypto.py). -_KEYSET_MAGIC = b"PMK3" +#: The account files' layouts, from the client (account_crypto.py). Format 2 +#: is what SecretSpace 4 writes; format 1 is an account from SecretSpace 3 +#: that has not been converted yet. +_KEYSET_MAGIC = b"SSK4" +_KEYSET_VERSION = 2 _KEYSET_HEADER = 4 + 1 + 1 + 4 + 1 + 1 + 16 # magic ver kdf mem passes lanes salt -_VAULT_MAGIC = b"PMV3" +_VAULT_MAGIC = b"SSV4" +FORMAT_OLD, FORMAT_NOW = 1, 2 class V2Error(Exception): @@ -324,8 +355,9 @@ def keyset_kdf(sealed: str, recovery: bool = False) -> dict: ciphertext this server cannot and does not need to read. """ raw = _unb64(sealed, "The recovery copy" if recovery else "The keyset") - if len(raw) < _KEYSET_HEADER + 12 + 16 or raw[:4] != _KEYSET_MAGIC: - raise V2Error(400, "That is not a SecretSpace 3 keyset.") + if len(raw) < _KEYSET_HEADER + 12 + 16 or \ + raw[:4] not in (_KEYSET_MAGIC, LEGACY_KEYSET_MAGIC): + raise V2Error(400, "That is not a SecretSpace keyset.") kdf = raw[5] if kdf != (0 if recovery else 1): raise V2Error(400, "The keyset and its recovery copy are swapped.") @@ -334,6 +366,12 @@ def keyset_kdf(sealed: str, recovery: bool = False) -> dict: "salt": _b64(raw[12:28])} +def keyset_format(sealed: str) -> int: + """Which account format a sealed keyset is in (see keyset_kdf).""" + raw = _unb64(sealed, "The keyset") + return FORMAT_NOW if raw[:4] == _KEYSET_MAGIC else FORMAT_OLD + + def hash_login_key(login_key: bytes, salt: bytes = None) -> dict: salt = salt or os.urandom(16) digest = hashlib.scrypt(login_key, salt=salt, n=SCRYPT_N, r=SCRYPT_R, @@ -511,6 +549,7 @@ def create_account(self, body: dict) -> dict: "keyset": body["keyset"], "recovery": body["recovery"], "kdf": kdf, + "format": keyset_format(body["keyset"]), "personal_vault": vault_id, "disabled": False, }) @@ -644,23 +683,44 @@ def me(self, username: str) -> dict: "can_recover": bool(record.get("recovery_check")), "keyset": record["keyset"], "recovery": record["recovery"], "kdf": record["kdf"], "personal_vault": vault_id, + "format": self.account_format(record), "vaults": vaults} - def change_keyset(self, username: str, body: dict, keep_token: str) -> None: + @staticmethod + def account_format(record) -> int: + """1 for an account from SecretSpace 3 not converted yet, else 2.""" + return int((record or {}).get("format", FORMAT_OLD) or FORMAT_OLD) + + def change_keyset(self, username: str, body: dict, keep_token: str) -> bool: """ A new master password: the same keys under a new seal, and the Login Key that goes with it. Every other session of the account ends -- a password is changed when it may have leaked. + + It is also how an account from SecretSpace 3 is converted: the same + keys sealed in the new format. Returns True when this call converted + the account. A converted account never goes back: a device that + still sends the old format has an old app. """ login_key = _unb64(body.get("login_key"), "The login key", LOGIN_KEY_LEN) kdf = keyset_kdf(body.get("keyset")) + new_format = keyset_format(body["keyset"]) with _lock: record = self.account(username) + had = self.account_format(record) + if new_format < had: + raise V2Error(409, "This account was converted to the new " + "format on another device. Update " + "SecretSpace on this one.") record.update({"login": hash_login_key(login_key), "keyset": body["keyset"], "kdf": kdf, + "format": new_format, "password_changed_at": utc_now()}) + if new_format > had: + record["converted_at"] = utc_now() self._write_json(self._account_path(username), record) self._drop_expired_sessions(user_to_end=username, keep_token=keep_token) + return new_format > had def set_signing_key(self, username: str, body: dict) -> None: """ @@ -730,7 +790,8 @@ def recovery_copy(self, username: str) -> dict: filler += hmac.new(key, b"recovery|%d|" % block + username.lower().encode(), hashlib.sha256).digest() block += 1 - header = _KEYSET_MAGIC + bytes([1, 0]) + bytes(4 + 1 + 1 + 16) + header = _KEYSET_MAGIC + bytes([_KEYSET_VERSION, 0]) + \ + bytes(4 + 1 + 1 + 16) return {"username": username, "recovery": _b64(header + filler[:_RECOVERY_BODY])} @@ -766,10 +827,17 @@ def recover(self, body: dict) -> dict: raise V2Error(401, "The recovery phrase does not open this account.") with self._failure_lock: self._failures.pop(username.lower(), None) + new_format = keyset_format(body["keyset"]) + if new_format < self.account_format(record): + raise V2Error(409, "This account was converted to the new format " + "on another device. Update SecretSpace on " + "this one.") with _lock: record = self.account(username) record.update({"login": hash_login_key(login_key), "keyset": body["keyset"], "kdf": kdf, + "format": max(new_format, + self.account_format(record)), "recovery": body["recovery"], "password_changed_at": utc_now(), "recovered_at": utc_now()}) @@ -896,13 +964,58 @@ def members(self, vault_id: str) -> dict: """Who is in a vault -- every member may know.""" members = self._read_json(self._members_path(vault_id)) or {} return {"key_generation": int(members.get("key_generation", 0)), + "format": self.vault_format(members), "members": [ {"username": name, "role": m.get("role", "owner"), "added_by": m.get("added_by", ""), - "added_at": m.get("added_at", "")} + "added_at": m.get("added_at", ""), + "format": self.account_format( + self._read_json(self._account_path(name)))} for name, m in sorted(members.get("members", {}).items(), key=lambda kv: kv[0].lower())]} + # -- the format of a vault ------------------------------------------------ + # A vault is converted (format 2) once it has been stored in the new + # form. That may only happen when every member's account is converted, + # so nobody is locked out of a vault by someone else's update; and from + # then on it takes neither an upload in the old form nor a member whose + # app could not read it. + @staticmethod + def vault_format(members: dict) -> int: + return int((members or {}).get("format", FORMAT_OLD) or FORMAT_OLD) + + def _members_all_converted(self, members: dict) -> bool: + return all(self.account_format(self._read_json(self._account_path(n))) + >= FORMAT_NOW for n in (members or {}).get("members", {})) + + def check_blob(self, raw: bytes, vault_id: str, generation: int = None, + staying=None) -> int: + """check_vault_blob, plus the rules above. Returns the blob's format. + ``staying``: the members after this call, when it removes some.""" + blob_format = check_vault_blob(raw, vault_id, generation) + members = self._read_json(self._members_path(vault_id)) or {} + if blob_format < self.vault_format(members): + raise V2Error(409, "This vault was converted to the new format. " + "Update SecretSpace on this device.") + if blob_format > self.vault_format(members): + people = members if staying is None else { + "members": {n: 1 for n in staying}} + if not self._members_all_converted(people): + raise V2Error(409, "Not every member of this vault has " + "updated SecretSpace yet; it stays in the " + "old format until they have.") + return blob_format + + def note_vault_format(self, vault_id: str, blob_format: int) -> None: + """Remember that a vault is now stored in the new form.""" + if blob_format < FORMAT_NOW: + return + with _lock: + members = self._read_json(self._members_path(vault_id)) or {} + if members and self.vault_format(members) < blob_format: + members["format"] = blob_format + self._write_json(self._members_path(vault_id), members) + def put_member(self, actor: str, vault_id: str, name: str, body: dict) -> dict: """ Add an account to a vault, or change a member's role (owners only; @@ -932,6 +1045,10 @@ def put_member(self, actor: str, vault_id: str, name: str, body: dict) -> dict: wrapped = _b64(_unb64(body.get("wrapped_key"), "The vault key", WRAPPED_KEY_LEN)) key_sig = _key_sig(body.get("key_sig")) + if self.vault_format(members) > self.account_format(target): + raise V2Error(409, f"“{who}” has to update SecretSpace and " + "unlock their account once before they " + "can join this vault.") if len(people) >= MAX_MEMBERS_PER_VAULT: raise V2Error(403, f"A vault can have {MAX_MEMBERS_PER_VAULT} " "members.") @@ -1023,9 +1140,12 @@ def rotate(self, actor: str, vault_id: str, body: dict) -> dict: raw = base64.b64decode(str(blob).encode("ascii"), validate=True) except Exception: raise V2Error(400, "Blob is not valid base64.") from None - check_vault_blob(raw, vault_id, generation) + blob_format = self.check_blob(raw, vault_id, generation, + staying=staying) self.vaults.write(vault_id, str(blob).encode("ascii"), generation, drop_history=True) + if blob_format > self.vault_format(members): + members["format"] = blob_format for gone in removed: del people[gone] @@ -1071,24 +1191,27 @@ def role(self, username: str, vault_id: str): return member.get("role") if member else None -def check_vault_blob(raw: bytes, vault_id: str, generation: int = None) -> None: - """A v2 vault blob must be PMV3 and name the vault it is stored as, so a - confused client cannot put one vault's file in another's place. With - ``generation`` it must also say it is under the vault's current key: a - device that missed a key change must sync before it uploads, or it would - put back a copy that the removed members can read.""" - if raw[:4] != _VAULT_MAGIC or len(raw) < 4 + 1 + 16 + 4 + 12 + 16: - raise V2Error(400, "That is not a SecretSpace 3 vault.") +def check_vault_blob(raw: bytes, vault_id: str, generation: int = None) -> int: + """A v2 vault blob must be a SecretSpace vault and name the vault it is + stored as, so a confused client cannot put one vault's file in another's + place. With ``generation`` it must also say it is under the vault's + current key: a device that missed a key change must sync before it + uploads, or it would put back a copy that the removed members can read. + Returns the blob's format (1 = from SecretSpace 3, 2 = the current one).""" + if raw[:4] not in (_VAULT_MAGIC, LEGACY_VAULT_MAGIC) or \ + len(raw) < 4 + 1 + 16 + 4 + 12 + 16: + raise V2Error(400, "That is not a SecretSpace vault.") if raw[5:21] != bytes.fromhex(vault_id): raise V2Error(400, "That vault file belongs to another vault.") if generation is not None and \ int.from_bytes(raw[21:25], "big") != generation: raise V2Error(409, "This vault's key was changed on another device. " "Sync again to get the new key.") + return FORMAT_NOW if raw[:4] == _VAULT_MAGIC else FORMAT_OLD class Handler(BaseHTTPRequestHandler): - server_version = "PassManSync/1.0" + server_version = "SecretSpaceSync/2" # -- plumbing --------------------------------------------------------- def _send(self, status: int, payload: dict) -> None: @@ -1150,11 +1273,13 @@ def do_GET(self): # "sharing" = members, roles, key change and the audit log # (2.0.0-alpha.3). "recovery" = an account recovered with its # 12-word phrase (2.0.0-alpha.4). "signed-keys" = a signature is - # kept with every wrapped vault key (2.0.0-alpha.5). - return self._send(200, {"ok": True, "service": "passman-sync", + # kept with every wrapped vault key (2.0.0-alpha.5). "format-2" = + # accounts and vaults in the format SecretSpace 4 writes (2.1.0). + return self._send(200, {"ok": True, "service": SERVICE_NAME, "api": [1, 2], "features": ["vaults", "sharing", - "recovery", "signed-keys"]}) + "recovery", "signed-keys", + "format-2"]}) if self._is_v2(): return self._v2("GET") @@ -1260,16 +1385,19 @@ def _store_blob(self, store, name, check=None, stored=None): # Optional, and absent from clients older than password-change # support; the store then keeps what it had. - generation = self.headers.get("X-PassMan-Key-Generation") + generation = self.headers.get(HEADER_KEY_GENERATION) + if generation is None: + generation = self.headers.get(LEGACY_HEADER_KEY_GENERATION) if generation is not None: try: generation = int(generation) except ValueError: generation = -1 if generation < 0: - return self._error(400, "X-PassMan-Key-Generation must be a " + return self._error(400, "The key generation header must be a " "whole number, 0 or more.") - drop_history = self.headers.get("X-PassMan-Drop-History") == "1" + drop_history = "1" in (self.headers.get(HEADER_DROP_HISTORY), + self.headers.get(LEGACY_HEADER_DROP_HISTORY)) # One writer at a time: check-then-write has to be atomic, or two # simultaneous pushes could both pass the revision check. @@ -1396,8 +1524,9 @@ def _v2_route(self, method, parts): body = self._read_body() if body is None: return None - accounts.change_keyset(username, body, keep_token=token) - accounts.audit(None, "password_changed", username, address) + converted = accounts.change_keyset(username, body, keep_token=token) + accounts.audit(None, "account_converted" if converted + else "password_changed", username, address) return self._send(200, {}) if route == ("PUT", "me", 2) and parts[1] == "signing-key": body = self._read_body() @@ -1455,12 +1584,18 @@ def _v2_route(self, method, parts): return self._error(403, "You can read this vault but not " "change it.") generation = accounts.key_generation(vault_id) - return self._store_blob( - accounts.vaults, vault_id, - check=lambda raw: check_vault_blob(raw, vault_id, generation), - stored=lambda meta: accounts.audit( - vault_id, "upload", username, address, - revision=meta["revision"])) + seen = {} + + def check(raw): + seen["format"] = accounts.check_blob(raw, vault_id, + generation) + + def stored(meta): + accounts.note_vault_format(vault_id, seen.get("format", 0)) + accounts.audit(vault_id, "upload", username, address, + revision=meta["revision"]) + return self._store_blob(accounts.vaults, vault_id, + check=check, stored=stored) return self._error(404, "No such endpoint.") def _v2_vault_owner_call(self, method, tail, count, member, username): @@ -1545,7 +1680,7 @@ def key_sig(accounts, vault_id, actor, address, body): def load_token(args) -> str: """ - The shared secret, from --token-file, $PASSMAN_TOKEN, or server/token. + The shared secret, from --token-file, $SECRETSPACE_TOKEN, or server/token. Deliberately not a command-line --token: arguments are visible in `ps` to every user on the box. @@ -1561,7 +1696,7 @@ def load_token(args) -> str: f"{e.strerror}. Check that it exists and that the user " f"running the server can read it.") - env = os.environ.get("PASSMAN_TOKEN", "").strip() + env = env_setting(os.environ, "TOKEN") if env: return env @@ -1591,21 +1726,21 @@ def parse_port(value: str, source: str) -> int: def resolve_listen(args, environ=None): """ - Where to listen: a flag wins, then $PASSMAN_HOST / $PASSMAN_PORT, then - 127.0.0.1:8787. + Where to listen: a flag wins, then $SECRETSPACE_HOST / $SECRETSPACE_PORT, + then 127.0.0.1:8787. The environment is how the systemd unit gets them: it loads - /etc/passman/sync.env, which passman_setup.py writes, so changing the - port never means editing the unit. + /etc/secretspace/sync.env, which secretspace_setup.py writes, so changing + the port never means editing the unit. """ environ = os.environ if environ is None else environ host = args.host if host is None: - host = environ.get("PASSMAN_HOST", "").strip() or DEFAULT_HOST + host = env_setting(environ, "HOST") or DEFAULT_HOST port = args.port if port is None: - env = environ.get("PASSMAN_PORT", "").strip() - port = parse_port(env, "PASSMAN_PORT") if env else DEFAULT_PORT + env = env_setting(environ, "PORT") + port = parse_port(env, ENV_PREFIX + "PORT") if env else DEFAULT_PORT else: port = parse_port(str(port), "--port") return host, port @@ -1674,13 +1809,13 @@ def main(argv=None): here =os.path.dirname(os.path.abspath(__file__)) parser = argparse.ArgumentParser(description=__doc__.strip().split("\n")[0]) parser.add_argument("--host", - help="address to listen on (default: $PASSMAN_HOST, " + help="address to listen on (default: $SECRETSPACE_HOST, " "else 127.0.0.1, this machine only). Use 0.0.0.0 " "for every network, or one of this machine's own " "addresses (see `ip -4 addr show`) for just that " "network.") parser.add_argument("--port", type=int, - help=f"port to listen on (default: $PASSMAN_PORT, " + help=f"port to listen on (default: $SECRETSPACE_PORT, " f"else {DEFAULT_PORT})") parser.add_argument("--store", default=os.path.join(here, "store"), help="directory for the encrypted blobs") diff --git a/sourceFiles/tests/test_account_crypto.py b/sourceFiles/tests/test_account_crypto.py index ec47bf5..e8455c7 100644 --- a/sourceFiles/tests/test_account_crypto.py +++ b/sourceFiles/tests/test_account_crypto.py @@ -57,7 +57,7 @@ def test_secret_key_round_trip_and_shape(): for _ in range(50): raw = ac.new_secret_key() text = ac.format_secret_key(raw) - assert text.startswith("A3-") and len(text.replace("-", "")) == 29 + assert text.startswith("A4-") and len(text.replace("-", "")) == 29 assert [len(g) for g in text.split("-")] == [2, 5, 5, 5, 6, 6] assert ac.parse_secret_key(text) == raw @@ -84,7 +84,7 @@ def test_typos_read_as_typos_almost_always(): continue tried += 1 try: - ac.parse_secret_key("A3" + body[:i] + c + body[i + 1:]) + ac.parse_secret_key("A4" + body[:i] + c + body[i + 1:]) except ac.CryptoError: continue missed += 1 @@ -94,9 +94,10 @@ def test_typos_read_as_typos_almost_always(): @pytest.mark.parametrize("text,words", [ ("", "Enter your Secret Key"), - ("B3-AAAAA", "starts with A3"), - ("A3-AAAAA-AAAAA", "27 characters"), - ("A3-AAAA0-AAAAA-AAAAA-AAAAAA-AAAAAA", "never used"), + ("B3-AAAAA", "starts with A4"), + ("A4-AAAAA-AAAAA", "27 characters after A4-"), + ("A3-AAAAA-AAAAA", "27 characters after A3-"), + ("A4-AAAA0-AAAAA-AAAAA-AAAAAA-AAAAAA", "never used"), ]) def test_secret_key_mistakes_say_what_is_wrong(text, words): with pytest.raises(ac.CryptoError, match=words): @@ -151,7 +152,7 @@ def test_keyset_round_trip_and_header(): assert ac.open_keyset(b"a" * 32, blob) == {"x": 1} head = ac.keyset_header(blob) assert head == {"kdf": ac.KDF_ARGON2ID, "memory_kib": 64, "passes": 1, - "lanes": 1, "salt": SALT} + "lanes": 1, "salt": SALT, "format": 2} def test_wrong_key_reads_as_wrong_password_or_secret_key(): @@ -178,7 +179,7 @@ def test_an_absurd_cost_is_refused_before_any_work(cost): @pytest.mark.parametrize("mangle,words", [ - (lambda r: b"PMV2" + r[4:], "not a SecretSpace 3 account"), + (lambda r: b"PMV2" + r[4:], "not a SecretSpace account"), (lambda r: r[:4] + b"\x09" + r[5:], "newer SecretSpace"), (lambda r: r[:30], "too short"), ]) @@ -229,7 +230,8 @@ def test_fingerprint_is_six_groups_of_four_and_stable(): def test_vault_round_trip_and_header(): blob = ac.encrypt_vault(b"V" * 32, VID, 3, DATA) assert ac.decrypt_vault(b"V" * 32, blob, VID) == DATA - assert ac.vault_header(blob) == {"vault_id": VID, "key_generation": 3} + assert ac.vault_header(blob) == {"vault_id": VID, "key_generation": 3, + "format": 2} def test_a_vault_from_another_vault_is_refused(): @@ -250,11 +252,11 @@ def test_wrong_vault_key(): ac.decrypt_vault(b"W" * 32, ac.encrypt_vault(b"V" * 32, VID, 0, DATA)) -def test_a_2x_vault_is_not_mistaken_for_pmv3(): +def test_a_2x_vault_is_not_mistaken_for_an_account_vault(): import crypto_utils blob = crypto_utils.encrypt("pw", DATA, iterations=1000) blob = blob.decode() if isinstance(blob, bytes) else blob - with pytest.raises(ac.CryptoError, match="not a SecretSpace 3 vault"): + with pytest.raises(ac.CryptoError, match="not a SecretSpace vault"): ac.vault_header(blob) diff --git a/sourceFiles/tests/test_account_key_trust.py b/sourceFiles/tests/test_account_key_trust.py index 510f311..4770cf2 100644 --- a/sourceFiles/tests/test_account_key_trust.py +++ b/sourceFiles/tests/test_account_key_trust.py @@ -29,7 +29,7 @@ from sync_client import SyncError from test_account_sync import (PW, TOKEN, alice, cheap_argon2, device, # noqa: F401 - new_account, passman_server, server) + new_account, secretspace_server, server) from test_account_vaults import sync_all, sites from test_account_sharing import add_entry, share, rotate, with_server diff --git a/sourceFiles/tests/test_account_recovery.py b/sourceFiles/tests/test_account_recovery.py index 3e25fd5..434a921 100644 --- a/sourceFiles/tests/test_account_recovery.py +++ b/sourceFiles/tests/test_account_recovery.py @@ -18,7 +18,7 @@ from sync_client import SyncError from test_account_sync import (PW, TOKEN, alice, cheap_argon2, device, # noqa: F401 - new_account, passman_server, server) + new_account, secretspace_server, server) from test_account_vaults import sync_all, sites NEW_PW = "a brand new master password" diff --git a/sourceFiles/tests/test_account_sharing.py b/sourceFiles/tests/test_account_sharing.py index fe7fde8..d33119e 100644 --- a/sourceFiles/tests/test_account_sharing.py +++ b/sourceFiles/tests/test_account_sharing.py @@ -22,7 +22,7 @@ from sync_client import SyncError from test_account_sync import (PW, TOKEN, alice, cheap_argon2, device, # noqa: F401 - new_account, passman_server, server) + new_account, secretspace_server, server) from test_account_vaults import reconcile, sync_all, sync_vault, sites diff --git a/sourceFiles/tests/test_account_sync.py b/sourceFiles/tests/test_account_sync.py index afc2e78..7abbd7b 100644 --- a/sourceFiles/tests/test_account_sync.py +++ b/sourceFiles/tests/test_account_sync.py @@ -2,7 +2,7 @@ Phase 2 end to end: SecretSpace 3 accounts on a real copy of the sync server. Two "devices" are two data folders on one machine, switched with -PASSMAN_DATA_DIR. The server is the copy in sync_server/, run on a free port, +SECRETSPACE_DATA_DIR. The server is the copy in sync_server/, run on a free port, exactly as test_sync_server.py runs it for 2.x. """ @@ -21,7 +21,7 @@ import secret_store # noqa: E402 import sync # noqa: E402 import sync_client # noqa: E402 -import passman_server # noqa: E402 +import secretspace_server # noqa: E402 from account_sync import LoginFailed # noqa: E402 from sync_client import SyncError # noqa: E402 @@ -37,9 +37,9 @@ def cheap_argon2(monkeypatch): @pytest.fixture def server(tmp_path): - httpd = passman_server.ThreadingHTTPServer(("127.0.0.1", 0), - passman_server.Handler) - httpd.store = passman_server.Store(str(tmp_path / "store")) + httpd = secretspace_server.ThreadingHTTPServer(("127.0.0.1", 0), + secretspace_server.Handler) + httpd.store = secretspace_server.Store(str(tmp_path / "store")) httpd.token = TOKEN os.makedirs(httpd.store.root, exist_ok=True) thread = threading.Thread(target=httpd.serve_forever, @@ -58,7 +58,7 @@ def server(tmp_path): def device(tmp_path, monkeypatch): """device("a") makes that PC's data folder the current one.""" def use(name): - monkeypatch.setenv("PASSMAN_DATA_DIR", str(tmp_path / name / "data")) + monkeypatch.setenv("SECRETSPACE_DATA_DIR", str(tmp_path / name / "data")) use("a") return use @@ -139,9 +139,9 @@ def test_an_account_with_another_personal_vault_is_not_joined(server, device, def test_a_server_without_accounts_says_so(server, device, monkeypatch): monkeypatch.setattr(account_sync.AccountServer, "health", - lambda self: {"ok": True, "service": "passman-sync"}) + lambda self: {"ok": True, "service": "secretspace-sync"}) account = new_account().save() - with pytest.raises(SyncError, match="2.0 or newer"): + with pytest.raises(SyncError, match="2.1.0 or newer"): account_sync.connect(account, server.url, "", TOKEN) diff --git a/sourceFiles/tests/test_account_vaults.py b/sourceFiles/tests/test_account_vaults.py index fac24cb..5cfa6d0 100644 --- a/sourceFiles/tests/test_account_vaults.py +++ b/sourceFiles/tests/test_account_vaults.py @@ -21,7 +21,7 @@ # Reused from the phase 2 sync tests: the server, the "device" switch, the # cheap Argon2 settings and the two-device helpers. from test_account_sync import (PW, TOKEN, alice, cheap_argon2, device, # noqa: F401 - entry, new_account, passman_server, server) + entry, new_account, secretspace_server, server) # --------------------------------------------------------------------------- @@ -98,9 +98,9 @@ def read_file(path): def start_server(path): - httpd = passman_server.ThreadingHTTPServer(("127.0.0.1", 0), - passman_server.Handler) - httpd.store = passman_server.Store(str(path)) + httpd = secretspace_server.ThreadingHTTPServer(("127.0.0.1", 0), + secretspace_server.Handler) + httpd.store = secretspace_server.Store(str(path)) httpd.token = TOKEN os.makedirs(httpd.store.root, exist_ok=True) thread = threading.Thread(target=httpd.serve_forever, @@ -339,7 +339,7 @@ def test_a_bad_name_creates_nothing(): acc.create_vault(bad) assert acc.vault_ids() == [acc.personal_id] assert os.listdir(os.path.dirname(acc.vault_path)) == [ - acc.personal_id + ".pmv3"] + acc.personal_id + ".ssv"] def test_rename_a_vault(): @@ -731,18 +731,27 @@ def test_a_vault_never_on_a_server_is_deleted_without_asking_it(server, device, def _health_without_features(self): - return {"ok": True, "service": "passman-sync", "api": [1, 2]} + return {"ok": True, "service": "secretspace-sync", "api": [1, 2]} -@pytest.mark.parametrize("how", ["no_features_in_health", "has_vaults_false"]) +def test_a_server_older_than_this_version_needs_is_refused_before_login( + server, device, alice, monkeypatch): + """A server that does not list the current format cannot hold what this + version writes: say so, and send it nothing.""" + monkeypatch.setattr(account_sync.AccountServer, "health", + _health_without_features) + work = alice.create_vault("Work") + with pytest.raises(SyncError, match="2.1.0 or newer"): + reconcile(alice) + assert not alice.vault_record(work.hex).get("on_server") + assert not os.path.exists(os.path.join(server.accounts.vaults.root, work.hex)) + + +@pytest.mark.parametrize("how", ["has_vaults_false"]) def test_a_server_without_vaults_keeps_extra_vaults_here(server, device, alice, monkeypatch, how): - if how == "no_features_in_health": - monkeypatch.setattr(account_sync.AccountServer, "health", - _health_without_features) - else: - monkeypatch.setattr(account_sync.AccountServer, "has_vaults", - lambda self: False) + monkeypatch.setattr(account_sync.AccountServer, "has_vaults", + lambda self: False) work = alice.create_vault("Work") put_entry(alice, work.hex, "local-entry") put_entry(alice, alice.personal_id, "personal-entry") diff --git a/sourceFiles/tests/test_accounts.py b/sourceFiles/tests/test_accounts.py index 49647c8..3bbaf0c 100644 --- a/sourceFiles/tests/test_accounts.py +++ b/sourceFiles/tests/test_accounts.py @@ -51,10 +51,10 @@ 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 sorted(os.listdir(folder)) == ["account.json", "keyset.ssk", + "recovery.ssk", "vaults"] assert os.listdir(os.path.join(folder, "vaults")) == [ - acc.info["personal_vault"] + ".pmv3"] + acc.info["personal_vault"] + ".ssv"] assert accounts.list_accounts() == ["alice"] assert acc.load_vault() == {"accounts": []} @@ -78,7 +78,7 @@ 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 new.secret_key_text.startswith("A4-") assert len(new.recovery_phrase.split()) == 12 new.save() assert accounts.list_accounts() == ["alice"] @@ -227,7 +227,7 @@ def test_newer_account_format_refused(): 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 SecretSpace version"): + with pytest.raises(ValueError, match="newer SecretSpace"): accounts.unlock("alice", PW) @@ -303,8 +303,8 @@ 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"] + assert sorted(os.listdir(folder)) == ["account.json", "keyset.ssk", + "recovery.ssk", "vaults"] def test_change_password_then_new_device(scheme): @@ -377,7 +377,7 @@ def test_account_folders_are_private(): 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"): + for name in ("account.json", "keyset.ssk", "recovery.ssk"): path = os.path.join(accounts.account_dir("alice"), name) assert os.stat(path).st_mode & 0o777 == 0o600, name diff --git a/sourceFiles/tests/test_conversion.py b/sourceFiles/tests/test_conversion.py new file mode 100644 index 0000000..373bba9 --- /dev/null +++ b/sourceFiles/tests/test_conversion.py @@ -0,0 +1,1097 @@ +""" +Converting an account from SecretSpace 3 (format 1) to the current format. + +The accounts under fixtures/format1 were written by the real SecretSpace +3.1.0, and the server store beside them by its server 2.0.0 (see +fixtures/make_format1.py). Nothing here pretends to be old: every test +starts from those files. + + alice on the server. Personal: Bank, Mail. Team (hers, shared with bob + as an editor): Printer, Router. Solo (hers alone): Notes. + bob on the server. Personal: Forum. Member of Team. + carol never on a server. Personal: Diary. Box (local only): Spare key. +""" + +import base64 +import copy +import json +import os +import shutil +import sys +import threading + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "sync_server")) + +import account_crypto # noqa: E402 +import account_sync # noqa: E402 +import accounts # noqa: E402 +import changelog # noqa: E402 +import legacy_names # noqa: E402 +import recovery # noqa: E402 +import secret_store # noqa: E402 +import vault # noqa: E402 +import secretspace_server # noqa: E402 +from account_crypto import NOW, OLD, CryptoError # noqa: E402 +from account_sync import LoginFailed # noqa: E402 +from sync_client import SyncError # noqa: E402 + +FIXTURE = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "fixtures", "format1") +with open(os.path.join(FIXTURE, "facts.json"), encoding="utf-8") as _f: + FACTS = json.load(_f) +TEAM, SOLO, BOX = (FACTS["vaults"][n] for n in ("Team", "Solo", "Box")) + + +def pw(name): + return FACTS["accounts"][name]["password"] + + +@pytest.fixture(autouse=True) +def cheap_argon2(monkeypatch): + monkeypatch.setattr(account_crypto, "ARGON2_MEMORY_KIB", 64) + monkeypatch.setattr(account_crypto, "ARGON2_PASSES", 1) + # As the fixtures were made: openable on any machine. + monkeypatch.setattr(secret_store, "default_scheme", + lambda: secret_store.SCHEME_PASSWORD) + + +class World: + """The three devices and the server, copied out of the fixture.""" + + def __init__(self, root, monkeypatch): + self.root = str(root) + self._monkeypatch = monkeypatch + for name in "abc": + shutil.copytree(os.path.join(FIXTURE, name), + os.path.join(self.root, name)) + shutil.copytree(os.path.join(FIXTURE, "store"), + os.path.join(self.root, "store")) + self.server = None + + def device(self, name): + self._monkeypatch.setenv("SECRETSPACE_DATA_DIR", + os.path.join(self.root, name, "data")) + return self + + def folder(self, device, user): + return os.path.join(self.root, device, "accounts", user) + + def copy_device(self, source, target): + """A second device of the same account, as it was before anything.""" + shutil.copytree(os.path.join(FIXTURE, source), + os.path.join(self.root, target)) + self._point_at_server(target) + + def start_server(self): + httpd = secretspace_server.ThreadingHTTPServer( + ("127.0.0.1", 0), secretspace_server.Handler) + httpd.store = secretspace_server.Store(os.path.join(self.root, "store")) + httpd.token = FACTS["token"] + threading.Thread(target=httpd.serve_forever, + kwargs={"poll_interval": 0.01}, daemon=True).start() + httpd.url = f"http://127.0.0.1:{httpd.server_address[1]}" + self.server = httpd + for name in "ab": + self._point_at_server(name) + return httpd + + def _point_at_server(self, device): + """The fixture's accounts remember the address their server had.""" + if self.server is None: + return + base = os.path.join(self.root, device, "accounts") + for user in os.listdir(base): + path = os.path.join(base, user, "account.json") + with open(path, encoding="utf-8") as f: + info = json.load(f) + if info.get("server"): + info["server"]["url"] = self.server.url + with open(path, "w", encoding="utf-8") as f: + json.dump(info, f) + + def stored(self, *parts): + with open(os.path.join(self.root, "store", "_v2", *parts), "rb") as f: + return f.read() + + def stored_json(self, *parts): + return json.loads(self.stored(*parts)) + + def stop(self): + if self.server is not None: + self.server.shutdown() + self.server.server_close() + + +@pytest.fixture +def world(tmp_path, monkeypatch): + made = World(tmp_path, monkeypatch) + yield made + made.stop() + + +@pytest.fixture +def online(world): + world.start_server() + return world + + +def sites(account, vault_id=None): + return sorted(e["site"] for e in account.vault_file(vault_id).load()["accounts"]) + + +def tree(folder): + found = {} + for base, _, files in os.walk(folder): + for name in files: + path = os.path.join(base, name) + with open(path, "rb") as f: + found[os.path.relpath(path, folder).replace("\\", "/")] = f.read() + return found + + +def sync_all(account, convert=True): + """One press of Sync, as account_sync_ui runs it.""" + server = account_sync.logged_in(account) + try: + changes = account_sync.reconcile_vaults(account, server) + ids = account_sync.syncable_vaults(account, server) + data = {} + for vault_id in ids: + session = account_sync.AccountSync(account, server, vault_id) + local = session.file.load() if session.file.has_file \ + else {"accounts": []} + result = session.prepare(local) + assert not result.conflicts, result.conflicts + data[vault_id] = session.commit(local) if session.has_work() \ + else local + converted = account_sync.convert_vaults(account, server, data) \ + if convert else {} + return changes, converted + finally: + server.logout() + + +def add_entry(account, vault_id, site): + file = account.vault_file(vault_id) + before = file.load() + data = copy.deepcopy(before) + vault.add_account(data, site, "me", "pw") + changelog.record(data, before, account.username, account.keyset, file.hex, + file.fmt) + file.save(data) + + +def marker(blob) -> bytes: + return base64.b64decode(blob)[:4] + + +def log_checks(account, vault_id): + """How each change-log line of a vault checks out, by its author.""" + file = account.vault_file(vault_id) + return [(line["by"], changelog.check( + line, file.hex, account.signing_key_for(line["by"]))) + for line in changelog.lines_of(file.load())] + + +# =========================================================================== +# A. The cryptography: what a label change does, and what stays the same +# =========================================================================== +def test_the_two_formats_never_make_the_same_keys(): + salt, secret = bytes(16), bytes(range(16)) + new = account_crypto.derive_account_keys("pw", secret, "nik", salt, 1, 64, 1) + old = account_crypto.derive_account_keys("pw", secret, "nik", salt, 1, 64, 1, + OLD) + assert len({new[0], new[1], old[0], old[1]}) == 4 + + +def test_the_current_format_carries_the_current_name_everywhere(): + assert NOW.prefix.startswith("SecretSpace") + assert NOW.keyset_magic == b"SSK4" and NOW.vault_magic == b"SSV4" + assert NOW.secret_prefix == "A4" + for what in ("login key|", "wrap key", "vault name", "change"): + assert b"SecretSpace" in NOW.label(what) + assert legacy_names.LABEL_PREFIX.encode() not in NOW.label(what) + + +def test_the_old_format_is_exactly_what_secretspace_3_used(): + assert OLD.label("login key|") == b"PassMan3 login key|" + assert OLD.label("wrap|") == b"PassMan3 wrap|" + assert (OLD.keyset_magic, OLD.vault_magic) == (b"PMK3", b"PMV3") + + +def test_a_new_account_is_in_the_current_format_throughout(): + made = account_crypto.create_account("nik", "pw", 1, 64, 1) + assert marker(made["sealed_keyset"]) == b"SSK4" + assert marker(made["sealed_recovery"]) == b"SSK4" + assert made["secret_key_text"].startswith("A4-") + assert set(made["keyset"]) == {"x25519_private", "personal_vault_key", + "ed25519_seed", "recovery_proof"} + + +def test_a_new_accounts_signing_key_is_its_own_not_derived(): + """Two keysets with the same private key and different seeds sign + differently: nothing is derived with a label any more.""" + one = account_crypto.new_keyset() + two = dict(one, ed25519_seed=account_crypto._b64(os.urandom(32))) + assert account_crypto.signing_key_of(one) != account_crypto.signing_key_of(two) + + +def test_a_keyset_from_secretspace_3_gets_the_keys_it_always_had(): + """The signing key and the recovery proof that version derived are + stored from now on -- the same values, so nothing signed or registered + with them breaks.""" + for name in ("alice", "bob", "carol"): + facts = FACTS["accounts"][name] + device = {"alice": "a", "bob": "b", "carol": "c"}[name] + with open(os.path.join(FIXTURE, device, "accounts", name, + "keyset.pmk"), encoding="utf-8") as f: + sealed = f.read().strip() + secret = account_crypto.parse_secret_key(facts["secret_key"]) + keyset, _ = account_crypto.unlock_account(name, facts["password"], + secret, sealed) + assert account_crypto._b64(account_crypto.signing_key_of(keyset)) \ + == facts["signing_key"] + assert account_crypto._b64(account_crypto.recovery_proof(keyset)) \ + == facts["recovery_proof"] + assert account_crypto._b64(account_crypto.public_key_of(keyset)) \ + == facts["public_key"] + + +def test_a_converted_keyset_keeps_the_salt_so_every_device_agrees(): + facts = FACTS["accounts"]["alice"] + with open(os.path.join(FIXTURE, "a", "accounts", "alice", "keyset.pmk"), + encoding="utf-8") as f: + sealed = f.read().strip() + secret = account_crypto.parse_secret_key(facts["secret_key"]) + one = account_crypto.convert_keyset("alice", facts["password"], secret, sealed) + two = account_crypto.convert_keyset("alice", facts["password"], secret, sealed) + before = account_crypto.keyset_header(sealed) + after = account_crypto.keyset_header(one["sealed_keyset"]) + assert after["salt"] == before["salt"] and after["format"] == 2 + assert (after["passes"], after["memory_kib"]) == \ + (before["passes"], before["memory_kib"]) + assert one["login_key"] == two["login_key"] # devices converge + assert one["login_key"] != one["old_login_key"] + assert one["sealed_keyset"] != two["sealed_keyset"] # fresh nonce each + + +def test_an_emergency_kit_from_secretspace_3_still_works(): + for name in ("alice", "carol"): + old_text = FACTS["accounts"][name]["secret_key"] + assert old_text.startswith("A3-") + raw = account_crypto.parse_secret_key(old_text) + new_text = account_crypto.format_secret_key(raw) + assert new_text.startswith("A4-") + assert account_crypto.parse_secret_key(new_text) == raw + assert new_text[3:-1] == old_text[3:-1] # the same 128 bits + assert new_text[-1] != old_text[-1] or True # check character: its own + + +def test_a_secret_key_is_checked_in_the_spelling_it_was_typed_in(): + raw = bytes(range(16)) + new = account_crypto.format_secret_key(raw) + old = "A3" + new[2:-1] + account_crypto._secret_check_char(raw, OLD) + assert account_crypto.parse_secret_key(old) == raw + crossed = "A3" + new[2:] # A3 with the A4 check char + if crossed != old: + with pytest.raises(CryptoError, match="typo"): + account_crypto.parse_secret_key(crossed) + + +@pytest.mark.parametrize("fmt", [NOW, OLD]) +def test_a_wrapped_key_opens_in_the_format_it_was_wrapped_in(fmt): + keyset = account_crypto.new_keyset() + public = account_crypto.public_key_of(keyset) + vault_id, key = os.urandom(16), os.urandom(32) + wrapped = account_crypto.wrap_key(public, key, vault_id, "nik", fmt) + assert account_crypto.wrap_format(wrapped) is fmt + assert account_crypto.unwrap_key(keyset, wrapped, vault_id, "nik") == key + # Passing one format off as the other fails: the version byte is part + # of what picks the labels, and the labels are part of the key. + raw = bytearray(base64.b64decode(wrapped)) + raw[0] = (OLD if fmt is NOW else NOW).wrap_version + with pytest.raises(CryptoError): + account_crypto.unwrap_key(keyset, base64.b64encode(bytes(raw)).decode(), + vault_id, "nik") + + +@pytest.mark.parametrize("fmt", [NOW, OLD]) +def test_a_vault_is_read_in_either_format_and_says_which(fmt): + vault_id, key = os.urandom(16), os.urandom(32) + blob = account_crypto.encrypt_vault(key, vault_id, 4, {"accounts": []}, fmt) + assert marker(blob) == fmt.vault_magic + assert account_crypto.vault_header(blob) == { + "vault_id": vault_id, "key_generation": 4, "format": fmt.number} + assert account_crypto.decrypt_vault(key, blob, vault_id) == {"accounts": []} + + +def test_a_vaults_marker_cannot_be_swapped(): + """It is authenticated: an old file is not turned into a new one (or + back) by editing four bytes.""" + vault_id, key = os.urandom(16), os.urandom(32) + raw = base64.b64decode(account_crypto.encrypt_vault( + key, vault_id, 0, {"accounts": []}, OLD)) + forged = base64.b64encode(NOW.vault_magic + raw[4:]).decode() + with pytest.raises(CryptoError, match="could not be decrypted"): + account_crypto.decrypt_vault(key, forged, vault_id) + + +@pytest.mark.parametrize("fmt", [NOW, OLD]) +def test_a_vault_name_opens_whichever_format_sealed_it(fmt): + vault_id, key = os.urandom(16), os.urandom(32) + sealed = account_crypto.seal_vault_name(key, vault_id, "Team", fmt) + assert account_crypto.open_vault_name(key, vault_id, sealed) == "Team" + + +def test_a_recovery_copy_opens_in_its_own_format_only_with_its_words(): + keyset = account_crypto.new_keyset() + phrase = account_crypto.new_recovery_phrase() + copy_ = account_crypto.seal_recovery(phrase, "nik", keyset) + assert account_crypto.recover_account("nik", phrase, copy_) == keyset + with pytest.raises(CryptoError): + account_crypto.recover_account( + "nik", account_crypto.new_recovery_phrase(), copy_) + + +def test_the_fingerprint_is_made_with_the_current_name(): + facts = FACTS["accounts"]["alice"] + public = base64.b64decode(facts["public_key"]) + signing = base64.b64decode(facts["signing_key"]) + now = account_crypto.account_fingerprint(public, signing) + assert now != facts["fingerprint"] # the digits changed ... + assert len(now.replace(" ", "")) == 24 # ... not their shape + + +def test_a_change_log_line_is_checked_the_way_it_was_signed(): + keyset = account_crypto.new_keyset() + key = account_crypto.signing_key_of(keyset) + for fmt in (NOW, OLD): + data, before = {"accounts": []}, {"accounts": []} + vault.add_account(data, "Mail", "me", "pw") + changelog.record(data, before, "nik", keyset, "ab" * 16, fmt) + line = changelog.lines_of(data)[0] + assert ("v" in line) == (fmt is NOW) + assert changelog.check(line, "ab" * 16, key) == changelog.OK + # Told it was signed the other way, it no longer checks out. + other = dict(line) + if fmt is NOW: + del other["v"] + else: + other["v"] = 2 + assert changelog.check(other, "ab" * 16, key) == changelog.BAD + + +# =========================================================================== +# B. On the device +# =========================================================================== +def test_an_old_account_is_not_opened_unconverted(world): + world.device("c") + with pytest.raises(accounts.NeedsConversion): + accounts.unlock("carol", pw("carol")) + assert accounts.account_format("carol") == 1 + + +def test_a_wrong_password_is_a_wrong_password_not_a_conversion(world): + world.device("c") + with pytest.raises(CryptoError, match="Wrong master password"): + accounts.unlock("carol", "not it") + + +def test_asking_changes_nothing(world): + world.device("c") + before = tree(world.folder("c", "carol")) + with pytest.raises(accounts.NeedsConversion): + accounts.unlock("carol", pw("carol")) + assert tree(world.folder("c", "carol")) == before + + +def test_conversion_needs_the_right_password(world): + world.device("c") + before = tree(world.folder("c", "carol")) + with pytest.raises(CryptoError, match="Wrong master password"): + accounts.convert("carol", "not it") + assert tree(world.folder("c", "carol")) == before + + +def test_a_converted_account_has_only_files_of_the_current_format(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + folder = world.folder("c", "carol") + assert sorted(os.listdir(folder)) == ["account.json", "keyset.ssk", + "recovery.ssk", "vaults"] + assert sorted(os.listdir(os.path.join(folder, "vaults"))) == sorted( + [account.personal_id + ".ssv", BOX + ".ssv"]) + for name in os.listdir(os.path.join(folder, "vaults")): + with open(os.path.join(folder, "vaults", name), encoding="utf-8") as f: + assert marker(f.read()) == b"SSV4" + with open(os.path.join(folder, "keyset.ssk"), encoding="utf-8") as f: + assert marker(f.read()) == b"SSK4" + assert accounts.account_format("carol") == 2 + + +def test_every_entry_is_still_there_after_a_conversion(world): + world.device("c") + accounts.convert("carol", pw("carol")) + account = accounts.unlock("carol", pw("carol")) + assert sites(account) == ["Diary"] + assert sites(account, BOX) == ["Spare key"] + assert account.vault_file(BOX).name == "Box" + entry = account.vault_file().load()["accounts"][0] + assert entry["password"] == "diary-password" + + +def test_the_accounts_identity_is_unchanged_by_a_conversion(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + facts = FACTS["accounts"]["carol"] + assert account.signing_key == facts["signing_key"] + assert account.info["public_key"] == facts["public_key"] + assert account.personal_id == facts["personal_vault"] + assert account_crypto._b64(account_crypto.recovery_proof(account.keyset)) \ + == facts["recovery_proof"] + + +def test_the_old_change_log_still_checks_out_after_a_conversion(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + assert log_checks(account, None) == [("carol", changelog.OK)] + add_entry(account, None, "New one") + assert log_checks(account, None) == [("carol", changelog.OK)] * 2 + lines = changelog.lines_of(account.vault_file().load()) + assert ["v" in line for line in lines] == [False, True] + + +def test_a_local_vaults_key_is_wrapped_again_in_the_current_format(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + assert account.vault_format(BOX) is NOW + assert account_crypto.wrap_format( + account.vault_record(BOX)["wrapped_key"]) is NOW + + +def test_the_secret_key_is_stored_the_current_way_after_a_conversion(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + assert secret_store.record_version(account.info["secret_key"]) == 2 + with open(os.path.join(FIXTURE, "c", "accounts", "carol", "account.json"), + encoding="utf-8") as f: + assert secret_store.record_version(json.load(f)["secret_key"]) == 1 + # ... and bound with the current name: the old binding no longer opens it. + stale = dict(account.info["secret_key"]) + del stale["v"] + with pytest.raises(CryptoError): + secret_store.unprotect(stale, "carol", pw("carol")) + + +def test_the_emergency_kit_can_be_typed_at_the_conversion(world): + """A device that cannot read its stored Secret Key asks for the kit; the + one printed by SecretSpace 3 is the one the user has.""" + world.device("c") + path = os.path.join(world.folder("c", "carol"), "account.json") + with open(path, encoding="utf-8") as f: + info = json.load(f) + info["secret_key"] = None + with open(path, "w", encoding="utf-8") as f: + json.dump(info, f) + with pytest.raises(accounts.SecretKeyNeeded): + accounts.unlock("carol", pw("carol")) + with pytest.raises(accounts.NeedsConversion): + accounts.unlock("carol", pw("carol"), FACTS["accounts"]["carol"]["secret_key"]) + account = accounts.convert("carol", pw("carol"), + FACTS["accounts"]["carol"]["secret_key"]) + assert sites(account) == ["Diary"] + assert sites(accounts.unlock("carol", pw("carol"))) == ["Diary"] + + +def test_converting_twice_is_refused(world): + world.device("c") + accounts.convert("carol", pw("carol")) + with pytest.raises(ValueError, match="already converted"): + accounts.convert("carol", pw("carol")) + + +def test_an_account_that_was_never_on_a_server_has_nothing_to_tell_one(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + assert not account.server_conversion_pending + assert account.old_login_key is None + + +def test_a_copy_can_be_saved_first_and_is_the_account_as_it_was(world, tmp_path): + world.device("c") + before = tree(world.folder("c", "carol")) + target = tmp_path / "my backups" + target.mkdir() + made = accounts.copy_account_folder("carol", str(target)) + assert tree(made) == before + with pytest.raises(FileExistsError): + accounts.copy_account_folder("carol", str(target)) + + +# --- stopped half-way --------------------------------------------------------- +@pytest.mark.parametrize("stop_at", [1, 2, 3, 4, 5]) +def test_a_conversion_stopped_before_its_last_write_leaves_the_old_account_whole( + world, monkeypatch, stop_at): + """Whatever write it dies on -- two vaults, the recovery copy, the + keyset, account.json itself -- the old account still opens the old way, + nothing of the new format is left, and trying again works.""" + world.device("c") + before = tree(world.folder("c", "carol")) + real, calls = accounts._write_text, {"n": 0} + + def dying(path, text): + calls["n"] += 1 + if calls["n"] == stop_at: + raise OSError("disk full") + return real(path, text) + + monkeypatch.setattr(accounts, "_write_text", dying) + with pytest.raises(OSError): + accounts.convert("carol", pw("carol")) + monkeypatch.setattr(accounts, "_write_text", real) + assert tree(world.folder("c", "carol")) == before + account = accounts.convert("carol", pw("carol")) + assert sites(account) == ["Diary"] and sites(account, BOX) == ["Spare key"] + + +def test_a_conversion_stopped_after_its_last_write_is_finished_at_the_next_unlock( + world, monkeypatch): + world.device("c") + monkeypatch.setattr(accounts, "_remove_old_files", lambda username: None) + accounts.convert("carol", pw("carol")) + monkeypatch.undo() + world.device("c") + folder = world.folder("c", "carol") + assert "keyset.pmk" in os.listdir(folder) # the leftovers + account = accounts.unlock("carol", pw("carol")) + assert sorted(os.listdir(folder)) == ["account.json", "keyset.ssk", + "recovery.ssk", "vaults"] + assert all(name.endswith(".ssv") or name.endswith(".ssv.bak") + for name in os.listdir(os.path.join(folder, "vaults"))) + assert sites(account) == ["Diary"] + + +def test_a_local_vault_that_cannot_be_read_stops_the_conversion_untouched(world): + world.device("c") + path = os.path.join(world.folder("c", "carol"), "vaults", BOX + ".pmv3") + with open(path, "w", encoding="utf-8") as f: + f.write("AAAA") + before = tree(world.folder("c", "carol")) + with pytest.raises(CryptoError, match="nothing was converted"): + accounts.convert("carol", pw("carol")) + assert tree(world.folder("c", "carol")) == before + + +def test_a_vault_the_server_has_may_be_unreadable_here_and_is_fetched_again(online): + online.device("a") + path = os.path.join(online.folder("a", "alice"), "vaults", SOLO + ".pmv3") + with open(path, "w", encoding="utf-8") as f: + f.write("AAAA") + account = accounts.convert("alice", pw("alice")) + assert not account.vault_file(SOLO).has_file + sync_all(account) + assert sites(account, SOLO) == ["Notes"] + + +# --- the recovery copy -------------------------------------------------------- +def test_the_recovery_copy_is_left_for_the_12_words(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + assert account.recovery_converted is False + with open(os.path.join(world.folder("c", "carol"), "recovery.ssk"), + encoding="utf-8") as f: + assert marker(f.read()) == b"PMK3" # as it was, under its new name + assert accounts.check_recovery_phrase("carol", + FACTS["accounts"]["carol"]["phrase"]) + + +def test_typing_the_12_words_converts_the_recovery_copy_under_the_same_words(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + phrase = FACTS["accounts"]["carol"]["phrase"] + with pytest.raises(CryptoError): + account.convert_recovery(account_crypto.new_recovery_phrase()) + assert account.recovery_converted is False + sealed = account.convert_recovery(phrase) + assert marker(sealed) == b"SSK4" and account.recovery_converted is True + assert accounts.check_recovery_phrase("carol", phrase) + assert account_crypto.recover_account("carol", phrase, sealed) == account.keyset + + +def test_a_new_accounts_recovery_copy_needs_no_conversion(world): + world.device("z") + account = accounts.prepare_account("zoe", "a password").save() + assert account.recovery_converted is True + + +def test_a_forgotten_password_on_an_unconverted_account_recovers_and_converts(world): + """No server: the 12 words open the old recovery copy, and the account + comes back converted, with every vault.""" + world.device("c") + opened = recovery.open_with_phrase("carol", + FACTS["accounts"]["carol"]["phrase"]) + pending = recovery.prepare(opened, "a new master password") + account, data = pending.save() + assert accounts.account_format("carol") == 2 + assert sorted(e["site"] for e in data["accounts"]) == ["Diary"] + again = accounts.unlock("carol", "a new master password", + pending.secret_key_text) + assert sites(again, BOX) == ["Spare key"] + assert again.recovery_converted is True + assert sorted(os.listdir(world.folder("c", "carol"))) == [ + "account.json", "keyset.ssk", "recovery.ssk", "vaults"] + + +# =========================================================================== +# C. With the server +# =========================================================================== +def test_the_server_is_told_at_the_first_sync(online): + online.device("a") + account = accounts.convert("alice", pw("alice")) + assert account.server_conversion_pending and account.old_login_key + assert online.stored_json("accounts", "alice.json").get("format", 1) == 1 + sync_all(account) + record = online.stored_json("accounts", "alice.json") + assert record["format"] == 2 and marker(record["keyset"]) == b"SSK4" + assert not account.server_conversion_pending + assert account.old_login_key is None + # ... and it is remembered: the next unlock has nothing left to tell. + again = accounts.unlock("alice", pw("alice")) + assert not again.server_conversion_pending and again.old_login_key is None + sync_all(again) + + +def test_the_conversion_waits_for_the_server_across_a_restart(online): + """Converted offline, locked, unlocked: the old login key is there again + when the server is finally reached.""" + online.device("a") + accounts.convert("alice", pw("alice")) + account = accounts.unlock("alice", pw("alice")) + assert account.server_conversion_pending and account.old_login_key + sync_all(account) + assert online.stored_json("accounts", "alice.json")["format"] == 2 + + +def test_the_old_login_key_no_longer_works_once_the_server_is_told(online): + online.device("a") + account = accounts.convert("alice", pw("alice")) + old = account.old_login_key + sync_all(account) + server = account_sync.AccountServer(online.server.url) + with pytest.raises(LoginFailed): + server.login("alice", old) + + +def test_the_personal_vault_goes_to_the_server_in_the_current_format(online): + online.device("a") + account = accounts.convert("alice", pw("alice")) + assert marker(online.stored("vaults", account.personal_id, "vault.enc")) \ + == b"PMV3" + sync_all(account) + assert marker(online.stored("vaults", account.personal_id, "vault.enc")) \ + == b"SSV4" + assert sites(account) == ["Bank", "Mail"] + + +def test_a_vault_of_ones_own_converts_at_the_first_sync(online): + """Solo has one member, alice: it gets a new key in the current format + as soon as she has converted.""" + online.device("a") + account = accounts.convert("alice", pw("alice")) + assert account.vault_format(SOLO) is OLD + _, converted = sync_all(account) + assert SOLO in converted + assert account.vault_format(SOLO) is NOW + assert marker(online.stored("vaults", SOLO, "vault.enc")) == b"SSV4" + members = online.stored_json("vaults", SOLO, "members.json") + assert members["format"] == 2 and members["key_generation"] == 1 + assert sites(account, SOLO) == ["Notes"] + + +def test_a_shared_vault_waits_for_every_member(online): + """bob has not updated: Team stays as it is, and both of them still + read and write it.""" + online.device("a") + alice = accounts.convert("alice", pw("alice")) + _, converted = sync_all(alice) + assert TEAM not in converted and alice.vault_format(TEAM) is OLD + assert marker(online.stored("vaults", TEAM, "vault.enc")) == b"PMV3" + assert online.stored_json("vaults", TEAM, "members.json").get("format", 1) == 1 + + add_entry(alice, TEAM, "Scanner") # she writes it the old way + sync_all(alice) + assert marker(online.stored("vaults", TEAM, "vault.enc")) == b"PMV3" + line = changelog.lines_of(alice.vault_file(TEAM).load())[-1] + assert "v" not in line # so bob's app can check it + + +def test_a_member_who_has_not_updated_reads_what_a_converted_owner_wrote(online): + """The blob alice's SecretSpace 4 uploaded for Team is exactly what + SecretSpace 3 expects: its marker, its labels, a line it can check.""" + online.device("a") + alice = accounts.convert("alice", pw("alice")) + sync_all(alice) + add_entry(alice, TEAM, "Scanner") + sync_all(alice) + blob = online.stored("vaults", TEAM, "vault.enc").decode("ascii") + # Opened the way 3.1.0 does: its own key, the old marker, the old labels. + with open(os.path.join(FIXTURE, "b", "accounts", "bob", "account.json"), + encoding="utf-8") as f: + bob_info = json.load(f) + with open(os.path.join(FIXTURE, "b", "accounts", "bob", "keyset.pmk"), + encoding="utf-8") as f: + bob_keyset, _ = account_crypto.unlock_account( + "bob", pw("bob"), + account_crypto.parse_secret_key(FACTS["accounts"]["bob"]["secret_key"]), + f.read().strip()) + wrapped = bob_info["vaults"][TEAM]["wrapped_key"] + assert account_crypto.wrap_format(wrapped) is OLD + key = account_crypto.unwrap_key(bob_keyset, wrapped, bytes.fromhex(TEAM), "bob") + assert marker(blob) == b"PMV3" + data = account_crypto.decrypt_vault(key, blob, bytes.fromhex(TEAM)) + assert sorted(e["site"] for e in data["accounts"]) == [ + "Printer", "Router", "Scanner"] + last = changelog.lines_of(data)[-1] + old_message = json.dumps( + ["PassMan3 change", TEAM, last["id"], last["at"], last["by"], + last["action"], last["entry"], last["title"], list(last["fields"]), + int(last["count"])], separators=(",", ":"), ensure_ascii=True).encode() + assert account_crypto.verify( + base64.b64decode(FACTS["accounts"]["alice"]["signing_key"]), + old_message, last["sig"]) + + +def test_the_shared_vault_converts_once_the_last_member_has(online): + online.device("a") + alice = accounts.convert("alice", pw("alice")) + sync_all(alice) + online.device("b") + bob = accounts.convert("bob", pw("bob")) + sync_all(bob) # bob is an editor: no change + assert marker(online.stored("vaults", TEAM, "vault.enc")) == b"PMV3" + add_entry(bob, TEAM, "Scanner") + sync_all(bob) + + online.device("a") + alice = accounts.unlock("alice", pw("alice")) + _, converted = sync_all(alice) + assert TEAM in converted + assert alice.vault_format(TEAM) is NOW + assert marker(online.stored("vaults", TEAM, "vault.enc")) == b"SSV4" + members = online.stored_json("vaults", TEAM, "members.json") + assert members["format"] == 2 + for name in ("alice", "bob"): + assert account_crypto.wrap_format( + members["members"][name]["wrapped_key"]) is NOW + assert sites(alice, TEAM) == ["Printer", "Router", "Scanner"] + + online.device("b") + bob = accounts.unlock("bob", pw("bob")) + changes, _ = sync_all(bob) + assert changes.rekeyed == ["Team"] + assert bob.vault_format(TEAM) is NOW + assert sites(bob, TEAM) == ["Printer", "Router", "Scanner"] + add_entry(bob, TEAM, "Camera") + sync_all(bob) + online.device("a") + sync_all(alice) + assert sites(alice, TEAM) == ["Camera", "Printer", "Router", "Scanner"] + # Every line, old and new, by either of them, still checks out. + assert set(log_checks(alice, TEAM)) == {("alice", changelog.OK), + ("bob", changelog.OK)} + + +def test_trust_between_members_survives_both_conversions(online): + """bob took Team's key from alice before; he takes its new key from her + after, without comparing fingerprints again: her signing key is the + same key.""" + online.device("b") + bob = accounts.convert("bob", pw("bob")) + sync_all(bob) + online.device("a") + alice = accounts.convert("alice", pw("alice")) + sync_all(alice) # converts Team + online.device("b") + bob = accounts.unlock("bob", pw("bob")) + seen = bob.contact("alice") + assert account_crypto._b64(seen["signing_key"]) == \ + FACTS["accounts"]["alice"]["signing_key"] + changes, _ = sync_all(bob) # no KeyNotTrusted + assert changes.rekeyed == ["Team"] + + +def test_two_devices_of_one_account_converge_without_talking(online): + """Each converts on its own; whichever syncs first tells the server, + and the other simply finds its new login key accepted.""" + online.copy_device("a", "a2") + online.device("a") + first = accounts.convert("alice", pw("alice")) + online.device("a2") + second = accounts.convert("alice", pw("alice")) + assert first.login_key == second.login_key + assert second.server_conversion_pending + online.device("a") + sync_all(first) + online.device("a2") + changes, _ = sync_all(second) + assert not second.server_conversion_pending + assert sites(second) == ["Bank", "Mail"] + assert sites(second, SOLO) == ["Notes"] # under Solo's new key + assert "Solo" in changes.rekeyed + + +def test_a_second_device_that_converts_later_still_gets_in(online): + online.copy_device("a", "a2") + online.device("a") + sync_all(accounts.convert("alice", pw("alice"))) + online.device("a2") + second = accounts.convert("alice", pw("alice")) + sync_all(second) + assert sites(second, TEAM) == ["Printer", "Router"] + + +def test_a_converted_account_signs_in_on_a_new_device(online): + online.device("a") + sync_all(accounts.convert("alice", pw("alice"))) + online.device("new") + account, data = account_sync.sign_in( + online.server.url, "", "alice", pw("alice"), + FACTS["accounts"]["alice"]["secret_key"]) # the old kit + assert sorted(e["site"] for e in data["accounts"]) == ["Bank", "Mail"] + assert accounts.account_format("alice") == 2 + assert sites(account, TEAM) == ["Printer", "Router"] + + +def test_signing_in_to_an_account_nobody_converted_converts_it(online): + """A new device with SecretSpace 4, an account still as 3 left it on the + server: the sign-in converts it there and then.""" + online.device("new") + account, data = account_sync.sign_in( + online.server.url, "", "bob", pw("bob"), + FACTS["accounts"]["bob"]["secret_key"]) + assert sorted(e["site"] for e in data["accounts"]) == ["Forum"] + record = online.stored_json("accounts", "bob.json") + assert record["format"] == 2 and marker(record["keyset"]) == b"SSK4" + assert not account.server_conversion_pending + assert sites(accounts.unlock("bob", pw("bob")), TEAM) == ["Printer", "Router"] + # bob's first device, converting later, finds the server already done. + online.device("b") + first = accounts.convert("bob", pw("bob")) + sync_all(first) + assert not first.server_conversion_pending + + +def test_signing_in_with_a_wrong_password_is_still_just_wrong(online): + online.device("new") + with pytest.raises(LoginFailed, match="Wrong username"): + account_sync.sign_in(online.server.url, "", "bob", "not it", + FACTS["accounts"]["bob"]["secret_key"]) + assert online.stored_json("accounts", "bob.json").get("format", 1) == 1 + + +def test_signing_in_over_an_unconverted_copy_on_this_pc_converts_it(online): + """The password was changed on another (updated) device; this PC still + has the account as SecretSpace 3 left it.""" + online.device("new") + account_sync.sign_in(online.server.url, "", "bob", pw("bob"), + FACTS["accounts"]["bob"]["secret_key"]) + account = accounts.unlock("bob", pw("bob")) + account_sync.change_password(account, "bob's second password") + + online.device("b") + with pytest.raises(accounts.NeedsConversion): + accounts.unlock("bob", pw("bob")) # the old one still opens it + # (Under the password scheme the stored Secret Key is sealed with the + # old password, so the kit is typed; Windows reads it back by itself.) + account, _ = account_sync.sign_in(online.server.url, "", "bob", + "bob's second password", + FACTS["accounts"]["bob"]["secret_key"]) + assert accounts.account_format("bob") == 2 + assert sites(account) == ["Forum"] + assert sites(account, TEAM) == ["Printer", "Router"] + assert sorted(os.listdir(online.folder("b", "bob"))) == [ + "account.json", "keyset.ssk", "recovery.ssk", "vaults"] + + +def test_the_recovery_copy_reaches_the_server_when_the_words_are_typed(online): + online.device("a") + account = accounts.convert("alice", pw("alice")) + sync_all(account) + assert marker(online.stored_json("accounts", "alice.json")["recovery"]) \ + == b"PMK3" + sealed = account.convert_recovery(FACTS["accounts"]["alice"]["phrase"]) + server = account_sync.logged_in(account) + try: + server.put_recovery(sealed) + finally: + server.logout() + assert marker(online.stored_json("accounts", "alice.json")["recovery"]) \ + == b"SSK4" + + +def test_converting_the_recovery_copy_tells_the_server_first(online): + """recovery.convert_copy, as the window runs it: the same words, the + server and this PC in step.""" + online.device("a") + account = accounts.convert("alice", pw("alice")) + sync_all(account) + with pytest.raises(CryptoError): + recovery.convert_copy(account, account_crypto.new_recovery_phrase()) + assert account.recovery_converted is False + recovery.convert_copy(account, FACTS["accounts"]["alice"]["phrase"]) + assert account.recovery_converted is True + here = accounts.read_recovery_copy("alice") + assert online.stored_json("accounts", "alice.json")["recovery"] == here + assert marker(here) == b"SSK4" + + +def test_the_recovery_copy_is_not_converted_here_if_the_server_cannot_be_told(online): + online.device("a") + account = accounts.convert("alice", pw("alice")) + sync_all(account) + online.stop() + online.server = None + with pytest.raises(SyncError): + recovery.convert_copy(account, FACTS["accounts"]["alice"]["phrase"]) + assert account.recovery_converted is False # still the copy the server has + + +def test_converting_the_recovery_copy_of_a_local_account_needs_no_server(world): + world.device("c") + account = accounts.convert("carol", pw("carol")) + recovery.convert_copy(account, FACTS["accounts"]["carol"]["phrase"]) + assert account.recovery_converted is True + + +def test_a_converted_account_recovers_with_its_old_words_on_a_new_device(online): + """The copy on the server is still the old one; the 12 words open it, + and the proof the server checks is the one it always had.""" + online.device("a") + sync_all(accounts.convert("alice", pw("alice"))) + online.device("new") + opened = recovery.open_with_phrase("alice", + FACTS["accounts"]["alice"]["phrase"], + online.server.url) + pending = recovery.prepare(opened, "alice's new password") + account, data = pending.save() + assert sorted(e["site"] for e in data["accounts"]) == ["Bank", "Mail"] + record = online.stored_json("accounts", "alice.json") + assert marker(record["recovery"]) == b"SSK4" and record["format"] == 2 + + +def test_an_unconverted_account_recovers_onto_a_new_device_converted(online): + online.device("new") + opened = recovery.open_with_phrase("bob", FACTS["accounts"]["bob"]["phrase"], + online.server.url) + pending = recovery.prepare(opened, "bob's new password") + account, data = pending.save() + assert sorted(e["site"] for e in data["accounts"]) == ["Forum"] + assert online.stored_json("accounts", "bob.json")["format"] == 2 + assert accounts.account_format("bob") == 2 + + +def test_a_server_too_old_for_this_version_is_named_and_nothing_is_sent( + online, monkeypatch): + online.device("a") + account = accounts.convert("alice", pw("alice")) + monkeypatch.setattr(account_sync.AccountServer, "health", lambda self: { + "ok": True, "service": "passman-sync", "api": [1, 2], + "features": ["vaults", "sharing", "recovery", "signed-keys"]}) + before = tree(os.path.join(online.root, "store")) + with pytest.raises(SyncError, match="2.1.0 or newer"): + sync_all(account) + assert tree(os.path.join(online.root, "store")) == before + assert account.server_conversion_pending # still to be told + + +def test_a_new_vault_made_after_the_conversion_is_in_the_current_format(online): + online.device("a") + account = accounts.convert("alice", pw("alice")) + sync_all(account) + made = account.create_vault("Fresh") + add_entry(account, made.hex, "Thing") + sync_all(account) + assert account.vault_format(made.hex) is NOW + assert marker(online.stored("vaults", made.hex, "vault.enc")) == b"SSV4" + assert online.stored_json("vaults", made.hex, "members.json")["format"] == 2 + + +def test_a_member_added_to_an_unconverted_vault_gets_a_key_their_app_can_open(online): + """alice (converted) shares Solo-like vault while it is still old: the + key is wrapped the old way, as the vault is.""" + online.device("a") + alice = accounts.convert("alice", pw("alice")) + sync_all(alice, convert=False) # Solo not converted yet + assert alice.vault_format(SOLO) is OLD + server = account_sync.logged_in(alice) + try: + keys = account_sync.lookup_contact(alice, server, "bob") + account_sync.share_vault(alice, server, SOLO, keys, accounts.ROLE_READER) + finally: + server.logout() + members = online.stored_json("vaults", SOLO, "members.json") + assert account_crypto.wrap_format( + members["members"]["bob"]["wrapped_key"]) is OLD + # ... and now Solo has a member who has not updated: it waits. + _, converted = sync_all(alice) + assert SOLO not in converted + + +def test_the_sync_base_is_converted_with_its_vault(online): + """Without its base the next sync could not tell a deletion from an + addition; it must come through the conversion readable.""" + online.device("a") + account = accounts.convert("alice", pw("alice")) + for vault_id in (None, TEAM, SOLO): + base = account.vault_file(vault_id).load_base() + assert base is not None, vault_id + assert sorted(e["site"] for e in base["accounts"]) == sites(account, vault_id) + vaults = os.path.join(online.folder("a", "alice"), "vaults") + assert sorted(n for n in os.listdir(vaults) if n.endswith(".base")) == sorted( + v + ".ssv.base" for v in (account.personal_id, TEAM, SOLO)) + + +def test_changing_a_key_while_a_member_has_not_updated_keeps_the_old_format(online): + """alice (converted) changes Team's key by hand -- Members -> Change key. + bob is still on SecretSpace 3, so the new key, its signature, the name + and the vault are all made the way his app reads them.""" + online.device("a") + alice = accounts.convert("alice", pw("alice")) + sync_all(alice) + server = account_sync.logged_in(alice) + try: + account_sync.change_key(alice, server, TEAM, + alice.vault_file(TEAM).load()) + finally: + server.logout() + assert alice.vault_format(TEAM) is OLD + members = online.stored_json("vaults", TEAM, "members.json") + assert members["key_generation"] == 1 and members.get("format", 1) == 1 + assert marker(online.stored("vaults", TEAM, "vault.enc")) == b"PMV3" + wrapped = members["members"]["bob"]["wrapped_key"] + assert account_crypto.wrap_format(wrapped) is OLD + # bob's old app: the key opens with the old labels, and the signature on + # it checks out as that app builds the message. + with open(os.path.join(FIXTURE, "b", "accounts", "bob", "keyset.pmk"), + encoding="utf-8") as f: + bob_keyset, _ = account_crypto.unlock_account( + "bob", pw("bob"), + account_crypto.parse_secret_key(FACTS["accounts"]["bob"]["secret_key"]), + f.read().strip()) + key = account_crypto.unwrap_key(bob_keyset, wrapped, bytes.fromhex(TEAM), "bob") + blob = online.stored("vaults", TEAM, "vault.enc").decode("ascii") + assert sorted(e["site"] for e in account_crypto.decrypt_vault( + key, blob, bytes.fromhex(TEAM))["accounts"]) == ["Printer", "Router"] + import hashlib + old_message = json.dumps( + ["PassMan3 vault key", TEAM, 1, "bob", + hashlib.sha256(wrapped.encode("ascii")).hexdigest(), "alice"], + separators=(",", ":"), ensure_ascii=True).encode("ascii") + assert account_crypto.verify( + base64.b64decode(FACTS["accounts"]["alice"]["signing_key"]), + old_message, members["members"]["bob"]["key_sig"]) + assert account_crypto.open_vault_name(key, bytes.fromhex(TEAM), + members["name"]) == "Team" diff --git a/sourceFiles/tests/test_old_name.py b/sourceFiles/tests/test_old_name.py new file mode 100644 index 0000000..a3c9e01 --- /dev/null +++ b/sourceFiles/tests/test_old_name.py @@ -0,0 +1,149 @@ +""" +The project was called PassMan until November 2026. In the program the old +name may stay in exactly these places: + +* ``legacy_names.py`` -- the one module that holds the names an older + version wrote, so they can be read and converted; +* the sentences that say why (listed below, one per place); +* the clean-up of an older install: the Windows installer removing the old + program, the Linux package replacing the old package. + +Anything else is a leftover of the rename, and this test names the line. It +also checks the other half of the rule: the old names are read, and written +only where the format requires it. +""" + +import os +import re + +import account_crypto +import legacy_names + +HERE = os.path.dirname(os.path.abspath(__file__)) +SOURCE = os.path.dirname(HERE) +REPO = os.path.dirname(SOURCE) +OLD = re.compile(r"passman|PMV[23]|PMK3|\.pmk|\.pmv3", re.IGNORECASE) + +#: (file, text the line contains): prose and clean-up, nothing a key or a +#: file of the current format is made with. The Windows and the Linux app +#: share this test; each has the files of its own. +ALLOWED = [ + ("build_deb.py", 'OLD_PACKAGE = "passman"'), + ("build_deb.py", "The package was called passman before the rename"), + ("account_crypto.py", "when the project was still called PassMan"), + ("crypto_utils.py", "from when the project was called PassMan"), + ("version.py", 'OLDER_VAULT_FORMATS = ("PMV3", "PMV2")'), + ("SecretSpace.iss", "rename (as PassMan) has them in %APPDATA%\\PassMan"), + ("SecretSpace.iss", 'Name: "{app}\\PassMan.exe"'), + ("SecretSpace.iss", 'Name: "{autoprograms}\\PassMan.lnk"'), + ("SecretSpace.iss", 'Name: "{autodesktop}\\PassMan.lnk"'), + ("SecretSpace.iss", "OldHome := ExpandConstant('{userappdata}\\PassMan');"), + ("SecretSpace.iss", "called PassMan then), Setup can move"), +] + + +def program_files(): + """Every text file the program is built from (not tests, not docs).""" + found = [] + for folder in (SOURCE, os.path.join(REPO, "builder"), + os.path.join(REPO, "installer"), os.path.join(REPO, "deb"), + os.path.join(REPO, "tools")): + if not os.path.isdir(folder): + continue + for name in sorted(os.listdir(folder)): + path = os.path.join(folder, name) + if os.path.isfile(path) and name.endswith( + (".py", ".iss", ".spec", ".desktop", ".txt", ".toml")) \ + or name == "installed": + found.append(path) + return found + + +def test_the_old_name_is_only_where_it_is_allowed(): + leftovers = [] + for path in program_files(): + name = os.path.basename(path) + if name == "legacy_names.py": + continue + if OLD.search(name): + leftovers.append(f"{name}: the file's own name") + with open(path, encoding="utf-8") as f: + for number, line in enumerate(f, 1): + if OLD.search(line) and not any( + name == allowed and text in line + for allowed, text in ALLOWED): + leftovers.append(f"{name}:{number}: {line.strip()}") + assert leftovers == [] + + +def test_every_allowed_line_is_still_there(): + """An allowance for a line that is gone is a hole in the rule.""" + checked = 0 + for name, text in ALLOWED: + path = next((p for p in program_files() + if os.path.basename(p) == name), None) + if path is None: + continue # the other platform's file + with open(path, encoding="utf-8") as f: + assert text in f.read(), (name, text) + checked += 1 + assert checked >= 3 + + +def test_legacy_names_is_only_names(): + """Strings and bytes, no code and no imports: nothing can be done with + the old names by accident.""" + with open(os.path.join(SOURCE, "legacy_names.py"), encoding="utf-8") as f: + text = f.read() + assert "import " not in text.split('"""', 2)[2] + assert "def " not in text and "class " not in text + for name, value in vars(legacy_names).items(): + if not name.startswith("_"): + assert isinstance(value, (str, bytes)), name + + +def test_nothing_else_spells_the_old_labels(): + """The old format's labels come from legacy_names through + account_crypto.OLD and from nowhere else.""" + assert account_crypto.OLD.prefix == legacy_names.LABEL_PREFIX + assert account_crypto.OLD.keyset_magic == legacy_names.KEYSET_MAGIC + assert account_crypto.OLD.vault_magic == legacy_names.VAULT_MAGIC + assert account_crypto.OLD.secret_prefix == legacy_names.SECRET_KEY_PREFIX + assert legacy_names.LABEL_PREFIX not in account_crypto.NOW.prefix + + +def test_the_current_format_is_the_default_everywhere(): + """A call that does not say which format gets the current one: writing + the old format is always a decision made at the call.""" + import inspect + for function in (account_crypto.derive_account_keys, + account_crypto.wrap_key, account_crypto.encrypt_vault, + account_crypto.seal_vault_name, + account_crypto.recovery_key): + default = inspect.signature(function).parameters["fmt"].default + assert default is account_crypto.NOW, function.__name__ + + +def test_the_old_format_is_written_only_for_a_vault_someone_else_still_reads(): + """Which lines of the program can choose a format at all: each is about + a vault's own format (``.fmt``, ``fmt`` from the members list), never a + bare OLD -- except reading, converting, and the one place that picks the + format for a key change.""" + allowed = { + # reading and converting + "account_crypto.py", "accounts.py", + # the format of a key change, decided from the members' accounts + "account_sync.py", + } + bad = [] + for path in program_files(): + name = os.path.basename(path) + if name in allowed or not name.endswith(".py"): + continue + with open(path, encoding="utf-8") as f: + for number, line in enumerate(f, 1): + if re.search(r"account_crypto\.OLD\b|\bOLD\.label|" + r"legacy_names\.(LABEL_PREFIX|KEYSET_MAGIC|" + r"VAULT_MAGIC)\b", line): + bad.append(f"{name}:{number}: {line.strip()}") + assert bad == [] diff --git a/sourceFiles/tests/test_sync_server.py b/sourceFiles/tests/test_sync_server.py index fc51150..7c5748f 100644 --- a/sourceFiles/tests/test_sync_server.py +++ b/sourceFiles/tests/test_sync_server.py @@ -26,7 +26,7 @@ import crypto_utils # noqa: E402 import sync # noqa: E402 import sync_client # noqa: E402 -import passman_server # noqa: E402 +import secretspace_server # noqa: E402 TOKEN = "test-token-that-is-long-enough" @@ -48,9 +48,9 @@ def quick(master_password, salt, iterations=1000): @pytest.fixture def server(tmp_path): """A real ThreadingHTTPServer on a free port, torn down after the test.""" - httpd = passman_server.ThreadingHTTPServer( - ("127.0.0.1", 0), passman_server.Handler) - httpd.store = passman_server.Store(str(tmp_path / "store")) + httpd = secretspace_server.ThreadingHTTPServer( + ("127.0.0.1", 0), secretspace_server.Handler) + httpd.store = secretspace_server.Store(str(tmp_path / "store")) httpd.token = TOKEN os.makedirs(httpd.store.root, exist_ok=True) @@ -77,15 +77,15 @@ def __init__(self, data_dir, url, username="alice", password="master-pw"): @contextlib.contextmanager def at_keyboard(self): """Point the vault module at THIS machine's data directory.""" - previous = os.environ.get("PASSMAN_DATA_DIR") - os.environ["PASSMAN_DATA_DIR"] = self.data_dir + previous = os.environ.get("SECRETSPACE_DATA_DIR") + os.environ["SECRETSPACE_DATA_DIR"] = self.data_dir try: yield finally: if previous is None: - os.environ.pop("PASSMAN_DATA_DIR", None) + os.environ.pop("SECRETSPACE_DATA_DIR", None) else: - os.environ["PASSMAN_DATA_DIR"] = previous + os.environ["SECRETSPACE_DATA_DIR"] = previous def create(self): with self.at_keyboard(): @@ -728,7 +728,7 @@ def test_editing_the_address_keeps_the_password_generation(): def test_a_store_from_before_key_generations_reads_as_zero(tmp_path): """Upgrading the server must not change how an existing vault reads.""" - store = passman_server.Store(str(tmp_path / "store")) + store = secretspace_server.Store(str(tmp_path / "store")) (tmp_path / "store" / "alice").mkdir(parents=True) (tmp_path / "store" / "alice" / "meta.json").write_text( json.dumps({"revision": 3, "sha256": "x", "updated_at": "", "size": 1})) @@ -741,7 +741,7 @@ def test_the_server_rejects_a_nonsense_generation(server): blob = __import__("base64").b64encode(b"PMV2" + b"\0" * 60) with pytest.raises(sync_client.SyncError): client._request("PUT", "/vault/alice", body={"blob": blob.decode()}, - headers={"X-PassMan-Key-Generation": "-3"}) + headers={"X-SecretSpace-Key-Generation": "-3"}) # ========================================================================== @@ -870,9 +870,9 @@ def test_a_vault_removed_on_the_server_is_uploaded_again_not_wiped( def test_moving_to_a_brand_new_server_uploads_the_vault(synced, tmp_path): """Same situation from the other side: a new, empty server.""" pc1, _ = synced - fresh_store = passman_server.Store(str(tmp_path / "new-store")) - httpd = passman_server.ThreadingHTTPServer( - ("127.0.0.1", 0), passman_server.Handler) + fresh_store = secretspace_server.Store(str(tmp_path / "new-store")) + httpd = secretspace_server.ThreadingHTTPServer( + ("127.0.0.1", 0), secretspace_server.Handler) httpd.store, httpd.token = fresh_store, TOKEN threading.Thread(target=httpd.serve_forever, daemon=True).start() try: @@ -932,9 +932,9 @@ def test_a_live_entry_purged_elsewhere_is_put_to_the_user(synced): @contextlib.contextmanager def another_server(path): """A second, independent server -- the Ubuntu box beside the Windows one.""" - httpd = passman_server.ThreadingHTTPServer( - ("127.0.0.1", 0), passman_server.Handler) - httpd.store = passman_server.Store(str(path)) + httpd = secretspace_server.ThreadingHTTPServer( + ("127.0.0.1", 0), secretspace_server.Handler) + httpd.store = secretspace_server.Store(str(path)) httpd.token = TOKEN os.makedirs(httpd.store.root, exist_ok=True) thread = threading.Thread(target=httpd.serve_forever, daemon=True) diff --git a/sourceFiles/tests/test_vault.py b/sourceFiles/tests/test_vault.py index 23c8068..f50b96f 100644 --- a/sourceFiles/tests/test_vault.py +++ b/sourceFiles/tests/test_vault.py @@ -278,28 +278,28 @@ def test_ordinary_save_keeps_the_previous_generation(alice): # --- project layout: vault/ beside the app, legacy data/ still honoured ---- def test_app_dir_is_the_project_root_not_the_source_folder(monkeypatch): """Sources live in sourceFiles/, but the vault belongs one level up.""" - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) app_dir = vault.get_app_dir() assert os.path.basename(app_dir) != "sourceFiles" assert os.path.isfile(os.path.join(app_dir, "sourceFiles", "vault.py")) def test_default_vault_folder_is_named_vault(tmp_path, monkeypatch): - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path)) assert vault.get_data_dir() == str(tmp_path / "vault") def test_existing_legacy_data_folder_is_still_used(tmp_path, monkeypatch): """Renaming the folder must not orphan an existing installation.""" - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path)) (tmp_path / "data").mkdir() assert vault.get_data_dir() == str(tmp_path / "data") def test_vault_folder_wins_when_both_exist(tmp_path, monkeypatch): - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path)) (tmp_path / "data").mkdir() (tmp_path / "vault").mkdir() @@ -308,7 +308,7 @@ def test_vault_folder_wins_when_both_exist(tmp_path, monkeypatch): def test_a_legacy_vault_file_is_readable_after_the_rename(tmp_path, monkeypatch): """End to end: a vault created under data/ still opens after upgrading.""" - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path)) (tmp_path / "data").mkdir() vault.create_new_user("alice", "pw") @@ -324,7 +324,7 @@ def test_a_legacy_vault_file_is_readable_after_the_rename(tmp_path, monkeypatch) def test_env_override_still_beats_both(tmp_path, monkeypatch): monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path)) (tmp_path / "data").mkdir() - monkeypatch.setenv("PASSMAN_DATA_DIR", str(tmp_path / "elsewhere")) + monkeypatch.setenv("SECRETSPACE_DATA_DIR", str(tmp_path / "elsewhere")) assert vault.get_data_dir() == str(tmp_path / "elsewhere") @@ -755,7 +755,7 @@ def test_csv_export_leaves_history_out_json_keeps_it(tmp_path): # --- installed copy: data in ~/.local/share/secretspace, not the program folder def test_portable_copy_keeps_data_next_to_the_app(tmp_path, monkeypatch): - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path / "app")) assert not vault.is_installed() assert vault.get_home_dir() == str(tmp_path / "app") @@ -764,7 +764,7 @@ def test_portable_copy_keeps_data_next_to_the_app(tmp_path, monkeypatch): def test_installed_copy_keeps_data_in_xdg_data_home(tmp_path, monkeypatch): import appconfig - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) app = tmp_path / "usr" / "lib" / "secretspace" app.mkdir(parents=True) (app / vault.INSTALLED_MARKER).write_text("installed by the package") @@ -793,7 +793,7 @@ def test_installed_without_xdg_data_home_uses_local_share(tmp_path, def _installed(tmp_path, monkeypatch): - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) app = tmp_path / "usr" / "lib" / "secretspace" app.mkdir(parents=True) (app / vault.INSTALLED_MARKER).write_text("installed by the package") @@ -802,16 +802,90 @@ def _installed(tmp_path, monkeypatch): return tmp_path / "share" -def test_an_upgrade_from_passman_keeps_the_old_data_folder(tmp_path, monkeypatch): - """Installed as the passman package before the rename: the vaults stay - where they are, nothing is moved, no empty secretspace folder appears.""" - share = _installed(tmp_path, monkeypatch) +def _old_home(share): + """A data folder as the package from before the rename left it.""" old = share / "passman" (old / "vault").mkdir(parents=True) + (old / "accounts" / "bob").mkdir(parents=True) (old / "vault" / "bob.vault.enc").write_bytes(b"x") + (old / "accounts" / "bob" / "account.json").write_bytes(b"{}") + (old / "config.json").write_bytes(b"{}") + return old + + +def _files(folder): + return sorted(str(p.relative_to(folder)).replace("\\", "/") + for p in folder.rglob("*") if p.is_file()) + + +def test_the_data_folder_of_the_old_name_is_renamed_at_start(tmp_path, monkeypatch): + """Installed before the rename: at the next start the folder gets its + current name -- renamed, not copied, with everything in it.""" + share = _installed(tmp_path, monkeypatch) + old = _old_home(share) + before = _files(old) + status, was, now = vault.settle_home() + assert status == vault.HOME_MOVED + assert (was, now) == (str(old), str(share / "secretspace")) + assert not old.exists() + assert _files(share / "secretspace") == before + assert vault.get_data_dir() == str(share / "secretspace" / "vault") + assert "bob" in vault.list_users() + + +def test_a_second_start_has_nothing_left_to_move(tmp_path, monkeypatch): + share = _installed(tmp_path, monkeypatch) + _old_home(share) + vault.settle_home() + assert vault.settle_home()[0] is None + + +def test_a_folder_that_cannot_be_renamed_is_used_where_it_is(tmp_path, monkeypatch): + share = _installed(tmp_path, monkeypatch) + old = _old_home(share) + real = os.rename + + def busy(source, target): + raise PermissionError("in use") + + monkeypatch.setattr(vault.os, "rename", busy) + assert vault.settle_home()[0] == vault.HOME_BUSY assert vault.get_data_dir() == str(old / "vault") assert "bob" in vault.list_users() assert not (share / "secretspace").exists() + monkeypatch.setattr(vault.os, "rename", real) + assert vault.settle_home()[0] == vault.HOME_MOVED + assert vault.get_data_dir() == str(share / "secretspace" / "vault") + + +def test_with_both_folders_the_current_one_is_used_and_the_old_left_alone( + tmp_path, monkeypatch): + share = _installed(tmp_path, monkeypatch) + old = _old_home(share) + before = _files(old) + (share / "secretspace" / "vault").mkdir(parents=True) + assert vault.settle_home()[0] == vault.HOME_BOTH + assert _files(old) == before + assert vault.get_data_dir() == str(share / "secretspace" / "vault") + + +def test_a_run_from_a_checkout_has_no_folder_to_rename(tmp_path, monkeypatch): + app = tmp_path / "checkout" + app.mkdir() + monkeypatch.setattr(vault, "get_app_dir", lambda: str(app)) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share")) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) + _old_home(tmp_path / "share") + assert vault.settle_home() == (None, None, None) + assert (tmp_path / "share" / "passman").is_dir() + + +def test_a_test_run_never_moves_the_real_folder(tmp_path, monkeypatch): + share = _installed(tmp_path, monkeypatch) + _old_home(share) + monkeypatch.setenv("SECRETSPACE_DATA_DIR", str(tmp_path / "elsewhere")) + assert vault.settle_home() == (None, None, None) + assert (share / "passman").is_dir() def test_the_new_folder_wins_once_it_exists(tmp_path, monkeypatch): @@ -823,7 +897,7 @@ def test_the_new_folder_wins_once_it_exists(tmp_path, monkeypatch): @pytest.mark.skipif(os.name != "posix", reason="POSIX file modes") def test_vaults_and_their_folder_are_private(tmp_path, monkeypatch): - monkeypatch.setenv("PASSMAN_DATA_DIR", str(tmp_path / "new")) + monkeypatch.setenv("SECRETSPACE_DATA_DIR", str(tmp_path / "new")) vault.create_new_user("alice", "pw") assert (tmp_path / "new").stat().st_mode & 0o777 == 0o700 assert (tmp_path / "new" / "alice.vault.enc").stat().st_mode & 0o777 == 0o600 @@ -844,7 +918,7 @@ def test_a_plaintext_export_is_private(tmp_path): def test_env_override_beats_an_installed_copy(tmp_path, monkeypatch): (tmp_path / vault.INSTALLED_MARKER).write_text("") monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path)) - monkeypatch.setenv("PASSMAN_DATA_DIR", str(tmp_path / "elsewhere")) + monkeypatch.setenv("SECRETSPACE_DATA_DIR", str(tmp_path / "elsewhere")) assert vault.get_data_dir() == str(tmp_path / "elsewhere") @@ -919,7 +993,7 @@ def test_installed_copy_has_the_plain_title(tmp_path, monkeypatch): def test_copy_without_the_marker_says_lab_and_where(tmp_path, monkeypatch): """A lab copy or a source run names itself LAB, with its vault folder.""" - monkeypatch.delenv("PASSMAN_DATA_DIR", raising=False) + monkeypatch.delenv("SECRETSPACE_DATA_DIR", raising=False) monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path)) title = vault.window_title() assert title.startswith(vault.APP_TITLE) @@ -929,5 +1003,5 @@ def test_copy_without_the_marker_says_lab_and_where(tmp_path, monkeypatch): def test_lab_title_follows_the_data_dir_override(tmp_path, monkeypatch): monkeypatch.setattr(vault, "get_app_dir", lambda: str(tmp_path)) - monkeypatch.setenv("PASSMAN_DATA_DIR", str(tmp_path / "elsewhere")) + monkeypatch.setenv("SECRETSPACE_DATA_DIR", str(tmp_path / "elsewhere")) assert str(tmp_path / "elsewhere") in vault.window_title() diff --git a/sourceFiles/tests/test_version.py b/sourceFiles/tests/test_version.py index fb71afb..75f5685 100644 --- a/sourceFiles/tests/test_version.py +++ b/sourceFiles/tests/test_version.py @@ -45,4 +45,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() == account_crypto.VAULT_MAGIC - assert version.LEGACY_VAULT_FORMAT.encode() == crypto_utils.MAGIC + assert [name.encode() for name in version.OLDER_VAULT_FORMATS] == [ + account_crypto.OLD.vault_magic, crypto_utils.MAGIC] diff --git a/sourceFiles/vault.py b/sourceFiles/vault.py index 0e4e06a..1050a5b 100644 --- a/sourceFiles/vault.py +++ b/sourceFiles/vault.py @@ -8,6 +8,7 @@ import tempfile from datetime import datetime, timedelta, timezone +import legacy_names from crypto_utils import encrypt, decrypt, blob_iterations, DEFAULT_ITERATIONS #: Suffix of the previous-generation copy kept beside every vault. @@ -75,6 +76,9 @@ def legacy_id(site: str, username: str) -> str: #: Where vaults live, relative to the project root / the folder holding the exe. VAULT_DIR_NAME = "vault" +#: Tests and tools point the app at another data folder with this variable. +DATA_DIR_VARIABLE = "SECRETSPACE_DATA_DIR" + #: What that folder was called in earlier versions. Still honoured so an #: existing installation is not orphaned by the rename. LEGACY_DIR_NAME = "data" @@ -114,7 +118,8 @@ def get_home_dir() -> str: and its data travel together, as on Windows. Installed: $XDG_DATA_HOME/secretspace, by default ~/.local/share/secretspace, which survives upgrades and removing the package and needs no write access to - the program folder -- or .../passman, see ``data_home``. + the program folder. (A folder from before the rename is moved there at + start: see ``settle_home``.) """ if is_installed(): base = os.environ.get("XDG_DATA_HOME") or os.path.join( @@ -123,17 +128,21 @@ def get_home_dir() -> str: return get_app_dir() -#: The folder name before the rename to SecretSpace (2026-11). -OLD_HOME_NAME = "passman" +#: The data folder's name, and what it was before the rename (2026-11). HOME_NAME = "secretspace" +OLD_HOME_NAME = legacy_names.DATA_FOLDER_LINUX + +#: What move_home can say happened. +HOME_MOVED, HOME_BUSY, HOME_BOTH = "moved", "busy", "both" def data_home(base: str) -> str: """ - The data folder under ``base``: secretspace, unless only the folder of - the old name, passman, exists -- then that one, so an upgrade from the - passman package finds the vaults where they are. Nothing is moved or - renamed. The same rule as appdata_home in SecretSpace for Windows. + The data folder under ``base``: the current name -- unless only the + folder of the old name is there, which means it could not be moved at + start (``move_home``); it is then used where it is, so the vaults are + never out of reach. The same rule as appdata_home in SecretSpace for + Windows. """ new = os.path.join(base, HOME_NAME) old = os.path.join(base, OLD_HOME_NAME) @@ -142,6 +151,45 @@ def data_home(base: str) -> str: return new +def move_home(base: str, old_name: str = OLD_HOME_NAME, + new_name: str = HOME_NAME): + """ + Give a data folder from before the rename its current name. One rename + on the same disk: nothing is copied, so there is never a second copy of + a vault. Returns HOME_MOVED; HOME_BUSY when the folder could not be + renamed -- it is used where it is and the next start tries again; + HOME_BOTH when a folder of each name exists -- the current one is used + and the old one is left alone, for the user to look at; None when there + was nothing to do. + """ + old = os.path.join(base, old_name) + new = os.path.join(base, new_name) + if not os.path.isdir(old): + return None + if os.path.exists(new): + return HOME_BOTH + try: + os.rename(old, new) + except OSError: + return HOME_BUSY + return HOME_MOVED + + +def settle_home(): + """ + Called once at start, before anything is read: move the installed + copy's data folder to its current name. A run from a checkout keeps its + data beside itself, which has no name to change. Returns (what move_home + said, the old folder, the new folder). + """ + if not is_installed() or os.environ.get(DATA_DIR_VARIABLE): + return None, None, None + base = os.environ.get("XDG_DATA_HOME") or os.path.join( + os.path.expanduser("~"), ".local", "share") + return (move_home(base), os.path.join(base, OLD_HOME_NAME), + os.path.join(base, HOME_NAME)) + + APP_TITLE = "SecretSpace - Password Manager" @@ -171,9 +219,9 @@ def get_data_dir() -> str: installation keeps working, and the user can rename the folder themselves whenever they like. - PASSMAN_DATA_DIR overrides the location (used by tests). + SECRETSPACE_DATA_DIR overrides the location (used by tests). """ - override = os.environ.get("PASSMAN_DATA_DIR") + override = os.environ.get(DATA_DIR_VARIABLE) if override: return override diff --git a/sourceFiles/version.py b/sourceFiles/version.py index 4163dbe..7f543a1 100644 --- a/sourceFiles/version.py +++ b/sourceFiles/version.py @@ -12,12 +12,13 @@ import re -__version__ = "1.1.0" +__version__ = "2.0.0" -#: 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" +#: The container an account's vaults are written in (account_crypto). +VAULT_FORMAT = "SSV4" +#: What SecretSpace 3 wrote, which is read to convert an account, and the +#: 2.x container, which is read to import a vault (legacy_names). +OLDER_VAULT_FORMATS = ("PMV3", "PMV2") _VERSION = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.(\d+))?") @@ -36,6 +37,6 @@ def version_string() -> str: def long_version_string() -> str: - """e.g. 'SecretSpace for Linux v1.0.0 · vault format PMV3' — for the + """e.g. 'SecretSpace for Linux v2.0.0 · vault format SSV4' — for the Settings footer.""" return f"SecretSpace for Linux {version_string()} · vault format {VAULT_FORMAT}" diff --git a/tools/smoke_gui.py b/tools/smoke_gui.py index 1ae073e..5c0ef8d 100644 --- a/tools/smoke_gui.py +++ b/tools/smoke_gui.py @@ -39,8 +39,8 @@ else: sys.path.insert(0, os.path.join(os.path.dirname(HERE), "sourceFiles")) -TMP = tempfile.mkdtemp(prefix="passman-smoke-") -os.environ["PASSMAN_DATA_DIR"] = os.path.join(TMP, "vault") +TMP = tempfile.mkdtemp(prefix="secretspace-smoke-") +os.environ["SECRETSPACE_DATA_DIR"] = os.path.join(TMP, "vault") import customtkinter as ctk # noqa: E402 @@ -141,7 +141,7 @@ def step_entry(): 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"))) + for n in ("account.json", "keyset.ssk", "recovery.ssk"))) root.after(200, root.destroy) def guarded(fn): From 3550c8763fffc1aa310bb11824c144d8e0b1ccc0 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Sat, 14 Nov 2026 20:48:28 +0200 Subject: [PATCH 2/3] Signing in asks before it converts the account on the server Review finding: a sign-in from a server converted an account that no device had converted yet, on the server, without asking - which locks out every device of that account still running SecretSpace 3. Now sign_in raises NeedsConversion (nothing changed, anywhere) unless convert=True, and the window asks with the same "will be converted" question as an unlock, saying that it happens on the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ojnyXb6JY4UPVU4waZe1w --- sourceFiles/account_sync.py | 12 +++++++++++- sourceFiles/account_sync_ui.py | 13 +++++++++++-- sourceFiles/conversion_ui.py | 22 +++++++++++++++------ sourceFiles/tests/test_conversion.py | 29 +++++++++++++++++++++++++--- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/sourceFiles/account_sync.py b/sourceFiles/account_sync.py index dce6bdb..c796522 100644 --- a/sourceFiles/account_sync.py +++ b/sourceFiles/account_sync.py @@ -959,11 +959,18 @@ def convert_vaults(account: accounts.Account, server: AccountServer, # Signing in from a server # --------------------------------------------------------------------------- def sign_in(url: str, ca_file: str, username: str, password: str, - secret_key_text: str = None): + secret_key_text: str = None, convert: bool = False): """ Bring an account from the server onto this device -- a new device, or this one after the master password was changed on another. + An account that no device has converted from SecretSpace 3 yet is + converted on the server by this sign-in -- which locks out every device + of it that still runs SecretSpace 3. So that only happens with + ``convert=True``; without it accounts.NeedsConversion is raised, the + details having been right and nothing having been changed, for the + caller to ask the user. + ``secret_key_text`` comes from the Emergency Kit; it may be left out when this device already has the account and keeps its Secret Key. Returns (account, vault data or None when the server has no vault yet). A @@ -1006,6 +1013,9 @@ def sign_in(url: str, ca_file: str, username: str, password: str, except LoginFailed: raise LoginFailed("Wrong username, master password or Secret " "Key.") from None + if not convert: + server.logout() + raise accounts.NeedsConversion(username) me = server.me() try: keyset = account_crypto.open_keyset(old_auk or auk, me["keyset"]) diff --git a/sourceFiles/account_sync_ui.py b/sourceFiles/account_sync_ui.py index b2ff627..55fa0f9 100644 --- a/sourceFiles/account_sync_ui.py +++ b/sourceFiles/account_sync_ui.py @@ -317,7 +317,7 @@ def sign_in_dialog(app): wraplength=shell.text_width, justify="left", anchor="w") status.pack(anchor="w", fill="x", pady=(10, 0)) - def sign_in(): + def sign_in(convert=False): url = url_entry.get().strip() username = user_entry.get().strip() password = pw_entry.get() @@ -334,7 +334,8 @@ def sign_in(): def work(): return account_sync.sign_in(url, ca_entry.get().strip(), username, - password, key_entry.get().strip() or None) + password, key_entry.get().strip() or None, + convert) def done(payload): account, data = payload @@ -346,6 +347,14 @@ def done(payload): run_sync(app) def failed(e): + if isinstance(e, accounts.NeedsConversion): + # Right details, and an account still as SecretSpace 3 left + # it: signing in converts it on the server. Ask first. + import conversion_ui # late: it imports this module's users + status.configure(text="") + if conversion_ui.ask_conversion(app, username, on_server=True): + sign_in(convert=True) + return if isinstance(e, accounts.SecretKeyNeeded): text = ("Enter the Secret Key from your Emergency Kit — this PC " "does not have it for this password.") diff --git a/sourceFiles/conversion_ui.py b/sourceFiles/conversion_ui.py index 87bc658..fafdf09 100644 --- a/sourceFiles/conversion_ui.py +++ b/sourceFiles/conversion_ui.py @@ -24,10 +24,12 @@ from widgets import dialog_shell, error_label, field_label, note -def ask_conversion(app, username) -> bool: +def ask_conversion(app, username, on_server=False) -> bool: """ Tell the user their account is about to be converted and let them save a copy first. True to go ahead, False to stay locked (nothing changed). + ``on_server``: asked while signing in from a server, where it is the + server's copy of the account that gets converted. """ top = app._modal("Convert this account", 520, 420) shell = dialog_shell( @@ -39,9 +41,16 @@ def ask_conversion(app, username) -> bool: note(wrap, "Afterwards SecretSpace 3.0 and 3.1 can no longer open this " "account. If you use it on other devices, update SecretSpace there " "too before you sync them again.", shell.text_width, pady=(0, 10)) - note(wrap, "To be able to go back, save a copy of the account as it is " - "now. It is a folder of encrypted files; keep it or delete it as " - "you like.", shell.text_width, size=11) + has_copy = accounts.account_exists(username) + if on_server: + note(wrap, "Signing in converts the account on the server now. A " + "device that still runs SecretSpace 3 cannot sync it after " + "that until it is updated.", shell.text_width, size=11, + pady=(0, 8)) + if has_copy: + note(wrap, "To be able to go back, save a copy of the account as it " + "is on this PC now. It is a folder of encrypted files; keep it " + "or delete it as you like.", shell.text_width, size=11) saved = ctk.CTkLabel(wrap, text="", font=F(11), text_color=GREEN, wraplength=shell.text_width, justify="left") saved.pack(anchor="w", pady=(8, 0)) @@ -70,8 +79,9 @@ def convert(): width=100).pack(side="left") app._accent_button(shell.buttons, "Convert", convert, width=120).pack(side="right") - app._ghost_button(shell.buttons, "Save a copy first…", save_copy, - width=170).pack(side="right", padx=(0, 8)) + if has_copy: + app._ghost_button(shell.buttons, "Save a copy first…", save_copy, + width=170).pack(side="right", padx=(0, 8)) top.bind("", lambda e: top.destroy()) app.root.wait_window(top) return out["go"] diff --git a/sourceFiles/tests/test_conversion.py b/sourceFiles/tests/test_conversion.py index 373bba9..3c3757a 100644 --- a/sourceFiles/tests/test_conversion.py +++ b/sourceFiles/tests/test_conversion.py @@ -865,13 +865,36 @@ def test_a_converted_account_signs_in_on_a_new_device(online): assert sites(account, TEAM) == ["Printer", "Router"] +def test_signing_in_does_not_convert_an_account_without_being_told_to(online): + """Converting on the server locks out the account's devices that still + run SecretSpace 3: the sign-in says so and changes nothing, anywhere.""" + online.device("new") + before = tree(os.path.join(online.root, "store", "_v2", "accounts")) + with pytest.raises(accounts.NeedsConversion): + account_sync.sign_in(online.server.url, "", "bob", pw("bob"), + FACTS["accounts"]["bob"]["secret_key"]) + assert tree(os.path.join(online.root, "store", "_v2", "accounts")) == before + assert not accounts.account_exists("bob") + # ... and bob's device with SecretSpace 3 still gets in: the login key + # the server expects is the one it always had. + server = account_sync.AccountServer(online.server.url) + old_login = account_crypto.derive_account_keys( + pw("bob"), account_crypto.parse_secret_key( + FACTS["accounts"]["bob"]["secret_key"]), + "bob", base64.b64decode(server.prelogin("bob")["kdf"]["salt"]), + 1, 64, 1, OLD)[1] + server.login("bob", old_login) + server.logout() + + def test_signing_in_to_an_account_nobody_converted_converts_it(online): """A new device with SecretSpace 4, an account still as 3 left it on the - server: the sign-in converts it there and then.""" + server: once the user has agreed, the sign-in converts it there and + then.""" online.device("new") account, data = account_sync.sign_in( online.server.url, "", "bob", pw("bob"), - FACTS["accounts"]["bob"]["secret_key"]) + FACTS["accounts"]["bob"]["secret_key"], convert=True) assert sorted(e["site"] for e in data["accounts"]) == ["Forum"] record = online.stored_json("accounts", "bob.json") assert record["format"] == 2 and marker(record["keyset"]) == b"SSK4" @@ -897,7 +920,7 @@ def test_signing_in_over_an_unconverted_copy_on_this_pc_converts_it(online): has the account as SecretSpace 3 left it.""" online.device("new") account_sync.sign_in(online.server.url, "", "bob", pw("bob"), - FACTS["accounts"]["bob"]["secret_key"]) + FACTS["accounts"]["bob"]["secret_key"], convert=True) account = accounts.unlock("bob", pw("bob")) account_sync.change_password(account, "bob's second password") From 5e150075a603bc39ac88302e32576971d47c7175 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Sat, 14 Nov 2026 20:50:25 +0200 Subject: [PATCH 3/3] README: fingerprint digits differ between this version and the previous one Found in the security review of the new format: compare only once both people have updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ojnyXb6JY4UPVU4waZe1w --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b2b0fd6..eb1d643 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,10 @@ the first time you unlock it**. Your entries, vaults, master password and is too old when you press Sync. - **Your Emergency Kit stays valid.** A Secret Key printed by 1.x starts with `A3-`; 2.0.0 accepts it and shows the same key as `A4-…`. +- **Fingerprint digits change.** The digits two people compare before + sharing a vault are new in this version. Nothing you already compared + has to be compared again, but compare with someone only once you have + both updated: an older app shows other digits for the same account. The steps: