From 6f3e7b8741bbe8fdacc98cfb200c7753c6ee168a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 13 Oct 2026 20:14:03 +0200 Subject: [PATCH 1/5] feat(server): a way back from a lost or compromised instance key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `key rotate` carries every sealed value onto a new key; `key adopt` takes a database over when the old one is gone, discarding what only it could read. Two commands rather than one and a flag, because the second destroys exactly what the first preserves — and `adopt` refuses outright when the key beside the database still opens it, since that is a rotation and a rotation costs nothing. Both run before the boot they exist to unblock, since that boot is the one that aborts on a mismatched pair. Both take the database exclusively — `locking_mode=EXCLUSIVE` and an immediate `BEGIN EXCLUSIVE`, which a running server's open connections refuse, and which then stays held for the whole operation rather than being a check with a gap after it. Both count what the change costs from the tables, print it, and change nothing until `--confirm`. The previous pair goes into `data/pre-key-` first, the way `database restore` already does, and the new key file is renamed into place only once the transaction has committed. Shares are the part the manual procedure in the operations guide got wrong. A share token is *derived* from the instance key rather than stored, so a key change that leaves `token_hash` alone does not re-address the share, it strands it: the hash is one no later key can produce, and the owner is shown a URL that answers 404. Both commands recompute those hashes, which is what makes "shares survive, with a new URL" true rather than hoped for. The scrobbling queue is the part an operator would not predict. `scrobble_outbox` hangs off `scrobble_link` by `ON DELETE CASCADE`, so discarding the links discards the listens behind them — counted and printed for that reason, and `rekey` asks for `PRAGMA foreign_keys = ON` itself rather than trusting the caller, because `migrate_on` leaves it off and a cascade that did not fire leaves no error, only rows. `migrate_on` is extracted from `migrate` so both paths run one migration routine: the commands write rows, and writing rows into a schema older than the binary is how a repair becomes damage — `scrobble_link` does not exist before 2.0.0-beta.1. Fourteen inversions, each made to fail on the assertion that names it. One of them passed at first: the fixture seeded one of everything, so an assertion on the printed counts held with two of its four fields swapped. The fixture now seeds four different numbers. Closes #221 Signed-off-by: InstaZDLL --- README.md | 2 +- docs/operations.md | 62 ++- docs/rfcs/RFC-002-waveflow-server-v2.md | 2 +- src/cli.rs | 254 ++++++++++- src/database.rs | 338 +++++++++++++- src/main.rs | 3 + src/security.rs | 32 +- tests/cli.rs | 567 ++++++++++++++++++++++++ 8 files changed, 1219 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 72b3a6d5..efc1164e 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Point any Subsonic client at `http://your-host:4533` with that username and pass | **Scrobbling** | ListenBrainz, Last.fm and Maloja, each a named instance, behind a durable queue that survives a restart, retries once and honours `Retry-After` | [RFC-010](docs/rfcs/RFC-010-external-scrobbling.md) | | **Web player** | React 19 client compiled into the binary — complete player and administration surface, authenticated artwork, Media Session, preloading, keyboard controls, 14 localized themes, English and French, responsive | — | | **Security** | Argon2id passwords, tokens stored only as SHA-256 hashes, the dedicated Subsonic password encrypted with ChaCha20-Poly1305 under a local instance key, stream tickets so `