Skip to content

fix(fdosecrets): handle Secret Service requests during database load#13321

Open
ddidderr wants to merge 1 commit into
keepassxreboot:developfrom
ddidderr:develop
Open

fix(fdosecrets): handle Secret Service requests during database load#13321
ddidderr wants to merge 1 commit into
keepassxreboot:developfrom
ddidderr:develop

Conversation

@ddidderr
Copy link
Copy Markdown

@ddidderr ddidderr commented May 6, 2026

A SearchItems call arriving while a database is still being opened crashed
the application. Reading the KDBX4 payload spins a nested event loop, and
Service::onDatabaseTabOpened has inserted the new Collection into
m_collections before Collection::reloadBackend has finished populating it.
In that window the backend database is not locked but m_exposedGroup is
still null, so Collection::searchItems dereferences a null group via the
UUID/path fast paths or EntrySearcher (Group::groupsRecursive with
this == nullptr).

The half-loaded state was also observable as inconsistent state to clients:
the Locked property reported false while SearchItems returned empty, which
a client interprets as "the entry isn't there" and behaves accordingly.

Introduce collectionLocked() as the predicate the public DBus API uses
(Locked, ensureUnlocked, label, SearchItems, and the collectionLockChanged
signal payload). It is true when the database is locked or while
m_exposedGroup is not yet established, so during the populate window
clients consistently see Locked=true and SearchItems empty rather than the
inconsistent mix. backendLocked() keeps its prior meaning (database locked,
uninitialised, or absent) and is used by the bootstrap path in
reloadBackend and by signal-handler guards that run only when
m_exposedGroup is already set.

cleanupConnections() now also clears m_exposedGroup and m_entryToItem.
Without that, after a database reload the collection would still appear
searchable through stale signal wiring, and the UUID/path shortcuts in
searchItems would consult a map pointing at removed Item objects.

KeePassXC has an "unlock before search" setting that prompts the user to
unlock a database whenever SearchItems finds no unlocked collection. With
the consistency fix above, a half-loaded collection counts as locked, so
the populate window now also satisfies that "no unlocked collection"
condition. The prompt would then open for a database already on its way
to being unlocked, with no actually-locked database to offer - an empty
dialog the user cannot act on. Service::searchItems therefore also
requires that at least one DatabaseWidget actually be locked before
opening the prompt. During the populate window SearchItems returns empty
results; populate completes on its own and a follow-up call from the
client returns real data.

Test Plan:

  • Build and run testfdosecrets and testentrysearcher with
    WITH_XC_FDOSECRETS=ON and WITH_TESTS=ON.
  • Manual: with unlockBeforeSearch=on and an AutoOpen child database,
    SearchItems calls during the load window no longer crash, no empty
    unlock dialog appears, and a retry once populate completes returns
    real data.
  • Original crash locally reproduced with gdb --args keepassxc.

Co-Authored-By: Codex GPT-5.5 (xhigh) noreply@openai.com
Co-Authored-By: Claude Opus 4.7 (1M context) (xhigh) noreply@anthropic.com

AI: The debugging with gdb was done by Codex, the initial fix done by Codex, the improvement of the PR was done together with Claude.

Testing strategy

  • Configured /tmp/keepassxc-codex-build with WITH_XC_FDOSECRETS=ON.
  • Built keepassxc, testfdosecrets, and testentrysearcher.
  • Ran /tmp/keepassxc-codex-build/tests/testfdosecrets.
  • Ran /tmp/keepassxc-codex-build/tests/testentrysearcher.

I tested (and reproduced) this manually:

  1. Create two databases: test1 and test2
  2. Make test2 have a long unlock time (I used 3s)
  3. Create an AutoOpen entry in test1 for test2
  4. Create a test secret service entry in test1, configure test1 to use this secret service entry, configure KeePassXC to act as the secret service provider on your system (mine was GNOME, I'm using it instead of GNOME keyring)
  5. Close DBs
  6. Unlock test1 database
  7. While test2 is being unlocked -> secret-tool lookup label "label-of-your-test-entry"
  8. a) (without fix) Segfault!
    b) (with fix) secret-tool immediately fails to look up the entry as long as test2 is being unlocked. Once everything is unlocked, the secret-tool lookup works as expected.

Type of change

  • ✅ Bug fix (non-breaking change that fixes an issue)

GDB Logs

This was a 2nd debugging run for show by Codex so you can see what was happening under the hood:
fdosecrets-search-crash-gdb.log

