diff --git a/store/keychain/internal/go-keychain/accesscontrol_darwin.go b/store/keychain/internal/go-keychain/accesscontrol_darwin.go new file mode 100644 index 00000000..fc9c8323 --- /dev/null +++ b/store/keychain/internal/go-keychain/accesscontrol_darwin.go @@ -0,0 +1,138 @@ +//go:build darwin && !ios +// +build darwin,!ios + +// Copyright 2025-2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keychain + +/* +#cgo LDFLAGS: -framework CoreFoundation -framework Security +#include +#include +*/ +import "C" + +import ( + "errors" + "fmt" +) + +// AccessControlFlags is a bit set matching Apple's SecAccessControlCreateFlags. +// Combine flags with the bitwise OR operator. The "Or" / "And" flags select +// how multiple authentication requirements compose; when omitted, Apple +// defaults to "Or" (any of the listed factors satisfies the prompt). +// +// See https://developer.apple.com/documentation/security/secaccesscontrolcreateflags +type AccessControlFlags uint64 + +const ( + // AccessControlUserPresence prompts the user with Touch ID, Face ID, or + // passcode as a fallback. Equivalent to kSecAccessControlUserPresence. + AccessControlUserPresence AccessControlFlags = 1 << 0 + // AccessControlBiometryAny requires any enrolled biometric. New biometric + // enrolments remain valid for the item. Equivalent to + // kSecAccessControlBiometryAny. + AccessControlBiometryAny AccessControlFlags = 1 << 1 + // AccessControlBiometryCurrentSet binds the item to the biometric set + // at creation time; adding or removing a fingerprint/face invalidates + // the item. Equivalent to kSecAccessControlBiometryCurrentSet. + AccessControlBiometryCurrentSet AccessControlFlags = 1 << 3 + // AccessControlDevicePasscode requires the device passcode. Equivalent + // to kSecAccessControlDevicePasscode. + AccessControlDevicePasscode AccessControlFlags = 1 << 4 + // AccessControlOr makes multiple constraints satisfiable with any one + // factor (the default when multiple factors are listed). + AccessControlOr AccessControlFlags = 1 << 14 + // AccessControlAnd requires every listed factor. + AccessControlAnd AccessControlFlags = 1 << 15 + // AccessControlApplicationPassword requires an application-provided + // password in addition to the listed factors. Rarely useful here. + AccessControlApplicationPassword AccessControlFlags = 1 << 30 +) + +// AccessControlKey is the attribute key for kSecAttrAccessControl. +// +// Items created with kSecAttrAccessControl must NOT also carry +// kSecAttrAccessible — the protection class is baked into the access control +// object itself. Callers that opt into access control should leave Accessible +// unset on the Item. +var AccessControlKey = attrKey(C.CFTypeRef(C.kSecAttrAccessControl)) + +// UseOperationPromptKey is the attribute key for kSecUseOperationPrompt. +// It is set at query time (Get / QueryItem) to customise the message shown +// in the Touch ID / Face ID / passcode prompt. +var UseOperationPromptKey = attrKey(C.CFTypeRef(C.kSecUseOperationPrompt)) + +// AccessControl describes an access-control policy that gets materialised +// into a SecAccessControl object each time the keychain item is built. It +// implements [Convertable] so ConvertMapToCFDictionary releases the freshly +// created CFTypeRef once the enclosing dictionary is no longer needed. +// +// We build the ref lazily (in Convert) rather than at SetAccessControl +// time so the Item remains safe to reuse across AddItem/QueryItem calls +// without leaking — the alternative of caching one ref per Item is harder +// to reason about because the dictionary may be created multiple times +// during a single store operation (Save→Delete→Save retry, etc.). +type AccessControl struct { + Protection Accessible + Flags AccessControlFlags +} + +// Convert is called by ConvertMapToCFDictionary; the returned ref is owned +// by the caller and the framework defers Release on it. +func (a AccessControl) Convert() (C.CFTypeRef, error) { + protectionRef, ok := accessibleTypeRef[a.Protection] + if !ok { + return 0, fmt.Errorf("unsupported protection class for access control: %d", a.Protection) + } + + var cfErr C.CFErrorRef + acl := C.SecAccessControlCreateWithFlags( + C.kCFAllocatorDefault, + protectionRef, + C.SecAccessControlCreateFlags(a.Flags), + &cfErr, + ) + if acl == 0 { + if cfErr != 0 { + defer C.CFRelease(C.CFTypeRef(cfErr)) + return 0, fmt.Errorf("SecAccessControlCreateWithFlags: %s", + CFStringToString(C.CFErrorCopyDescription(cfErr))) + } + return 0, errors.New("SecAccessControlCreateWithFlags returned nil") + } + return C.CFTypeRef(acl), nil +} + +// SetAccessControl attaches a SecAccessControl policy to the item's +// kSecAttrAccessControl attribute, requiring the listed authentication +// factors before the keychain releases the secret. The protection class +// replaces the role normally played by Accessible — callers should not also +// call SetAccessible on the same item. +func (k *Item) SetAccessControl(protection Accessible, flags AccessControlFlags) { + // Drop any previously-set Accessible attribute: the protection class is + // encoded inside the SecAccessControl object, and supplying both + // confuses SecItemAdd (errSecParam). + delete(k.attr, AccessibleKey) + k.attr[AccessControlKey] = AccessControl{Protection: protection, Flags: flags} +} + +// SetUseOperationPrompt sets kSecUseOperationPrompt on a query so the +// Touch ID / Face ID / passcode dialog shows the supplied message. Empty +// strings clear the attribute. This is read-side only; it has no effect at +// item creation. +func (k *Item) SetUseOperationPrompt(prompt string) { + k.SetString(UseOperationPromptKey, prompt) +} diff --git a/store/keychain/internal/go-keychain/accesscontrol_darwin_test.go b/store/keychain/internal/go-keychain/accesscontrol_darwin_test.go new file mode 100644 index 00000000..eb90fbc6 --- /dev/null +++ b/store/keychain/internal/go-keychain/accesscontrol_darwin_test.go @@ -0,0 +1,100 @@ +//go:build darwin && !ios && cgo + +// Copyright 2025-2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package keychain + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAccessControlConvert exercises the Convert() path against a known-good +// flag combination. A successful Convert returns a non-zero CFTypeRef that we +// must release to keep the test leak-free. +func TestAccessControlConvert(t *testing.T) { + cases := []struct { + name string + protection Accessible + flags AccessControlFlags + }{ + { + name: "user presence + after first unlock", + protection: AccessibleAfterFirstUnlock, + flags: AccessControlUserPresence, + }, + { + name: "biometry any + when unlocked", + protection: AccessibleWhenUnlocked, + flags: AccessControlBiometryAny, + }, + { + name: "biometry current set + this device only", + protection: AccessibleWhenUnlockedThisDeviceOnly, + flags: AccessControlBiometryCurrentSet, + }, + { + name: "biometry + passcode fallback combined with OR", + protection: AccessibleAfterFirstUnlock, + flags: AccessControlBiometryAny | AccessControlDevicePasscode | AccessControlOr, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ref, err := AccessControl{Protection: tc.protection, Flags: tc.flags}.Convert() + require.NoError(t, err) + require.NotZero(t, ref, "expected non-zero SecAccessControlRef") + t.Cleanup(func() { Release(ref) }) + }) + } +} + +// TestAccessControlConvert_UnknownProtection covers the failure path for an +// unsupported Accessible value (the AccessibleDefault sentinel maps to no +// kSecAttrAccessible* constant). +func TestAccessControlConvert_UnknownProtection(t *testing.T) { + _, err := AccessControl{Protection: AccessibleDefault, Flags: AccessControlUserPresence}.Convert() + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported protection class") +} + +// TestSetAccessControl_ReplacesAccessible verifies that calling +// SetAccessControl after SetAccessible drops the Accessible attribute — they +// cannot coexist on the same item. +func TestSetAccessControl_ReplacesAccessible(t *testing.T) { + item := NewItem() + item.SetAccessible(AccessibleAfterFirstUnlock) + require.NotNil(t, item.attr[AccessibleKey], "precondition: Accessible is set") + + item.SetAccessControl(AccessibleAfterFirstUnlock, AccessControlUserPresence) + _, hasAccessible := item.attr[AccessibleKey] + assert.False(t, hasAccessible, "Accessible should be removed when AccessControl is set") + _, hasAccessControl := item.attr[AccessControlKey] + assert.True(t, hasAccessControl, "AccessControl should be set") +} + +// TestSetUseOperationPrompt verifies the round-trip on the attribute map. +func TestSetUseOperationPrompt(t *testing.T) { + item := NewItem() + item.SetUseOperationPrompt("Authenticate to read Docker secrets") + assert.Equal(t, "Authenticate to read Docker secrets", item.attr[UseOperationPromptKey]) + + // Empty string clears the attribute (SetString contract). + item.SetUseOperationPrompt("") + _, exists := item.attr[UseOperationPromptKey] + assert.False(t, exists) +} diff --git a/store/keychain/keychain.go b/store/keychain/keychain.go index a13c2f96..0b7b168e 100644 --- a/store/keychain/keychain.go +++ b/store/keychain/keychain.go @@ -53,6 +53,7 @@ func (f optionFunc[K]) apply(s K) error { return f(s) } type darwinOptions interface { setUseDataProtectionKeychain(bool) + setBiometricAuth(prompt string) } type DarwinOptions optionFunc[darwinOptions] @@ -78,6 +79,24 @@ func WithUseDataProtectionKeychain() DarwinOptions { } } +// WithBiometricAuth makes every secret created by the store require a +// Touch ID, Face ID, or device-passcode prompt on read. The prompt argument +// customises the message shown in the system dialog; an empty string falls +// back to the macOS default. On non-Darwin platforms this option is a +// no-op (the keychain store ignores it). +// +// IMPORTANT: items already present in the keychain when the option is +// enabled keep their original access-control policy. Only secrets *written* +// after the store is constructed with this option pick up biometric ACLs. +// Callers that want every existing secret to be protected need to delete +// and re-save them after migrating. +func WithBiometricAuth(prompt string) DarwinOptions { + return func(do darwinOptions) error { + do.setBiometricAuth(prompt) + return nil + } +} + // New creates a new keychain store. // // It takes ServiceGroup and ServiceName and a [Factory] as input. diff --git a/store/keychain/keychain_biometric_darwin_test.go b/store/keychain/keychain_biometric_darwin_test.go new file mode 100644 index 00000000..b088c911 --- /dev/null +++ b/store/keychain/keychain_biometric_darwin_test.go @@ -0,0 +1,70 @@ +// Copyright 2025-2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build darwin && cgo + +package keychain + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/secrets-engine/store" + "github.com/docker/secrets-engine/store/mocks" +) + +// TestWithBiometricAuth_Construction asserts that the option is plumbed into +// the keychain store struct without performing any keychain I/O — we cannot +// exercise a Save/Get round-trip in tests because the macOS authentication +// prompt would block waiting for Touch ID input. +func TestWithBiometricAuth_Construction(t *testing.T) { + factory := func(_ context.Context, _ store.ID) *mocks.MockCredential { + return &mocks.MockCredential{} + } + + s, err := New[*mocks.MockCredential]( + "com.test.biometric", + "sbx-test", + factory, + WithDarwinOptions(WithBiometricAuth("Authenticate to read Docker secrets")), + ) + require.NoError(t, err) + + // Cast back to the concrete type so we can inspect the wired fields. + // This is test-only inspection; production callers use the store.Store + // interface. + ks, ok := s.(*keychainStore[*mocks.MockCredential]) + require.True(t, ok, "New returned an unexpected store type") + assert.True(t, ks.useBiometricAuth, "biometric auth flag should be set") + assert.Equal(t, "Authenticate to read Docker secrets", ks.biometricPrompt) +} + +// TestWithBiometricAuth_DefaultIsOff covers the unchanged-default invariant: +// constructing a store without WithBiometricAuth must leave the flag off, so +// production callers that have not opted in keep their current behaviour. +func TestWithBiometricAuth_DefaultIsOff(t *testing.T) { + factory := func(_ context.Context, _ store.ID) *mocks.MockCredential { + return &mocks.MockCredential{} + } + + s, err := New[*mocks.MockCredential]("com.test.biometric", "sbx-test", factory) + require.NoError(t, err) + ks, ok := s.(*keychainStore[*mocks.MockCredential]) + require.True(t, ok) + assert.False(t, ks.useBiometricAuth) + assert.Empty(t, ks.biometricPrompt) +} diff --git a/store/keychain/keychain_darwin.go b/store/keychain/keychain_darwin.go index af80ac0c..7afcda19 100644 --- a/store/keychain/keychain_darwin.go +++ b/store/keychain/keychain_darwin.go @@ -37,12 +37,19 @@ type keychainStore[T store.Secret] struct { serviceName string factory store.Factory[T] useDataProtectionKeychain bool + useBiometricAuth bool + biometricPrompt string } func (k *keychainStore[T]) setUseDataProtectionKeychain(v bool) { k.useDataProtectionKeychain = v } +func (k *keychainStore[T]) setBiometricAuth(prompt string) { + k.useBiometricAuth = true + k.biometricPrompt = prompt +} + // newKeychainItem creates a new keychain item with valid default parameters. // // It uses a generic password class, which is suitable for most use cases. @@ -51,6 +58,12 @@ func (k *keychainStore[T]) setUseDataProtectionKeychain(v bool) { // // The id parameter can be empty, in which case the item will search based on // the service name and group, but not the item label or account. +// +// When biometric auth is enabled on the store, query-time items carry the +// custom operation prompt so the Touch ID / Face ID dialog shows it; the +// access-control policy itself is applied only on Save (see saveItem). At +// query time we deliberately do NOT set kSecAttrAccessControl because that +// would filter matches by ACL, not configure them. func newKeychainItem[T store.Secret](id string, k *keychainStore[T]) kc.Item { item := kc.NewItem() // generic password is used here as we don't know what we are storing @@ -68,6 +81,12 @@ func newKeychainItem[T store.Secret](id string, k *keychainStore[T]) kc.Item { if k.useDataProtectionKeychain { item.SetUseDataProtectionKeychain(kc.UseDataProtectionKeychainYes) } + if k.useBiometricAuth { + // Surface the caller-supplied message in the system prompt. + // Empty string keeps macOS's default (" wants to use your + // confidential information stored in in your keychain"). + item.SetUseOperationPrompt(k.biometricPrompt) + } if id != "" { item.SetAccount(id) @@ -76,6 +95,23 @@ func newKeychainItem[T store.Secret](id string, k *keychainStore[T]) kc.Item { return item } +// applyBiometricACL replaces the item's Accessible policy with a +// kSecAccessControl that requires Touch ID, Face ID, or the device passcode +// as a fallback. It must be called before AddItem; it is a no-op when the +// store was constructed without WithBiometricAuth. +// +// We use AccessControlUserPresence (any enrolled biometric OR passcode) +// rather than BiometryCurrentSet so adding a new fingerprint or re-enrolling +// Face ID doesn't invalidate the stored secrets — silently losing access on +// a hardware change is a worse user experience than the small risk delta +// between "current set only" and "any enrolled factor". +func applyBiometricACL[T store.Secret](item *kc.Item, k *keychainStore[T]) { + if !k.useBiometricAuth { + return + } + item.SetAccessControl(kc.AccessibleAfterFirstUnlock, kc.AccessControlUserPresence) +} + // getItemWithData retrieves a keychain item with its data. // // It uses the SetReturnData attribute to query for an item with its data. @@ -182,6 +218,7 @@ func (k *keychainStore[T]) Save(_ context.Context, id store.ID, secret store.Sec } defer clear(data) item := newKeychainItem(id.String(), k) + applyBiometricACL(&item, k) item.SetData(data) // only creation of a secret needs the label attribute. // it is a user-friendly name for the item, which is displayed in the keychain UI.