qualia2fa-ffi 0.4.1

Qualia2FA FFI layer: exposes qualia2fa-core as UniFFI bindings for Kotlin and Swift
Documentation

Qualia2FA-FFI

简体中文 | English

Exposes qualia2fa-core as UniFFI bindings, for managed languages such as Kotlin and Swift to call.

License Releases crates.io


Item Value
Crate name qualia2fa-ffi
Type cdylib + staticlib (plus a uniffi-bindgen binary)
Edition 2024
License LGPL-3.0-or-later
UniFFI 0.29

The following are exported:

  • OTP (TOTP / HOTP / mOTP / Yandex OTP / Steam Guard)
  • the vault
  • a tree of groups (parent links, and one order for entries and child groups)
  • icons (passed through as bytes)
  • importing and exporting (six formats, including the two .q2fa containers)
  • recovery keys
  • registration/login
  • challenge-response and synchronization

On top of that, this layer implements the LAN channel itself (multicast discovery plus an endpoint data plane) — that half is Rust-only, and no host-side implementation could reach it.

Usage

1. Build the dynamic library

cargo build --release

The artifacts are target/release/qualia2fa_ffi.dll (Windows), .so (Linux/Android) and .dylib (macOS/iOS). For a static library, take the .lib/.a in the same directory.

2. Generate the host-side bindings

The binding generator is this repository's own binary, so that the UniFFI version used to generate the bindings is exactly the one the library was compiled with. Switching to a globally installed uniffi-bindgen would let the two versions drift apart and produce bindings that "compile but do not match at run time".

cd FFI    # must run from the repository root: uniffi-bindgen calls cargo metadata internally

# Kotlin (the JVM desktop app and Android share the same bindings)
cargo run --bin uniffi-bindgen -- generate \
    --library target/debug/qualia2fa_ffi.dll \
    --language kotlin --out-dir ./bindings

# Swift (iOS)
cargo run --bin uniffi-bindgen -- generate \
    --library target/debug/qualia2fa_ffi.dll \
    --language swift --out-dir ./bindings-swift

The generated bindings land in bindings/ and bindings-swift/ and are not committed — they are derived artifacts and should be generated by the host-side build before compiling. Committing them immediately creates "bindings out of sync with the interface definition": a Rust signature is changed but the bindings are not regenerated, and nothing fails at compile time — only at run time.

If Unable to auto-format ... using ktlint: program not found appears, it only means the optional ktlint is missing; the artifacts are still correct and usable.

3. Calling from Kotlin

The generated bindings live in the uniffi.qualia2fa_ffi package and the functions are top-level:

import uniffi.qualia2fa_ffi.*

val entry = parseOtpauthUri("otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP")
val code = totpCode(entry.secretBase32, System.currentTimeMillis() / 1000, entry.period, entry.algorithm, entry.digits)

4. Distributing the dynamic library

The bindings look up the dynamic library by name, through JNA (JVM) or the system dynamic loader (Swift), so qualia2fa_ffi.dll / libqualia2fa_ffi.so / libqualia2fa_ffi.dylib must be distributed together with the application and land where the loader can find them. How they are placed on each platform is up to the host-side build.

Interface

Codes

Function Description
parse_otpauth_uri Parses an otpauth:// URI
serialize_otpauth_uri Serializes back to a URI (an account name containing : is rejected)
totp_code / hotp_code Computes a code
steam_guard_code The Steam Guard 5-character alphabet
verify_code_with_drift Drift-tolerant verification; returns the matched offset for clock calibration
codes_match Constant-time comparison
generate_secret_base32 Generates a 256-bit random seed (OS CSPRNG)
recommended_min_secret_len / is_secret_shorter_than_recommended / secret_byte_len Seed strength hints
core_version The core version, for compatibility checks

Time is always passed in by the caller. The core does not read the clock; only with time as a parameter can the RFC 6238 test vectors be verified deterministically. The host side should pass the system clock's Unix seconds.

The vault (FfiVault)

