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
+116
View File
@@ -0,0 +1,116 @@
"""TOTP 动态验证码(RFC 6238,零依赖自实现)
用于凭据库 2FA:存储 base32 secret,按 30s 步长生成 6 位动态码。
不引第三方库(pyotp)的原因:零 SSH 部署链路不重装依赖,避免 update.sh
缺包导致服务自毁;正确性用 RFC 6238 附录 B 官方向量在 pytest 锚定
tests/test_totp.py)。
"""
import base64
import hashlib
import hmac
import secrets
import struct
import time
from urllib.parse import parse_qs, quote, unquote, urlparse
PERIOD = 30 # 步长(秒),标准值
DIGITS = 6 # 码位数,标准值
def b32decode(secret: str) -> bytes:
"""Base32 解码(容错:去空白、大写化、自动补 = 填充)"""
s = "".join(secret.split()).upper()
return base64.b32decode(s + "=" * ((-len(s)) % 8))
def random_secret() -> str:
"""生成 20 字节随机 base32 secret(供「生成随机密钥」入口)"""
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
def _hotp(key: bytes, counter: int, digits: int = DIGITS) -> str:
"""RFC 4226 HOTPHMAC-SHA1(key, counter) 动态截断 → digits 位十进制码"""
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
code = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
def totp_at(
secret_b32: str, ts: float | None = None, period: int = PERIOD, digits: int = DIGITS
) -> tuple[str, int]:
"""计算指定时刻的 TOTP 码
返回 (code, expires_in)expires_in 为当前码剩余有效秒数(前端倒计时用)。
ts 为 None 时取当前时间。
"""
now = time.time() if ts is None else ts
counter = int(now // period)
left = period - int(now % period)
return _hotp(b32decode(secret_b32), counter, digits), left
def verify(
secret_b32: str,
code: str,
window: int = 1,
ts: float | None = None,
period: int = PERIOD,
digits: int = DIGITS,
) -> bool:
"""校验用户输入的动态码(±window 个步进,容忍时钟偏差)
常量时间比较,防时序旁路。录入绑定时用 window=1 即可。
"""
code = "".join(code.split())
if not code.isdigit() or len(code) != digits:
return False
now = time.time() if ts is None else ts
base_counter = int(now // period)
key = b32decode(secret_b32)
return any(
hmac.compare_digest(_hotp(key, base_counter + off, digits), code)
for off in range(-window, window + 1)
)
def parse_otpauth_uri(uri: str) -> dict:
"""解析 otpauth://totp/Label?secret=...&issuer=...&period=30&digits=6
返回 {secret, issuer, account}Label 形如 "Issuer:account""account"
无 issuer 参数时从 Label 前缀提取。非 TOTP、缺 secret 或非标准参数抛 ValueError
(v1 仅支持 6 位/30s 标准参数,避免录入成功但生成码对不上)。
"""
parsed = urlparse(uri.strip())
if parsed.scheme != "otpauth":
raise ValueError("不是 otpauth:// 链接")
if parsed.netloc.lower() != "totp":
raise ValueError(f"仅支持 TOTP(当前类型:{parsed.netloc}")
params = parse_qs(parsed.query)
secret = (params.get("secret") or [""])[0]
if not secret:
raise ValueError("链接缺少 secret 参数")
period = int((params.get("period") or [PERIOD])[0])
digits = int((params.get("digits") or [DIGITS])[0])
if period != PERIOD or digits != DIGITS:
raise ValueError(f"暂仅支持 {DIGITS} 位/{PERIOD}s 标准参数(当前 {digits} 位/{period}s")
label = unquote(parsed.path.lstrip("/"))
issuer = (params.get("issuer") or [""])[0]
account = label
if ":" in label:
prefix, _, rest = label.partition(":")
account = rest or prefix
if not issuer:
issuer = prefix
return {"secret": secret, "issuer": issuer or None, "account": account or None}
def build_otpauth_uri(secret_b32: str, issuer: str | None, account: str | None) -> str:
"""重建 otpauth URI(导出/换机重新绑定用)"""
label = f"{issuer}:{account}" if issuer and account else (account or issuer or "")
q = f"secret={secret_b32}"
if issuer:
q += f"&issuer={quote(issuer)}"
return f"otpauth://totp/{quote(label)}?{q}"