Files
vps-manager/docs/goal-spec-credentials-vault.md
T
gouki d711ad5827 feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
2026-09-05 15:54:41 +00:00

277 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Goal Spec:凭据库(密码 + 2FA)与账号体系联动
> 供 goal 机制执行的规格说明。执行顺序:M0 → M1 → M2 → M3(M4 待用户圈选后启动)。
## 1. Goal Objective(可复制的目标摘要)
为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(录入需校验码验证);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。
**同时建立 MASTER_KEY 密钥托管(M0,最先落地)**:系统生成随机 RESTORE_KEY(离线抄写保存 2~3 份),用其加密 MASTER_KEY 生成 `master_key.escrow` 随 data/ 备份流转;MASTER_KEY 遗失时运行一次性恢复脚本输入 RESTORE_KEY 找回并回写 .env,全量密文零迁移恢复,日常运行零接触 escrow、零新增网络接口。
本地 pytest + uvicorn 端到端验证截图后提交,再由 Gitea → cc1 timer 自动部署并做生产验证。
## 2. 领域模型决策(已与用户确认)
1. **凭据粒度 = 站点 × 登录方式**。同一 gmail 注册 A~F 六个网站 = 六条 credential,邮箱只是 `username` 的取值,重复出现是常态而非冗余。
2. **密码相同不共享**。A/B 站都是 `xxx` 也各存一份密文(改密逐站发生,禁止联动耦合)。
3. **授权登录(OAuth/SSO)是一条正常记录**`login_type='oauth'` + `oauth_provider='google'` 等,`password` 为空,仍占一条便于检索回忆。
4. **2FA 挂在 credential 上**(登录凭据的一部分,非独立孤岛);账号体系通过关联的凭据使用 2FA。
5. **唯一事实源 = credentials 表**`accounts.login_password_encrypted` 迁移后清空、停止写入;Account 的 `login_user` 字段保留(非机密,供展示);`api_config_encrypted`SDK 用)留在 Account 不动。
6. **账号与凭据解耦生命周期**:删除账号不影响其凭据条目(凭据库独立留存);账号侧新建/改密自动同步到凭据。
7. **TOTP 不引第三方库**`app/core/totp.py` 自实现 RFC 6238HMAC-SHA1 + Base326 位 / 30s 步长),原因:零 SSH 部署链路不重装依赖,避免 `update.sh` 缺包导致自毁;用 RFC 6238 附录 B 官方向量做 pytest 锚定。
8. `site` 显示平台中文名(迁移时 join providers),无平台记录时回退 platform 原文 / "未分类"。
9. **MASTER_KEY 防遗失 = Key Escrow(托管),而非"后补万能钥匙"**Fernet 单钥设计下 key 遗失后补的钥匙解不开旧密文,恢复能力必须预先建立(见 §3)。
## 3. MASTER_KEY 密钥托管(M0,最先落地)
### 3.1 设计决策
- **问题**Fernet 单钥架构下 MASTER_KEY 遗失 = 全量密文不可解;其中 2FA secret 无法像密码一样逐站重置,损失不可逆。
- **方案(Key Escrow**
1. 系统生成随机 `RESTORE_KEY`Fernet key 格式,44 字符 urlsafe base64);
2. 用 RESTORE_KEY 加密当前 MASTER_KEY,得 escrow token`v1:` 前缀 + Fernet token),写入 `data/master_key.escrow`
3. escrow 随 data/ 备份流转(其内容被 RESTORE_KEY 加密,库/备份泄露也解不开);RESTORE_KEY **只离线保存**:用户抄写 2~3 份(密码管理器 / 纸质 / 可信家人);
4. MASTER_KEY 遗失时:运行 `scripts/recover_master_key.py`,输入 RESTORE_KEY → 解密 escrow 找回 MASTER_KEY → 写回 .env → 重启服务,全量密文可解、零迁移。
- **恢复钥匙形态**:系统生成随机强钥匙(已确认),不引入口令派生(防弱口令拖库风险)。
- **恢复入口**:一次性 CLI 脚本(已确认),零新增网络接口/攻击面;日常运行不读取 escrow。
- **边界**RESTORE_KEY 自身遗失 = escrow 失效 → 缓解手段是离线多副本抄写;不做 Shamir 秘密拆分(单用户规模过度设计)。
- **托管对象是 key 本身而非用户数据**:escrow 泄露最坏影响 = 需轮换 MASTER_KEY,不直接暴露任何业务密文(还要同时拿到离线 RESTORE_KEY 才有意义)。
### 3.2 实现清单
- `app/core/crypto.py` 增加(不动现有 encrypt/decrypt 与 lru 缓存):
- `_fernet_from_key(key: str) -> Fernet`:按给定 key 构造独立实例(不走 MASTER_KEY 缓存)。
- `build_escrow(restore_key: str) -> str`:读 `settings.MASTER_KEY`,返回 `'v1:' + Fernet(restore_key).encrypt(master_key_bytes)`
- `recover_master_key(restore_key: str, escrow: str) -> str`:解析 `v1:` 前缀并解密,失败抛错(钥匙错误/文件损坏)。
- 新增 `scripts/setup_key_escrow.py`
- 生成 RESTORE_KEY`Fernet.generate_key()`);
- 立即用 build_escrow + recover_master_key 回验自检;
- 写入 `data/master_key.escrow`(权限 0600);幂等:文件已存在且回验通过则跳过并提示,`--force` 才重建;
- 终端**仅此一次**打印 RESTORE_KEY,附保存建议(3 个离线位置),随后清屏提示已保存到 .env 旁说明文件(`data/master_key.escrow.README`,含步骤简述、不含钥匙)。
- 新增 `scripts/recover_master_key.py`
- `--key <RESTORE_KEY>` 必填(也支持环境变量 `RESTORE_KEY`,避免 shell 历史残留);
-`data/master_key.escrow``recover_master_key`;失败提示"恢复钥匙错误或 escrow 损坏"
- 默认仅打印找回的 MASTER_KEY`--write` 则备份 `.env``.env.bak-pre-recover` 后回写 MASTER_KEY 行,提示重启服务。
- `.gitignore`:确认 `data/` 已忽略(escrow 绝不进 git);M0 落地时手动把 escrow 文件复制一份到离线备份介质。
### 3.3 M0 验收门槛
1. 本地运行 setup 脚本生成 escrow,终端显示 RESTORE_KEY(留存截图一次后即离线保存)。
2. **丢失演练**:备份 `.env` → 用错误 MASTER_KEY 启动,验证真实数据解密失败 → 运行 recover(不带 --write)找回原 key → 回写 .env → 重启 → 既有真实账号密码可正常解密查看。
3. escrow 文件权限 0600、未纳入 git、已复制离线备份。
## 4. 数据模型
### 4.1 新表 `credentials`
`app/models/credential.py`,挂 assets.db
| 列 | 类型 | 说明 |
|---|---|---|
| id | int PK | |
| site | str, index | 站点/服务名(如 "GitHub"、"阿里云"),迁移自平台名 |
| username | str, index, nullable | 登录用户名/邮箱 |
| login_type | str, default 'password' | password / oauth / other |
| oauth_provider | str, nullable | 授权来源:google / apple / github / wechat 等 |
| password_encrypted | str, nullable | Fernet 密文;oauth 为空 |
| otp_secret_encrypted | str, nullable | Fernet 密文,存 base32 secret(不存 otpauth URI,可随时重建) |
| url | str, nullable | 登录页地址(可选跳转) |
| note | str, nullable | 备注 |
| created_at / updated_at | datetime | 与 Asset 同款 utcnow 模式 |
- 不建 `(site, username)` 唯一约束(SQLite 对 NULL 不友好 + 同站多账号合法);服务层创建时提示重复但允许继续。
- 2FA 展示所需信息均从 secret 派生:`issuer=site``account=username`,导出时可重建 otpauth URI。
### 4.2 `accounts` 加列
`Account` 增加 `credential_id: Optional[int] = Field(default=None, foreign_key="credentials.id", index=True)`
### 4.3 相关文件
- `app/database.py``init_db``asset_models` 加入 `Credential``_migrate_assets_db` 增加迁移函数(见 §5)。
- `app/main.py``include_router(credentials.router)`
## 5. 幂等迁移(_migrate_assets_db 内)
```text
_migrate_credentials():
1. accounts 表存在时:若无 credential_id 列 → ALTER TABLE accounts ADD COLUMN credential_id INTEGER
2. 建表/加列后执行 backfill(幂等条件:credential_id 已非空则跳过):
遍历 accounts 中 login_password_encrypted 非空 且 credential_id IS NULL 的行:
site = providers.nameplatform=slug 匹配)→ 否则 platform → 否则 '未分类'
username = COALESCE(login_user, name)
login_type = 'password'
password_encrypted = 原密文原样搬入(不解密再加密,避免中间态暴露)
credential_id = 新行 id
搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
3. 为 credentials 建索引 ix_credentials_site / ix_credentials_usernameIF NOT EXISTS
```
**执行前必须备份**`cp data/assets.db data/assets.db.bak-pre-vault`(本地与生产各自执行一次,人工确认)。
**回滚**git revert 后执行逆迁移(credential.password_encrypted 写回 account.login_password_encrypted 并解除关联)——单用户数据量小,直接在 sqlite3/脚本内完成,仅在需要时编写。
## 6. 后端改动清单
### 6.1 新增 `app/core/totp.py`(无依赖)
- `b32decode(secret)`:容错去空格/补 `=`
- `totp_at(secret_b32, ts=None)``(code6, period_left_seconds)`RFC 6238 标准实现。
- `verify(secret_b32, code, window=1)`:允许 ±1 步进(录入校验容时钟偏差)。
- `parse_otpauth_uri(uri)``{secret, issuer, account}`:支持用户直接粘贴 `otpauth://totp/...` 录入(仅取 secret 入库,issuer/account 仅回填建议)。
- `random_secret()``secrets` 生成 20 字节 → Base32(供"生成随机密钥"按钮,可选)。
### 6.2 新增 `app/schemas/credential.py`
```text
CredentialBase: site, username, login_type='password', oauth_provider, url, note
CredentialCreate: CredentialBase + password(明文, 可空) + otp_secret(可空) + otp_code(可空)
CredentialUpdate: 全字段可空;password: None=不改,''=清除,非空=重加密(沿用 Account 惯例)
CredentialRead: id, site, username, login_type, oauth_provider, url, note,
has_password, has_otp, account_name(可空,反向关联的账号名), created_at, updated_at
OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校验
```
### 6.3 新增 `app/services/credential_service.py`
- `list_credentials(session, q, login_type, has_otp)`:搜索 site/username/note;反向查 account 填充 account_name。
- `create_credential``(site, username)` 重复时允许但返回提示(校验在路由层给 warning 字段或直接允许);password 用 `crypto.encrypt`otp 见 §6.5。
- `update_credential / delete_credential`
- `reveal_password(credential_id)``{username, password}`(同账号 reveal 语义)。
- `bind_otp / unbind_otp / current_otp`(见 §6.5)。
- 依赖注入、404、加密方式与 `account_service` 完全同构。
### 6.4 新增 `app/routers/credentials.py`prefix `/api/credentials`
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `` ?q=&login_type=&has_otp= | 读 | 列表 |
| POST | `` | require_api_key | 创建(含可选 otp 绑定) |
| PUT | `/{id}` | require_api_key | 更新(含改密/清密) |
| DELETE | `/{id}` | require_api_key | 删除凭据(不动任何账号/资产) |
| GET | `/{id}/password` | require_api_key | 解密查看密码 |
| GET | `/{id}/otp` | require_api_key | `{code, expires_in}`;未绑定 404 |
| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`verify 通过才存 |
| DELETE | `/{id}/otp` | require_api_key | 解绑 2FA |
- `GET /otp` 返回 `Cache-Control: no-store`(动态码防缓存)。
- 所有写操作与账号/资产路由一致使用 `require_api_key`(内网放行规则自动生效)。
### 6.5 2FA 绑定与生成规则
- **绑定**`secret` + 用户当前 6 位码 `code` 一起提交 → 服务端 `verify(secret, code)` 通过才加密入库,失败 400「验证码不匹配」(防止 secret 手误录入成废条目,沿用 GitHub 添加 TOTP 模式)。
- **生成**`current_otp` 用 `totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询)。
### 6.6 改造 `app/services/account_service.py`
- `_to_read``has_login_password` 改判 `bool(account.credential_id)``AccountRead` 增 `credential_id`、`has_otp` 两字段(schema 同步加,均默认 False/None,兼容老前端)。 `has_otp` 需查关联 credential —— list 时批量取 `credential_id in (...)` 后填充。
- `create_account`:保存 `login_password` 时(解密前不落库):
- 同一事务内创建 credential(site=平台显示名规则同迁移,username=COALESCE(login_user,name)),记 `account.credential_id`。
- `update_account`:改名/换平台时同步 credential.site/username(保持引用一致);`login_password` 非空 → upsert 到关联 credential(没有则新建并回填 id);`''` → 清 credential.password`None` → 不动。
- `reveal_password`:改从关联 credential 解密(未关联 → 404 同旧语义)。
- `delete_account`**不删** credential(生命周期解耦,凭据库独立留存)。
### 6.7 `AccountRead` schema 扩展
```text
credential_id: Optional[int] = None
has_otp: bool = False
```
## 7. 前端改动清单
### 7.1 导航与路由(`app.js` + `static/js/views/vault.js` 新建)
- `NAVS` 增加 `{ key: 'vault', label: '凭据库', icon: '🔐' }`(放在「平台」之前)。
- `VIEW_MAP` 增加 `vault: 'vault-view'`。
- 新建 `static/js/views/vault.js``VaultView` 组件。
- `index.html` 在 modals.js 之前加入 `<script src="/static/js/views/vault.js?v={{ asset_version }}">`。
- `app.js` 注册 `app.component('vault-view', VaultView)`。
### 7.2 凭据库视图(vault.js)行为
- **头部**:搜索框(site/username/note,复用资产页搜索样式);「+ 新增」。
- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••`、`Google 授权` 等、`其他`);`2FA` 蓝色徽章;有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。
- **展开详情**:url(可点击跳转)、note、关联账号(若有,`account_name` 显示"来自平台账号")、操作:显示/复制密码、编辑、删除。
- **2FA 区块**`has_otp` 才显示):大字 6 位码 + 倒计时秒(`expires_in` 驱动本地 1s tick,归零重新请求 `GET /otp`+ 复制验证码 + 解绑。
- **空态**:提示"从平台账号迁移的凭据会自动出现在这里"。
### 7.3 凭据表单(扩展 modals.js 或新建 CredentialModal
- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_providerlogin_type=oauth 时出现,含常用建议 google/apple/github/wechat + 自由输入)、passwordtype=password,编辑时留空=不改)、note。
- **2FA 区块**secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+「获取当前验证码」辅助说明 + 当前 code 输入(6 位,必须填,提交时服务端校验)→ 校验失败原地报错不落库。
- 保存后刷新列表 + `loadAccounts()`(账号关联展示需要)。
### 7.4 账号体系联动(modals.js / accounts-view 相关)
- 账号列表(accounts-view-modal)与账号弹窗的"查看密码"按钮逻辑不变(后端已重定向)。
- 若账号关联凭据且有 2FA:查看密码弹窗旁增加「获取验证码」按钮 → 调 `GET /api/credentials/{credential_id}/otp` 展示动态码(复用展开详情的 2FA 组件逻辑,独立小函数)。
- 平台页账号入口保持不动。
### 7.5 store.js / api.js
- `store.credentials` + `loadCredentials()``loadAll()` 并联加入。
- CRUD 函数与 credentialModal state(复制 saveAccount 的 saving 锁模式防双击)。
- `Fmt.LOGIN_TYPE_LABELS = { password: '密码', oauth: '授权登录', other: '其他' }` 与 oauth provider 徽章色。
## 8. 交互打磨(M4,占位待用户圈选后启动)
用户对交互尚有保留意见,此处列候选独立小改动,启动前由用户勾选范围:
1. 全局 toast「已复制」取代旧式 alert/瞬时无反馈(可做成 store.toast + 简单组件)。
2. OTP 环形/进度条倒计时视觉 + 复制即消失反馈。
3. `/` 快捷键聚焦当前页搜索框(移动端不启用)。
4. 账号查看密码弹窗合并进凭据详情(统一交互路径,减少两套弹窗)。
5. 快速录入:从平台账号弹窗一键「补全 2FA」跳凭据表单并预填 site/username。
6. 二维码录入(需要引入前端 QR 解码库/后端解码,成本高,默认不做,除非用户点名)。
7. 双击行快速复制密码、长按移动端复制。
> M4 独立成 goal/任务执行,不阻塞 M0–M3。
## 9. 测试与验收
### 9.1 pytesttests/ 新增 test_totp.py、test_credential_migration.py
- RFC 6238 附录 B 官方向量(secret = ASCII "12345678901234567890" 的 Base32T=59 / 1111111109 / 1111111111 / 1234567890 / 2000000000 / 200000000008 位转 6 位 = 取模 1000000 补零)逐条断言 code 与 expires_in。
- `parse_otpauth_uri` 解析标准 URI 与缺 issuer 容错。
- 迁移幂等:造含密码账号 → 跑两次 backfill → 只产生一条 credential、account 密码字段已清空、第二次不重复建。
- 绑定校验:错 code 拒绝、对 code 落库、`current_otp` 用固定时间戳种子断言稳定性。
- escrow 回验:build_escrow 后 recover_master_key 能还原 MASTER_KEY;错误钥匙抛错。
### 9.2 本地端到端(必做,提交前)
1. `.venv/bin/uvicorn app.main:app --port 8000`(项目根 vps-manager/ 下,load_dotenv 自动读 .env)。
2. 先完成 M0 丢失演练(见 §3.3)。
3. 手动建测试凭据(含 oauth 条目 + 2FA 绑定真实 secret,用手机验证器/在线 TOTP 工具对码)。
4. 浏览器逐项验证:列表搜索、复制密码、OTP 倒计时刷新、账号弹窗查密码仍可用、新增账号自动生成凭据条目。
5. 迁移干跑:确认生产量级账号行全部迁移、无残留明文。
6. 截图留存(本地验证 + 产物截图惯例),再 git commit + push。
### 9.3 生产验证(cc1 自动部署后)
- git push origin main → cc1 timer 拉取更新(deploy.sh 若再出现 git push 卡住,按经验手动 `git push origin main` 干预)。
- 生产环境先备份 `data/assets.db`,确认 /health 新 commit。
- 生产也执行 M0 setup(生成新 escrow + RESTORE_KEY 离线保存)。
- 端到端抽查:老账号密码可见、2FA 录入→对码成功→删除测试数据。
- 确认 VERSION/commit 展示与静态资源版本(`?v=` mtime 机制自动生效,无需手动)。
## 10. 里程碑与验收门槛
| 里程碑 | 内容 | 完成门槛 |
|---|---|---|
| M0 | Key Escrow 托管(crypto 扩展 + setup/recover 脚本 + escrow 文件) | 丢失演练通过(§3.3);pytest escrow 回验绿 |
| M1 | 模型/schema/totp/迁移/credential service+router/account_service 改造 + pytest | 全绿;迁移干跑幂等 |
| M2 | vault 视图 + CredentialModal + 账号联动 + store/api | 本地端到端截图验证通过 |
| M3 | commit → push → cc1 生产验证(含生产 escrow 建立) | 生产抽查通过,无回归 |
| M4 | 交互打磨(圈选后另行启动) | 独立 |
## 11. 风险与注意
- 迁移前**必须**备份 assets.db(本地与生产各一次),迁移函数幂等可重跑。
- 迁移/回滚脚本只搬密文、不落明文,避免中间态暴露。
- RESTORE_KEY 一旦遗失 escrow 即失效 → 落地时强制抄写 3 个离线位置,并保存一份 escrow 到离线介质;`.env` 与 escrow 尽量分介质存放。
- 旧前端页面在部署后需刷新(HTML no-cache + asset_version 已保证)。
- credentials 是个人全量密码仓库:`reveal` / `otp` 接口均走 require_api_keyTailscale 内网直连放行策略不变;前端不落任何明文到 localStorage(仅展示期内存持有)。
- 依赖零新增:requirements.txt / .env 均不改(MASTER_KEY 已存在;RESTORE_KEY 只作为脚本参数/环境变量出现,不常驻配置)。
- 本 spec 为凭据与 2FA 的完整闭环;账号的 `api_config_encrypted`SDK 密钥)不在本次范围(后续可演进为"API 凭据"视图,不阻塞)。