From e61d4fedd82b5cf6e5e3b674c68b54b87d3c98db Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 9 Jul 2026 19:23:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=92=A5=E5=8C=99?= =?UTF-8?q?=E4=B8=B2=E4=BF=9D=E5=AD=98=E9=85=8D=E7=BD=AE=E6=97=B6errSecMis?= =?UTF-8?q?singEntitlement(-34018)=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit biometric钥匙串路径在缺少代码签名entitlement时自动降级到普通钥匙串; 添加LMVPN.entitlements文件和Makefile可选codesign步骤以支持未来Touch ID。 --- Makefile | 41 ++++++- internal/keychain/keychain.go | 6 ++ internal/keychain/keychain_darwin.go | 108 +++++++++++++++---- internal/keychain/keychain_darwin_touchid.go | 14 +++ resources/LMVPN.entitlements | 10 ++ 5 files changed, 159 insertions(+), 20 deletions(-) create mode 100644 resources/LMVPN.entitlements diff --git a/Makefile b/Makefile index bebc80b..d525f3d 100644 --- a/Makefile +++ b/Makefile @@ -11,12 +11,18 @@ GO = go CGO_ENABLED = 1 WINDRES ?= x86_64-w64-mingw32-windres MINGW_CC ?= x86_64-w64-mingw32-gcc -SEMVER ?= 0.6.6 +SEMVER ?= 0.6.7 GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) VERSION = $(SEMVER)-$(GIT_HASH) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) -.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows +# Code signing (optional). Set these to sign the .app bundle with a +# Developer ID certificate, enabling biometric (Touch ID) keychain access. +# Example: make app CODESIGN_IDENTITY="Developer ID Application: Name (ABCDE12345)" TEAM_ID=ABCDE12345 +CODESIGN_IDENTITY ?= +TEAM_ID ?= + +.PHONY: all build app run daemon clean vet tidy fmt icon icon-windows build-windows installer-windows sign ## all: build the .app bundle (default) all: app @@ -41,8 +47,39 @@ app: build @if [ -f resources/icon.icns ]; then \ cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \ else echo " (no icon.icns found, skipping icon)"; fi + @if [ -n "$(CODESIGN_IDENTITY)" ]; then $(MAKE) sign; \ + else echo " (skipping code signing - set CODESIGN_IDENTITY and TEAM_ID to enable Touch ID keychain)"; fi @echo "Built $(APP_BUNDLE)" +## sign: code-sign the .app bundle with a Developer ID certificate +## Requires CODESIGN_IDENTITY and TEAM_ID to be set. +sign: + @if [ -z "$(CODESIGN_IDENTITY)" ]; then \ + echo "ERROR: CODESIGN_IDENTITY not set."; \ + echo "Usage: make sign CODESIGN_IDENTITY='Developer ID Application: Name (TEAMID)' TEAM_ID=TEAMID"; \ + exit 1; \ + fi + @if [ -z "$(TEAM_ID)" ]; then \ + echo "ERROR: TEAM_ID not set."; \ + exit 1; \ + fi + @tmp=$$(mktemp LMVPN.entitlements.XXXXXX); \ + sed 's/\$$(AppIdentifierPrefix)/$(TEAM_ID)./' resources/LMVPN.entitlements > $$tmp; \ + codesign --force --options runtime \ + --sign "$(CODESIGN_IDENTITY)" \ + --entitlements $$tmp \ + $(APP_BUNDLE)/Contents/MacOS/$(GUI_BIN); \ + codesign --force --options runtime \ + --sign "$(CODESIGN_IDENTITY)" \ + --entitlements $$tmp \ + $(APP_BUNDLE)/Contents/MacOS/$(DAEMON_BIN); \ + codesign --force --options runtime \ + --sign "$(CODESIGN_IDENTITY)" \ + --entitlements $$tmp \ + $(APP_BUNDLE); \ + rm -f $$tmp + @echo "Signed $(APP_BUNDLE) with identity: $(CODESIGN_IDENTITY)" + ## icon: generate icon.icns from resources/icon.png (or resources/logo.svg) icon: @if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \ diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index ca082c0..f118815 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -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. diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index 58e9bc9..a8b4a51 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -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) } diff --git a/internal/keychain/keychain_darwin_touchid.go b/internal/keychain/keychain_darwin_touchid.go index 4a2750a..fe08274 100644 --- a/internal/keychain/keychain_darwin_touchid.go +++ b/internal/keychain/keychain_darwin_touchid.go @@ -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 diff --git a/resources/LMVPN.entitlements b/resources/LMVPN.entitlements new file mode 100644 index 0000000..9ef9336 --- /dev/null +++ b/resources/LMVPN.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.lmvpn.client + + +