feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管

credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
gouki
2026-09-05 15:54:41 +00:00
parent 2fbb658126
commit d711ad5827
26 changed files with 2529 additions and 35 deletions
+120
View File
@@ -0,0 +1,120 @@
"""建立 MASTER_KEY 密钥托管(Key Escrow,防 key 遗失)
用法:
.venv/bin/python scripts/setup_key_escrow.py # 生成托管,终端显示 RESTORE_KEY
.venv/bin/python scripts/setup_key_escrow.py --key <钥匙> # 回验已有托管是否可解
.venv/bin/python scripts/setup_key_escrow.py --force # 重建(旧钥匙抄本随之作废)
- 生成随机 RESTORE_KEYFernet 格式),加密当前 MASTER_KEY 写入 data/master_key.escrow
- RESTORE_KEY 仅在终端显示一次,请立即抄写到 2~3 个离线位置(密码管理器/纸质/可信家人)
- escrow 随 data/ 备份流转(内容被离线钥匙加密,库泄露也无法还原),建议另复制一份到离线介质
"""
import argparse
import sys
from pathlib import Path
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from cryptography.fernet import Fernet
from app.core import crypto
from app.core.config import settings
BASE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_ESCROW = BASE_DIR / "data" / "master_key.escrow"
README_PATH = BASE_DIR / "data" / "master_key.escrow.README"
README_TEXT = """MASTER_KEY 密钥托管说明(Key Escrow
=====================================
data/master_key.escrow 是用 RESTORE_KEY 加密的 MASTER_KEY 副本,
随 data/ 一起备份。其内容只有离线保管的 RESTORE_KEY 能解开。
MASTER_KEY 遗失时的恢复步骤:
1. 找到离线保存的 RESTORE_KEY(44 字符,建立托管时终端显示过一次)
2. 在 vps-manager 目录执行(仅打印找回结果,先人工确认):
RESTORE_KEY=<你的钥匙> .venv/bin/python scripts/recover_master_key.py
3. 确认无误后回写 .env(自动备份为 .env.bak-pre-recover):
RESTORE_KEY=<你的钥匙> .venv/bin/python scripts/recover_master_key.py --write
4. 重启服务:sudo systemctl restart vps-manager
全部加密数据(密码/API Key/2FA secret)即可正常解密,零迁移。
注意:
- RESTORE_KEY 只离线保存,绝不写入本文件、.env 或数据库
- 重建托管(setup_key_escrow.py --force)后,旧 RESTORE_KEY 抄本即作废,需重新抄写
- 建议把 escrow 文件另复制一份到离线介质,与 .env 分开存放
"""
def _write_readme() -> None:
"""写恢复步骤说明(不含任何钥匙材料)"""
README_PATH.parent.mkdir(parents=True, exist_ok=True)
README_PATH.write_text(README_TEXT, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="建立 MASTER_KEY 密钥托管(Key Escrow")
parser.add_argument("--key", help="已有 RESTORE_KEY:仅回验托管是否可解,不重建")
parser.add_argument("--force", action="store_true", help="强制重建托管(旧 RESTORE_KEY 抄本随之作废)")
parser.add_argument(
"--escrow", default=str(DEFAULT_ESCROW),
help="escrow 文件路径(默认 data/master_key.escrow",
)
args = parser.parse_args()
escrow_path = Path(args.escrow)
# 回验模式:--key 提供时只校验已有托管,不重建
if args.key:
if not escrow_path.exists():
print(f"[托管] 未找到托管文件 {escrow_path}", file=sys.stderr)
return 2
try:
recovered = crypto.recover_master_key(
args.key.strip(), escrow_path.read_text(encoding="utf-8").strip()
)
except ValueError as e:
print(f"[托管] 回验失败:{e}", file=sys.stderr)
return 1
ok = recovered == settings.MASTER_KEY
print(
f"[托管] 回验{'通过' if ok else '不通过'}escrow 可解开,"
f"还原的 MASTER_KEY 与 .env {'一致' if ok else '不一致(.env 已更换 key?)'}"
)
return 0 if ok else 1
if escrow_path.exists() and not args.force:
print(
f"[托管] {escrow_path} 已存在,未重建(重建加 --force;回验已有托管用 --key",
file=sys.stderr,
)
return 2
restore_key = Fernet.generate_key().decode()
escrow = crypto.build_escrow(restore_key)
# 自检:写盘前立即回验,确保托管文件可用(避免生成废档)
if crypto.recover_master_key(restore_key, escrow) != settings.MASTER_KEY:
print("[托管] 自检失败,未写入文件", file=sys.stderr)
return 1
escrow_path.parent.mkdir(parents=True, exist_ok=True)
escrow_path.write_text(escrow + "\n", encoding="utf-8")
escrow_path.chmod(0o600)
if escrow_path == DEFAULT_ESCROW:
_write_readme()
print("[托管] 已建立 MASTER_KEY 密钥托管:")
print(f" escrow 文件:{escrow_path}(权限 600,随 data/ 备份流转)")
print()
print(" RESTORE_KEY(仅此一次显示,请立即抄写保存到 2~3 个离线位置):")
print(f" {restore_key}")
print()
print(" 建议保存位置:密码管理器 / 纸质抄件(防火防潮)/ 可信家人")
print(" 另建议把 escrow 文件复制一份到离线介质(与 .env 分开存放)")
print(" 恢复命令:RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py")
return 0
if __name__ == "__main__":
sys.exit(main())