Skip to content

fix(keychain): update existing items in place on Upsert - #687

Merged
Benehiko merged 3 commits into
docker:mainfrom
cdupuis:fix/keychain-upsert-in-place
Oct 13, 2026
Merged

Benehiko merged 3 commits into
docker:mainfrom
cdupuis:fix/keychain-upsert-in-place

Conversation

@cdupuis

@cdupuis cdupuis commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On macOS, keychainStore.Upsert deleted the item and added it again. SecItemAdd gives the new item an access control list that trusts only the calling binary and resets its partition list. Any other application the user had clicked "Always Allow" for loses access and gets a keychain prompt again after every write.

Docker Sandboxes hits this on every Docker Hub token refresh: sbx rewrites the sandboxes-auth item through Upsert, and the desktop app is prompted for the keychain password again.

Change

Upsert now updates the existing item in place with SecItemUpdate, which keeps its access control and partition lists. It adds the item with SecItemAdd only when the update finds no match. Upsert is still atomic within a store. Another store instance or process can still write the same item concurrently, in which case Upsert returns ErrDuplicateItem.

The update query names only the identifying attributes (class, service, access group, account, and data-protection keychain). The new data and the generic metadata are the only changes. The label and other attributes stay as they are, and Delete and the item format are unchanged.

Upsert marshals the secret once and uses the same bytes for the update and the add, clearing them only when it is done. A secret whose Marshal returns its own buffer is no longer stored as zeros.

Save and Upsert reject an empty secret value with the new ErrEmptySecret. The keychain ignores an empty value on update and keeps the old secret, so an empty Upsert would otherwise succeed without changing anything; Save rejects it too so the two behave the same. This only applies to the macOS Keychain.

Related: #693 makes PassValue.Marshal return a copy, since every store backend clears the bytes Marshal returns.

Testing

  • New TestUpsert subtest "updates an existing credential in place". After Save, it sets a comment on the keychain item directly, outside the store. After Upsert, it checks that the comment and label are unchanged and that Get returns the new secret and metadata. Deleting and re-adding the item drops the comment, so the test fails against the previous delete-and-add implementation. It does not depend on timestamps or sleeps.
  • New TestUpsert subtests using a secret whose Marshal returns its backing buffer: inserting a new secret stores its real value, upserting an empty value over an existing one returns ErrEmptySecret and keeps the old value, and saving an empty value returns ErrEmptySecret and stores nothing.
  • go test ./... in store/ passes on macOS; gofmt and go vet are clean.

AI usage

Cursor (Claude) wrote most of the original change and the first test, after diagnosing the repeated prompts in Docker Sandboxes. The review follow-up commits (single marshal, no retry, ErrEmptySecret, the sleep-free test, and the docs) and this updated description were written by Claude (Anthropic) through the alki coding agent, at the direction of @Benehiko.

Upsert deleted the item and added it again. SecItemAdd gives the new item an
access control list that trusts only the calling binary, so every other
application the user had allowed to read the item gets a keychain prompt again
after each write.

Update the item in place with SecItemUpdate, which keeps its access control and
partition lists, and add it only when it does not exist yet.

Signed-off-by: Christian Dupuis <cd@docker.com>
Comment thread store/keychain/keychain_darwin.go Outdated
Comment thread store/keychain/keychain_darwin.go
Comment thread store/keychain/keychain_darwin.go Outdated
Comment thread store/keychain/keychain_darwin.go Outdated
Marshal the secret once and reuse it for the update and the add, so a secret whose Marshal returns its backing buffer is not stored as zeros. Drop the update retry after a duplicate-item error and return the error to the caller. Reject empty secret values in Upsert with ErrEmptySecret, since the keychain ignores an empty value on update.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
Save now returns ErrEmptySecret for an empty value, matching Upsert. The in-place update test checks that a comment set outside the store survives Upsert instead of comparing timestamps, so it no longer sleeps. Document ErrEmptySecret and the cross-process ErrDuplicateItem case on Save and Upsert.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
return err
}
defer clear(data)
if len(data) == 0 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think this is consistent cross platform, no?

@Benehiko
Benehiko merged commit 1876ae0 into docker:main Oct 13, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants