From 48159563ee1d5106c326caf6dc8134b5216fb88a Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Sat, 14 Nov 2026 11:01:22 +0200 Subject: [PATCH 1/2] Server 2.1.0: the new names everywhere, format 2, `migrate` The project was renamed from PassMan to SecretSpace in November 2026; until now a server kept its old names inside. This gives everything the current ones, and lets the server hold accounts and vaults in the format SecretSpace 4 writes. - Program files secretspace_server.py / secretspace_setup.py, the unit secretspace-sync.service, /opt|/etc|/var/lib/secretspace, the user secretspace, SECRETSPACE_* settings, X-SecretSpace-* headers, the health name secretspace-sync. - `secretspace_setup.py migrate` moves an install from before the rename (--dry-run, --undo). Port, token and vaults are not changed. On Windows the setup step moves the data folder and replaces the task and firewall rules. - The old names are read, never written: old headers, old settings keys. - Accounts and vaults have a format. A converted account never goes back; a vault is stored in the new form only when every member's account is converted; a converted vault takes neither an old upload nor a member who has not updated. /health lists "format-2". - Tests: 622 (69 new); 35 mutants, all caught. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyXb6JY4UPVU4waZe1w --- README.md | 266 ++++++----- SECURITY.md | 2 +- ...-sync.service => secretspace-sync.service} | 24 +- ...assman_server.py => secretspace_server.py} | 229 ++++++++-- ...{passman_setup.py => secretspace_setup.py} | 379 ++++++++++++++-- server/version.py | 4 +- tests/test_old_name.py | 56 +++ tests/test_server_http.py | 35 +- tests/test_server_migrate.py | 429 ++++++++++++++++++ tests/test_server_setup.py | 118 ++--- tests/test_server_v2.py | 16 +- tests/test_server_v2_format.py | 348 ++++++++++++++ tests/test_server_v2_key_sigs.py | 4 +- tests/test_server_v2_recovery.py | 27 +- tests/test_server_v2_sharing.py | 51 ++- tests/test_server_v2_vaults.py | 27 +- 16 files changed, 1666 insertions(+), 349 deletions(-) rename server/{passman-sync.service => secretspace-sync.service} (57%) rename server/{passman_server.py => secretspace_server.py} (88%) rename server/{passman_setup.py => secretspace_setup.py} (65%) create mode 100644 tests/test_old_name.py create mode 100644 tests/test_server_migrate.py create mode 100644 tests/test_server_v2_format.py diff --git a/README.md b/README.md index 6ef9a2d..e9f0423 100644 --- a/README.md +++ b/README.md @@ -11,30 +11,28 @@ recovery path here, by design — the only secret that matters never leaves your PC. **Formerly PassMan.** The project was renamed to SecretSpace in November -2026, because another password manager already uses the name PassMan. Only -the names you read changed. What a running server is made of keeps its -name, so an existing server needs no change and updates as before: the -service `passman-sync`, the user `passman`, `/opt/passman/server`, -`/etc/passman`, `/var/lib/passman/store`, the files `passman_server.py` and -`passman_setup.py`, and the `X-PassMan-*` headers of the sync protocol. A -clone made before the rename is in `~/PassMan-Server-Linux`; it keeps -working (GitHub forwards the old address), so use that folder wherever this -README says `~/SecretSpace-Server-Linux`. +2026, because another password manager already uses the name PassMan. Since +2.1.0 everything a server is made of carries the new name too: its folders, +its service, its user, its settings and its program files. A server +installed before that is moved over by one command, which changes neither +the port, the token nor the vaults: see +[Updating a server from before 2.1.0](#updating-a-server-from-before-210). Standard library only: no `pip install`, no virtualenv, nothing to keep patched beyond Python itself. -**Which apps it serves.** Server **2.0.0** serves both lines of the app: +**Which apps it serves.** Server **2.1.0** serves every line of the app: -- **SecretSpace 3** (Windows 3.0.0, Linux 1.0.0 and later): accounts with - several vaults, sharing and recovery, through the routes under *API v2* - below. +- **SecretSpace 4** (Windows 4.0.0, Linux 2.0.0): accounts and vaults in the + current format. It needs this version of the server. +- **SecretSpace 3** (Windows 3.x, Linux 1.x): the same accounts in the + format from before the rename, through the same routes (*API v2* below). + An account is converted by its owner's own app, when they update it; the + server keeps both forms side by side until then. - **SecretSpace 2.x** (Windows 2.x, Linux 0.x): one vault per user, through the token routes, exactly as server 1.x did. -Updating from 1.x changes nothing for 2.x apps: the same store, token and -settings. SecretSpace 3 needs this version; it does not sync with a 1.x -server. +Updating changes nothing for the apps: the same store, token and settings. This repository is the server for **Linux**, run by systemd. The other parts of SecretSpace have their own repositories: @@ -86,12 +84,12 @@ it — touches the live server until you choose to update it. ### 2. Service user, folders, address, port and token ```bash -sudo useradd --system --no-create-home --shell /usr/sbin/nologin passman -sudo mkdir -p /opt/passman/server /var/lib/passman/store /etc/passman -sudo cp ~/SecretSpace-Server-Linux/server/passman_server.py ~/SecretSpace-Server-Linux/server/passman_setup.py /opt/passman/server/ -sudo chown -R passman:passman /var/lib/passman +sudo useradd --system --no-create-home --shell /usr/sbin/nologin secretspace +sudo mkdir -p /opt/secretspace/server /var/lib/secretspace/store /etc/secretspace +sudo cp ~/SecretSpace-Server-Linux/server/secretspace_server.py ~/SecretSpace-Server-Linux/server/secretspace_setup.py /opt/secretspace/server/ +sudo chown -R secretspace:secretspace /var/lib/secretspace -sudo python3 /opt/passman/server/passman_setup.py +sudo python3 /opt/secretspace/server/secretspace_setup.py ``` If `useradd` says the user already exists, carry on. @@ -106,8 +104,8 @@ The setup asks two things: - **The port** the PCs connect on. Press Enter for `8787`, or type another from 1024 to 65535. -It writes both to `/etc/passman/sync.env`, which the service reads, and -creates the token in `/etc/passman/token` (readable by `passman` only). If +It writes both to `/etc/secretspace/sync.env`, which the service reads, and +creates the token in `/etc/secretspace/token` (readable by `secretspace` only). If ufw is already on, it adds the firewall rule for step 4 itself; if not, it prints the lines to run. At the end it prints the address to enter on your PCs. Run it again any time to change the address or port — see @@ -128,9 +126,9 @@ With the address and port you chose in step 2 (`0.0.0.0` means every network): ```bash -sudo -u passman python3 /opt/passman/server/passman_server.py \ +sudo -u secretspace python3 /opt/secretspace/server/secretspace_server.py \ --host 0.0.0.0 --port 8787 \ - --store /var/lib/passman/store --token-file /etc/passman/token + --store /var/lib/secretspace/store --token-file /etc/secretspace/token ``` It prints a note about plain HTTP; that is expected (see @@ -144,7 +142,7 @@ PCs, check it answers. In Windows PowerShell use `curl.exe` — plain curl.exe http://192.168.1.50:8787/health ``` -You want `{"ok": true, "service": "passman-sync"}`. If it fails, +You want `{"ok": true, "service": "secretspace-sync"}`. If it fails, `Test-NetConnection 192.168.1.50 -Port 8787` tells you more: `PingSucceeded` says whether the PC can reach the server at all, `TcpTestSucceeded` whether the port answers. See [Troubleshooting](#troubleshooting). @@ -186,7 +184,7 @@ sudo ufw enable again and pick the LAN network instead of every network: ```bash -sudo python3 /opt/passman/server/passman_setup.py +sudo python3 /opt/secretspace/server/secretspace_setup.py ``` The catch: if that address changes, the service no longer starts, so the @@ -195,17 +193,17 @@ DHCP reservation stops being a nicety and becomes a requirement. ### 5. Install it as a service ```bash -sudo cp ~/SecretSpace-Server-Linux/server/passman-sync.service /etc/systemd/system/ +sudo cp ~/SecretSpace-Server-Linux/server/secretspace-sync.service /etc/systemd/system/ sudo systemctl daemon-reload -sudo systemctl enable --now passman-sync -systemctl status passman-sync +sudo systemctl enable --now secretspace-sync +systemctl status secretspace-sync ``` You want `active (running)`. Run the `curl.exe` check from step 3 again; at some point, reboot and check it comes back by itself. The unit ships with the paths from step 2 and takes the address and port from -`/etc/passman/sync.env`, so it works as copied — there is no port in it to +`/etc/secretspace/sync.env`, so it works as copied — there is no port in it to edit. If it fails with `Failed to load environment files`, step 2's setup has not been run. @@ -214,7 +212,7 @@ has not been run. Show the token and copy it: ```bash -sudo cat /etc/passman/token +sudo cat /etc/secretspace/token ``` **First PC (SecretSpace 2.x):** unlock your vault, open **Settings → Sync**, enter the address @@ -236,8 +234,8 @@ here: it opens no SecretSpace 3 account. ### 7. Check it worked ```bash -sudo ls -l /var/lib/passman/store/*/ # vault.enc appears after the first sync -journalctl -u passman-sync -n 20 # recent requests +sudo ls -l /var/lib/secretspace/store/*/ # vault.enc appears after the first sync +journalctl -u secretspace-sync -n 20 # recent requests ``` The access log records usernames and status codes — never the vault, never @@ -246,16 +244,16 @@ the token. ## Changing the address or port ```bash -sudo python3 /opt/passman/server/passman_setup.py +sudo python3 /opt/secretspace/server/secretspace_setup.py ``` It offers the current values — press Enter to keep one — rewrites -`/etc/passman/sync.env` and restarts the service. With ufw on, it also swaps +`/etc/secretspace/sync.env` and restarts the service. With ufw on, it also swaps the firewall rule: the old network/port rule goes, the new one comes in. What is left is the new address in Settings → Sync on every PC, in every synced vault. -For a script, skip the questions: `… passman_setup.py setup --host 0.0.0.0 +For a script, skip the questions: `… secretspace_setup.py setup --host 0.0.0.0 --port 9443`, or `… setup --keep --port 9443` to change only the port. ## Letting in a PC from another network @@ -265,12 +263,12 @@ behind a router, on a VPN the server is not part of — needs its own rule. The setup's third question asks for those addresses, or give them directly: ```bash -sudo python3 /opt/passman/server/passman_setup.py setup --keep --allow-from 192.168.0.1 +sudo python3 /opt/secretspace/server/secretspace_setup.py setup --keep --allow-from 192.168.0.1 ``` Name one address or a network (`10.20.0.0/24`); repeat `--allow-from` or separate them with commas for more, and `--allow-from none` removes them. -They are saved in `sync.env` as `PASSMAN_ALLOW_FROM`, so a later port change +They are saved in `sync.env` as `SECRETSPACE_ALLOW_FROM`, so a later port change moves their rules too. Only the firewall changes: the service is not restarted. A rule you had already typed by hand for the same address and port is taken over (ufw skips the duplicate); one for another port stays @@ -279,10 +277,10 @@ until you delete it: `sudo ufw status numbered`, then `sudo ufw delete N`. ## If the token leaks ```bash -sudo python3 /opt/passman/server/passman_setup.py new-token +sudo python3 /opt/secretspace/server/secretspace_setup.py new-token ``` -It asks for confirmation, writes a new random token to `/etc/passman/token`, +It asks for confirmation, writes a new random token to `/etc/secretspace/token`, restarts the service and prints the new value. From then on the old token is refused everywhere. On each PC, unlock each synced vault, open **Settings → Sync**, paste the new token, keep the address, and press **Save & sync**. @@ -293,55 +291,91 @@ copied, change the master password too. ## Updating -When `main` has changed: +When `main` has changed (a server that is already 2.1.0 or newer): ```bash cd ~/SecretSpace-Server-Linux && git pull -sudo cp server/passman_server.py server/passman_setup.py /opt/passman/server/ -sudo systemctl restart passman-sync +sudo cp server/secretspace_server.py server/secretspace_setup.py /opt/secretspace/server/ +sudo systemctl restart secretspace-sync ``` -Vaults in `/var/lib/passman/store`, the token in `/etc/passman/token` and -the settings in `/etc/passman/sync.env` are untouched by an update. +Vaults in `/var/lib/secretspace/store`, the token in `/etc/secretspace/token` and +the settings in `/etc/secretspace/sync.env` are untouched by an update. If +`secretspace-sync.service` itself changed, copy it to `/etc/systemd/system/` +and run `sudo systemctl daemon-reload` before the restart. -**Updating from a server without `passman_setup.py`** (before the address and -port moved into `sync.env`): the new unit file has no `--host`/`--port` and -will not start without `sync.env`, so do it in this order: +**Update the server before the apps.** SecretSpace 4 does not sync with a +server older than 2.1.0, and a master password should not be changed +against a server older than the app. -```bash -cd ~/SecretSpace-Server-Linux && git pull -sudo cp server/passman_server.py server/passman_setup.py /opt/passman/server/ -sudo python3 /opt/passman/server/passman_setup.py # Enter, Enter keeps 0.0.0.0 and 8787 -sudo cp server/passman-sync.service /etc/systemd/system/ -sudo systemctl daemon-reload && sudo systemctl restart passman-sync -``` +## Updating a server from before 2.1.0 + +Up to 2.0.0 a server lived under the project's old name: `/opt/passman`, +`/etc/passman`, `/var/lib/passman`, the service `passman-sync`, the user +`passman`. `migrate` gives all of it the current names in one go. **The +port, the token and the vaults do not change**, so no PC needs a new +setting, and SecretSpace 2.x and 3 apps keep syncing as before. + +1. Get the code. A clone made before the rename is in + `~/PassMan-Server-Linux`; it still updates (GitHub forwards the old + address): + + ```bash + cd ~/PassMan-Server-Linux && git pull + ``` -The setup keeps your existing token. If you had used `sed` to put your LAN -address in the old unit, pick that network in the setup instead. +2. Put the two program files beside the old ones: -**If your checkout is of the old `SecretSpace` repository** (the one that held -the app and the server together, cloned to `~/SecretSpace`): it gets no more -server updates. Clone this repository as in [step 1](#1-get-the-code) and -update from it as above — the running copy in `/opt/passman`, the token, -the settings and the vaults stay as they are. Then delete `~/SecretSpace` when -you like. + ```bash + sudo cp server/secretspace_server.py server/secretspace_setup.py /opt/passman/server/ + ``` -**Update the server before changing a master password** in SecretSpace. The -server now records how many times each vault's password has changed; an older -server does not, and after a second change it can send a PC the wrong way — -told to adopt a password that is really its own previous one. If `passman-sync.service` itself changed, copy it -again and run `sudo systemctl daemon-reload` before the restart. +3. See what it would do, then do it: + + ```bash + sudo python3 /opt/passman/server/secretspace_setup.py migrate --dry-run + sudo python3 /opt/passman/server/secretspace_setup.py migrate + ``` + + It prints a backup line first (a `tar` of the three folders) — run that + before you answer `y`. Then it stops and disables `passman-sync`, + renames the user and group to `secretspace`, moves the three folders to + `/opt/secretspace`, `/etc/secretspace` and `/var/lib/secretspace`, + writes the settings under their current names, installs + `secretspace-sync.service` and starts it. + +4. Check it, with your own port: + + ```bash + curl http://127.0.0.1:8787/health + ``` + + The answer names `"service": "secretspace-sync"` and lists `"format-2"` + among its features. + +The firewall rules are by port and are not touched. **The way back:** +`sudo python3 /opt/secretspace/server/secretspace_setup.py migrate --undo` +puts the old names back (running the current program). + +Afterwards you may rename the clone too: `mv ~/PassMan-Server-Linux +~/SecretSpace-Server-Linux`. + +**From a server even older** — one without a setup tool, or a checkout of +the first repository that held the app and the server together: install +2.1.0 fresh as in [Install on Ubuntu](#install-on-ubuntu), with the same +port, then copy the old store folder and token into the new places before +the first start. ## Troubleshooting **Start here if a PC cannot connect.** Check the server side first — most failures are simply that nothing is running. Use your port if it is not 8787 -(`cat /etc/passman/sync.env` shows it): +(`cat /etc/secretspace/sync.env` shows it): ```bash sudo ss -tlnp | grep 8787 # anything listening? no output = not running curl http://127.0.0.1:8787/health # does it answer on the server itself? -systemctl status passman-sync --no-pager +systemctl status secretspace-sync --no-pager ``` On Windows, a refused connection can still take about 20 seconds to fail — @@ -351,13 +385,13 @@ firewall. | Symptom | Likely cause | |---|---| -| Nothing listening on 8787; local `curl` refused | The server is not running: `sudo systemctl start passman-sync`, then read `journalctl -u passman-sync -n 50` if it will not stay up | +| Nothing listening on 8787; local `curl` refused | The server is not running: `sudo systemctl start secretspace-sync`, then read `journalctl -u secretspace-sync -n 50` if it will not stay up | | `curl` works on the server but not from a PC | Firewall: `sudo ufw status`, and check the network in the `allow` rule matches yours | | `Test-NetConnection` shows `PingSucceeded: False` | The PC cannot reach the server at all — different networks, or a wrong address. Compare with `ipconfig` on the PC | -| Service restarts over and over | `journalctl -u passman-sync -n 50`. Usually the token file's path or permissions, or `PASSMAN_HOST` in `sync.env` set to an address this machine no longer has — run the setup again | -| `Failed to load environment files` | `/etc/passman/sync.env` is missing — run `sudo python3 /opt/passman/server/passman_setup.py` | -| `PASSMAN_PORT must be a port number` in the log | `sync.env` was edited by hand into something that is not a port — run the setup again | -| `Cannot read the token file` in the log | `/etc/passman/token` is missing, or not owned by `passman` — redo the `chown` in step 2 | +| Service restarts over and over | `journalctl -u secretspace-sync -n 50`. Usually the token file's path or permissions, or `SECRETSPACE_HOST` in `sync.env` set to an address this machine no longer has — run the setup again | +| `Failed to load environment files` | `/etc/secretspace/sync.env` is missing — run `sudo python3 /opt/secretspace/server/secretspace_setup.py` | +| `SECRETSPACE_PORT must be a port number` in the log | `sync.env` was edited by hand into something that is not a port — run the setup again | +| `Cannot read the token file` in the log | `/etc/secretspace/token` is missing, or not owned by `secretspace` — redo the `chown` in step 2 | | `Address already in use` | Something else on the port — often the hand-started copy from step 3 still running | | SecretSpace: "rejected the sync token" | A stray space or line break when pasting; copy it again. Or the token was replaced with `new-token` — paste the new one | | SecretSpace: "Could not reach …" | Wrong address or port in Settings → Sync, the PC is on another network, or the firewall is blocking it | @@ -401,8 +435,8 @@ To get rid of a vault for good: its sync state on that PC. 2. **Then on the server:** ```bash - sudo ls /var/lib/passman/store/ # check the exact name - sudo rm -r /var/lib/passman/store/ + sudo ls /var/lib/secretspace/store/ # check the exact name + sudo rm -r /var/lib/secretspace/store/ ``` This removes the current copy, `meta.json` and all its history. The service does not need stopping; it reads the files on every request. @@ -422,12 +456,12 @@ simply puts it back. | Path | What | |---|---| -| `/opt/passman/server/passman_server.py` | the running copy of the server | -| `/opt/passman/server/passman_setup.py` | the setup: address, port, `new-token` | -| `/etc/systemd/system/passman-sync.service` | the service definition | -| `/etc/passman/sync.env` | the address and port (`PASSMAN_HOST`, `PASSMAN_PORT`) | -| `/etc/passman/token` | the shared token (`600`, owned by `passman`) | -| `/var/lib/passman/store/` | the encrypted vaults and their history | +| `/opt/secretspace/server/secretspace_server.py` | the running copy of the server | +| `/opt/secretspace/server/secretspace_setup.py` | the setup: address, port, `new-token` | +| `/etc/systemd/system/secretspace-sync.service` | the service definition | +| `/etc/secretspace/sync.env` | the address and port (`SECRETSPACE_HOST`, `SECRETSPACE_PORT`) | +| `/etc/secretspace/token` | the shared token (`600`, owned by `secretspace`) | +| `/var/lib/secretspace/store/` | the encrypted vaults and their history | | `~/SecretSpace-Server-Linux/` | your git checkout — only the source | ## LAN or VPN @@ -457,15 +491,15 @@ To protect the token too, add TLS with a self-signed certificate that the client pins. The `subjectAltName` must be the address the PCs connect to: ```bash -cd /etc/passman +cd /etc/secretspace sudo openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \ - -keyout server.key -out server.crt -subj "/CN=passman.internal" \ + -keyout server.key -out server.crt -subj "/CN=secretspace.internal" \ -addext "subjectAltName=IP:192.168.1.50" -sudo chown passman:passman server.key server.crt +sudo chown secretspace:secretspace server.key server.crt sudo chmod 600 server.key ``` -Then add `--cert /etc/passman/server.crt --key /etc/passman/server.key` to +Then add `--cert /etc/secretspace/server.crt --key /etc/secretspace/server.key` to the `ExecStart` line of the unit, reload and restart. Copy `server.crt` to each PC, set it as the certificate file in Settings → Sync, and change the address to `https://`. @@ -483,7 +517,7 @@ For a quick try-out, from the checkout: cd ~/SecretSpace-Server-Linux/server python3 -c "import secrets; print(secrets.token_urlsafe(32))" > token chmod 600 token -python3 passman_server.py --host 0.0.0.0 --port 8787 +python3 secretspace_server.py --host 0.0.0.0 --port 8787 ``` The token is read from `server/token` and vaults land in `server/store/`; @@ -504,9 +538,9 @@ Every route but `/health` needs `Authorization: Bearer `. `PUT` takes `{"blob": ""}` and requires `If-Match: ` — the revision you merged against. Two optional headers: -- `X-PassMan-Key-Generation: ` — how many times the vault's master +- `X-SecretSpace-Key-Generation: ` — how many times the vault's master password has changed. Stored as-is; kept unchanged when absent. -- `X-PassMan-Drop-History: 1` — discard every earlier revision, including +- `X-SecretSpace-Drop-History: 1` — discard every earlier revision, including the one being replaced. Sent after a password change. The server replies: @@ -518,11 +552,10 @@ The server replies: A first push may omit `If-Match`. `If-Match: *` overwrites whatever is there, which the client never sends — it exists for recovering by hand. -### API v2: SecretSpace 3 accounts +### API v2: accounts -Server **2.0** adds accounts for SecretSpace 3 (Windows 3.0.0, Linux -1.0.0). The paragraphs below name the test version in which each part -arrived; 2.0.0 has all of them. It sits beside the routes +Server **2.0** added accounts, for SecretSpace 3 and later. The paragraphs +below name the version in which each part arrived. It sits beside the routes above, which stay exactly as they are for 2.x clients. The server still never sees a master password, a Secret Key or a vault key: an account is the client's **Login Key** (stored as a salted scrypt hash), its public key, and @@ -561,7 +594,7 @@ you are not a member of answers 404, the same as one that does not exist. Server **2.0.0-alpha.2** lets an account hold **several vaults**: the personal one, which every account has, and up to **50** more (`MAX_VAULTS_PER_ACCOUNT` -in `passman_server.py`). The client makes a vault's id and key. A vault's +in `secretspace_server.py`). The client makes a vault's id and key. A vault's name arrives sealed under that key and the key arrives wrapped to the owner's public key, so the server can read neither; it only stores them and hands them back in `/v2/me`, where the personal vault comes first. A deleted vault is @@ -682,7 +715,7 @@ answers). ## What it keeps -Inside the store directory (`/var/lib/passman/store` with the service): +Inside the store directory (`/var/lib/secretspace/store` with the service): ``` /vault.enc the current blob — encrypted @@ -704,15 +737,15 @@ lets a PC tell whether the copy here is under an older or a newer password than its own. History is server-side undo, in the same spirit as the client's archive. To -roll back: `sudo systemctl stop passman-sync`, copy the revision you want over -`vault.enc` (keeping it owned by `passman`), delete `meta.json` so the +roll back: `sudo systemctl stop secretspace-sync`, copy the revision you want over +`vault.enc` (keeping it owned by `secretspace`), delete `meta.json` so the numbering restarts, and start the service again. Every client then sees a revision it did not expect and re-merges, which is the safe outcome. ## Backups This server is not a backup. It holds one copy of a file your PCs also hold, -and it will happily store a vault you damaged. Keep `/var/lib/passman/store` +and it will happily store a vault you damaged. Keep `/var/lib/secretspace/store` in whatever backs up the rest of the machine — the contents are encrypted, so a backup of it is no more sensitive than the vault itself. @@ -725,6 +758,33 @@ the token as a single trust boundary (API v2 accounts each have their own login). No push notification: the client syncs when you press the button. +### Two formats, side by side (2.1.0) + +SecretSpace 4 writes accounts and vaults in a new format (the same +cryptography; new markers and labels, which no longer carry the project's +old name). The server cannot read either, but it can tell them apart by +their first four bytes, and it keeps a few rules so that nobody is locked +out by someone else's update: + +- An account's **format** is 1 until its app sends its keyset in the new + form (`PUT /v2/me/keyset`), then 2. `GET /v2/me` and the members list of + a vault say which. **A converted account never goes back**: a device that + still sends the old form is told to update. +- A **vault** is stored in the new form only when every member's account + is converted. Until then it stays in the old form, and every member, + converted or not, reads and writes it that way. +- Once a vault is stored in the new form it takes no upload in the old one, + and no new member whose account is not converted. +- The recovery copy may stay in the old form after a conversion: it opens + only with the 12 words, so the app converts it when the owner types them. +- `/health` lists the feature `"format-2"`. + +The names from before the rename are still **read**, never written: the +headers `X-PassMan-Key-Generation` / `X-PassMan-Drop-History` from older +apps, and `PASSMAN_HOST` / `PASSMAN_PORT` / `PASSMAN_TOKEN` in the +environment. They will go together with the 2.x routes in a later major +version. + ## Versions The Linux server has its own version, in `server/version.py`, and each @@ -749,11 +809,11 @@ them on Ubuntu for every pull request and every push to `main`. bodies, the history limit and the access log. It is the same file in SecretSpace-Server-Windows. -`server/passman_server.py` and `server/passman_setup.py` are the same files -as in SecretSpace-Server-Windows — `passman_setup.py` holds the setup for both +`server/secretspace_server.py` and `server/secretspace_setup.py` are the same files +as in SecretSpace-Server-Windows — `secretspace_setup.py` holds the setup for both systems. Make a change to either in both repositories, so the two servers never drift apart. SecretSpace-Client-Windows and SecretSpace-Client-Linux each keep -a copy of `passman_server.py` for their end-to-end sync tests; refresh +a copy of `secretspace_server.py` for their end-to-end sync tests; refresh those too. ## License diff --git a/SECURITY.md b/SECURITY.md index 714b446..df15206 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ SecretSpace keeps passwords, so security reports are welcome and taken seriously Please **do not open a public issue**. Report it privately through GitHub: the repository's **Security** tab -> **Report a vulnerability**. -Include what you found, the version (`/opt/passman/server/version.py`), and the steps to +Include what you found, the version (`/opt/secretspace/server/version.py`), and the steps to reproduce it. You will get an answer within a week. Please give a reasonable time for a fix before you publish anything. diff --git a/server/passman-sync.service b/server/secretspace-sync.service similarity index 57% rename from server/passman-sync.service rename to server/secretspace-sync.service index ea26eea..7b35fbc 100644 --- a/server/passman-sync.service +++ b/server/secretspace-sync.service @@ -7,19 +7,19 @@ Wants=network-online.target Type=simple # These paths match the install steps in README.md. Change them only if you # put things somewhere else. -User=passman -Group=passman -WorkingDirectory=/opt/passman/server -# Where it listens comes from /etc/passman/sync.env (PASSMAN_HOST and -# PASSMAN_PORT), which passman_setup.py writes: run it again to change them, -# no edit here needed. 0.0.0.0 listens on every network this machine is on; -# limit who can connect with the firewall (README.md, step 4). The file is +User=secretspace +Group=secretspace +WorkingDirectory=/opt/secretspace/server +# Where it listens comes from /etc/secretspace/sync.env (the HOST and PORT +# settings), which the setup writes: run it again to change them, no edit +# here needed. 0.0.0.0 listens on every network this machine is on; limit +# who can connect with the firewall (README.md, step 4). The file is # required on purpose: without it the server would quietly fall back to # 127.0.0.1, which no PC can reach. -EnvironmentFile=/etc/passman/sync.env -ExecStart=/usr/bin/python3 /opt/passman/server/passman_server.py \ - --store /var/lib/passman/store \ - --token-file /etc/passman/token +EnvironmentFile=/etc/secretspace/sync.env +ExecStart=/usr/bin/python3 /opt/secretspace/server/secretspace_server.py \ + --store /var/lib/secretspace/store \ + --token-file /etc/secretspace/token Restart=on-failure RestartSec=5 @@ -30,7 +30,7 @@ NoNewPrivileges=yes PrivateTmp=yes ProtectSystem=strict ProtectHome=yes -ReadWritePaths=/var/lib/passman +ReadWritePaths=/var/lib/secretspace ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes diff --git a/server/passman_server.py b/server/secretspace_server.py similarity index 88% rename from server/passman_server.py rename to server/secretspace_server.py index 8632510..5b74ae7 100644 --- a/server/passman_server.py +++ b/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/server/passman_setup.py b/server/secretspace_setup.py similarity index 65% rename from server/passman_setup.py rename to server/secretspace_setup.py index f099e74..2dc1932 100644 --- a/server/passman_setup.py +++ b/server/secretspace_setup.py @@ -3,12 +3,14 @@ SecretSpace sync server setup -- where it listens, its token, its firewall rule. Linux (systemd): - sudo python3 passman_setup.py # ask, then write the settings - sudo python3 passman_setup.py new-token # replace a leaked token - sudo python3 passman_setup.py show-token # print the token + sudo python3 secretspace_setup.py # ask, then write the settings + sudo python3 secretspace_setup.py new-token # replace a leaked token + sudo python3 secretspace_setup.py show-token # print the token + sudo python3 secretspace_setup.py migrate # move an install from + # before the rename (below) Windows (SecretSpace-Server-Installer.exe runs the first one for you): - PassMan-Server-Setup.exe [setup] | new-token | show-token | remove + SecretSpace-Server-Setup.exe [setup] | new-token | show-token | remove `setup` asks which address to listen on (listing this machine's networks) and which port, writes them to sync.env, creates the token if there is none @@ -16,16 +18,23 @@ any other addresses you name, such as a PC on a network this machine is not on (`--allow-from 192.168.0.1`): -- Linux: sync.env is /etc/passman/sync.env, which the systemd unit loads. +- Linux: sync.env is /etc/secretspace/sync.env, which the systemd unit loads. The firewall rule is added when ufw is on; ufw is never switched on from here, because doing that over SSH without an SSH rule locks you out. -- Windows: everything lives in %ProgramData%\\PassMan Server. A Windows +- Windows: everything lives in %ProgramData%\\SecretSpace Server. A Windows Firewall rule and a Task Scheduler task (at startup, as LOCAL SERVICE) are created or replaced, and the server is (re)started. `new-token` writes a fresh token, restarts the server and prints the new value. Every PC then needs it in Settings -> Sync before it can sync again. +`migrate` is for a server installed while the project was still called +PassMan: it gives the folders, the service, its user and the settings the +current names. The port, the token and the vaults stay as they are, so no PC +needs a new setting. `migrate --dry-run` only shows what it would do; +`migrate --undo` puts the old names back. On Windows the installer does the +same by itself. + Standard library only, like the server. """ @@ -40,14 +49,35 @@ import subprocess sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from passman_server import DEFAULT_PORT, write_atomic # noqa: E402 - -SERVICE = "passman-sync" -SERVICE_USER = "passman" +from secretspace_server import (DEFAULT_PORT, ENV_PREFIX, # noqa: E402 + LEGACY_ENV_PREFIX, write_atomic) + +SERVICE = "secretspace-sync" +SERVICE_USER = "secretspace" +#: Linux: where the program, its settings and its vaults live. +PROGRAM_DIR = "/opt/secretspace" +CONFIG_DIR = "/etc/secretspace" +DATA_DIR = "/var/lib/secretspace" +UNIT_DIR = "/etc/systemd/system" +SERVER_FILE = "secretspace_server.py" #: Windows: the scheduled task, the firewall rule group and their names. -TASK_NAME = "PassMan Sync Server" -FIREWALL_GROUP = "PassMan Sync Server" +TASK_NAME = "SecretSpace Sync Server" +FIREWALL_GROUP = "SecretSpace Sync Server" +WINDOWS_DIR = "SecretSpace Server" + +# The project was called PassMan until November 2026. These are an install's +# names from then: `migrate` (and the Windows installer) move one to the +# names above. Nothing is ever created under them. +LEGACY_SERVICE = "passman-sync" +LEGACY_SERVICE_USER = "passman" +LEGACY_PROGRAM_DIR = "/opt/passman" +LEGACY_CONFIG_DIR = "/etc/passman" +LEGACY_DATA_DIR = "/var/lib/passman" +LEGACY_PROGRAM_FILES = ("passman_server.py", "passman_setup.py") +LEGACY_TASK_NAME = "PassMan Sync Server" +LEGACY_FIREWALL_GROUP = "PassMan Sync Server" +LEGACY_WINDOWS_DIR = "PassMan Server" #: Well-known SIDs, so icacls works on any Windows display language. SID_SYSTEM = "*S-1-5-18" SID_LOCAL_SERVICE = "*S-1-5-19" @@ -59,9 +89,10 @@ ALL_NETWORKS = "0.0.0.0" LOCALHOST = "127.0.0.1" -#: sync.env key for the extra addresses let through the firewall. Only the -#: setup reads it; the server ignores it. -ALLOW_KEY = "PASSMAN_ALLOW_FROM" +#: sync.env keys, without their prefix. ALLOW_FROM is the extra addresses +#: let through the firewall: only the setup reads it, the server ignores it. +SETTING_KEYS = ("HOST", "PORT", "ALLOW_FROM") +ALLOW_KEY = ENV_PREFIX + "ALLOW_FROM" #: What clears the list, typed at the question or given to --allow-from. NONE_WORDS = ("-", "none") @@ -73,8 +104,16 @@ def default_platform(): def default_config_dir(platform): if platform == "windows": base = os.environ.get("ProgramData", r"C:\ProgramData") - return os.path.join(base, "PassMan Server") - return "/etc/passman" + return os.path.join(base, WINDOWS_DIR) + return CONFIG_DIR + + +def legacy_config_dir(platform): + """Where an install from before the rename keeps its settings.""" + if platform == "windows": + base = os.environ.get("ProgramData", r"C:\ProgramData") + return os.path.join(base, LEGACY_WINDOWS_DIR) + return LEGACY_CONFIG_DIR def _run(cmd): @@ -171,14 +210,29 @@ def read_env(path): return values -def render_env(host, port, allow=()): - text = ("# SecretSpace sync server -- written by passman_setup.py.\n" +def read_settings(path): + """ + sync.env as {"HOST", "PORT", "ALLOW_FROM"} (those that are set). A file + written before the rename has the old prefix on its keys; it reads the + same, and the next write gives it the current ones. + """ + values = read_env(path) + found = {} + for key in SETTING_KEYS: + value = values.get(ENV_PREFIX + key, values.get(LEGACY_ENV_PREFIX + key)) + if value is not None: + found[key] = value + return found + + +def render_env(host, port, allow=(), prefix=ENV_PREFIX): + text = ("# SecretSpace sync server -- written by its setup.\n" "# Change it by running the setup again.\n" - f"PASSMAN_HOST={host}\n" - f"PASSMAN_PORT={port}\n") + f"{prefix}HOST={host}\n" + f"{prefix}PORT={port}\n") if allow: text += (f"# Also let through the firewall (the server ignores " - f"this).\n{ALLOW_KEY}={','.join(allow)}\n") + f"this).\n{prefix}ALLOW_FROM={','.join(allow)}\n") return text @@ -394,14 +448,15 @@ def firewall_linux(run, say, new_networks, port, old_networks, old_port): # --------------------------------------------------------------------------- # Windows: Task Scheduler and Windows Firewall # --------------------------------------------------------------------------- -#: The frozen server, next to PassMan-Server-Setup.exe in the install folder. -SERVER_EXE = "PassMan-Server.exe" +#: The frozen server, next to SecretSpace-Server-Setup.exe in the install +#: folder. +SERVER_EXE = "SecretSpace-Server.exe" def server_command(frozen=None, executable=None): """ (program, arguments before ours) that start the server. Frozen, that is - PassMan-Server.exe beside whichever exe is running this -- the setup + SecretSpace-Server.exe beside whichever exe is running this -- the setup exe must not put itself in the startup task. """ frozen = getattr(sys, "frozen", False) if frozen is None else frozen @@ -409,7 +464,7 @@ def server_command(frozen=None, executable=None): if frozen: return os.path.join(os.path.dirname(executable), SERVER_EXE), [] return executable, [os.path.join(os.path.dirname( - os.path.abspath(__file__)), "passman_server.py")] + os.path.abspath(__file__)), SERVER_FILE)] def task_arguments(config_dir, host, port, prefix=()): @@ -510,29 +565,31 @@ def cmd_setup(args, ask=input, say=print, run=_run): windows = args.platform == "windows" env_path = os.path.join(args.config_dir, "sync.env") token_path = os.path.join(args.config_dir, "token") - current = read_env(env_path) + if windows and args.default_dir: + migrate_windows(run, say, legacy_config_dir("windows"), args.config_dir) + current = read_settings(env_path) addresses = list_addresses(run, args.platform) - keep = args.keep and "PASSMAN_HOST" in current and "PASSMAN_PORT" in current + keep = args.keep and "HOST" in current and "PORT" in current if args.host or keep: - host, problem = check_host(args.host or current["PASSMAN_HOST"], + host, problem = check_host(args.host or current["HOST"], addresses) if problem: sys.exit(problem) else: host = choose_host(ask, say, addresses, - current.get("PASSMAN_HOST", ALL_NETWORKS)) + current.get("HOST", ALL_NETWORKS)) if args.port or keep: - port, problem = check_port(str(args.port or current["PASSMAN_PORT"])) + port, problem = check_port(str(args.port or current["PORT"])) if problem: sys.exit(problem) else: port = choose_port(ask, say, - current.get("PASSMAN_PORT", str(DEFAULT_PORT))) + current.get("PORT", str(DEFAULT_PORT))) # The extra addresses: --allow-from sets them; with the other questions # answered by flags they stay as saved; otherwise they are asked too. - old_allow, _ = check_allow(current.get(ALLOW_KEY)) + old_allow, _ = check_allow(current.get("ALLOW_FROM")) old_allow = old_allow or [] if args.allow_from is not None: allow, problem = check_allow(",".join(args.allow_from)) @@ -543,8 +600,7 @@ def cmd_setup(args, ask=input, say=print, run=_run): else: allow = choose_allow(ask, say, old_allow) - changed = (current.get("PASSMAN_HOST"), current.get("PASSMAN_PORT")) \ - != (host, str(port)) + changed = (current.get("HOST"), current.get("PORT")) != (host, str(port)) if changed and port_in_use(host if host != ALL_NETWORKS else "", port): say(f"Note: something is already listening on port {port}. If it is " f"not this server, pick another port.") @@ -577,8 +633,8 @@ def cmd_setup(args, ask=input, say=print, run=_run): f"Created the task, but it did not start. Open Task Scheduler, " f"find '{TASK_NAME}' and check its last result.") else: - old_host = current.get("PASSMAN_HOST") - old_port = current.get("PASSMAN_PORT") + old_host = current.get("HOST") + old_port = current.get("PORT") old_sources = [*(n for _, n in networks_for(old_host, addresses)), *old_allow] if old_host and old_port else [] new_sources = [] if host == LOCALHOST else \ @@ -638,6 +694,222 @@ def cmd_remove(args, ask=input, say=print, run=_run): f"the settings are still in {args.config_dir}.") +# --------------------------------------------------------------------------- +# An install from before the rename +# --------------------------------------------------------------------------- +def unit_text(user=SERVICE_USER, program_dir=PROGRAM_DIR, + config_dir=CONFIG_DIR, data_dir=DATA_DIR) -> str: + """The systemd unit, as server/secretspace-sync.service ships it.""" + return f"""[Unit] +Description=SecretSpace sync server (encrypted vault blob store) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# These paths match the install steps in README.md. Change them only if you +# put things somewhere else. +User={user} +Group={user} +WorkingDirectory={program_dir}/server +# Where it listens comes from {config_dir}/sync.env (the HOST and PORT +# settings), which the setup writes: run it again to change them, no edit +# here needed. 0.0.0.0 listens on every network this machine is on; limit +# who can connect with the firewall (README.md, step 4). The file is +# required on purpose: without it the server would quietly fall back to +# 127.0.0.1, which no PC can reach. +EnvironmentFile={config_dir}/sync.env +ExecStart=/usr/bin/python3 {program_dir}/server/{SERVER_FILE} \\ + --store {data_dir}/store \\ + --token-file {config_dir}/token + +Restart=on-failure +RestartSec=5 + +# The service reads one token and writes one directory; nothing else on the +# filesystem needs to be reachable from it. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths={data_dir} +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes + +[Install] +WantedBy=multi-user.target +""" + + +#: Everything `migrate` renames on Linux, as (old, new); --undo swaps them. +_NOW = {"service": SERVICE, "user": SERVICE_USER, "program": PROGRAM_DIR, + "config": CONFIG_DIR, "data": DATA_DIR, "prefix": ENV_PREFIX} +_BEFORE = {"service": LEGACY_SERVICE, "user": LEGACY_SERVICE_USER, + "program": LEGACY_PROGRAM_DIR, "config": LEGACY_CONFIG_DIR, + "data": LEGACY_DATA_DIR, "prefix": LEGACY_ENV_PREFIX} + + +def _user_exists(run, name) -> bool: + return ok(run(["id", "-u", name])) + + +def cmd_migrate(args, ask=input, say=print, run=_run): + """ + Linux: move an install from before the rename to the current names -- + folders, service, its user, the settings' keys. The port, the token and + the vaults are not changed. Windows: the installer's setup step does the + same (migrate_windows); here it only says so. + """ + if args.platform == "windows": + say("On Windows the installer does this: run the current " + "SecretSpace-Server-Installer.exe.") + return + root = getattr(args, "root", "") or "" + + def at(path): + return os.path.join(root, path.lstrip("/")) if root else path + + old, new = (_NOW, _BEFORE) if args.undo else (_BEFORE, _NOW) + folders = [(old[k], new[k]) for k in ("program", "config", "data")] + present = [(a, b) for a, b in folders if os.path.isdir(at(a))] + if not present: + say(f"Nothing to move: there is no {old['config']} here. " + + ("This server already has the current names." + if os.path.isdir(at(new["config"])) else + "This machine has no SecretSpace sync server.")) + return + in_the_way = [b for _, b in present if os.path.exists(at(b))] + if in_the_way: + sys.exit("Both the old and the new folders exist: " + + ", ".join(in_the_way) + ". Nothing was changed. Look at " + "them, remove the ones that are not in use, and run this " + "again.") + + rename_user = _user_exists(run, old["user"]) and \ + not _user_exists(run, new["user"]) + say("Back up first:") + say(" sudo tar czf ~/secretspace-server-backup.tgz " + + " ".join(a for a, _ in present)) + say("") + say("This will:") + say(f" - stop and disable the service {old['service']}") + if rename_user: + say(f" - rename the user and group {old['user']} to {new['user']}") + for a, b in present: + say(f" - move {a} to {b}") + say(f" - write {new['config']}/sync.env with the " + f"{new['prefix']}... settings") + say(f" - install {new['service']}.service, remove " + f"{old['service']}.service, and start {new['service']}") + say("The port, the token and the vaults stay as they are.") + if args.dry_run: + say("") + say("Dry run: nothing was changed.") + return + if not args.yes and ask("Go ahead? [y/N]: ").strip().lower() not in ( + "y", "yes"): + say("Nothing was changed.") + return + + systemd = bool(shutil.which("systemctl")) + if systemd: + run(["systemctl", "stop", old["service"]]) + run(["systemctl", "disable", old["service"]]) + if rename_user: + for cmd in (["usermod", "-l", new["user"], old["user"]], + ["groupmod", "-n", new["user"], old["user"]]): + result = run(cmd) + if not ok(result): + sys.exit(f"Could not run {' '.join(cmd)}: " + f"{(result.stderr if result else '').strip()}\n" + "Nothing else was changed. Start the old service " + f"again with: sudo systemctl enable --now " + f"{old['service']}") + say(f"Renamed the user {old['user']} to {new['user']}.") + for a, b in present: + os.makedirs(os.path.dirname(at(b)), exist_ok=True) + shutil.move(at(a), at(b)) + say(f"Moved {a} to {b}.") + + env_path = os.path.join(at(new["config"]), "sync.env") + settings = read_settings(env_path) + if "HOST" in settings and "PORT" in settings: + allow, _ = check_allow(settings.get("ALLOW_FROM")) + write_atomic(env_path, render_env(settings["HOST"], settings["PORT"], + allow or [], new["prefix"]).encode()) + os.chmod(env_path, 0o644) + say(f"Wrote {new['config']}/sync.env.") + + # The program files of the old name, once the current ones are beside them. + server_dir = os.path.join(at(new["program"]), "server") + if not args.undo and os.path.isfile(os.path.join(server_dir, SERVER_FILE)): + for name in LEGACY_PROGRAM_FILES: + try: + os.remove(os.path.join(server_dir, name)) + except OSError: + pass + + unit_dir = at(UNIT_DIR) + os.makedirs(unit_dir, exist_ok=True) + write_atomic(os.path.join(unit_dir, new["service"] + ".service"), + unit_text(new["user"], new["program"], new["config"], + new["data"]).encode()) + try: + os.remove(os.path.join(unit_dir, old["service"] + ".service")) + except OSError: + pass + if systemd: + run(["systemctl", "daemon-reload"]) + result = run(["systemctl", "enable", "--now", new["service"]]) + say(f"Started {new['service']}." if ok(result) else + f"Could not start {new['service']}: " + f"{(result.stderr if result else '').strip()}\n" + f"Look at: journalctl -u {new['service']} -n 50") + else: + say("No systemd here; start the server yourself.") + if "PORT" in settings: + say(f"Check it: curl http://127.0.0.1:{settings['PORT']}/health") + say("The other way round: sudo python3 " + f"{new['program']}/server/secretspace_setup.py migrate" + + ("" if args.undo else " --undo")) + + +def migrate_windows(run, say, old_dir, new_dir) -> bool: + """ + Windows: an install from before the rename. Its task and firewall rules + go (the setup that follows makes the current ones), its data folder + moves to the current name. True when there was one. Port, token and + vaults are not changed. + """ + if not os.path.isdir(old_dir): + return False + run(powershell( + f"Stop-ScheduledTask -TaskName {ps_quote(LEGACY_TASK_NAME)} " + "-ErrorAction SilentlyContinue; " + f"Unregister-ScheduledTask -TaskName {ps_quote(LEGACY_TASK_NAME)} " + "-Confirm:$false -ErrorAction SilentlyContinue; " + f"Remove-NetFirewallRule -Group {ps_quote(LEGACY_FIREWALL_GROUP)} " + "-ErrorAction SilentlyContinue")) + if os.path.exists(new_dir): + say(f"Both {old_dir} and {new_dir} exist; using {new_dir} and " + f"leaving the other alone.") + return True + try: + shutil.move(old_dir, new_dir) + except OSError as e: + sys.exit(f"Could not move {old_dir} to {new_dir}: {e}. Close " + "anything that has it open and run the installer again.") + # Its settings file still has the old keys; the setup that called this + # reads them and writes the file again with the current ones. + say(f"Moved the server's data from {old_dir} to {new_dir}.") + return True + + #: What the menu offers, in order: (label, command). MENU = (("Change network, port or allowed addresses", "setup"), ("Replace the token", "new-token"), @@ -646,19 +918,18 @@ def cmd_remove(args, ask=input, say=print, run=_run): def cmd_menu(args, ask=input, say=print, run=_run): """ - Everything in one place: what PassMan-Server-Setup.exe shows when it is + Everything in one place: what SecretSpace-Server-Setup.exe shows when it is double-clicked. Each choice runs its command, then the menu comes back, until Close. """ env_path = os.path.join(args.config_dir, "sync.env") close = str(len(MENU) + 1) while True: - current = read_env(env_path) + current = read_settings(env_path) say("") say("SecretSpace sync server") - if "PASSMAN_HOST" in current and "PASSMAN_PORT" in current: - say(f" Listening on {current['PASSMAN_HOST']}:" - f"{current['PASSMAN_PORT']}") + if "HOST" in current and "PORT" in current: + say(f" Listening on {current['HOST']}:{current['PORT']}") else: say(" Not set up yet.") say("") @@ -686,7 +957,7 @@ def cmd_menu(args, ask=input, say=print, run=_run): COMMANDS = {"setup": cmd_setup, "new-token": cmd_new_token, "show-token": cmd_show_token, "remove": cmd_remove, - "menu": cmd_menu} + "menu": cmd_menu, "migrate": cmd_migrate} def is_admin(platform): @@ -717,8 +988,8 @@ def parse_args(argv): parser = argparse.ArgumentParser(description=__doc__.strip().split("\n")[0]) parser.add_argument("--config-dir", help="where sync.env and the token live (default: " - "/etc/passman, or %%ProgramData%%\\PassMan " - "Server on Windows)") + "/etc/secretspace, or %%ProgramData%%\\" + "SecretSpace Server on Windows)") parser.add_argument("--platform", choices=("linux", "windows"), default=default_platform(), help=argparse.SUPPRESS) parser.add_argument("--pause", action="store_true", @@ -742,9 +1013,19 @@ def parse_args(argv): sub.add_parser("show-token", help="print the token") sub.add_parser("remove", help="Windows: remove the task and firewall rule") sub.add_parser("menu", help="choose one of the above from a list") + migrate = sub.add_parser("migrate", help="Linux: give an install from " + "before the rename the current names") + migrate.add_argument("--dry-run", action="store_true", + help="only show what would be done") + migrate.add_argument("--undo", action="store_true", + help="put the old names back") + migrate.add_argument("--yes", action="store_true", + help="do not ask for confirmation") + migrate.add_argument("--root", default="", help=argparse.SUPPRESS) # No command means setup, with every question asked. parser.set_defaults(command="setup", host=None, port=None, keep=False, - allow_from=None, yes=False) + allow_from=None, yes=False, dry_run=False, undo=False, + root="") args = parser.parse_args(argv) args.default_dir = args.config_dir is None if args.config_dir is None: @@ -754,7 +1035,11 @@ def parse_args(argv): def main(argv=None, ask=input, say=print, run=_run): args = parse_args(argv) - if args.default_dir and not is_admin(args.platform): + if args.command == "migrate" and args.platform != "windows" \ + and not args.root and not args.dry_run and not is_admin("linux"): + sys.exit("Run it with sudo: it renames the service and its folders.") + if args.default_dir and args.command != "migrate" \ + and not is_admin(args.platform): sys.exit("Run it as administrator: it writes to " f"{args.config_dir}." if args.platform == "windows" else f"Run it with sudo: it writes to {args.config_dir}.") diff --git a/server/version.py b/server/version.py index f83f74a..b9b0f77 100644 --- a/server/version.py +++ b/server/version.py @@ -3,8 +3,8 @@ server as this repository ships them. Bump it on release and tag the release commit v. -It is kept apart from passman_server.py on purpose: that file is the same +It is kept apart from secretspace_server.py on purpose: that file is the same in SecretSpace-Server-Windows, which has its own version. """ -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/tests/test_old_name.py b/tests/test_old_name.py new file mode 100644 index 0000000..7f8f848 --- /dev/null +++ b/tests/test_old_name.py @@ -0,0 +1,56 @@ +""" +The project was called PassMan until November 2026. The old name may stay +in exactly one kind of place in the program: a constant named ``LEGACY_...`` +(a name that is only ever read, to move an older install or serve an older +app) and the sentence that introduces those constants. Anything else is a +leftover of the rename, and this test names the line. +""" + +import os +import re + +SERVER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "server") +OLD = re.compile(r"passman|PMV3|PMK3", re.IGNORECASE) +#: Prose that explains the constants; nothing a program acts on. +EXPLAINS = ("The project was called PassMan until November 2026", + "PassMan: it gives the folders, the service, its user") + + +def leftovers(): + found = [] + for name in sorted(os.listdir(SERVER)): + path = os.path.join(SERVER, name) + if not os.path.isfile(path): + continue + if OLD.search(name): + found.append(f"{name}: the file's own name") + with open(path, encoding="utf-8") as f: + for number, line in enumerate(f, 1): + if not OLD.search(line): + continue + if re.match(r"\s*LEGACY_[A-Z_]+ = ", line): + continue + if any(text in line for text in EXPLAINS): + continue + found.append(f"{name}:{number}: {line.strip()}") + return found + + +def test_the_old_name_is_only_in_the_legacy_constants(): + assert leftovers() == [] + + +def test_the_legacy_constants_are_never_written_with(): + """They are read to find old things; what is written uses the current + names. A legacy name on the left of a write is the mistake to catch.""" + writes = re.compile(r"(render_env\([^)]*LEGACY|" + r"headers\[[^\]]*LEGACY|" + r"environ\[[^\]]*LEGACY[^\]]*\]\s*=)") + bad = [] + for name in sorted(os.listdir(SERVER)): + if name.endswith(".py"): + with open(os.path.join(SERVER, name), encoding="utf-8") as f: + for number, line in enumerate(f, 1): + if writes.search(line): + bad.append(f"{name}:{number}: {line.strip()}") + assert bad == [] diff --git a/tests/test_server_http.py b/tests/test_server_http.py index 2c76aeb..3d9949c 100644 --- a/tests/test_server_http.py +++ b/tests/test_server_http.py @@ -26,7 +26,7 @@ sys.path.insert(0, os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "server")) -import passman_server # noqa: E402 +import secretspace_server # noqa: E402 TOKEN = "test-token-that-is-long-enough" @@ -84,9 +84,9 @@ def history(self, user): @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) # serve_forever notices shutdown() only between polls; the default half @@ -107,8 +107,9 @@ def server(tmp_path): def test_health_answers_without_a_token(server): status, _, body = server.request("GET", "/health", token=None) assert status == 200 - assert body == {"ok": True, "service": "passman-sync", "api": [1, 2], - "features": ["vaults", "sharing", "recovery", "signed-keys"]} + assert body == {"ok": True, "service": "secretspace-sync", "api": [1, 2], + "features": ["vaults", "sharing", "recovery", "signed-keys", + "format-2"]} @pytest.mark.parametrize("header", [ @@ -314,13 +315,13 @@ def test_two_pushes_racing_for_one_revision_cannot_both_win(server, check before either had written -- and one PC's changes would vanish. """ server.push("alice", VAULT_1) - real_write = passman_server.Store.write + real_write = secretspace_server.Store.write def slow_write(self, *args, **kwargs): time.sleep(0.3) return real_write(self, *args, **kwargs) - monkeypatch.setattr(passman_server.Store, "write", slow_write) + monkeypatch.setattr(secretspace_server.Store, "write", slow_write) results = [] start = threading.Barrier(2) @@ -379,7 +380,7 @@ def test_a_nonsense_content_length_is_refused(server): def test_a_body_larger_than_any_vault_is_refused_unread(server): """Refused on the declared length alone, before reading a byte of it.""" status, _, payload = raw_put(server, b"", - content_length=passman_server.MAX_BODY + 1) + content_length=secretspace_server.MAX_BODY + 1) assert status == 413 assert server.store.exists("alice") is False @@ -404,7 +405,7 @@ def test_each_push_keeps_the_revision_it_replaces(server): def test_only_the_newest_revisions_are_kept(server, monkeypatch): - monkeypatch.setattr(passman_server, "HISTORY_LIMIT", 3) + monkeypatch.setattr(secretspace_server, "HISTORY_LIMIT", 3) server.push("alice", VAULT_1) for revision in range(1, 6): status, _, _ = server.push("alice", VAULT_2, if_match=str(revision)) @@ -430,7 +431,7 @@ def test_drop_history_clears_every_earlier_revision(server): server.push("alice", VAULT_1, if_match="2") assert len(server.history("alice")) == 2 status, _, meta = server.push("alice", VAULT_2, if_match="3", - **{"X-PassMan-Drop-History": "1"}) + **{"X-SecretSpace-Drop-History": "1"}) assert status == 200 and meta["revision"] == 4 assert server.history("alice") == [] # not even revision 3 is kept assert server.store.read("alice") == VAULT_2.encode() @@ -439,7 +440,7 @@ def test_drop_history_clears_every_earlier_revision(server): def test_drop_history_only_means_1(server): server.push("alice", VAULT_1) server.push("alice", VAULT_2, if_match="1", - **{"X-PassMan-Drop-History": "yes"}) + **{"X-SecretSpace-Drop-History": "yes"}) assert server.history("alice") == ["00000001.enc"] @@ -447,12 +448,12 @@ def test_drop_history_only_means_1(server): def test_a_key_generation_is_stored_and_kept_until_changed(server): _, _, meta = server.push("alice", VAULT_1, - **{"X-PassMan-Key-Generation": "2"}) + **{"X-SecretSpace-Key-Generation": "2"}) assert meta["key_generation"] == 2 _, _, meta = server.push("alice", VAULT_2, if_match="1") # header left out assert meta["key_generation"] == 2 _, _, meta = server.push("alice", VAULT_1, if_match="2", - **{"X-PassMan-Key-Generation": "3"}) + **{"X-SecretSpace-Key-Generation": "3"}) assert meta["key_generation"] == 3 _, _, pulled = server.pull("alice") assert pulled["key_generation"] == 3 @@ -461,9 +462,9 @@ def test_a_key_generation_is_stored_and_kept_until_changed(server): @pytest.mark.parametrize("generation", ["-1", "two", "1.5", ""]) def test_a_nonsense_key_generation_is_refused(server, generation): status, _, body = server.push("alice", VAULT_1, - **{"X-PassMan-Key-Generation": generation}) + **{"X-SecretSpace-Key-Generation": generation}) assert status == 400 - assert "Key-Generation" in body["error"] + assert "key generation" in body["error"] assert server.store.exists("alice") is False @@ -479,7 +480,7 @@ def test_the_log_names_the_request_but_never_the_body_or_token(server, def test_the_log_leaves_out_the_query_string(server, capsys): - """passman_server.Handler.log_message promises this.""" + """secretspace_server.Handler.log_message promises this.""" server.request("GET", "/vault/alice/meta?leak=something-private") server.request("GET", "/health?also=private-too", token=None) log = capsys.readouterr().err diff --git a/tests/test_server_migrate.py b/tests/test_server_migrate.py new file mode 100644 index 0000000..594b3f7 --- /dev/null +++ b/tests/test_server_migrate.py @@ -0,0 +1,429 @@ +""" +`secretspace_setup.py migrate`: an install from before the rename gets the +current names -- folders, service, its user, the settings' keys -- and the +port, the token and the vaults stay exactly as they were. + +A whole Linux install is laid out under a temporary root; `systemctl`, +`usermod`, `groupmod` and `id` are a fake that records what was run. +""" + +import os +import sys +import types + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "server")) +import secretspace_server # noqa: E402 +import secretspace_setup # noqa: E402 + +OLD_ENV = ("# PassMan sync server\n" + "PASSMAN_HOST=0.0.0.0\n" + "PASSMAN_PORT=59872\n" + "PASSMAN_ALLOW_FROM=172.17.17.2,192.168.0.106\n") +TOKEN = "the-token-stays-the-same\n" +VAULT = b"ciphertext of a vault" + + +class Box: + """A machine: the users it has and every command that was run on it.""" + + def __init__(self, users=("passman",), fail=()): + self.users = set(users) + self.fail = fail + self.calls = [] + + def __call__(self, cmd): + self.calls.append(list(cmd)) + text = " ".join(cmd) + if any(needle in text for needle in self.fail): + return types.SimpleNamespace(returncode=1, stdout="", stderr="no") + if cmd[:2] == ["id", "-u"]: + return types.SimpleNamespace( + returncode=0 if cmd[2] in self.users else 1, stdout="", + stderr="") + if cmd[0] == "usermod": + self.users.discard(cmd[3]) + self.users.add(cmd[2]) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + def ran(self, *words): + return [c for c in self.calls if c[:len(words)] == list(words)] + + +@pytest.fixture(autouse=True) +def systemctl_present(monkeypatch): + monkeypatch.setattr(secretspace_setup.shutil, "which", lambda name: "/bin/x") + + +def write(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(data if isinstance(data, bytes) else data.encode()) + + +def read(path): + with open(path, "rb") as f: + return f.read() + + +@pytest.fixture +def old_install(tmp_path): + """An install as 1.5.0 / 2.0.0 left it, with the 2.1.0 program files + copied in beside the old ones (the update step before `migrate`).""" + root = str(tmp_path) + write(os.path.join(root, "opt/passman/server/passman_server.py"), "old") + write(os.path.join(root, "opt/passman/server/passman_setup.py"), "old") + write(os.path.join(root, "opt/passman/server/secretspace_server.py"), "new") + write(os.path.join(root, "opt/passman/server/secretspace_setup.py"), "new") + write(os.path.join(root, "etc/passman/sync.env"), OLD_ENV) + write(os.path.join(root, "etc/passman/token"), TOKEN) + write(os.path.join(root, "var/lib/passman/store/alice/vault.enc"), VAULT) + write(os.path.join(root, "var/lib/passman/store/_v2/accounts/alice.json"), + "{}") + write(os.path.join(root, "etc/systemd/system/passman-sync.service"), "old") + return root + + +def migrate(root, *flags, box=None, answers=()): + box = box or Box() + answers = list(answers) + said = [] + secretspace_setup.main(["--platform", "linux", "migrate", "--root", root, + *flags], + ask=lambda prompt: answers.pop(0), + say=said.append, run=box) + assert not answers, "not every answer was asked for" + return "\n".join(said), box + + +def tree(root): + found = {} + for folder, _, files in os.walk(root): + for name in files: + path = os.path.join(folder, name) + found[os.path.relpath(path, root).replace("\\", "/")] = read(path) + return found + + +# --- the move ------------------------------------------------------------------- + +def test_migrate_moves_the_three_folders(old_install): + migrate(old_install, "--yes") + for gone in ("opt/passman", "etc/passman", "var/lib/passman"): + assert not os.path.exists(os.path.join(old_install, gone)) + for there in ("opt/secretspace/server", "etc/secretspace", + "var/lib/secretspace/store"): + assert os.path.isdir(os.path.join(old_install, there)) + + +def test_the_token_and_the_vaults_are_exactly_as_they_were(old_install): + migrate(old_install, "--yes") + new = os.path.join(old_install, "var/lib/secretspace/store") + assert read(os.path.join(old_install, "etc/secretspace/token")) == \ + TOKEN.encode() + assert read(os.path.join(new, "alice/vault.enc")) == VAULT + assert read(os.path.join(new, "_v2/accounts/alice.json")) == b"{}" + + +def test_the_settings_keep_their_values_under_the_current_keys(old_install): + migrate(old_install, "--yes") + path = os.path.join(old_install, "etc/secretspace/sync.env") + values = secretspace_setup.read_env(path) + assert values == {"SECRETSPACE_HOST": "0.0.0.0", + "SECRETSPACE_PORT": "59872", + "SECRETSPACE_ALLOW_FROM": "172.17.17.2,192.168.0.106"} + assert "passman" not in read(path).decode().lower() + + +def test_the_server_reads_what_migrate_wrote(old_install): + migrate(old_install, "--yes") + values = secretspace_setup.read_env( + os.path.join(old_install, "etc/secretspace/sync.env")) + args = types.SimpleNamespace(host=None, port=None) + assert secretspace_server.resolve_listen(args, values) == ("0.0.0.0", 59872) + + +def test_the_user_and_group_are_renamed_not_made_anew(old_install): + _, box = migrate(old_install, "--yes") + assert box.ran("usermod") == [["usermod", "-l", "secretspace", "passman"]] + assert box.ran("groupmod") == [["groupmod", "-n", "secretspace", "passman"]] + assert not box.ran("useradd") and not box.ran("chown") + + +def test_a_user_that_already_has_the_current_name_is_left_alone(old_install): + _, box = migrate(old_install, "--yes", box=Box(users=("secretspace",))) + assert not box.ran("usermod") and not box.ran("groupmod") + + +def test_with_both_users_there_neither_is_renamed(old_install): + """A half-done move by hand: do not rename one user onto another.""" + _, box = migrate(old_install, "--yes", + box=Box(users=("passman", "secretspace"))) + assert not box.ran("usermod") and not box.ran("groupmod") + assert os.path.isdir(os.path.join(old_install, "etc/secretspace")) + + +def test_the_old_service_is_stopped_before_anything_moves(old_install): + _, box = migrate(old_install, "--yes") + first = [c for c in box.calls if c[0] != "id"][:2] + assert first == [["systemctl", "stop", "passman-sync"], + ["systemctl", "disable", "passman-sync"]] + + +def test_the_current_unit_is_installed_and_the_old_one_removed(old_install): + _, box = migrate(old_install, "--yes") + units = os.path.join(old_install, "etc/systemd/system") + assert sorted(os.listdir(units)) == ["secretspace-sync.service"] + unit = read(os.path.join(units, "secretspace-sync.service")).decode() + assert unit == secretspace_setup.unit_text() + assert box.calls[-2:] == [["systemctl", "daemon-reload"], + ["systemctl", "enable", "--now", + "secretspace-sync"]] + + +def test_the_unit_it_installs_is_the_one_the_repository_ships(): + shipped = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", + "server", "secretspace-sync.service") + if not os.path.exists(shipped): + pytest.skip("this repository ships no systemd unit (Windows server)") + with open(shipped, encoding="utf-8", newline="") as f: + assert f.read() == secretspace_setup.unit_text() + + +def test_the_unit_names_nothing_from_before_the_rename(): + assert "passman" not in secretspace_setup.unit_text().lower() + + +def test_the_old_program_files_go_once_the_current_ones_are_there(old_install): + migrate(old_install, "--yes") + server = os.path.join(old_install, "opt/secretspace/server") + assert sorted(os.listdir(server)) == ["secretspace_server.py", + "secretspace_setup.py"] + + +def test_the_old_program_files_stay_if_the_current_ones_are_missing(old_install): + for name in ("secretspace_server.py", "secretspace_setup.py"): + os.remove(os.path.join(old_install, "opt/passman/server", name)) + migrate(old_install, "--yes") + server = os.path.join(old_install, "opt/secretspace/server") + assert sorted(os.listdir(server)) == ["passman_server.py", + "passman_setup.py"] + + +def test_it_says_how_to_check_and_how_to_go_back(old_install): + said, _ = migrate(old_install, "--yes") + assert "curl http://127.0.0.1:59872/health" in said + assert "migrate --undo" in said + + +# --- asking first ---------------------------------------------------------------- + +def test_it_shows_the_plan_and_a_backup_line_before_asking(old_install): + said, _ = migrate(old_install, answers=["n"]) + assert "sudo tar czf" in said and "/var/lib/passman" in said + assert "move /etc/passman to /etc/secretspace" in said + assert "rename the user and group passman to secretspace" in said + assert "Nothing was changed." in said + + +def test_no_means_nothing_is_touched(old_install): + before = tree(old_install) + _, box = migrate(old_install, answers=["n"]) + assert tree(old_install) == before + assert [c for c in box.calls if c[0] != "id"] == [] + + +def test_yes_at_the_question_does_it(old_install): + migrate(old_install, answers=["y"]) + assert os.path.isdir(os.path.join(old_install, "etc/secretspace")) + + +def test_dry_run_only_shows(old_install): + before = tree(old_install) + said, box = migrate(old_install, "--dry-run") + assert "Dry run: nothing was changed." in said + assert tree(old_install) == before + assert [c for c in box.calls if c[0] != "id"] == [] + + +# --- when there is nothing to do, or it cannot be done ---------------------------- + +def test_a_server_that_already_has_the_current_names_is_left_alone(old_install): + migrate(old_install, "--yes") + before = tree(old_install) + said, box = migrate(old_install, "--yes") + assert "already has the current names" in said + assert tree(old_install) == before and box.calls == [] + + +def test_a_machine_without_a_server_is_told_so(tmp_path): + said, box = migrate(str(tmp_path), "--yes") + assert "no SecretSpace sync server" in said and box.calls == [] + + +def test_old_and_new_folders_together_stop_it_untouched(old_install): + write(os.path.join(old_install, "etc/secretspace/sync.env"), "x") + before = tree(old_install) + with pytest.raises(SystemExit) as stop: + migrate(old_install, "--yes") + assert "/etc/secretspace" in str(stop.value) + assert "Nothing was changed" in str(stop.value) + assert tree(old_install) == before + + +def test_a_user_that_cannot_be_renamed_stops_it_before_any_move(old_install): + with pytest.raises(SystemExit) as stop: + migrate(old_install, "--yes", box=Box(fail=("usermod",))) + assert "usermod" in str(stop.value) + assert "systemctl enable --now passman-sync" in str(stop.value) + assert os.path.isdir(os.path.join(old_install, "etc/passman")) + assert not os.path.exists(os.path.join(old_install, "etc/secretspace")) + + +def test_an_install_without_a_settings_file_still_moves(old_install): + os.remove(os.path.join(old_install, "etc/passman/sync.env")) + said, _ = migrate(old_install, "--yes") + assert os.path.isdir(os.path.join(old_install, "etc/secretspace")) + assert "sync.env" not in os.listdir( + os.path.join(old_install, "etc/secretspace")) + + +def test_without_systemd_it_says_to_start_the_server_by_hand(old_install, + monkeypatch): + monkeypatch.setattr(secretspace_setup.shutil, "which", lambda name: None) + said, box = migrate(old_install, "--yes") + assert "start the server yourself" in said + assert not box.ran("systemctl") + assert os.path.isdir(os.path.join(old_install, "etc/secretspace")) + + +# --- the way back ----------------------------------------------------------------- + +def test_undo_puts_every_name_back(old_install): + data_before = {k: v for k, v in tree(old_install).items() + if k.startswith(("var/", "etc/passman/token"))} + migrate(old_install, "--yes") + _, box = migrate(old_install, "--undo", "--yes", + box=Box(users=("secretspace",))) + for there in ("opt/passman/server", "etc/passman", "var/lib/passman/store"): + assert os.path.isdir(os.path.join(old_install, there)) + for gone in ("opt/secretspace", "etc/secretspace", "var/lib/secretspace"): + assert not os.path.exists(os.path.join(old_install, gone)) + now = tree(old_install) + assert {k: now[k] for k in data_before} == data_before + assert box.ran("usermod") == [["usermod", "-l", "passman", "secretspace"]] + assert secretspace_setup.read_env( + os.path.join(old_install, "etc/passman/sync.env"))["PASSMAN_PORT"] == \ + "59872" + units = os.path.join(old_install, "etc/systemd/system") + assert os.listdir(units) == ["passman-sync.service"] + unit = read(os.path.join(units, "passman-sync.service")).decode() + # The old names, running the program that is there now. + assert "/opt/passman/server/secretspace_server.py" in unit + assert "User=passman" in unit and "/etc/passman/sync.env" in unit + assert box.calls[-1] == ["systemctl", "enable", "--now", "passman-sync"] + + +def test_the_server_still_reads_the_settings_undo_wrote(old_install): + migrate(old_install, "--yes") + migrate(old_install, "--undo", "--yes", box=Box(users=("secretspace",))) + values = secretspace_setup.read_env( + os.path.join(old_install, "etc/passman/sync.env")) + args = types.SimpleNamespace(host=None, port=None) + assert secretspace_server.resolve_listen(args, values) == ("0.0.0.0", 59872) + + +# --- needing root ----------------------------------------------------------------- + +def test_the_real_thing_needs_sudo(monkeypatch): + monkeypatch.setattr(secretspace_setup, "is_admin", lambda platform: False) + with pytest.raises(SystemExit) as stop: + secretspace_setup.main(["--platform", "linux", "migrate"], + say=lambda *a: None, run=Box()) + assert "sudo" in str(stop.value) + + +def test_a_dry_run_does_not_need_sudo(monkeypatch, tmp_path): + monkeypatch.setattr(secretspace_setup, "is_admin", lambda platform: False) + said = [] + secretspace_setup.main(["--platform", "linux", "migrate", "--dry-run", + "--root", str(tmp_path)], + say=said.append, run=Box()) + assert said + + +# --- Windows: the installer's setup step does it ------------------------------------ + +def windows_setup(tmp_path, monkeypatch, system=None): + """`setup --keep` as the installer runs it, with ProgramData in tmp.""" + from test_server_setup import FakeSystem + monkeypatch.setenv("ProgramData", str(tmp_path)) + monkeypatch.setattr(secretspace_setup, "is_admin", lambda platform: True) + monkeypatch.setattr(secretspace_setup, "port_in_use", lambda h, p: False) + system = system or FakeSystem() + said = [] + secretspace_setup.main(["--platform", "windows", "setup", "--keep"], + ask=lambda prompt: "", say=said.append, run=system) + return "\n".join(said), system + + +def test_windows_setup_moves_an_old_data_folder_first(tmp_path, monkeypatch): + old = tmp_path / "PassMan Server" + write(str(old / "sync.env"), "PASSMAN_HOST=0.0.0.0\nPASSMAN_PORT=60517\n") + write(str(old / "token"), TOKEN) + write(str(old / "store" / "alice" / "vault.enc"), VAULT) + said, system = windows_setup(tmp_path, monkeypatch) + new = tmp_path / "SecretSpace Server" + assert not old.exists() + assert read(str(new / "token")) == TOKEN.encode() + assert read(str(new / "store" / "alice" / "vault.enc")) == VAULT + assert secretspace_setup.read_env(str(new / "sync.env")) == { + "SECRETSPACE_HOST": "0.0.0.0", "SECRETSPACE_PORT": "60517"} + assert "Moved the server's data" in said + # The old task and firewall rules go; the current ones are made. + assert system.ran("Unregister-ScheduledTask -TaskName 'PassMan Sync Server'") + assert system.ran("Remove-NetFirewallRule -Group 'PassMan Sync Server'") + assert system.ran("Register-ScheduledTask -TaskName 'SecretSpace Sync Server'") + assert system.ran("-LocalPort 60517") + + +def test_windows_setup_keeps_the_saved_port_without_asking(tmp_path, monkeypatch): + old = tmp_path / "PassMan Server" + write(str(old / "sync.env"), + "PASSMAN_HOST=0.0.0.0\nPASSMAN_PORT=60517\n" + "PASSMAN_ALLOW_FROM=172.17.17.2\n") + write(str(old / "token"), TOKEN) + said, system = windows_setup(tmp_path, monkeypatch) + assert "listening on 0.0.0.0:60517" in said + assert system.ran("'172.17.17.2'") + + +def test_windows_setup_without_an_old_install_touches_nothing_old(tmp_path, monkeypatch): + new = tmp_path / "SecretSpace Server" + write(str(new / "sync.env"), + "SECRETSPACE_HOST=0.0.0.0\nSECRETSPACE_PORT=60517\n") + write(str(new / "token"), TOKEN) + said, system = windows_setup(tmp_path, monkeypatch) + assert not system.ran("PassMan Sync Server") + assert "Moved" not in said + + +def test_windows_old_and_new_folders_together_use_the_new_one(tmp_path, monkeypatch): + old, new = tmp_path / "PassMan Server", tmp_path / "SecretSpace Server" + write(str(old / "token"), "old-token\n") + write(str(new / "sync.env"), + "SECRETSPACE_HOST=0.0.0.0\nSECRETSPACE_PORT=60517\n") + write(str(new / "token"), TOKEN) + said, _ = windows_setup(tmp_path, monkeypatch) + assert read(str(old / "token")) == b"old-token\n" + assert read(str(new / "token")) == TOKEN.encode() + assert "leaving the other alone" in said + + +def test_migrate_on_windows_points_to_the_installer(): + said = [] + secretspace_setup.main(["--platform", "windows", "migrate"], + say=said.append, run=Box()) + assert "installer" in said[0] diff --git a/tests/test_server_setup.py b/tests/test_server_setup.py index 0da7b47..8d3a121 100644 --- a/tests/test_server_setup.py +++ b/tests/test_server_setup.py @@ -1,8 +1,8 @@ """ The sync server's listen settings and its setup script. -Where it listens: a flag wins, then PASSMAN_HOST / PASSMAN_PORT (which the -systemd unit loads from /etc/passman/sync.env), then 127.0.0.1:8787. The +Where it listens: a flag wins, then SECRETSPACE_HOST / SECRETSPACE_PORT (which the +systemd unit loads from /etc/secretspace/sync.env), then 127.0.0.1:8787. The setup script writes that file and the token; nothing here touches the real /etc -- every test points --config-dir at a temporary folder and fakes `ip` and `systemctl`. @@ -19,12 +19,12 @@ sys.path.insert(0, os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "server")) -import passman_server # noqa: E402 -import passman_setup # noqa: E402 +import secretspace_server # noqa: E402 +import secretspace_setup # noqa: E402 def listen(environ, host=None, port=None): - return passman_server.resolve_listen( + return secretspace_server.resolve_listen( argparse.Namespace(host=host, port=port), environ) @@ -35,30 +35,30 @@ def test_with_nothing_set_it_listens_on_this_machine_only(): def test_the_environment_sets_host_and_port(): - env = {"PASSMAN_HOST": "0.0.0.0", "PASSMAN_PORT": "9000"} + env = {"SECRETSPACE_HOST": "0.0.0.0", "SECRETSPACE_PORT": "9000"} assert listen(env) == ("0.0.0.0", 9000) def test_a_flag_beats_the_environment(): - env = {"PASSMAN_HOST": "0.0.0.0", "PASSMAN_PORT": "9000"} + env = {"SECRETSPACE_HOST": "0.0.0.0", "SECRETSPACE_PORT": "9000"} assert listen(env, host="10.0.0.5", port=9100) == ("10.0.0.5", 9100) def test_each_setting_falls_back_on_its_own(): - assert listen({"PASSMAN_PORT": "9000"}) == ("127.0.0.1", 9000) - assert listen({"PASSMAN_HOST": "0.0.0.0"}, port=9100) == ("0.0.0.0", 9100) + assert listen({"SECRETSPACE_PORT": "9000"}) == ("127.0.0.1", 9000) + assert listen({"SECRETSPACE_HOST": "0.0.0.0"}, port=9100) == ("0.0.0.0", 9100) def test_an_empty_variable_counts_as_unset(): - assert listen({"PASSMAN_HOST": " ", "PASSMAN_PORT": ""}) == \ + assert listen({"SECRETSPACE_HOST": " ", "SECRETSPACE_PORT": ""}) == \ ("127.0.0.1", 8787) @pytest.mark.parametrize("bad", ["eighty", "0", "65536", "-1", "80.5"]) def test_a_bad_port_in_the_environment_stops_with_its_name(bad): with pytest.raises(SystemExit) as stop: - listen({"PASSMAN_PORT": bad}) - assert "PASSMAN_PORT" in str(stop.value) + listen({"SECRETSPACE_PORT": bad}) + assert "SECRETSPACE_PORT" in str(stop.value) def test_a_bad_port_flag_stops_too(): @@ -127,7 +127,7 @@ def __call__(self, cmd): @property def restarted(self): - return ["systemctl", "restart", "passman-sync"] in self.calls + return ["systemctl", "restart", "secretspace-sync"] in self.calls def ran(self, needle): """Every command (as one string) that contains needle.""" @@ -136,19 +136,19 @@ def ran(self, needle): @pytest.fixture(autouse=True) def systemctl_present(monkeypatch): - monkeypatch.setattr(passman_setup.shutil, "which", lambda name: "/bin/x") + monkeypatch.setattr(secretspace_setup.shutil, "which", lambda name: "/bin/x") @pytest.fixture(autouse=True) def port_free(monkeypatch): - monkeypatch.setattr(passman_setup, "port_in_use", lambda h, p: False) + monkeypatch.setattr(secretspace_setup, "port_in_use", lambda h, p: False) def run_setup(tmp_path, answers=(), argv=(), system=None, platform="linux"): answers = list(answers) said = [] system = system or FakeSystem() - passman_setup.main(["--config-dir", str(tmp_path), "--platform", platform, + secretspace_setup.main(["--config-dir", str(tmp_path), "--platform", platform, *argv], ask=lambda prompt: answers.pop(0), say=said.append, run=system) @@ -157,26 +157,26 @@ def run_setup(tmp_path, answers=(), argv=(), system=None, platform="linux"): def test_it_lists_this_machines_networks_without_loopback(): - assert passman_setup.list_addresses(FakeSystem()) == [ + assert secretspace_setup.list_addresses(FakeSystem()) == [ ("eth0", "192.168.1.50", 24), ("wg0", "10.8.0.1", 24)] def test_without_ip_there_is_simply_nothing_to_list(): - assert passman_setup.list_addresses(FakeSystem(ip_output=None)) == [] + assert secretspace_setup.list_addresses(FakeSystem(ip_output=None)) == [] def test_setup_writes_the_chosen_address_and_port(tmp_path): out, _ = run_setup(tmp_path, answers=["2", "9443", ""]) - env = passman_setup.read_env(tmp_path / "sync.env") - assert env == {"PASSMAN_HOST": "192.168.1.50", "PASSMAN_PORT": "9443"} + env = secretspace_setup.read_env(tmp_path / "sync.env") + assert env == {"SECRETSPACE_HOST": "192.168.1.50", "SECRETSPACE_PORT": "9443"} assert "http://192.168.1.50:9443" in out assert "ufw allow from 192.168.1.0/24 to any port 9443" in out def test_the_defaults_are_every_network_and_8787(tmp_path): out, _ = run_setup(tmp_path, answers=["", "", ""]) - env = passman_setup.read_env(tmp_path / "sync.env") - assert env == {"PASSMAN_HOST": "0.0.0.0", "PASSMAN_PORT": "8787"} + env = secretspace_setup.read_env(tmp_path / "sync.env") + assert env == {"SECRETSPACE_HOST": "0.0.0.0", "SECRETSPACE_PORT": "8787"} # Every network: one address and one firewall rule per network. assert "http://192.168.1.50:8787" in out and "http://10.8.0.1:8787" in out assert "from 10.8.0.0/24 to any port 8787" in out @@ -184,28 +184,28 @@ def test_the_defaults_are_every_network_and_8787(tmp_path): def test_what_setup_writes_is_what_the_server_reads(tmp_path): run_setup(tmp_path, argv=["setup", "--host", "10.8.0.1", "--port", "9000"]) - env = passman_setup.read_env(tmp_path / "sync.env") + env = secretspace_setup.read_env(tmp_path / "sync.env") assert listen(env) == ("10.8.0.1", 9000) def test_running_setup_again_offers_the_current_values(tmp_path): run_setup(tmp_path, argv=["setup", "--host", "10.8.0.1", "--port", "9000"]) run_setup(tmp_path, answers=["", "", ""]) - assert passman_setup.read_env(tmp_path / "sync.env") == \ - {"PASSMAN_HOST": "10.8.0.1", "PASSMAN_PORT": "9000"} + assert secretspace_setup.read_env(tmp_path / "sync.env") == \ + {"SECRETSPACE_HOST": "10.8.0.1", "SECRETSPACE_PORT": "9000"} @pytest.mark.parametrize("bad", ["80", "70000", "web"]) def test_a_bad_port_is_asked_again(tmp_path, bad): out, _ = run_setup(tmp_path, answers=["1", bad, "9000", ""]) - assert passman_setup.read_env(tmp_path / "sync.env")["PASSMAN_PORT"] == \ + assert secretspace_setup.read_env(tmp_path / "sync.env")["SECRETSPACE_PORT"] == \ "9000" def test_an_address_this_machine_does_not_have_is_refused(tmp_path): out, _ = run_setup(tmp_path, answers=["192.168.7.7", "3", "", ""]) assert "does not have 192.168.7.7" in out - assert passman_setup.read_env(tmp_path / "sync.env")["PASSMAN_HOST"] == \ + assert secretspace_setup.read_env(tmp_path / "sync.env")["SECRETSPACE_HOST"] == \ "10.8.0.1" @@ -274,7 +274,7 @@ def test_new_token_yes_skips_the_question(tmp_path): def test_the_server_accepts_the_token_setup_wrote(tmp_path): run_setup(tmp_path, argv=["new-token", "--yes"]) args = argparse.Namespace(token_file=str(tmp_path / "token")) - token = passman_server.load_token(args) + token = secretspace_server.load_token(args) assert token == (tmp_path / "token").read_text().strip() assert len(token) >= 16 @@ -294,20 +294,20 @@ def test_show_token_without_one_says_to_run_setup(tmp_path): def test_keep_reuses_the_saved_values_without_asking(tmp_path): run_setup(tmp_path, argv=["setup", "--host", "10.8.0.1", "--port", "9000"]) run_setup(tmp_path, argv=["setup", "--keep"]) # no answers given - assert passman_setup.read_env(tmp_path / "sync.env") == \ - {"PASSMAN_HOST": "10.8.0.1", "PASSMAN_PORT": "9000"} + assert secretspace_setup.read_env(tmp_path / "sync.env") == \ + {"SECRETSPACE_HOST": "10.8.0.1", "SECRETSPACE_PORT": "9000"} def test_keep_with_nothing_saved_asks(tmp_path): run_setup(tmp_path, answers=["", "", ""], argv=["setup", "--keep"]) - assert passman_setup.read_env(tmp_path / "sync.env")["PASSMAN_PORT"] == \ + assert secretspace_setup.read_env(tmp_path / "sync.env")["SECRETSPACE_PORT"] == \ "8787" def test_pause_shows_an_error_before_waiting(tmp_path): said, asked = [], [] with pytest.raises(SystemExit): - passman_setup.main( + secretspace_setup.main( ["--config-dir", str(tmp_path), "--platform", "linux", "--pause", "show-token"], ask=lambda prompt: asked.append((prompt, list(said))) or "", @@ -358,8 +358,8 @@ def test_no_change_touches_no_firewall_rule(tmp_path): # --- extra addresses (--allow-from) ----------------------------------------- def allowed(tmp_path): - return passman_setup.read_env(tmp_path / "sync.env").get( - passman_setup.ALLOW_KEY) + return secretspace_setup.read_env(tmp_path / "sync.env").get( + secretspace_setup.ALLOW_KEY) @pytest.mark.parametrize("text, expected", [ @@ -371,13 +371,13 @@ def allowed(tmp_path): ("-", []), ("none", []), ("", []), ]) def test_allow_from_accepts_addresses_and_networks(text, expected): - assert passman_setup.check_allow(text) == (expected, None) + assert secretspace_setup.check_allow(text) == (expected, None) @pytest.mark.parametrize("bad", ["0.0.0.0/0", "everyone", "fe80::1", "10.0.0.300", "1.2.3.4/33"]) def test_allow_from_refuses_everyone_and_nonsense(bad): - allow, problem = passman_setup.check_allow(bad) + allow, problem = secretspace_setup.check_allow(bad) assert allow is None and problem @@ -386,7 +386,7 @@ def test_the_question_saves_extra_addresses(tmp_path): assert allowed(tmp_path) == "192.168.0.1" assert "Also let in: 192.168.0.1" in out # Host and port are still what the server reads. - env = passman_setup.read_env(tmp_path / "sync.env") + env = secretspace_setup.read_env(tmp_path / "sync.env") assert listen(env) == ("0.0.0.0", 8787) @@ -497,14 +497,14 @@ def win_setup(tmp_path, answers=(), argv=(), system=None): def test_windows_lists_its_networks_without_loopback_or_link_local(): - assert passman_setup.list_addresses(FakeSystem(), "windows") == \ + assert secretspace_setup.list_addresses(FakeSystem(), "windows") == \ [("Ethernet", "192.168.1.60", 24)] def test_windows_one_network_comes_back_as_an_object_not_a_list(): one = json.dumps({"InterfaceAlias": "Ethernet", "IPAddress": "10.0.0.9", "PrefixLength": 16}) - assert passman_setup.list_addresses(FakeSystem(win_output=one), + assert secretspace_setup.list_addresses(FakeSystem(win_output=one), "windows") == \ [("Ethernet", "10.0.0.9", 16)] @@ -588,13 +588,13 @@ def test_remove_on_linux_points_at_systemctl(tmp_path): def test_a_quote_in_a_path_cannot_break_out_of_powershell(): - assert passman_setup.ps_quote("C:\\it's here") == "'C:\\it''s here'" + assert secretspace_setup.ps_quote("C:\\it's here") == "'C:\\it''s here'" # --- the server's Windows options ------------------------------------------- def test_the_log_file_stamps_every_line(tmp_path): - log = passman_server.open_log(str(tmp_path / "logs" / "server.log")) + log = secretspace_server.open_log(str(tmp_path / "logs" / "server.log")) log.write("one\ntwo ") log.write("still two\n") lines = (tmp_path / "logs" / "server.log").read_text().splitlines() @@ -603,10 +603,10 @@ def test_the_log_file_stamps_every_line(tmp_path): def test_a_large_log_is_rotated_at_start(tmp_path, monkeypatch): - monkeypatch.setattr(passman_server, "LOG_ROTATE_BYTES", 10) + monkeypatch.setattr(secretspace_server, "LOG_ROTATE_BYTES", 10) path = tmp_path / "server.log" path.write_text("x" * 20) - passman_server.open_log(str(path)).write("new\n") + secretspace_server.open_log(str(path)).write("new\n") assert (tmp_path / "server.log.1").read_text() == "x" * 20 assert path.read_text().endswith("new\n") @@ -619,9 +619,9 @@ def flaky(address, handler): if len(attempts) < 3: raise OSError(10049, "not there") return "server" - monkeypatch.setattr(passman_server, "ThreadingHTTPServer", flaky) + monkeypatch.setattr(secretspace_server, "ThreadingHTTPServer", flaky) slept = [] - assert passman_server.bind("10.9.9.9", 9000, 10, sleep=slept.append) == \ + assert secretspace_server.bind("10.9.9.9", 9000, 10, sleep=slept.append) == \ "server" assert slept == [10, 10] @@ -629,30 +629,30 @@ def flaky(address, handler): def test_bind_without_retry_fails_at_once(monkeypatch): def broken(address, handler): raise OSError(10049, "not there") - monkeypatch.setattr(passman_server, "ThreadingHTTPServer", broken) + monkeypatch.setattr(secretspace_server, "ThreadingHTTPServer", broken) with pytest.raises(OSError): - passman_server.bind("10.9.9.9", 9000) + secretspace_server.bind("10.9.9.9", 9000) -# --- the Windows task (passman_setup.py is the same file on both systems) -- +# --- the Windows task (secretspace_setup.py is the same file on both systems) -- def test_the_task_runs_the_server_not_the_setup_program(): - """Run from PassMan-Server-Setup.exe, the task must still start - PassMan-Server.exe beside it -- never the setup program itself.""" + """Run from SecretSpace-Server-Setup.exe, the task must still start + SecretSpace-Server.exe beside it -- never the setup program itself.""" setup = os.path.join("C:\\Program Files", "SecretSpace Server", - "PassMan-Server-Setup.exe") - program, prefix = passman_setup.server_command(frozen=True, + "SecretSpace-Server-Setup.exe") + program, prefix = secretspace_setup.server_command(frozen=True, executable=setup) assert program == os.path.join("C:\\Program Files", "SecretSpace Server", - "PassMan-Server.exe") + "SecretSpace-Server.exe") assert prefix == [] -def test_from_source_the_task_runs_passman_server_py(): - program, prefix = passman_setup.server_command(frozen=False, +def test_from_source_the_task_runs_secretspace_server_py(): + program, prefix = secretspace_setup.server_command(frozen=False, executable="python.exe") assert program == "python.exe" - assert prefix[0].endswith("passman_server.py") + assert prefix[0].endswith("secretspace_server.py") @@ -681,7 +681,7 @@ def test_the_menu_changes_the_port_then_comes_back(tmp_path): run_setup(tmp_path, argv=["setup", "--host", "10.8.0.1", "--port", "9000"]) out, _ = run_setup(tmp_path, answers=["1", "", "9443", "", "3", "4"], argv=["menu"]) - assert passman_setup.read_env(tmp_path / "sync.env")["PASSMAN_PORT"] == \ + assert secretspace_setup.read_env(tmp_path / "sync.env")["SECRETSPACE_PORT"] == \ "9443" # Back at the menu with the new port, and then the token was shown. assert "Listening on 10.8.0.1:9443" in out @@ -704,14 +704,14 @@ def test_the_menu_closes_on_end_of_input(tmp_path): def ask(prompt): raise EOFError - passman_setup.main(["--config-dir", str(tmp_path), "--platform", "linux", + secretspace_setup.main(["--config-dir", str(tmp_path), "--platform", "linux", "menu"], ask=ask, say=said.append, run=FakeSystem()) def test_the_menu_does_not_add_a_second_press_enter(tmp_path): """An elevated relaunch always adds --pause; the menu has Close.""" asked = [] - passman_setup.main(["--config-dir", str(tmp_path), "--platform", "linux", + secretspace_setup.main(["--config-dir", str(tmp_path), "--platform", "linux", "--pause", "menu"], ask=lambda prompt: asked.append(prompt) or "4", say=lambda line: None, run=FakeSystem()) diff --git a/tests/test_server_v2.py b/tests/test_server_v2.py index 267e1ee..5866050 100644 --- a/tests/test_server_v2.py +++ b/tests/test_server_v2.py @@ -16,7 +16,7 @@ # First: it puts server/ on the path, and brings the running-server fixture. from test_server_http import TOKEN, server # noqa: F401 (fixture) -import passman_server # noqa: E402 +import secretspace_server # noqa: E402 def b64(raw: bytes) -> str: @@ -208,7 +208,7 @@ def test_a_disabled_account_cannot_log_in(server, alice): def test_too_many_wrong_logins_pause_the_account(server, alice): - for _ in range(passman_server.MAX_LOGIN_FAILURES): + for _ in range(secretspace_server.MAX_LOGIN_FAILURES): assert login(server, alice, login_key=os.urandom(32))[0] == 401 # paused: even the right key waits status, _, body = login(server, alice) @@ -216,10 +216,10 @@ def test_too_many_wrong_logins_pause_the_account(server, alice): def test_the_pause_ends(server, alice, monkeypatch): - for _ in range(passman_server.MAX_LOGIN_FAILURES): + for _ in range(secretspace_server.MAX_LOGIN_FAILURES): login(server, alice, login_key=os.urandom(32)) - later = passman_server.time.time() + passman_server.LOCKOUT_SECONDS + 1 - monkeypatch.setattr(passman_server.time, "time", lambda: later) + later = secretspace_server.time.time() + secretspace_server.LOCKOUT_SECONDS + 1 + monkeypatch.setattr(secretspace_server.time, "time", lambda: later) assert login(server, alice)[0] == 200 @@ -249,8 +249,8 @@ def test_v2_calls_need_a_session(server, alice, token): def test_a_session_expires(server, alice, monkeypatch): session = session_for(server, alice) - later = passman_server.time.time() + passman_server.SESSION_SECONDS + 1 - monkeypatch.setattr(passman_server.time, "time", lambda: later) + later = secretspace_server.time.time() + secretspace_server.SESSION_SECONDS + 1 + monkeypatch.setattr(secretspace_server.time, "time", lambda: later) assert server.request("GET", "/v2/me", token=session)[0] == 401 @@ -304,7 +304,7 @@ def test_a_vault_refuses_a_2x_vault(server, alice): session = session_for(server, alice) blob = b64(b"PMV2" + os.urandom(80)) status, _, body = put_vault(server, session, alice.vault, blob) - assert status == 400 and "SecretSpace 3" in body["error"] + assert status == 400 and "not a SecretSpace vault" in body["error"] def test_someone_elses_vault_is_not_there(server, alice): diff --git a/tests/test_server_v2_format.py b/tests/test_server_v2_format.py new file mode 100644 index 0000000..88e3012 --- /dev/null +++ b/tests/test_server_v2_format.py @@ -0,0 +1,348 @@ +""" +The account and vault formats over HTTP (server 2.1.0). + +Format 1 is what SecretSpace 3 wrote, while the project was still called +PassMan; format 2 is what SecretSpace 4 writes. The server reads both, +because an account is converted by its owner's own device, one account at a +time. These tests pin the rules that keep that safe: + +- a converted account never goes back to the old format; +- a vault is stored in the new form only when every member's account is + converted, so nobody is locked out by someone else's update; +- a converted vault takes neither an upload in the old form nor a member + whose app could not read it; +- the names from before the rename (settings, headers) are still read. +""" + +import os +import struct + +import pytest + +from test_server_http import TOKEN, VAULT_1, server # noqa: F401 (fixture) +from test_server_v2 import (Account, alice, b64, keyset_blob, # noqa: F401 + put_vault, session_for, signup, vault_blob) +from test_server_v2 import login +from test_server_v2_recovery import (Fresh, get_copy, proved, # noqa: F401 + recover, recover_body, unb64) +from test_server_v2_sharing import (bob, carol, members, # noqa: F401 + post_rotate, put_member, rotate_body, + team, team_up) +import secretspace_server + + +def new_keyset(salt: bytes = None, kdf: int = 1) -> str: + """Shaped like a format-2 keyset: the current marker, version 2.""" + salt = salt if salt is not None else os.urandom(16) + cost = (65536, 3, 1) if kdf else (0, 0, 0) + header = struct.pack(">4sBBIBB16s", b"SSK4", 2, kdf, *cost, salt) + return b64(header + os.urandom(12) + os.urandom(275)) + + +def new_vault(vault_id: str, generation: int = 0) -> str: + header = struct.pack(">4sB16sI", b"SSV4", 1, bytes.fromhex(vault_id), + generation) + return b64(header + os.urandom(12) + os.urandom(64)) + + +def me(server, session): + return server.request("GET", "/v2/me", token=session)[2] + + +def convert(server, account, session=None) -> str: + """Send the account's keyset in the new format, as a converting device + does. Returns a session that is still valid afterwards.""" + session = session or session_for(server, account) + account.keyset = new_keyset(account.salt) + status, _, body = server.request("PUT", "/v2/me/keyset", { + "login_key": b64(account.login_key), "keyset": account.keyset}, + token=session) + assert status == 200, body + return session + + +# --- the account's format ----------------------------------------------------- + +def test_an_account_from_secretspace_3_is_format_1(server, alice): + assert me(server, session_for(server, alice))["format"] == 1 + + +def test_an_account_made_in_the_new_format_is_format_2(server): + dana = Account("dana") + dana.keyset = new_keyset(dana.salt) + dana.recovery = new_keyset(bytes(16), kdf=0) + assert signup(server, dana)[0] == 201 + assert me(server, session_for(server, dana))["format"] == 2 + + +def test_a_record_without_a_format_counts_as_format_1(server, alice): + """Accounts stored by server 2.0.0 have no such field.""" + store = secretspace_server.AccountStore(server.store.root) + record = store.account("alice") + assert record.pop("format") == 1 + store._write_json(store._account_path("alice"), record) + assert me(server, session_for(server, alice))["format"] == 1 + + +def test_sending_the_keyset_in_the_new_format_converts_the_account(server, alice): + session = convert(server, alice) + assert me(server, session)["format"] == 2 + assert me(server, session)["keyset"] == alice.keyset + + +def test_prelogin_still_gives_the_salt_after_a_conversion(server, alice): + before = server.request("GET", "/v2/prelogin/alice", token=None)[2] + convert(server, alice) + assert server.request("GET", "/v2/prelogin/alice", token=None)[2] == before + + +def test_a_converted_account_does_not_go_back(server, alice): + session = convert(server, alice) + status, _, body = server.request("PUT", "/v2/me/keyset", { + "login_key": b64(alice.login_key), "keyset": keyset_blob(alice.salt)}, + token=session) + assert status == 409 and "Update SecretSpace" in body["error"] + assert me(server, session)["format"] == 2 + assert me(server, session)["keyset"] == alice.keyset + + +def test_a_conversion_is_logged_as_one_not_as_a_password_change(server, alice): + convert(server, alice) + path = os.path.join(server.store.root, "_v2", "logins.log") + with open(path, encoding="utf-8") as f: + text = f.read() + assert "account_converted" in text and "password_changed" not in text + + +def test_a_later_password_change_is_logged_as_that(server, alice): + session = convert(server, alice) + alice.keyset = new_keyset() + assert server.request("PUT", "/v2/me/keyset", { + "login_key": b64(os.urandom(32)), "keyset": alice.keyset}, + token=session)[0] == 200 + path = os.path.join(server.store.root, "_v2", "logins.log") + with open(path, encoding="utf-8") as f: + assert "password_changed" in f.read() + + +def test_a_keyset_that_is_neither_format_is_refused(server, alice): + session = session_for(server, alice) + header = struct.pack(">4sBBIBB16s", b"XXK9", 1, 1, 65536, 3, 1, bytes(16)) + status, _, body = server.request("PUT", "/v2/me/keyset", { + "login_key": b64(os.urandom(32)), + "keyset": b64(header + os.urandom(100))}, token=session) + assert status == 400 and "not a SecretSpace keyset" in body["error"] + + +def test_the_recovery_copy_may_stay_in_the_old_form_after_a_conversion(server, alice): + """It opens only with the 12 words, which the converting device may not + have been given yet.""" + before = alice.recovery + convert(server, alice) + assert get_copy(server, "alice")[2]["recovery"] == before + assert unb64(before)[:4] != b"SSK4" + + +def test_the_recovery_copy_can_be_sent_in_the_new_form(server, alice): + session = convert(server, alice) + copy = new_keyset(bytes(16), kdf=0) + assert server.request("PUT", "/v2/me/recovery", {"recovery": copy}, + token=session)[0] == 200 + assert get_copy(server, "alice")[2]["recovery"] == copy + + +# --- a recovery, which also replaces the keyset -------------------------------- + +def test_a_recovery_from_an_updated_device_converts_the_account(server, proved): + fresh = Fresh() + fresh.keyset = new_keyset() + fresh.recovery = new_keyset(bytes(16), kdf=0) + assert recover(server, recover_body(proved, proved.proof, fresh))[0] == 200 + session = login(server, proved, login_key=fresh.login_key)[2]["session"] + assert me(server, session)["format"] == 2 + + +def test_a_recovery_from_an_old_app_leaves_an_old_account_as_it_is(server, proved): + fresh = Fresh() + assert recover(server, recover_body(proved, proved.proof, fresh))[0] == 200 + session = login(server, proved, login_key=fresh.login_key)[2]["session"] + assert me(server, session)["format"] == 1 + + +def test_a_recovery_cannot_take_a_converted_account_back(server, proved): + session = convert(server, proved) + fresh = Fresh() # an old app: the old form + status, _, body = recover(server, recover_body(proved, proved.proof, fresh)) + assert status == 409 and "Update SecretSpace" in body["error"] + assert me(server, session)["format"] == 2 + assert me(server, session)["keyset"] == proved.keyset + assert login(server, proved, login_key=fresh.login_key)[0] == 401 + + +# --- the personal vault --------------------------------------------------------- + +def test_an_unconverted_account_cannot_store_its_vault_in_the_new_form(server, alice): + session = session_for(server, alice) + status, _, body = put_vault(server, session, alice.vault, + new_vault(alice.vault)) + assert status == 409 and "old format" in body["error"] + assert server.request("GET", f"/v2/vaults/{alice.vault}/meta", + token=session)[2]["revision"] == 0 + + +def test_a_converted_account_stores_its_vault_in_the_new_form(server, alice): + session = convert(server, alice) + status, _, body = put_vault(server, session, alice.vault, + new_vault(alice.vault)) + assert status == 200 and body["revision"] == 1 + + +def test_a_converted_account_may_still_store_the_old_form_until_the_vault_converts(server, alice): + """Its other device may not have converted its files yet.""" + session = convert(server, alice) + assert put_vault(server, session, alice.vault, + vault_blob(alice.vault))[0] == 200 + + +def test_a_converted_vault_refuses_the_old_form(server, alice): + session = convert(server, alice) + assert put_vault(server, session, alice.vault, + new_vault(alice.vault))[0] == 200 + status, _, body = put_vault(server, session, alice.vault, + vault_blob(alice.vault), "1") + assert status == 409 and "Update SecretSpace" in body["error"] + assert server.request("GET", f"/v2/vaults/{alice.vault}/meta", + token=session)[2]["revision"] == 1 + + +def test_a_vault_that_is_neither_form_is_refused(server, alice): + session = session_for(server, alice) + header = struct.pack(">4sB16sI", b"XXV9", 1, bytes.fromhex(alice.vault), 0) + status, _, body = put_vault(server, session, alice.vault, + b64(header + os.urandom(76))) + assert status == 400 and "not a SecretSpace vault" in body["error"] + + +def test_the_new_form_must_still_name_its_own_vault(server, alice): + session = convert(server, alice) + other = os.urandom(16).hex() + status, _, body = put_vault(server, session, alice.vault, new_vault(other)) + assert status == 400 and "another vault" in body["error"] + + +# --- a shared vault ------------------------------------------------------------- + +def test_the_members_list_says_who_has_converted(server, team): + convert(server, team.alice, team.sa) + body = members(server, team.sa, team.vid)[2] + assert body["format"] == 1 + assert {m["username"]: m["format"] for m in body["members"]} == { + "alice": 2, "bob": 1, "Carol": 1} + + +def test_a_shared_vault_stays_in_the_old_form_while_a_member_has_not_converted(server, team): + convert(server, team.alice, team.sa) + convert(server, team.bob, team.sb) + status, _, body = put_vault(server, team.sa, team.vid, new_vault(team.vid)) + assert status == 409 and "Not every member" in body["error"] + # ... and everyone, converted or not, still writes it in the old form. + assert put_vault(server, team.sa, team.vid, vault_blob(team.vid))[0] == 200 + assert put_vault(server, team.sb, team.vid, vault_blob(team.vid), "1")[0] == 200 + + +def test_a_shared_vault_converts_once_every_member_has(server, team): + for account, session in ((team.alice, team.sa), (team.bob, team.sb), + (team.carol, team.sc)): + convert(server, account, session) + assert put_vault(server, team.sa, team.vid, new_vault(team.vid))[0] == 200 + assert members(server, team.sa, team.vid)[2]["format"] == 2 + status, _, body = put_vault(server, team.sb, team.vid, + vault_blob(team.vid), "1") + assert status == 409 and "Update SecretSpace" in body["error"] + + +def test_a_key_change_may_convert_the_vault_when_everyone_who_stays_has(server, team_up): + """Removing the member who has not updated, in the same step.""" + team = team_up + convert(server, team.alice, team.sa) + convert(server, team.bob, team.sb) + body = rotate_body(team.vid, ["alice", "bob"], remove=["Carol"]) + body["blob"] = new_vault(team.vid, 1) + status, view = post_rotate(server, team.sa, team.vid, body) + assert status == 200, view + assert members(server, team.sa, team.vid)[2]["format"] == 2 + + +def test_a_key_change_cannot_convert_the_vault_past_a_member_who_stays(server, team_up): + team = team_up + convert(server, team.alice, team.sa) + body = rotate_body(team.vid, ["alice", "bob", "Carol"]) + body["blob"] = new_vault(team.vid, 1) + status, answer = post_rotate(server, team.sa, team.vid, body) + assert status == 409 and "Not every member" in answer["error"] + assert members(server, team.sa, team.vid)[2]["key_generation"] == 0 + + +def test_a_converted_vault_does_not_take_a_member_who_has_not_updated(server, team): + for account, session in ((team.alice, team.sa), (team.bob, team.sb), + (team.carol, team.sc)): + convert(server, account, session) + assert put_vault(server, team.sa, team.vid, new_vault(team.vid))[0] == 200 + erin = Account("erin") + assert signup(server, erin)[0] == 201 + status, body = put_member(server, team.sa, team.vid, "erin", "editor") + assert status == 409 and "update SecretSpace" in body["error"] + convert(server, erin) + assert put_member(server, team.sa, team.vid, "erin", "editor")[0] == 200 + + +def test_an_unconverted_vault_still_takes_any_member(server, team): + erin = Account("erin") + assert signup(server, erin)[0] == 201 + assert put_member(server, team.sa, team.vid, "erin", "editor")[0] == 200 + + +# --- the names from before the rename are still read ------------------------------ + +def test_the_old_key_generation_header_is_still_read(server): + status, _, _ = server.push("alice", VAULT_1, + **{"X-PassMan-Key-Generation": "3"}) + assert status == 200 + assert server.store.meta("alice")["key_generation"] == 3 + + +def test_the_current_header_wins_over_the_old_one(server): + status, _, _ = server.push("alice", VAULT_1, + **{"X-SecretSpace-Key-Generation": "5", + "X-PassMan-Key-Generation": "3"}) + assert status == 200 + assert server.store.meta("alice")["key_generation"] == 5 + + +def test_the_old_drop_history_header_is_still_read(server): + server.push("alice", VAULT_1) + server.push("alice", VAULT_1, if_match="1") + assert server.history("alice") != [] + status, _, _ = server.push("alice", VAULT_1, if_match="2", + **{"X-PassMan-Drop-History": "1"}) + assert status == 200 + assert server.history("alice") == [] + + +@pytest.mark.parametrize("prefix", ["SECRETSPACE_", "PASSMAN_"]) +def test_settings_are_read_under_the_current_and_the_old_prefix(prefix): + environ = {prefix + "HOST": "10.0.0.5", prefix + "PORT": "9000"} + assert secretspace_server.env_setting(environ, "HOST") == "10.0.0.5" + assert secretspace_server.env_setting(environ, "PORT") == "9000" + + +def test_the_current_prefix_wins_over_the_old_one(): + environ = {"SECRETSPACE_PORT": "9001", "PASSMAN_PORT": "9000"} + assert secretspace_server.env_setting(environ, "PORT") == "9001" + + +def test_the_server_does_not_call_itself_by_the_old_name(server): + status, headers, body = server.request("GET", "/health", token=None) + assert body["service"] == "secretspace-sync" + assert "passman" not in (headers.get("Server") or "").lower() + assert "format-2" in body["features"] diff --git a/tests/test_server_v2_key_sigs.py b/tests/test_server_v2_key_sigs.py index 5e1fe20..2e32c01 100644 --- a/tests/test_server_v2_key_sigs.py +++ b/tests/test_server_v2_key_sigs.py @@ -18,10 +18,10 @@ from test_server_v2_sharing import (carol, my_vault, post_rotate, # noqa: F401 put_member, rotate_body, state, team, team_up) -import passman_server # noqa: E402 +import secretspace_server # noqa: E402 -def sig(size: int = passman_server.KEY_SIG_LEN) -> str: +def sig(size: int = secretspace_server.KEY_SIG_LEN) -> str: return b64(os.urandom(size)) diff --git a/tests/test_server_v2_recovery.py b/tests/test_server_v2_recovery.py index 1dca2a8..eadd57d 100644 --- a/tests/test_server_v2_recovery.py +++ b/tests/test_server_v2_recovery.py @@ -21,7 +21,7 @@ from test_server_http import TOKEN, server # noqa: F401 (fixture) from test_server_v2 import (Account, alice, b64, keyset_blob, # noqa: F401 login, session_for, signup) -import passman_server # noqa: E402 +import secretspace_server # noqa: E402 # --- helpers ----------------------------------------------------------------- @@ -72,10 +72,10 @@ def recovery_blob() -> str: def client_sized_recovery() -> bytes: - """A recovery copy with the body size of the real client's (a nonce, 133 + """A recovery copy with the body size of the real client's (a nonce, 259 bytes of keyset JSON and a tag), which the Account helper does not make.""" - header = struct.pack(">4sBBIBB16s", b"PMK3", 1, 0, 0, 0, 0, bytes(16)) - return header + os.urandom(12 + 133 + 16) + header = struct.pack(">4sBBIBB16s", b"SSK4", 2, 0, 0, 0, 0, bytes(16)) + return header + os.urandom(12 + 259 + 16) def with_proof(server, account, proof=None): @@ -135,7 +135,7 @@ def proved(server): @pytest.fixture def small_lockout(monkeypatch): - monkeypatch.setattr(passman_server, "MAX_LOGIN_FAILURES", 3) + monkeypatch.setattr(secretspace_server, "MAX_LOGIN_FAILURES", 3) return 3 @@ -143,7 +143,8 @@ def small_lockout(monkeypatch): def test_health_lists_vaults_sharing_and_recovery(server): _, _, body = server.request("GET", "/health", token=None) - assert body["features"] == ["vaults", "sharing", "recovery", "signed-keys"] + assert body["features"] == ["vaults", "sharing", "recovery", "signed-keys", + "format-2"] # --- sign-up with a proof ---------------------------------------------------- @@ -317,7 +318,7 @@ def test_the_recovery_copy_of_an_unknown_name_is_made_up_but_the_same_shape(serv assert set(body) == {"username", "recovery"} assert body["username"] == "mallory" raw = unb64(body["recovery"]) - assert raw[:4] == b"PMK3" and raw[5] == 0 + assert raw[:4] == b"SSK4" and raw[4] == 2 and raw[5] == 0 def test_the_made_up_copy_is_the_same_on_every_ask(server): @@ -341,7 +342,7 @@ def test_the_made_up_copy_depends_on_a_key_only_the_server_has(server): def test_the_made_up_copy_has_the_length_of_a_clients_real_one(server): - """The real client's copy is a header, a nonce, 133 bytes of keyset JSON + """The real client's copy is a header, a nonce, 259 bytes of keyset JSON and a tag.""" account = Account("real") account.recovery = b64(client_sized_recovery()) @@ -357,7 +358,7 @@ def test_the_made_up_copy_length_against_the_account_test_helper(server, alice): (189 in all).""" real = unb64(get_copy(server, "alice")[2]["recovery"]) made = unb64(get_copy(server, "nobody")[2]["recovery"]) - assert (len(real), len(made)) == (28 + 12 + 80, 28 + 12 + 133 + 16) + assert (len(real), len(made)) == (28 + 12 + 80, 28 + 12 + 259 + 16) def test_a_disabled_account_answers_like_an_unknown_name(server, alice): @@ -369,8 +370,8 @@ def test_a_disabled_account_answers_like_an_unknown_name(server, alice): assert body["recovery"] != alice.recovery assert body["recovery"] == get_copy(server, "alice")[2]["recovery"] raw = unb64(body["recovery"]) - assert raw[:4] == b"PMK3" and raw[5] == 0 - assert len(raw) == 28 + 12 + 133 + 16 + assert raw[:4] == b"SSK4" and raw[4] == 2 and raw[5] == 0 + assert len(raw) == 28 + 12 + 259 + 16 @pytest.mark.parametrize("name", ["..%2Fetc", "a%2Fb", "%20", "x" * 200]) @@ -609,8 +610,8 @@ def test_one_wrong_proof_fewer_than_the_limit_does_not_pause(server, proved, sma def test_the_recovery_pause_ends(server, proved, small_lockout, monkeypatch): for _ in range(small_lockout): recover(server, recover_body(proved, new_proof())) - later = passman_server.time.time() + passman_server.LOCKOUT_SECONDS + 1 - monkeypatch.setattr(passman_server.time, "time", lambda: later) + later = secretspace_server.time.time() + secretspace_server.LOCKOUT_SECONDS + 1 + monkeypatch.setattr(secretspace_server.time, "time", lambda: later) assert recover(server, recover_body(proved, proved.proof))[0] == 200 diff --git a/tests/test_server_v2_sharing.py b/tests/test_server_v2_sharing.py index ad2e183..7ff7d7c 100644 --- a/tests/test_server_v2_sharing.py +++ b/tests/test_server_v2_sharing.py @@ -20,7 +20,7 @@ login, put_vault, session_for, signup, vault_blob) from test_server_v2_vaults import (bob, create, create_body, made, me, # noqa: F401 new_id, sealed_name, vault_dir, wrapped_key) -import passman_server # noqa: E402 +import secretspace_server # noqa: E402 @pytest.fixture @@ -343,8 +343,8 @@ def test_a_personal_vault_cannot_be_shared(server, alice, bob): @pytest.mark.parametrize("key", [None, "not base64 !!", - b64(os.urandom(passman_server.WRAPPED_KEY_LEN - 1)), - b64(os.urandom(passman_server.WRAPPED_KEY_LEN + 1))]) + b64(os.urandom(secretspace_server.WRAPPED_KEY_LEN - 1)), + b64(os.urandom(secretspace_server.WRAPPED_KEY_LEN + 1))]) def test_a_new_member_needs_a_whole_wrapped_key(server, alice, bob, key): session = session_for(server, alice) view, _ = made(server, session) @@ -355,7 +355,7 @@ def test_a_new_member_needs_a_whole_wrapped_key(server, alice, bob, key): def test_the_member_limit_refuses_a_new_member(server, team, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_MEMBERS_PER_VAULT", 3) + monkeypatch.setattr(secretspace_server, "MAX_MEMBERS_PER_VAULT", 3) dora = Account("dora") signup(server, dora) before = state(server, team.vid, "dora") @@ -365,18 +365,18 @@ def test_the_member_limit_refuses_a_new_member(server, team, monkeypatch): def test_the_member_limit_does_not_stop_a_role_change(server, team, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_MEMBERS_PER_VAULT", 3) + monkeypatch.setattr(secretspace_server, "MAX_MEMBERS_PER_VAULT", 3) assert put_member(server, team.sa, team.vid, "bob", "reader", wrapped=None)[0] == 200 def test_the_member_limit_allows_one_more_below_it(server, team, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_MEMBERS_PER_VAULT", 4) + monkeypatch.setattr(secretspace_server, "MAX_MEMBERS_PER_VAULT", 4) signup(server, Account("dora")) assert put_member(server, team.sa, team.vid, "dora", "reader")[0] == 200 def test_an_account_in_too_many_vaults_cannot_be_added_to_another(server, alice, bob, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_VAULTS_LISTED", 1) + monkeypatch.setattr(secretspace_server, "MAX_VAULTS_LISTED", 1) sa, sb = session_for(server, alice), session_for(server, bob) first, _ = made(server, sb) # bob lists one vault: the limit other, _ = made(server, sa) @@ -438,7 +438,8 @@ def test_the_members_list_is_sorted_and_says_who_added_whom(server, team): ("alice", "owner", ""), ("bob", "editor", "alice"), ("Carol", "reader", "alice")] assert all(m["added_at"].endswith("Z") for m in body["members"][1:]) - assert set(body["members"][0]) == {"username", "role", "added_by", "added_at"} + assert set(body["members"][0]) == {"username", "role", "added_by", "added_at", + "format"} def test_every_member_may_read_the_members_list_but_it_holds_no_keys(server, team): @@ -755,7 +756,7 @@ def apply(body): def _short_key(body): - body["wrapped_keys"]["bob"] = b64(os.urandom(passman_server.WRAPPED_KEY_LEN - 1)) + body["wrapped_keys"]["bob"] = b64(os.urandom(secretspace_server.WRAPPED_KEY_LEN - 1)) return body @@ -783,8 +784,8 @@ def _short_key(body): ("blob missing from a vault that has one", 400, _without("blob", "revision")), ("name missing", 400, _without("name")), ("name not base64", 400, _mutate(name="not base64 !!")), - ("name too short", 400, _mutate(name=b64(b"x" * (passman_server.MIN_SEALED_NAME - 1)))), - ("name too long", 400, _mutate(name=b64(b"x" * (passman_server.MAX_SEALED_NAME + 1)))), + ("name too short", 400, _mutate(name=b64(b"x" * (secretspace_server.MIN_SEALED_NAME - 1)))), + ("name too long", 400, _mutate(name=b64(b"x" * (secretspace_server.MAX_SEALED_NAME + 1)))), ] @@ -825,7 +826,7 @@ def test_a_revision_other_than_zero_is_stale_for_a_vault_never_uploaded(server, # --- vault limits count only what an account made ------------------------------------------------- def test_a_vault_shared_with_you_does_not_count_against_your_own_limit(server, alice, bob, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_VAULTS_PER_ACCOUNT", 1) + monkeypatch.setattr(secretspace_server, "MAX_VAULTS_PER_ACCOUNT", 1) sa, sb = session_for(server, alice), session_for(server, bob) theirs, _ = made(server, sb) put_member(server, sb, theirs["id"], "alice", "editor") @@ -834,7 +835,7 @@ def test_a_vault_shared_with_you_does_not_count_against_your_own_limit(server, a def test_the_limit_on_own_vaults_still_applies_to_a_member_of_many(server, alice, bob, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_VAULTS_PER_ACCOUNT", 2) + monkeypatch.setattr(secretspace_server, "MAX_VAULTS_PER_ACCOUNT", 2) sa, sb = session_for(server, alice), session_for(server, bob) for _ in range(2): put_member(server, sb, made(server, sb)[0]["id"], "alice", "reader") @@ -844,7 +845,7 @@ def test_the_limit_on_own_vaults_still_applies_to_a_member_of_many(server, alice def test_the_total_listed_limit_counts_shared_vaults_too(server, alice, bob, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_VAULTS_LISTED", 2) + monkeypatch.setattr(secretspace_server, "MAX_VAULTS_LISTED", 2) sa, sb = session_for(server, alice), session_for(server, bob) put_member(server, sb, made(server, sb)[0]["id"], "alice", "reader") assert create(server, sa)[0] == 201 @@ -922,7 +923,7 @@ def test_the_log_of_a_deleted_vault_goes_with_its_folder(server, team): def test_the_log_is_cut_to_the_newest_lines(server, team, monkeypatch): - monkeypatch.setattr(passman_server, "AUDIT_LINES_SENT", 3) + monkeypatch.setattr(secretspace_server, "AUDIT_LINES_SENT", 3) put_vault(server, team.sa, team.vid, vault_blob(team.vid)) for rev in "123": put_vault(server, team.sa, team.vid, vault_blob(team.vid), if_match=rev) @@ -1043,8 +1044,8 @@ def test_the_logins_log_is_not_readable_through_the_api(server, alice): # --- rotation and failure of the log files ---------------------------------------------------------------- def test_a_log_that_reaches_the_size_is_renamed_and_a_new_one_started(tmp_path, monkeypatch): - monkeypatch.setattr(passman_server, "AUDIT_ROTATE_BYTES", 300) - store = passman_server.AccountStore(str(tmp_path)) + monkeypatch.setattr(secretspace_server, "AUDIT_ROTATE_BYTES", 300) + store = secretspace_server.AccountStore(str(tmp_path)) path = os.path.join(store.root, "logins.log") written = 0 while not os.path.exists(path + ".1") and written < 20: @@ -1058,8 +1059,8 @@ def test_a_log_that_reaches_the_size_is_renamed_and_a_new_one_started(tmp_path, def test_a_second_rotation_replaces_the_earlier_dot_one(tmp_path, monkeypatch): - monkeypatch.setattr(passman_server, "AUDIT_ROTATE_BYTES", 200) - store = passman_server.AccountStore(str(tmp_path)) + monkeypatch.setattr(secretspace_server, "AUDIT_ROTATE_BYTES", 200) + store = secretspace_server.AccountStore(str(tmp_path)) for number in range(40): store.audit(None, "login", f"user{number}", "127.0.0.1") path = os.path.join(store.root, "logins.log") @@ -1069,7 +1070,7 @@ def test_a_second_rotation_replaces_the_earlier_dot_one(tmp_path, monkeypatch): def test_a_vault_log_rotates_in_its_own_folder(server, team, monkeypatch): - monkeypatch.setattr(passman_server, "AUDIT_ROTATE_BYTES", 400) + monkeypatch.setattr(secretspace_server, "AUDIT_ROTATE_BYTES", 400) put_vault(server, team.sa, team.vid, vault_blob(team.vid)) for rev in range(1, 6): put_vault(server, team.sa, team.vid, vault_blob(team.vid), if_match=str(rev)) @@ -1081,24 +1082,24 @@ def test_a_vault_log_rotates_in_its_own_folder(server, team, monkeypatch): def test_a_log_below_the_size_is_not_rotated(tmp_path): - store = passman_server.AccountStore(str(tmp_path)) + store = secretspace_server.AccountStore(str(tmp_path)) for _ in range(5): store.audit(None, "login", "alice") assert os.listdir(store.root) == ["logins.log"] def test_audit_never_raises_when_the_folder_cannot_be_made(tmp_path, monkeypatch): - store = passman_server.AccountStore(str(tmp_path)) + store = secretspace_server.AccountStore(str(tmp_path)) def refuse(*args, **kwargs): raise OSError("disk full") - monkeypatch.setattr(passman_server.os, "makedirs", refuse) + monkeypatch.setattr(secretspace_server.os, "makedirs", refuse) store.audit(None, "login", "alice", "127.0.0.1") store.audit("ab" * 16, "upload", "alice", revision=1) def test_audit_never_raises_when_the_file_cannot_be_opened(tmp_path, monkeypatch): - store = passman_server.AccountStore(str(tmp_path)) + store = secretspace_server.AccountStore(str(tmp_path)) blocker = tmp_path / "blocker" blocker.write_text("a file where a folder should be") monkeypatch.setattr(store, "_audit_path", @@ -1110,7 +1111,7 @@ def test_audit_never_raises_when_the_file_cannot_be_opened(tmp_path, monkeypatch def test_an_upload_still_succeeds_when_the_log_cannot_be_written(server, team, tmp_path, monkeypatch): blocker = tmp_path / "blocker" blocker.write_text("not a folder") - monkeypatch.setattr(passman_server.AccountStore, "_audit_path", + monkeypatch.setattr(secretspace_server.AccountStore, "_audit_path", lambda self, vault_id=None: str(blocker / "x.log")) status, _, meta = put_vault(server, team.sa, team.vid, vault_blob(team.vid)) assert status == 200 and meta["revision"] == 1 diff --git a/tests/test_server_v2_vaults.py b/tests/test_server_v2_vaults.py index 97113f5..fcfc7f6 100644 --- a/tests/test_server_v2_vaults.py +++ b/tests/test_server_v2_vaults.py @@ -15,7 +15,7 @@ from test_server_http import TOKEN, VAULT_1, server # noqa: F401 (fixture) from test_server_v2 import (Account, alice, b64, put_vault, # noqa: F401 session_for, signup, vault_blob) -import passman_server # noqa: E402 +import secretspace_server # noqa: E402 def new_id() -> str: @@ -26,7 +26,7 @@ def sealed_name(size: int = 40) -> str: return b64(os.urandom(size)) -def wrapped_key(size: int = passman_server.WRAPPED_KEY_LEN) -> str: +def wrapped_key(size: int = secretspace_server.WRAPPED_KEY_LEN) -> str: return b64(os.urandom(size)) @@ -74,7 +74,8 @@ def bob(server): def test_health_says_the_server_has_vaults(server): _, _, body = server.request("GET", "/health", token=None) - assert body["features"] == ["vaults", "sharing", "recovery", "signed-keys"] + assert body["features"] == ["vaults", "sharing", "recovery", "signed-keys", + "format-2"] # --- create -------------------------------------------------------------------- @@ -107,7 +108,7 @@ def test_me_lists_the_personal_vault_first_then_the_others(server, alice): def test_the_names_size_limits_are_inclusive(server, alice): session = session_for(server, alice) - for size in (passman_server.MIN_SEALED_NAME, passman_server.MAX_SEALED_NAME): + for size in (secretspace_server.MIN_SEALED_NAME, secretspace_server.MAX_SEALED_NAME): assert create(server, session, name=sealed_name(size))[0] == 201 @@ -120,13 +121,13 @@ def test_the_names_size_limits_are_inclusive(server, alice): {"id": 12345}, # not a string {"name": None}, # missing {"name": "not base64 !!"}, - {"name": b64(b"x" * (passman_server.MIN_SEALED_NAME - 1))}, # too short - {"name": b64(b"x" * (passman_server.MAX_SEALED_NAME + 1))}, # too long + {"name": b64(b"x" * (secretspace_server.MIN_SEALED_NAME - 1))}, # too short + {"name": b64(b"x" * (secretspace_server.MAX_SEALED_NAME + 1))}, # too long {"name": 7}, {"wrapped_key": None}, {"wrapped_key": "not base64 !!"}, - {"wrapped_key": b64(os.urandom(passman_server.WRAPPED_KEY_LEN - 1))}, - {"wrapped_key": b64(os.urandom(passman_server.WRAPPED_KEY_LEN + 1))}, + {"wrapped_key": b64(os.urandom(secretspace_server.WRAPPED_KEY_LEN - 1))}, + {"wrapped_key": b64(os.urandom(secretspace_server.WRAPPED_KEY_LEN + 1))}, ]) def test_a_bad_vault_is_refused_and_nothing_is_made(server, alice, changes): session = session_for(server, alice) @@ -171,7 +172,7 @@ def test_making_a_vault_needs_a_session(server, alice, token): def test_the_limit_of_vaults_per_account(server, alice, bob, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_VAULTS_PER_ACCOUNT", 3) + monkeypatch.setattr(secretspace_server, "MAX_VAULTS_PER_ACCOUNT", 3) session = session_for(server, alice) for _ in range(3): assert create(server, session)[0] == 201 @@ -184,7 +185,7 @@ def test_the_limit_of_vaults_per_account(server, alice, bob, monkeypatch): def test_deleting_a_vault_makes_room_under_the_limit(server, alice, monkeypatch): - monkeypatch.setattr(passman_server, "MAX_VAULTS_PER_ACCOUNT", 1) + monkeypatch.setattr(secretspace_server, "MAX_VAULTS_PER_ACCOUNT", 1) session = session_for(server, alice) view, _ = made(server, session) assert create(server, session)[0] == 403 @@ -236,7 +237,7 @@ def test_a_new_vault_refuses_a_file_that_is_not_pmv3(server, alice): view, _ = made(server, session) for blob in (b64(b"PMV2" + os.urandom(80)), VAULT_1): status, _, body = put_vault(server, session, view["id"], blob, "0") - assert status == 400 and "SecretSpace 3" in body["error"] + assert status == 400 and "not a SecretSpace vault" in body["error"] def test_a_vault_and_the_personal_vault_hold_separate_blobs(server, alice): @@ -304,8 +305,8 @@ def test_the_owner_renames_a_vault(server, alice): @pytest.mark.parametrize("body", [ {}, {"name": None}, {"name": "not base64 !!"}, {"name": 5}, - {"name": b64(b"x" * (passman_server.MIN_SEALED_NAME - 1))}, - {"name": b64(b"x" * (passman_server.MAX_SEALED_NAME + 1))}, + {"name": b64(b"x" * (secretspace_server.MIN_SEALED_NAME - 1))}, + {"name": b64(b"x" * (secretspace_server.MAX_SEALED_NAME + 1))}, ]) def test_a_bad_new_name_is_refused(server, alice, body): session = session_for(server, alice) From 242df72fb1f08e16ae981bb39ca88c6cd2e81633 Mon Sep 17 00:00:00 2001 From: usermaneGermane Date: Sat, 14 Nov 2026 20:27:08 +0200 Subject: [PATCH 2/2] The old-name guard reads program files only, not an icon or a cache Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_011ojnyXb6JY4UPVU4waZe1w --- tests/test_old_name.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_old_name.py b/tests/test_old_name.py index 7f8f848..0de751a 100644 --- a/tests/test_old_name.py +++ b/tests/test_old_name.py @@ -24,6 +24,8 @@ def leftovers(): continue if OLD.search(name): found.append(f"{name}: the file's own name") + if not name.endswith((".py", ".service", ".spec")): + continue # an icon, a cache with open(path, encoding="utf-8") as f: for number, line in enumerate(f, 1): if not OLD.search(line):