Published on

安全默认值的方向:keychainAccessible 为什么把"便利"设成了默认、把"安全"留给你

Authors
  • avatar
    Name
    Jack Qin
    Twitter

最危险的一类配置错误,是那种不会让任何东西报错的错误。编译通过、测试全绿、功能照常工作,唯一变化的是一个你看不见的安全属性。expo-secure-storekeychainAccessible 就是这样一个旋钮:选错了,build 不报错、运行也正常,只是用户的密码被悄悄同步到了同一 Apple ID 下的每一台 iOS 设备。

本文不讲某个 helper 怎么写,而是想拆清楚这个旋钮背后的两笔账:一笔是默认值的方向——为什么 Keychain API 把"跨设备便利"设成了默认、把"设备本地安全"留给你显式索取;另一笔是选类的决定变量——为什么决定用哪个 keychainAccessible 的,不是数据有多敏感,而是你在什么时机读它。理解这两笔账,你面对的就不只是一个 Expo API,而是一整类"默认值偏向便利、安全需主动声明"的存储/加密接口。


默认值的方向:便利在前,安全靠你索取

iOS Keychain 的可访问性属性有一个容易被忽略的设计取向:默认行为偏向便利。不传 options 对象时,默认的 keychain 类是 WHEN_UNLOCKED——而这个类是 iCloud Keychain 同步合格的。也就是说,你什么都不做,写进去的 secret 默认就有资格扇出到用户的所有设备。

// ❌ 没传 options
const email = await SecureStore.getItemAsync('auth.remember.email')
const password = await SecureStore.getItemAsync('auth.remember.password')

这段代码没有任何语法或类型错误,跑起来完全正常。但它埋了四宗罪,第一宗就是致命的:没有 keychainAccessible → 默认 WHEN_UNLOCKED → iCloud 同步合格 → 用户密码扇出到同一 Apple ID 下的每台 iOS 设备

这就是默认值方向的代价。要把存储钉成设备本地,必须显式带上 THIS_DEVICE_ONLY 后缀——安全不是默认送你的,是你主动索取的:

const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}

一条硬规则随之而来:给每一个 setItemAsync / getItemAsync / deleteItemAsync 都传 KEYCHAIN_OPTIONS 漏传哪怕一个调用,那个 key 就静默回退到默认 WHEN_UNLOCKED——一个隐藏的安全回归。本仓库没有任何 helper 允许 iCloud 同步;没有 ADR,不要引入。


选类的决定变量:是"何时读",不是"多敏感"

直觉会告诉你"越敏感的数据选越严的类"。但 keychainAccessible 的真正决定变量不是敏感度,而是这个值在什么时机被读取——因为不同的类对应"设备处于什么解锁状态时数据可读",而你的读取时机必须落在那个可读窗口内,否则读出来是 null。

读取发生在…必需的类为什么
冷启动 / 解锁前的后台工作(token、bootstrap 状态)AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLYboot 路径在用户输入 PIN 之前就跑;WHEN_UNLOCKED 会阻塞并返回 null
仅用户活跃的屏幕(登录表单、资料编辑)WHEN_UNLOCKED_THIS_DEVICE_ONLY仍能工作的最严格类。堵住"手机锁着但后台定时器唤醒 App"的攻击面
想要跨设备便利(刻意同步 iCloud)(默认——省略 THIS_DEVICE_ONLY几乎从不正确。 选之前先讨论

这张表的逻辑是:先用"何时读"定下能用的最宽松类,再在能用的前提下取最严格。token 在冷启动 bootstrap 时读(用户还没解锁),所以必须 AFTER_FIRST_UNLOCK——选更严的 WHEN_UNLOCKED 会让 boot 路径读到 null;记住的凭证在登录表单 mount 时读(用户活跃、设备已解锁),所以可以也应该用更严的 WHEN_UNLOCKED,顺带堵掉"锁屏期后台唤醒"这条攻击面。三个现有先例正好框出这个设计空间:

文件读取时机Keychain 类
lastSiteStorage.tsUI 交互(默认——非敏感 UI 偏好)
tokenStore.ts冷启动 bootstrapAFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY
rememberedCredentials.ts登录表单 mount(用户活跃)WHEN_UNLOCKED_THIS_DEVICE_ONLY

三个配套契约:信封、自愈、失败隔离

围绕 keychain 类,还有三条契约共同构成一个安全且自洽的 helper。它们都遵循同一个三函数形态(纯异步,不用 class、不用 hook):load / save / clear

信封形态(仅 gate 值)。 存储一个带 gate 的值(用户选了开/关,如"记住我")时,load 必须返回信封而非裸 payload:

{
  enabled: boolean
  credentials: T | null
}

因为消费方需要两个独立信号才能正确渲染 UI:gate 是否曾打勾, payload 是否在场。塌缩成 T | null 会丢掉 gate 状态——UI 就分不清"用户选了关"和"用户选了开但 Keychain 返回 null"。非 gate 值(token 对、上次站点 id)返回裸值即可。

不一致状态自愈(仅 gate 值)。 如果 load 读到 enabled === "true" 但任何必需 payload 字段缺失,helper 必须给调用方返回 { enabled: false, credentials: null },并 fire-and-forget(不 await)调 clear() 清掉残留 key。这防止"幽灵记住"——gate 显示 ON 但自动填充是空的。

失败隔离。 每个 SecureStore 调用都包 try/catch,失败时 dev 下 console.warn、生产静默,绝不 throw——即便 keychain 不可用,登录/boot 路径也必须走完。

把这些拼起来就是正确实现:

// SecureStore 前缀:auth.remember.*
const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
}