The entries themselves are held by FfiVault, and the master key stays on the Rust side. On the most common path — "open → list entries → change an entry → store back to the file" — the master key never appears in Kotlin/Swift as bytes even once.

Method Description
FfiVault.register(keys, store, label) Generates the master key, obtains the device identity, writes to the platform key store
FfiVault.open(store, data) Opens a vault file with the master key from the key store
FfiVault.fromMasterKey(bytes) Builds an empty vault from an existing master key (used after login/recovery)
entryCount / isEmpty / tombstoneCount Counts
entries / entry(id) Reads entries (order is not guaranteed; sort the list yourself)
addEntry(entry, now) → id Adds one
updateEntry(entry, now) Modifies one (the identity is kept and no tombstone is left)
removeEntry(id, now) → Bool Removes one (leaves a tombstone)
serialize Serializes, for the host side to write atomically
codeFor(id, now) / hotpFor(id, counter) Computes a code from the entry's kind
groups / childrenOf(id) / parentOf(id) / depthOf(id) The tree: groups in pre-order, one group's children, the parent and the depth (the top group is always first)
createGroupIn / renameGroup / setChildren / removeGroup Creates under a given parent, renames, writes one whole child list, removes (its children move to the parent)
setOrdering / assignToGroup Reorders only the entries of one group, and moves an entry into a group (uses of setChildren on a subset)
export(format, password) Writes one of six formats (the two .q2fa containers and the four external ones)
detectPortableFile / openPortableFile / FfiPortableRestore Recognises and restores .q2fa (old key-only files included), rolling the master key back on failure
exportRecoveryKey(passphrase, …) Exports a recovery key file

updateEntry and removeEntry differ by more than semantics: an update keeps the entry's identity, a removal leaves a tombstone. The two are read as completely different operations on another device, so do not implement an update as "remove + add".

Importing (FfiImport)

Imports an export from Qualia2FA / Aegis: FfiImport.preview(bytes, format, password, now) takes JSON, HTML or one URI per line (an encrypted JSON needs a password), and detectFormat(bytes) can guess the format from the content first. On the returned object, entries / problems / groups / dropped are what the preview shows, compare(vault) says which entries the vault already holds and which share a secret but differ (a question for the user), and apply(vault, choices) carries the answers out.

No secret and no PIN cross the boundary: FfiImportedEntry holds the name, the service, the account, the algorithm, the digit count and the kind, and the kind arrives without its PIN. The parsed export stays in Rust, which reads it back when the import is applied. An entry with no answer is added, never overwritten - losing a note the user wrote is worse than leaving a duplicate they can delete.

Exporting (FfiVault.export)

vault.export(format, password) writes the whole vault and returns the file's bytes: this layer does no I/O, so where they go is the host side's save dialog. The two .q2fa containers (Vault with the master key in clear, EncryptedVault with it wrapped by a passphrase) restore the whole account - entries, the group tree and the icons. The four external formats mirror the import exactly: Text (one URI per line), Json, EncryptedJson and Html (a QR code drawn for every entry). Three functions answer what the interface has to ask while the user picks a format: exportFormatNeedsPassword, exportFormatExtension, exportFormatCarriesGroups, and exportFormatGroupCarrying (None / FlatGroups / FullTree; whether the groups and their order travel - only the two JSON forms carry them).

Only EncryptedJson protects anything. The other three are as sensitive as the vault itself: the seeds are in there as they stand, and every QR code of the HTML form holds that entry's otpauth:// URI with its secret. The interface has to say so before the file is written.

An empty password is refused (export_password_empty), because it is one anybody can guess by trying nothing. The encryption is the format's own two stages: scrypt derives the slot key, the slot holds a random master key, and the master key encrypts the database with AES-256-GCM; every random value comes from the OS CSPRNG. The Rust side holds the vault's lock for the duration of an export (the whole vault is read once, and taking a snapshot would mean a second copy of every secret in memory).

Registration and login

// Register a new device
val vault = FfiVault.register(deviceKeys, secureStore, "qualia2fa-device")

