Skip to content
Merged
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
10 changes: 9 additions & 1 deletion store/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@

package store

import "github.com/docker/secrets-engine/x/secrets"
import (
"errors"

"github.com/docker/secrets-engine/x/secrets"
)

var ErrCredentialNotFound = secrets.ErrNotFound

// ErrEmptySecret is returned by Save and Upsert when the secret's value is
// empty.
var ErrEmptySecret = errors.New("secret value must not be empty")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

ErrEmptySecret is defined in store with its own errors.New, unlike ErrCredentialNotFound above, which aliases secrets.ErrNotFound from x/secrets.

Not-found is shared because it has to be recognised outside the store: a plugin passes the store's not-found on, the resolver API maps secrets.ErrNotFound to CodeNotFound, and the client maps it back. plugin.ErrNotFound and client.ErrSecretNotFound are the same value for the same reason.

An empty secret is a store-level validation error: it is returned by Save/Upsert before anything is written, and nothing beyond the store needs to recognise it. If plugins or the engine API should ever report it specially, it can move to x/secrets and be aliased here the same way.

Written by Claude (Anthropic) through the alki coding agent, at the direction of @Benehiko.

6 changes: 0 additions & 6 deletions store/keychain/keychain.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,6 @@ var ErrCollectionLocked = errors.New("keychain collection is locked")
// existing item in place.
var ErrDuplicateItem = errors.New("keychain item already exists")

// ErrEmptySecret is returned by Save and Upsert when the secret's value is
// empty. The macOS Keychain ignores an empty value on update and keeps the old
// one, so both refuse it instead of reporting a success that changed nothing.
// macOS-only.
var ErrEmptySecret = errors.New("keychain secret value must not be empty")

type (
Option interface{ apply(any) error }
optionFunc[K any] func(K) error
Expand Down
6 changes: 3 additions & 3 deletions store/keychain/keychain_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ func (k *keychainStore[T]) Save(_ context.Context, id store.ID, secret store.Sec
}
defer clear(data)
if len(data) == 0 {
return ErrEmptySecret
return store.ErrEmptySecret
}
return k.add(id, secret, data)
}
Expand Down Expand Up @@ -224,7 +224,7 @@ func (k *keychainStore[T]) genericMetadata(id store.ID, secret store.Secret) map
// applications the user allowed to read the item stay allowed. Deleting and
// re-adding it would reset those lists to the calling binary alone.
//
// An empty secret value returns [ErrEmptySecret] without touching the
// An empty secret value returns [store.ErrEmptySecret] without touching the
// keychain. Another store instance or process can still write the same item
// concurrently, in which case Upsert returns [ErrDuplicateItem].
func (k *keychainStore[T]) Upsert(_ context.Context, id store.ID, secret store.Secret) error {
Expand All @@ -237,7 +237,7 @@ func (k *keychainStore[T]) Upsert(_ context.Context, id store.ID, secret store.S
}
defer clear(data)
if len(data) == 0 {
return ErrEmptySecret
return store.ErrEmptySecret
}

err = k.update(id, secret, data)
Expand Down
8 changes: 4 additions & 4 deletions store/keychain/keychain_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,8 @@ func TestUpsert(t *testing.T) {
})

require.NoError(t, bs.Save(t.Context(), id, &bufferSecret{value: []byte("review-old-value")}))
require.ErrorIs(t, bs.Upsert(t.Context(), id, &bufferSecret{}), ErrEmptySecret)
require.ErrorIs(t, bs.Upsert(t.Context(), id, &bufferSecret{value: []byte{}}), ErrEmptySecret)
require.ErrorIs(t, bs.Upsert(t.Context(), id, &bufferSecret{}), store.ErrEmptySecret)
require.ErrorIs(t, bs.Upsert(t.Context(), id, &bufferSecret{value: []byte{}}), store.ErrEmptySecret)

got, err := bs.Get(t.Context(), id)
require.NoError(t, err)
Expand All @@ -308,8 +308,8 @@ func TestUpsert(t *testing.T) {
assert.NoError(t, bs.Delete(t.Context(), id))
})

