Skip to content

fix: verify passwords server-side instead of running argon2 in the browser#3468

Merged
MattDHill merged 5 commits into
masterfrom
fix/backup-password-argon2-main-thread
Jul 13, 2026
Merged

fix: verify passwords server-side instead of running argon2 in the browser#3468
MattDHill merged 5 commits into
masterfrom
fix/backup-password-argon2-main-thread

Conversation

@MattDHill

Copy link
Copy Markdown
Member

Summary

On mobile, entering the master password when creating a backup froze the whole browser for 20–25 seconds — long enough to read as a device crash. Same on Change Password. It was not tuiAutoFocus; it was argon2.

The UI called verify() from @start9labs/argon2 — a synchronous WASM Argon2id hash on the main thread — in three places: backup create, backup restore, and change password. StartOS hashes the master password with argon2::Config::rfc9106_low_mem() = Argon2id, 64 MiB, t=3, p=1, so that's ~192 MiB of memory-hard mixing, single-threaded, with the main thread blocked throughout. It can't be made fast client-side: the shipped WASM is already an optimized 45 KB release build, p=1 rules out threading, and SIMD would only buy 2–4×.

Worse, creating a backup onto a target whose existing backup used a different password ran it three times (master hash → target hash → original password), and Change Password ran it even when a cheaper validation had already failed — so mistyping the confirmation field cost you 20–25s before being told the passwords don't match. None of this reproduces against the mock server, whose fixture hash is m=1024,t=1.

The server already did the identical check on every one of these calls, natively, in milliseconds:

Client-side verify Server-side equivalent
Create → master password backup_allcheck_password, before the backup task spawns (backup_bulk.rs)
Create → target's existing-backup password BackupMountGuard::mountload_metadatacheck_password (disk/mount/backup.rs)
Restore → backup password same mount path, via getBackupInfo (backup/target/mod.rs)
Change Password → current password reset_password_implargon2::verify_encoded (auth.rs)

So all four client-side verifies are deleted and @start9labs/argon2 is dropped from the web entirely (dependency, wasm asset, and the initArgon() bootstrap — which the setup-wizard ran on every boot despite never calling verify()).

The one thing that needed a backend change

backup_all runs two password checks and both returned ErrorKind::IncorrectPassword, so the client couldn't tell "you typed your master password wrong" from "this target's existing backup was encrypted with a different password — ask for the original". Added ErrorKind::BackupPasswordMismatch (81) for the mount failure, which reaches the client as the RPC error code. ErrorKind has no TS derive, so no binding regeneration was needed.

Prompt semantics are preserved exactly: a password the server rejects leaves the prompt open to retry (previously verifyPassword's EMPTY + take(1); now TaskService.run's Promise<boolean> + filter(Boolean) + take(1) — note PromptModal.submit() calls next(), not complete(), so the take(1) is what closes the dialog).

Also in this PR

  • tuiAutoFocus removed from every site — the shared prompt dialog, the refresh alert, the OS-update dialog, the marketplace package drawer, the setup wizard's password page, three StartTunnel dialogs, and StartWRT's inbound-rule dialog. On mobile it raised the keyboard the instant a sheet opened, over the dialog's own buttons.
  • Ad-hoc password prompts are no longer form submissions. The shared prompt dialog sets autocapitalize="off" (mobile keyboards were capitalizing the first character of the backup password) and submits via its own buttons / Enter rather than a <form> submit, so browser password managers stop offering to save a backup encryption password as a saved credential. The setup wizard's Unlock Backup prompt gets the same autocapitalize treatment.

Notes

  • The parked portal/routes/backups/ tree (@TODO 041, scheduled backup jobs) is kept — only its argon2 call is stripped, so un-parking it in 0.4.1 doesn't silently reintroduce the freeze.
  • Verified: npm run check (all projects) + check:tunnel, and successful builds of ui, setup-wizard, tunnel, wrt, and brochure. Rust is left to CI.
  • Unrelated, noticed in passing: check:tunnel is defined in package.json but missing from the aggregate check script, so npm run check never typechecks the StartTunnel web app. It passes clean today; not fixed here.

🤖 Generated with Claude Code

…owser

The UI ran a synchronous WASM Argon2id verify on the main thread when creating a
backup, restoring a backup, and changing the master password. At the parameters
StartOS hashes with (rfc9106_low_mem: Argon2id, 64 MiB, t=3) that blocks the tab
for seconds on desktop and 20-25s on mobile, where it reads as a hung browser.
Creating a backup onto a target whose existing backup used a different password
ran it three times over.

The server already performed the identical argon2 verification on every one of
these calls -- backup_all's check_password, BackupMountGuard::mount's
load_metadata, and reset_password_impl -- natively, in milliseconds. All three
flows now rely on it, and @start9labs/argon2 is dropped from the web entirely.

backup_all's two password checks were indistinguishable to the client: both came
back as IncorrectPassword. Add ErrorKind::BackupPasswordMismatch for the mount
failure so the UI can still tell "wrong master password" apart from "this
target's existing backup used a different password" and prompt for the original.

