diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index c71db82..d289adf 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -141,3 +141,6 @@ PlaceholderTLSPinnedHash = "sha256:a1b2c3..." BtnBrowse = "Browse..." DlgTLSError = "TLS Certificate Error" TLSErrorVerification = "Server certificate verification failed." + +DlgBiometricCanceled = "Authentication was canceled." +TouchIDPrompt = "authenticate to access your VPN password" diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index 243ecb1..3fa5e34 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -141,3 +141,6 @@ PlaceholderTLSPinnedHash = "sha256:a1b2c3..." BtnBrowse = "浏览..." DlgTLSError = "TLS 证书错误" TLSErrorVerification = "服务器证书验证失败。" + +DlgBiometricCanceled = "认证已取消。" +TouchIDPrompt = "验证指纹以访问 VPN 密码" diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index 4ea78cb..ca082c0 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -19,6 +19,11 @@ const ServiceName = paths.BundleID // ErrNotFound is returned when a secret is not present in the store. var ErrNotFound = fmt.Errorf("secret not found") +// ErrUserCanceled is returned when the user cancels a biometric +// authentication prompt (e.g. Touch ID) or the system cannot complete +// authentication. +var ErrUserCanceled = fmt.Errorf("user canceled authentication") + // Store is the secret storage interface. type Store interface { SetPassword(profileName, password string) error diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index a2bc285..58e9bc9 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -14,15 +14,34 @@ const ( tokenAccountPrefix = "token:" ) +// touchIDPrompt is the localized prompt shown in the Touch ID dialog. +// It is set by the UI layer at startup via SetTouchIDPrompt. +var touchIDPrompt = "authenticate to access your VPN password" + +// SetTouchIDPrompt sets the localized prompt text shown in the Touch ID +// dialog when retrieving secrets from the keychain. +func SetTouchIDPrompt(prompt string) { + if prompt != "" { + touchIDPrompt = prompt + } +} + // DarwinStore implements Store using the macOS Keychain. type DarwinStore struct{} -// New returns a macOS Keychain-backed Store. +// New returns a macOS Keychain-backed Store. On Macs with Touch ID, +// secrets are stored with biometric (Touch ID) protection; on older +// Macs without a biometric sensor, the standard keychain accessibility +// is used as a fallback. 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) + } item := keychain.NewItem() item.SetSecClass(keychain.SecClassGenericPassword) item.SetService(ServiceName) @@ -38,6 +57,11 @@ func (DarwinStore) setItem(account, secret string) error { } 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) + } item := keychain.NewItem() item.SetSecClass(keychain.SecClassGenericPassword) item.SetService(ServiceName) @@ -55,6 +79,11 @@ func (DarwinStore) getItem(account string) (string, error) { } 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) + } item := keychain.NewItem() item.SetSecClass(keychain.SecClassGenericPassword) item.SetService(ServiceName) diff --git a/internal/keychain/keychain_darwin_touchid.go b/internal/keychain/keychain_darwin_touchid.go new file mode 100644 index 0000000..4a2750a --- /dev/null +++ b/internal/keychain/keychain_darwin_touchid.go @@ -0,0 +1,360 @@ +//go:build darwin + +package keychain + +/* +#cgo LDFLAGS: -framework CoreFoundation -framework Security -framework LocalAuthentication -framework Foundation + +#include +#include +#include +#include +#include + +// LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1 + +// biometricAvailable returns 1 if Touch ID is available, 0 otherwise. +// Uses the Objective-C runtime to call LAContext without including +// Objective-C headers (which cannot be parsed by the C compiler). +static int biometricAvailable(void) { + Class cls = objc_getClass("LAContext"); + if (!cls) return 0; + + id alloc = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("alloc")); + if (!alloc) return 0; + id context = ((id (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("init")); + if (!context) { + ((void (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("release")); + return 0; + } + + // canEvaluatePolicy:error: returns BOOL, takes (NSInteger, NSError**) + // LAPolicyDeviceOwnerAuthenticationWithBiometrics = 1 + BOOL result = ((BOOL (*)(id, SEL, long, void *))objc_msgSend)( + context, sel_getUid("canEvaluatePolicy:error:"), 1, NULL); + + ((void (*)(id, SEL))objc_msgSend)(context, sel_getUid("release")); + return result ? 1 : 0; +} + +// secAccessControlCreateBiometric creates a SecAccessControlRef with +// kSecAccessControlBiometryAny. Must be released with CFRelease by caller. +static SecAccessControlRef secAccessControlCreateBiometric(void) { + SecAccessControlRef acl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, + kSecAccessControlBiometryAny, + NULL); + return acl; +} + +// storeBiometricItem stores data in the keychain with Touch ID protection. +// Returns 0 on success, or a non-zero OSStatus error code on failure. +static int storeBiometricItem(CFStringRef service, CFStringRef account, CFDataRef data) { + SecAccessControlRef acl = secAccessControlCreateBiometric(); + if (!acl) { + return errSecAllocate; + } + + const void *keys[] = { + kSecClass, + kSecAttrService, + kSecAttrAccount, + kSecValueData, + kSecAttrAccessControl, + }; + const void *values[] = { + kSecClassGenericPassword, + service, + account, + data, + acl, + }; + CFDictionaryRef query = CFDictionaryCreate( + kCFAllocatorDefault, + keys, values, sizeof(keys) / sizeof(keys[0]), + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + + // Delete any existing item first (Add fails on duplicates). + SecItemDelete(query); + + OSStatus status = SecItemAdd(query, NULL); + CFRelease(query); + CFRelease(acl); + return (int)status; +} + +// getBiometricItem retrieves data from the keychain, prompting for Touch ID +// via an LAContext with a localized reason. Returns: +// 0 on success (dataOut filled, caller must CFRelease) +// -1 if item not found +// -128 (errSecUserCanceled) if user canceled +// other positive OSStatus error codes on failure +// +// prompt is the localized Touch ID prompt string (CFStringRef). +static int getBiometricItem(CFStringRef service, CFStringRef account, CFStringRef prompt, CFDataRef *dataOut) { + // Create an LAContext and set its localizedReason for the Touch ID + // prompt. We use the objc runtime to avoid including Objective-C + // headers. CFStringRef is toll-free bridged to NSString*. + id laContext = NULL; + if (prompt) { + Class cls = objc_getClass("LAContext"); + if (cls) { + id alloc = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_getUid("alloc")); + if (alloc) { + laContext = ((id (*)(id, SEL))objc_msgSend)(alloc, sel_getUid("init")); + } + } + if (laContext) { + ((void (*)(id, SEL, id))objc_msgSend)( + laContext, sel_getUid("setLocalizedReason:"), (id)prompt); + } + } + + CFDictionaryRef query; + if (laContext) { + const void *keys[] = { + kSecClass, + kSecAttrService, + kSecAttrAccount, + kSecMatchLimit, + kSecReturnData, + kSecUseAuthenticationContext, + }; + const void *values[] = { + kSecClassGenericPassword, + service, + account, + kSecMatchLimitOne, + kCFBooleanTrue, + laContext, + }; + query = CFDictionaryCreate( + kCFAllocatorDefault, + keys, values, sizeof(keys) / sizeof(keys[0]), + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + } else { + // Fallback: no LAContext (e.g. LAContext class not available). + const void *keys[] = { + kSecClass, + kSecAttrService, + kSecAttrAccount, + kSecMatchLimit, + kSecReturnData, + }; + const void *values[] = { + kSecClassGenericPassword, + service, + account, + kSecMatchLimitOne, + kCFBooleanTrue, + }; + query = CFDictionaryCreate( + kCFAllocatorDefault, + keys, values, sizeof(keys) / sizeof(keys[0]), + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + } + + CFTypeRef result = NULL; + OSStatus status = SecItemCopyMatching(query, &result); + CFRelease(query); + + if (laContext) { + ((void (*)(id, SEL))objc_msgSend)(laContext, sel_getUid("release")); + } + + if (status == errSecItemNotFound) { + return -1; + } + if (status != errSecSuccess) { + return (int)status; + } + *dataOut = (CFDataRef)result; + return 0; +} + +// deleteBiometricItem deletes a keychain item by service+account. +// Returns 0 on success (including "not found"), or OSStatus on error. +static int deleteBiometricItem(CFStringRef service, CFStringRef account) { + const void *keys[] = { + kSecClass, + kSecAttrService, + kSecAttrAccount, + }; + const void *values[] = { + kSecClassGenericPassword, + service, + account, + }; + CFDictionaryRef query = CFDictionaryCreate( + kCFAllocatorDefault, + keys, values, sizeof(keys) / sizeof(keys[0]), + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks); + + OSStatus status = SecItemDelete(query); + CFRelease(query); + if (status == errSecItemNotFound) { + return 0; + } + return (int)status; +} +*/ +import "C" + +import ( + "fmt" + "sync" + "unsafe" +) + +// errSecUserCanceled is the macOS Security framework error code returned +// when the user cancels a Touch ID / password prompt (-128). +const errSecUserCanceled = -128 + +var ( + biometricCacheOnce sync.Once + biometricCacheVal bool +) + +// biometricAvailable reports whether Touch ID is available on this Mac. +// The result is computed once and cached. +func biometricAvailable() bool { + biometricCacheOnce.Do(func() { + biometricCacheVal = C.biometricAvailable() == 1 + }) + return biometricCacheVal +} + +// cfRelease releases a CFTypeRef (CFString, CFData, etc.). +func cfRelease(ref C.CFTypeRef) { + if ref != 0 { + C.CFRelease(ref) + } +} + +// cfString creates a CFStringRef from a Go string. The caller must +// CFRelease the result. +func cfString(s string) (C.CFStringRef, error) { + if s == "" { + return 0, nil + } + cs := C.CFStringCreateWithBytes( + C.kCFAllocatorDefault, + (*C.UInt8)(unsafe.Pointer(&[]byte(s)[0])), + C.CFIndex(len(s)), + C.kCFStringEncodingUTF8, + C.false) + if cs == 0 { + return 0, fmt.Errorf("CFStringCreateWithBytes failed for %q", s) + } + return cs, nil +} + +// cfData creates a CFDataRef from a byte slice. The caller must +// CFRelease the result. +func cfData(b []byte) (C.CFDataRef, error) { + var p *C.UInt8 + if len(b) > 0 { + p = (*C.UInt8)(unsafe.Pointer(&b[0])) + } + cd := C.CFDataCreate(C.kCFAllocatorDefault, p, C.CFIndex(len(b))) + if cd == 0 { + return 0, fmt.Errorf("CFDataCreate failed") + } + return cd, nil +} + +// storeBiometricItemGo stores a secret in the keychain with Touch ID +// protection. It first deletes any existing item (which may or may not +// have biometric protection), then adds a new biometric-protected item. +func storeBiometricItemGo(service, account, secret string) error { + svcRef, err := cfString(service) + if err != nil { + return fmt.Errorf("keychain biometric store %s: %w", account, err) + } + defer cfRelease(C.CFTypeRef(svcRef)) + + accRef, err := cfString(account) + if err != nil { + return fmt.Errorf("keychain biometric store %s: %w", account, err) + } + defer cfRelease(C.CFTypeRef(accRef)) + + secretBytes := []byte(secret) + dataRef, err := cfData(secretBytes) + if err != nil { + return fmt.Errorf("keychain biometric store %s: %w", account, err) + } + defer cfRelease(C.CFTypeRef(dataRef)) + + status := C.storeBiometricItem(svcRef, accRef, dataRef) + if status != 0 { + return fmt.Errorf("keychain biometric store %s: OSStatus %d", account, status) + } + return nil +} + +// getBiometricItemGo retrieves a secret from the keychain, prompting +// for Touch ID if the item is biometric-protected. prompt is the +// localized text shown in the Touch ID dialog. +func getBiometricItemGo(service, account, prompt string) (string, error) { + svcRef, err := cfString(service) + if err != nil { + return "", fmt.Errorf("keychain biometric get %s: %w", account, err) + } + defer cfRelease(C.CFTypeRef(svcRef)) + + accRef, err := cfString(account) + if err != nil { + return "", fmt.Errorf("keychain biometric get %s: %w", account, err) + } + defer cfRelease(C.CFTypeRef(accRef)) + + promptRef, err := cfString(prompt) + if err != nil { + return "", fmt.Errorf("keychain biometric get %s: %w", account, err) + } + defer cfRelease(C.CFTypeRef(promptRef)) + + var dataOut C.CFDataRef + status := C.getBiometricItem(svcRef, accRef, promptRef, &dataOut) + if status == -1 { + return "", ErrNotFound + } + if status == errSecUserCanceled { + return "", ErrUserCanceled + } + if status != 0 { + return "", fmt.Errorf("keychain biometric get %s: OSStatus %d", account, status) + } + defer cfRelease(C.CFTypeRef(dataOut)) + + bytes := C.GoBytes(unsafe.Pointer(C.CFDataGetBytePtr(dataOut)), C.int(C.CFDataGetLength(dataOut))) + return string(bytes), nil +} + +// deleteBiometricItemGo deletes a keychain item by service+account. +// It works regardless of whether the item has biometric protection. +func deleteBiometricItemGo(service, account string) error { + svcRef, err := cfString(service) + if err != nil { + return fmt.Errorf("keychain biometric delete %s: %w", account, err) + } + defer cfRelease(C.CFTypeRef(svcRef)) + + accRef, err := cfString(account) + if err != nil { + return fmt.Errorf("keychain biometric delete %s: %w", account, err) + } + defer cfRelease(C.CFTypeRef(accRef)) + + status := C.deleteBiometricItem(svcRef, accRef) + if status != 0 { + return fmt.Errorf("keychain biometric delete %s: OSStatus %d", account, status) + } + return nil +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 7887cb5..d989b13 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -98,6 +98,9 @@ func Run() { log.L().Error("init i18n", "error", err) } + // Set the localized Touch ID prompt for keychain access (macOS). + setTouchIDPromptFromI18n() + a := &App{ fyneApp: app.NewWithID(paths.BundleID), db: store, @@ -358,6 +361,9 @@ func (a *App) changeLanguage(lang string) { // Switch the active localizer. i18n.SetLanguage(lang) + // Update the Touch ID prompt text for the new language. + setTouchIDPromptFromI18n() + // Rebuild everything that holds cached strings. a.rebuildUI() } diff --git a/internal/ui/touchid_darwin.go b/internal/ui/touchid_darwin.go new file mode 100644 index 0000000..ec5e409 --- /dev/null +++ b/internal/ui/touchid_darwin.go @@ -0,0 +1,14 @@ +//go:build darwin + +package ui + +import ( + "lmvpn/internal/i18n" + "lmvpn/internal/keychain" +) + +// setTouchIDPromptFromI18n configures the localized Touch ID prompt +// text on the keychain store for the current language. +func setTouchIDPromptFromI18n() { + keychain.SetTouchIDPrompt(i18n.T("TouchIDPrompt")) +} diff --git a/internal/ui/touchid_other.go b/internal/ui/touchid_other.go new file mode 100644 index 0000000..d2c41bd --- /dev/null +++ b/internal/ui/touchid_other.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package ui + +// setTouchIDPromptFromI18n is a no-op on non-darwin platforms where +// Touch ID is not available. +func setTouchIDPromptFromI18n() {} diff --git a/internal/ui/view.go b/internal/ui/view.go index 92f4526..9736874 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -3,12 +3,14 @@ package ui import ( _ "embed" "encoding/json" + "errors" "fmt" "net/url" "time" "lmvpn/internal/i18n" "lmvpn/internal/ipc" + "lmvpn/internal/keychain" "lmvpn/internal/model" "lmvpn/internal/stats" @@ -172,11 +174,17 @@ func (a *App) onConnect() { password, err := a.kc.GetPassword(a.currentProfile.Name) if err != nil { fyne.Do(func() { - showError(i18n.T("DlgCredentialError"), - i18n.T("DlgCredentialErrorMsg"), - a.window) - a.stateLabel.SetText(i18n.T("StateDisconnected")) - a.setConnButtons(true, false) + if errors.Is(err, keychain.ErrUserCanceled) { + // User canceled the Touch ID / password prompt. + a.stateLabel.SetText(i18n.T("StateDisconnected")) + a.setConnButtons(true, false) + } else { + showError(i18n.T("DlgCredentialError"), + i18n.T("DlgCredentialErrorMsg"), + a.window) + a.stateLabel.SetText(i18n.T("StateDisconnected")) + a.setConnButtons(true, false) + } }) return }