Skip to content

fix(KeyringStorage): don't wipe stored secrets when saving before the initial fetch - #3616

Open
PHONE1X wants to merge 1 commit into
end-4:mainfrom
PHONE1X:fix/keyring-save-before-load-wipes-secrets
Open

fix(KeyringStorage): don't wipe stored secrets when saving before the initial fetch#3616
PHONE1X wants to merge 1 commit into
end-4:mainfrom
PHONE1X:fix/keyring-save-before-load-wipes-secrets

Conversation

@PHONE1X

@PHONE1X PHONE1X commented Aug 25, 2026

Copy link
Copy Markdown

Problem

KeyringStorage.saveKeyringData() serialises the entire keyringData
object and pipes it to secret-tool store, which replaces the stored value
outright — there is no merge on the storage side.

keyringData defaults to {} and is only populated by the asynchronous
try_lookup.sh fetch. Nothing triggers that fetch at startup, so any
setNestedField() call that lands before it resolves writes a blob containing
only the field just written, destroying every other secret already stored.

Both call sites are reachable in that state:

  • services/Ai.qml:546setApiKey(), behind the /key <apikey> command.
    The nearest protection is Ai.qml:600, which fires a fire-and-forget
    fetchKeyringData() in a different function and never waits for it.
  • services/GoogleCloud.qml:46setKeyJson(), when pasting a service
    account key. loadKeyIfPossible() checks KeyringStorage.loaded before
    reading, but setKeyJson() does not check it before writing.

So: open the shell, paste an API key before the keyring fetch has completed,
and the other keys are silently gone. There is no error and no obvious cause —
the panel simply shows blank fields next time.

Fix

Queue writes that arrive before the data is loaded, and replay them once the
real contents are in memory:

  • setNestedField() returns early and queues when !loaded, kicking off a
    fetch instead of saving.
  • The mutation body moves unchanged into applyNestedField(), so queued
    writes reuse exactly the same logic.
  • flushPendingWrites() replays the queue after loaded becomes true and
    saves once.
  • The exitCode === 1 (no entry yet) branch no longer writes an empty blob
    when writes are already queued, so a fresh install does one save, not two.
  • dataChanged() is emitted when the initial fetch populates the data.
    GoogleCloud.qml:107 already listens for it; previously it only ever fired
    after a save.
  • fetchKeyringData() is now re-entrancy safe, since the queue path can call
    it while a fetch is already in flight.

I deliberately did not add an eager fetch at startup. That would fix the
race too, but it would spawn secret-tool on every launch and can trigger a
keyring unlock prompt at login, which is a behaviour change. Queueing keeps
the existing lazy-fetch model intact.

If the keyring is locked (exitCode === 2), loaded stays false and writes
remain queued rather than being lost. LockScreen.qml already re-fetches on
unlock, which drains the queue.

Testing

I extracted the state machine and ran it against the scenarios that matter.
All pass, including a regression check confirming current main does destroy
the other keys:

PASS  1. nothing saved while unloaded
PASS  1. all three keys survive
PASS  2. all queued writes land
PASS  2. exactly one save
PASS  3. post-load write preserves existing
PASS  4. fresh install stores the queued key
PASS  4. no empty blob written first
PASS  5. locked: stored untouched
PASS  5. locked: write still queued
PASS  5. after unlock both keys present
PASS  6. upstream today WIPES the other keys

Also running on my own install (Hyprland 0.56.2, CachyOS) with several keys in
the keyring; setting a new key no longer disturbs the others.

For context, I hit this as actual data loss on my own machine before tracking
down the cause.

… initial fetch

saveKeyringData() serialises the whole keyringData object and `secret-tool
store` replaces the stored value outright, so there is no merge on the storage
side. keyringData defaults to {} and is only populated by the async
try_lookup.sh fetch, which nothing triggers at startup.

Any setNestedField() call that lands before that fetch resolves therefore
persists a blob containing only the field just written, destroying every other
secret in the keyring. Both call sites are reachable that way: Ai.setApiKey()
(the /key command) and GoogleCloud.setKeyJson().

Queue writes that arrive before the data is loaded and replay them once the
real contents are in memory, rather than saving over them. Also emit
dataChanged() when the initial fetch populates the data, and make
fetchKeyringData() re-entrancy safe.
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