// Old device (already signed in): show the QR code
val login = FfiLogin.begin()
showQrCode(login.payload())

// New device: scan, send the request, and finish once the reply arrives
val joiner = FfiJoiner.fromQr(scannedText, "qualia2fa-device")
val request = joiner.request(deviceKeys)
// …hand request to the old device…
val reply = login.authorize(oldVault, request)   // the UI must ask the user to confirm here first
val masterKey = joiner.complete(reply, deviceKeys, secureStore)
Method Description
FfiLogin.begin() / payload() Starts a login and obtains the QR code text
FfiLogin.authorize(vault, msg1) Authorizes (calling it hands over the master key; the UI must confirm first)
FfiLogin.joinerDeviceId() The requester's device identity, to show the user for verification
FfiJoiner.fromQr(text, label) Scans the code and establishes the session
FfiJoiner.request(keys) Sends the request (idempotent)
FfiJoiner.identity() This device's identity; has a value only before completion
FfiJoiner.complete(msg2, keys, store) Verifies the reply, retrieves the master key, writes it to the key store
FfiPlatformKeys.open(label) Opens the device identity in the platform key store (CNG/TPM on Windows, the CNG software store without a TPM). It brings the qualia-dik implementation to this side of the boundary, so the host only forwards when implementing FfiDeviceKeys; backend() reports truthfully which of the two it got

A login can be completed only once. The second call reports the same error as "the reply was forged", and that is deliberate — reporting "already used" on its own would tell a replayer that the replay was detected. Host-side wording should stay vague too.

The two forms of master key storage (FfiDirectMasterKeyStore / FfiWrappedMasterKeyStore)

The master key has exactly two storage forms on a platform, and which object you ask is the choice — there is no mode argument:

Method Description
FfiDirectMasterKeyStore.open() Opens the form where the platform store holds the material itself (CNG on Windows, Keystore on Android): store(label, key) / load(label) / delete(label), and the host side persists nothing for it
FfiWrappedMasterKeyStore.open() Opens the form where the platform store holds a non-exportable key-encryption key: store(label, key) returns the wrapped bytes for the host side to persist, load(label, wrapped) takes them back, and delete(label) removes that key (every blob wrapped with it becomes permanently unusable)
backend() / isHardwareBacked() Reports truthfully which backend answered ("tpm" / "android-keystore" / "software" / "secure-enclave" / "unavailable"), so the host side can tell the user before relying on the store

The master key crosses the boundary as bytes only (32 in, 32 out), never in a log and never in an error message; once the host side has it, it should drop it at once, or build the vault straight from it with FfiVault.fromMasterKey. The wrapped form hands back opaque bytes: the host side persists them next to the vault file they belong to, and no operation that wraps, unwraps, encrypts, decrypts or hands out a key-encryption key exists in this interface — that is the platform store's own business. A machine with no usable provider reports hardware_unavailable, a platform with no backend yet reports unsupported_platform, and nothing falls back to an in-memory implementation.

Recovery keys

Function Description
FfiVault.exportRecoveryKey Exports while the device is unlocked (the master key does not cross the boundary)
exportRecoveryKeyFromMaster Exports when only the master key bytes are available
importRecoveryKey(file, passphrase, store) Imports and writes to the key store, returning the master key
storeMasterKey(bytes, store) Stores the master key on its own

Pass null for the KDF parameters (memory_kib / iterations / parallelism) to use the recommended values (64 MiB / 3 iterations / 1 lane). Do not lower them "for speed": the export is a one-off cost, while an attacker who has the file is doing an offline brute-force search, so lowering the parameters directly weakens the user's last line of defence.

Challenge-response

Used to prove "the other side holds the master key" without handing the master key over. It differs from login: login transfers the key, while this only proves it. All three steps must be taken; skipping the third is doing only half of the authentication.

val verifier = FfiVerifier.begin()
val responder = FfiResponder.accept(verifier.challenge())
val response = responder.respond(vault, label, deviceKeys)
verifier.verify(response, vault)                  // step two: the master key really matches
responder.confirm(vault, verifier.sessionId()!!, verifier.confirmation()!!)  // step three

