Repository navigation
fix(keychain): update existing items in place on Upsert #687
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) { | ||||||||
| return err | ||||||||
| } | ||||||||
| return k.Save(ctx, id, secret) | ||||||||
| err = k.Save(ctx, id, secret) | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, I reproduced this with Please marshal once for the whole |
||||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||
| 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) | ||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Write an explicit empty payload when Marshal returns nil
I reproduced this by saving Please normalize a nil marshaled payload to a non-nil empty slice before calling |
||||||||
| 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) { | ||||||||
|
|
||||||||
There was a problem hiding this comment.
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.