app: the Remember Password box writes the encrypted blob

Asked for explicitly, a round after the machinery landed. `AutoLogin::
remember` is now the one write path the login screen uses, and it stores
`password_encrypted` — the authenticated blob of `credential.rs`, keyed
on 32 random bytes in a separate 0600 file. `migrate_plaintext` upgrades
a file written by an older build on the next launch, and the plaintext
field stays *readable* so that upgrade cannot lock anyone out of a store
they already had.

Two decisions inside it, both places where the convenient answer is the
wrong one:

A password that cannot be encrypted is **not stored at all** — there is
no fallback to the clear field. The player cannot see the difference
between a box that wrote a blob and one that quietly wrote plaintext, so
a silent downgrade is worse than a box that visibly did not take.

A stale plaintext field beside a blob is **cleared, not encrypted**:
`effective_password` never read it, so encrypting it would promote a
password the player has already replaced.

What changed is the file. What did not is the threat model: the blob
answers the file being read by another account, copied into a backup or
pasted into a bug report, and cannot answer anything running as this
user — the point of the feature is logging in without typing a secret,
so no secret of the player's is in the chain to key it on. Both files
are 0600 from their first byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-24 15:43:06 -06:00
co-authored by Claude Opus 5
parent 7c1ef274a3
commit a093f3c476
3 changed files with 226 additions and 30 deletions
+14 -6
View File
@@ -240,7 +240,7 @@ rebuilt in `autologin.rs` instead.
| Area | Status | Notes |
|---|---|---|
| Remember the password | done | `autologin.rs` holds the store; `login::enter_login` fills both boxes when the screen comes up. **Fill only — deliberately not automatic.** The mod submits the form and enters the world for you; both halves of that were built here and then cut on the player's own call. An auto-login that lands you in the world is a client you cannot get a screen out of without knowing a trick, and the saving over a filled box is one click. The fill is gated on the tick as well as on the stored text, so unticking stops it in the same frame rather than waiting on the secret being cleared |
| Credential storage | done, **plaintext** | `benilla-config/autologin.toml`, written `0600` by `local_state::write_atomic_private` (asserted, including that it tightens a file that was already world-readable). Plaintext is the mod's own behaviour and its README's own warning; `0600` protects it from other users on the machine and from nothing else — anything running as this user can read it, and it lands in any backup of the folder. Its own file rather than a key in `config.toml`, because that is the file a player pastes into a bug report. Written **on the roster**, not at submit: a roster is the proof the credential was accepted, so a typo is never persisted and refilled on every later launch. Unticking the box **clears the password**, since that is the only way to get it off disk from inside the game |
| Credential storage | **superseded: encrypted** | `benilla-config/autologin.toml`, written `0600` by `local_state::write_atomic_private` (asserted, including that it tightens a file that was already world-readable). Plaintext is the mod's own behaviour and its README's own warning; `0600` protects it from other users on the machine and from nothing else — anything running as this user can read it, and it lands in any backup of the folder. Its own file rather than a key in `config.toml`, because that is the file a player pastes into a bug report. Written **on the roster**, not at submit: a roster is the proof the credential was accepted, so a typo is never persisted and refilled on every later launch. Unticking the box **clears the password**, since that is the only way to get it off disk from inside the game. **Superseded at the player's explicit request:** the box now writes `password_encrypted`, the authenticated blob of `crate::credential`, through `AutoLogin::remember`; `migrate_plaintext` upgrades an existing plaintext file in place on the next launch, and `effective_password` still reads the old field so a file written by an older build logs in once before it is upgraded. What changed is the file; what did not is the threat model — see the credential section below for what the blob answers and what it cannot |
| Remember the last character | done, **selects only** | `char_select::apply_roster_policy`. The roster is always shown — nothing enters the world for the player. Remembered by **name**, and it outranks 1.12's own `lastCharacter` CVar, which is not a preference but a correctness fix: that CVar is a row *index*, and this screen now lets the player reorder rows, so after a reorder the index names whoever moved into that slot. A name survives reordering; an index does not. A remembered character who is gone (deleted, or another realm) falls back to the stock row. Recorded in `select_input` — the path that means *the player chose* — so a reconnect's re-pick and the env fast paths cannot rewrite it |
| Login screen cleanup | done | `cleanLoginScreen` (CVar, default **1**) drops the Blizzard logo, the `BLIZZ_DISCLAIMER` line and the version string. That is the whole of what this screen had left to hide: the rest of the mod's list — the Turtle website/armory/forum/Discord/Reddit buttons, Credits, Cinematics, Terms of Use, ESRB, Manage Account, Community Site, server news — **this screen never drew**, because `login/` keeps only what logging in needs (its module doc says so, and predates this work). Read once in `enter_login` and latched on `LoginForm`, not read per frame where it is used: the latter measured at six undeclared orders against every writer of the registry, for a value that cannot change while the screen is up (there is no console at the login screen to write it from) |
| Character row reordering | done | The mod's per-realm `order` + `char_buttons_locked`. `autologin::apply_order` is a permutation by construction: saved names first in their saved order, anything unmentioned keeping its server order on the end, so a character created since the order was saved appears at the bottom rather than vanishing and one deleted elsewhere is skipped rather than leaving a hole. Applied in `apply_roster_policy` *before* anything indexes a row, because the remembered-row CVar, `select_created_by_name` and `WOW_CHARSELECT_PICK` all mean rows as the player sees them. The arrows fold into `refresh_list`'s existing row loop (they are `Button`s carrying a `SelectAction`, so that query already walked past them) rather than a second `Visibility` writer. A move keeps the selection on the *character*, not the row — swapping rows under a highlighted name would silently re-aim Enter World at its neighbour |
@@ -614,11 +614,19 @@ key file generated here would not decrypt one made there whatever the cipher. A
real install answers nil, which the drain reports rather than storing — a stored credential that
cannot be read back is a login box that silently refills empty.
**The existing plaintext path is untouched.** Storing the password in plaintext was grom's own
explicit choice when the alternatives were a keyring or nothing; this is additive, and nothing
migrates by itself. The machinery to make the Remember Password tick-box write a blob instead is
now all here — one line in `login/mod.rs` — and that is his call to make, not a side effect of
porting two verbs.
**The tick-box now writes the blob** — asked for explicitly, a round after the machinery landed.
Storing the password in plaintext had been grom's own choice when the alternatives were a keyring
or nothing, so the round that built these two verbs left that path alone and said what switching
it would cost; this is the switch, not a side effect of the port. `AutoLogin::remember` is the one
write path, `migrate_plaintext` upgrades a file written by an older build, and the plaintext field
stays *readable* so that upgrade cannot lock anyone out of their own store.
Two decisions inside it worth naming, because both are places a convenient answer is the wrong
one. **A password that cannot be encrypted is not stored at all** — no fallback to the clear
field: the player cannot see the difference between a box that stored a blob and one that quietly
stored plaintext, so a silent downgrade is worse than a box that visibly did not take. And **a
stale plaintext field beside a blob is cleared rather than encrypted**: `effective_password` never
read it, so encrypting it would promote a password the player has already replaced.
**Everything below this line is the reasoning of earlier rounds, kept rather than deleted.** Each
section records what was missing *then*, what it would have taken, and — where a later round built