Also drop tuiAutoFocus from every site, and stop the shared prompt dialog from
submitting as a form -- it now sets autocapitalize="off" and submits via its own
buttons, so mobile keyboards stop capitalizing passwords and browser password
managers stop offering to save a backup password as a saved credential.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@dr-bonez dr-bonez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we shouldn't return the hash to the frontend in the first place if we're doing this

…tend

Now that the browser no longer verifies passwords, the two hashes we shipped it
are pure attack surface.

`serverInfo.passwordHash` was a copy of the master password's argon2 hash living
in the *public* database, so it replicated to every authenticated client. It
existed solely for the client-side verify. Drop the field; the real hash stays in
the private database. No migration needed -- `Current::pre_init` round-trips the
db through the typed `Database` model on every boot (both the Equal and Less
branches), and with no `deny_unknown_fields` that drops the removed key from
existing databases on the next start.

Worse, `StartOsRecoveryInfo` -- returned by `getBackupTargets`, `setup.cifs.verify`
and the disk listing -- carried each backup's `passwordHash` *and* its
`wrappedKey`: the backup's encryption key sealed under that password. A client
holding both can crack the hash offline and then unwrap the actual backup key.
It couldn't simply skip_serializing them: the same struct is written back to the
backup drive as unencrypted-metadata.json, so that would blank the hash and
wrapped key on disk and make the backup undecryptable. Split it -- on-disk
`BackupUnencryptedMetadata` keeps the key material and never leaves the server;
the ts-exported `StartOsRecoveryInfo` is now just hostname/version/timestamp, and
`recovery_info` maps between them.

Also drop the dead `passwordHash` write from the v0_3_6_alpha_0 migration, so the
hash never lands in the public db even transiently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@MattDHill

Copy link
Copy Markdown
Member Author

Agreed — done in de0c3b0. And it was worse than just the hash:

  • serverInfo.passwordHash — the master password's argon2 hash, sitting in the public db, so it replicated to every authenticated client. Field dropped; the real hash stays in the private db.
  • StartOsRecoveryInfo.wrappedKey — shipped alongside the backup's passwordHash by getBackupTargets / setup.cifs.verify / the disk listing. That's the backup's encryption key sealed under the password, so a client holding both could crack the hash offline and then unwrap the actual backup key.

Two things that weren't obvious going in:

StartOsRecoveryInfo couldn't just get skip_serializing. The same struct is written back to the backup drive as unencrypted-metadata.json, so skipping those fields would have blanked the hash and wrapped key on disk and made existing backups undecryptable. Split instead: on-disk BackupUnencryptedMetadata keeps the key material and never leaves the server; the ts-exported StartOsRecoveryInfo is now just hostname/version/timestamp, with recovery_info mapping between them.

No migration needed. Current::pre_init round-trips the db through the typed Database model on every boot (both the Equal and Less branches), and with no deny_unknown_fields that drops the removed key from existing databases on next start — including boxes already on beta.10. I also removed the now-dead passwordHash write from the v0_3_6_alpha_0 migration so it never lands in the public db even transiently.

One thing to flag: I couldn't compile Rust locally on this machine, so the start-core changes and the two regenerated binding files are riding on CI. start-core-ts-bindings-check regenerates and diffs, so a green Generated Artifacts job is the proof those bindings are byte-for-byte what ts-rs emits — worth a look before merge.

MattDHill and others added 3 commits July 12, 2026 10:57
ts-rs propagates Rust `///` doc comments into the generated TypeScript as a TSDoc
block (see CheckPortV6Res / RangeBindInfo). The doc comment added to
StartOsRecoveryInfo therefore emits four lines the hand-edited binding was
missing, which is exactly the drift ts-bindings-check reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`check:tunnel` was defined but missing from the aggregate script, so the
StartTunnel web app was never type-checked by `npm run check` (only its i18n
was). It passes clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three things that each cost a CI round trip on this branch:

- osBindings/index.ts is built from a shell glob, so its order follows LC_COLLATE.
  CI runs under C; regenerating under a UTF-8 locale reorders all 336 export lines
  and fails the Generated Artifacts gate on pure churn.
- ts-rs propagates a Rust `///` doc comment into the generated .ts as a TSDoc block,
  so adding one to a #[ts(export)] type is not a comment-only change.
- Bindings can only come from compiling the crate (the exporters are #[test] fns the
  derive macro generates inside it), and that build is opt-level=3 by way of
  [profile.test] -- so cap it rather than hunting for a lighter command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
### Changed

- **Dialogs no longer steal focus when they open.** The Add Device, Add Subnet,
and Change Password dialogs no longer autofocus their first field, which on

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unrelated


### Changed

- **The inbound-rule dialog no longer steals focus when it opens**, which on

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

@MattDHill MattDHill merged commit 0c1fc46 into master Jul 13, 2026
27 checks passed
@MattDHill MattDHill deleted the fix/backup-password-argon2-main-thread branch July 13, 2026 04:01
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.

2 participants