Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ bun run build # webapp first, then cargo — the order

Tests need no service container: `test_app()` builds a `Config` over a `TempDir` and the suite drives the same router `main` serves. FFmpeg and ffprobe must be on `PATH`.

The integration suite is seventeen targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says.
The integration suite is seventeen targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says.

`cargo build` works without a client build — `build.rs` embeds a placeholder page when `webapp/dist` is absent. A real `vite build` overwrites it.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<audio src>` needs no header, origin validation and CSRF protection for browsers | [operations](docs/operations.md) |
| **Operations** | Single-file SQLite in WAL with one process-wide writer, immutable checksummed migrations, coherent backup and restore of the database/key pair, `/health` and `/ready`, JSON logging that never records a query string or a token | [operations](docs/operations.md) |
| **Operations** | Single-file SQLite in WAL with one process-wide writer, immutable checksummed migrations, coherent backup and restore of the database/key pair and a way back from a lost or compromised key, `/health` and `/ready`, JSON logging that never records a query string or a token | [operations](docs/operations.md) |

## Documentation

Expand Down
84 changes: 59 additions & 25 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,52 +32,86 @@ unrecoverable, or when it must be replaced because it leaked.

### What you lose, and what you keep

Almost everything is unaffected, because almost nothing is encrypted:
This is the **adoption** case below, where the key is genuinely gone. A
rotation, where you still hold it, loses nothing from the left-hand column.

Almost everything is unaffected either way, because almost nothing is
encrypted:

| Lost, and re-entered by hand | Survives untouched |
| --- | --- |
| The dedicated Subsonic password of every account | Every account, and its web password — Argon2id, never encrypted |
| Every external scrobbling link (Last.fm, ListenBrainz, Maloja) | The whole catalogue: tracks, albums, artists, libraries and their members |
| The Subsonic credential of every account — the dedicated password **and** the `wfsk_` API key beside it | Every account, and its web password — Argon2id, never encrypted |
| Every external scrobbling link (Last.fm, ListenBrainz, Maloja), and the listens queued behind it | The whole catalogue: tracks, albums, artists, libraries and their members |
| | Favourites, ratings, playlists, play queues, listening history, bookmarks |

The API key is not encrypted — it is stored as a SHA-256 hash, which no key
change can touch — but it shares its row with the password that is, and the
columns are `NOT NULL`. So the row goes whole. `credential set` mints a new
password **and** a new API key, and both have to reach the client.

Shares are kept but **their public URLs change**: a share token is derived from
the instance key rather than stored, so every link handed out under the old key
stops resolving.
stops resolving. The commands below recompute the stored hash under the new
key, which is what keeps the share reachable at its new address instead of
stranding it at an old one nobody can produce again.

Stream tickets are sealed under the key too. They last an hour and are minted on
demand, so nothing has to be done about them — but it is also why a leaked key
matters even when almost nothing is encrypted under it: whoever holds one can
mint a ticket for any track they can address.

### The procedure
### Two commands, because there are two situations

There is no command for this yet
([#221](https://github.com/InstaZDLL/waveflow-server/issues/221)). Until there
is, it is two writes and a deleted file, with the server **stopped**:
They are named apart rather than separated by a flag, because one preserves
exactly what the other destroys.

```sql
-- Values only the old key could read. They are unreadable now, not damaged.
DELETE FROM subsonic_credential;
DELETE FROM scrobble_link;
-- Release the fingerprint. The next start binds whatever key it finds.
DELETE FROM instance_metadata;
```
Both run with the server **stopped**: they take the database exclusively, and
refuse to run at all while another process is holding it. And both are a reading until you say
otherwise — without `--confirm` they count what the change costs, print it,
and change nothing at all. Not even a pending migration: if the binary carries
migrations this database has not run, the reading says so and stops, because
applying one is a change and this half makes none.

**The old key is in hand**, and you want a different one — it leaked, or it is
simply time:

```bash
rm data/instance.key
waveflow-server key rotate # what it would cost
waveflow-server key rotate --confirm
```

Start the server. It generates a fresh 32-byte key, binds its SHA-256 fingerprint
to the database, and comes up on the catalogue you already had. Then re-set the
Subsonic password of each account with `credential set`, and re-link any
scrobbling destination.
Every encrypted value is read with the old key and written back under the new
one. Nothing is lost at all; the share URLs change and that is the whole of it.

**Take a copy of `data/` before you begin.** The three deletions are not
reversible, and a mistyped table name is a restore away from being someone's
evening.
**The old key is gone**, and nothing can be carried over:

```bash
waveflow-server key adopt
waveflow-server key adopt --confirm
```

Verified end to end on a real instance: 164 tracks, 130 albums, 160 artists, one
account and one library came back untouched under a new key.
The Subsonic credentials and the scrobbling links go, the queued listens with
them, and a fresh key is bound to the catalogue you already had. `adopt`
refuses outright when the key beside the database still opens it: that is a
rotation, and a rotation costs nothing.

Both move the previous pair into a timestamped `data/pre-key-…` directory
before touching anything — before any pending migration as well as before the
key, so what is in there is the database as the command found it. The way
`database restore` does.

Afterwards, re-set the Subsonic credential of each account with
`credential set` and hand each client its new password and API key, re-link
any scrobbling destination, and **take a fresh backup**: the new key is exactly
as unrecoverable as the one you just replaced.

**Not by hand.** Deleting `subsonic_credential`, `scrobble_link` and
`instance_metadata` in SQL — which is what this page documented before the
commands existed, and what was verified end to end on a real instance of 164
tracks, 130 albums and 160 artists — does bring the catalogue back under a new
key. What it also does is strand every share: the stored token hash was
computed from the old key, no later key can produce it again, and the owner is
shown a URL that answers 404. That is the difference the commands carry.

## One path whose query string must not reach your proxy's log

Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/RFC-002-waveflow-server-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Libraries are private or shared through explicit owner, manager and listener mem

WaveFlow accounts are local. Web passwords use Argon2id. Access, refresh and API tokens are random opaque values stored only as SHA-256 hashes. Refresh tokens rotate on use and sessions belong to revocable devices.

Every user may have a separate Subsonic app password. It is never the web password. Compatibility with token-and-salt authentication requires reversible verification, so the app password is encrypted with ChaCha20-Poly1305 under a random local instance key. The database and key must be backed up together. SQLite stores only a SHA-256 fingerprint of that key; boot, backup verification and restore reject mismatched pairs before exposing or replacing data.
Every user may have a separate Subsonic app password. It is never the web password. Compatibility with token-and-salt authentication requires reversible verification, so the app password is encrypted with ChaCha20-Poly1305 under a random local instance key. The database and key must be backed up together. SQLite stores only a SHA-256 fingerprint of that key; boot, backup verification and restore reject mismatched pairs before exposing or replacing data. A restored bundle is the cheap way out of a mismatch and not the only one: `key rotate` carries every sealed value onto a new key, `key adopt` discards what a key that is gone sealed and binds a fresh one, and both re-derive the share tokens the key names rather than leaving them stranded.

### Playback

Expand Down
Loading
Loading