@@ -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+
148227type 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
201282func (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