Skip to content

fix(keychain): update Secret Service items in place on Linux to stop duplicate accumulation#557

Open
Benehiko wants to merge 2 commits into
mainfrom
fix/keychain-linux-dedup
Open

fix(keychain): update Secret Service items in place on Linux to stop duplicate accumulation#557
Benehiko wants to merge 2 commits into
mainfrom
fix/keychain-linux-dedup

Conversation

@Benehiko

@Benehiko Benehiko commented Jun 19, 2026

Copy link
Copy Markdown
Member

What

On Linux, keychainStore.Save copied the secret's volatile Metadata() into the searchable Secret Service attributes and relied on CreateItem(ReplaceBehaviorReplace) to overwrite the prior item. Both gnome-keyring and kwalletd select the replace target by matching the full supplied attribute set, so any change in the volatile metadata (e.g. the Docker Hub OAuth credential's rotating JWT claims) defeats the match and a brand-new item is created on every save — duplicates pile up without bound, and each stale item keeps a cleartext copy of the old claims.

Fixes the write side of docker/secrets-engine-private#446 and docker/sbx-releases#245.

How

Save now updates in place instead of relying on the daemon's replace-match:

  1. Search by the stable identity triple {service:group, service:name, id} only — never the volatile metadata — so a changed metadata value can never hide a previously-stored item.
  2. AbsentCreateItem.
  3. Present → rewrite the first match's secret (SetItemSecret), then best-effort refresh its attributes/label and delete the remaining duplicates.

The item's object path is preserved, so the secret is never momentarily absent and no duplicate is minted. To enable the in-place update, three thin org.freedesktop.Secret.Item wrappers were added to the vendored secretservice library: SetItemSecret, SetItemAttributes, SetItemLabel.

This is backend-agnostic — the attribute-match behaviour is shared by gnome-keyring and kwalletd, verified against both daemons' source. macOS and Windows key items on a stable identifier (Account / TargetName) and are unaffected.

Contract

Unchanged: Save still returns nil iff the secret is stored. Refreshing attributes/label and collapsing leftover duplicates are best-effort and never flip the result. Delete is untouched.

Tests

  • Unit (fake-backed): TestKeychainSaveCreatesWhenAbsent (create branch), TestKeychainSaveCollapsesDuplicatesInPlace (in-place update + collapse).
  • Integration (real gnome-keyring harness):
    • TestKeychainCollapsesExistingDuplicates — seeds a 3-item duplicate backlog directly via the daemon, then asserts a single Save collapses it to one item holding the latest secret.
    • TestKeychainSaveDoesNotAccumulate — five saves with changing metadata stay at exactly one item, with the surviving item's metadata refreshed in place.

All pass against real gnome-keyring; lint clean (golangci-lint v2.12.2). The kwalletd target is daemon-agnostic but remains disabled in the harness (prompts under headless).

🤖 Generated with Claude Code

@docker-agent docker-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Assessment: 🟢 APPROVE

The in-place update logic is well-structured and correct. The stable-triple search () reliably isolates existing items regardless of volatile metadata changes, and the LIFO defer ordering ensures the session remains valid throughout all D-Bus calls. The new SetItemSecret/SetItemAttributes/SetItemLabel wrappers are minimal, correct, and properly documented. The best-effort contract for attribute refresh and duplicate collapse is clearly stated and consistently enforced. Test coverage spans both the unit (fake-backed) and integration (real gnome-keyring) paths.

Comment on lines +418 to +426
primary := items[0]
if err := service.SetItemSecret(primary, sessSecret); err != nil {
return err
}
_ = service.SetItemAttributes(primary, attributes)
_ = service.SetItemLabel(primary, label)
for _, dup := range items[1:] {
_ = service.DeleteItem(dup)
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

migration step for secrets that are currently being duplicated. future writes won't duplicate

Benehiko and others added 2 commits June 19, 2026 16:00
…duplicate accumulation

On Linux, keychainStore.Save copied the secret's volatile Metadata() into the
searchable Secret Service attributes and relied on CreateItem(ReplaceBehaviorReplace)
to overwrite the prior item. Both gnome-keyring and kwalletd select the replace
target by matching the full supplied attribute set, so any change in the volatile
metadata (e.g. the Docker Hub OAuth credential's rotating JWT claims) defeats the
match and a brand-new item is created on every save -- duplicates pile up without
bound, and each stale item keeps a cleartext copy of the old claims.

Fix Save to update in place: search by the stable identity triple
{service:group, service:name, id} only, then either create when absent or rewrite
the first match's secret/attributes/label in place and collapse any pre-existing
duplicates. The item's object path is preserved, so the secret is never momentarily
absent and no duplicate is minted. The observable store contract is unchanged:
Save still returns nil iff the secret is stored (refreshing attributes/label and
collapsing leftovers are best-effort).

This is backend-agnostic: the attribute-match behaviour is shared by gnome-keyring
and kwalletd, and macOS/Windows key items on a stable identifier so are unaffected.

Add SetItemSecret/SetItemAttributes/SetItemLabel to the vendored secretservice
library (thin org.freedesktop.Secret.Item wrappers) to enable the in-place update.

Refs: docker/secrets-engine-private#446

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

Fake-backed unit tests for the create vs in-place-collapse branches, plus
real-keychain integration tests (run under the gnome-keyring harness) that seed a
duplicate backlog and assert a single Save collapses it to one item, and that
repeated saves with changing metadata never accumulate.

The integration assertions poll the daemon via require.EventuallyWithT until the
expected item count is reached, absorbing the lag between the store deleting
duplicates and an independent connection observing it without masking a genuine
failure to converge. The dedup tests use their own service group/name
(com.test.dedup/dedup) so a leaked credential can never reach TestKeychain, and
ensureUnlocked polls IsLocked after Unlock to close the first-unlock race on the
direct-to-daemon seed and purge paths.

Refs: docker/secrets-engine-private#446

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Benehiko Benehiko force-pushed the fix/keychain-linux-dedup branch from ef6cbee to cff4885 Compare June 19, 2026 14:01
@Benehiko Benehiko requested a review from joe0BAB June 19, 2026 14: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.

1 participant