require.ErrorIs(t, bs.Save(t.Context(), id, &bufferSecret{}), ErrEmptySecret)
require.ErrorIs(t, bs.Save(t.Context(), id, &bufferSecret{value: []byte{}}), ErrEmptySecret)
require.ErrorIs(t, bs.Save(t.Context(), id, &bufferSecret{}), store.ErrEmptySecret)
require.ErrorIs(t, bs.Save(t.Context(), id, &bufferSecret{value: []byte{}}), store.ErrEmptySecret)

_, err := bs.Get(t.Context(), id)
require.ErrorIs(t, err, store.ErrCredentialNotFound)
Expand Down
15 changes: 9 additions & 6 deletions store/keychain/keychain_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,15 @@ func (k *keychainStore[T]) GetAllMetadata(ctx context.Context) (map[store.ID]sto
}

func (k *keychainStore[T]) Save(ctx context.Context, id store.ID, secret store.Secret) error {
value, err := secret.Marshal()
if err != nil {
return err
}
defer clear(value)
if len(value) == 0 {
return store.ErrEmptySecret
}

service, err := operationService(ctx)
if err != nil {
return err
Expand All @@ -562,12 +571,6 @@ func (k *keychainStore[T]) Save(ctx context.Context, id store.ID, secret store.S
return err
}

value, err := secret.Marshal()
if err != nil {
return err
}
defer clear(value)

sessSecret, err := session.NewSecret(value)
if err != nil {
return err
Expand Down
16 changes: 16 additions & 0 deletions store/keychain/keychain_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,22 @@ func TestKeychainSaveCreatesWhenAbsent(t *testing.T) {
assert.Empty(t, fake.deletedItems, "nothing to collapse")
}

func TestKeychainSaveRejectsEmptySecret(t *testing.T) {
fake := &fakeService{}
withFakeService(t, fake)

ks := setupKeychain(t, nil)
fake.opened.Store(0)
id := store.MustParseID("com.test.test/test/empty")

require.ErrorIs(t, ks.Save(t.Context(), id, &emptySecret{}), store.ErrEmptySecret)
require.ErrorIs(t, ks.Upsert(t.Context(), id, &emptySecret{value: []byte{}}), store.ErrEmptySecret)

assert.Zero(t, fake.opened.Load(), "must not connect to the secret service")
assert.Zero(t, fake.createCalls)
assert.Zero(t, fake.setSecretCalls)
}

// TestKeychainSaveCollapsesDuplicatesInPlace is the issue #446 regression test:
// when several items already share one stable identity (the accumulated
// duplicates), Save must update the first match in place — never minting a new
Expand Down
31 changes: 31 additions & 0 deletions store/keychain/keychain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ func (m *mustUnmarshalError) Unmarshal([]byte) error {
return errors.New("i am failing on purpose")
}

type emptySecret struct {
attributes
value []byte
}

var _ store.Secret = &emptySecret{}

func (e *emptySecret) Marshal() ([]byte, error) {
return e.value, nil
}

func (e *emptySecret) Unmarshal([]byte) error {
return nil
}

func setupKeychain(t *testing.T, secretFactory func(context.Context, store.ID) store.Secret) store.Store {
t.Helper()
if secretFactory == nil {
Expand Down Expand Up @@ -352,6 +367,22 @@ func TestKeychain(t *testing.T) {
require.ErrorContains(t, kc.Save(t.Context(), id, &mustMarshalError{}), "i am failing on purpose")
})

t.Run("save and upsert reject an empty secret value", func(t *testing.T) {
ks := setupKeychain(t, nil)
id := store.MustParseID("com.test.test/test/empty-secret")
t.Cleanup(func() {
require.NoError(t, ks.Delete(context.Background(), id))
})

for _, secret := range []store.Secret{&emptySecret{}, &emptySecret{value: []byte{}}} {
require.ErrorIs(t, ks.Save(t.Context(), id, secret), store.ErrEmptySecret)
require.ErrorIs(t, ks.Upsert(t.Context(), id, secret), store.ErrEmptySecret)
}

_, err := ks.Get(t.Context(), id)
require.ErrorIs(t, err, store.ErrCredentialNotFound)
})

t.Run("unmarshal error on get", func(t *testing.T) {
kc := setupKeychain(t, func(_ context.Context, _ store.ID) store.Secret {
return &mustUnmarshalError{}
Expand Down
3 changes: 3 additions & 0 deletions store/keychain/keychain_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func encodeSecret(secret store.Secret) ([]byte, error) {
return nil, err
}
defer clear(data)
if len(data) == 0 {
return nil, store.ErrEmptySecret
}

encoder := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder()
blob, _, err := transform.Bytes(encoder, data)
Expand Down
14 changes: 9 additions & 5 deletions store/posixage/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,15 @@ func (f *fileStore[T]) GetAllMetadata(ctx context.Context) (map[store.ID]store.S
}

func (f *fileStore[T]) Save(ctx context.Context, id store.ID, s store.Secret) error {
secret, err := s.Marshal()
if err != nil {
return err
}
defer clear(secret)
if len(secret) == 0 {
return store.ErrEmptySecret
}

unlock, err := f.tryLock(ctx)
if err != nil {
return err
Expand All @@ -344,11 +353,6 @@ func (f *fileStore[T]) Save(ctx context.Context, id store.ID, s store.Secret) er
return err
}

secret, err := s.Marshal()
if err != nil {
return err
}
defer clear(secret)
metadata := s.Metadata()

var secrets []secretfile.EncryptedSecret
Expand Down
44 changes: 44 additions & 0 deletions store/posixage/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,47 @@ func TestScryptWorkFactorMigration(t *testing.T) {
})
}
}

type emptySecret struct {
value []byte
}

func (e *emptySecret) Marshal() ([]byte, error) { return e.value, nil }
func (e *emptySecret) Unmarshal([]byte) error { return nil }
func (e *emptySecret) Metadata() map[string]string { return nil }
func (e *emptySecret) SetMetadata(map[string]string) error { return nil }

func TestSaveRejectsEmptySecret(t *testing.T) {
root, err := os.OpenRoot(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
assert.NoError(t, root.Close())
})

prompted := 0
s, err := New(root,
func(_ context.Context, _ store.ID) *emptySecret {
return &emptySecret{}
},
WithLogger(&testLogger{t}),
WithEncryptionCallbackFunc[EncryptionPassword](func(_ context.Context) ([]byte, error) {
prompted++
return []byte("password"), nil
}),
WithDecryptionCallbackFunc[DecryptionPassword](func(_ context.Context) ([]byte, error) {
return []byte("password"), nil
}),
)
require.NoError(t, err)
id := secrets.MustParseID("test/something/" + uuid.NewString())

for _, secret := range []store.Secret{&emptySecret{}, &emptySecret{value: []byte{}}} {
require.ErrorIs(t, s.Save(t.Context(), id, secret), store.ErrEmptySecret)
require.ErrorIs(t, s.Upsert(t.Context(), id, secret), store.ErrEmptySecret)
}

assert.Zero(t, prompted, "must not prompt for encryption keys")
dirs, err := onlyDirs(root.FS())
require.NoError(t, err)
assert.Empty(t, dirs, "must not write anything")
}
12 changes: 6 additions & 6 deletions store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,16 @@ type Store interface {
GetAllMetadata(ctx context.Context) (map[ID]Secret, error)
// Save persists credentials from the store.
//
// The macOS Keychain rejects an empty secret value with
// keychain.ErrEmptySecret.
// An empty secret value is rejected with [ErrEmptySecret].
Save(ctx context.Context, id ID, secret Secret) error
// Upsert atomically replaces an existing credential or inserts a new one.
//
// An empty secret value is rejected with [ErrEmptySecret].
//
// On the macOS Keychain an existing item is updated in place, keeping the
// applications the user allowed to access it. The macOS Keychain rejects
// an empty secret value with keychain.ErrEmptySecret. Another store
// instance or process can still write the same item concurrently, in which
// case Upsert returns keychain.ErrDuplicateItem.
// applications the user allowed to access it. Another store instance or
// process can still write the same item concurrently, in which case Upsert
// returns keychain.ErrDuplicateItem.
Upsert(ctx context.Context, id ID, secret Secret) error
// Filter returns a map of secrets based on a [Pattern].
//
Expand Down
Loading