Qualia2FA-Core
English version: README.en.md.
Qualia2FA 的核心库。承载双因素认证(2FA)的全部核心逻辑:TOTP/HOTP 计算、
otpauth:// 解析、加密保险库、设备配对与同步合并。
刻意不含任何界面或命令行入口,也不做任何 I/O 与时钟读取——以便 Windows 桌面端、Android、iOS 各自作为独立项目复用它。
状态
| 项 | 值 |
|---|---|
| Crate 名 | qualia2fa-core |
| 版本 | 0.2.2 |
| 类型 | 纯 library crate |
| Edition | 2024 |
| MSRV | 1.89 |
| 开发工具链 | stable(由 rust-toolchain.toml 固定) |
| 许可证 | LGPL-3.0-or-later |
已实现:HOTP(RFC 4226)、TOTP(RFC 6238)、mOTP、Yandex OTP、Steam Guard、Base32
(RFC 4648,宽容与严格两种模式)、URI 解析与序列化(otpauth:// 与 Aegis 的 motp:)、
加密保险库(本地存储格式)、分组与排序(含同步与冲突裁决)、从 Aegis 的
JSON / HTML / TXT 导出导入(加密导出也能开)、导出成同样四种格式(含加密 JSON)、
恢复密钥、挑战-应答认证、设备配对、同步载荷与合并,以及传输层协议。
用法
[]
= "0.2"
计算验证码:
use ;
let secret = from_base32?;
// 当前时间由调用方传入——本库不读时钟
let code = totp_code?;
assert_eq!;
导入一条 otpauth://:
use uri;
let entry = parse?;
assert_eq!;
assert_eq!;
校验用户输入,并容忍时钟漂移:
use otp;
// 返回命中的偏移步数(0 = 当前步,-1 = 落后一步),而不是布尔值,
// 便于据此校准该条目的时钟偏差。比较是常量时间的。
let offset = verify_with_drift;
加密保险库
一条 otpauth:// 配置存进保险库后就变成一条逐条加密的条目:每条有自己的
128-bit 随机 ID 与 XChaCha20-Poly1305 密文,文件里除了那些 ID 之外没有任何明文
语义——连「这是 TOTP 还是 HOTP」都看不出来。
use ;
// 从扫码结果建条目。时间由调用方传入(本库不读时钟)。
let parsed = parse?;
let entry = entry_from_parsed_uri?;
let mut file = create?; // 生成新的随机主密钥
let id = file.vault_mut.insert?;
let bytes = file.serialize?;
write_file_atomically?;
读回来需要主密钥——文件里不含它,只含一个由它派生的检查值(格式版本 2 起, 32 字节,只能回答「是不是这把密钥」,无法反推出主密钥):
use ;
let master: MasterKey = load_from_platform_keystore?; // 例如 Windows TPM
let file = read_file?;
for in file.vault.iter
几件需要知道的事:
- 主密钥丢失 = 数据永久无法解密。 没有服务端、没有账号密码,也就没有找回 途径。把主密钥存进平台硬件密钥库(Windows TPM、Android Keystore、iOS Secure Enclave)是计划中的做法,但那属于上层;本库只负责生成、派生与保管它。
- 文件可以被攻击者随意改写。 任何一个字节被改动都会导致解密失败,而不是 悄悄解出一份被篡改的数据。你自己实现存储层时也应当如此。
- 落盘请用
write_file_atomically。 直接fs::write在断电时可能留下一个 被截断的文件,而那意味着全部条目损坏。 - 删除会留下墓碑(同样加密,只有 ID 与时间戳),这样「另一台设备还有这条」 才可裁决。墓碑不设过期,每条 45 字节。
- 密文长度会泄露备注、账号名的长短。这是已知的、有意的取舍:掩盖它需要填充 到定长块,代价是文件变大数倍。
兼容性
保险库文件格式版本:3。 本版本一律写版本 3,并且仍然能读版本 1 与版本 2 的文件 (可读版本只增不减)。老版本应用读到版本 3 的文件会得到一个明确的「版本不支持」错误; 唯一的例外是版本 1 的解析器,它报的是文件尾部有多余内容(那 32 字节的主密钥检查值 在它看来就是多余内容)。
同步载荷版本:3。 载荷只接受当前版本,因此 0.1.x 与 0.2.0 之间的任何同步都会 失败,报的是版本错误而不是认证失败(版本号在明文头部,不需要密钥就能读到)。 两端必须一起升级——这是刻意的:不抬版本号的话,老版本会把 mOTP 条目读成一条普通 TOTP, PIN 与步长在往返里静默丢失。
导入另一款认证器的导出:
use ;
// 字节进、报告出:本库不做 I/O,也不读时钟
let report = import?;
for entry in &report.entries
// 与已有保险库比对:哪些是新的、哪些已经有了、哪些要人来定
let matches = compare;
导出成同样的四种格式之一(Text / Json / EncryptedJson / Html):
use ;
// 同样是字节出、字节进:本库不做 I/O,写到哪里由调用方决定。
// 只有 EncryptedJson 需要口令;另外三种是**明文**,与保险库本身一样敏感。
let bytes = export?;
设计约定
无 I/O、无时钟
不访问文件系统、网络、环境变量,不读系统时钟。时间一律由调用方以 u64
Unix 秒传入。 这不是洁癖:只有时间作为参数传入,RFC 6238 的测试向量才能被
确定性地验证。
随机数只用 getrandom(OS CSPRNG),不经任何用户态 PRNG。
绝不 panic
所有对外函数返回 Result,错误类型化。任何输入都不得导致 panic——包括恶意
二维码、剪贴板内容与手输链接。错误类型绝不携带秘密材料:不放进原始输入、
不放密钥字节、不放 OTP 码。
秘密材料的处理
库内所有秘密(OTP 种子、保险库主密钥、会话密钥)一律用 secrecy 的类型承载,
不用裸 Vec<u8> / String,使「这是秘密」在类型上可见:
| 用途 | 类型 |
|---|---|
| 任意字节秘密(种子、密钥、nonce) | SecretBytes(= SecretSlice<u8>) |
| 文本秘密(口令、恢复短语) | SecretString |
| 定长 / 其它秘密 | SecretBox |
| 取用明文 | ExposeSecret / ExposeSecretMut |
以上类型全部由本库再导出,调用方不需要直接依赖 secrecy。
该依赖已人工审计(依赖必要性无法由 cargo-deny 代劳,故逐项核过):
- 唯一必需依赖是
zeroize ^1.6,全树只有一个zeroize。 - 可选依赖
serde ^1本库不启用,不进依赖树。 - 上游 monorepo 活跃(未归档、近期有推送);crate 本身自 2024-10 无新版本,属
成熟稳定而非废弃,实现为纯逻辑封装(无
unsafe、无 FFI)。 - 本库只在
src/secret.rs一处接触它,替换成本可控。
与直觉不同的上游行为:
secrecy的Debug连长度都不打印,输出形如SecretBox<str>([REDACTED])。 需要长度时请自行expose_secret()。secrecy只为原始整数实现CloneableSecret,没有Vec/String/Box的实现。因此本库的字节秘密统一用可克隆的SecretSlice<u8>;不要改成SecretBox<Vec<u8>>,那会因不可克隆而无法通过编译。- 这些保障只覆盖「明文被打印 / 忘记擦除 / 误克隆」,不覆盖编译器把明文
复制到寄存器、栈副本或交换区。
forbid(unsafe_code)之下无法用mlock阻止 换页,也不防同进程内的恶意代码。
平台原生调用不属于本库
需要 unsafe 的平台代码(如 Windows TPM/CNG 密钥库)隔离在独立 crate,
以保住本库的 #![forbid(unsafe_code)]。
接入你自己的项目
本库已发布到 crates.io:在 Cargo.toml 里写版本号即可,不需要 submodule,也不
需要指向上游目录的路径依赖。
[]
= "0.2"
⚠️ 若你用 Gobley + uniffi 做 Kotlin 绑定,注意 Gobley 当前强制
uniffi = "0.29.4"。不要把 uniffi 加进本库——绑定应放在宿主侧或独立的 FFI crate(例如Qualia2FA-FFI), 以保持本库的依赖面干净、且不受绑定生成器的版本锁定牵连。
参与贡献
提交前请确保全绿:
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets
cargo doc --no-deps # 文档链接与 intra-doc link 不得有警告
依赖审计与许可证策略(改动依赖后必跑):
cargo deny check
cargo audit
模糊测试(需要 nightly 与 cargo-fuzz,因为要用 -Zsanitizer):
rustup toolchain install nightly --component rust-src
cargo install cargo-fuzz --locked
cd fuzz
cargo +nightly fuzz list
cargo +nightly fuzz run uri_parse -- -max_total_time=300
cargo +nightly fuzz run vault_parse -- -max_total_time=300
otpauth:// 解析器、Base32 解码与保险库文件解析都是攻击者可控的输入面,
改动它们时必须跑 fuzz。历史崩溃样本固化在 fuzz/regressions/,CI 每轮都会跑到。
详见 fuzz/README.md。
MSRV 策略
rust-version = "1.89" 是最低支持声明,不是开发工具链版本:
- 开发工具链由
rust-toolchain.toml固定为stable,避免各人本机漂移。 - MSRV 下限由依赖栈决定(当前实际约束来自加密依赖栈;将来引入 KEM 后会更紧)。
- CI 有独立的 MSRV job,用 1.89 真机编译一遍——声明的 MSRV 必须真的能编译, 否则这个字段就是谎言。
安全纪律
- 常量时间是编译产物的属性,不是 crate 的属性。 上游源码看着是常量时间的, 编译器仍可能生成分支(RustSec 上多起此类公告均属 codegen 失败)。因此 升级任何 crypto 依赖后必须重跑常量时间相关测试。
- 依赖审计是安全控制,不是杂事。 RustSec 库 2026 年新增 334 条公告(2025 年
为 172 条),本项目关注的上游问题几乎都在补丁/次版本中修复。
Cargo.lock入库, CI 跑cargo-deny。 - 只发布 release 构建:常量时间比较库的
debug_assert使用依赖秘密的分支。 - 秘密材料永不进入日志、错误消息、panic 信息或
Debug输出。 deny.toml中有明确的禁用 crate 清单,每条都附了理由。
许可
本项目按 GNU Lesser General Public License v3.0 or later
(LGPL-3.0-or-later)授权。你可以依据自由软件基金会发布的 GNU 宽通用公共许可证
第 3 版,或(任选)任何更高版本的条款,再分发和/或修改本作品。
COPYING.LESSER:LGPL-3.0 全文LICENSE:GPL-3.0 全文(LGPL-3.0 通过引用并入)
本作品出于希望其有用的目的分发,但不提供任何担保,甚至不提供适销性或特定用途 适用性的默示担保。详见许可证正文。