export async function loadRememberedCredentials(): Promise<{
  enabled: boolean
  credentials: RememberedCredentials | null
}> {
  try {
    const [enabled, email, password] = await Promise.all([
      SecureStore.getItemAsync('auth.remember.enabled', KEYCHAIN_OPTIONS),
      SecureStore.getItemAsync('auth.remember.email', KEYCHAIN_OPTIONS),
      SecureStore.getItemAsync('auth.remember.password', KEYCHAIN_OPTIONS),
    ])
    if (enabled !== 'true') return { enabled: false, credentials: null }
    if (email && password) {
      return { enabled: true, credentials: { email, password } }
    }
    void clearRememberedCredentials() // 自愈
    return { enabled: false, credentials: null }
  } catch (err) {
    if (__DEV__) console.warn('[rememberedCredentials] load failed:', err)
    return { enabled: false, credentials: null }
  }
}

把"看不见的属性"钉成可执行断言

既然选错 keychain 类不会报错,唯一能拦它的就是测试和 review。最关键的一条断言,是对 keychain 类本身的断言:

expect(SecureStore.setItemAsync).toHaveBeenCalledWith('auth.remember.email', 'alice@example.com', {
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
})

没有这条断言,helper 可以悄悄回退到默认 WHEN_UNLOCKED 而其他测试照样全过——因为功能(存取本身)没坏,只有安全属性变了。这正是"build 不报错"类问题必须靠断言守门的原因:你要断言的不是行为,而是那个不影响行为的属性

这里有一个连带的 mock 坑值得记:vitest.setup.ts 的全局 mock 只显式导出了已被用到的 keychain 常量。当 tokenStore.ts 是唯一用 THIS_DEVICE_ONLY 类的 helper 时,mock 里只有 AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY;一个新用 WHEN_UNLOCKED_THIS_DEVICE_ONLY 的 helper 在测试下会读到 undefined,于是上面那条断言报 keychainAccessible: undefined 失败——尽管运行时代码明明传了常量。修法是把该常量作为一行加进 mock(值随便填,运行时从不检查,但镜像已有的 camelCase 字符串约定)。预防:helper 选了 mock 里还没有的类,就在同一次提交里加上。

其余断言构成完整守卫:Roundtrip(save → load)、Clear(save → clear → load 返回空信封)、Overwrite(save(a) → save(b) → load 只返回 b)、不一致状态恢复(gate helper 的四个分支:missing-A、missing-B、empty-string-A 必须调 deleteItemAsyncenabled="false" 必须不调)、失败回退(getItemAsync throw → load resolve 出空、不外抛)。用全局 mock 并在 beforeEach__resetSecureStoreMock()不要引入 per-test 的 vi.mock("expo-secure-store", ...)——它会遮蔽全局 mock 并破坏 __reset 不变式。

文件头注释是强制的。 没有中央注册表——每个存储 owner 文件顶部声明它的 SecureStore 前缀,文件头注释就是注册表


可迁移的那一层

抛开 Expo 和 iOS Keychain 的具体 API,真正可迁移的认知有两条。

很多加密/存储接口的默认值偏向便利而非安全,所以"安全"是一个必须在每个调用点重复声明的姿态,而不是一次性的全局开关。 Keychain 的 WHEN_UNLOCKED 默认、很多 SDK 的默认日志级别、ORM 的默认软删除——它们的共性是"不显式收紧就默认放宽"。对这类接口,漏写一处就是一个隐藏回归,而且因为不报错,只能靠"每个调用点都传显式 options"的纪律 + 一条针对该属性的断言来守。

当一个错误不改变行为、只改变某个不可见属性时,测试必须直接断言那个属性。 行为测试在这类问题上是全绿的安慰剂;唯一有效的守卫是把那个看不见的属性(keychain 类、TLS 版本、权限范围)拎出来做成显式断言。设计安全敏感的封装时,先问一句:如果有人把这个属性悄悄改回不安全的默认值,我的测试会红吗?