跳到主要内容

密钥存储与身份验证

BitTool Signer 将每个钱包的助记词(以及 BIP39 passphrase,如果设置了的话)保存在各自独立的加密文件中。打开该文件需要两个相互独立的密钥:一个由钱包密码派生,另一个由 iOS 仅在该设备上保管。

Android 版

本页以 iPhone 版为准进行说明。BitTool Signer Android 版采用相同的两层加密,并使用 Android 自身的密钥存储;见Android 版。

保险库​

每个钱包对应 App 私有目录 Library/Application Support/BitToolSigner/ 下的一个文件 Vaults/<UUID>.bsvault。创建过程如下:

  1. 用 SecRandomCopyBytes 生成新的 32 字节盐值。
  2. 派生一个 256 位的密码密钥:PBKDF2-HMAC-SHA256,600,000 次迭代(CommonCrypto)。
  3. 在密码密钥下用 AES-256-GCM 加密机密数据(助记词 + passphrase,JSON 格式):使用新的 96 位 nonce 和 128 位认证标签。
  4. 再在一个随机的 256 位设备密钥下,用 AES-256-GCM 对该密文加密一次。
  5. 将设备密钥以 kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly 存入钥匙串(Keychain),并关闭 iCloud 同步。如果手机没有设置锁屏密码,钱包创建会失败;不存在更弱的回退方案。
  6. 以原子方式写入文件,启用完全文件保护、仅所有者可访问的权限,并将其排除在备份之外。
vault file
├── header (not secret, but authenticated)
│ format version 2 · KDF parameters · salt · wallet ID · name · created at
└── AES-GCM(device key,
AES-GCM(PBKDF2-SHA256(password, salt, 600 000),
{ mnemonic, passphrase }))

两层 AES-GCM 都将存储的头部字节作为关联数据进行认证,并加上一个域标签前缀(com.bittool.signer.vault.v2/<layer>)。对头部的任何字节进行编辑、重新编码或重新排序,都会导致解密失败。

钱包密码用于加密本地保险库。它不是 BIP39 passphrase,对地址没有任何影响。规则:8–64 个字符,至少包含大写字母、小写字母、数字和符号中的三类,不含控制字符;严格按输入原样使用(不做裁剪或规范化)。

保险库不是备份

设备密钥永远不会离开手机,并且不纳入备份,因此复制出去的保险库文件无法在其他地方打开。丢失手机、删除 App 或移除锁屏密码都可能导致保险库无法读取。手写的助记词(以及 passphrase)是唯一的备份。

为什么用 PBKDF2 而不是 Argon2id?​

Argon2id 是内存困难型(memory-hard)算法,作为密码 KDF 会更强。当前版本使用 CommonCrypto 的 PBKDF2(采用 OWASP 目前针对 HMAC-SHA256 推荐的 600,000 次迭代),以避免引入原生密码学依赖。KDF 参数保存在带版本号的头部中,因此将来迁移时无需更改文件布局。

Opus 5 安全评审将这一选择评为信息级(BTS-OP5-03),而不是一项弱点:由于设备密钥之下还有第二层加密,只复制了保险库文件的人根本无法开始穷举密码。该评审建议在未来的保险库格式中采用 Argon2id,作为纵深防御,以防设备密钥也被攻破。

密码尝试​

每个钱包的密码失败次数记录在一个经过加密和认证的 .attempts 文件中。计数会在耗时的密钥派生之前保存,因此强制退出 App 也无法将其重置。

失败次数下次尝试前的等待时间
1–4无
52 秒
64 秒
nmin(2n−4 秒, 5 分钟)

输入正确的密码会重置计数器。App 绝不会因为密码错误而删除钱包。这是应用层面的控制,而不是硬件计数器:能够回滚 App 文件的人可以重置它。真正的密码学保护来自 KDF。

无需密码的公开数据​

地址、配对二维码和活动记录无需密码即可查看,因为它们只需要公钥。在创建或导入钱包时,引擎会一次性派生一个公开索引,并将其保存在保险库之外:

  • 六个 Bitcoin 账户节点(BIP84、BIP86、BIP49、BIP44,以及 BIP48 原生和嵌套多签)
  • EVM 和 TRON 账户节点,以及 TRON 地址 0–99
  • 三个 Solana 公钥(参见派生路径)

公开索引文件带有一个 HMAC-SHA256,其密钥是钥匙串中另一个独立的随机密钥。如果验证失败(有人编辑过它,或它属于另一个钱包),App 会将其视为不存在,并要求输入一次密码以重建。因此,能够写入 App 文件的攻击者也无法替换成他们自己的地址。

授权​

  • 不存在“已解锁”状态,也没有全局会话。每个签名任务都需要输入该钱包的密码,显示助记词、修改密码和删除钱包也是如此。
  • App Lock(应用锁,可选,使用 Face ID)只保护对 App 的访问,从不授权签名。开启 App Lock 后,App 会在冷启动时以及进入后台 120 秒后锁定;计时所用的时钟在手机休眠时也会继续计数。
  • App 进入后台时,会立即丢弃已输入的密码、风险确认以及正在显示的助记词。在 App 切换器中以及屏幕录制期间,隐私遮罩会遮住屏幕内容。

内存清理​

BIP39 种子在原生代码中计算,临时缓冲区会用 memset_s 覆写。每个涉及机密数据或不可信输入的操作,都在一个用完即弃的全新 JavaScriptCore 上下文中运行。这缩短了机密数据在内存中的存留时间,但 Swift 和 JavaScript 的字符串都无法保证被清除,因此 App 并不声称能够完全清零。

Android 版​

保险库采用相同的两层加密和相同的经认证头部。不同之处在于设备密钥存放的位置,以及平台如何保护这款 App。

  • 密码层。PBKDF2-HMAC-SHA256,600,000 次迭代,在原生代码中计算(处理器支持时使用 ARMv8 SHA-256 指令),然后进行 AES-256-GCM 加密,每次加密都使用新的 12 字节随机数和新的 32 字节盐值。
  • 设备层。每个钱包一把在 Android Keystore 内部生成、永远无法导出的 AES-GCM 密钥,手机支持时位于 StrongBox 中。保险库要求设置屏幕锁定。
  • 文件。保存在 App 的私有存储中,以原子方式写入,不包含在云备份和设备间迁移中。
  • 密码尝试。与 iPhone 版一样,在耗时的密钥派生之前按钱包计数;计数器损坏时会拒绝所有尝试。
  • 授权。每一次签名都需要钱包密码。App 锁(可选,指纹或面部识别)永远不能授权签名。所有窗口都不会出现在截图、录屏和最近任务中;在 Android 12 及以上版本会隐藏其他 App 的悬浮层;当有其他窗口遮挡时,“签名”按钮和风险确认会拒绝点按。
  • 内存。机密保存在用完即关闭并擦除的容器中,即使操作被取消也是如此;每个涉及机密的操作都在全新的 QuickJS 运行时中执行。助记词通过 App 自带的键盘输入,任何输入法都看不到。JVM 无法保证每个副本都被擦除,因此 App 在这里同样不宣称能完全清零。

Opus 5.5 评审列出了与这一层相关的两项低危发现:在 Android 8–11 上发生 Keystore 错误后,用于验证公开钱包数据的密钥可能被重新生成(BTS-13);两个辅助密钥没有检查屏幕锁定(BTS-14)。参见安全评审。