Skip to content

Modernize crypto, rebuild database setup, and add SQL migrations - #222

Merged
einsteinx2 merged 6 commits into
mainfrom
cleanup-crypto-and-migrations
Jul 27, 2026
Merged

Modernize crypto, rebuild database setup, and add SQL migrations#222
einsteinx2 merged 6 commits into
mainfrom
cleanup-crypto-and-migrations

Conversation

@einsteinx2

Copy link
Copy Markdown
Owner

Started as three small cleanups. Two of them turned out to be already fixed by #221, and the
third grew into replacing the password scheme and building a migration system.

Two things to know before merging

Last.fm scrobbling was broken, and this fixes it. Lastfm.CompileApiCall builds the
api_sig parameter straight from string.MD5(), which returned dash-separated uppercase —
so WaveBox was sending a 47-character string where the API requires
"a 32-character hexadecimal md5 hash". Every signed call was malformed: auth.getSession,
and scrobbling from both /api/scrobble and the Subsonic scrobble endpoint. It falls out
of normalizing the hash helpers, so it isn't visible from the commit subjects.

Existing databases need their users recreated. Passwords move to bcrypt with no legacy
verification path, so any user rows in an existing wavebox.db stop authenticating. The
failure mode is a silent "wrong password", not an error. Nothing is deployed, so this is a
hard cut on purpose.

The arc

