Qualia2FA-Core
简体中文 | English
The core library of Qualia2FA.
It carries all of Qualia2FA's core logic: code computation, URI parsing, the encrypted vault, and device pairing and sync merging.
It deliberately contains no user interface or command-line entry point, and performs no I/O and no clock reads — so that Windows, Android and other platforms can each reuse it as an independent project.
Status
| Item | Value |
|---|---|
| Crate name | qualia2fa-core |
| Version | 0.3.2 |
| Type | pure library crate |
| Edition | 2024 |
| MSRV | 1.89 |
| Development toolchain | stable (pinned by rust-toolchain.toml) |
| License | LGPL-3.0-or-later |
The following features are supported:
- 2FA code computation:
- HOTP (RFC 4226)
- TOTP (RFC 6238)
- mOTP
- Yandex OTP
- Steam Guard
- Base32 (RFC 4648, in both lenient and strict modes)
- URI parsing and serialization (
otpauth://and Aegis'smotp:form) - The encrypted vault (local storage format)
- A tree of groups (parent links, entries and child groups in one order)
- Importing from Aegis's JSON / HTML / TXT exports (encrypted exports included), icons included
- Recovery keys and the
.q2facontainers (plain and encrypted vault) - Challenge-response authentication
- Device pairing
- Sync payloads and merging
- The transport-layer protocol
Usage
[]
= "0.2"
Computing a code:
use ;
let secret = from_base32?;
// The current time is passed in by the caller — this library does not read the clock
let code = totp_code?;
assert_eq!;
Importing an otpauth:// URI:
use uri;
let entry = parse?;
assert_eq!;
assert_eq!;
Validating user input while tolerating clock drift:
use otp;
// Returns the offset step that matched (0 = the current step, -1 = one step behind)
// rather than a boolean, so the entry's clock skew can be calibrated from it.
// The comparison is constant-time.
let offset = verify_with_drift;
The encrypted vault
Once an otpauth:// configuration is stored in the vault it becomes an individually
encrypted entry: each one has its own 128-bit random ID and XChaCha20-Poly1305
ciphertext, and apart from those IDs the file holds no plaintext semantics at all — not
even "TOTP or HOTP" is visible.
use ;
// Build an entry from a scan result. The time is passed in by the caller
// (this library does not read the clock).
let parsed = parse?;
let entry = entry_from_parsed_uri?;
let mut file = create?; // generate a fresh random master key
let id = file.vault_mut.insert?;
let bytes = file.serialize?;
write_file_atomically?;
Reading it back requires the master key — the file does not contain it, only a value derived from it (a 32-byte check value since format version 2, which answers "is this the key you guessed" and cannot be inverted to the master key):
use ;
let master: MasterKey = load_from_platform_keystore?; // e.g. the Windows TPM
let file = read_file?;
for in file.vault.iter
A few things worth knowing:
- Losing the master key means the data can never be decrypted. There is no server and
no account password, so there is no recovery route either. Storing the master key in the
platform hardware key store (Windows TPM, Android Keystore) is available, but that belongs
to the upper layer (
qualia-dikand the application); this library only generates, derives and holds it. The Secure Enclave on iOS and macOS is still planned. - The file can be rewritten freely by an attacker. Flipping any single byte makes decryption fail, rather than quietly yielding tampered data. Your own storage layer should behave the same way.
- Write to disk with
write_file_atomically. A plainfs::writecan leave a truncated file after a power loss, and that means every entry is damaged. - Deletion leaves a tombstone (encrypted the same way, holding only the ID and a timestamp), so that "another device still has this entry" can be adjudicated. Tombstones never expire and are 45 bytes each.
- The ciphertext length leaks the length of a note or an account name. This is a known, deliberate trade-off: hiding it would take padding to fixed-size blocks at several times the file size.
Compatibility
Vault format version: 4. This version always writes version 4, and still reads version 1 through 3 (the readable set only grows). A version 4 group record is a name plus an ordered list of child nodes, and an entry may carry an icon; the flat groups of versions 1-3 are read as named groups under the top group. An older application reading a version 4 file gets a clear "unsupported version" error; the one exception is the version 1 parser, which reports trailing bytes (the 32-byte master key check value looks like extra content to it).
Sync payload version: 4. A payload only accepts the current version, so syncing 0.2.x with 0.3.0 fails, with a version error rather than an authentication failure (the version sits in the cleartext header, so it is readable without the key). Both devices have to be upgraded together, which is deliberate: the entry plaintext grew an icon node, the group plaintext changed shape, and the length prefix is now u32.
Importing an Aegis export:
use ;
// Bytes in, a report out: this library does no I/O and reads no clock
let report = import?;
for entry in &report.entries
// Compared against the vault: what is new, what is already there, what needs a human
let matches = compare;
Exporting to one of six forms - the two .q2fa containers (Vault / EncryptedVault) and the four external ones (Text / Json / EncryptedJson / Html):
use ;
// Bytes out of bytes in, again: this library does no I/O, and where the file goes is the
// caller's decision. Only EncryptedJson and EncryptedVault take a password; Vault holds the
// master key in clear, and the other plaintext forms are as sensitive as the vault itself.
// and as sensitive as the vault itself.
let bytes = export?;
Design conventions
No I/O, no clock
It does not access the file system, the network or environment variables, and it does not
read the system clock. Time is always passed in by the caller as u64 Unix seconds.
This is not fastidiousness: only with time as a parameter can the RFC 6238 test vectors be
verified deterministically.
Randomness comes only from getrandom (the OS CSPRNG), never through a user-space PRNG.
Never panic
Every public function returns a Result with typed errors. No input may cause a panic —
including malicious QR codes, clipboard contents and hand-typed links. Error types never
carry secret material: not the raw input, not key bytes, not OTP codes.
Handling of secret material
Every secret inside the library (OTP seeds, the vault master key, session keys) is carried
in a secrecy type, never a bare Vec<u8> / String, so that "this is a secret"
is visible in the type:
| Purpose | Type |
|---|---|
| Arbitrary byte secrets (seeds, keys, nonces) | SecretBytes (= SecretSlice<u8>) |
| Text secrets (passphrases, recovery phrases) | SecretString |
| Fixed-size / other secrets | SecretBox |
| Taking the plaintext | ExposeSecret / ExposeSecretMut |
All of the types above are re-exported by this library, so callers do not need to
depend on secrecy directly.
That dependency has been audited manually (whether a dependency is necessary cannot be
delegated to cargo-deny, so every item was checked):
- Its only required dependency is
zeroize ^1.6, and the whole tree has exactly onezeroize. - The optional
serde ^1dependency is not enabled here and stays out of the dependency tree. - The upstream monorepo is active (not archived, recently pushed to); the crate itself has
had no release since 2024-10, making it mature and stable rather than abandoned, and
it is a pure logic wrapper (no
unsafe, no FFI). - This library touches it in exactly one place,
src/secret.rs, so the cost of swapping it out is contained.
Upstream behaviour that is not what you would expect:
secrecy'sDebugdoes not even print the length; the output looks likeSecretBox<str>([REDACTED]). Callexpose_secret()yourself when you need the length.secrecyimplementsCloneableSecretonly for primitive integers; there is no implementation forVec/String/Box. This library therefore uses the cloneableSecretSlice<u8>for byte secrets throughout; do not change it toSecretBox<Vec<u8>>, which will not compile because it is not cloneable.- These guarantees only cover "the plaintext was printed / erasure was forgotten / it
was cloned by mistake". They do not cover the compiler copying plaintext into
registers, stack copies or swap space. Under
forbid(unsafe_code)there is no way to usemlockto prevent paging, and it does not defend against malicious code in the same process either.
Platform-native calls do not belong in this library
Platform code that needs unsafe (such as the Windows TPM/CNG key store) is isolated in a
separate crate, to preserve this library's #![forbid(unsafe_code)].
Integrating into your own project
This library is published on crates.io: write the version number in Cargo.toml — no
submodule and no path dependency pointing at an upstream directory is needed.
[]
= "0.2"
⚠️ If you use Gobley + uniffi for Kotlin bindings, note that Gobley currently requires
uniffi = "0.29.4". Do not add uniffi to this library — bindings belong on the host side or in a separate FFI crate (such asQualia2FA-FFI), so that this library's dependency surface stays clean and is not dragged around by the binding generator's version pin.
Contributing
Please make sure everything is green before committing:
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets
cargo doc --no-deps # documentation links and intra-doc links must not warn
Dependency audit and license policy (must be run after changing dependencies):
cargo deny check
cargo audit
Fuzzing (needs nightly and cargo-fuzz, because -Zsanitizer is used):
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
The otpauth:// parser, Base32 decoding and vault file parsing are all
attacker-controlled input surfaces, so fuzz them whenever you change them. Historical
crash samples are frozen in fuzz/regressions/ and CI runs them every round. See
fuzz/README.md for details.
MSRV policy
rust-version = "1.89" is a minimum supported version declaration, not the development
toolchain version:
- The development toolchain is pinned to
stablebyrust-toolchain.toml, so it does not drift from machine to machine. - The MSRV floor is determined by the dependency stack (the current constraint comes from the crypto dependency stack; a future KEM would tighten it).
- CI has a separate MSRV job that compiles once with 1.89 on a real machine — the declared MSRV must actually compile, otherwise the field is a lie.
Security discipline
- Constant-time behaviour is a property of the compiled artifact, not of the crate. A source file that looks constant-time may still compile into a branch (several RustSec advisories of this kind are codegen failures). Therefore rerun the constant-time tests after upgrading any crypto dependency.
- Dependency auditing is a security control, not a chore. The RustSec database added
334 advisories in 2026 (172 in 2025), and nearly every upstream issue this project cares
about was fixed in a patch or minor release.
Cargo.lockis committed and CI runscargo-deny. - Ship release builds only: constant-time comparison libraries use
debug_assertwith branches that depend on secrets. - Secret material never enters logs, error messages, panic messages or
Debugoutput. deny.tomlcarries an explicit list of banned crates, each with its reason.
License
This project is licensed under the GNU Lesser General Public License v3.0 or later
(LGPL-3.0-or-later). You may redistribute and/or modify this work under the terms of the
GNU Lesser General Public License as published by the Free Software Foundation, either
version 3 of the License, or (at your option) any later version.
COPYING.LESSER: the full text of LGPL-3.0LICENSE: the full text of GPL-3.0 (LGPL-3.0 is incorporated by reference)
This work is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; even without the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the license text for details.