An empty vault can decide whether the master key is right too. Format version 2 puts a master key check value at the end of the file, so FfiVault.open rejects a wrong master key even on a file with not a single entry. The host side may treat "opened successfully" directly as "unlocked".

The exception is an old version 1 file: that file really carries nothing to decide with, so use challenge-response when authoritative confirmation is needed.

Synchronization (FfiSyncSession)

Syncing is two devices, one code each, one direction at a time: one shows its state, the other reads it and merges it, and then it is the other way round. The payload is encrypted and authenticated by the core; the channel only moves bytes.

There are two channels and one interface (FfiTransport): the host side implements the one that turns a payload into text/QR codes the user carries, and this layer implements the one that runs over the LAN (see below). Both satisfy the same contract, so the session code has no branch for either.

// The channel: an outbound queue and an inbound queue, with no decisions of its own
class CodeChannel(private val me: String) : FfiTransport { /* … */ }

val session = FfiSyncSession.open(vault)

// Show this device's state
session.send(channel, FfiRecipient.All, FfiPayloadKind.Vault)
showQrCode(channel.takeOutgoing())

// Read the other device's code and merge it
channel.accept(senderId, payloadBytes)
val outcome = session.receive(channel)
if (outcome.needsAdjudication) {
    for (conflict in session.pendingConflicts()) {
        showConflict(conflict)
    }
    session.adjudicate(conflict.entryId, FfiDecision.TakeIncoming)
}
vault.serialize()?.let(::saveVault)     // the merge wrote into the vault: persist it
Member Meaning
FfiSyncSession.open(vault) Opens a session on an unlocked vault
send(transport, to, kind) → syncId Seals and sends (Vault = this device's whole state, Request = "send me yours")
receive(transport) → FfiSyncOutcome Takes one message and merges it; on failure neither the vault nor the replay log changes
pendingConflicts() The conflicts still waiting, in the order the merge reported them
adjudicate(entryId, decision) Decides one, by identifier rather than by position (positions shift once one is decided)
planShare(transport) Answers "does it fit" only: it sends nothing and displays nothing
FfiPlatformKeys.deviceId() This device's identifier, which the channel has to report truthfully

An FfiTransport implementation owes the same contract as the core's Transport: report the sender truthfully (the core checks it against the authenticated field inside the payload), respect FfiRecipient.One, return null rather than block when there is nothing to receive, and report transport_already_used when a one-shot channel is used twice. Device identifiers are always 64 hexadecimal characters.

A conflict's kind is a stable string, and the host side needs a fallback branch for values it does not know:

kind Meaning The two choices
entry_mismatch Both sides hold the entry with different content keep this device's version / take the other one
peer_deleted The other device deleted it; this one edited it and still has it keep the entry / accept the deletion
local_deleted This device deleted it; the other one still has it keep it deleted / restore the entry

A decision applies to this device only. "Take the other version" really writes into the vault, so it must be persisted; "keep this device's version" does not make the disagreement go away — the other device still holds its version and will ask its own user in turn. The two sides converge only once both have chosen.

The replay log is not persisted. Within one run the same payload is never applied twice; after a restart the log is empty and the same payload can be merged again — merging is idempotent, so the result is the same. The code channel is out-of-band and one-shot, which is why that boundary is acceptable; the LAN channel goes through the same session, so its replay log lives in one run as well. It will be exposed at the point where it has to survive a restart.

The LAN channel (FfiLanChannel / FfiLanLobby)

Besides the QR-code half, this layer implements the LAN channel itself: FfiLanChannel is an endpoint that can be dialed, and FfiLanLobby is discovery on the same network segment. It satisfies the same contract as a host-side FfiTransport (send / receive / peers / capabilities), so the session code has no branch for either carrier: FfiSyncSession takes an Arc<dyn FfiTransport> and cannot tell which one it was handed.

// Host side: open the endpoint and the lobby, and hand record() to your own pairing code
val channel = FfiLanChannel.open(deviceId)      // opened explicitly: one UDP port and one runtime
val lobby = channel.openLobby()                 // announcing starts here; skip it and nothing is announced
showCode(withRecord(host.payload(), channel.record()))

// Joining side: after scanning, find that device in the lobby by its record and note where it can be dialed
val record = recordOf(scannedText)              // whatever your own pairing code carries
val peer = lobby.peers().firstOrNull { it.record.contentEquals(record) }
if (peer?.socket != null) channel.rememberPeer(peer.deviceId, peer.record, peer.socket!!)

// From here it is the same sync session: the carrier changed, the protocol and the decisions did not
val session = FfiSyncSession.open(vault)
session.send(channel.transport(), FfiRecipient.One(peer.deviceId), FfiPayloadKind.Vault)

withRecord / recordOf stand for the host side's own pairing code: this layer does not define that text format, it only provides record() (to hand out) and FfiLanPeer.record (to match against). Where no lobby is usable — a different segment, or multicast forbidden — the record still says who to look for, but not where: rememberPeer also needs an address, and only the party that received a datagram from that peer has one.

Member Meaning
FfiLanChannel.open(deviceId) Opens an endpoint on a system-chosen port; the endpoint key is generated per run and never persisted
record() This endpoint's connection record (endpoint key + port, 42 bytes fixed), for a pairing code or another out-of-band route
rememberPeer(deviceId, record, socket) Registers a peer that may be dialed; only the IP of socket is used, the port comes from the record
forgetPeer(deviceId) Forgets that peer and closes the connection to it
transport() Hands this object to a session as an FfiTransport
openLobby() Starts announcing and listening; returns an FfiLanLobby
shutdown() Closes the endpoint: every connection is closed and the runtime stops; safe to call again
FfiLanLobby.peers() The peers it knows, including the ones that left (here is false)
FfiLanLobby.announceNow() Announces once right away instead of waiting for the interval
FfiLanLobby.shutdown() One goodbye, then the thread and the socket; safe to call again
FfiLanPeer deviceId / record / socket (the dialable ip:port: the IP of whoever received the announcement, the port the endpoint announced; possibly null) / here

Three things a caller has to know:

  1. Opened explicitly, closed explicitly, no sync in the background. An open channel holds one UDP port and one tokio runtime (2 worker threads); an open lobby announces this device into the segment every 30 seconds and listens for the others. Neither is reclaimed automatically: have the interface open them while a screen that needs them is up, and shutdown() on the way out.
  2. A connection record carries no IP. record() holds the endpoint key and the port only; the IP comes from the party that received the multicast datagram — an endpoint reports its own socket as the wildcard address (0.0.0.0), which cannot be dialed. That is why "the code carries a record, and the peer finds you in the lobby by it" is how the out-of-band step is crossed, and why FfiLanPeer.socket is the only dialable address: a peer without one can be shown but not connected to.
  3. It depends on qualia-transport 0.1.3 with the iroh-lan feature, which this crate enables unconditionally. iroh, tokio and their tree enter every build, the Android one included, so build time and artifact size both grow noticeably. Multicast discovery and the QR codes do not need that feature; the data plane here does.

transport().peers() reports the registered peers, which is what FfiRecipient.All reaches; the lobby reports the peers that were heard. The two are not the same set: to make a device reachable, register it out of the lobby first. An announcement is unauthenticated — it only answers "who is probably on this segment, and where to reach them"; the connection handshake proves the identity, and the core decides from the device identifier whether a payload may be accepted.

Breaking changes in 0.3.0

  • Depends on qualia2fa-core 0.3.0: vault format 4 and sync payload 4, so it cannot sync with 0.2.x; older vaults are still read, and an older peer gets a clear version error.
  • Groups are a tree: FfiGroup no longer has order and gains children (FfiGroupChild: entry or group); moveGroup is replaced by setChildren / createGroupIn. A conflict's local / incoming are child lists too.
  • Entries gain icon (FfiEntryIcon: format plus raw bytes); exporting gains two .q2fa formats and FfiGroupCarrying.

Breaking changes in 0.2.0

  • Entry shape: FfiVaultEntry / FfiOtpAuth no longer carry period / counter / uri_type; they carry kind (FfiEntryKind: TOTP, HOTP, mOTP, Yandex OTP, Steam). FfiDigits gains Five, Nine and OtherDigits (a count a form does not offer is carried through).
  • One code entry point: totpFor / steamGuardFor are replaced by codeFor, which lets the entry's own kind decide the computation, while hotpFor stays. Calling the wrong one for an entry is wrong_code_kind rather than a code that never matches.
  • The PIN is write-only: it is passed in through FfiEntryKind's pin and reported back only as pinSet. On update, pin: None means "keep the stored PIN", so editing a note does not ask the user to retype it.
  • Depends on qualia2fa-core 0.2.0: vault format 3, sync payload 3, and no synchronization with 0.1.x at all - both devices have to be upgraded together.
  • Depends on qualia2fa-core 0.2.1 (since 0.2.3): export and its three format functions are new here, and 0.2.1 is also the release whose HTML reader takes the time step out of the QR code - the two belong together.
  • Depends on qualia2fa-core 0.2.2 (since 0.2.4): three cells of the HTML export are spelled the way Aegis spells them - the type in upper case, the favourite column as the word, and an empty cell for a field nobody filled in (the dash belongs to a field that does not apply to the entry's type). The reader accepted both spellings all along, so this only changes what a written file looks like.

Once an FFI error crosses into Kotlin it is a sealed class FfiException:

Variant Meaning
InputTooLong The input exceeds the boundary limit
Core An error returned by the core, structured as FfiCoreError { kind, message }
Qr A failure reported by the QR-code half of the channel itself (kind + detail), such as an image with no code in it
Lan A failure reported by the LAN channel itself (kind + detail), such as a multicast group that cannot be joined or a peer that was never registered
InvalidDeviceId A device identifier passed in is not 64 hexadecimal characters
UnknownConflict A decision was made for a conflict that is not waiting: a decision belongs to the merge that produced it

A Lan kind is a stable identifier just like a Qr one. The common values are lan_socket (the endpoint has no bound socket), lan_transport (the runtime or the bind failed), no_connection_to_peer (the peer was never registered, or cannot be reached), payload_too_large, no_lan_peers_found, bad_socket (the address passed in is not ip:port), and not_our_lan_address / bad_lan_address_length / zero_port for a connection record. The enum is #[non_exhaustive], so an unmapped variant reaches the host as lan_error and a fallback branch is required.

The master key storage failures carry kinds of their own as well: invalid_wrapped_key (the wrapped bytes do not unwrap — damaged, or produced by another store entry or another device; never a different but plausible key), invalid_master_key, key_not_exportable (the platform store took the material but will not hand it back, which is the case that wants the wrapped form), hardware_unavailable and unsupported_platform.

kind is a stable machine-readable identifier (such as secret_empty, vault_entry_not_found) that the host side dispatches its UI on; message is for display and logs only. These strings are part of the public interface: new ones may be added, but existing values must not be renamed.

Several groups of kind are kept apart deliberately, and merging them would cause user-visible errors:

kind Do not merge it with Consequence of merging
device_not_registered vault_decryption_failed The user is led to register again and the existing entries are lost for good
vault_entry_not_found "the vault is empty" An empty list is shown while the user was just looking at an entry
recovery_authentication_failed recovery_file_malformed The user retries the passphrase over and over when the passphrase is not the problem
pairing_authentication_failed any more specific error Gives an attacker an "almost succeeded" oracle
sync_already_applied sync_authentication_failed "Already merged" is reported as "this code is fake", and the user regenerates a code that was fine
sync_sender_mismatch sync_authentication_failed "The sender does not match" is reported as "wrong key", which sends the investigation the wrong way

pairing_authentication_failed covers many causes — altered ciphertext, a session that does not match, a reply coming from a different login — and that is deliberate. Host-side wording should stay vague ("pairing failed, please scan again") rather than trying to infer a more specific cause from it.

The core is #[non_exhaustive] and will gain new error variants in the future, so the host side must have a fallback branch for unknown kinds:

try {
    parseOtpauthUri(input)
} catch (e: FfiException.Core) {
    when (e.detail.kind) {
        "uri_missing_secret" -> showReenterHint()
        "randomness_unavailable" -> showRetryHint()
        else -> showGenericError(e.detail.message)   // the fallback branch must stay
    }
}

Secret material

Across the FFI boundary, OTP seeds are always represented as a Base32 string (from_base32 on the way in, to_base32 on the way out). The seed's bytes never cross FFI as a Vec<u8> — that would leave a copy on each side whose erasure is out of our control.

⚠️ Residual risk the host side must know about: on the Kotlin/Swift side a Base32 string is an ordinary String, not protected by the core's secrecy erasure, and it may be copied by the GC or interned in the string constant pool. That is an inherent limitation of managed languages. The host side should agree on this rule: a seed's Base32 exists only in the scope where it is needed, is dropped immediately afterwards, and is never written to a log or persisted.

The master key is the same but goes further: on the normal path it does not cross the boundary at all. FfiVault holds it on the Rust side, and reading entries, changing entries, computing codes and serializing all happen inside Rust. Only the entry points below need the host side to hand it over or receive it:

  • FfiVault.fromMasterKey(bytes) / storeMasterKey(bytes, store): after login or recovery the host side already has the master key bytes.
  • FfiJoiner.complete(...) / importRecoveryKey(...): return the master key bytes so the host side can immediately build an FfiVault or hand them to the key store.
  • FfiDirectMasterKeyStore.store / load and FfiWrappedMasterKeyStore.store / load: the two storage forms, where the material goes in and comes back out as 32 bytes — the wrapped form traded for its own opaque bytes.

Once it has them, the host side should drop those bytes immediately. The two callbacks FfiDeviceKeys / FfiSecureStore use bytes rather than structs precisely because the master key's permanent home is the platform key store, which only the host side can reach — moving the whole key store abstraction into Rust would drag every platform's unsafe in with it.

Contributing

The most important convention: this layer only moves things

This crate's job is: turn the raw types coming from the host side into core types, call the core, and turn the results back.

Any cryptography, parsing or validation logic appearing here is a design regression. Those must stay inside qualia2fa-core, because only the core is unsafe-free and independently auditable. There is exactly one thing to check in code review: has any core logic been moved in here.

Why #![forbid(unsafe_code)] is impossible here

uniffi::setup_scaffolding!() expands, inside this crate, the unsafe code the FFI boundary needs (raw pointer dereference, catch_unwind, string ownership transfer). The FFI boundary is itself unsafe, and that is non-negotiable. The safety boundary is therefore drawn between crates:

crate Constraint
qualia2fa-core #![forbid(unsafe_code)], no I/O, no clock
qualia2fa-ffi The only place unsafe is allowed, and it must be thin

Cargo.toml sets unsafe_code to warn rather than allow so that any new unsafe is noticed at build time; CI raises it to a hard error with -D warnings.

Why the UniFFI version stops at 0.29

Upstream is at 0.32.2, but the application-side binding generator Gobley requires uniffi = "0.29.4", and the Rust-side and Kotlin-side scaffolding must be the same version, or the generated serialization format does not match.

0.29 (equivalent to ^0.29) is used rather than a pinned =0.29.4: 0.29.5 is released and semver-compatible within the same minor version, so patches are picked up without crossing into 0.30.

This is also why uniffi does not enter qualia2fa-core: the binding generator's version pin should not drag the core along. The core stays independent of any binding tool.

Before committing

cargo fmt --all -- --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked

This repository depends on qualia2fa-core and qualia-dik by version number; both are published on crates.io, so no upstream directory is checked out and there is no submodule.

License

LGPL-3.0-or-later, see LICENSE and COPYING.LESSER.