# KeyKeep **Repository Path**: cc123hh/KeyKeep ## Basic Information - **Project Name**: KeyKeep - **Description**: 本地优先的手机端密码与 Token 保险库(Android)。数据不上云,AES-256-GCM 加密,支持 DeepSeek / Z.ai Token 检测。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-11-05 - **Last Updated**: 2026-11-06 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README
KeyKeep 图标 # KeyKeep **纯本地密码与 Token 保险库 —— 数据只在你自己的手机里** 不注册账号,不连云,不同步。整个保险库是一个 AES-256-GCM 认证加密容器, 主密码经 Argon2id 派生密钥;`vault.bin` 被拷走、被云盘自动备份、被别的 App 翻到, 拿到的也只是一串乱码。只有你亲手点「测试」的那一刻,某一个 Token 才会离开这台手机。 ![release](https://img.shields.io/badge/release-v0.2.2-3b82f6) ![apk](https://img.shields.io/badge/APK-3.3%20MB-22c55e) ![kotlin](https://img.shields.io/badge/Kotlin-2.0.21-7f52ff) ![compose](https://img.shields.io/badge/UI-Jetpack%20Compose-4285f4) ![minSdk](https://img.shields.io/badge/minSdk-26-9ca3af) ![platform](https://img.shields.io/badge/平台-Android%208.0%2B-34a853) ![crypto](https://img.shields.io/badge/加密-AES--256--GCM%20%2B%20Argon2id-0E6B44) ![tests](https://img.shields.io/badge/单测-42%20passed-0ea5e9) ![license](https://img.shields.io/badge/license-MIT-f59e0b)
--- ## ✨ 特性 **保险库本体** - 🔐 **单文件加密容器**:`filesDir/vault.bin` 一个文件装下所有条目,不是 SQLCipher、不引 NDK, 解锁时全量解密进内存——条目量小(< 1 万),搜索直接走内存,零 native 依赖 - 🔑 **三把钥匙开同一把锁**:主密码 / 恢复码 / 指纹分别包装同一个数据密钥(DEK), **改主密码只重包装 DEK,密文一个字节都不动** - 🧂 **Argon2id 慢哈希**:BouncyCastle 纯 Java 实现,m=19 MiB / t=2 / p=1 起步, 参数写进容器头可演进;本机解锁净耗时 289 ms - 💾 **双副本自愈**:写入走 `tmp → fsync → rename` 原子替换,另留一份 `vault.bak` 副副本; 主文件校验不过时自动回退副副本,不让你因为一次异常关机丢掉整库 **解锁与防护** - 🖐️ **指纹解锁**:BiometricPrompt + CryptoObject(`BIOMETRIC_STRONG`), `setInvalidatedByBiometricEnrollment(true)`——增删指纹后旧的包装密钥立即失效 - ⏳ **自动锁定**:退后台立即 / 1 分钟 / 5 分钟 / 从不,默认 1 分钟; 弹指纹框期间不会误锁 - 🚦 **失败限速**:连续错 5 次起指数退避(15s → 30s → 60s…,上限 5 分钟) - 📵 **全程防截屏**:`FLAG_SECURE`,截图与录屏都是黑屏 **条目与使用** - 🗂️ **三种条目**:密码(用户名/密码/网址/备注)、Token/API Key(Provider/环境/Key)、安全笔记; 类型页签 + 关键词搜索 + 标签 - 🎲 **随机密码生成器**:长度与字符集可勾选,每类至少出一个 + Fisher-Yates 打乱,SecureRandom - 📋 **剪贴板守卫**:复制敏感字段后定时清空(默认 60s,可配);Android 12L+ 标记 `EXTRA_IS_SENSITIVE`,系统不弹预览;期间你自己复制了别的东西就绝不碰你的剪贴板 **Token 检测(只做两家)** - 🧪 **DeepSeek**:可用性 / 可用模型列表 / 余额(多币种) - 🧪 **Z.ai Coding Plan**:额度与用量(术语只说「额度/用量」,不叫余额) - 🛡️ **不信 HTTP 200**:Z.ai 出错时也返回 200,适配层一律以 `body.success` / `body.code` 判成败,并把这条写进了代码注释 - ⏱️ **60s 限频**:同一条目一分钟内不重复打第三方接口 - 🚫 **可以整个关掉**:设置里打开「仅保存,不联网测试」后,检测入口直接提示,不发任何请求 **备份** - 📦 **`.vault` 加密容器导出/导入**:与主库同构但 magic 不同(`KKVB`), 备份体**用独立的随机 DEK** + 单独的备份密码包装——备份密码泄露不会连累主库 - 🔁 **导入即整体替换**:用于换机或恢复;导入要求先解锁,且备份密码忘了就真作废 ## 🧱 加密是怎么搭的 ``` 主密码 ─Argon2id─▶ KEK₁ ─┐ 恢复码 ─Argon2id─▶ KEK₂ ─┼─▶ DEK(随机 256 bit)══AES-256-GCM══▶ 条目密文 指纹 ─Keystore─▶ KEK₃ ─┘ 落盘 vault.bin(主)+ vault.bak(副) 写入 tmp → fsync → rename,主坏副好自动恢复 出网 只有点「测试」这一条 App ──HTTPS──▶ DeepSeek / Z.ai,只发该条目的 Key ``` 容器布局(小端,字段定长、无分隔符): ``` 头部 magic "KKVL" 4B · version 1B · kdfAlgo 1B · 参数 12B salt 16B · recSalt 16B · bodyNonce 12B · flags 1B wrapPw 60B · wrapRec 60B · wrapKs 60B(启用指纹时才有) 正文 bodyLen 4B · body = GCM(DEK, JSON 条目) AAD = magic + version + bodyNonce AAD 只绑 magic + version + bodyNonce,于是: - body 与每个 wrap 的完整性各自由自己的 GCM tag 保证 - 防「A 库的头配 B 库的正文」,同时保证改主密码不必重加密全部条目 - 备份容器 magic 换成 KKVB,两种文件互相当对方解会直接认证失败 ``` ## 🚀 快速开始 **下载**:(APK 约 3.3 MB,Android 8.0 / API 26+) > 目前是**调试密钥签名**的测试包,装的时候系统会提示「未知来源」,仅供测试与自用。 **第一次用**,三步: 1. **创建保险库** —— 设置主密码(这一步决定一切,忘了没有后门)→ 系统生成一次性恢复码, 要你抄下来并确认 → 可顺手开启指纹 2. **加条目** —— 「+」新建:密码 / Token / 安全笔记;生成器可以一键回填 3. **(可选)测 Token** —— 打开某条 Token,点「测试」;想彻底断网就把 设置 →「仅保存,不联网测试」打开 **升级注意**:从 v0.2.1 或更早的**带指纹**容器升级到 v0.2.2 时,旧容器会被判为损坏—— 那是旧版漏拼 IV 的已知 bug(见 [QA.md](QA.md)),需要重建保险库。纯主密码容器不受影响。 ## 🛡️ 安全边界 说清楚防什么、不防什么,比喊「银行级加密」有用。 **防住了** - 文件被拷走:`vault.bin` / `.vault` 都是认证加密,没有口令就是乱码;篡改任意一字节 → 解密失败 - 手机被借走:自动锁定 + 防截屏 + 指纹变更即失效 - 备份泄露:备份有独立密钥与独立密码,主库密码不因此暴露 - 日志泄露:自研 KeyLog 只记事件与元信息,**绝不记录主密码、恢复码、条目内容、API Token** **防不住**(本地 App 的物理边界) - 设备被 root 且攻击者能实时读进程内存 - 系统级键盘记录,或装了无障碍/录屏类恶意软件(防截屏挡不住相机拍屏幕) - 主密码本身太弱 —— Argon2id 只提高暴力破解成本,不能变弱口令为强口令 - 你主动点「测试」时,那一个 Key 会以 TLS 发到对应服务商 —— 这是联网检测的固有代价, 所以这个开关是可选、可关的 ## 🧠 关键设计决策
为什么不用 SQLCipher,用单文件 AEAD 容器(点击展开) 需求是「条目 < 1 万、离线、无云同步」。SQLCipher 要引 native 库、多一层构建与 ABI 适配, 换来的是「增量查询」,而这里根本用不上——解锁时全量解密进内存做搜索,几毫秒的事。 于是自建容器格式:与备份容器共用同一套 AEAD 原语,**纯 Kotlin、零 native 依赖**, 格式自己写清、能加版本号演进,也不算被第三方库的格式绑死。
改主密码为什么不用重新加密全部条目(点击展开) 密钥层级:条目只用 DEK 加密,DEK 被三把 KEK 分别包装(password / recovery / keystore)。 改主密码 = 用新密码派生新 KEK₁、把 DEK 重新包装一次,**密文不动**。 代价是 AAD 不能包含 KDF 盐或 wrap——否则两者一变,整个 body 的认证就失效了。 所以 AAD 只绑 `magic + version + bodyNonce`,这三个字段恰好都不随密码变化。
备份为什么用独立 DEK(点击展开) 如果备份直接复用主库 DEK,那么「备份密码」一旦泄露,攻击者拿到的就是能解主库的钥匙; 反过来,备份被爆破也等于主库被爆破。 现在备份体有自己的随机 DEK,只被备份密码派生的 KEK 包装:备份密码泄露只影响那一份备份。
为什么写一次容器要 serialize → parse 自检(点击展开) 真机上曾连续四次出现「容器损坏」,最后定位到 Keystore 分支回调里漏拼了 `cipher.iv`, 落盘 48B 而不是 60B,整个头部错位。文件本身从没被外部改过——是**写入端**出了错。 教训直接变成防线:写入前先 serialize 再 parse 一遍,结构对不上就拒绝落盘; 再加 `fsync`(f2fs 的异步回写会弄脏未同步的小文件)与 `vault.bak` 副副本。
为什么 Z.ai 的错误响应也要当成功处理(点击展开) 实测发现 Z.ai 配额接口在鉴权失败时**返回 HTTP 200**,错误信息藏在 body 里 (`{"code":401,"success":false,...}`)。只看状态码会把失败当成功,直接展示一片空白。 所以适配层统一以 `body.success` / `body.code` 判定,HTTP 状态码仅作参考。 成功结构目前按 `data` 节点的 key:value 平铺展示——不猜字段名,宽容渲染,拿到真 Key 也不用改代码。
## 🏗️ 构建与测试 ```bash # 本机沙箱 ~/.gradle 只读,Gradle home 必须指到工程内 export GRADLE_USER_HOME=$PWD/.gradle-home # debug 包 ./gradlew :app:assembleDebug --no-daemon # 单测(42 例) ./gradlew :app:testDebugUnitTest --no-daemon # 静态检查(CI 同款) ./gradlew ktlintCheck lintDebug --no-daemon # 出包:构建 → 对齐 → 签名 → dist/KeyKeep-<版本>-<时间>.apk # 顺带自动 +1 版本号并提交 ./scripts/release.sh ``` - 工具链:JDK 17+,Android SDK(platforms 35 / build-tools 35.0.0),纯 Kotlin 无 NDK - 技术栈:Kotlin 2.0.21 / AGP 8.7.3 / Gradle 8.9 / Compose(Material3,BOM 2024.12.01) - 目标平台:minSdk 26 / targetSdk 35 / compileSdk 35 - 版本名(`versionName`):百进制自增,每次打包 patch +1、满 100 进位(同 Dfy) - 版本码(`versionCode`):`10000 + major*10000 + minor*100 + patch`,与版本名同步、严格单调 - CI:`.github/workflows/ci.yml`(ktlint → lint 闸门 → 单测 → assembleDebug) > lint 闸门是真实抓过 bug 的:它揪出 7 处「在 composition 里读 `StateFlow.value`」—— > 那是不会触发重组的真缺陷,不是风格问题。 ## 📁 目录结构 ``` app/src/main/java/com/keykeep/vault/ ├── MainActivity.kt 唯一 Activity(根导航:创建 / 解锁 / 保险库 / 设置) ├── crypto/ Argon2id KDF、AEAD、容器格式、DEK 三通道包装、备份容器 ├── data/ 设置(DataStore)+ 条目模型 + VaultStore 状态机 ├── provider/ Token 检测适配层(DeepSeek / Z.ai / 限频) ├── security/ 剪贴板守卫、密码生成器、解锁限速 ├── debug/ KeyLog 内存环形日志(不含任何敏感内容) └── ui/ Compose 页面 + components/ + theme/(双主题) ``` ## 📚 文档 | 文档 | 内容 | |---|---| | [AGENTS.md](AGENTS.md) | 架构、约定与逐条决策记录(含实测数据) | | [QA.md](QA.md) | 踩过的坑:现象 / 原因 / 解法 / 复查 | | [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) | 需求书全文与 12 条验收标准 | | [design/icon/](design/icon/) | 图标源文件(SVG)与 1024 PNG | ## ⚠️ 已知限制 - **不做云同步 / 多设备**:刻意的。同步就要有服务端,有服务端就有新的泄露面 - **不做系统级自动填充**:需求明确划出范围,避免把它变成一个「随时向任意 App 吐密码」的组件 - **APK 是调试签名**:只适合测试与自用,不适合正式分发 - **全量内存解密**:条目上万后搜索会变笨,需要改成增量结构(当前量级没这个需求) - **Z.ai 成功响应未用真 Key 实测**:解析按 `data` 平铺兼容,等有真 Key 再补实测 - **指纹要求 Class 3**:仅支持 Class 2 的设备会自动降级为「无可用指纹」,只用主密码/恢复码 - **AVD 的 UI 自动化有死区**:部分按钮在模拟器里点不动,涉及真机专属链路的结论以真机为准 ## 🛣️ Roadmap - [ ] 换成正式图标(当前 App 内还是 K 描边占位,本仓库的 SVG 可以直接导出替换) - [ ] 正式签名密钥 + 更正式的分发方式 - [ ] 更多 Provider(沿「只测你保存的那一个 Key」的原则扩展) - [ ] 备份提醒与导入前的内容预览(防止「恢复成一份旧库」而不自知) - [ ] 多机型验证(当前主要在 K70E 真机 + AVD 上验证) ## 📄 许可证 本项目以 [MIT](LICENSE) 许可开源 —— 可自由使用、修改、分发,保留版权声明即可。 第三方依赖保留各自许可证:BouncyCastle(MIT-style)、OkHttp / AndroidX / kotlinx.serialization(Apache-2.0)。 ---
**KeyKeep** · 钥匙归你,密文归锁 🔐