我写了很多年前端,Rust 和 macOS 原生开发是做 OwnKeep 的时候才开始碰的。OwnKeep 是我业余做的一个 Mac 离线加密保险箱,Tauri 2 + Rust + React。Rust 那部分基本是我和 AI 一起写出来的,它给方案、写代码,我一行一行看,看不懂就一直问到懂为止。没看懂的代码我不合。
做 Touch ID 解锁的时候,这个习惯帮了大忙,因为这块几乎每一步都跟我想的不一样。
先看报错。签名、公证都做完了,Gatekeeper 打开没有任何提示,结果在设置里一开 Touch ID:
biometric unlock failed: A required entitlement isn't present.
对应的系统错误码是 -34018,errSecMissingEntitlement。
Touch ID 在 OwnKeep 里管什么
后面那些钥匙串配置都跟这个设计有关,所以先交代一下。
保险箱是一个加密文件,用一把随机的 256 位 DEK 加密。DEK 本身不落盘,文件里存的是被别的 key 加密过的 DEK。最开始有两份:
- 主密码经 Argon2id(256 MiB、t=3、p=4)派生出 KEK_master,包一份
- 恢复码(12 个词)经 HKDF-SHA256 派生出 KEK_recovery,再包一份
Touch ID 是后来加的第三份。随机生成一个 KEK_biometric 存进钥匙串,让系统负责“按了指纹才给”。解锁时钥匙串把它交出来,Rust 用它解开第三份 DEK,往后的流程跟输密码一模一样。
我最在意的是,Touch ID 出什么问题,最坏也就是让用户输一次密码,绝对不能把人锁在外面。所以定了几条规矩:
- 只能在已解锁时开启,手里得先有 DEK 才能包第三份
- KEK_biometric 只在本机,备份里没有它,恢复备份后 Touch ID 是关的
- 指纹一增删它就作废,得重新开
envelope.rs 里这部分全是纯函数,不碰文件也不碰系统,可以用 proptest 随便造数据测。对前端出身的我来说这个很亲切,跟把业务逻辑从组件里抽出去是一回事。
/// Wrap the DEK a third time under a biometric KEK (held in the OS Keychain).
/// Requires the caller to already hold the DEK (an unlocked vault).
pub fn wrap_biometric(container: &mut Container, dek: &Key, kek_biometric: &Key) -> Result<()>
存进钥匙串
let access_control = SecAccessControl::create_with_protection(
Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly),
AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
)?;
BIOMETRY_CURRENT_SET 对应“指纹一变就作废”,ThisDeviceOnly 对应“不离开本机”。kSecAttrSynchronizable 我特意没设,设了会同步到 iCloud 钥匙串。
crate 用的是 security-framework 3.x。判断这台 Mac 能不能用指纹,是用 objc2-local-authentication 调 LAContext 的 canEvaluatePolicy。
代码就这么点。然后就是 -34018。
去后台找 Touch ID 权限,没有
看到 entitlement 这个词,我第一反应是去 Apple Developer 后台给 App ID 勾一个 Touch ID 相关的 capability。翻了一圈,没有。
后来读 Apple 的两篇技术说明(TN3137 讲 Mac 钥匙串,TN3125 讲 provisioning profile)才弄明白。带生物识别访问控制的条目只能存在 data-protection 钥匙串里,就是 iOS 一直在用的那种。它按 access group 隔离,access group 从签名里的 entitlement 来。代码里不传 kSecAttrAccessGroup、也没声明 keychain-access-groups 的话,默认 group 就是 com.apple.application-identifier 的值,格式是 TEAMID.com.your.bundleid。
问题在于 com.apple.application-identifier 是受限 entitlement,光写在 entitlements 文件里不算数,得有 provisioning profile 给它背书。macOS 上 profile 的位置是 YourApp.app/Contents/embedded.provisionprofile。
我自己琢磨了一下为什么要这么麻烦。service 和 account 只是条目的名字,谁都能起一样的。如果钥匙串只认名字,随便一个程序起个同名 service 就能把 key 读走。所以它认的是签名里的 Team ID、Bundle ID,再加上给它们背书的 profile。想通之后觉得很合理,只是一点都不直观。
当时还搞混过两件事:
- 公证不给钥匙串权限,hardened runtime 也替代不了 entitlement。反过来,用 Apple Development 证书签名、嵌开发 profile 的包,不公证也能测 Touch ID。
- 只有自己一个 app 用的话,不需要 Keychain Sharing,也不需要
keychain-access-groups。加了反而会改变默认 group,已经开启 Touch ID 的用户就读不到原来的条目了。
Tauri 这边的配置
entitlements 文件:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"https://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.application-identifier</key>
<string>APP_ID_PREFIX.com.your.bundleid</string>
<key>com.apple.developer.team-identifier</key>
<string>TEAM_ID</string>
</dict>
</plist>
手写的 plist 不会展开 Xcode 的 $(AppIdentifierPrefix),要填真实的值。App ID 前缀和 Team ID 在新账号上一般是同一个,但 Apple 没保证过,我是从 profile 里解出来对照着填的。
tauri.conf.json:
{
"bundle": {
"macOS": {
"hardenedRuntime": true,
"entitlements": "./YourApp.entitlements",
"files": {
"embedded.provisionprofile": "./signing/YourApp_Developer_ID.provisionprofile"
}
}
}
}
files 会在 Tauri 签名之前把 profile 拷到 Contents/embedded.provisionprofile。签完名再往包里放东西,签名就失效了,所以必须走这个配置。profile 在后台 Profiles → Distribution → Developer ID 里建,App ID 选显式的那个。
我不太信配置文件,构建前后各查一次。构建前看 profile:
security cms -D -i src-tauri/signing/YourApp_Developer_ID.provisionprofile > /tmp/profile.plist
/usr/libexec/PlistBuddy -c 'Print :ApplicationIdentifierPrefix:0' /tmp/profile.plist
/usr/libexec/PlistBuddy -c 'Print :TeamIdentifier:0' /tmp/profile.plist
/usr/libexec/PlistBuddy -c 'Print :ExpirationDate' /tmp/profile.plist
构建后看最终签进 .app 的 entitlements。源文件写对了,不代表签进去的也对:
codesign --display --entitlements - --xml YourApp.app | plutil -p -
test -f YourApp.app/Contents/embedded.provisionprofile && echo "profile ok"
签名之外,代码也有问题
排查签名那段时间,我把 biometric.rs 从头又审了一遍。为了看清楚卡在哪一步,还留了一个直接调真实系统实现的诊断测试:
#[test]
fn diag_store_key_reports_the_real_error() {
let store = SystemBiometricKeyStore::new();
println!("DIAG available={} has_key={}", store.is_available(), store.has_key());
let key = crate::crypto::random_key().unwrap();
match store.store_key(&key) {
Ok(()) => {
println!("DIAG STORE OK");
let _ = store.delete_key();
}
Err(e) => println!("DIAG STORE ERR: {e}"),
}
}
测试二进制没有 profile,所以写入肯定失败,这在意料之中。它的用处是确认 available=true,传感器那边没问题,卡住的是写入。
审下来发现一个跟签名无关的问题:macOS 上的 SecItem* 默认操作旧的文件型钥匙串,也就是 login.keychain 那套,它存不了生物识别访问控制。要用 data-protection 钥匙串,增、查、删每个查询都得带上 kSecUseDataProtectionKeychain = true。当时的代码一个都没带。
也就是说,就算签名全对,条目也会被写到一个根本存不了它的地方。
改的时候 security-framework 有几个细节,都得去翻 crate 源码才能确认:
-
这个 key 要开
OSX_10_15feature 才会暴露出来:security-framework = { version = "3", features = ["OSX_10_15"] } -
存、读、删共用一个函数拼查询。以后谁改了其中一处,另外两处也跟着变:
fn protected_item_query() -> PasswordOptions { let mut options = PasswordOptions::new_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); options.use_protected_keychain(); // kSecUseDataProtectionKeychain options } -
删除要用
delete_generic_password_options(protected_item_query())。delete_generic_password在内部自己拼查询,没法指定钥匙串。 -
最阴的一个。
ItemSearchOptions::ignore_legacy_keychains()在没开 feature 时照样能编译,只是什么也不做,查询悄悄回到旧钥匙串。use_protected_keychain()没开 feature 就不存在,会直接编译报错。所以我故意让后者充当这个 feature 的守卫,谁把 feature 删了,构建立刻挂掉,不用等到用户机器上才发现。
让测试盯着
这种回归 CI 发现不了,CI 上既没有指纹也没有签名。我的办法是把查询里允许出现的 key 全部列出来:
/// class (kSecClass), svce (kSecAttrService), acct (kSecAttrAccount),
/// nleg (kSecUseDataProtectionKeychain)
const EXPECTED_QUERY_KEYS: [&str; 4] = ["acct", "class", "nleg", "svce"];
#[test]
fn protected_query_targets_data_protection_keychain_and_nothing_else() {
let mut keys: Vec<String> = protected_item_query()
.query
.iter()
.map(|(key, _)| key.to_string())
.collect();
keys.sort();
assert_eq!(keys, EXPECTED_QUERY_KEYS);
}
nleg、svce、acct 是 Security.framework 内部用的四字符 key。nleg 我猜是 no legacy 的意思。
断言的是整个集合,不光看 nleg 在不在。哪天有人(包括 AI)顺手加了 sync,把 key 送上 iCloud,cargo test 也会挂。
不过搜索那条路径没法这么测,ItemSearchOptions 不暴露它拼好的查询,只能靠真机手动验。
刷新个状态,弹出一个指纹框
功能跑通以后,又冒出一个体验问题。设置页和锁屏要显示“Touch ID 已开启”,我一开始的想法很前端:去钥匙串查一下有没有这个条目,只要属性,不要数据。
结果在 macOS 上,对带访问控制的条目做这种查询,也可能要求验证。于是锁屏每刷新一次状态,就弹一次指纹框。
最后状态不查钥匙串了,改看容器文件里有没有 wrapped_biometric。这个字段不是机密,读它不会弹框:
pub fn status_from<S: BiometricKeyStore + ?Sized>(store: &S, wrap_present: bool) -> BiometricStatus {
let available = store.is_available(); // 只问传感器,不碰条目
BiometricStatus { available, enrolled: available && wrap_present }
}
代价是,如果条目已经失效(比如用户改了指纹),要等他真点解锁时才会发现,失败后回退到输密码。我觉得可以接受。为了防止以后有人又把 has_key() 加回状态查询,写了个测试,给一个在 has_key() 里直接 panic! 的假 store。
钥匙串操作我抽成了一个 BiometricKeyStore trait。开启、关闭、解锁的逻辑全部用内存里的假实现来测,跟在 Vitest 里 mock fetch 是一个思路。真正调系统的只有薄薄一层,放在 #[cfg(target_os = "macos")] 后面,在真机上手动验。
开发时怎么测
tauri dev 下开启 Touch ID 一定是 -34018,因为没有 .app,profile 没地方放。不知道这点的话,很容易以为是代码写错了。
本地要测,就打一个 .app,用 Apple Development 证书签名,嵌 Mac App Development profile(测试机要加进设备列表),不用公证。发布时换成 Developer ID 证书和对应的 profile,再走公证。
正式版、开发版、测试二进制我用了三个不同的 service 名:...biometric、...biometric.dev、...biometric.test。本地开发和跑测试都碰不到正式版的 key。
我现在的排查顺序
再遇到 -34018,按这个顺序查:
- 跑的是打包好的 .app,还是
tauri dev/ Cargo 可执行文件 Contents/embedded.provisionprofile在不在- 最终签名里的
com.apple.application-identifier和com.apple.developer.team-identifier值对不对 - 签名和 profile 的 App ID 前缀、Team ID、Bundle ID 是否一致
- profile 是不是对应错了 App ID 或证书,有没有过期
- 打包之后有没有被重新签名,把 entitlements 弄丢了
- 代码里每个查询都带了
kSecUseDataProtectionKeychain没有,OSX_10_15开了没有
另外,带 Touch ID 的版本一旦发出去,App ID 前缀、Team ID、Bundle ID 就别再动了。同一个 team 里续签证书一般没事;换 team、改 Bundle ID 或者 ad-hoc 签名,已经开启的用户就读不到原来的条目了。
签名、公证、Universal 2 构建和 DMG 公证的完整步骤,我写在了 code-signing.md 里,比这篇细。Touch ID 的代码在 biometric.rs,信封加密在 envelope.rs。
OwnKeep 是 GPL-3.0 开源的。密码、证件、合同这些放在自己 Mac 上的一个加密文件里,不联网,不用注册账号,界面支持中文。它没做过第三方安全审计,目前也只有 macOS 版。
如果你在 Tauri 里也折腾过钥匙串或者签名,欢迎在评论区交流。