Skip to content
Closed
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
138 changes: 138 additions & 0 deletions store/keychain/internal/go-keychain/accesscontrol_darwin.go
Original file line number Diff line number Diff line change
@@ -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 <CoreFoundation/CoreFoundation.h>
#include <Security/Security.h>
*/
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)
}
100 changes: 100 additions & 0 deletions store/keychain/internal/go-keychain/accesscontrol_darwin_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
19 changes: 19 additions & 0 deletions store/keychain/keychain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions store/keychain/keychain_biometric_darwin_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading