Skip to content

Commit afb9c58

Browse files
authored
Merge pull request #557 from docker/fix/keychain-linux-dedup
fix(keychain): update Secret Service items in place on Linux to stop duplicate accumulation
2 parents 5d056a7 + e03dc7a commit afb9c58

3 files changed

Lines changed: 562 additions & 8 deletions

File tree

store/keychain/internal/go-keychain/secretservice/secretservice.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,40 @@ func (s *SecretService) PromptAndWait(prompt dbus.ObjectPath) (paths *dbus.Varia
436436
}
437437
}
438438

439+
// SetItemSecret replaces an existing item's secret value in place via
440+
// org.freedesktop.Secret.Item.SetSecret. The secret must already be encoded for
441+
// the session it references (see [Session.NewSecret]). SetSecret takes a single
442+
// Secret argument (D-Bus signature (oayays)) and returns no values, so there is
443+
// no prompt path: the item's collection must be unlocked first (use [Unlock]),
444+
// otherwise the call fails.
445+
func (s *SecretService) SetItemSecret(item dbus.ObjectPath, secret Secret) error {
446+
if err := s.Obj(item).Call("org.freedesktop.Secret.Item.SetSecret", NilFlags, secret).Store(); err != nil {
447+
return fmt.Errorf("failed to set item secret: %w", err)
448+
}
449+
return nil
450+
}
451+
452+
// SetItemAttributes replaces an existing item's lookup attributes in place by
453+
// setting the read-write org.freedesktop.Secret.Item.Attributes property
454+
// (type a{ss}) through org.freedesktop.DBus.Properties.Set. The collection must
455+
// be unlocked.
456+
func (s *SecretService) SetItemAttributes(item dbus.ObjectPath, attributes Attributes) error {
457+
if err := s.Obj(item).SetProperty("org.freedesktop.Secret.Item.Attributes", dbus.MakeVariant(map[string]string(attributes))); err != nil {
458+
return fmt.Errorf("failed to set item attributes: %w", err)
459+
}
460+
return nil
461+
}
462+
463+
// SetItemLabel replaces an existing item's displayable label in place by setting
464+
// the read-write org.freedesktop.Secret.Item.Label property (type s) through
465+
// org.freedesktop.DBus.Properties.Set. The collection must be unlocked.
466+
func (s *SecretService) SetItemLabel(item dbus.ObjectPath, label string) error {
467+
if err := s.Obj(item).SetProperty("org.freedesktop.Secret.Item.Label", dbus.MakeVariant(label)); err != nil {
468+
return fmt.Errorf("failed to set item label: %w", err)
469+
}
470+
return nil
471+
}
472+
439473
// NewSecretProperties
440474
func NewSecretProperties(label string, attributes map[string]string) map[string]dbus.Variant {
441475
return map[string]dbus.Variant{

store/keychain/keychain_linux.go

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"fmt"
2424
"maps"
2525
"slices"
26+
"time"
2627

2728
dbus "github.com/godbus/dbus/v5"
2829

@@ -65,6 +66,9 @@ type secretService interface {
6566
DeleteItem(item dbus.ObjectPath) error
6667
GetAttributes(item dbus.ObjectPath) (kc.Attributes, error)
6768
GetSecret(item dbus.ObjectPath, session kc.Session) ([]byte, error)
69+
SetItemSecret(item dbus.ObjectPath, secret kc.Secret) error
70+
SetItemAttributes(item dbus.ObjectPath, attributes kc.Attributes) error
71+
SetItemLabel(item dbus.ObjectPath, label string) error
6872
Close() error
6973
}
7074

@@ -145,6 +149,81 @@ func isCollectionUnlocked(collectionPath dbus.ObjectPath, service secretService)
145149
return errCollectionLocked
146150
}
147151

152+
// secretServiceIsLockedError is the D-Bus error name the secret service returns
153+
// when a mutating call (e.g. CreateItem) targets a locked collection.
154+
//
155+
// https://specifications.freedesktop.org/secret-service-spec/latest/errors.html
156+
const secretServiceIsLockedError = "org.freedesktop.Secret.Error.IsLocked"
157+
158+
// isLockedDBusError reports whether err is the secret service's "collection is
159+
// locked" D-Bus error. The lock state is matched on the structured D-Bus error
160+
// name rather than the human-readable message so it is stable across backends
161+
// and locales.
162+
func isLockedDBusError(err error) bool {
163+
var dbusErr dbus.Error
164+
return errors.As(err, &dbusErr) && dbusErr.Name == secretServiceIsLockedError
165+
}
166+
167+
// Relock retry tuning. An operation that hits a relocked collection is retried
168+
// with exponential backoff: the relock is a brief race that settles on its own,
169+
// and spacing the attempts out avoids hammering the secret service (or, on a
170+
// password-protected keyring, re-issuing Unlock fast enough to spam the user
171+
// with authentication prompts).
172+
//
173+
// relockRetryMaxDelay caps the backoff growth; with the current
174+
// maxRelockRetries the slept delays are 20,40,80,160,320ms (the cap only takes
175+
// effect once maxRelockRetries reaches 6, where the sixth delay would otherwise
176+
// be 640ms).
177+
const (
178+
maxRelockRetries = 5
179+
relockRetryBaseDelay = 20 * time.Millisecond
180+
relockRetryMaxDelay = 500 * time.Millisecond
181+
)
182+
183+
// sleepFn is the sleep seam used by the relock backoff so tests can exercise the
184+
// retry loop without real delays. It is a package-level var with no
185+
// synchronisation, so tests that swap it must not run in parallel.
186+
var sleepFn = time.Sleep
187+
188+
// withRelockRetry runs a collection operation, retrying it with exponential
189+
// backoff when the secret service rejects it because the collection is locked.
190+
//
191+
// The store dials a fresh D-Bus connection for every operation and closes it on
192+
// return. gnome-keyring scopes an unlock to the session that performed it, so
193+
// when a previous operation's connection closes the daemon relocks the
194+
// collection — and that relock can land asynchronously in the middle of a later
195+
// operation, after we have already observed the collection as unlocked but
196+
// before the call against the collection runs. The result is an intermittent
197+
// "Cannot create an item in a locked collection" error even though we unlocked
198+
// moments earlier. IsLocked cannot guard against this because the state changes
199+
// between the check and the call, so we react to the authoritative signal — the
200+
// operation's own locked error — by unlocking again and retrying.
201+
//
202+
// In the common case this is the passwordless auto-unlock path (e.g. the
203+
// PAM-unlocked login keyring), where Unlock returns the null prompt and asks
204+
// the user for nothing. withRelockRetry cannot itself prove the keyring is
205+
// passwordless, so on a password-protected keyring a retry could surface an
206+
// authentication prompt; the bounded retry count and backoff keep that to a
207+
// handful of spaced-out prompts at worst, and a dismissed prompt makes Unlock
208+
// return an error that aborts the loop immediately rather than re-prompting.
209+
func withRelockRetry(service secretService, collectionPath dbus.ObjectPath, op func() error) error {
210+
err := op()
211+
delay := relockRetryBaseDelay
212+
for attempt := 0; attempt < maxRelockRetries && isLockedDBusError(err); attempt++ {
213+
sleepFn(delay)
214+
delay = min(delay*2, relockRetryMaxDelay)
215+
if unlockErr := service.Unlock([]dbus.ObjectPath{collectionPath}); unlockErr != nil {
216+
// Surface why the retry stopped while preserving errors.Is on the
217+
// underlying Unlock error (e.g. a dismissed prompt). The original
218+
// locked error is intentionally dropped: the failed unlock is the
219+
// actionable cause once we have decided to stop retrying.
220+
return fmt.Errorf("unlock after relock: %w", unlockErr)
221+
}
222+
err = op()
223+
}
224+
return err
225+
}
226+
148227
type keychainStore[T store.Secret] struct {
149228
serviceGroup string
150229
serviceName string
@@ -195,7 +274,9 @@ func (k *keychainStore[T]) Delete(_ context.Context, id store.ID) error {
195274
return nil
196275
}
197276

198-
return service.DeleteItem(items[0])
277+
return withRelockRetry(service, objectPath, func() error {
278+
return service.DeleteItem(items[0])
279+
})
199280
}
200281

201282
func (k *keychainStore[T]) Get(ctx context.Context, id store.ID) (store.Secret, error) {
@@ -385,13 +466,52 @@ func (k *keychainStore[T]) Save(_ context.Context, id store.ID, secret store.Sec
385466
safelySetID(id, attributes)
386467

387468
label := k.itemLabel(id.String())
388-
properties := kc.NewSecretProperties(label, attributes)
389469

390-
_, err = service.CreateItem(objectPath, properties, sessSecret, kc.ReplaceBehaviorReplace)
470+
// Find existing items for this identity by the stable triple only
471+
// {service:group, service:name, id}, never the volatile metadata, so a
472+
// changed metadata value can never hide a previously-stored item. This is
473+
// what makes the in-place update below reliable and stops the duplicate
474+
// accumulation described in issue #446.
475+
ident := make(map[string]string)
476+
safelySetMetadata(k.serviceGroup, k.serviceName, ident)
477+
safelySetID(id, ident)
478+
479+
items, err := service.SearchCollection(objectPath, ident)
391480
if err != nil {
392481
return err
393482
}
394483

484+
// Nothing stored yet: create a fresh item.
485+
if len(items) == 0 {
486+
properties := kc.NewSecretProperties(label, attributes)
487+
return withRelockRetry(service, objectPath, func() error {
488+
_, createErr := service.CreateItem(objectPath, properties, sessSecret, kc.ReplaceBehaviorReplace)
489+
return createErr
490+
})
491+
}
492+
493+
// Update the first match in place. Its object path is preserved, so the
494+
// secret is never momentarily absent and no duplicate is minted. Writing the
495+
// secret value IS the operation, so only its failure fails Save; refreshing
496+
// the attributes and label and collapsing any pre-existing duplicates are
497+
// best-effort (the secret is already stored) and must not flip the result.
498+
primary := items[0]
499+
if err := withRelockRetry(service, objectPath, func() error {
500+
return service.SetItemSecret(primary, sessSecret)
501+
}); err != nil {
502+
return err
503+
}
504+
_ = service.SetItemAttributes(primary, attributes)
505+
_ = service.SetItemLabel(primary, label)
506+
for _, dup := range items[1:] {
507+
// Best-effort, but still relock-aware: a collection that relocks
508+
// mid-collapse would otherwise leave the duplicates the whole feature
509+
// exists to drain (see withRelockRetry and issue #446).
510+
_ = withRelockRetry(service, objectPath, func() error {
511+
return service.DeleteItem(dup)
512+
})
513+
}
514+
395515
return nil
396516
}
397517

0 commit comments

Comments
 (0)