qualia2fa-core 0.2.2

Qualia2FA core library: TOTP/HOTP primitives, encrypted vault and device sync
Documentation

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)、 恢复密钥、挑战-应答认证、设备配对、同步载荷与合并,以及传输层协议。

用法

[dependencies]
qualia2fa-core = "0.2"

计算验证码:

use qualia2fa_core::otp::{self, Algorithm, Digits, OtpSecret};

let secret = OtpSecret::from_base32("JBSWY3DPEHPK3PXP")?;
// 当前时间由调用方传入——本库不读时钟
let code = otp::totp_code(&secret, 1_700_000_000, 30, Algorithm::Sha1, Digits::SIX)?;
assert_eq!(code.len(), 6);

导入一条 otpauth://:

use qualia2fa_core::uri;

let entry = uri::parse(
    "otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"
)?;
assert_eq!(entry.issuer.as_deref(), Some("GitHub"));
assert_eq!(entry.account, "alice");

校验用户输入,并容忍时钟漂移:

use qualia2fa_core::otp;

// 返回命中的偏移步数(0 = 当前步,-1 = 落后一步),而不是布尔值,
// 便于据此校准该条目的时钟偏差。比较是常量时间的。
let offset = otp::verify_with_drift(&secret, counter, 1, algorithm, digits, &user_input);

加密保险库

一条 otpauth:// 配置存进保险库后就变成一条逐条加密的条目:每条有自己的 128-bit 随机 ID 与 XChaCha20-Poly1305 密文,文件里除了那些 ID 之外没有任何明文 语义——连「这是 TOTP 还是 HOTP」都看不出来。

use qualia2fa_core::vault::{self, Entry, VaultFile};

// 从扫码结果建条目。时间由调用方传入(本库不读时钟)。
let parsed = qualia2fa_core::uri::parse("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub")?;
let entry = vault::entry_from_parsed_uri(&parsed, now)?;

let mut file = VaultFile::create()?;              // 生成新的随机主密钥
let id = file.vault_mut().insert(entry)?;

let bytes = file.serialize()?;
vault::write_file_atomically(std::path::Path::new("vault.q2fa"), &bytes)?;

读回来需要主密钥——文件里不含它,只含一个由它派生的检查值(格式版本 2 起, 32 字节,只能回答「是不是这把密钥」,无法反推出主密钥):

use qualia2fa_core::vault::{self, MasterKey};

let master: MasterKey = load_from_platform_keystore()?;   // 例如 Windows TPM
let file = vault::read_file(master, std::path::Path::new("vault.q2fa"))?;
for (id, entry) in file.vault().iter() {
    println!("{id} {}", entry.name);
}

几件需要知道的事:

  • 主密钥丢失 = 数据永久无法解密。 没有服务端、没有账号密码,也就没有找回 途径。把主密钥存进平台硬件密钥库(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 qualia2fa_core::import::{self, ImportFormat};

// 字节进、报告出:本库不做 I/O,也不读时钟
let report = import::import(&bytes, ImportFormat::Json, now, None)?;
for entry in &report.entries {
    println!("{}", entry.entry.name);
}
// 与已有保险库比对:哪些是新的、哪些已经有了、哪些要人来定
let matches = import::apply::compare(&report, &vault);

导出成同样的四种格式之一(Text / Json / EncryptedJson / Html):

use qualia2fa_core::export::{self, ExportFormat};

// 同样是字节出、字节进:本库不做 I/O,写到哪里由调用方决定。
// 只有 EncryptedJson 需要口令;另外三种是**明文**,与保险库本身一样敏感。
let bytes = export::export(vault, ExportFormat::EncryptedJson, Some(b"password"))?;

设计约定

无 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,也不 需要指向上游目录的路径依赖。

[dependencies]
qualia2fa-core = "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 版,或(任选)任何更高版本的条款,再分发和/或修改本作品。

本作品出于希望其有用的目的分发,但不提供任何担保,甚至不提供适销性或特定用途 适用性的默示担保。详见许可证正文。