2 Commits
Author SHA1 Message Date
kevin e61d4fedd8 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。
2026-07-09 19:23:09 +08:00
kevin 1886d9b07b up 2026-07-09 19:07:40 +08:00
5 changed files with 159 additions and 20 deletions
+39 -2
View File
@@ -11,12 +11,18 @@ GO = go
CGO_ENABLED = 1 CGO_ENABLED = 1
WINDRES ?= x86_64-w64-mingw32-windres WINDRES ?= x86_64-w64-mingw32-windres
MINGW_CC ?= x86_64-w64-mingw32-gcc MINGW_CC ?= x86_64-w64-mingw32-gcc
SEMVER ?= 0.6.5 SEMVER ?= 0.6.7
GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
VERSION = $(SEMVER)-$(GIT_HASH) VERSION = $(SEMVER)-$(GIT_HASH)
LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) 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: build the .app bundle (default)
all: app all: app
@@ -41,8 +47,39 @@ app: build
@if [ -f resources/icon.icns ]; then \ @if [ -f resources/icon.icns ]; then \
cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \ cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \
else echo " (no icon.icns found, skipping icon)"; fi 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)" @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: generate icon.icns from resources/icon.png (or resources/logo.svg)
icon: icon:
@if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \ @if [ -f resources/logo.svg ] && [ ! -f resources/icon.png -o resources/logo.svg -nt resources/icon.png ]; then \
+6
View File
@@ -19,6 +19,12 @@ const ServiceName = paths.BundleID
// ErrNotFound is returned when a secret is not present in the store. // ErrNotFound is returned when a secret is not present in the store.
var ErrNotFound = fmt.Errorf("secret not found") 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 // ErrUserCanceled is returned when the user cancels a biometric
// authentication prompt (e.g. Touch ID) or the system cannot complete // authentication prompt (e.g. Touch ID) or the system cannot complete
// authentication. // authentication.
+90 -18
View File
@@ -3,7 +3,9 @@
package keychain package keychain
import ( import (
"errors"
"fmt" "fmt"
"sync"
"github.com/keybase/go-keychain" "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. // DarwinStore implements Store using the macOS Keychain.
type DarwinStore struct{} type DarwinStore struct{}
@@ -37,18 +69,15 @@ func New() Store {
return DarwinStore{} return DarwinStore{}
} }
func (DarwinStore) setItem(account, secret string) error { // setItemPlain stores a secret in the file-based keychain (no biometric
// Use biometric-protected storage when Touch ID is available. // protection) using the keybase/go-keychain library.
if biometricAvailable() { func setItemPlain(account, secret string) error {
return storeBiometricItemGo(ServiceName, account, secret)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
item.SetAccount(account) item.SetAccount(account)
item.SetData([]byte(secret)) item.SetData([]byte(secret))
item.SetAccessible(keychain.AccessibleAfterFirstUnlock) item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
// Delete any existing item first (Add fails on duplicates).
_ = keychain.DeleteItem(item) _ = keychain.DeleteItem(item)
if err := keychain.AddItem(item); err != nil { if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("keychain add %s: %w", account, err) return fmt.Errorf("keychain add %s: %w", account, err)
@@ -56,12 +85,8 @@ func (DarwinStore) setItem(account, secret string) error {
return nil return nil
} }
func (DarwinStore) getItem(account string) (string, error) { // getItemPlain retrieves a secret from the file-based keychain.
// When Touch ID is available, use the direct CGo path which can func getItemPlain(account string) (string, error) {
// show a biometric prompt for protected items.
if biometricAvailable() {
return getBiometricItemGo(ServiceName, account, touchIDPrompt)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
@@ -78,12 +103,8 @@ func (DarwinStore) getItem(account string) (string, error) {
return string(results[0].Data), nil return string(results[0].Data), nil
} }
func (DarwinStore) deleteItem(account string) error { // deleteItemPlain deletes a secret from the file-based keychain.
// Use the direct CGo delete which works for both biometric and func deleteItemPlain(account string) error {
// non-biometric items (and doesn't trigger a Touch ID prompt).
if biometricAvailable() {
return deleteBiometricItemGo(ServiceName, account)
}
item := keychain.NewItem() item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword) item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName) item.SetService(ServiceName)
@@ -91,6 +112,57 @@ func (DarwinStore) deleteItem(account string) error {
return keychain.DeleteItem(item) 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 { func (s DarwinStore) SetPassword(profileName, password string) error {
return s.setItem(passwordAccountPrefix+profileName, password) return s.setItem(passwordAccountPrefix+profileName, password)
} }
@@ -215,6 +215,11 @@ import (
// when the user cancels a Touch ID / password prompt (-128). // when the user cancels a Touch ID / password prompt (-128).
const errSecUserCanceled = -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 ( var (
biometricCacheOnce sync.Once biometricCacheOnce sync.Once
biometricCacheVal bool biometricCacheVal bool
@@ -293,6 +298,9 @@ func storeBiometricItemGo(service, account, secret string) error {
status := C.storeBiometricItem(svcRef, accRef, dataRef) status := C.storeBiometricItem(svcRef, accRef, dataRef)
if status != 0 { 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 fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status)
} }
return nil return nil
@@ -329,6 +337,9 @@ func getBiometricItemGo(service, account, prompt string) (string, error) {
return "", ErrUserCanceled return "", ErrUserCanceled
} }
if status != 0 { 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) return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status)
} }
defer cfRelease(C.CFTypeRef(dataOut)) defer cfRelease(C.CFTypeRef(dataOut))
@@ -354,6 +365,9 @@ func deleteBiometricItemGo(service, account string) error {
status := C.deleteBiometricItem(svcRef, accRef) status := C.deleteBiometricItem(svcRef, accRef)
if status != 0 { 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 fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status)
} }
return nil return nil
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.lmvpn.client</string>
</array>
</dict>
</plist>