Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 45 additions & 10 deletions store/keychain/keychain_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,34 +192,69 @@ func (k *keychainStore[T]) Save(_ context.Context, id store.ID, secret store.Sec
// it is a user-friendly name for the item, which is displayed in the keychain UI.
// https://developer.apple.com/documentation/security/ksecattrlabel
item.SetLabel(k.itemLabel(id.String()))
item.SetGenericMetadata(k.genericMetadata(id, secret))

return mapKeychainError(kc.AddItem(item))
}

func (k *keychainStore[T]) genericMetadata(id store.ID, secret store.Secret) map[string]any {
metadata := make(map[string]string)
maps.Copy(metadata, secret.Metadata())
safelySetMetadata(k.serviceGroup, k.serviceName, metadata)
safelySetID(id, metadata)

metadataAny := make(map[string]any)
metadataAny := make(map[string]any, len(metadata))
for k, v := range metadata {
metadataAny[k] = v
}
item.SetGenericMetadata(metadataAny)

return mapKeychainError(kc.AddItem(item))
return metadataAny
}

// Upsert atomically replaces a credential in the macOS Keychain.
// Upsert updates an existing credential in place and adds it when it does
// not exist yet.
//
// The macOS Keychain does not allow overwriting an existing item via AddItem,
// so Upsert holds a mutex and performs a Delete followed by a Save to ensure
// no concurrent Upsert can interleave between the two operations.
// Updating in place keeps the item's access control and partition lists, so
// applications the user allowed to read the item stay allowed. Deleting and
// re-adding it would reset those lists to the calling binary alone.
func (k *keychainStore[T]) Upsert(ctx context.Context, id store.ID, secret store.Secret) error {
k.mu.Lock()
defer k.mu.Unlock()

if err := k.Delete(ctx, id); err != nil {
err := k.update(id, secret)
if !errors.Is(err, store.ErrCredentialNotFound) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the upsert fails (i.e. due to another processing deleting a credential) then we should simply return with the error. A retry can always happen by the caller. Being too smart here might actually open up more problems in future and backtracking this change would simply be devestating for other tools relying on this library.

return err
}
return k.Save(ctx, id, secret)
err = k.Save(ctx, id, secret)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P1] Preserve the marshaled payload across the update/add attempts

When the item does not exist, k.update still calls secret.Marshal() and runs defer clear(data) before returning ErrCredentialNotFound. PassValue.Marshal() in plugins/pass/store/store.go returns its backing buffer, so this subsequent Save marshals an already-zeroed value and successfully stores zeros. The duplicate-item retry has the same buffer-lifetime problem.

I reproduced this with Upsert of a new PassValue containing review-test-value: reading it back returns 17 zero bytes. The same check passes on the parent commit. MockCredential.Marshal() allocates fresh bytes on each call, which is why the current insertion test passes.

Please marshal once for the whole Upsert, reuse that payload for update/add/retry, and clear it only after the operation finishes. Add a regression case using a secret whose Marshal returns its backing buffer.

if errors.Is(err, ErrDuplicateItem) {
// Another process added the item after our update missed it.
return k.update(id, secret)
}
return err
}

func (k *keychainStore[T]) update(id store.ID, secret store.Secret) error {
data, err := secret.Marshal()
if err != nil {
return err
}
defer clear(data)

// The query names only the attributes that identify the item; the match
// and return options of newKeychainItem belong to SecItemCopyMatching.
Comment on lines +242 to +243

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// The query names only the attributes that identify the item; the match
// and return options of newKeychainItem belong to SecItemCopyMatching.
// We aren't using newKeychainItem here because we only want to identify the item, not read it.

query := kc.NewItem()
query.SetSecClass(kc.SecClassGenericPassword)
query.SetService(k.serviceName)
query.SetAccessGroup(k.serviceGroup)
query.SetAccount(id.String())
if k.useDataProtectionKeychain {
query.SetUseDataProtectionKeychain(kc.UseDataProtectionKeychainYes)
}

changes := kc.NewItem()
changes.SetData(data)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] Write an explicit empty payload when Marshal returns nil

kc.Item.SetData(nil) removes kSecValueData from the attributes dictionary. In an update, omitting that attribute leaves the existing secret unchanged, so an empty replacement silently becomes a metadata-only update. This affects the real PassValue: its Unmarshal produces a nil backing slice for empty input.

I reproduced this by saving review-old-value and then upserting passstore.NewPassValue(nil): Upsert returns nil, but Get still returns review-old-value. The parent commit correctly returns an empty value.

Please normalize a nil marshaled payload to a non-nil empty slice before calling SetData, and add a regression test that replaces a non-empty secret with an empty one.

changes.SetGenericMetadata(k.genericMetadata(id, secret))

return mapKeychainError(kc.UpdateItem(query, changes))
}

func (k *keychainStore[T]) Filter(ctx context.Context, pattern store.Pattern) (map[store.ID]store.Secret, error) {
Expand Down
38 changes: 38 additions & 0 deletions store/keychain/keychain_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"bytes"
"context"
"testing"
"time"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -209,6 +210,43 @@ func TestUpsert(t *testing.T) {
assert.Equal(t, updated.Password, actual.Password)
})

t.Run("updates an existing credential in place", func(t *testing.T) {
id := store.MustParseID(serviceGroup + "/" + serviceName + "/" + uuid.NewString())
t.Cleanup(func() {
assert.NoError(t, ks.Delete(t.Context(), id))
})

require.NoError(t, ks.Save(t.Context(), id, &mocks.MockCredential{
Username: "dana",
Password: "original-password",
Attributes: map[string]string{"expiry": "1"},
}))
before, err := getItemWithData(id.String(), &ks)
require.NoError(t, err)

// Keychain timestamps have one-second resolution.
time.Sleep(1100 * time.Millisecond)

updated := &mocks.MockCredential{
Username: "dana",
Password: "updated-password",
Attributes: map[string]string{"expiry": "2"},
}
require.NoError(t, ks.Upsert(t.Context(), id, updated))

after, err := getItemWithData(id.String(), &ks)
require.NoError(t, err)
assert.Equal(t, before.CreationDate, after.CreationDate, "the item must be updated, not deleted and re-added")
assert.True(t, after.ModificationDate.After(before.ModificationDate))
assert.Equal(t, before.Label, after.Label)

got, err := ks.Get(t.Context(), id)
require.NoError(t, err)
actual := got.(*mocks.MockCredential)
assert.Equal(t, updated.Password, actual.Password)
assert.Equal(t, map[string]string{"expiry": "2"}, actual.Metadata())
})

t.Run("save returns duplicate item error when credential already exists", func(t *testing.T) {
id := store.MustParseID(serviceGroup + "/" + serviceName + "/" + uuid.NewString())
t.Cleanup(func() {
Expand Down
5 changes: 2 additions & 3 deletions store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,8 @@ type Store interface {
// Save persists credentials from the store.
Save(ctx context.Context, id ID, secret Secret) error
// Upsert atomically replaces an existing credential or inserts a new one.
// On stores that do not support overwriting (e.g. macOS Keychain), it
// deletes the existing credential and then saves the new one under a mutex
// to ensure the two operations are not interleaved.
// On the macOS Keychain an existing item is updated in place, keeping the
// applications the user allowed to access it.
Upsert(ctx context.Context, id ID, secret Secret) error
// Filter returns a map of secrets based on a [Pattern].
//
Expand Down
Loading