credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
121 lines
5.2 KiB
Python
121 lines
5.2 KiB
Python
"""建立 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_KEY(Fernet 格式),加密当前 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())
|