Commits 3 and 6 look contradictory in isolation — one deletes the migration machinery, the
other adds it back — so the order matters:

  1. 99cec85 README links → relative, so they can't rot on another branch rename.
  2. 5bd9e53 Obsolete crypto APIs (SYSLIB0021, SYSLIB0054) → the static HashData
    APIs and Volatile.Write. The four hash helpers had drifted into three different output
    formats with nothing requiring the spread; they all return lowercase hex now, which is
    what fixes Last.fm.
  3. 9eb90d3 Deletes the prebuilt res/*.db templates in favour of applying the SQL
    schema on first run. Keeping a binary and a .sql file in sync by hand is what made the
    one existing migration necessary, and the binary had already drifted — its User table
    carried an ALTER-appended , ApiKey TEXT) tail. With a fresh database always built
    from the current schema, UpgradeSchema() had nothing left to do.
  4. b3c6e0c PBKDF2-HMACSHA1 at 2500 iterations (~240× below OWASP's floor, on the wrong
    PRF, via obsolete APIs) → bcrypt. BCrypt.Net-Next keeps NativeAOT clean: net10.0
    target, zero dependencies, no reflection or P/Invoke. Work factor 11 by measurement, not
    default — cost 12 is ~279ms here but several times that on Pi-class linux-arm64, past
    OWASP's one-second ceiling.
  5. dd14d2e The seed runner split SQL on a bare ; and carried the warning "do not
    point this at hand-written SQL" — which migrations are. The fix isn't a smarter splitter,
    it's not splitting: sqlite3_prepare_v2 returns a pointer to the remaining SQL, and the
    vendored binding already declared that parameter and passed IntPtr.Zero.
  6. cabe601 Ordered migrations in res/migrations/00001_description.sql, tracked in the
    Version table that has existed unused all along. wavebox.sql is now a frozen baseline
    at version 0, so a fresh database is the seed plus every migration replayed — one code
    path, and the chain is exercised by every fresh install and CI run rather than first
    meeting a real database on someone's server.

Notes for review

  • Deliberately not using EnhancedHashPassword. Its unkeyed SHA-384 pre-hash is the
    password-shucking construction OWASP warns against,
    and it emits an ordinary-looking $2a$ string with no marker, so pairing it with Verify
    later would fail silently. Over-72-byte passwords are rejected instead of truncated.
  • ExecuteScript marshals UTF-8 explicitly rather than reusing the existing string
    overload, which marshals as ANSI and passes a character count where SQLite wants a byte
    count. Both are invisible today only because every .sql file here is ASCII; the
    pre-existing call sites still have them.
  • Migration failures stop startup — malformed file name, duplicate version, or a database
    newer than the build. WaveBoxMain reports these as a plain message and exits 1 rather
    than as an unhandled-exception abort, since they're operator-actionable.
  • res/migrations/ ships with only its README for now; there's no pending schema change and
    a no-op migration would be noise.

Verification

443 unit/integration + 19 E2E pass. The E2E suite also runs against the NativeAOT-published
binary, which is what proves both bcrypt and the new P/Invoke survive AOT — no trim or AOT
warnings. A clean build is down to 4 warnings from 8 in the touched categories, leaving only
the two SYSLIB0014 WebRequest.Create calls in Lastfm.cs.

The upgrade path itself was checked by hand against the published binary, since no automated
test spans two builds: fresh boot lands at version 0; adding a migration takes it to 1 and
adds the column; restarting re-runs nothing; removing it refuses to start with the downgrade
message.

Follow-ups, not in this PR

  • ~290 CA analyzer warnings, ~33 of which look like real bugs rather than style: CA2013
    ReferenceEquals on int?/long? (boxes, so always false — e.g. the null guard in
    User.Delete never fires), CA1806 ignored TryParse results, CA2022 inexact
    FileStream.Read.
  • SYSLIB0014 WebRequest.CreateHttpClient in Lastfm.cs, plus that file's untested
    CompileApiCall.
  • Utility.RandomString seeds session IDs from System.Random, not a CSPRNG.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TXVofbLdTr6YWA892F6pMQ

einsteinx2 and others added 6 commits July 26, 2026 18:07
The LICENSE.md and API_DOCS.md links pointed at github.com/.../blob/<branch>/,
which broke once on the master -> main rename. Relative links resolve correctly
on GitHub and in local editors, and can't rot on a future rename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXVofbLdTr6YWA892F6pMQ
Replaces the obsolete MD5CryptoServiceProvider/SHA1CryptoServiceProvider
(SYSLIB0021) with the static HashData APIs (CA1850), and collapses the
NETFX_CORE/SILVERLIGHT preprocessor block in the vendored sqlite-net down to
Volatile.Write (SYSLIB0054).

The four hash helpers had accreted three different output formats: string.MD5()
was dash-separated uppercase, string.SHA1() was uppercase undashed, and the
byte[]/Stream overloads were lowercase undashed. Nothing required the spread --
each helper just hand-rolled its own formatting -- so they all now return plain
lowercase hex.

That fixes a live bug. Lastfm.CompileApiCall builds the api_sig parameter
straight from string.MD5(), so WaveBox was sending a 47-character
dash-separated string where the Last.fm API requires "a 32-character
hexadecimal md5 hash". Every signed call -- auth.getSession, and scrobbling
from both /api/scrobble and the Subsonic scrobble endpoint -- was malformed.

Playlist.CalculateHash no longer needs its .Replace("-", string.Empty), and
Playlist.Md5Hash / ETags change case; both are self-consistent internal values
that regenerate, and nothing compares across the formats.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXVofbLdTr6YWA892F6pMQ
res/wavebox.db and res/wavebox_querylog.db were prebuilt SQLite files copied
into the root dir on first run, with res/wavebox.sql sitting alongside as a
second copy of the same schema that nothing at runtime ever read. Keeping the
two in sync by hand is what made a migration necessary at all, and the binary
had already drifted: its User table carried an ALTER-appended ", ApiKey TEXT)"
tail rather than a clean declaration.

Applying the schema script on first run takes moments, so the binaries are gone
and the .sql files are now the only source of truth. Adds a
res/wavebox_querylog.sql, which had no textual counterpart before.

Verified the replacement is faithful before deleting: applying wavebox.sql to an
empty database reproduces the old template exactly, schema and seed data alike
(ItemType 12 rows, FileType 10 rows), modulo that ALTER artifact.

With a fresh database always built from the current schema, UpgradeSchema() has
nothing left to upgrade -- User.ApiKey and its unique index are already declared
in wavebox.sql -- so it and its test go away. Note this leaves no migration
mechanism; that's fine pre-release but will need one before the first release,
and the (currently unused) Version table is where it should hang.

Two behavioral notes:

- Setup now keys off whether the database has any tables rather than whether the
  file exists. The connection pools are constructed before DatabaseSetup runs,
  so an empty file left by an early connection previously caused the copy to be
  skipped and the schema to go missing entirely.
- A failed schema apply now propagates instead of being logged and swallowed.
  Continuing without a schema just produces confusing null references later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXVofbLdTr6YWA892F6pMQ
The password scheme was PBKDF2-HMACSHA1 at 2500 iterations, roughly 240x below
OWASP's floor for that construction, using the now-obsolete Rfc2898DeriveBytes
constructor (SYSLIB0060) and RNGCryptoServiceProvider (SYSLIB0023). Rather than
modernize the call sites in place, this moves to bcrypt.

A bcrypt hash is self describing -- "$2a$<cost>$<salt><hash>" -- so the salt and
work factor travel with it. That removes the separate PasswordSalt column, which
is dropped from the schema outright; no migration, since nothing is deployed.
This is a hard cut with no legacy verification path, so any user rows in an
existing wavebox.db stop authenticating and need recreating.

Adds BCrypt.Net-Next, which stays NativeAOT-clean: a first-class net10.0 target,
zero package dependencies, and no reflection or P/Invoke in its sources. Verified
by publishing NativeAOT and running the E2E suite against the published binary --
no trim or AOT warnings, all 19 pass.

Deliberately not using EnhancedHashPassword: its unkeyed SHA-384 pre-hash is the
password-shucking construction OWASP warns against, and it emits an ordinary
"$2a$" string with no marker, so mispairing it with Verify later would fail
silently. Instead, passwords over bcrypt's 72-byte input limit are rejected at
the set-password boundary rather than being silently truncated.

Work factor 11, chosen by measurement rather than default. On an M-series Mac:
cost 10 ~115ms, 11 ~140ms, 12 ~279ms, 13 ~552ms. Pi-class linux-arm64 runs
several times slower, which would put 12 past OWASP's one-second ceiling there.
Tests drop to bcrypt's minimum via a module initializer, since fixtures hash
constantly and the production factor would dominate the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXVofbLdTr6YWA892F6pMQ
The seed script runner split files on a bare ';' and had to carry the warning
"do not point this at hand-written SQL", because that breaks on a semicolon
inside a string literal, a comment, or a trigger body. Migrations will be
hand-written, so the splitter had to go first.

The fix isn't a smarter splitter, it's not splitting at all. sqlite3_prepare_v2
compiles one statement and hands back a pointer to the remainder, so SQLite's
own tokenizer does the work. The vendored binding already declared that pzTail
parameter and passed IntPtr.Zero, which is exactly why Execute() only ever ran
the first statement of a script.

Adds SQLite3.ExecuteScript plus a prepare_v2 overload that keeps the tail, and
surfaces it as ISQLiteConnection.ExecuteScript so callers don't reach for the
raw handle. No new dependency: SQLitePCLRaw.core would mean two SQLite bindings
in one process, Microsoft.Data.Sqlite is a second data-access stack, and DbUp's
splitting is SQL-Server oriented.

The script is marshalled as an explicit UTF-8 buffer and walked by byte offset,
which sidesteps two latent bugs in the existing string overload rather than
inheriting them: it marshals as ANSI (the system codepage on Windows), and it
passes a character count where sqlite3_prepare_v2 wants a byte count, so any
non-ASCII SQL is silently truncated. The pre-existing call sites still have
both; those are unrelated to this path.

wavebox.sql loses the PRAGMA/BEGIN TRANSACTION/COMMIT wrapper that sqlite3
.dump emits, since the caller owns the transaction now. That's what lets the
statement filtering disappear along with the splitter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXVofbLdTr6YWA892F6pMQ
Collapsing the ApiKey migration into the seed was the right call while nothing
was deployed, but it left no upgrade path at all: an existing database silently
kept its old schema and only failed later with a cryptic "no such column". The
server is about to run on a real machine, so schema changes now need to reach
databases that already exist.

Migrations are ordered SQL files in res/migrations, named 00001_description.sql
-- readable and diffable individually rather than accumulating in one C# method.
The five zero-padded digits mean lexical and numeric order agree at a glance,
though the runner sorts numerically regardless. How far a database has got lives
in the single-row Version table, which existed and was never used.

res/wavebox.sql becomes a frozen baseline at version 0, so a fresh database is
the seed plus every migration replayed in order. That keeps one code path
instead of reintroducing the seed/migration duplication just deleted, and means
every fresh install and CI run exercises the whole chain -- a broken migration
can't lurk until it reaches somebody's server.

Failures are loud, since the alternative is a database in an unknown state:

- A malformed file name or two files sharing a version number stop startup,
  rather than being skipped without comment.
- A database whose version exceeds the newest bundled migration is refused with
  "created by a newer version of WaveBox", which is the downgrade case.
- Each migration shares a transaction with its version bump, so a mid-chain
  failure leaves the database cleanly at the last migration that did succeed.

WaveBoxMain now reports any database setup failure as a plain message and exits
1. These are operator-actionable ("you misnamed a migration"), and burying them
in an unhandled-exception abort under twenty lines of host machinery makes them
look like a crash instead.

Verified the upgrade path by hand against the published AOT binary, since no
automated test spans two builds: fresh boot lands at 0; adding a migration takes
it to 1 and adds the column; restarting re-runs nothing; and removing it again
refuses to start with the downgrade message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXVofbLdTr6YWA892F6pMQ
@einsteinx2
einsteinx2 merged commit c8cf25b into main Jul 27, 2026
9 checks passed
@einsteinx2
einsteinx2 deleted the cleanup-crypto-and-migrations branch July 27, 2026 00:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant