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
+112
View File
@@ -0,0 +1,112 @@
"""TOTP(RFC 6238)自实现测试
官方向量锚定正确性(RFC 6238 附录 B:SHA1、20 字节 ASCII secret
"12345678901234567890"),覆盖:码生成、剩余秒数、录入校验窗口、
otpauth URI 解析/重建、随机 secret 可用性。
运行:.venv/bin/pytest tests/test_totp.py -v
"""
import pytest
from app.core import totp
# RFC 6238 附录 B 官方向量 secret 的 base32 形式
RFC_SECRET_B32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
# (时刻 T, 官方 8 位码);本实现为 6 位 = 8 位码取模 10^6 补零
RFC_VECTORS = [
(59, "94287082"),
(1111111109, "07081804"),
(1111111111, "14050471"),
(1234567890, "89005924"),
(2000000000, "69279037"),
(20000000000, "65353130"),
]
@pytest.mark.parametrize("ts,code8", RFC_VECTORS)
def test_rfc6238_official_vectors(ts, code8):
"""逐条对齐 RFC 6238 附录 B 官方向量(6 位截断)"""
code6, _ = totp.totp_at(RFC_SECRET_B32, ts=ts)
assert code6 == str(int(code8) % 10**6).zfill(6)
def test_expires_in():
"""剩余秒数:T=59 窗口已过 29s 剩 1s;T=60 新窗口剩 30s"""
_, left = totp.totp_at(RFC_SECRET_B32, ts=59)
assert left == 1
_, left = totp.totp_at(RFC_SECRET_B32, ts=60)
assert left == 30
def test_verify_window_tolerance():
"""录入校验容忍 ±1 步进时钟偏差,隔两个窗口必须失败"""
code, _ = totp.totp_at(RFC_SECRET_B32, ts=59) # counter=1
assert totp.verify(RFC_SECRET_B32, code, ts=59)
assert totp.verify(RFC_SECRET_B32, code, ts=61) # counter=2,窗口含 1
assert totp.verify(RFC_SECRET_B32, code, ts=80) # counter=2
assert not totp.verify(RFC_SECRET_B32, code, ts=150) # counter=5,超出窗口
def test_verify_rejects_bad_input():
assert not totp.verify(RFC_SECRET_B32, "12345") # 位数不对
assert not totp.verify(RFC_SECRET_B32, "abcdef") # 非数字
assert not totp.verify(RFC_SECRET_B32, "000000", ts=59) # 错误码
assert not totp.verify(RFC_SECRET_B32, "", ts=59)
def test_b32decode_tolerant():
"""小写/空白/缺填充都应正确解码"""
raw = totp.b32decode(RFC_SECRET_B32)
assert totp.b32decode(RFC_SECRET_B32.lower()) == raw
assert totp.b32decode("gezdg nbvgy 3tqoj qgezd gnbvg y3tqo jq") == raw
assert totp.b32decode(RFC_SECRET_B32.rstrip("=")) == raw
def test_parse_otpauth_uri_full():
uri = (
"otpauth://totp/GitHub:me@example.com"
"?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&period=30&digits=6"
)
p = totp.parse_otpauth_uri(uri)
assert p["secret"] == "JBSWY3DPEHPK3PXP"
assert p["issuer"] == "GitHub"
assert p["account"] == "me@example.com"
def test_parse_otpauth_uri_label_only():
"""无 issuer 参数时从 Label 前缀提取,account 需 URL 解码"""
p = totp.parse_otpauth_uri("otpauth://totp/Google:me%40gmail.com?secret=JBSWY3DPEHPK3PXP")
assert p["issuer"] == "Google"
assert p["account"] == "me@gmail.com"
def test_parse_otpauth_uri_rejects():
with pytest.raises(ValueError):
totp.parse_otpauth_uri("https://example.com") # 非 otpauth 协议
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP") # 非 TOTP
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?issuer=y") # 缺 secret
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&digits=8") # 非 6 位
with pytest.raises(ValueError):
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&period=60") # 非 30s
def test_build_otpauth_uri_roundtrip():
"""重建的 URI 可被解析回同样的 secret/issuer/account(换机导出场景)"""
uri = totp.build_otpauth_uri("JBSWY3DPEHPK3PXP", "GitHub", "me@example.com")
p = totp.parse_otpauth_uri(uri)
assert p["secret"] == "JBSWY3DPEHPK3PXP"
assert p["issuer"] == "GitHub"
assert p["account"] == "me@example.com"
def test_random_secret_usable():
"""随机 secret 生成的码应能通过自身校验(录入闭环)"""
s = totp.random_secret()
code, left = totp.totp_at(s)
assert len(code) == 6 and code.isdigit()
assert 1 <= left <= 30
assert totp.verify(s, code)