# 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 6238(HMAC-SHA1 + Base32,6 位 / 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`,避免 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.name(platform=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_username(IF 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 之前加入 `