Qualia2FA-FFI
English version: README.en.md.
把 qualia2fa-core 暴露为
UniFFI 绑定,供 Kotlin、Swift 等托管语言调用。
| 项 | 值 |
|---|---|
| Crate 名 | qualia2fa-ffi |
| 类型 | cdylib + staticlib(另有 uniffi-bindgen 二进制) |
| Edition | 2024 |
| 许可证 | LGPL-3.0-or-later |
| UniFFI | 0.29 |
已导出 OTP(TOTP / HOTP / mOTP / Yandex OTP / Steam Guard)、保险库、分组与排序、导入、 恢复密钥、注册/登录、挑战-应答与同步。除此之外,本层还自己实现了局域网通道(组播发现 + 端点数据面)——那一半只在 Rust 侧可用,端侧实现不了。
用法
1. 构建动态库
产物为 target/release/qualia2fa_ffi.dll(Windows)、.so(Linux/Android)、
.dylib(macOS/iOS)。要静态库则取同目录下的 .lib/.a。
2. 生成端侧绑定
绑定生成器是本仓库自己的二进制,这样生成绑定所用的 UniFFI 版本与库编译时
所用的完全一致。改用全局安装的 uniffi-bindgen 会让两边版本漂移,产出
「能编译但运行时不匹配」的绑定。
# Kotlin(JVM 桌面端与 Android 共用同一份绑定)
# Swift(iOS)
生成的绑定落在 bindings/、bindings-swift/,不入库——它们是派生物,应由
端侧构建流程在编译前生成。入库会立刻产生「绑定与接口定义不同步」的问题:改了
Rust 签名却忘了重新生成,编译期不报错、运行期才崩。
提示若出现
Unable to auto-format ... using ktlint: program not found,只是缺少 可选的ktlint,产物依然正确可用。
3. 在 Kotlin 中调用
生成的绑定位于 uniffi.qualia2fa_ffi 包下,函数为顶层函数:
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. 分发动态库
绑定通过 JNA(JVM)或系统动态加载器(Swift)按名字查找动态库,因此
qualia2fa_ffi.dll / libqualia2fa_ffi.so / libqualia2fa_ffi.dylib 必须与
应用一起分发,并落在加载器能找到的位置。各平台的放置方式由端侧构建决定。
接口
验证码
| 函数 | 说明 |
|---|---|
parse_otpauth_uri |
解析 otpauth:// URI |
serialize_otpauth_uri |
序列化回 URI(账号名含 : 会被拒绝) |
totp_code / hotp_code |
计算验证码 |
steam_guard_code |
Steam Guard 5 位字母表 |
verify_code_with_drift |
容错校验,返回命中的偏移量便于时钟校准 |
codes_match |
常量时间比较 |
generate_secret_base32 |
生成 256 位随机种子(OS CSPRNG) |
recommended_min_secret_len / is_secret_shorter_than_recommended / secret_byte_len |
种子强度提示 |
core_version |
核心版本,供兼容性检查 |
时间一律由调用方传入。 核心不读时钟,只有时间作为参数传入,RFC 6238 的 测试向量才能被确定性地验证。端侧应传系统时钟的 Unix 秒数。
保险库(FfiVault)
条目本身由 FfiVault 持有,主密钥留在 Rust 侧。「打开 → 列条目 → 改条目 →
存回文件」这条最常见的路径上,主密钥一次都不会以字节形式出现在 Kotlin/Swift 里。
| 方法 | 说明 |
|---|---|
FfiVault.register(keys, store, label) |
生成主密钥、取得设备身份、写入平台密钥库 |
FfiVault.open(store, data) |
用密钥库里的主密钥打开保险库文件 |
FfiVault.fromMasterKey(bytes) |
用已有主密钥建立空保险库(登录/恢复之后用) |
entryCount / isEmpty / tombstoneCount |
计数 |
entries / entry(id) |
读取条目(顺序不保证,列表请自行排序) |
addEntry(entry, now) → id |
新增 |
updateEntry(entry, now) |
修改(标识不变、不留墓碑) |
removeEntry(id, now) → Bool |
删除(留墓碑) |
serialize |
序列化,供端侧原子地落盘 |
codeFor(id, now) / hotpFor(id, counter) |
按条目的种类算验证码 |
groups / groupOf(id) |
分组与它们的排序(顶分组永远在第一个) |
createGroup / renameGroup / moveGroup / removeGroup |
分组的增删改名与排序(删组不删条目) |
setOrdering / assignToGroup |
写排序(整份列表)与把条目移进某个分组 |
exportRecoveryKey(passphrase, …) |
导出恢复密钥文件 |
updateEntry 与 removeEntry 的区别不只是语义:修改保留条目标识,删除留下墓碑。
两者在另一台设备上被读成完全不同的操作,因此不要用「删除 + 新增」实现修改。
导入(FfiImport)
从别的认证器导入一份导出:FfiImport.preview(bytes, format, password, now) 读它(JSON、HTML,
或每行一个 URI;加密的 JSON 需要口令),detectFormat(bytes) 也可以先从内容猜格式。返回的对象上,
entries / problems / groups / dropped 是预览要显示的东西,compare(vault) 说出「哪些已经有
了、哪些共享同一把密钥但字段不同(要人来定)」,apply(vault, choices) 落实答案。
密钥与 PIN 都不跨界:FfiImportedEntry 只有名字、服务、账号、算法、位数与种类,而种类里的 PIN
是空的;解析后的导出留在 Rust 侧,应用时由它自己读回来。没给答案的条目一律是「加入」,绝不覆盖
——丢掉用户写过的备注,比留下一条他能删掉的重复更糟。
注册与登录
// 新设备注册
val vault = FfiVault.register(deviceKeys, secureStore, "qualia2fa-device")
// 老设备(已登录):出示二维码
val login = FfiLogin.begin()
showQrCode(login.payload())
// 新设备:扫码、发请求、拿回应答后完成
val joiner = FfiJoiner.fromQr(scannedText, "qualia2fa-device")
val request = joiner.request(deviceKeys)
// …把 request 交给老设备…
val reply = login.authorize(oldVault, request) // 界面在这里必须先让用户确认
val masterKey = joiner.complete(reply, deviceKeys, secureStore)
| 方法 | 说明 |
|---|---|
FfiLogin.begin() / payload() |
发起登录,取得二维码文本 |
FfiLogin.authorize(vault, msg1) |
授权(调用即交出主密钥,界面须先确认) |
FfiLogin.joinerDeviceId() |
请求方的设备标识,显示给用户核对 |
FfiJoiner.fromQr(text, label) |
扫码建立会话 |
FfiJoiner.request(keys) |
发出请求(幂等) |
FfiJoiner.identity() |
本机身份,完成之前才有值 |
FfiJoiner.complete(msg2, keys, store) |
验证应答、取回主密钥、写入密钥库 |
FfiPlatformKeys.open(label) |
打开平台密钥库里的设备身份(Windows 上是 CNG/TPM,没有 TPM 时是 CNG 软件库)。它把 qualia-dik 的实现搬到边界这一侧,host 只需写一层不含判断的转发去实现 FfiDeviceKeys;backend() 如实报告实际拿到的是哪一种 |
一次登录只能完成一次。第二次调用报的是与「应答是伪造的」相同的错误,这是故意 的——单独报「已经用过了」等于告诉重放者他被认出来了。端侧文案也应保持笼统。
主密钥的两种存法(FfiDirectMasterKeyStore / FfiWrappedMasterKeyStore)
主密钥在平台上只有两种存法,用哪个对象去问就是选择,没有模式参数:
| 方法 | 说明 |
|---|---|
FfiDirectMasterKeyStore.open() |
打开「平台库直接持有材料」的那一种(Windows 上是 CNG,Android 上是 Keystore):store(label, key) / load(label) / delete(label),端侧不为它持久化任何东西 |
FfiWrappedMasterKeyStore.open() |
打开「平台库持有一把不可导出的 KEK」的那一种:store(label, key) 返回要由端侧持久化的包裹字节,load(label, wrapped) 把它们收回来,delete(label) 删掉 KEK(先前那些字节随之永久作废) |
backend() / isHardwareBacked() |
如实报告实际答话的后端("tpm" / "android-keystore" / "software" / "secure-enclave" / "unavailable"),端侧应在依赖它之前把这一点告诉用户 |
主密钥只以字节过边界(32 字节进、32 字节出),既不进日志也不进错误消息;拿到之后
端侧应立刻丢弃,或直接用 FfiVault.fromMasterKey 建保险库。包裹形式交出的字节是不透明
的:端侧只负责把它们与对应的保险库文件一起持久化,任何包裹/解包、加解密或取得 KEK
的操作都不在这层接口里——那是平台库内部的事。没有可用提供者时报
hardware_unavailable,平台还没有后端时报 unsupported_platform,不会退化成内存里
的软件实现。
恢复密钥
| 函数 | 说明 |
|---|---|
FfiVault.exportRecoveryKey |
设备已解锁时导出(主密钥不过边界) |
exportRecoveryKeyFromMaster |
只有主密钥字节时导出 |
importRecoveryKey(file, passphrase, store) |
导入并写入密钥库,返回主密钥 |
storeMasterKey(bytes, store) |
单独保存主密钥 |
KDF 参数(memory_kib / iterations / parallelism)传 null 用推荐值
(64 MiB / 3 次 / 1 路)。不要为了「快」调低:导出是一次性开销,而拿到文件的
攻击者是离线暴力破解,参数调低直接削弱用户最后一道防线。
挑战-应答
用来证明「对方持有主密钥」而不把主密钥交出去。它与登录不同:登录传输密钥,它只做 证明。三步都要走完,漏掉第三步等于只做了一半认证。
val verifier = FfiVerifier.begin()
val responder = FfiResponder.accept(verifier.challenge())
val response = responder.respond(vault, label, deviceKeys)
verifier.verify(response, vault) // 第二步:主密钥确实对得上
responder.confirm(vault, verifier.sessionId()!!, verifier.confirmation()!!) // 第三步
空保险库也能判定主密钥对错。 文件格式版本 2 在末尾放了一个主密钥检查值, 因此
FfiVault.open在一个条目都没有的文件上也会拒绝错误的主密钥。端侧可以 把「打开成功」直接当成「已解锁」。例外是版本 1 的旧文件:那份文件里确实没有可判定的信息,需要权威确认时用 挑战-应答。
同步(FfiSyncSession)
同步是两台设备各出一个码、一次一个方向:一方出示自己的状态,另一方读入并合并, 然后反过来。载荷由核心加密并认证,通道只负责搬字节。
通道有两条,接口只有一个(FfiTransport):一条由端侧实现,把载荷变成用户搬运的
文本/二维码;另一条由本层实现,走局域网(见下)。两者满足同一套契约,所以会话代码对
两种载体没有分支。
// 通道:一个出站队列 + 一个入站队列,不含任何判断
class CodeChannel(private val me: String) : FfiTransport { /* … */ }
val session = FfiSyncSession.open(vault)
// 出示自己的状态
session.send(channel, FfiRecipient.All, FfiPayloadKind.Vault)
showQrCode(channel.takeOutgoing())
// 读入对方的码并合并
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) // 合并写进了保险库,必须落盘
| 成员 | 说明 |
|---|---|
FfiSyncSession.open(vault) |
在一个已解锁的保险库上开会话 |
send(transport, to, kind) → syncId |
封装并发出(Vault = 自己的全部状态,Request = 「把你的发给我」) |
receive(transport) → FfiSyncOutcome |
读入一条消息并合并;失败时保险库与重放日志都不变 |
pendingConflicts() |
还没裁决的冲突,按合并报告的先后排列 |
adjudicate(entryId, decision) |
裁决一条,按标识而不是下标(下标会在裁决后移位) |
planShare(transport) |
只回答装不装得下,不发送也不显示 |
FfiPlatformKeys.deviceId() |
本机设备标识,通道要如实报告它 |
FfiTransport 的实现要求与核心 Transport 一致:如实报告发送方(核心会拿它和
载荷里那个认证过的字段对账)、尊重 FfiRecipient.One、没有消息时返回 null 而不是
阻塞、一次性通道第二次使用要报 transport_already_used。设备标识一律是 64 位
十六进制。
冲突的 kind 是稳定字符串,端侧对未知值要有兜底分支:
| kind | 含义 | 两个选择 |
|---|---|---|
entry_mismatch |
两边都有这条条目,内容不同 | 保留本机版本 / 采用对方版本 |
peer_deleted |
对方删除了它,本机改过它并留着 | 保留条目 / 接受删除 |
local_deleted |
本机删除了它,对方还留着 | 保持删除 / 恢复条目 |
裁决只作用于本机。 「采用对方版本」会真正写进保险库,所以必须落盘; 「保留本机版本」不会让分歧消失——对方仍持有另一版,轮到它同步时它会问它的用户。 两边都选择之后才会收敛。
重放日志不落盘。 同一次运行里同一个载荷不会被应用两次;重启之后日志是空的, 同一个载荷可以再被合并一次——合并是幂等的,结果不变。二维码通道是带外、一次性的, 这个边界因此可以接受;局域网通道用的是同一个会话,所以它的重放日志同样只存在于 本次运行里。将来需要它跨重启时再暴露出来。
局域网通道(FfiLanChannel / FfiLanLobby)
除了二维码那一半,本层还自己实现了局域网通道:FfiLanChannel 是一个可以拨号的
端点,FfiLanLobby 是同一网段上的发现。它与端侧自己实现的 FfiTransport 满足同一套
契约(send / receive / peers / capabilities),因此会话代码对两种载体没有
分支:FfiSyncSession 收的是 Arc<dyn FfiTransport>,看不出拿到的是哪一个。
// 发起方:打开端点与大厅,把 record() 交给自己的配对码
val channel = FfiLanChannel.open(deviceId) // 显式打开:占一个 UDP 端口与一套运行时
val lobby = channel.openLobby() // 显式开始公告;不调用就不公告
showCode(withRecord(host.payload(), channel.record()))
// 加入方:扫到码之后在大厅里按记录找到那台设备,记下它可拨的地址
val record = recordOf(scannedText) // 端侧自己那层配对码里带的记录
val peer = lobby.peers().firstOrNull { it.record.contentEquals(record) }
if (peer?.socket != null) channel.rememberPeer(peer.deviceId, peer.record, peer.socket!!)
// 之后就是同一套同步会话:载体换了,协议与裁决一个字都没变
val session = FfiSyncSession.open(vault)
session.send(channel.transport(), FfiRecipient.One(peer.deviceId), FfiPayloadKind.Vault)
withRecord / recordOf 代表端侧自己那层配对码:本层不定义配对码的文本格式,它只
提供 record()(交出去)与 FfiLanPeer.record(拿来比对)。没有大厅可用时(不在同一
网段、或组播被禁),记录仍然说得清要找谁,但说不出去哪里找:rememberPeer 还需
一个可拨的地址,而那个地址只有收到过对方报文的一方才有。
| 成员 | 说明 |
|---|---|
FfiLanChannel.open(deviceId) |
打开端点,端口由系统选;端点密钥每次运行重新生成、不落盘 |
record() |
本端点的连接记录(端点公钥 + 端口,42 字节定长),交给配对码或别的带外通道 |
rememberPeer(deviceId, record, socket) |
登记一个可拨的对端;socket 只有 IP 有用,端口以记录为准 |
forgetPeer(deviceId) |
忘记该对端并关闭到它的连接 |
transport() |
把本对象作为 FfiTransport 交给会话 |
openLobby() |
开始公告与收听,返回 FfiLanLobby |
shutdown() |
关闭端点:连接全关、运行时停止;重复调用安全 |
FfiLanLobby.peers() |
已知的对端,含已经离开的(here 为 false) |
FfiLanLobby.announceNow() |
立刻公告一次,不等下一个周期 |
FfiLanLobby.shutdown() |
一次告别,然后停掉线程与 socket;重复调用安全 |
FfiLanPeer |
deviceId / record / socket(可拨地址 ip:port:IP 来自收到报文的那一方、端口是端点公告的那个;可能为 null)/ here |
三件使用者必须知道的事:
- 显式打开、显式关闭,不做后台同步。 打开一个通道就占一个 UDP 端口与一套 tokio
运行时(2 个工作线程),打开大厅之后还会按 30 秒的周期往网段里公告自己、并收听
别人的公告。两者都不会自动回收:让界面在需要它们的屏幕出现时打开,离开时
shutdown()。 - 连接记录里没有 IP。
record()只有端点公钥与端口,IP 来自收到组播报文的那 一方——端点自己报出来的 socket 是通配地址(0.0.0.0),照它拨号拨不通。因此跨过 带外那一步的方式正是「码里带记录,对方在大厅里按记录找到你」,而FfiLanPeer.socket是唯一可拨的地址;没有它的对端只能显示、不能连接。 - 依赖
qualia-transport 0.1.3并打开 featureiroh-lan,本 crate 无条件打开它。 iroh、tokio 及其依赖树会进入每一次构建(Android 那份也一样),构建时间与产物体积 都明显增加。组播发现与二维码并不需要这个 feature,需要它的是这里的数据面。
transport().peers()报告的是登记过的对端,FfiRecipient.All发的就是这些;大厅 报告的是听到的对端。两者不是同一个集合:要让一台设备能收到消息,先把它从大厅 登记进来。公告本身不认证——它只回答「这个网段上大概有谁、去哪里找他」,真的身份 由连接握手证明,载荷该不该收由核心按设备标识判断。
0.2.0 的破坏性变更
- 条目形状:
FfiVaultEntry/FfiOtpAuth不再有period/counter/uri_type,改为kind(FfiEntryKind:TOTP / HOTP / mOTP / Yandex OTP / Steam)。FfiDigits补上Five/Nine与OtherDigits(表单不提供的档位照实带过)。 - 算码入口:
totpFor/steamGuardFor合并成codeFor(由条目自己的种类决定算法),hotpFor保留。对条目调错函数会得到wrong_code_kind,而不是一个永远对不上的码。 - PIN 只进不出:写入时经
FfiEntryKind的pin传入,读取时只报pinSet。更新时pin: None表示保留原有 PIN,所以编辑备注不需要用户重输。 - 依赖
qualia2fa-core 0.2.0:保险库格式 3、同步载荷 3,与 0.1.x 无法同步, 两端必须一起升级。
FFI 错误跨到 Kotlin 后是 sealed class FfiException:
| 变体 | 含义 |
|---|---|
InputTooLong |
输入超过边界上限 |
Core |
核心返回的错误,结构化为 FfiCoreError { kind, message } |
Qr |
二维码那一半的通道自身报出的失败(kind + detail),例如图片里读不出码 |
Lan |
局域网通道自身报出的失败(kind + detail),例如加入不了组播组、对端没有登记过 |
InvalidDeviceId |
传进来的设备标识不是 64 位十六进制 |
UnknownConflict |
为一条不在等待裁决的冲突做了裁决:裁决只属于产生它的那次合并 |
Lan 的 kind 与 Qr 一样是稳定标识,常见的几个:lan_socket(端点没有绑定的 socket)、
lan_transport(运行时或绑定失败)、no_connection_to_peer(对端没登记过或连不上)、
payload_too_large、no_lan_peers_found、bad_socket(传进来的地址不是 ip:port),
以及连接记录的 not_our_lan_address / bad_lan_address_length / zero_port。枚举是
#[non_exhaustive] 的,未映射的变体一律落到 lan_error,因此兜底分支必须有。
主密钥两种存法的失败同样各有其 kind:invalid_wrapped_key(包裹字节解不开——被损坏,
或来自另一个库条目、另一台设备;不会退回成另一把看起来有效的密钥)、
invalid_master_key、key_not_exportable(平台库收下了材料却不肯交出来,这种平台该用
包裹形式)、hardware_unavailable、unsupported_platform。
kind 是稳定的机器可读标识(如 secret_empty、vault_entry_not_found),
端侧据此分流 UI;message 仅供展示与日志。这些字符串属于公开接口:可以新增,
但既有值不得改名。
有几组 kind 是刻意分开的,合并它们会造成用户可见的错误:
| kind | 不要与它合并 | 合并的后果 |
|---|---|---|
device_not_registered |
vault_decryption_failed |
用户被引导重新注册,原有条目永久丢失 |
vault_entry_not_found |
「保险库为空」 | 显示空列表,而用户刚才还在看条目 |
recovery_authentication_failed |
recovery_file_malformed |
用户反复试口令,而问题不在口令 |
pairing_authentication_failed |
任何更具体的错误 | 给攻击者一个「差一点就成功」的预言机 |
sync_already_applied |
sync_authentication_failed |
把「已经合并过」报成「这个码是假的」,用户会去重新生成一个本来没问题的码 |
sync_sender_mismatch |
sync_authentication_failed |
把「来源对不上」报成「密钥不对」,排查方向完全错 |
pairing_authentication_failed 覆盖密文被改、会话对不上、应答来自另一次登录等
多种成因,这是刻意的。端侧文案应当保持笼统(「配对失败,请重新扫码」),不要
试图从它推断更具体的原因。
核心是 #[non_exhaustive] 的,未来会新增错误变体,因此端侧必须对未知
kind 有兜底分支:
try {
parseOtpauthUri(input)
} catch (e: FfiException.Core) {
when (e.detail.kind) {
"uri_missing_secret" -> showReenterHint()
"randomness_unavailable" -> showRetryHint()
else -> showGenericError(e.detail.message) // 必须保留兜底分支
}
}
秘密材料
跨 FFI 边界时,OTP 种子统一以 Base32 字符串表示(传入走 from_base32,
传出走 to_base32)。种子的字节永远不会以 Vec<u8> 形式经过 FFI——那会在
两侧各留一份不受擦除控制的拷贝。
⚠️ 端侧必须知晓的残留风险:Base32 字符串在 Kotlin/Swift 侧是普通
String, 不受核心secrecy的擦除保护,且可能被 GC 复制、进字符串常量池。这是托管语言 的固有限制。端侧应约定:种子的 Base32 只在必要作用域内存在,用完立即丢弃, 绝不写入日志或持久化。
主密钥同理但更进一步:它正常路径下根本不过边界。FfiVault 在 Rust 侧持有
它,读条目、改条目、算验证码、序列化都在 Rust 内完成。只有下面这几个入口需要端侧交出
或接收它:
FfiVault.fromMasterKey(bytes)/storeMasterKey(bytes, store):登录或恢复之后, 端侧手里已经有主密钥字节。FfiJoiner.complete(...)/importRecoveryKey(...):返回主密钥字节,供端侧立刻 建一个FfiVault或交给密钥库。FfiDirectMasterKeyStore.store/load与FfiWrappedMasterKeyStore.store/load:两种 存法,材料以 32 字节进出,包裹形式进出的则是它自己那串不透明字节。
拿到之后端侧应当立刻丢掉那些字节。FfiDeviceKeys / FfiSecureStore 两个回调
之所以用字节而不是结构体,正是因为主密钥的永久归宿是平台密钥库,而只有端侧能访问
它——把整个密钥库抽象搬进 Rust 会让每个平台的 unsafe 跟着进来。
参与贡献
最重要的约定:这里只做搬运
本 crate 的职责是:把端侧传来的原始类型转成核心类型、调用核心、把结果转回去。
任何加密、解析、校验逻辑出现在这里都是设计倒退。 那些必须留在
qualia2fa-core 内,因为只有核心是无 unsafe、可独立审计的。代码审查时的
检查项就一条:有没有把核心的逻辑搬到这里来。
为什么这里不能 #![forbid(unsafe_code)]
uniffi::setup_scaffolding!() 在本 crate 内展开 FFI 边界所需的 unsafe 代码
(裸指针解引用、catch_unwind、字符串所有权转移)。FFI 边界本身就是 unsafe
的,这是不可协商的。安全边界因此划在 crate 之间:
| crate | 约束 |
|---|---|
qualia2fa-core |
#![forbid(unsafe_code)],无 I/O,无时钟 |
qualia2fa-ffi |
唯一允许 unsafe 的地方,且必须是薄的 |
Cargo.toml 里把 unsafe_code 设为 warn 而非 allow,是为了让任何新增的
unsafe 在构建时发出声音;CI 用 -D warnings 把它上升为硬错误。
UniFFI 版本为何停在 0.29
上游最新是 0.32.2,但端的绑定生成器 Gobley 强制 uniffi = "0.29.4",Rust
侧与 Kotlin 侧的脚手架必须同版本,否则生成的序列化格式不匹配。
取 0.29(等价 ^0.29)而非写死 =0.29.4:0.29.5 已发布且同 minor 内 semver
兼容,既能吃到补丁又不会跨到 0.30。
这也是 uniffi 不进 qualia2fa-core 的原因:绑定生成器的版本锁定不该牵连
核心。核心保持与任何绑定工具无关。
提交前
本仓库按版本号依赖 qualia2fa-core 与 qualia-dik,两者都发布在 crates.io 上,
因此不检出任何上游目录,也没有 submodule。
许可证
LGPL-3.0-or-later,见 LICENSE 与 COPYING.LESSER。