AfanasievN/capacitor-token-vault

GitHub: AfanasievN/capacitor-token-vault

一款零运行时依赖的 Capacitor 插件,利用 iOS Keychain 和 Android Keystore 为跨平台应用提供安全且配置合理的令牌(Token)存储方案。

Stars: 0 | Forks: 0

# capacitor-token-vault [![npm 版本](https://img.shields.io/npm/v/capacitor-token-vault.svg)](https://www.npmjs.com/package/capacitor-token-vault) [![CI](https://static.pigsec.cn/wp-content/uploads/repos/cas/39/39faa54be350a1dab8afd3b2fb8c1c83e4d9cff84abfef2374d19a18053687c4.svg)](https://github.com/AfanasievN/capacitor-token-vault/actions/workflows/ci.yml) [![CodeQL](https://static.pigsec.cn/wp-content/uploads/repos/cas/66/66fc5a886813e0eabae6e75e35f1c1a59c73d1396a3032a20be2bad0a84f2a92.svg)](https://github.com/AfanasievN/capacitor-token-vault/actions/workflows/codeql.yml) [![运行时依赖](https://img.shields.io/badge/runtime%20dependencies-0-brightgreen)](#零依赖及其重要性) [![Capacitor](https://img.shields.io/badge/Capacitor-8%2B-119EFF)](https://capacitorjs.com) [![平台](https://img.shields.io/badge/platforms-iOS%20%7C%20Android%20%7C%20Web-lightgrey)](#各平台的实际行为) [![许可证](https://img.shields.io/badge/license-MIT-blue.svg)](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, 凭证存储, 安全存储, 操作系统检测, 移动开发