用 Tauri 2 给 Mac 应用接 Touch ID,签名公证都做了,还是 -34018

我写了很多年前端,Rust 和 macOS 原生开发是做 OwnKeep 的时候才开始碰的。OwnKeep 是我业余做的一个 Mac 离线加密保险箱,Tauri 2 + Rust + React。Rust 那部分基本是我和 AI 一起写出来的,它给方案、写代码,我一行一行看,看不懂就一直问到懂为止。没看懂的代码我不合。

做 Touch ID 解锁的时候,这个习惯帮了大忙,因为这块几乎每一步都跟我想的不一样。

先看报错。签名、公证都做完了,Gatekeeper 打开没有任何提示,结果在设置里一开 Touch ID:

text 复制代码
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 随便造数据测。对前端出身的我来说这个很亲切,跟把业务逻辑从组件里抽出去是一回事。

rust 复制代码
/// 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<()>

存进钥匙串

rust 复制代码
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 复制代码
<?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:

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:

bash 复制代码
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。源文件写对了,不代表签进去的也对:

bash 复制代码
codesign --display --entitlements - --xml YourApp.app | plutil -p -
test -f YourApp.app/Contents/embedded.provisionprofile && echo "profile ok"

签名之外,代码也有问题

排查签名那段时间,我把 biometric.rs 从头又审了一遍。为了看清楚卡在哪一步,还留了一个直接调真实系统实现的诊断测试:

rust 复制代码
#[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 源码才能确认:

  1. 这个 key 要开 OSX_10_15 feature 才会暴露出来:

    toml 复制代码
    security-framework = { version = "3", features = ["OSX_10_15"] }
  2. 存、读、删共用一个函数拼查询。以后谁改了其中一处,另外两处也跟着变:

    rust 复制代码
    fn protected_item_query() -> PasswordOptions {
        let mut options = PasswordOptions::new_generic_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
        options.use_protected_keychain(); // kSecUseDataProtectionKeychain
        options
    }
  3. 删除要用 delete_generic_password_options(protected_item_query())。delete_generic_password 在内部自己拼查询,没法指定钥匙串。

  4. 最阴的一个。ItemSearchOptions::ignore_legacy_keychains() 在没开 feature 时照样能编译,只是什么也不做,查询悄悄回到旧钥匙串。use_protected_keychain() 没开 feature 就不存在,会直接编译报错。所以我故意让后者充当这个 feature 的守卫,谁把 feature 删了,构建立刻挂掉,不用等到用户机器上才发现。

让测试盯着

这种回归 CI 发现不了,CI 上既没有指纹也没有签名。我的办法是把查询里允许出现的 key 全部列出来:

rust 复制代码
/// 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。这个字段不是机密,读它不会弹框:

rust 复制代码
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,按这个顺序查:

  1. 跑的是打包好的 .app,还是 tauri dev / Cargo 可执行文件
  2. Contents/embedded.provisionprofile 在不在
  3. 最终签名里的 com.apple.application-identifier 和 com.apple.developer.team-identifier 值对不对
  4. 签名和 profile 的 App ID 前缀、Team ID、Bundle ID 是否一致
  5. profile 是不是对应错了 App ID 或证书,有没有过期
  6. 打包之后有没有被重新签名,把 entitlements 弄丢了
  7. 代码里每个查询都带了 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 版。

github.com/ownkeep-app...

如果你在 Tauri 里也折腾过钥匙串或者签名,欢迎在评论区交流。

相关推荐
木子欢儿7 小时前
国内 macOS/Linux 安装 rust
linux·运维·服务器·macos·rust
Kapaseker8 小时前
Rust 一行 entry() 干掉五行 if/else
rust
红尘散仙18 小时前
谁说终端只能敲键盘?我用 Rust 给小说阅读器加上了鼠标
前端·后端·rust
unconditional2 天前
我拆了 sdkm 的回滚设计:每个写操作,都提前写好了翻车预案
rust·开源
数字幽灵2 天前
开源!我用 Rust 手搓了一个 Agent 原生的鸿蒙逆向分析平台
rust
Kapaseker3 天前
Rust 不用写 for 循环?看我怎么写!
rust
奶油话梅糖3 天前
打开项目 0.9 秒、内存只有 IDEA 的 1/12:OpenIDEA 用 Rust 把 IntelliJ IDEA 重造了一遍
java·rust·github·intellij-idea
Vex2une4 天前
Google Picasa 精神续作:Rust 重写的离线本地相册工具‌
rust·图片管理·picasa·马良·本地照片管理·重复图片检测·maliang
unconditional5 天前
我拆了 sdkm 的自定义 SDK 机制:所谓“内置”,就是官方提前帮你填好的表
rust·开源