fix: 修复钥匙串保存配置时errSecMissingEntitlement(-34018)错误
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s

biometric钥匙串路径在缺少代码签名entitlement时自动降级到普通钥匙串;
添加LMVPN.entitlements文件和Makefile可选codesign步骤以支持未来Touch ID。
This commit is contained in:
2026-07-09 19:23:09 +08:00
parent 1886d9b07b
commit e61d4fedd8
5 changed files with 159 additions and 20 deletions
+6
View File
@@ -19,6 +19,12 @@ const ServiceName = paths.BundleID
// ErrNotFound is returned when a secret is not present in the store.
var ErrNotFound = fmt.Errorf("secret not found")
// ErrSecMissingEntitlement is returned when the biometric keychain path
// fails because the app lacks the required code-signing entitlements
// (errSecMissingEntitlement, OSStatus -34018). Callers should fall back
// to the non-biometric keychain path when this error is encountered.
var ErrSecMissingEntitlement = fmt.Errorf("missing keychain entitlement")
// ErrUserCanceled is returned when the user cancels a biometric
// authentication prompt (e.g. Touch ID) or the system cannot complete
// authentication.
+90 -18
View File
@@ -3,7 +3,9 @@
package keychain
import (
"errors"
"fmt"
"sync"
"github.com/keybase/go-keychain"
)
@@ -26,6 +28,36 @@ func SetTouchIDPrompt(prompt string) {
}
}
// biometricDisabled tracks whether the biometric keychain path has been
// disabled at runtime. This happens when a biometric operation fails with
// errSecMissingEntitlement (-34018), indicating the app lacks the required
// code-signing entitlements. Once disabled, all subsequent operations use
// the non-biometric file-based keychain path.
var (
biometricDisabled bool
biometricDisabledMu sync.Mutex
)
// shouldUseBiometric reports whether the biometric keychain path should be
// used. It returns false if biometric storage has been disabled at runtime
// (e.g. due to missing entitlements on an ad-hoc signed build).
func shouldUseBiometric() bool {
if !biometricAvailable() {
return false
}
biometricDisabledMu.Lock()
defer biometricDisabledMu.Unlock()
return !biometricDisabled
}
// disableBiometric disables the biometric keychain path for all subsequent
// operations, forcing a fallback to the non-biometric file-based keychain.
func disableBiometric() {
biometricDisabledMu.Lock()
defer biometricDisabledMu.Unlock()
biometricDisabled = true
}
// DarwinStore implements Store using the macOS Keychain.
type DarwinStore struct{}
@@ -37,18 +69,15 @@ func New() Store {
return DarwinStore{}
}
func (DarwinStore) setItem(account, secret string) error {
// Use biometric-protected storage when Touch ID is available.
if biometricAvailable() {
return storeBiometricItemGo(ServiceName, account, secret)
}
// setItemPlain stores a secret in the file-based keychain (no biometric
// protection) using the keybase/go-keychain library.
func setItemPlain(account, secret string) error {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
item.SetAccount(account)
item.SetData([]byte(secret))
item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
// Delete any existing item first (Add fails on duplicates).
_ = keychain.DeleteItem(item)
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("keychain add %s: %w", account, err)
@@ -56,12 +85,8 @@ func (DarwinStore) setItem(account, secret string) error {
return nil
}
func (DarwinStore) getItem(account string) (string, error) {
// When Touch ID is available, use the direct CGo path which can
// show a biometric prompt for protected items.
if biometricAvailable() {
return getBiometricItemGo(ServiceName, account, touchIDPrompt)
}
// getItemPlain retrieves a secret from the file-based keychain.
func getItemPlain(account string) (string, error) {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
@@ -78,12 +103,8 @@ func (DarwinStore) getItem(account string) (string, error) {
return string(results[0].Data), nil
}
func (DarwinStore) deleteItem(account string) error {
// Use the direct CGo delete which works for both biometric and
// non-biometric items (and doesn't trigger a Touch ID prompt).
if biometricAvailable() {
return deleteBiometricItemGo(ServiceName, account)
}
// deleteItemPlain deletes a secret from the file-based keychain.
func deleteItemPlain(account string) error {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
@@ -91,6 +112,57 @@ func (DarwinStore) deleteItem(account string) error {
return keychain.DeleteItem(item)
}
func (DarwinStore) setItem(account, secret string) error {
if shouldUseBiometric() {
err := storeBiometricItemGo(ServiceName, account, secret)
if err == nil {
return nil
}
if errors.Is(err, ErrSecMissingEntitlement) {
disableBiometric()
// Fall through to non-biometric path.
} else {
return err
}
}
return setItemPlain(account, secret)
}
func (DarwinStore) getItem(account string) (string, error) {
if shouldUseBiometric() {
secret, err := getBiometricItemGo(ServiceName, account, touchIDPrompt)
if err == nil {
return secret, nil
}
if errors.Is(err, ErrSecMissingEntitlement) {
disableBiometric()
// Fall through to non-biometric path.
} else if errors.Is(err, ErrNotFound) {
// Item not in the Data Protection Keychain; it may have
// been stored via the non-biometric path. Fall through.
} else {
return "", err
}
}
return getItemPlain(account)
}
func (DarwinStore) deleteItem(account string) error {
if shouldUseBiometric() {
err := deleteBiometricItemGo(ServiceName, account)
if err == nil {
return nil
}
if errors.Is(err, ErrSecMissingEntitlement) {
disableBiometric()
// Fall through to non-biometric path.
} else {
return err
}
}
return deleteItemPlain(account)
}
func (s DarwinStore) SetPassword(profileName, password string) error {
return s.setItem(passwordAccountPrefix+profileName, password)
}
@@ -215,6 +215,11 @@ import (
// when the user cancels a Touch ID / password prompt (-128).
const errSecUserCanceled = -128
// errSecMissingEntitlementCode is errSecMissingEntitlement (-34018),
// returned by the Data Protection Keychain when the app is not properly
// code-signed with keychain-access-groups entitlement.
const errSecMissingEntitlementCode = -34018
var (
biometricCacheOnce sync.Once
biometricCacheVal bool
@@ -293,6 +298,9 @@ func storeBiometricItemGo(service, account, secret string) error {
status := C.storeBiometricItem(svcRef, accRef, dataRef)
if status != 0 {
if status == errSecMissingEntitlementCode {
return fmt.Errorf("keychain biometric store %s: %w", account, ErrSecMissingEntitlement)
}
return fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status)
}
return nil
@@ -329,6 +337,9 @@ func getBiometricItemGo(service, account, prompt string) (string, error) {
return "", ErrUserCanceled
}
if status != 0 {
if status == errSecMissingEntitlementCode {
return "", fmt.Errorf("keychain biometric get %s: %w", account, ErrSecMissingEntitlement)
}
return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status)
}
defer cfRelease(C.CFTypeRef(dataOut))
@@ -354,6 +365,9 @@ func deleteBiometricItemGo(service, account string) error {
status := C.deleteBiometricItem(svcRef, accRef)
if status != 0 {
if status == errSecMissingEntitlementCode {
return fmt.Errorf("keychain biometric delete %s: %w", account, ErrSecMissingEntitlement)
}
return fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
}
return nil