Skip to content

feat: keep platform tokens in the OS keyring only - #16

Merged
MotherSphere merged 5 commits into
mainfrom
keyring-only-token-storage
Oct 8, 2026
Merged

MotherSphere merged 5 commits into
mainfrom
keyring-only-token-storage

Conversation

@MotherSphere

@MotherSphere MotherSphere commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

What changed

1. Platform tokens live in the OS keyring only

Until now every token was saved twice: in plain text in the user_profile table, and in the keyring. With keyring = "3" and no features that keyring was the in-memory mock, so nothing reached it. Since keyring 4 (#11) the keyring really stores them, but the plaintext copy stayed, so it protected nothing.

src/keyring.rs now owns the profile's secrets (steam_api_key, gog_token, gog_refresh_token, xbox_api_key, psn_npsso, epic_token, epic_refresh_token):

  • Save (keyring::save_profile): each secret is written to the keyring and read back. Only when the keyring returns the same value is its database column written as an empty string. An emptied secret deletes the keyring entry, but only one this session read or stored: a locked store can report an existing secret as missing (KeePassXC returns no search results for a locked database, which the Secret Service store maps to NoEntry), and the empty value loaded then must not delete the real secret on a later save. Saves take a lock for their whole duration, so two saves running at once (every keystroke in a token field saves the profile on its own thread) never mistake each other's read-back for a broken keyring.
  • Load (keyring::load_profile): empty columns are filled from the keyring. A non-empty column (every existing user, or a session without a keyring) is the newest value, so it is moved into the keyring through the same save path, and its column is cleared only after the read-back matches.
  • No usable keyring (no store, for example Linux without a Secret Service, or any read/write failure): the secrets stay in the database as before, so nothing is lost. The failure is logged once, the Profile page shows "No system keyring available: platform tokens are stored unencrypted in the database.", and for the rest of the session Spotter leaves the keyring alone: nothing is deleted from a keyring whose contents could not be read (otherwise a locked keyring at startup followed by any profile save would have wiped the tokens). The next start retries and migrates.
  • PRAGMA secure_delete = ON on every connection: without it, the cleared tokens stay readable in the file's free space. Checked locally: after clearing seven 327-byte tokens, all seven were still in spotter.db without the pragma, none with it.
  • The keyring code now uses keyring_core::Entry (keyring 4's own core, added as a direct dependency, already in the tree) and selects the platform store once at startup through keyring::Entry::store_status(). That is what lets the test install keyring-core's mock store: keyring's own Entry::new always replaces the default store with the platform one.
  • Secrets are stored as UTF-8 bytes (set_secret) rather than set_password, which Windows stores as UTF-16 and so halves Credential Manager's 2560-byte limit; Epic's eg1 tokens are signed JWTs. No release ever wrote real keyring entries (0.2.1 had the mock), so nothing needs converting.
  • db.rs comments, README (Data Storage, Project Structure, and the Privacy policy under Code signing policy), docs/DATA_MODEL.md and env.example now say where the tokens are and when they are unencrypted.

2. GOG login code and refresh token no longer travel in the URL

src/gog/auth.rs called GET https://auth.gog.com/token?...&refresh_token=... (the refresh token not even URL-encoded) and the same for the login code. Both now go through one post_token helper that sends them as an application/x-www-form-urlencoded POST body.

Evidence that the endpoint accepts this:

  • The established open source clients all use GET with query parameters, so they neither prove nor disprove POST: minigalaxy, lgogdownloader, heroic-gogdl, comet. Lutris also uses GET and redacts refresh_token and code from its logs, which is the leak this change avoids.

  • Other clients refresh with a form-encoded POST body: Clarity and Steam-App-ID-Finder (both grants). GOG publishes no documentation for this endpoint.

  • Direct check against auth.gog.com/token with dummy values (the public Galaxy client id and secret already in the code, fake code and refresh token), POST with a form body:

    • fake refresh token: 400 invalid_grant "Invalid refresh token, client_id:46899977096215655", identical to the GET answer
    • fake login code: 400 invalid_grant "Code doesn't exist or is invalid for the client", identical to GET
    • wrong client secret: 400 invalid_client
    • grant_type left out: 400 invalid_request "Invalid grant_type parameter or parameter missing", and "code" is required when only the code is left out

    So the server reads the client credentials, the grant type, the code and the refresh token from the body. The same answers came back through ureq's send_form, the call Spotter now makes.

3. Dead code

epic::auth::verify_token was unused (only kept alive by #[allow(dead_code)]) and is removed. The unused keyring::delete and CredentialKey went away with the rewrite.

Proven locally

  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, and cargo test (lib 1, bin 34, integration 53, unit 58 tests, all passing) on Linux.
  • cargo clippy --all-targets --target x86_64-pc-windows-msvc -- -D warnings (clang-cl + xwin) passes.
  • New test keyring::tests::secrets_move_to_the_keyring_or_stay_in_the_database (mock store) covers: no keyring (secrets stay in the database), migration of legacy columns into the keyring with the columns cleared, save and delete through the keyring only, a keyring read failure (nothing deleted, later saves fall back to the database), a locked keyring that answers NoEntry at load (the secret survives a later save), and 8 threads saving 200 times each at once (the session stays on the keyring). Mutation checks: removing the session guard, the column clearing, the migration or the seen-secrets guard each makes it fail; removing the save lock made it fail in 10 of 10 runs, and it passed 20 of 20 runs with the lock.
  • The test never touches a real keyring: the platform store is only selected by keyring::init(), which only the app's startup calls.

Only CI, a release or a real login can prove

  • macOS was not built locally; CI covers it.
  • cargo audit is not installed locally; CI runs it. keyring-core was already in the dependency tree.
  • Real keyring behaviour (GNOME Keyring or KWallet prompts, macOS Keychain access prompts for a new binary, Windows Credential Manager) and the migration of an existing database need a run of the app on each platform.
  • A real GOG login and refresh with a valid code and refresh token over POST: the dummy-value checks above show the server parses the body, but only a real account proves the success path.

The tokens were saved twice: in plain text in the user_profile table and
in the keyring, which keyring 3 without features only kept in memory.
Saves now write each secret to the OS keyring, check it by reading it
back, and leave its database column empty. On start, secrets still in
the database are moved to the keyring and their column is cleared once
the keyring returns the same value.

When no keyring is usable, the secrets stay in the database as before,
the failure is logged, the Profile page says the tokens are stored
unencrypted, and nothing is deleted from a keyring that could not be
read. secure_delete keeps a cleared token from lingering in the file.
They travelled in the token URL's query string, where logs and proxies
keep them, and the refresh token was not even URL-encoded.
auth.gog.com/token reads the same parameters from an
application/x-www-form-urlencoded POST body: with dummy values it gives
the same invalid_grant answers as GET, invalid_client for a wrong
secret and invalid_request when grant_type is missing.
Nothing called it; it only compiled behind allow(dead_code).
A locked store can report an existing secret as missing (KeePassXC
returns no search results for a locked database, which the Secret
Service store maps to NoEntry). The secret then loaded as empty, and
once the store was unlocked any profile save deleted it, with its
database column already cleared. An empty secret now only deletes a
keyring entry this session read or stored.
Every keystroke in a token field saves the profile on its own thread.
Two saves of the same secret could interleave, and one's read-back
then saw the other's value, which switched the session to storing all
tokens unencrypted in the database. Saves now take a lock for their
whole duration.
@MotherSphere
MotherSphere merged commit 773aba6 into main Oct 8, 2026
3 checks passed
@MotherSphere
MotherSphere deleted the keyring-only-token-storage branch October 8, 2026 12:33
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