尝试使用mac的指纹识别来授权,但是似乎没有效果
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -141,3 +141,6 @@ PlaceholderTLSPinnedHash = "sha256:a1b2c3..."
|
||||
BtnBrowse = "浏览..."
|
||||
DlgTLSError = "TLS 证书错误"
|
||||
TLSErrorVerification = "服务器证书验证失败。"
|
||||
|
||||
DlgBiometricCanceled = "认证已取消。"
|
||||
TouchIDPrompt = "验证指纹以访问 VPN 密码"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//go:build darwin
|
||||
|
||||
package keychain
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -framework CoreFoundation -framework Security -framework LocalAuthentication -framework Foundation
|
||||
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <Security/Security.h>
|
||||
#include <objc/objc.h>
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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() {}
|
||||
+13
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user