From 9007e9fd513d32afb1f5b73d067d8a5c9e4cb2c3 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 3 Jun 2026 13:59:16 +0200 Subject: [PATCH] feat(keychain): opt-in biometric (Touch ID / Face ID) access control Add a WithBiometricAuth(prompt) DarwinOptions that wraps stored secrets in a kSecAccessControl object requiring Touch ID, Face ID, or the device passcode before the keychain releases the secret. The optional prompt string customises the system dialog via kSecUseOperationPrompt at query time. The default remains kSecAttrAccessibleAfterFirstUnlock (password popup on first access of the session), so existing callers see no behavioural change. Items already in the keychain when the option is enabled keep their original ACL; only secrets written after construction pick up the biometric policy. Implementation notes: - accesscontrol_darwin.go in internal/go-keychain exposes the cgo bindings for SecAccessControlCreateWithFlags + the flag constants (UserPresence, BiometryAny, BiometryCurrentSet, DevicePasscode, Or/And combiners). - AccessControl implements the existing Convertable interface so the ConvertMapToCFDictionary release path correctly frees the CFTypeRef after each dictionary build, avoiding leaks across multi-call store operations (Save -> Delete -> Save). - SetAccessControl deliberately removes kSecAttrAccessible: the protection class is encoded inside the SecAccessControl object and supplying both yields errSecParam. - The default flag combination is AccessControlUserPresence, which accepts any enrolled biometric and falls back to the device passcode. This is friendlier than BiometryCurrentSet (which invalidates the item when a fingerprint or face enrolment changes). Non-darwin platforms keep their existing behaviour: the option is declared cross-platform but only the darwin keychain store implements the new setBiometricAuth method on darwinOptions, so the option falls through errSkipOptions on Linux/Windows. Tests: - AccessControl.Convert() validates flag combinations and the unsupported-protection failure path without touching the system keychain. - SetAccessControl removes kSecAttrAccessible from the Item's attr map. - Store construction wires the flag and prompt through New(); the default-off invariant is covered explicitly. - A full Save/Get round-trip is intentionally NOT exercised in the test suite because the macOS authentication dialog would block CI. --- .../go-keychain/accesscontrol_darwin.go | 138 ++++++++++++++++++ .../go-keychain/accesscontrol_darwin_test.go | 100 +++++++++++++ store/keychain/keychain.go | 19 +++ .../keychain_biometric_darwin_test.go | 70 +++++++++ store/keychain/keychain_darwin.go | 37 +++++ 5 files changed, 364 insertions(+) create mode 100644 store/keychain/internal/go-keychain/accesscontrol_darwin.go create mode 100644 store/keychain/internal/go-keychain/accesscontrol_darwin_test.go create mode 100644 store/keychain/keychain_biometric_darwin_test.go 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.