1717package keychain
1818
1919import (
20+ "context"
21+ "fmt"
2022 "sync/atomic"
2123 "testing"
24+ "time"
2225
2326 dbus "github.com/godbus/dbus/v5"
2427 "github.com/stretchr/testify/assert"
2528 "github.com/stretchr/testify/require"
2629
2730 "github.com/docker/secrets-engine/store"
2831 kc "github.com/docker/secrets-engine/store/keychain/internal/go-keychain/secretservice"
32+ "github.com/docker/secrets-engine/store/mocks"
2933)
3034
3135// fakeService is a pure in-memory [secretService]. It never talks to a real
@@ -40,6 +44,13 @@ type fakeService struct {
4044 // "credential not found" path.
4145 items []dbus.ObjectPath
4246
47+ // recorded write operations, for assertions in the Save tests. Not
48+ // concurrency-safe: the tests that read them drive a single sequential
49+ // operation through the fake.
50+ createCalls int
51+ setSecretItems []dbus.ObjectPath
52+ deletedItems []dbus.ObjectPath
53+
4354 opened atomic.Int64
4455 closed atomic.Int64
4556}
@@ -50,7 +61,9 @@ func (f *fakeService) Collections() ([]dbus.ObjectPath, error) {
5061func (f * fakeService ) ReadAlias (string ) (dbus.ObjectPath , error ) { return loginKeychainObjectPath , nil }
5162func (f * fakeService ) IsLocked (dbus.ObjectPath ) (bool , error ) { return false , nil }
5263func (f * fakeService ) OpenSession (kc.AuthenticationMode ) (* kc.Session , error ) {
53- return & kc.Session {}, nil
64+ // plain mode so Session.NewSecret works without a negotiated AES key, which
65+ // lets the Save path run end-to-end against the fake.
66+ return & kc.Session {Mode : kc .AuthenticationInsecurePlain }, nil
5467}
5568func (f * fakeService ) CloseSession (* kc.Session ) {}
5669func (f * fakeService ) Unlock ([]dbus.ObjectPath ) error { return nil }
@@ -59,11 +72,22 @@ func (f *fakeService) SearchCollection(dbus.ObjectPath, kc.Attributes) ([]dbus.O
5972}
6073
6174func (f * fakeService ) CreateItem (dbus.ObjectPath , map [string ]dbus.Variant , kc.Secret , kc.ReplaceBehavior ) (dbus.ObjectPath , error ) {
62- return "" , nil
75+ f .createCalls ++
76+ return "/created" , nil
77+ }
78+
79+ func (f * fakeService ) DeleteItem (item dbus.ObjectPath ) error {
80+ f .deletedItems = append (f .deletedItems , item )
81+ return nil
6382}
64- func (f * fakeService ) DeleteItem (dbus.ObjectPath ) error { return nil }
6583func (f * fakeService ) GetAttributes (dbus.ObjectPath ) (kc.Attributes , error ) { return nil , nil }
6684func (f * fakeService ) GetSecret (dbus.ObjectPath , kc.Session ) ([]byte , error ) { return nil , nil }
85+ func (f * fakeService ) SetItemSecret (item dbus.ObjectPath , _ kc.Secret ) error {
86+ f .setSecretItems = append (f .setSecretItems , item )
87+ return nil
88+ }
89+ func (f * fakeService ) SetItemAttributes (dbus.ObjectPath , kc.Attributes ) error { return nil }
90+ func (f * fakeService ) SetItemLabel (dbus.ObjectPath , string ) error { return nil }
6791func (f * fakeService ) Close () error {
6892 f .closed .Add (1 )
6993 return nil
@@ -116,6 +140,254 @@ func TestKeychainClosesEveryConnection(t *testing.T) {
116140 assert .Equal (t , opened , closed , "every opened connection must be closed" )
117141}
118142
143+ // TestKeychainSaveCreatesWhenAbsent asserts Save mints a new item only when the
144+ // identity has no existing item, and performs no in-place update or cleanup.
145+ func TestKeychainSaveCreatesWhenAbsent (t * testing.T ) {
146+ fake := & fakeService {} // no items -> create path
147+ withFakeService (t , fake )
148+
149+ ks := setupKeychain (t , nil )
150+ id := store .MustParseID ("com.test.test/test/new-user" )
151+ creds := & mocks.MockCredential {Username : "alice" , Password : "alice-password" }
152+
153+ require .NoError (t , ks .Save (t .Context (), id , creds ))
154+
155+ assert .Equal (t , 1 , fake .createCalls , "must CreateItem when no existing item" )
156+ assert .Empty (t , fake .setSecretItems , "no in-place update when creating" )
157+ assert .Empty (t , fake .deletedItems , "nothing to collapse" )
158+ }
159+
160+ // TestKeychainSaveCollapsesDuplicatesInPlace is the issue #446 regression test:
161+ // when several items already share one stable identity (the accumulated
162+ // duplicates), Save must update the first match in place — never minting a new
163+ // item — and drain the remaining duplicates, leaving exactly one.
164+ func TestKeychainSaveCollapsesDuplicatesInPlace (t * testing.T ) {
165+ fake := & fakeService {
166+ items : []dbus.ObjectPath {"/item/a" , "/item/b" , "/item/c" },
167+ }
168+ withFakeService (t , fake )
169+
170+ ks := setupKeychain (t , nil )
171+ id := store .MustParseID ("com.test.test/test/bob" )
172+ creds := & mocks.MockCredential {Username : "bob" , Password : "bob-password" }
173+
174+ require .NoError (t , ks .Save (t .Context (), id , creds ))
175+
176+ assert .Zero (t , fake .createCalls , "must not CreateItem when an item already exists" )
177+ assert .Equal (t , []dbus.ObjectPath {"/item/a" }, fake .setSecretItems ,
178+ "secret must be rewritten on the first match in place" )
179+ assert .ElementsMatch (t , []dbus.ObjectPath {"/item/b" , "/item/c" }, fake .deletedItems ,
180+ "the remaining duplicates must be collapsed, leaving only the first match" )
181+ }
182+
183+ // The real-keychain dedup tests use their own service group/name so their items
184+ // are namespace-isolated from TestKeychain (which shares com.test.test/test).
185+ // GetAllMetadata/Filter search by {service:group, service:name}, so a leaked
186+ // dedup item can never show up in — and break — the shared suite.
187+ const (
188+ dedupServiceGroup = "com.test.dedup"
189+ dedupServiceName = "dedup"
190+ )
191+
192+ // ensureUnlocked unlocks the collection and waits until the daemon actually
193+ // reports it unlocked. The freedesktop Unlock call can return before the
194+ // collection is fully unlocked, so a CreateItem/DeleteItem issued immediately
195+ // after can still fail with "locked collection" — polling IsLocked closes that
196+ // race. (The store's own Save/Get/Delete avoid it only because the collection
197+ // stays unlocked once any earlier operation has unlocked it.)
198+ func ensureUnlocked (t * testing.T , svc * kc.SecretService , collection dbus.ObjectPath ) {
199+ t .Helper ()
200+ require .NoError (t , svc .Unlock ([]dbus.ObjectPath {collection }))
201+ require .Eventually (t , func () bool {
202+ locked , err := svc .IsLocked (collection )
203+ return err == nil && ! locked
204+ }, 5 * time .Second , 100 * time .Millisecond , "collection did not unlock" )
205+ }
206+
207+ // searchRealItems queries a live Secret Service for every item sharing id's
208+ // stable identity triple {service:group, service:name, id}. The store API keys
209+ // results by ID and so would collapse duplicates into one logical entry —
210+ // counting the physical items requires going under it, straight to the daemon.
211+ //
212+ // It opens its own short-lived connection (mirroring how the store dials a fresh
213+ // connection per operation) so it observes exactly what an independent client
214+ // would see. Object paths it returns stay valid after the connection closes.
215+ //
216+ // It returns its error rather than failing the test so it is safe to call from a
217+ // [require.Eventually] condition, which runs on a separate goroutine where the
218+ // require.* helpers must not be used.
219+ func searchRealItems (serviceGroup , serviceName string , id store.ID ) ([]dbus.ObjectPath , error ) {
220+ svc , err := kc .NewService ()
221+ if err != nil {
222+ return nil , err
223+ }
224+ defer func () { _ = svc .Close () }()
225+
226+ collection , err := getDefaultCollection (svc )
227+ if err != nil {
228+ return nil , err
229+ }
230+
231+ attrs := map [string ]string {}
232+ safelySetMetadata (serviceGroup , serviceName , attrs )
233+ safelySetID (id , attrs )
234+
235+ return svc .SearchCollection (collection , attrs )
236+ }
237+
238+ // findRealItems is the test-goroutine wrapper around [searchRealItems] that fails
239+ // the test on error.
240+ func findRealItems (t * testing.T , serviceGroup , serviceName string , id store.ID ) []dbus.ObjectPath {
241+ t .Helper ()
242+ items , err := searchRealItems (serviceGroup , serviceName , id )
243+ require .NoError (t , err )
244+ return items
245+ }
246+
247+ // requireItemCount polls the live Secret Service until exactly want items remain
248+ // for id, failing after a short timeout. Polling absorbs any lag between the
249+ // store deleting duplicates and an independent connection observing it; on
250+ // timeout EventuallyWithT reports the last observed count (and any search
251+ // error), so a genuine failure to converge is still caught and diagnosable.
252+ func requireItemCount (t * testing.T , serviceGroup , serviceName string , id store.ID , want int , msg string ) {
253+ t .Helper ()
254+ require .EventuallyWithT (t , func (c * assert.CollectT ) {
255+ items , err := searchRealItems (serviceGroup , serviceName , id )
256+ assert .NoError (c , err )
257+ assert .Len (c , items , want , msg )
258+ }, 10 * time .Second , 200 * time .Millisecond )
259+ }
260+
261+ // seedRealDuplicates creates n separate Secret Service items that all share id's
262+ // stable identity triple but carry a distinct volatile attribute each — exactly
263+ // how issue #446 accumulates duplicates: the daemon's replace match fails on the
264+ // differing volatile attributes, so every save mints a fresh item.
265+ func seedRealDuplicates (t * testing.T , serviceGroup , serviceName string , id store.ID , n int ) {
266+ t .Helper ()
267+ svc , err := kc .NewService ()
268+ require .NoError (t , err )
269+ defer func () { _ = svc .Close () }()
270+
271+ session , err := svc .OpenSession (kc .AuthenticationDHAES )
272+ require .NoError (t , err )
273+ defer svc .CloseSession (session )
274+
275+ collection , err := getDefaultCollection (svc )
276+ require .NoError (t , err )
277+
278+ // Talking to the daemon directly skips the unlock the store does internally,
279+ // and gnome-keyring reports even the passwordless 'login' collection as
280+ // locked until then.
281+ ensureUnlocked (t , svc , collection )
282+
283+ label := serviceGroup + ":" + serviceName + ":" + id .String ()
284+ for i := range n {
285+ sessSecret , err := session .NewSecret (fmt .Appendf (nil , "seed-user:seed-pass-%d" , i ))
286+ require .NoError (t , err )
287+
288+ attrs := map [string ]string {
289+ // volatile: distinct per item, so each CreateItem adds a new one
290+ // rather than replacing — the duplicate-accumulation pattern.
291+ "nonce" : fmt .Sprintf ("seed-%d" , i ),
292+ }
293+ safelySetMetadata (serviceGroup , serviceName , attrs )
294+ safelySetID (id , attrs )
295+
296+ _ , err = svc .CreateItem (collection , kc .NewSecretProperties (label , attrs ), sessSecret , kc .ReplaceBehaviorDoNotReplace )
297+ require .NoError (t , err )
298+ }
299+ }
300+
301+ // purgeRealItems removes every item for id, draining any leftover duplicates so
302+ // the test cannot leak state. It unlocks the collection first (DeleteItem fails
303+ // on a locked collection) and deletes all matches, asserting success so a silent
304+ // cleanup failure surfaces as a leak rather than corrupting a later test.
305+ func purgeRealItems (t * testing.T , serviceGroup , serviceName string , id store.ID ) {
306+ t .Helper ()
307+ svc , err := kc .NewService ()
308+ require .NoError (t , err )
309+ defer func () { _ = svc .Close () }()
310+
311+ collection , err := getDefaultCollection (svc )
312+ require .NoError (t , err )
313+ ensureUnlocked (t , svc , collection )
314+
315+ attrs := map [string ]string {}
316+ safelySetMetadata (serviceGroup , serviceName , attrs )
317+ safelySetID (id , attrs )
318+ items , err := svc .SearchCollection (collection , attrs )
319+ require .NoError (t , err )
320+ for _ , item := range items {
321+ require .NoError (t , svc .DeleteItem (item ))
322+ }
323+ }
324+
325+ // TestKeychainCollapsesExistingDuplicates is the issue #446 backlog test against
326+ // a real Secret Service: given several duplicate items already stored under one
327+ // identity, a single Save must update one item in place and delete the rest,
328+ // leaving exactly one item holding the latest secret.
329+ func TestKeychainCollapsesExistingDuplicates (t * testing.T ) {
330+ id := store .MustParseID (dedupServiceGroup + "/" + dedupServiceName + "/backlog" )
331+ t .Cleanup (func () { purgeRealItems (t , dedupServiceGroup , dedupServiceName , id ) })
332+
333+ // Pre-existing backlog: three duplicate items for one identity.
334+ seedRealDuplicates (t , dedupServiceGroup , dedupServiceName , id , 3 )
335+ require .Len (t , findRealItems (t , dedupServiceGroup , dedupServiceName , id ), 3 , "precondition: three duplicates seeded" )
336+
337+ ks , err := New (dedupServiceGroup , dedupServiceName , func (_ context.Context , _ store.ID ) store.Secret {
338+ return & mocks.MockCredential {}
339+ })
340+ require .NoError (t , err )
341+
342+ require .NoError (t , ks .Save (t .Context (), id , & mocks.MockCredential {
343+ Username : "backlog-user" ,
344+ Password : "final-password" ,
345+ }))
346+
347+ requireItemCount (t , dedupServiceGroup , dedupServiceName , id , 1 ,
348+ "a single Save must collapse the duplicate backlog to one item" )
349+
350+ got , err := ks .Get (t .Context (), id )
351+ require .NoError (t , err )
352+ assert .Equal (t , "final-password" , got .(* mocks.MockCredential ).Password ,
353+ "the surviving item must hold the latest secret" )
354+ }
355+
356+ // TestKeychainSaveDoesNotAccumulate is the forward-looking issue #446 test
357+ // against a real Secret Service: saving the same identity repeatedly with
358+ // metadata that changes on every save (mimicking volatile JWT claims) must keep
359+ // exactly one item, never minting duplicates.
360+ func TestKeychainSaveDoesNotAccumulate (t * testing.T ) {
361+ id := store .MustParseID (dedupServiceGroup + "/" + dedupServiceName + "/no-accumulate" )
362+ t .Cleanup (func () { purgeRealItems (t , dedupServiceGroup , dedupServiceName , id ) })
363+
364+ ks , err := New (dedupServiceGroup , dedupServiceName , func (_ context.Context , _ store.ID ) store.Secret {
365+ return & mocks.MockCredential {}
366+ })
367+ require .NoError (t , err )
368+
369+ const saves = 5
370+ for i := range saves {
371+ require .NoError (t , ks .Save (t .Context (), id , & mocks.MockCredential {
372+ Username : "no-accumulate-user" ,
373+ Password : fmt .Sprintf ("password-%d" , i ),
374+ Attributes : map [string ]string {
375+ "nonce" : fmt .Sprintf ("%d" , i ), // volatile: differs every save
376+ },
377+ }))
378+ }
379+
380+ requireItemCount (t , dedupServiceGroup , dedupServiceName , id , 1 ,
381+ "saving with changing metadata must not accumulate duplicate items" )
382+
383+ got , err := ks .Get (t .Context (), id )
384+ require .NoError (t , err )
385+ actual := got .(* mocks.MockCredential )
386+ assert .Equal (t , fmt .Sprintf ("password-%d" , saves - 1 ), actual .Password )
387+ assert .Equal (t , fmt .Sprintf ("%d" , saves - 1 ), actual .Attributes ["nonce" ],
388+ "the surviving item's metadata must be refreshed in place" )
389+ }
390+
119391func TestResolveDefaultCollection (t * testing.T ) {
120392 const customCollection = dbus .ObjectPath ("/org/freedesktop/secrets/collection/custom" )
121393
0 commit comments