Copilot AI review requested due to automatic review settings May 6, 2026 19:18
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a crash in the FDO Secrets integration where SearchItems could be invoked during a short-lived collection initialization window (backend unlocked, but exposed group not yet populated), leading to null dereference during search.

Changes:

  • Treat collections without an exposed group as not searchable (return empty results), matching the “locked collection” behavior.
  • Strengthen teardown/reload cleanup by clearing m_exposedGroup and m_entryToItem in cleanupConnections() to avoid stale state during transitions.

Comment thread src/fdosecrets/objects/Collection.cpp Outdated
Comment on lines 222 to 226
if (backendLocked() || !m_exposedGroup) {
// searchItems should work, whether `this` is locked or not.
// however, we can't search items the same way as in gnome-keying,
// because there's no database at all when locked.
// However, we can't search items the same way as in gnome-keyring,
// because there may be no database or exposed group available yet.
return {};
@michaelk83
Copy link
Copy Markdown

michaelk83 commented May 6, 2026

If this is a short-lived state, it may be better to wait for it to sync. "Exposed groups" are KPXC internal implementation detail that the public API doesn't care about. The API needs to always show a collection list that's consistent with the locked/unlocked state. Otherwise it can break client expectations.

@ddidderr
Copy link
Copy Markdown
Author

ddidderr commented May 6, 2026

may be better to wait for it to sync

Possible, but in my opinion the current fix can still land as is because from a pure secret service client's perspective it is consistent behaviour. This is already a crazy edge case that I only noticed because I was fast enough to open a terminal and type an alias that uses secret-tool under the hood, while my database was still unlocking a 2nd AutoOpen db. The case you make sounds like a client that somehow gets an event (or polls) when KeePassXC is unlocked and immediately runs a secret-tool command... Theoretically possible, but unlikely. And again, could be improved later, this still fixes a real segfault.

"Exposed groups" are KPXC internal implementation detail that the public API doesn't care about

Right and its never violated, or is it?

A client sees:

  • Backend locked -> {} result or waiting until user unlocks db which results in either also an empty result or matches
  • Unlocked, not yet populated -> {} result (with this fix)
  • Unlocked, populated -> returns matches

This is perfectly fine from a client's perspective.

API needs to always show a collection list that's consistent with the locked/unlocked state

See my first paragraph above.

@droidmonkey
Copy link
Copy Markdown
Member

Might be better to place the null check in the backendLocked() function to be more consistent.

@ddidderr
Copy link
Copy Markdown
Author

ddidderr commented May 6, 2026

I see now, a secret service client could see the collection as unlocked but still get an empty result for the search which would be inconsistent.

I'll try to improve this tomorrow.

@michaelk83
Copy link
Copy Markdown

The case you make sounds like a client that somehow gets an event (or polls) when KeePassXC is unlocked and immediately runs a secret-tool command...

Most client apps talk to Secret Service via DBus, so as soon as they get a DBus reply, they can immediately send the next request. This is typically inside a single c++ function.

A client sees:
* Backend locked -> {} result or waiting until user unlocks db which results in either also an empty result or matches
* Unlocked, not yet populated -> {} result (with this fix)
* Unlocked, populated -> returns matches

The 2nd step is the problematic one. It should be either locked, {} or unlocked, {matches} (unless nothing matches).

a secret service client could see the collection as unlocked but still get an empty result

Exactly. This can cause a client to misbehave, because it would think the thing it's looking for just isn't there.

A SearchItems call arriving while a database is still being opened crashed
the application. Reading the KDBX4 payload spins a nested event loop, and
Service::onDatabaseTabOpened has inserted the new Collection into
m_collections before Collection::reloadBackend has finished populating it.
In that window the backend database is not locked but m_exposedGroup is
still null, so Collection::searchItems dereferences a null group via the
UUID/path fast paths or EntrySearcher (Group::groupsRecursive with
this == nullptr).

The half-loaded state was also observable as inconsistent state to clients:
the Locked property reported false while SearchItems returned empty, which
a client interprets as "the entry isn't there" and behaves accordingly.

Introduce collectionLocked() as the predicate the public DBus API uses
(Locked, ensureUnlocked, label, SearchItems, and the collectionLockChanged
signal payload). It is true when the database is locked or while
m_exposedGroup is not yet established, so during the populate window
clients consistently see Locked=true and SearchItems empty rather than the
inconsistent mix. backendLocked() keeps its prior meaning (database locked,
uninitialised, or absent) and is used by the bootstrap path in
reloadBackend and by signal-handler guards that run only when
m_exposedGroup is already set.

cleanupConnections() now also clears m_exposedGroup and m_entryToItem.
Without that, after a database reload the collection would still appear
searchable through stale signal wiring, and the UUID/path shortcuts in
searchItems would consult a map pointing at removed Item objects.

KeePassXC has an "unlock before search" setting that prompts the user to
unlock a database whenever SearchItems finds no unlocked collection. With
the consistency fix above, a half-loaded collection counts as locked, so
the populate window now also satisfies that "no unlocked collection"
condition. The prompt would then open for a database already on its way
to being unlocked, with no actually-locked database to offer - an empty
dialog the user cannot act on. Service::searchItems therefore also
requires that at least one DatabaseWidget actually be locked before
opening the prompt. During the populate window SearchItems returns empty
results; populate completes on its own and a follow-up call from the
client returns real data.

Test Plan:
- Build and run testfdosecrets and testentrysearcher with
  WITH_XC_FDOSECRETS=ON and WITH_TESTS=ON.
- Manual: with unlockBeforeSearch=on and an AutoOpen child database,
  SearchItems calls during the load window no longer crash, no empty
  unlock dialog appears, and a retry once populate completes returns
  real data.
- Original crash locally reproduced with gdb --args keepassxc.

Co-Authored-By: Codex GPT-5.5 (xhigh) <noreply@openai.com>
Co-Authored-By: Claude Opus 4.7 (1M context) (xhigh) <noreply@anthropic.com>
@ddidderr ddidderr changed the title fix(fdosecrets): handle searches before exposed group loads fix(fdosecrets): handle Secret Service requests during database load May 8, 2026
@ddidderr
Copy link
Copy Markdown
Author

ddidderr commented May 8, 2026

PR is updated: see the new commit message.

The split: backendLocked keeps its previous meaning, and there's a new collectionLocked (backendLocked || !m_exposedGroup) that's only used by the Secret Service DBus code. Internal callers still use backendLocked.

Tested with two databases, DB1 normal and DB2 auto-opened by DB1:

  • DB1 unlocked, DB2 still in the middle of populating: SearchItems returns empty and DB2's collection reports locked (New behaviour). Before, it could report unlocked-but-empty, which clients can mis-interpret.
  • DB1 and DB2 both unlocked: no change.
  • DB1 and DB2 both locked: no change - with unlockBeforeSearch on, the password prompt is shown as usual.
  • DB1 itself is in the middle of unlocking: the password prompt still shows. This is the slightly weird case: the DB is already on its way to being unlocked. But either showing the prompt or returning empty/locked is internally consistent, so I left it alone / for you to discuss/decide.

Net effect:

busctl --user get-property org.freedesktop.secrets /org/freedesktop/secrets/collection/my_collection org.freedesktop.Secret.Collection Locked

now stays true until my_collection and its auto-opened children have all finished populating. Before this PR it flipped to false as soon as my_collection's own backend was unlocked, even while the children were still loading, which is the inconsistency the PR addresses.

What's your opinion?

@michaelk83
Copy link
Copy Markdown

Update LGTM, though not sure about the unlock prompt in the middle of unlocking. If the exposed group is in the DB that's already unlocking, then I suppose it would be redundant. Also depends if it shows the partly-unlocked DB as one of the options, and whether trying to unlock (again) in that state could cause some problems. But I think that can be left to a follow-up PR.

  • DB1 itself is in the middle of unlocking: the password prompt still shows. This is the slightly weird case: the DB is already on its way to being unlocked. But either showing the prompt or returning empty/locked is internally consistent, so I left it alone / for you to discuss/decide.

That's because the child database is fully locked, so anyBackendLocked() returns true. Just to clarify, which DB has the exposed group in your setup?

@ddidderr
Copy link
Copy Markdown
Author

ddidderr commented May 8, 2026

which DB has the exposed group in your setup?

DB1

@michaelk83
Copy link
Copy Markdown

Ok, so actually DB2 is irrelevant to Secret Service in that case (unless it also has an exposed group), and I guess it just extends that "half locked" state. But even without DB2, the fix here makes things more robust from the public API point of view.

Yea, so if the exposed group is in DB1, then from the user perspective, that unlock dialog isn't needed if that DB is already unlocking. And it might cause other problems too, in that state. But I'd leave that to a separate PR, as it might need more extensive refactoring to fix.

@varjolintu varjolintu added the pr: ai-assisted Pull request contains significant contributions by generative AI label May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr: ai-assisted Pull request contains significant contributions by generative AI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants