# 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
**纯本地密码与 Token 保险库 —— 数据只在你自己的手机里**
不注册账号,不连云,不同步。整个保险库是一个 AES-256-GCM 认证加密容器,
主密码经 Argon2id 派生密钥;`vault.bin` 被拷走、被云盘自动备份、被别的 App 翻到,
拿到的也只是一串乱码。只有你亲手点「测试」的那一刻,某一个 Token 才会离开这台手机。









---
## ✨ 特性
**保险库本体**
- 🔐 **单文件加密容器**:`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** · 钥匙归你,密文归锁 🔐