AfanasievN/capacitor-token-vault
GitHub: AfanasievN/capacitor-token-vault
一款零运行时依赖的 Capacitor 插件,利用 iOS Keychain 和 Android Keystore 为跨平台应用提供安全且配置合理的令牌(Token)存储方案。
Stars: 0 | Forks: 0
# capacitor-token-vault
[](https://www.npmjs.com/package/capacitor-token-vault)
[](https://github.com/AfanasievN/capacitor-token-vault/actions/workflows/ci.yml)
[](https://github.com/AfanasievN/capacitor-token-vault/actions/workflows/codeql.yml)
[](#零依赖及其重要性)
[](https://capacitorjs.com)
[](#各平台的实际行为)
[](https://github.com/AfanasievN/capacitor-token-vault/blob/main/LICENSE)
**在 Capacitor 应用中,你把 refresh token 存放在哪里?** 别放在 `localStorage` 里。这是一个仅专注于单一任务的**安全
存储**:它将 token 保存在每个平台所提供的最安全的存储中 —— **iOS
Keychain**、**Android Keystore**、Web 端的 `sessionStorage` —— 仅通过五个方法访问,并且具有**零
运行时依赖**。
[快速开始](#quick-start) · [集成模式](https://github.com/AfanasievN/capacitor-token-vault/blob/main/docs/INTEGRATION.md) ·
[AI 提示词](https://github.com/AfanasievN/capacitor-token-vault/blob/main/docs/AI.md) · [兼容性](#compatibility) ·
[各平台行为](#what-each-platform-actually-does) · [威胁模型](#threat-model) ·
[常见问题](#faq) · [设计说明](https://github.com/AfanasievN/capacitor-token-vault/blob/main/docs/DESIGN.md)
```
await TokenVault.setToken({value: refreshToken});
const {value} = await TokenVault.getToken(); // string | null
await TokenVault.clear(); // logout
```
## 为什么需要另一个存储插件
存储 refresh token 是 Capacitor 中最常见的安全问题,而通常的答案往往经不起推敲:
| 常见选择 | 出了什么问题 |
| --- | --- |
| 原生应用中的 `localStorage` / `sessionStorage` | app sandbox 内的一个明文文件 —— 在已 root 或越狱的设备上可读 |
| `@capacitor/preferences` | 普通的 `UserDefaults` / `SharedPreferences`;它不是加密存储,也未曾声称自己是 |
| 通用安全存储插件 | 通常无法控制 **Keychain accessibility** 或 **iCloud sync**,因此 token 可能会随着备份转移到其他设备上 |
| `androidx.security` `EncryptedSharedPreferences` | 自 `security-crypto:1.1.0-alpha07` 起已被弃用(主线程性能问题,OEM keyset 损坏) |
这个插件只做一件事,具有**固定且有据可查的安全态势**,调用者不会意外削弱它。
## 快速开始
```
npm install capacitor-token-vault
npx cap sync
```
插件无法替你完成的两个安装步骤:
**1. 保持原生构建的精简。** Capacitor 会链接它找到的每一个插件;请固定 allowlist:
```
// capacitor.config.ts
const config: CapacitorConfig = {
includePlugins: ["capacitor-token-vault"],
};
```
**2. 将 Android 存储排除在云备份之外**,这样加密的 blob 就不会转移到其他
设备上(密钥永远不会离开设备,因此最多只是无法解密 —— 但将其发布到云端是
毫无意义的):
```
```
想要开启备份?请改用 `dataExtractionRules` 为 `token_vault.xml` 设置排除项。**iOS 什么也
不需要** —— `WhenUnlockedThisDeviceOnly` 项目永远不会包含在备份中。
## 工作原理
```
your code
│ setToken / getToken / removeToken / clear / getCapabilities
▼
registerPlugin("TokenVault") picks the implementation, 13 lines
│
┌─────┴───────────────┬────────────────────────────┐
▼ ▼ ▼
iOS bridge Android bridge web
TokenVaultPlugin TokenVaultPlugin TokenVaultWeb
│ argument │ argument │
│ plumbing only │ plumbing only │
▼ ▼ ▼
TokenVault.swift TokenVault.kt sessionStorage
SecItemAdd/Copy Keystore key + AES-GCM (memory fallback)
│ │ │
▼ ▼ ▼
Keychain SharedPreferences browser storage
WhenUnlocked (ciphertext only) secure: false
ThisDeviceOnly key never leaves TEE
```
Bridge 文件仅用于参数管道处理,因此平台代码可以在没有 Capacitor 的情况下进行单元测试。
你的应用与一个 API 通信,并且从不根据平台进行分支 —— 它是根据 `getCapabilities()` 进行分支的。
总计:约 690 行代码,每个原生平台约 100 行。其价值不在于代码量 —— 而在于传递了哪些
标志。请参阅 [docs/DESIGN.md](https://github.com/AfanasievN/capacitor-token-vault/blob/main/docs/DESIGN.md)。
## 集成到你的项目中
有三种形式,取决于你的应用是如何构建的 —— 直接在 auth service 中使用、放在你自己的
port/DI 之后,或者包装在带有 single-flight 刷新的 HTTP 拦截器中。完整的可用示例:
**[docs/INTEGRATION.md](https://github.com/AfanasievN/capacitor-token-vault/blob/main/docs/INTEGRATION.md)**。
使用 AI 代理来连线?**[docs/AI.md](https://github.com/AfanasievN/capacitor-token-vault/blob/main/docs/AI.md)** 中有可以直接复制粘贴的提示词,可适应你的
架构,并声明了代理往往会弄错的安全规则(持久化 access token、在网络故障时让
用户退出登录、根据平台而不是 capabilities 进行分支)。可以读取 URL 的代理可以从 [`llms.txt`](https://github.com/AfanasievN/capacitor-token-vault/blob/main/llms.txt) 开始。
## 兼容性
| | 支持情况 |
| --- | --- |
| Capacitor | **8.x**(`@capacitor/core` 是一个 peer dependency,`>=8.0.0`) |
| iOS | 14.0+ · SPM(为 CocoaPods 项目包含了一个 podspec) |
| Android | API 23+ (Android 6) · compileSdk 36 · JDK 21 |
| Web | 任何带有 `sessionStorage` 的浏览器;在没有它时会降级为内存存储 |
| Node(工具) | 20, 22, 24 — 已在 CI 中测试 |
| 模块格式 | ESM **和** CommonJS(`import` 和 `require` 均可工作) |
不支持 Capacitor 6/7:该插件使用了为
Capacitor 6+ 引入的 `CAPBridgedPlugin` 注册,并且仅在 8 上进行了测试。如果你需要较旧的
大版本,请提交一个 issue —— 原生
代码本身没有特定版本的依赖。
## 用法
```
import {TokenVault} from "capacitor-token-vault";
// write / read / delete the default slot ("refresh")
await TokenVault.setToken({value: refreshToken});
const {value} = await TokenVault.getToken(); // null when empty — not an error
await TokenVault.removeToken();
// more than one secret? named slots
await TokenVault.setToken({value: deviceToken, name: "device"});
// logout: every slot this plugin owns
await TokenVault.clear();
// branch on what you actually got, not on the platform name
const caps = await TokenVault.getCapabilities();
if (!caps.persistent) {
// web tab or private mode: do not promise "stay signed in"
}
```
Slot 名称匹配 `^[a-zA-Z0-9._-]{1,64}$`。Rejection 带有 `code`:
`UNAVAILABLE` | `INVALID_ARGUMENT` | `STORAGE_FAILURE` —— 并且绝不包含 token 值。
### API
| 方法 | 结果 |
| --- | --- |
| `getCapabilities()` | `{backend, secure, persistent, hardwareBacked}` |
| `setToken({value, name?})` | 写入或覆盖一个 slot |
| `getToken({name?})` | `{value: string \| null}` |
| `removeToken({name?})` | 幂等删除 |
| `clear()` | 移除此插件拥有的所有 slot |
## 从其他插件迁移
API 很小,因此迁移就是在首次启动时进行的一次性复制。使用旧插件读取,使用
此插件写入,然后删除旧值:
```
import {Preferences} from "@capacitor/preferences"; // or your current plugin
import {TokenVault} from "capacitor-token-vault";
async function migrateToken(): Promise {
if ((await TokenVault.getToken()).value !== null) return; // already migrated
const {value} = await Preferences.get({key: "refreshToken"});
if (!value) return;
await TokenVault.setToken({value});
await Preferences.remove({key: "refreshToken"}); // stop leaving a plaintext copy
}
```
| 迁移来源 | 注意事项 |
| --- | --- |
| `@capacitor/preferences`, `localStorage` | 旧值是明文 —— 复制后将其删除,如上所述 |
| `capacitor-secure-storage-plugin` | `get`/`set`/`remove` 一一对应;其 iOS 项目位于不同的 Keychain service 下,因此请在迁移窗口期间使用该插件读取它们 |
| `@aparajita/capacitor-secure-storage` | 形式相同;如果你只存储了一个 token,之后你就可以移除该依赖(以及它引入的两个 Capacitor 插件) |
将迁移代码保留一两个版本,然后将其删除 —— 只要代码还在,跳过
多个版本的用户仍然会经过此迁移过程。
## 各平台的实际行为
| 平台 | token 的去向 | 固定参数 |
| --- | --- | --- |
| **iOS 14+** | Keychain, `kSecClassGenericPassword`, service `.token-vault` | `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`(这也是使其不被包含在备份中的原因),`kSecAttrSynchronizable = false`,`kSecUseDataProtectionKeychain = true` |
| **Android 6+ (API 23)** | `SharedPreferences("token_vault", MODE_PRIVATE)` 中的 AES-256-GCM 密文 | 密钥 `capacitor.token-vault.v1` 在 `AndroidKeyStore` 中生成,GCM,无填充,256 位,每次写入使用随机化 IV,无用户认证要求 |
| **Web / PWA** | `token-vault.` 下的 `sessionStorage`,当存储被阻止时使用内存 | 报告 `secure: false`;从不使用 `localStorage` |
为什么做出这些选择,简而言之 —— 完整版在 [docs/DESIGN.md](https://github.com/AfanasievN/capacitor-token-vault/blob/main/docs/DESIGN.md) 中:
- **`WhenUnlockedThisDeviceOnly`** 是唯一的一个 Keychain class,它既要求设备
*已*解锁,又排除了备份/恢复到另一台设备的可能。代价:后台代码无法在
设备锁定时读取 token —— 这没问题,因为刷新操作通常发生在应用使用之后。
- **直接使用 Keystore 而不是 `EncryptedSharedPreferences`** —— 参见上表;约 60 行代码,
没有库生命周期的风险。
- **一个带版本的 key alias**,以便将来的参数变更将变成一个新的别名加上有据可查的
迁移,而不是在实际安装中悄无声息的解密失败。
- **Web 上使用 `sessionStorage` 而不是抛出 `Unavailable` 错误**,以便使用者到处都能获得可用的
行为,而关心此问题的使用者可以读取 `capabilities.secure`。
- **损坏或无法解密的 slot 在每个平台上读取的结果均为“不存在”**:损坏的存储绝不能
阻止用户再次登录。
- **不会返回由先前安装写入的 token。** iOS 在应用被删除时会保留 Keychain 项目,因此
新安装可能会在转售或共享的设备上找到其他人的 token。该
插件将一个标记写入 `UserDefaults` —— 它*确实*会随应用一起被移除 —— 并将没有匹配
标记的 token 视为不存在,将其清除。Android 不需要做任何事:它的存储会随
应用一起消失。
## 常见问题
### 我应该在这里也存储 access token 吗?
通常不。将 access token 保留在内存中,并且仅持久化 refresh token:内存中的短生命周期
token 根本无法从磁盘上被盗取。如果你确实需要第二个 secret,则有命名的 slot 可用。
### 我可以存储一对 JSON 吗?
可以 —— `setToken({value: JSON.stringify(pair)})`。该插件刻意不解析你的 payload;
它存储的是一个不透明的字符串。
### 为什么在 Web 端 `secure` 报告为 `false`?
因为没有浏览器具有安全的存储。该插件使用 `sessionStorage`(以标签页为作用域,这是最小的
窗口)而不是 `localStorage`,并且告诉你真相,以便由你决定向用户承诺什么。
### 应用重新安装后会怎样?
不会继承任何东西:参见上面的注释。请做好让用户重新登录的准备。
### 它支持生物识别(Face ID / 指纹)来读取 token 吗?
v1 版本不支持 —— 它会改变故障面(注册失效、取消流程),并且属于
session-policy 层。该设计为可选的 `requireUserPresence` 留出了空间,而无需更改存储的
格式;如果你需要它,请提交一个 issue。
### 在 Android 上 `hardwareBacked` 总是 true 吗?
不。它是按密钥向 Keystore 询问的,因此模拟器和带有软件 Keystore 的设备报告为
`false`。请根据该值进行分支判断,而不是想当然。
### 它能与 `require()` 和 Jest (CommonJS) 一起工作吗?
是的 —— 该包同时提供 ESM 和 CommonJS,并且 CI 会同时加载两者。
### 它需要任何权限吗?
不需要。Android manifest 是空的,iOS 也不需要任何 entitlement(没有 Keychain 共享,也没有 iCloud)。
## 威胁模型
**防止:** 在已 root/越狱的设备上的另一个应用或 shell 从磁盘上读取
token;token 在设备备份中留存并在其他地方恢复;iCloud Keychain 同步
将其带到另一台设备。
**不能防止:** 在你的应用内部执行代码 —— WebView 中的 XSS 或恶意的
依赖项可以像你的代码一样准确地调用 `getToken()`。严格的 CSP 和供应链卫生是
这里的控制手段;存储选择只能限制*静态*盗用。完整声明:[SECURITY.md](https://github.com/AfanasievN/capacitor-token-vault/blob/main/SECURITY.md)。
v1 版本中没有生物识别门禁:`kSecAccessControl` / `setUserAuthenticationRequired` 会改变故障
面(注册失效、取消流程),并且属于 session-policy 功能,而不是
存储。该设计为可选的 `requireUserPresence` 留出了空间,而无需更改存储的格式。
## 零依赖,及其重要性
`npm ls --omit=dev --all` 会打印出一个空树,并且 [CI 会在每次推送时断言它](https://github.com/AfanasievN/capacitor-token-vault/blob/main/.github/workflows/ci.yml)。
具体来说:`@capacitor/core` 是一个 **peer** dependency(将其声明为 dependency 就是
将第二个 Capacitor 拉入使用者树的原因);Android 仅针对平台 Keystore
API 进行编译;iOS 仅依赖于 Capacitor;构建仅仅是普通的 `tsc`,因此也没有 bundler 链。
对于一个持有凭证的包来说,每一个传递性依赖都是他人在你的 token 存储中
获得写权限的途径。
## 开发
```
npm install
npm run verify # typecheck + web unit tests + dual (ESM + CJS) build
```
iOS测试会访问真实的 Keychain,因此它们需要一个 iOS 模拟器目标 —— `swift test` 构建用于
macOS,无法满足要求:
```
xcodebuild test -scheme CapacitorTokenVault \
-destination 'platform=iOS Simulator,name=iPhone 16'
```
Android instrumented 测试需要一个设备或模拟器以及一个宿主应用
(`./gradlew connectedAndroidTest`)—— `AndroidKeyStore` 没有 JVM 实现,因此 Robolectric
测试无法证明任何关于关键部分的问题。
诚实地说一下现状:Web 层和打包由 CI 在每次推送时覆盖;原生套件
已经编写完成,但尚未运行(这就是此仓库的第一次 CI 运行将要告诉我们的)。
为 Android 模拟器任务连线是一个[不错的首次贡献](https://github.com/AfanasievN/capacitor-token-vault/blob/main/CONTRIBUTING.md)。
原生套件是安全测试:在 iOS 上,它们断言 accessibility 和 sync 属性;
在 Android 上,断言第二个实例可以解密第一个实例写入的内容,密文因每次写入而异
(随机化 IV),并且明文永远不会出现在存储的值中。
欢迎贡献 —— [CONTRIBUTING.md](https://github.com/AfanasievN/capacitor-token-vault/blob/main/CONTRIBUTING.md) 解释了塑造每次审查的唯一规则:
这个插件保持小巧。
## 许可证
MIT © [AfanasievN](https://github.com/AfanasievN)
标签:Android Keystore, Capacitor, iOS Keychain, 凭证存储, 安全存储, 操作系统检测, 移动开发